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:
parent
92edaaed45
commit
682c995cc3
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
/* Files module — dark theme */
|
||||
folk-file-browser {
|
||||
display: block;
|
||||
min-height: 400px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
|
@ -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)} · ${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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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} · ${inst.region} · ${inst.size}
|
||||
${inst.vps_ip ? ` · ${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 · 4 GB · ~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 · 8 GB · ~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 · 16 GB · ~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);
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
/* Forum module — dark theme */
|
||||
folk-forum-dashboard {
|
||||
display: block;
|
||||
min-height: 400px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
@ -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
|
||||
`;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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() });
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
);
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in New Issue