feat: add files and forum modules — Phase 7+8

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 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-20 23:25:18 +00:00
parent 92edaaed45
commit 682c995cc3
18 changed files with 1974 additions and 1 deletions

View File

@ -46,19 +46,21 @@ COPY --from=build /encryptid-sdk /encryptid-sdk
RUN bun install --production RUN bun install --production
# Create data directories # 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 # Set environment
ENV NODE_ENV=production ENV NODE_ENV=production
ENV STORAGE_DIR=/data/communities ENV STORAGE_DIR=/data/communities
ENV BOOKS_DIR=/data/books ENV BOOKS_DIR=/data/books
ENV SWAG_ARTIFACTS_DIR=/data/swag-artifacts ENV SWAG_ARTIFACTS_DIR=/data/swag-artifacts
ENV FILES_DIR=/data/files
ENV PORT=3000 ENV PORT=3000
# Data volumes for persistence # Data volumes for persistence
VOLUME /data/communities VOLUME /data/communities
VOLUME /data/books VOLUME /data/books
VOLUME /data/swag-artifacts VOLUME /data/swag-artifacts
VOLUME /data/files
EXPOSE 3000 EXPOSE 3000

View File

@ -10,6 +10,7 @@ services:
- rspace-data:/data/communities - rspace-data:/data/communities
- rspace-books:/data/books - rspace-books:/data/books
- rspace-swag:/data/swag-artifacts - rspace-swag:/data/swag-artifacts
- rspace-files:/data/files
environment: environment:
- NODE_ENV=production - NODE_ENV=production
- STORAGE_DIR=/data/communities - STORAGE_DIR=/data/communities
@ -21,6 +22,7 @@ services:
- FLOW_SERVICE_URL=http://payment-flow:3010 - FLOW_SERVICE_URL=http://payment-flow:3010
- FLOW_ID=a79144ec-e6a2-4e30-a42a-6d8237a5953d - FLOW_ID=a79144ec-e6a2-4e30-a42a-6d8237a5953d
- FUNNEL_ID=0ff6a9ac-1667-4fc7-9a01-b1620810509f - FUNNEL_ID=0ff6a9ac-1667-4fc7-9a01-b1620810509f
- FILES_DIR=/data/files
depends_on: depends_on:
rspace-db: rspace-db:
condition: service_healthy condition: service_healthy
@ -65,6 +67,7 @@ volumes:
rspace-data: rspace-data:
rspace-books: rspace-books:
rspace-swag: rspace-swag:
rspace-files:
rspace-pgdata: rspace-pgdata:
networks: networks:

View File

@ -0,0 +1,6 @@
/* Files module — dark theme */
folk-file-browser {
display: block;
min-height: 400px;
padding: 20px;
}

View File

