/** * Forum module — Discourse cloud provisioner. * Deploy self-hosted Discourse forums on Hetzner VPS with Cloudflare DNS. */ import { Hono } from "hono"; import { readFileSync } from "node:fs"; import { resolve } from "node:path"; import { sql } from "../../shared/db/pool"; import { renderShell } from "../../server/shell"; import { getModuleInfoList } from "../../shared/module"; import { provisionInstance, destroyInstance } from "./lib/provisioner"; import type { RSpaceModule } from "../../shared/module"; const routes = new Hono(); const SCHEMA_SQL = readFileSync(resolve(import.meta.dir, "db/schema.sql"), "utf-8"); // ── DB initialization ── async function initDB() { try { await sql.unsafe(SCHEMA_SQL); console.log("[Forum] DB schema initialized"); } catch (e: any) { console.error("[Forum] DB init error:", e.message); } } initDB(); // ── Helpers ── async function getOrCreateUser(did: string): Promise { const [existing] = await sql.unsafe("SELECT * FROM rforum.users WHERE did = $1", [did]); if (existing) return existing; const [user] = await sql.unsafe( "INSERT INTO rforum.users (did) VALUES ($1) RETURNING *", [did], ); return user; } // ── API: List instances ── routes.get("/api/instances", async (c) => { const did = c.req.header("X-User-DID"); if (!did) return c.json({ error: "Authentication required" }, 401); const user = await getOrCreateUser(did); const rows = await sql.unsafe( "SELECT * FROM rforum.instances WHERE user_id = $1 AND status != 'destroyed' ORDER BY created_at DESC", [user.id], ); return c.json({ instances: rows }); }); // ── API: Create instance ── routes.post("/api/instances", async (c) => { const did = c.req.header("X-User-DID"); if (!did) return c.json({ error: "Authentication required" }, 401); const user = await getOrCreateUser(did); const body = await c.req.json<{ name: string; subdomain: string; region?: string; size?: string; admin_email: string; smtp_config?: Record; }>(); 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 [existing] = await sql.unsafe("SELECT id FROM rforum.instances WHERE domain = $1", [domain]); if (existing) return c.json({ error: "Domain already taken" }, 409); const [instance] = await sql.unsafe( `INSERT INTO rforum.instances (user_id, name, domain, region, size, admin_email, smtp_config) VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb) RETURNING *`, [user.id, body.name, domain, body.region || "nbg1", body.size || "cx22", body.admin_email, JSON.stringify(body.smtp_config || {})], ); // Start provisioning asynchronously provisionInstance(instance.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 did = c.req.header("X-User-DID"); if (!did) return c.json({ error: "Authentication required" }, 401); const user = await getOrCreateUser(did); const [instance] = await sql.unsafe( "SELECT * FROM rforum.instances WHERE id = $1 AND user_id = $2", [c.req.param("id"), user.id], ); if (!instance) return c.json({ error: "Instance not found" }, 404); const logs = await sql.unsafe( "SELECT * FROM rforum.provision_logs WHERE instance_id = $1 ORDER BY created_at ASC", [instance.id], ); return c.json({ instance, logs }); }); // ── API: Destroy instance ── routes.delete("/api/instances/:id", async (c) => { const did = c.req.header("X-User-DID"); if (!did) return c.json({ error: "Authentication required" }, 401); const user = await getOrCreateUser(did); const [instance] = await sql.unsafe( "SELECT * FROM rforum.instances WHERE id = $1 AND user_id = $2", [c.req.param("id"), user.id], ); if (!instance) return c.json({ error: "Instance not found" }, 404); if (instance.status === "destroyed") return c.json({ error: "Already destroyed" }, 400); // Destroy asynchronously destroyInstance(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 logs = await sql.unsafe( "SELECT * FROM rforum.provision_logs WHERE instance_id = $1 ORDER BY created_at ASC", [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"; return c.html(renderShell({ title: `${spaceSlug} — Forum | rSpace`, moduleId: "forum", spaceSlug, modules: getModuleInfoList(), theme: "light", styles: ``, body: ``, scripts: ``, })); }); export const forumModule: RSpaceModule = { id: "forum", name: "rForum", icon: "\uD83D\uDCAC", description: "Deploy and manage Discourse forums", routes, standaloneDomain: "rforum.online", };