From 682c995cc3a677d67e10c3b635093458d466e173 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Fri, 20 Feb 2026 23:25:18 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20add=20files=20and=20forum=20modules=20?= =?UTF-8?q?=E2=80=94=20Phase=207+8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Files module (rFiles): file upload/download, share links with expiry/password/download limits, memory cards CRUD, access logging, cleanup timers replacing Django Celery tasks. Forum module (rForum): Discourse cloud provisioner with Hetzner VPS creation, Cloudflare DNS, cloud-init for automated Discourse install, async provisioning pipeline with step logging, instance management. All 10 modules now active: Canvas, rBooks, rPubs, rCart, Providers, Swag, rChoices, rFunds, rFiles, rForum. Co-Authored-By: Claude Opus 4.6 --- Dockerfile | 4 +- docker-compose.yml | 3 + modules/files/components/files.css | 6 + modules/files/components/folk-file-browser.ts | 389 ++++++++++++++++ modules/files/db/schema.sql | 74 ++++ modules/files/mod.ts | 359 +++++++++++++++ modules/files/standalone.ts | 23 + .../forum/components/folk-forum-dashboard.ts | 419 ++++++++++++++++++ modules/forum/components/forum.css | 6 + modules/forum/db/schema.sql | 55 +++ modules/forum/lib/cloud-init.ts | 81 ++++ modules/forum/lib/dns.ts | 53 +++ modules/forum/lib/hetzner.ts | 80 ++++ modules/forum/lib/provisioner.ts | 173 ++++++++ modules/forum/mod.ts | 169 +++++++ modules/forum/standalone.ts | 23 + server/index.ts | 4 + vite.config.ts | 54 +++ 18 files changed, 1974 insertions(+), 1 deletion(-) create mode 100644 modules/files/components/files.css create mode 100644 modules/files/components/folk-file-browser.ts create mode 100644 modules/files/db/schema.sql create mode 100644 modules/files/mod.ts create mode 100644 modules/files/standalone.ts create mode 100644 modules/forum/components/folk-forum-dashboard.ts create mode 100644 modules/forum/components/forum.css create mode 100644 modules/forum/db/schema.sql create mode 100644 modules/forum/lib/cloud-init.ts create mode 100644 modules/forum/lib/dns.ts create mode 100644 modules/forum/lib/hetzner.ts create mode 100644 modules/forum/lib/provisioner.ts create mode 100644 modules/forum/mod.ts create mode 100644 modules/forum/standalone.ts diff --git a/Dockerfile b/Dockerfile index b6d09e2..2ab2615 100644 --- a/Dockerfile +++ b/Dockerfile @@ -46,19 +46,21 @@ COPY --from=build /encryptid-sdk /encryptid-sdk RUN bun install --production # Create data directories -RUN mkdir -p /data/communities /data/books /data/swag-artifacts +RUN mkdir -p /data/communities /data/books /data/swag-artifacts /data/files # Set environment ENV NODE_ENV=production ENV STORAGE_DIR=/data/communities ENV BOOKS_DIR=/data/books ENV SWAG_ARTIFACTS_DIR=/data/swag-artifacts +ENV FILES_DIR=/data/files ENV PORT=3000 # Data volumes for persistence VOLUME /data/communities VOLUME /data/books VOLUME /data/swag-artifacts +VOLUME /data/files EXPOSE 3000 diff --git a/docker-compose.yml b/docker-compose.yml index 0b9f76e..8fea7ad 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,6 +10,7 @@ services: - rspace-data:/data/communities - rspace-books:/data/books - rspace-swag:/data/swag-artifacts + - rspace-files:/data/files environment: - NODE_ENV=production - STORAGE_DIR=/data/communities @@ -21,6 +22,7 @@ services: - FLOW_SERVICE_URL=http://payment-flow:3010 - FLOW_ID=a79144ec-e6a2-4e30-a42a-6d8237a5953d - FUNNEL_ID=0ff6a9ac-1667-4fc7-9a01-b1620810509f + - FILES_DIR=/data/files depends_on: rspace-db: condition: service_healthy @@ -65,6 +67,7 @@ volumes: rspace-data: rspace-books: rspace-swag: + rspace-files: rspace-pgdata: networks: diff --git a/modules/files/components/files.css b/modules/files/components/files.css new file mode 100644 index 0000000..8bbdb85 --- /dev/null +++ b/modules/files/components/files.css @@ -0,0 +1,6 @@ +/* Files module — dark theme */ +folk-file-browser { + display: block; + min-height: 400px; + padding: 20px; +} diff --git a/modules/files/components/folk-file-browser.ts b/modules/files/components/folk-file-browser.ts new file mode 100644 index 0000000..91d7082 --- /dev/null +++ b/modules/files/components/folk-file-browser.ts @@ -0,0 +1,389 @@ +/** + * — file browsing, upload, share links, and memory cards. + * + * Attributes: + * space="slug" — shared space to browse (default: "default") + */ + +class FolkFileBrowser extends HTMLElement { + private shadow: ShadowRoot; + private space = "default"; + private files: any[] = []; + private cards: any[] = []; + private tab: "files" | "cards" = "files"; + private loading = false; + + constructor() { + super(); + this.shadow = this.attachShadow({ mode: "open" }); + } + + connectedCallback() { + this.space = this.getAttribute("space") || "default"; + this.render(); + this.loadFiles(); + this.loadCards(); + } + + private async loadFiles() { + this.loading = true; + this.render(); + try { + const base = this.getApiBase(); + const res = await fetch(`${base}/api/files?space=${encodeURIComponent(this.space)}`); + const data = await res.json(); + this.files = data.files || []; + } catch (e) { + console.error("[FileBrowser] Error loading files:", e); + } + this.loading = false; + this.render(); + } + + private async loadCards() { + try { + const base = this.getApiBase(); + const res = await fetch(`${base}/api/cards?space=${encodeURIComponent(this.space)}`); + const data = await res.json(); + this.cards = data.cards || []; + } catch { + this.cards = []; + } + } + + private getApiBase(): string { + const path = window.location.pathname; + const match = path.match(/^\/([^/]+)\/files/); + return match ? `/${match[1]}/files` : ""; + } + + private formatSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`; + } + + private formatDate(d: string): string { + return new Date(d).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }); + } + + private mimeIcon(mime: string): string { + if (mime?.startsWith("image/")) return "\uD83D\uDDBC\uFE0F"; + if (mime?.startsWith("video/")) return "\uD83C\uDFA5"; + if (mime?.startsWith("audio/")) return "\uD83C\uDFB5"; + if (mime?.includes("pdf")) return "\uD83D\uDCC4"; + if (mime?.includes("zip") || mime?.includes("tar") || mime?.includes("gz")) return "\uD83D\uDCE6"; + if (mime?.includes("text") || mime?.includes("json") || mime?.includes("xml")) return "\uD83D\uDCDD"; + return "\uD83D\uDCC1"; + } + + private cardTypeIcon(type: string): string { + const icons: Record = { + note: "\uD83D\uDCDD", + idea: "\uD83D\uDCA1", + task: "\u2705", + reference: "\uD83D\uDD17", + quote: "\uD83D\uDCAC", + }; + return icons[type] || "\uD83D\uDCDD"; + } + + private async handleUpload(e: Event) { + e.preventDefault(); + const form = this.shadow.querySelector("#upload-form") as HTMLFormElement; + if (!form) return; + + const fileInput = form.querySelector('input[type="file"]') as HTMLInputElement; + if (!fileInput?.files?.length) return; + + const formData = new FormData(); + formData.append("file", fileInput.files[0]); + formData.append("space", this.space); + + const titleInput = form.querySelector('input[name="title"]') as HTMLInputElement; + if (titleInput?.value) formData.append("title", titleInput.value); + + try { + const base = this.getApiBase(); + const res = await fetch(`${base}/api/files`, { method: "POST", body: formData }); + if (res.ok) { + form.reset(); + this.loadFiles(); + } else { + const err = await res.json(); + alert(`Upload failed: ${err.error || "Unknown error"}`); + } + } catch (e) { + alert("Upload failed — network error"); + } + } + + private async handleDelete(fileId: string) { + if (!confirm("Delete this file?")) return; + try { + const base = this.getApiBase(); + await fetch(`${base}/api/files/${fileId}`, { method: "DELETE" }); + this.loadFiles(); + } catch {} + } + + private async handleShare(fileId: string) { + try { + const base = this.getApiBase(); + const res = await fetch(`${base}/api/files/${fileId}/share`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ expires_in_hours: 72 }), + }); + const data = await res.json(); + if (data.share?.url) { + const fullUrl = `${window.location.origin}${this.getApiBase()}${data.share.url}`; + await navigator.clipboard.writeText(fullUrl).catch(() => {}); + alert(`Share link copied!\n${fullUrl}\nExpires in 72 hours.`); + } + } catch { + alert("Failed to create share link"); + } + } + + private async handleCreateCard(e: Event) { + e.preventDefault(); + const form = this.shadow.querySelector("#card-form") as HTMLFormElement; + if (!form) return; + + const title = (form.querySelector('input[name="card-title"]') as HTMLInputElement)?.value; + const body = (form.querySelector('textarea[name="card-body"]') as HTMLTextAreaElement)?.value; + const cardType = (form.querySelector('select[name="card-type"]') as HTMLSelectElement)?.value; + + if (!title) return; + + try { + const base = this.getApiBase(); + const res = await fetch(`${base}/api/cards`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ title, body, card_type: cardType, shared_space: this.space }), + }); + if (res.ok) { + form.reset(); + this.loadCards(); + } + } catch {} + } + + private async handleDeleteCard(cardId: string) { + if (!confirm("Delete this card?")) return; + try { + const base = this.getApiBase(); + await fetch(`${base}/api/cards/${cardId}`, { method: "DELETE" }); + this.loadCards(); + } catch {} + } + + private render() { + const filesActive = this.tab === "files"; + this.shadow.innerHTML = ` + + +
+
\uD83D\uDCC1 Files
+
\uD83C\uDFB4 Memory Cards
+
+ + ${filesActive ? this.renderFilesTab() : this.renderCardsTab()} + `; + + this.shadow.querySelectorAll(".tab-btn").forEach((btn) => { + btn.addEventListener("click", () => { + this.tab = (btn as HTMLElement).dataset.tab as "files" | "cards"; + this.render(); + }); + }); + + const uploadForm = this.shadow.querySelector("#upload-form"); + if (uploadForm) uploadForm.addEventListener("submit", (e) => this.handleUpload(e)); + + const cardForm = this.shadow.querySelector("#card-form"); + if (cardForm) cardForm.addEventListener("submit", (e) => this.handleCreateCard(e)); + + this.shadow.querySelectorAll("[data-action]").forEach((btn) => { + const action = (btn as HTMLElement).dataset.action!; + const id = (btn as HTMLElement).dataset.id!; + btn.addEventListener("click", () => { + if (action === "delete") this.handleDelete(id); + else if (action === "share") this.handleShare(id); + else if (action === "delete-card") this.handleDeleteCard(id); + else if (action === "download") { + const base = this.getApiBase(); + window.open(`${base}/api/files/${id}/download`, "_blank"); + } + }); + }); + } + + private renderFilesTab(): string { + return ` +
+