@ -0,0 +1,389 @@
/**
* <folk-file-browser> 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<string, string> = {
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 = `
<style>
:host { display: block; font-family: system-ui, -apple-system, sans-serif; color: #e0e0e0; }
* { box-sizing: border-box; }
.tabs { display: flex; gap: 4px; margin-bottom: 20px; }
.tab-btn {
padding: 8px 20px; border: 1px solid #444; border-radius: 6px 6px 0 0;
background: ${filesActive ? "#1a1a2e" : "#2a2a3e"}; color: #e0e0e0;
cursor: pointer; font-size: 14px; border-bottom: ${filesActive ? "2px solid #64b5f6" : "none"};
}
.tab-btn:last-child {
background: ${!filesActive ? "#1a1a2e" : "#2a2a3e"};
border-bottom: ${!filesActive ? "2px solid #64b5f6" : "none"};
}
.upload-section {
background: #1e1e2e; border: 1px dashed #555; border-radius: 8px;
padding: 16px; margin-bottom: 20px;
}
.upload-section h3 { margin: 0 0 12px; font-size: 14px; color: #aaa; }
.upload-row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
input[type="file"] { color: #ccc; font-size: 13px; }
input[type="text"], select, textarea {
background: #2a2a3e; border: 1px solid #444; color: #e0e0e0;
padding: 6px 10px; border-radius: 4px; font-size: 13px;
}
textarea { width: 100%; min-height: 60px; resize: vertical; }
button {
padding: 6px 14px; border-radius: 4px; border: 1px solid #555;
background: #2a4a7a; color: #e0e0e0; cursor: pointer; font-size: 13px;
}
button:hover { background: #3a5a9a; }
button.danger { background: #7a2a2a; }
button.danger:hover { background: #9a3a3a; }
.file-grid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 12px;
}
.file-card {
background: #1e1e2e; border: 1px solid #333; border-radius: 8px;
padding: 14px; transition: border-color 0.2s;
}
.file-card:hover { border-color: #64b5f6; }
.file-icon { font-size: 28px; margin-bottom: 8px; }
.file-name {
font-size: 14px; font-weight: 500; margin-bottom: 4px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.file-meta { font-size: 12px; color: #888; margin-bottom: 8px; }
.file-actions { display: flex; gap: 6px; flex-wrap: wrap; }
.file-actions button { padding: 3px 8px; font-size: 11px; }
.card-grid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 12px;
}
.memory-card {
background: #1e1e2e; border: 1px solid #333; border-radius: 8px;
padding: 14px;
}
.memory-card:hover { border-color: #81c784; }
.card-header { display: flex; justify-content: space-between; align-items: start; margin-bottom: 8px; }
.card-title { font-size: 14px; font-weight: 500; }
.card-type { font-size: 11px; background: #2a2a3e; padding: 2px 6px; border-radius: 3px; color: #aaa; }
.card-body { font-size: 13px; color: #aaa; white-space: pre-wrap; word-break: break-word; }
.empty { text-align: center; color: #666; padding: 40px 20px; font-size: 14px; }
.loading { text-align: center; color: #888; padding: 40px; }
.card-form { margin-bottom: 20px; display: flex; flex-direction: column; gap: 8px; }
.card-form-row { display: flex; gap: 8px; }
</style>
<div class="tabs">
<div class="tab-btn" data-tab="files">\uD83D\uDCC1 Files</div>
<div class="tab-btn" data-tab="cards">\uD83C\uDFB4 Memory Cards</div>
</div>
${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 `
<div class="upload-section">
<h3>Upload File</h3>
<form id="upload-form">
<div class="upload-row">
<input type="file" name="file" required>
<input type="text" name="title" placeholder="Title (optional)" style="flex:1;min-width:120px">
<button type="submit">Upload</button>
</div>
</form>
</div>
${this.loading ? '<div class="loading">Loading files...</div>' : ""}
${!this.loading && this.files.length === 0 ? '<div class="empty">No files yet. Upload one above.</div>' : ""}
${
!this.loading && this.files.length > 0
? `<div class="file-grid">
${this.files
.map(
(f) => `
<div class="file-card">
<div class="file-icon">${this.mimeIcon(f.mime_type)}</div>
<div class="file-name" title="${this.esc(f.original_filename)}">${this.esc(f.title || f.original_filename)}</div>
<div class="file-meta">${this.formatSize(f.file_size)} &middot; ${this.formatDate(f.created_at)}</div>
<div class="file-actions">
<button data-action="download" data-id="${f.id}">Download</button>
<button data-action="share" data-id="${f.id}">Share</button>
<button class="danger" data-action="delete" data-id="${f.id}">Delete</button>
</div>
</div>
`,
)
.join("")}
</div>`
: ""
}
`;
}
private renderCardsTab(): string {
return `
<div class="upload-section">
<h3>New Memory Card</h3>
<form id="card-form" class="card-form">
<div class="card-form-row">
<input type="text" name="card-title" placeholder="Title" required style="flex:1">
<select name="card-type">
<option value="note">Note</option>
<option value="idea">Idea</option>
<option value="task">Task</option>
<option value="reference">Reference</option>
<option value="quote">Quote</option>
</select>
<button type="submit">Add</button>
</div>
<textarea name="card-body" placeholder="Body (optional)"></textarea>
</form>
</div>
${this.cards.length === 0 ? '<div class="empty">No memory cards yet.</div>' : ""}
${
this.cards.length > 0
? `<div class="card-grid">
${this.cards
.map(
(c) => `
<div class="memory-card">
<div class="card-header">
<span class="card-title">${this.cardTypeIcon(c.card_type)} ${this.esc(c.title)}</span>
<span class="card-type">${c.card_type}</span>
</div>
${c.body ? `<div class="card-body">${this.esc(c.body)}</div>` : ""}
<div class="file-actions" style="margin-top:8px">
<button class="danger" data-action="delete-card" data-id="${c.id}">Delete</button>
</div>
</div>
`,
)
.join("")}
</div>`
: ""
}
`;
}
private esc(s: string): string {
const d = document.createElement("div");
d.textContent = s || "";
return d.innerHTML;
}
}
customElements.define("folk-file-browser", FolkFileBrowser);

View File

@ -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);

359
modules/files/mod.ts Normal file
View File

@ -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<string> {
const hasher = new Bun.CryptoHasher("sha256");
hasher.update(pw + "rfiles-salt");
return hasher.digest("hex");
}
async function computeFileHash(buffer: ArrayBuffer): Promise<string> {
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: `<link rel="stylesheet" href="/modules/files/files.css">`,
body: `<folk-file-browser space="${spaceSlug}"></folk-file-browser>`,
scripts: `<script type="module" src="/modules/files/folk-file-browser.js"></script>`,
}));
});
export const filesModule: RSpaceModule = {
id: "files",
name: "rFiles",
icon: "\uD83D\uDCC1",
description: "File sharing, share links, and memory cards",
routes,
};

View File

@ -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,
};

View File

@ -0,0 +1,419 @@
/**
* <folk-forum-dashboard> 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<string, string> {
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<string, string> = {
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 `<span style="display:inline-block;padding:2px 8px;border-radius:4px;font-size:11px;font-weight:600;background:${color}22;color:${color};border:1px solid ${color}44;${pulse}">${status}</span>`;
}
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 = `
<style>
:host { display: block; font-family: system-ui, -apple-system, sans-serif; color: #e0e0e0; }
* { box-sizing: border-box; }
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
.toolbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
button {
padding: 6px 14px; border-radius: 4px; border: 1px solid #555;
background: #2a4a7a; color: #e0e0e0; cursor: pointer; font-size: 13px;
}
button:hover { background: #3a5a9a; }
button.danger { background: #7a2a2a; }
button.danger:hover { background: #9a3a3a; }
button.secondary { background: #333; }
input, select {
background: #2a2a3e; border: 1px solid #444; color: #e0e0e0;
padding: 8px 12px; border-radius: 4px; font-size: 13px; width: 100%;
}
label { display: block; font-size: 12px; color: #aaa; margin-bottom: 4px; }
.form-group { margin-bottom: 14px; }
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.instance-list { display: flex; flex-direction: column; gap: 10px; }
.instance-card {
background: #1e1e2e; border: 1px solid #333; border-radius: 8px;
padding: 16px; cursor: pointer; transition: border-color 0.2s;
}
.instance-card:hover { border-color: #64b5f6; }
.instance-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
.instance-name { font-size: 16px; font-weight: 600; }
.instance-meta { font-size: 12px; color: #888; }
.detail-panel { background: #1e1e2e; border: 1px solid #333; border-radius: 8px; padding: 20px; }
.detail-header { display: flex; justify-content: space-between; align-items: start; margin-bottom: 16px; }
.detail-title { font-size: 20px; font-weight: 600; }
.detail-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 20px; }
.detail-item label { font-size: 11px; color: #888; text-transform: uppercase; }
.detail-item .value { font-size: 14px; margin-top: 2px; }
.logs-section h3 { font-size: 14px; color: #aaa; margin: 0 0 12px; }
.log-entry { display: flex; gap: 10px; align-items: start; padding: 8px 0; border-bottom: 1px solid #2a2a3e; }
.log-icon { font-size: 16px; flex-shrink: 0; }
.log-step { font-size: 13px; font-weight: 500; }
.log-msg { font-size: 12px; color: #888; margin-top: 2px; }
.empty { text-align: center; color: #666; padding: 40px 20px; }
.loading { text-align: center; color: #888; padding: 40px; }
.pricing { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin-bottom: 20px; }
.price-card {
background: #1e1e2e; border: 1px solid #333; border-radius: 8px; padding: 14px;
text-align: center; cursor: pointer; transition: border-color 0.2s;
}
.price-card:hover, .price-card.selected { border-color: #64b5f6; }
.price-name { font-weight: 600; font-size: 14px; margin-bottom: 4px; }
.price-cost { font-size: 18px; color: #64b5f6; font-weight: 700; }
.price-specs { font-size: 11px; color: #888; margin-top: 4px; }
</style>
${this.view === "list" ? this.renderList() : ""}
${this.view === "detail" ? this.renderDetail() : ""}
${this.view === "create" ? this.renderCreate() : ""}
`;
this.attachEvents();
}
private renderList(): string {
return `
<div class="toolbar">
<h2 style="margin:0;font-size:18px">\uD83D\uDCAC Forum Instances</h2>
<button data-action="show-create">+ New Forum</button>
</div>
${this.loading ? '<div class="loading">Loading...</div>' : ""}
${!this.loading && this.instances.length === 0 ? '<div class="empty">No forum instances yet. Deploy your first Discourse forum!</div>' : ""}
<div class="instance-list">
${this.instances.map((inst) => `
<div class="instance-card" data-action="detail" data-id="${inst.id}">
<div class="instance-header">
<span class="instance-name">${this.esc(inst.name)}</span>
${this.statusBadge(inst.status)}
</div>
<div class="instance-meta">
${inst.domain} &middot; ${inst.region} &middot; ${inst.size}
${inst.vps_ip ? ` &middot; ${inst.vps_ip}` : ""}
</div>
</div>
`).join("")}
</div>
`;
}
private renderDetail(): string {
const inst = this.selectedInstance;
if (!inst) return "";
return `
<div class="toolbar">
<button class="secondary" data-action="back">\u2190 Back</button>
${inst.status !== "destroyed" ? `<button class="danger" data-action="destroy" data-id="${inst.id}">Destroy</button>` : ""}
</div>
<div class="detail-panel">
<div class="detail-header">
<div>
<div class="detail-title">${this.esc(inst.name)}</div>
<div style="margin-top:4px">${this.statusBadge(inst.status)}</div>
</div>
${inst.status === "active" ? `<a href="https://${inst.domain}" target="_blank" style="color:#64b5f6;font-size:13px">\u2197 Open Forum</a>` : ""}
</div>
${inst.error_message ? `<div style="background:#7a2a2a33;border:1px solid #7a2a2a;padding:10px;border-radius:6px;margin-bottom:16px;font-size:13px;color:#ef5350">${this.esc(inst.error_message)}</div>` : ""}
<div class="detail-grid">
<div class="detail-item"><label>Domain</label><div class="value">${inst.domain}</div></div>
<div class="detail-item"><label>IP Address</label><div class="value">${inst.vps_ip || "—"}</div></div>
<div class="detail-item"><label>Region</label><div class="value">${inst.region}</div></div>
<div class="detail-item"><label>Server Size</label><div class="value">${inst.size}</div></div>
<div class="detail-item"><label>Admin Email</label><div class="value">${inst.admin_email || "—"}</div></div>
<div class="detail-item"><label>SSL</label><div class="value">${inst.ssl_provisioned ? "\u2705 Active" : "\u23F3 Pending"}</div></div>
</div>
<div class="logs-section">
<h3>Provision Log</h3>
${this.selectedLogs.length === 0 ? '<div style="color:#666;font-size:13px">No logs yet</div>' : ""}
${this.selectedLogs.map((log) => `
<div class="log-entry">
<span class="log-icon">${this.logStepIcon(log.status)}</span>
<div>
<div class="log-step">${this.formatStep(log.step)}</div>
<div class="log-msg">${this.esc(log.message || "")}</div>
</div>
</div>
`).join("")}
</div>
</div>
`;
}
private renderCreate(): string {
return `
<div class="toolbar">
<button class="secondary" data-action="back">\u2190 Back</button>
</div>
<div class="detail-panel">
<h2 style="margin:0 0 16px;font-size:18px">Deploy New Forum</h2>
<div class="pricing">
<div class="price-card selected" data-size="cx22">
<div class="price-name">Starter</div>
<div class="price-cost">\u20AC3.79/mo</div>
<div class="price-specs">2 vCPU &middot; 4 GB &middot; ~500 users</div>
</div>
<div class="price-card" data-size="cx32">
<div class="price-name">Standard</div>
<div class="price-cost">\u20AC6.80/mo</div>
<div class="price-specs">4 vCPU &middot; 8 GB &middot; ~2000 users</div>
</div>
<div class="price-card" data-size="cx42">
<div class="price-name">Performance</div>
<div class="price-cost">\u20AC13.80/mo</div>
<div class="price-specs">8 vCPU &middot; 16 GB &middot; ~10k users</div>
</div>
</div>
<form id="create-form">
<div class="form-row">
<div class="form-group">
<label>Forum Name</label>
<input name="name" placeholder="My Community" required>
</div>
<div class="form-group">
<label>Subdomain</label>
<div style="display:flex;align-items:center;gap:4px">
<input name="subdomain" placeholder="my-community" required style="flex:1">
<span style="font-size:12px;color:#888;white-space:nowrap">.rforum.online</span>
</div>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Admin Email</label>
<input name="admin_email" type="email" placeholder="admin@example.com" required>
</div>
<div class="form-group">
<label>Region</label>
<select name="region">
<option value="nbg1">Nuremberg (EU)</option>
<option value="fsn1">Falkenstein (EU)</option>
<option value="hel1">Helsinki (EU)</option>
<option value="ash">Ashburn (US East)</option>
<option value="hil">Hillsboro (US West)</option>
</select>
</div>
</div>
<input type="hidden" name="size" value="cx22">
<button type="submit" style="width:100%;padding:10px;font-size:14px;margin-top:8px">
Deploy Forum
</button>
</form>
</div>
`;
}
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<string, string> = {
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);

View File

@ -0,0 +1,6 @@
/* Forum module — dark theme */
folk-forum-dashboard {
display: block;
min-height: 400px;
padding: 20px;
}

