238 lines
7.4 KiB
TypeScript
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)");
|
|
},
|
|
};
|