Upload File

+
+
+ + + +
+
+
+ + ${this.loading ? '
Loading files...
' : ""} + ${!this.loading && this.files.length === 0 ? '
No files yet. Upload one above.
' : ""} + ${ + !this.loading && this.files.length > 0 + ? `
+ ${this.files + .map( + (f) => ` +
+
${this.mimeIcon(f.mime_type)}
+
${this.esc(f.title || f.original_filename)}
+
${this.formatSize(f.file_size)} · ${this.formatDate(f.created_at)}
+
+ + + +
+
+ `, + ) + .join("")} +
` + : "" + } + `; + } + + private renderCardsTab(): string { + return ` +
+

New Memory Card

+
+
+ + + +
+ +
+
+ + ${this.cards.length === 0 ? '
No memory cards yet.
' : ""} + ${ + this.cards.length > 0 + ? `
+ ${this.cards + .map( + (c) => ` +
+
+ ${this.cardTypeIcon(c.card_type)} ${this.esc(c.title)} + ${c.card_type} +
+ ${c.body ? `
${this.esc(c.body)}
` : ""} +
+ +
+
+ `, + ) + .join("")} +
` + : "" + } + `; + } + + private esc(s: string): string { + const d = document.createElement("div"); + d.textContent = s || ""; + return d.innerHTML; + } +} + +customElements.define("folk-file-browser", FolkFileBrowser); diff --git a/modules/files/db/schema.sql b/modules/files/db/schema.sql new file mode 100644 index 0000000..25ec436 --- /dev/null +++ b/modules/files/db/schema.sql @@ -0,0 +1,74 @@ +-- rFiles schema — file sharing, memory cards +-- Inside rSpace shared DB, schema: rfiles + +CREATE TABLE IF NOT EXISTS rfiles.media_files ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + original_filename VARCHAR(500) NOT NULL, + title VARCHAR(500), + description TEXT, + mime_type VARCHAR(200), + file_size BIGINT DEFAULT 0, + file_hash VARCHAR(64), + storage_path TEXT NOT NULL, + tags JSONB DEFAULT '[]', + is_processed BOOLEAN DEFAULT FALSE, + processing_error TEXT, + uploaded_by TEXT, + shared_space TEXT, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_files_hash ON rfiles.media_files (file_hash); +CREATE INDEX IF NOT EXISTS idx_files_mime ON rfiles.media_files (mime_type); +CREATE INDEX IF NOT EXISTS idx_files_space ON rfiles.media_files (shared_space); +CREATE INDEX IF NOT EXISTS idx_files_created ON rfiles.media_files (created_at DESC); + +CREATE TABLE IF NOT EXISTS rfiles.public_shares ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + token VARCHAR(48) NOT NULL UNIQUE, + media_file_id UUID NOT NULL REFERENCES rfiles.media_files(id) ON DELETE CASCADE, + created_by TEXT, + expires_at TIMESTAMPTZ, + max_downloads INTEGER, + download_count INTEGER DEFAULT 0, + is_password_protected BOOLEAN DEFAULT FALSE, + password_hash TEXT, + note VARCHAR(500), + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_shares_token ON rfiles.public_shares (token); +CREATE INDEX IF NOT EXISTS idx_shares_active ON rfiles.public_shares (is_active) WHERE is_active = TRUE; +CREATE INDEX IF NOT EXISTS idx_shares_expires ON rfiles.public_shares (expires_at); + +CREATE TABLE IF NOT EXISTS rfiles.memory_cards ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + shared_space TEXT NOT NULL, + title VARCHAR(500) NOT NULL, + body TEXT, + card_type VARCHAR(20) DEFAULT 'note' CHECK (card_type IN ('note', 'idea', 'task', 'reference', 'quote')), + tags JSONB DEFAULT '[]', + position INTEGER DEFAULT 0, + created_by TEXT, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_cards_space ON rfiles.memory_cards (shared_space); +CREATE INDEX IF NOT EXISTS idx_cards_type ON rfiles.memory_cards (card_type); +CREATE INDEX IF NOT EXISTS idx_cards_position ON rfiles.memory_cards (position); + +CREATE TABLE IF NOT EXISTS rfiles.access_logs ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + media_file_id UUID REFERENCES rfiles.media_files(id) ON DELETE CASCADE, + share_id UUID REFERENCES rfiles.public_shares(id) ON DELETE SET NULL, + accessed_at TIMESTAMPTZ DEFAULT NOW(), + ip_address INET, + user_agent VARCHAR(500), + access_type VARCHAR(20) DEFAULT 'download' CHECK (access_type IN ('download', 'view', 'share_created', 'share_revoked')) +); + +CREATE INDEX IF NOT EXISTS idx_logs_accessed ON rfiles.access_logs (accessed_at DESC); +CREATE INDEX IF NOT EXISTS idx_logs_type ON rfiles.access_logs (access_type); diff --git a/modules/files/mod.ts b/modules/files/mod.ts new file mode 100644 index 0000000..9e8b6bb --- /dev/null +++ b/modules/files/mod.ts @@ -0,0 +1,359 @@ +/** + * Files module — file sharing, public share links, memory cards. + * Ported from rfiles-online (Django → Bun/Hono). + */ + +import { Hono } from "hono"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { mkdir, writeFile, unlink } from "node:fs/promises"; +import { createHash, randomBytes } from "node:crypto"; +import { sql } from "../../shared/db/pool"; +import { renderShell } from "../../server/shell"; +import { getModuleInfoList } from "../../shared/module"; +import type { RSpaceModule } from "../../shared/module"; + +const routes = new Hono(); + +const FILES_DIR = process.env.FILES_DIR || "/data/files"; +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("[Files] DB schema initialized"); + } catch (e: any) { + console.error("[Files] DB init error:", e.message); + } +} +initDB(); + +// ── Cleanup timers (replace Celery) ── +// Deactivate expired shares every hour +setInterval(async () => { + try { + const result = await sql.unsafe( + "UPDATE rfiles.public_shares SET is_active = FALSE WHERE is_active = TRUE AND expires_at IS NOT NULL AND expires_at < NOW()" + ); + if ((result as any).count > 0) console.log(`[Files] Deactivated ${(result as any).count} expired shares`); + } catch (e: any) { console.error("[Files] Cleanup error:", e.message); } +}, 3600_000); + +// Delete access logs older than 90 days, daily +setInterval(async () => { + try { + await sql.unsafe("DELETE FROM rfiles.access_logs WHERE accessed_at < NOW() - INTERVAL '90 days'"); + } catch (e: any) { console.error("[Files] Log cleanup error:", e.message); } +}, 86400_000); + +// ── Helpers ── +function generateToken(): string { + return randomBytes(24).toString("base64url"); +} + +async function hashPassword(pw: string): Promise { + const hasher = new Bun.CryptoHasher("sha256"); + hasher.update(pw + "rfiles-salt"); + return hasher.digest("hex"); +} + +async function computeFileHash(buffer: ArrayBuffer): Promise { + const hash = createHash("sha256"); + hash.update(Buffer.from(buffer)); + return hash.digest("hex"); +} + +// ── File upload ── +routes.post("/api/files", async (c) => { + const formData = await c.req.formData(); + const file = formData.get("file") as File | null; + if (!file) return c.json({ error: "file is required" }, 400); + + const space = c.req.param("space") || formData.get("space")?.toString() || "default"; + const title = formData.get("title")?.toString() || file.name.replace(/\.[^.]+$/, ""); + const description = formData.get("description")?.toString() || ""; + const tags = formData.get("tags")?.toString() || "[]"; + const uploadedBy = c.req.header("X-User-DID") || ""; + + const buffer = await file.arrayBuffer(); + const fileHash = await computeFileHash(buffer); + const now = new Date(); + const datePath = `${now.getFullYear()}/${String(now.getMonth() + 1).padStart(2, "0")}/${String(now.getDate()).padStart(2, "0")}`; + const fileId = crypto.randomUUID(); + const storagePath = `uploads/${datePath}/${fileId}/${file.name}`; + const fullPath = resolve(FILES_DIR, storagePath); + + await mkdir(resolve(fullPath, ".."), { recursive: true }); + await writeFile(fullPath, Buffer.from(buffer)); + + const [row] = await sql.unsafe( + `INSERT INTO rfiles.media_files (original_filename, title, description, mime_type, file_size, file_hash, storage_path, tags, uploaded_by, shared_space) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9, $10) RETURNING *`, + [file.name, title, description, file.type || "application/octet-stream", file.size, fileHash, storagePath, tags, uploadedBy, space] + ); + + return c.json({ file: row }, 201); +}); + +// ── File listing ── +routes.get("/api/files", async (c) => { + const space = c.req.param("space") || c.req.query("space") || "default"; + const mimeType = c.req.query("mime_type"); + const limit = Math.min(Number(c.req.query("limit")) || 50, 200); + const offset = Number(c.req.query("offset")) || 0; + + let query = "SELECT * FROM rfiles.media_files WHERE shared_space = $1"; + const params: any[] = [space]; + let paramIdx = 2; + + if (mimeType) { + query += ` AND mime_type LIKE $${paramIdx}`; + params.push(`${mimeType}%`); + paramIdx++; + } + + query += ` ORDER BY created_at DESC LIMIT $${paramIdx} OFFSET $${paramIdx + 1}`; + params.push(limit, offset); + + const rows = await sql.unsafe(query, params); + const [{ count }] = await sql.unsafe( + "SELECT COUNT(*) as count FROM rfiles.media_files WHERE shared_space = $1", + [space] + ); + + return c.json({ files: rows, total: Number(count), limit, offset }); +}); + +// ── File download ── +routes.get("/api/files/:id/download", async (c) => { + const [file] = await sql.unsafe("SELECT * FROM rfiles.media_files WHERE id = $1", [c.req.param("id")]); + if (!file) return c.json({ error: "File not found" }, 404); + + const fullPath = resolve(FILES_DIR, file.storage_path); + const bunFile = Bun.file(fullPath); + if (!await bunFile.exists()) return c.json({ error: "File missing from storage" }, 404); + + return new Response(bunFile, { + headers: { + "Content-Type": file.mime_type || "application/octet-stream", + "Content-Disposition": `attachment; filename="${file.original_filename}"`, + "Content-Length": String(file.file_size), + }, + }); +}); + +// ── File detail ── +routes.get("/api/files/:id", async (c) => { + const [file] = await sql.unsafe("SELECT * FROM rfiles.media_files WHERE id = $1", [c.req.param("id")]); + if (!file) return c.json({ error: "File not found" }, 404); + return c.json({ file }); +}); + +// ── File delete ── +routes.delete("/api/files/:id", async (c) => { + const [file] = await sql.unsafe("SELECT * FROM rfiles.media_files WHERE id = $1", [c.req.param("id")]); + if (!file) return c.json({ error: "File not found" }, 404); + + try { await unlink(resolve(FILES_DIR, file.storage_path)); } catch {} + await sql.unsafe("DELETE FROM rfiles.media_files WHERE id = $1", [c.req.param("id")]); + return c.json({ message: "Deleted" }); +}); + +// ── Create share link ── +routes.post("/api/files/:id/share", async (c) => { + const [file] = await sql.unsafe("SELECT * FROM rfiles.media_files WHERE id = $1", [c.req.param("id")]); + if (!file) return c.json({ error: "File not found" }, 404); + + const body = await c.req.json<{ expires_in_hours?: number; max_downloads?: number; password?: string; note?: string }>(); + const token = generateToken(); + const expiresAt = body.expires_in_hours ? new Date(Date.now() + body.expires_in_hours * 3600_000).toISOString() : null; + const createdBy = c.req.header("X-User-DID") || ""; + + let passwordHash: string | null = null; + let isPasswordProtected = false; + if (body.password) { + passwordHash = await hashPassword(body.password); + isPasswordProtected = true; + } + + const [share] = await sql.unsafe( + `INSERT INTO rfiles.public_shares (token, media_file_id, created_by, expires_at, max_downloads, is_password_protected, password_hash, note) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *`, + [token, file.id, createdBy, expiresAt, body.max_downloads || null, isPasswordProtected, passwordHash, body.note || null] + ); + + await sql.unsafe( + "INSERT INTO rfiles.access_logs (media_file_id, share_id, access_type) VALUES ($1, $2, 'share_created')", + [file.id, share.id] + ); + + return c.json({ share: { ...share, url: `/s/${token}` } }, 201); +}); + +// ── List shares for a file ── +routes.get("/api/files/:id/shares", async (c) => { + const rows = await sql.unsafe( + "SELECT * FROM rfiles.public_shares WHERE media_file_id = $1 ORDER BY created_at DESC", + [c.req.param("id")] + ); + return c.json({ shares: rows }); +}); + +// ── Revoke share ── +routes.post("/api/shares/:shareId/revoke", async (c) => { + const [share] = await sql.unsafe( + "UPDATE rfiles.public_shares SET is_active = FALSE WHERE id = $1 RETURNING *", + [c.req.param("shareId")] + ); + if (!share) return c.json({ error: "Share not found" }, 404); + return c.json({ message: "Revoked", share }); +}); + +// ── Public share download ── +routes.get("/s/:token", async (c) => { + const [share] = await sql.unsafe( + `SELECT s.*, f.storage_path, f.mime_type, f.original_filename, f.file_size + FROM rfiles.public_shares s JOIN rfiles.media_files f ON s.media_file_id = f.id + WHERE s.token = $1`, + [c.req.param("token")] + ); + if (!share) return c.json({ error: "Share not found" }, 404); + if (!share.is_active) return c.json({ error: "Share has been revoked" }, 410); + if (share.expires_at && new Date(share.expires_at) < new Date()) return c.json({ error: "Share has expired" }, 410); + if (share.max_downloads && share.download_count >= share.max_downloads) return c.json({ error: "Download limit reached" }, 410); + + if (share.is_password_protected) { + const pw = c.req.query("password"); + if (!pw) return c.json({ error: "Password required", is_password_protected: true }, 401); + const hash = await hashPassword(pw); + if (hash !== share.password_hash) return c.json({ error: "Invalid password" }, 401); + } + + await sql.unsafe("UPDATE rfiles.public_shares SET download_count = download_count + 1 WHERE id = $1", [share.id]); + const ip = c.req.header("X-Forwarded-For")?.split(",")[0]?.trim() || c.req.header("X-Real-IP") || null; + const ua = c.req.header("User-Agent") || ""; + await sql.unsafe( + "INSERT INTO rfiles.access_logs (media_file_id, share_id, ip_address, user_agent, access_type) VALUES ($1, $2, $3, $4, 'download')", + [share.media_file_id, share.id, ip, ua.slice(0, 500)] + ); + + const fullPath = resolve(FILES_DIR, share.storage_path); + const bunFile = Bun.file(fullPath); + if (!await bunFile.exists()) return c.json({ error: "File missing" }, 404); + + return new Response(bunFile, { + headers: { + "Content-Type": share.mime_type || "application/octet-stream", + "Content-Disposition": `attachment; filename="${share.original_filename}"`, + "Content-Length": String(share.file_size), + }, + }); +}); + +// ── Share info (public) ── +routes.get("/s/:token/info", async (c) => { + const [share] = await sql.unsafe( + `SELECT s.is_password_protected, s.is_active, s.expires_at, s.max_downloads, s.download_count, s.note, + f.original_filename, f.mime_type, f.file_size + FROM rfiles.public_shares s JOIN rfiles.media_files f ON s.media_file_id = f.id + WHERE s.token = $1`, + [c.req.param("token")] + ); + if (!share) return c.json({ error: "Share not found" }, 404); + + const isValid = share.is_active && + (!share.expires_at || new Date(share.expires_at) > new Date()) && + (!share.max_downloads || share.download_count < share.max_downloads); + + return c.json({ + is_password_protected: share.is_password_protected, + is_valid: isValid, + expires_at: share.expires_at, + downloads_remaining: share.max_downloads ? share.max_downloads - share.download_count : null, + file_info: { filename: share.original_filename, mime_type: share.mime_type, size: share.file_size }, + note: share.note, + }); +}); + +// ── Memory Cards CRUD ── +routes.post("/api/cards", async (c) => { + const body = await c.req.json<{ title: string; body?: string; card_type?: string; tags?: string[]; shared_space?: string }>(); + const space = c.req.param("space") || body.shared_space || "default"; + const createdBy = c.req.header("X-User-DID") || ""; + + const [card] = await sql.unsafe( + `INSERT INTO rfiles.memory_cards (shared_space, title, body, card_type, tags, created_by) + VALUES ($1, $2, $3, $4, $5::jsonb, $6) RETURNING *`, + [space, body.title, body.body || "", body.card_type || "note", JSON.stringify(body.tags || []), createdBy] + ); + return c.json({ card }, 201); +}); + +routes.get("/api/cards", async (c) => { + const space = c.req.param("space") || c.req.query("space") || "default"; + const cardType = c.req.query("type"); + const limit = Math.min(Number(c.req.query("limit")) || 50, 200); + + let query = "SELECT * FROM rfiles.memory_cards WHERE shared_space = $1"; + const params: any[] = [space]; + if (cardType) { query += " AND card_type = $2"; params.push(cardType); } + query += " ORDER BY position, created_at DESC LIMIT $" + (params.length + 1); + params.push(limit); + + const rows = await sql.unsafe(query, params); + return c.json({ cards: rows, total: rows.length }); +}); + +routes.patch("/api/cards/:id", async (c) => { + const body = await c.req.json<{ title?: string; body?: string; card_type?: string; tags?: string[]; position?: number }>(); + const sets: string[] = []; + const params: any[] = []; + let idx = 1; + + if (body.title !== undefined) { sets.push(`title = $${idx}`); params.push(body.title); idx++; } + if (body.body !== undefined) { sets.push(`body = $${idx}`); params.push(body.body); idx++; } + if (body.card_type !== undefined) { sets.push(`card_type = $${idx}`); params.push(body.card_type); idx++; } + if (body.tags !== undefined) { sets.push(`tags = $${idx}::jsonb`); params.push(JSON.stringify(body.tags)); idx++; } + if (body.position !== undefined) { sets.push(`position = $${idx}`); params.push(body.position); idx++; } + + if (sets.length === 0) return c.json({ error: "No fields to update" }, 400); + sets.push(`updated_at = NOW()`); + params.push(c.req.param("id")); + + const [card] = await sql.unsafe( + `UPDATE rfiles.memory_cards SET ${sets.join(", ")} WHERE id = $${idx} RETURNING *`, + params + ); + if (!card) return c.json({ error: "Card not found" }, 404); + return c.json({ card }); +}); + +routes.delete("/api/cards/:id", async (c) => { + const [card] = await sql.unsafe("DELETE FROM rfiles.memory_cards WHERE id = $1 RETURNING id", [c.req.param("id")]); + if (!card) return c.json({ error: "Card not found" }, 404); + return c.json({ message: "Deleted" }); +}); + +// ── Page route ── +routes.get("/", (c) => { + const spaceSlug = c.req.param("space") || "demo"; + return c.html(renderShell({ + title: `${spaceSlug} — Files | rSpace`, + moduleId: "files", + spaceSlug, + modules: getModuleInfoList(), + theme: "light", + styles: ``, + body: ``, + scripts: ``, + })); +}); + +export const filesModule: RSpaceModule = { + id: "files", + name: "rFiles", + icon: "\uD83D\uDCC1", + description: "File sharing, share links, and memory cards", + routes, +}; diff --git a/modules/files/standalone.ts b/modules/files/standalone.ts new file mode 100644 index 0000000..0d39cb9 --- /dev/null +++ b/modules/files/standalone.ts @@ -0,0 +1,23 @@ +/** + * Standalone server for the Files module. + * Serves rfiles.online independently. + */ + +import { Hono } from "hono"; +import { serveStatic } from "hono/bun"; +import { filesModule } from "./mod"; + +const app = new Hono(); + +// Serve static module assets +app.use("/modules/files/*", serveStatic({ root: "./dist" })); +app.use("/*", serveStatic({ root: "./dist" })); + +// Mount files routes at root +app.route("/", filesModule.routes); + +console.log(`[rFiles Standalone] Listening on :3000`); +export default { + port: 3000, + fetch: app.fetch, +}; diff --git a/modules/forum/components/folk-forum-dashboard.ts b/modules/forum/components/folk-forum-dashboard.ts new file mode 100644 index 0000000..08b4986 --- /dev/null +++ b/modules/forum/components/folk-forum-dashboard.ts @@ -0,0 +1,419 @@ +/** + * — Discourse instance provisioner dashboard. + * + * Lists user's forum instances, shows provisioning status, and allows + * creating new instances. + */ + +class FolkForumDashboard extends HTMLElement { + private shadow: ShadowRoot; + private instances: any[] = []; + private selectedInstance: any = null; + private selectedLogs: any[] = []; + private view: "list" | "detail" | "create" = "list"; + private loading = false; + private pollTimer: number | null = null; + + constructor() { + super(); + this.shadow = this.attachShadow({ mode: "open" }); + } + + connectedCallback() { + this.render(); + this.loadInstances(); + } + + disconnectedCallback() { + if (this.pollTimer) clearInterval(this.pollTimer); + } + + private getApiBase(): string { + const path = window.location.pathname; + const match = path.match(/^\/([^/]+)\/forum/); + return match ? `/${match[1]}/forum` : ""; + } + + private getAuthHeaders(): Record { + const token = localStorage.getItem("encryptid_session"); + if (token) { + try { + const parsed = JSON.parse(token); + return { "X-User-DID": parsed.did || "" }; + } catch {} + } + return {}; + } + + private async loadInstances() { + this.loading = true; + this.render(); + try { + const base = this.getApiBase(); + const res = await fetch(`${base}/api/instances`, { headers: this.getAuthHeaders() }); + if (res.ok) { + const data = await res.json(); + this.instances = data.instances || []; + } + } catch (e) { + console.error("[ForumDashboard] Error:", e); + } + this.loading = false; + this.render(); + } + + private async loadInstanceDetail(id: string) { + try { + const base = this.getApiBase(); + const res = await fetch(`${base}/api/instances/${id}`, { headers: this.getAuthHeaders() }); + if (res.ok) { + const data = await res.json(); + this.selectedInstance = data.instance; + this.selectedLogs = data.logs || []; + this.view = "detail"; + this.render(); + + // Poll if provisioning + const active = ["pending", "provisioning", "installing", "configuring"]; + if (active.includes(this.selectedInstance.status)) { + if (this.pollTimer) clearInterval(this.pollTimer); + this.pollTimer = setInterval(() => this.loadInstanceDetail(id), 5000) as any; + } else { + if (this.pollTimer) clearInterval(this.pollTimer); + } + } + } catch {} + } + + private async handleCreate(e: Event) { + e.preventDefault(); + const form = this.shadow.querySelector("#create-form") as HTMLFormElement; + if (!form) return; + + const name = (form.querySelector('[name="name"]') as HTMLInputElement)?.value; + const subdomain = (form.querySelector('[name="subdomain"]') as HTMLInputElement)?.value; + const adminEmail = (form.querySelector('[name="admin_email"]') as HTMLInputElement)?.value; + const region = (form.querySelector('[name="region"]') as HTMLSelectElement)?.value; + const size = (form.querySelector('[name="size"]') as HTMLSelectElement)?.value; + + if (!name || !subdomain || !adminEmail) return; + + try { + const base = this.getApiBase(); + const res = await fetch(`${base}/api/instances`, { + method: "POST", + headers: { "Content-Type": "application/json", ...this.getAuthHeaders() }, + body: JSON.stringify({ name, subdomain, admin_email: adminEmail, region, size }), + }); + if (res.ok) { + const data = await res.json(); + this.view = "detail"; + this.loadInstanceDetail(data.instance.id); + } else { + const err = await res.json(); + alert(err.error || "Failed to create instance"); + } + } catch { + alert("Network error"); + } + } + + private async handleDestroy(id: string) { + if (!confirm("Are you sure you want to destroy this forum instance? This cannot be undone.")) return; + try { + const base = this.getApiBase(); + await fetch(`${base}/api/instances/${id}`, { + method: "DELETE", + headers: this.getAuthHeaders(), + }); + this.view = "list"; + this.loadInstances(); + } catch {} + } + + private statusBadge(status: string): string { + const colors: Record = { + pending: "#ffa726", + provisioning: "#42a5f5", + installing: "#42a5f5", + configuring: "#42a5f5", + active: "#66bb6a", + error: "#ef5350", + destroying: "#ffa726", + destroyed: "#888", + }; + const color = colors[status] || "#888"; + const pulse = ["provisioning", "installing", "configuring"].includes(status) + ? "animation: pulse 1.5s ease-in-out infinite;" + : ""; + return `${status}`; + } + + private logStepIcon(status: string): string { + if (status === "success") return "\u2705"; + if (status === "error") return "\u274C"; + if (status === "running") return "\u23F3"; + return "\u23ED\uFE0F"; + } + + private render() { + this.shadow.innerHTML = ` + + + ${this.view === "list" ? this.renderList() : ""} + ${this.view === "detail" ? this.renderDetail() : ""} + ${this.view === "create" ? this.renderCreate() : ""} + `; + + this.attachEvents(); + } + + private renderList(): string { + return ` +
+