View File

@ -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);

View File

@ -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
`;
}

53
modules/forum/lib/dns.ts Normal file
View File

@ -0,0 +1,53 @@
/**
* Cloudflare DNS management for forum subdomains.
*/
const CF_API = "https://api.cloudflare.com/client/v4";
function headers(): Record<string, string> {
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<boolean> {
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;
}

View File

@ -0,0 +1,80 @@
/**
* Hetzner Cloud API client for VPS provisioning.
*/
const HETZNER_API = "https://api.hetzner.cloud/v1";
function headers(): Record<string, string> {
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<HetznerServer | null> {
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<boolean> {
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<boolean> {
const res = await fetch(`${HETZNER_API}/servers/${serverId}/actions/${action}`, {
method: "POST",
headers: headers(),
});
return res.ok;
}

View File

@ -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<string, unknown> = {},
) {
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<string, unknown>) {
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() });
}

169
modules/forum/mod.ts Normal file
View File

@ -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<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 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<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 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: `<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,
};

View File

@ -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,
};

View File

@ -46,6 +46,8 @@ import { providersModule } from "../modules/providers/mod";
import { swagModule } from "../modules/swag/mod"; import { swagModule } from "../modules/swag/mod";
import { choicesModule } from "../modules/choices/mod"; import { choicesModule } from "../modules/choices/mod";
import { fundsModule } from "../modules/funds/mod"; import { fundsModule } from "../modules/funds/mod";
import { filesModule } from "../modules/files/mod";
import { forumModule } from "../modules/forum/mod";
import { spaces } from "./spaces"; import { spaces } from "./spaces";
import { renderShell } from "./shell"; import { renderShell } from "./shell";
@ -58,6 +60,8 @@ registerModule(providersModule);
registerModule(swagModule); registerModule(swagModule);
registerModule(choicesModule); registerModule(choicesModule);
registerModule(fundsModule); registerModule(fundsModule);
registerModule(filesModule);
registerModule(forumModule);
// ── Config ── // ── Config ──
const PORT = Number(process.env.PORT) || 3000; const PORT = Number(process.env.PORT) || 3000;

View File

@ -274,6 +274,60 @@ export default defineConfig({
resolve(__dirname, "modules/funds/components/funds.css"), resolve(__dirname, "modules/funds/components/funds.css"),
resolve(__dirname, "dist/modules/funds/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"),
);
}, },
}, },
}, },