rspace-online/modules/rforum/mod.ts

238 lines
7.4 KiB
TypeScript

/**
* Forum module — Discourse cloud provisioner.
* Deploy self-hosted Discourse forums on Hetzner VPS with Cloudflare DNS.
*/
import { Hono } from "hono";
import * as Automerge from "@automerge/automerge";
import { renderShell, renderExternalAppShell } from "../../server/shell";
import { getModuleInfoList } from "../../shared/module";
import { provisionInstance, destroyInstance } from "./lib/provisioner";
import type { RSpaceModule } from "../../shared/module";
import { verifyEncryptIDToken, extractToken } from "@encryptid/sdk/server";
import { renderLanding } from "./landing";
import type { SyncServer } from '../../server/local-first/sync-server';
import {
forumSchema,
FORUM_DOC_ID,
type ForumDoc,
type ForumInstance,
} from './schemas';
let _syncServer: SyncServer | null = null;
const routes = new Hono();
// ── Helpers ──
function ensureDoc(): ForumDoc {
let doc = _syncServer!.getDoc<ForumDoc>(FORUM_DOC_ID);
if (!doc) {
doc = Automerge.change(Automerge.init<ForumDoc>(), 'init forum doc', (d) => {
const init = forumSchema.init();
d.meta = init.meta;
d.instances = {};
d.provisionLogs = {};
});
_syncServer!.setDoc(FORUM_DOC_ID, doc);
}
return doc;
}
// ── API: List instances ──
routes.get("/api/instances", async (c) => {
const token = extractToken(c.req.raw.headers);
if (!token) return c.json({ error: "Authentication required" }, 401);
let claims;
try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
const doc = ensureDoc();
const instances = Object.values(doc.instances).filter(
(inst) => inst.userId === claims.sub && inst.status !== 'destroyed',
);
// Sort by createdAt descending
instances.sort((a, b) => b.createdAt - a.createdAt);
return c.json({ instances });
});
// ── API: Create instance ──
routes.post("/api/instances", async (c) => {
const token = extractToken(c.req.raw.headers);
if (!token) return c.json({ error: "Authentication required" }, 401);
let claims;
try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
const body = await c.req.json<{
name: string;
subdomain: string;
region?: string;
size?: string;
admin_email: string;
smtp_config?: Record<string, unknown>;
}>();
if (!body.name || !body.subdomain || !body.admin_email) {
return c.json({ error: "name, subdomain, and admin_email are required" }, 400);
}
const domain = `${body.subdomain}.rforum.online`;
// Check uniqueness
const doc = ensureDoc();
const existing = Object.values(doc.instances).find((inst) => inst.domain === domain);
if (existing) return c.json({ error: "Domain already taken" }, 409);
const id = crypto.randomUUID();
const now = Date.now();
const instance: ForumInstance = {
id,
userId: claims.sub,
name: body.name,
domain,
status: 'pending',
errorMessage: '',
discourseVersion: 'stable',
provider: 'hetzner',
vpsId: '',
vpsIp: '',
region: body.region || 'nbg1',
size: body.size || 'cx22',
adminEmail: body.admin_email,
smtpConfig: body.smtp_config || {},
dnsRecordId: '',
sslProvisioned: false,
createdAt: now,
updatedAt: now,
provisionedAt: 0,
destroyedAt: 0,
};
_syncServer!.changeDoc<ForumDoc>(FORUM_DOC_ID, 'create instance', (d) => {
d.instances[id] = instance;
d.provisionLogs[id] = [];
});
// Start provisioning asynchronously
provisionInstance(_syncServer!, id).catch((e) => {
console.error("[Forum] Provision failed:", e);
});
return c.json({ instance }, 201);
});
// ── API: Get instance detail ──
routes.get("/api/instances/:id", async (c) => {
const token = extractToken(c.req.raw.headers);
if (!token) return c.json({ error: "Authentication required" }, 401);
let claims;
try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
const doc = ensureDoc();
const instance = doc.instances[c.req.param("id")];
if (!instance || instance.userId !== claims.sub) return c.json({ error: "Instance not found" }, 404);
const logs = doc.provisionLogs[instance.id] || [];
return c.json({ instance, logs });
});
// ── API: Destroy instance ──
routes.delete("/api/instances/:id", async (c) => {
const token = extractToken(c.req.raw.headers);
if (!token) return c.json({ error: "Authentication required" }, 401);
let claims;
try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
const doc = ensureDoc();
const instance = doc.instances[c.req.param("id")];
if (!instance || instance.userId !== claims.sub) return c.json({ error: "Instance not found" }, 404);
if (instance.status === "destroyed") return c.json({ error: "Already destroyed" }, 400);
// Destroy asynchronously
destroyInstance(_syncServer!, instance.id).catch((e) => {
console.error("[Forum] Destroy failed:", e);
});
return c.json({ message: "Destroying instance...", instance: { ...instance, status: "destroying" } });
});
// ── API: Get provision logs ──
routes.get("/api/instances/:id/logs", async (c) => {
const doc = ensureDoc();
const logs = doc.provisionLogs[c.req.param("id")] || [];
return c.json({ logs });
});
// ── API: Health ──
routes.get("/api/health", (c) => {
return c.json({ status: "ok", service: "rforum" });
});
// ── Page route ──
routes.get("/", (c) => {
const spaceSlug = c.req.param("space") || "demo";
const view = c.req.query("view");
if (view === "app") {
return c.html(renderExternalAppShell({
title: `${spaceSlug} — Discourse | rSpace`,
moduleId: "rforum",
spaceSlug,
modules: getModuleInfoList(),
appUrl: "https://commons.rforum.online",
appName: "Discourse",
theme: "dark",
}));
}
return c.html(renderShell({
title: `${spaceSlug} — Forum | rSpace`,
moduleId: "rforum",
spaceSlug,
modules: getModuleInfoList(),
theme: "dark",
body: `<div class="rapp-nav" style="padding:0 1rem;margin-top:8px"><span class="rapp-nav__title"></span><a href="?view=app" class="rapp-nav__btn--app-toggle">Open Full App</a></div>
<folk-forum-dashboard space="${spaceSlug}"></folk-forum-dashboard>`,
scripts: `<script type="module" src="/modules/rforum/folk-forum-dashboard.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/rforum/forum.css">`,
}));
});
export const forumModule: RSpaceModule = {
id: "rforum",
name: "rForum",
icon: "💬",
description: "Deploy and manage Discourse forums",
scoping: { defaultScope: 'global', userConfigurable: true },
docSchemas: [{ pattern: 'global:forum:instances', description: 'Forum provisioning metadata', init: forumSchema.init }],
routes,
landingPage: renderLanding,
standaloneDomain: "rforum.online",
externalApp: { url: "https://commons.rforum.online", name: "Discourse" },
feeds: [
{
id: "threads",
name: "Threads",
kind: "data",
description: "Discussion threads and topics from provisioned Discourse instances",
filterable: true,
},
{
id: "activity",
name: "Activity",
kind: "attention",
description: "Forum engagement — new posts, replies, and participation metrics",
},
],
acceptsFeeds: ["data", "governance"],
outputPaths: [
{ path: "threads", name: "Threads", icon: "💬", description: "Forum discussion threads" },
{ path: "categories", name: "Categories", icon: "📂", description: "Forum categories and topics" },
],
async onInit(ctx) {
_syncServer = ctx.syncServer;
console.log("[Forum] Module initialized (Automerge-only, no PG)");
},
};