\uD83D\uDCAC Forum Instances

+ +
+ + ${this.loading ? '
Loading...
' : ""} + ${!this.loading && this.instances.length === 0 ? '
No forum instances yet. Deploy your first Discourse forum!
' : ""} + +
+ ${this.instances.map((inst) => ` +
+
+ ${this.esc(inst.name)} + ${this.statusBadge(inst.status)} +
+
+ ${inst.domain} · ${inst.region} · ${inst.size} + ${inst.vps_ip ? ` · ${inst.vps_ip}` : ""} +
+
+ `).join("")} +
+ `; + } + + private renderDetail(): string { + const inst = this.selectedInstance; + if (!inst) return ""; + + return ` +
+ + ${inst.status !== "destroyed" ? `` : ""} +
+ +
+
+
+
${this.esc(inst.name)}
+
${this.statusBadge(inst.status)}
+
+ ${inst.status === "active" ? `\u2197 Open Forum` : ""} +
+ + ${inst.error_message ? `
${this.esc(inst.error_message)}
` : ""} + +
+
${inst.domain}
+
${inst.vps_ip || "—"}
+
${inst.region}
+
${inst.size}
+
${inst.admin_email || "—"}
+
${inst.ssl_provisioned ? "\u2705 Active" : "\u23F3 Pending"}
+
+ +
+

Provision Log

+ ${this.selectedLogs.length === 0 ? '
No logs yet
' : ""} + ${this.selectedLogs.map((log) => ` +
+ ${this.logStepIcon(log.status)} +
+
${this.formatStep(log.step)}
+
${this.esc(log.message || "")}
+
+
+ `).join("")} +
+
+ `; + } + + private renderCreate(): string { + return ` +
+ +
+ +
+

Deploy New Forum

+ +
+
+
Starter
+
\u20AC3.79/mo
+
2 vCPU · 4 GB · ~500 users
+
+
+
Standard
+
\u20AC6.80/mo
+
4 vCPU · 8 GB · ~2000 users
+
+
+
Performance
+
\u20AC13.80/mo
+
8 vCPU · 16 GB · ~10k users
+
+
+ +
+
+
+ + +
+
+ +
+ + .rforum.online +
+
+
+ +
+
+ + +
+
+ + +
+
+ + + + +
+
+ `; + } + + private attachEvents() { + this.shadow.querySelectorAll("[data-action]").forEach((el) => { + const action = (el as HTMLElement).dataset.action!; + const id = (el as HTMLElement).dataset.id; + el.addEventListener("click", () => { + if (action === "show-create") { this.view = "create"; this.render(); } + else if (action === "back") { + if (this.pollTimer) clearInterval(this.pollTimer); + this.view = "list"; this.loadInstances(); + } + else if (action === "detail" && id) { this.loadInstanceDetail(id); } + else if (action === "destroy" && id) { this.handleDestroy(id); } + }); + }); + + this.shadow.querySelectorAll(".price-card").forEach((card) => { + card.addEventListener("click", () => { + this.shadow.querySelectorAll(".price-card").forEach((c) => c.classList.remove("selected")); + card.classList.add("selected"); + const sizeInput = this.shadow.querySelector('[name="size"]') as HTMLInputElement; + if (sizeInput) sizeInput.value = (card as HTMLElement).dataset.size || "cx22"; + }); + }); + + const form = this.shadow.querySelector("#create-form"); + if (form) form.addEventListener("submit", (e) => this.handleCreate(e)); + } + + private formatStep(step: string): string { + const labels: Record = { + create_vps: "Create Server", + wait_ready: "Wait for Boot", + configure_dns: "Configure DNS", + install_discourse: "Install Discourse", + verify_live: "Verify Live", + }; + return labels[step] || step; + } + + private esc(s: string): string { + const d = document.createElement("div"); + d.textContent = s || ""; + return d.innerHTML; + } +} + +customElements.define("folk-forum-dashboard", FolkForumDashboard); diff --git a/modules/forum/components/forum.css b/modules/forum/components/forum.css new file mode 100644 index 0000000..3442512 --- /dev/null +++ b/modules/forum/components/forum.css @@ -0,0 +1,6 @@ +/* Forum module — dark theme */ +folk-forum-dashboard { + display: block; + min-height: 400px; + padding: 20px; +} diff --git a/modules/forum/db/schema.sql b/modules/forum/db/schema.sql new file mode 100644 index 0000000..3f53b08 --- /dev/null +++ b/modules/forum/db/schema.sql @@ -0,0 +1,55 @@ +-- rForum schema — Discourse cloud provisioning +-- Inside rSpace shared DB, schema: rforum + +CREATE TABLE IF NOT EXISTS rforum.users ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + did TEXT UNIQUE, + username TEXT, + email TEXT, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS rforum.instances ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID REFERENCES rforum.users(id), + name TEXT NOT NULL, + domain TEXT UNIQUE NOT NULL, + status TEXT NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending','provisioning','installing','configuring','active','error','destroying','destroyed')), + error_message TEXT, + discourse_version TEXT DEFAULT 'stable', + provider TEXT DEFAULT 'hetzner' CHECK (provider IN ('hetzner','digitalocean')), + vps_id TEXT, + vps_ip TEXT, + region TEXT DEFAULT 'nbg1', + size TEXT DEFAULT 'cx22', + admin_email TEXT, + smtp_config JSONB DEFAULT '{}', + dns_record_id TEXT, + ssl_provisioned BOOLEAN DEFAULT FALSE, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + provisioned_at TIMESTAMPTZ, + destroyed_at TIMESTAMPTZ +); + +CREATE INDEX IF NOT EXISTS idx_instances_user ON rforum.instances (user_id); +CREATE INDEX IF NOT EXISTS idx_instances_status ON rforum.instances (status); +CREATE INDEX IF NOT EXISTS idx_instances_domain ON rforum.instances (domain); + +CREATE TABLE IF NOT EXISTS rforum.provision_logs ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + instance_id UUID NOT NULL REFERENCES rforum.instances(id) ON DELETE CASCADE, + step TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'running' + CHECK (status IN ('running','success','error','skipped')), + message TEXT, + metadata JSONB DEFAULT '{}', + started_at TIMESTAMPTZ DEFAULT NOW(), + completed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_logs_instance ON rforum.provision_logs (instance_id); +CREATE INDEX IF NOT EXISTS idx_logs_step ON rforum.provision_logs (step); diff --git a/modules/forum/lib/cloud-init.ts b/modules/forum/lib/cloud-init.ts new file mode 100644 index 0000000..cd5bc39 --- /dev/null +++ b/modules/forum/lib/cloud-init.ts @@ -0,0 +1,81 @@ +/** + * Cloud-init user data generator for Discourse instances. + */ + +export interface DiscourseConfig { + hostname: string; + adminEmail: string; + smtpHost?: string; + smtpPort?: number; + smtpUser?: string; + smtpPassword?: string; +} + +export function generateCloudInit(config: DiscourseConfig): string { + const smtpHost = config.smtpHost || "mail.rmail.online"; + const smtpPort = config.smtpPort || 587; + const smtpUser = config.smtpUser || `noreply@rforum.online`; + const smtpPassword = config.smtpPassword || ""; + + return `#!/bin/bash +set -e + +# Swap +fallocate -l 2G /swapfile +chmod 600 /swapfile +mkswap /swapfile +swapon /swapfile +echo '/swapfile none swap sw 0 0' >> /etc/fstab + +# Install Docker +apt-get update +apt-get install -y git docker.io docker-compose +systemctl enable docker +systemctl start docker + +# Clone Discourse +git clone https://github.com/discourse/discourse_docker.git /var/discourse +cd /var/discourse + +# Write app.yml +cat > containers/app.yml << 'APPYML' +templates: + - "templates/postgres.template.yml" + - "templates/redis.template.yml" + - "templates/web.template.yml" + - "templates/web.ssl.template.yml" + - "templates/web.letsencrypt.ssl.template.yml" + +expose: + - "80:80" + - "443:443" + +params: + db_default_text_search_config: "pg_catalog.english" + +env: + LANG: en_US.UTF-8 + DISCOURSE_DEFAULT_LOCALE: en + DISCOURSE_HOSTNAME: '${config.hostname}' + DISCOURSE_DEVELOPER_EMAILS: '${config.adminEmail}' + DISCOURSE_SMTP_ADDRESS: '${smtpHost}' + DISCOURSE_SMTP_PORT: ${smtpPort} + DISCOURSE_SMTP_USER_NAME: '${smtpUser}' + DISCOURSE_SMTP_PASSWORD: '${smtpPassword}' + DISCOURSE_SMTP_ENABLE_START_TLS: true + LETSENCRYPT_ACCOUNT_EMAIL: '${config.adminEmail}' + +volumes: + - volume: + host: /var/discourse/shared/standalone + guest: /shared + - volume: + host: /var/discourse/shared/standalone/log/var-log + guest: /var/log +APPYML + +# Bootstrap and start +./launcher bootstrap app +./launcher start app +`; +} diff --git a/modules/forum/lib/dns.ts b/modules/forum/lib/dns.ts new file mode 100644 index 0000000..0d074b4 --- /dev/null +++ b/modules/forum/lib/dns.ts @@ -0,0 +1,53 @@ +/** + * Cloudflare DNS management for forum subdomains. + */ + +const CF_API = "https://api.cloudflare.com/client/v4"; + +function headers(): Record { + const token = process.env.CLOUDFLARE_API_TOKEN; + if (!token) throw new Error("CLOUDFLARE_API_TOKEN not set"); + return { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }; +} + +export async function createDNSRecord( + subdomain: string, + ip: string, +): Promise<{ recordId: string } | null> { + const zoneId = process.env.CLOUDFLARE_FORUM_ZONE_ID; + if (!zoneId) throw new Error("CLOUDFLARE_FORUM_ZONE_ID not set"); + + const res = await fetch(`${CF_API}/zones/${zoneId}/dns_records`, { + method: "POST", + headers: headers(), + body: JSON.stringify({ + type: "A", + name: `${subdomain}.rforum.online`, + content: ip, + ttl: 300, + proxied: false, + }), + }); + + if (!res.ok) { + console.error("[Forum DNS] Failed to create record:", await res.text()); + return null; + } + + const data = await res.json(); + return { recordId: data.result.id }; +} + +export async function deleteDNSRecord(recordId: string): Promise { + const zoneId = process.env.CLOUDFLARE_FORUM_ZONE_ID; + if (!zoneId) return false; + + const res = await fetch(`${CF_API}/zones/${zoneId}/dns_records/${recordId}`, { + method: "DELETE", + headers: headers(), + }); + return res.ok; +} diff --git a/modules/forum/lib/hetzner.ts b/modules/forum/lib/hetzner.ts new file mode 100644 index 0000000..febfa5d --- /dev/null +++ b/modules/forum/lib/hetzner.ts @@ -0,0 +1,80 @@ +/** + * Hetzner Cloud API client for VPS provisioning. + */ + +const HETZNER_API = "https://api.hetzner.cloud/v1"; + +function headers(): Record { + const token = process.env.HETZNER_API_TOKEN; + if (!token) throw new Error("HETZNER_API_TOKEN not set"); + return { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }; +} + +export interface HetznerServer { + id: number; + name: string; + status: string; + public_net: { + ipv4: { ip: string }; + ipv6: { ip: string }; + }; + server_type: { name: string }; + datacenter: { name: string }; +} + +export async function createServer(opts: { + name: string; + serverType: string; + region: string; + userData: string; +}): Promise<{ serverId: string; ip: string }> { + const res = await fetch(`${HETZNER_API}/servers`, { + method: "POST", + headers: headers(), + body: JSON.stringify({ + name: opts.name, + server_type: opts.serverType, + location: opts.region, + image: "ubuntu-22.04", + user_data: opts.userData, + start_after_create: true, + }), + }); + + if (!res.ok) { + const err = await res.text(); + throw new Error(`Hetzner create failed: ${res.status} ${err}`); + } + + const data = await res.json(); + return { + serverId: String(data.server.id), + ip: data.server.public_net.ipv4.ip, + }; +} + +export async function getServer(serverId: string): Promise { + const res = await fetch(`${HETZNER_API}/servers/${serverId}`, { headers: headers() }); + if (!res.ok) return null; + const data = await res.json(); + return data.server; +} + +export async function deleteServer(serverId: string): Promise { + const res = await fetch(`${HETZNER_API}/servers/${serverId}`, { + method: "DELETE", + headers: headers(), + }); + return res.ok; +} + +export async function serverAction(serverId: string, action: "poweron" | "poweroff" | "reboot"): Promise { + const res = await fetch(`${HETZNER_API}/servers/${serverId}/actions/${action}`, { + method: "POST", + headers: headers(), + }); + return res.ok; +} diff --git a/modules/forum/lib/provisioner.ts b/modules/forum/lib/provisioner.ts new file mode 100644 index 0000000..1c14ac8 --- /dev/null +++ b/modules/forum/lib/provisioner.ts @@ -0,0 +1,173 @@ +/** + * Forum instance provisioner — async pipeline that creates a VPS, + * configures DNS, installs Discourse, and verifies it's live. + */ + +import { sql } from "../../../shared/db/pool"; +import { createServer, getServer, deleteServer } from "./hetzner"; +import { createDNSRecord, deleteDNSRecord } from "./dns"; +import { generateCloudInit, type DiscourseConfig } from "./cloud-init"; + +type StepStatus = "running" | "success" | "error" | "skipped"; + +async function logStep( + instanceId: string, + step: string, + status: StepStatus, + message: string, + metadata: Record = {}, +) { + if (status === "running") { + await sql.unsafe( + `INSERT INTO rforum.provision_logs (instance_id, step, status, message, metadata) + VALUES ($1, $2, $3, $4, $5::jsonb)`, + [instanceId, step, status, message, JSON.stringify(metadata)], + ); + } else { + await sql.unsafe( + `UPDATE rforum.provision_logs SET status = $1, message = $2, metadata = $3::jsonb, completed_at = NOW() + WHERE instance_id = $4 AND step = $5 AND status = 'running'`, + [status, message, JSON.stringify(metadata), instanceId, step], + ); + } +} + +async function updateInstance(instanceId: string, fields: Record) { + const sets: string[] = []; + const params: unknown[] = []; + let idx = 1; + for (const [key, val] of Object.entries(fields)) { + sets.push(`${key} = $${idx}`); + params.push(val); + idx++; + } + sets.push("updated_at = NOW()"); + params.push(instanceId); + await sql.unsafe(`UPDATE rforum.instances SET ${sets.join(", ")} WHERE id = $${idx}`, params); +} + +async function sleep(ms: number) { + return new Promise((r) => setTimeout(r, ms)); +} + +export async function provisionInstance(instanceId: string) { + const [instance] = await sql.unsafe("SELECT * FROM rforum.instances WHERE id = $1", [instanceId]); + if (!instance) throw new Error("Instance not found"); + + await updateInstance(instanceId, { status: "provisioning" }); + + try { + // Step 1: Create VPS + await logStep(instanceId, "create_vps", "running", "Creating VPS..."); + const config: DiscourseConfig = { + hostname: instance.domain, + adminEmail: instance.admin_email, + ...(instance.smtp_config?.host ? { + smtpHost: instance.smtp_config.host, + smtpPort: instance.smtp_config.port, + smtpUser: instance.smtp_config.user, + smtpPassword: instance.smtp_config.password, + } : {}), + }; + const userData = generateCloudInit(config); + const { serverId, ip } = await createServer({ + name: `discourse-${instance.domain.replace(/\./g, "-")}`, + serverType: instance.size, + region: instance.region, + userData, + }); + await updateInstance(instanceId, { vps_id: serverId, vps_ip: ip }); + await logStep(instanceId, "create_vps", "success", `VPS created: ${ip}`, { serverId, ip }); + + // Step 2: Wait for boot + await logStep(instanceId, "wait_ready", "running", "Waiting for VPS to boot..."); + let booted = false; + for (let i = 0; i < 60; i++) { + await sleep(5000); + const server = await getServer(serverId); + if (server?.status === "running") { + booted = true; + break; + } + } + if (!booted) { + await logStep(instanceId, "wait_ready", "error", "VPS failed to boot within 5 minutes"); + await updateInstance(instanceId, { status: "error", error_message: "VPS boot timeout" }); + return; + } + await logStep(instanceId, "wait_ready", "success", "VPS is running"); + + // Step 3: Configure DNS + await logStep(instanceId, "configure_dns", "running", "Configuring DNS..."); + const subdomain = instance.domain.replace(".rforum.online", ""); + const dns = await createDNSRecord(subdomain, ip); + if (dns) { + await updateInstance(instanceId, { dns_record_id: dns.recordId }); + await logStep(instanceId, "configure_dns", "success", `DNS record created for ${instance.domain}`); + } else { + await logStep(instanceId, "configure_dns", "skipped", "DNS configuration skipped — configure manually"); + } + + // Step 4: Wait for Discourse install + await updateInstance(instanceId, { status: "installing" }); + await logStep(instanceId, "install_discourse", "running", "Installing Discourse (this takes 10-15 minutes)..."); + let installed = false; + for (let i = 0; i < 60; i++) { + await sleep(15000); + try { + const res = await fetch(`http://${ip}`, { redirect: "manual" }); + if (res.status === 200 || res.status === 302) { + installed = true; + break; + } + } catch {} + } + if (!installed) { + await logStep(instanceId, "install_discourse", "error", "Discourse did not respond within 15 minutes"); + await updateInstance(instanceId, { status: "error", error_message: "Discourse install timeout" }); + return; + } + await logStep(instanceId, "install_discourse", "success", "Discourse is responding"); + + // Step 5: Verify live + await updateInstance(instanceId, { status: "configuring" }); + await logStep(instanceId, "verify_live", "running", "Verifying Discourse is live..."); + try { + const res = await fetch(`https://${instance.domain}`, { redirect: "manual" }); + if (res.status === 200 || res.status === 302) { + await updateInstance(instanceId, { + status: "active", + ssl_provisioned: true, + provisioned_at: new Date().toISOString(), + }); + await logStep(instanceId, "verify_live", "success", "Forum is live with SSL!"); + } else { + await updateInstance(instanceId, { status: "active", provisioned_at: new Date().toISOString() }); + await logStep(instanceId, "verify_live", "success", "Forum is live (SSL pending)"); + } + } catch { + await updateInstance(instanceId, { status: "active", provisioned_at: new Date().toISOString() }); + await logStep(instanceId, "verify_live", "success", "Forum provisioned (SSL may take a few minutes)"); + } + } catch (e: any) { + console.error("[Forum] Provisioning error:", e); + await updateInstance(instanceId, { status: "error", error_message: e.message }); + await logStep(instanceId, "unknown", "error", e.message); + } +} + +export async function destroyInstance(instanceId: string) { + const [instance] = await sql.unsafe("SELECT * FROM rforum.instances WHERE id = $1", [instanceId]); + if (!instance) return; + + await updateInstance(instanceId, { status: "destroying" }); + + if (instance.vps_id) { + await deleteServer(instance.vps_id); + } + if (instance.dns_record_id) { + await deleteDNSRecord(instance.dns_record_id); + } + + await updateInstance(instanceId, { status: "destroyed", destroyed_at: new Date().toISOString() }); +} diff --git a/modules/forum/mod.ts b/modules/forum/mod.ts new file mode 100644 index 0000000..0a135a0 --- /dev/null +++ b/modules/forum/mod.ts @@ -0,0 +1,169 @@ +/** + * 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, +}; diff --git a/modules/forum/standalone.ts b/modules/forum/standalone.ts new file mode 100644 index 0000000..a449669 --- /dev/null +++ b/modules/forum/standalone.ts @@ -0,0 +1,23 @@ +/** + * Standalone server for the Forum module. + * Serves rforum.online independently. + */ + +import { Hono } from "hono"; +import { serveStatic } from "hono/bun"; +import { forumModule } from "./mod"; + +const app = new Hono(); + +// Serve static module assets +app.use("/modules/forum/*", serveStatic({ root: "./dist" })); +app.use("/*", serveStatic({ root: "./dist" })); + +// Mount forum routes at root +app.route("/", forumModule.routes); + +console.log(`[rForum Standalone] Listening on :3000`); +export default { + port: 3000, + fetch: app.fetch, +}; diff --git a/server/index.ts b/server/index.ts index 65aeabb..8a9e9a5 100644 --- a/server/index.ts +++ b/server/index.ts @@ -46,6 +46,8 @@ import { providersModule } from "../modules/providers/mod"; import { swagModule } from "../modules/swag/mod"; import { choicesModule } from "../modules/choices/mod"; import { fundsModule } from "../modules/funds/mod"; +import { filesModule } from "../modules/files/mod"; +import { forumModule } from "../modules/forum/mod"; import { spaces } from "./spaces"; import { renderShell } from "./shell"; @@ -58,6 +60,8 @@ registerModule(providersModule); registerModule(swagModule); registerModule(choicesModule); registerModule(fundsModule); +registerModule(filesModule); +registerModule(forumModule); // ── Config ── const PORT = Number(process.env.PORT) || 3000; diff --git a/vite.config.ts b/vite.config.ts index 203d94f..7014389 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -274,6 +274,60 @@ export default defineConfig({ resolve(__dirname, "modules/funds/components/funds.css"), resolve(__dirname, "dist/modules/funds/funds.css"), ); + + // Build files module component + await build({ + configFile: false, + root: resolve(__dirname, "modules/files/components"), + build: { + emptyOutDir: false, + outDir: resolve(__dirname, "dist/modules/files"), + lib: { + entry: resolve(__dirname, "modules/files/components/folk-file-browser.ts"), + formats: ["es"], + fileName: () => "folk-file-browser.js", + }, + rollupOptions: { + output: { + entryFileNames: "folk-file-browser.js", + }, + }, + }, + }); + + // Copy files CSS + mkdirSync(resolve(__dirname, "dist/modules/files"), { recursive: true }); + copyFileSync( + resolve(__dirname, "modules/files/components/files.css"), + resolve(__dirname, "dist/modules/files/files.css"), + ); + + // Build forum module component + await build({ + configFile: false, + root: resolve(__dirname, "modules/forum/components"), + build: { + emptyOutDir: false, + outDir: resolve(__dirname, "dist/modules/forum"), + lib: { + entry: resolve(__dirname, "modules/forum/components/folk-forum-dashboard.ts"), + formats: ["es"], + fileName: () => "folk-forum-dashboard.js", + }, + rollupOptions: { + output: { + entryFileNames: "folk-forum-dashboard.js", + }, + }, + }, + }); + + // Copy forum CSS + mkdirSync(resolve(__dirname, "dist/modules/forum"), { recursive: true }); + copyFileSync( + resolve(__dirname, "modules/forum/components/forum.css"), + resolve(__dirname, "dist/modules/forum/forum.css"), + ); }, }, },