180 lines
6.0 KiB
TypeScript
180 lines
6.0 KiB
TypeScript
/**
|
|
* 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";
|
|
import { verifyEncryptIDToken, extractToken } from "@encryptid/sdk/server";
|
|
|
|
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<any> {
|
|
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 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 user = await getOrCreateUser(claims.sub);
|
|
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 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 user = await getOrCreateUser(claims.sub);
|
|
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 [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 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 user = await getOrCreateUser(claims.sub);
|
|
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 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 user = await getOrCreateUser(claims.sub);
|
|
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: `<link rel="stylesheet" href="/modules/forum/forum.css">`,
|
|
body: `<folk-forum-dashboard space="${spaceSlug}"></folk-forum-dashboard>`,
|
|
scripts: `<script type="module" src="/modules/forum/folk-forum-dashboard.js"></script>`,
|
|
}));
|
|
});
|
|
|
|
export const forumModule: RSpaceModule = {
|
|
id: "forum",
|
|
name: "rForum",
|
|
icon: "\uD83D\uDCAC",
|
|
description: "Deploy and manage Discourse forums",
|
|
routes,
|
|
standaloneDomain: "rforum.online",
|
|
};
|