/** * Files module — file sharing, public share links, memory cards. * Ported from rfiles-online (Django → Bun/Hono). */ import { Hono } from "hono"; import { readFileSync } from "node:fs"; import { resolve } from "node:path"; import { mkdir, writeFile, unlink } from "node:fs/promises"; import { createHash, randomBytes } from "node:crypto"; import { sql } from "../../shared/db/pool"; import { renderShell } from "../../server/shell"; import { getModuleInfoList } from "../../shared/module"; import type { RSpaceModule } from "../../shared/module"; const routes = new Hono(); const FILES_DIR = process.env.FILES_DIR || "/data/files"; const SCHEMA_SQL = readFileSync(resolve(import.meta.dir, "db/schema.sql"), "utf-8"); // ── DB initialization ── async function initDB() { try { await sql.unsafe(SCHEMA_SQL); console.log("[Files] DB schema initialized"); } catch (e: any) { console.error("[Files] DB init error:", e.message); } } initDB(); // ── Cleanup timers (replace Celery) ── // Deactivate expired shares every hour setInterval(async () => { try { const result = await sql.unsafe( "UPDATE rfiles.public_shares SET is_active = FALSE WHERE is_active = TRUE AND expires_at IS NOT NULL AND expires_at < NOW()" ); if ((result as any).count > 0) console.log(`[Files] Deactivated ${(result as any).count} expired shares`); } catch (e: any) { console.error("[Files] Cleanup error:", e.message); } }, 3600_000); // Delete access logs older than 90 days, daily setInterval(async () => { try { await sql.unsafe("DELETE FROM rfiles.access_logs WHERE accessed_at < NOW() - INTERVAL '90 days'"); } catch (e: any) { console.error("[Files] Log cleanup error:", e.message); } }, 86400_000); // ── Helpers ── function generateToken(): string { return randomBytes(24).toString("base64url"); } async function hashPassword(pw: string): Promise { const hasher = new Bun.CryptoHasher("sha256"); hasher.update(pw + "rfiles-salt"); return hasher.digest("hex"); } async function computeFileHash(buffer: ArrayBuffer): Promise { const hash = createHash("sha256"); hash.update(Buffer.from(buffer)); return hash.digest("hex"); } // ── File upload ── routes.post("/api/files", async (c) => { const formData = await c.req.formData(); const file = formData.get("file") as File | null; if (!file) return c.json({ error: "file is required" }, 400); const space = c.req.param("space") || formData.get("space")?.toString() || "default"; const title = formData.get("title")?.toString() || file.name.replace(/\.[^.]+$/, ""); const description = formData.get("description")?.toString() || ""; const tags = formData.get("tags")?.toString() || "[]"; const uploadedBy = c.req.header("X-User-DID") || ""; const buffer = await file.arrayBuffer(); const fileHash = await computeFileHash(buffer); const now = new Date(); const datePath = `${now.getFullYear()}/${String(now.getMonth() + 1).padStart(2, "0")}/${String(now.getDate()).padStart(2, "0")}`; const fileId = crypto.randomUUID(); const storagePath = `uploads/${datePath}/${fileId}/${file.name}`; const fullPath = resolve(FILES_DIR, storagePath); await mkdir(resolve(fullPath, ".."), { recursive: true }); await writeFile(fullPath, Buffer.from(buffer)); const [row] = await sql.unsafe( `INSERT INTO rfiles.media_files (original_filename, title, description, mime_type, file_size, file_hash, storage_path, tags, uploaded_by, shared_space) VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9, $10) RETURNING *`, [file.name, title, description, file.type || "application/octet-stream", file.size, fileHash, storagePath, tags, uploadedBy, space] ); return c.json({ file: row }, 201); }); // ── File listing ── routes.get("/api/files", async (c) => { const space = c.req.param("space") || c.req.query("space") || "default"; const mimeType = c.req.query("mime_type"); const limit = Math.min(Number(c.req.query("limit")) || 50, 200); const offset = Number(c.req.query("offset")) || 0; let query = "SELECT * FROM rfiles.media_files WHERE shared_space = $1"; const params: any[] = [space]; let paramIdx = 2; if (mimeType) { query += ` AND mime_type LIKE $${paramIdx}`; params.push(`${mimeType}%`); paramIdx++; } query += ` ORDER BY created_at DESC LIMIT $${paramIdx} OFFSET $${paramIdx + 1}`; params.push(limit, offset); const rows = await sql.unsafe(query, params); const [{ count }] = await sql.unsafe( "SELECT COUNT(*) as count FROM rfiles.media_files WHERE shared_space = $1", [space] ); return c.json({ files: rows, total: Number(count), limit, offset }); }); // ── File download ── routes.get("/api/files/:id/download", async (c) => { const [file] = await sql.unsafe("SELECT * FROM rfiles.media_files WHERE id = $1", [c.req.param("id")]); if (!file) return c.json({ error: "File not found" }, 404); const fullPath = resolve(FILES_DIR, file.storage_path); const bunFile = Bun.file(fullPath); if (!await bunFile.exists()) return c.json({ error: "File missing from storage" }, 404); return new Response(bunFile, { headers: { "Content-Type": file.mime_type || "application/octet-stream", "Content-Disposition": `attachment; filename="${file.original_filename}"`, "Content-Length": String(file.file_size), }, }); }); // ── File detail ── routes.get("/api/files/:id", async (c) => { const [file] = await sql.unsafe("SELECT * FROM rfiles.media_files WHERE id = $1", [c.req.param("id")]); if (!file) return c.json({ error: "File not found" }, 404); return c.json({ file }); }); // ── File delete ── routes.delete("/api/files/:id", async (c) => { const [file] = await sql.unsafe("SELECT * FROM rfiles.media_files WHERE id = $1", [c.req.param("id")]); if (!file) return c.json({ error: "File not found" }, 404); try { await unlink(resolve(FILES_DIR, file.storage_path)); } catch {} await sql.unsafe("DELETE FROM rfiles.media_files WHERE id = $1", [c.req.param("id")]); return c.json({ message: "Deleted" }); }); // ── Create share link ── routes.post("/api/files/:id/share", async (c) => { const [file] = await sql.unsafe("SELECT * FROM rfiles.media_files WHERE id = $1", [c.req.param("id")]); if (!file) return c.json({ error: "File not found" }, 404); const body = await c.req.json<{ expires_in_hours?: number; max_downloads?: number; password?: string; note?: string }>(); const token = generateToken(); const expiresAt = body.expires_in_hours ? new Date(Date.now() + body.expires_in_hours * 3600_000).toISOString() : null; const createdBy = c.req.header("X-User-DID") || ""; let passwordHash: string | null = null; let isPasswordProtected = false; if (body.password) { passwordHash = await hashPassword(body.password); isPasswordProtected = true; } const [share] = await sql.unsafe( `INSERT INTO rfiles.public_shares (token, media_file_id, created_by, expires_at, max_downloads, is_password_protected, password_hash, note) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *`, [token, file.id, createdBy, expiresAt, body.max_downloads || null, isPasswordProtected, passwordHash, body.note || null] ); await sql.unsafe( "INSERT INTO rfiles.access_logs (media_file_id, share_id, access_type) VALUES ($1, $2, 'share_created')", [file.id, share.id] ); return c.json({ share: { ...share, url: `/s/${token}` } }, 201); }); // ── List shares for a file ── routes.get("/api/files/:id/shares", async (c) => { const rows = await sql.unsafe( "SELECT * FROM rfiles.public_shares WHERE media_file_id = $1 ORDER BY created_at DESC", [c.req.param("id")] ); return c.json({ shares: rows }); }); // ── Revoke share ── routes.post("/api/shares/:shareId/revoke", async (c) => { const [share] = await sql.unsafe( "UPDATE rfiles.public_shares SET is_active = FALSE WHERE id = $1 RETURNING *", [c.req.param("shareId")] ); if (!share) return c.json({ error: "Share not found" }, 404); return c.json({ message: "Revoked", share }); }); // ── Public share download ── routes.get("/s/:token", async (c) => { const [share] = await sql.unsafe( `SELECT s.*, f.storage_path, f.mime_type, f.original_filename, f.file_size FROM rfiles.public_shares s JOIN rfiles.media_files f ON s.media_file_id = f.id WHERE s.token = $1`, [c.req.param("token")] ); if (!share) return c.json({ error: "Share not found" }, 404); if (!share.is_active) return c.json({ error: "Share has been revoked" }, 410); if (share.expires_at && new Date(share.expires_at) < new Date()) return c.json({ error: "Share has expired" }, 410); if (share.max_downloads && share.download_count >= share.max_downloads) return c.json({ error: "Download limit reached" }, 410); if (share.is_password_protected) { const pw = c.req.query("password"); if (!pw) return c.json({ error: "Password required", is_password_protected: true }, 401); const hash = await hashPassword(pw); if (hash !== share.password_hash) return c.json({ error: "Invalid password" }, 401); } await sql.unsafe("UPDATE rfiles.public_shares SET download_count = download_count + 1 WHERE id = $1", [share.id]); const ip = c.req.header("X-Forwarded-For")?.split(",")[0]?.trim() || c.req.header("X-Real-IP") || null; const ua = c.req.header("User-Agent") || ""; await sql.unsafe( "INSERT INTO rfiles.access_logs (media_file_id, share_id, ip_address, user_agent, access_type) VALUES ($1, $2, $3, $4, 'download')", [share.media_file_id, share.id, ip, ua.slice(0, 500)] ); const fullPath = resolve(FILES_DIR, share.storage_path); const bunFile = Bun.file(fullPath); if (!await bunFile.exists()) return c.json({ error: "File missing" }, 404); return new Response(bunFile, { headers: { "Content-Type": share.mime_type || "application/octet-stream", "Content-Disposition": `attachment; filename="${share.original_filename}"`, "Content-Length": String(share.file_size), }, }); }); // ── Share info (public) ── routes.get("/s/:token/info", async (c) => { const [share] = await sql.unsafe( `SELECT s.is_password_protected, s.is_active, s.expires_at, s.max_downloads, s.download_count, s.note, f.original_filename, f.mime_type, f.file_size FROM rfiles.public_shares s JOIN rfiles.media_files f ON s.media_file_id = f.id WHERE s.token = $1`, [c.req.param("token")] ); if (!share) return c.json({ error: "Share not found" }, 404); const isValid = share.is_active && (!share.expires_at || new Date(share.expires_at) > new Date()) && (!share.max_downloads || share.download_count < share.max_downloads); return c.json({ is_password_protected: share.is_password_protected, is_valid: isValid, expires_at: share.expires_at, downloads_remaining: share.max_downloads ? share.max_downloads - share.download_count : null, file_info: { filename: share.original_filename, mime_type: share.mime_type, size: share.file_size }, note: share.note, }); }); // ── Memory Cards CRUD ── routes.post("/api/cards", async (c) => { const body = await c.req.json<{ title: string; body?: string; card_type?: string; tags?: string[]; shared_space?: string }>(); const space = c.req.param("space") || body.shared_space || "default"; const createdBy = c.req.header("X-User-DID") || ""; const [card] = await sql.unsafe( `INSERT INTO rfiles.memory_cards (shared_space, title, body, card_type, tags, created_by) VALUES ($1, $2, $3, $4, $5::jsonb, $6) RETURNING *`, [space, body.title, body.body || "", body.card_type || "note", JSON.stringify(body.tags || []), createdBy] ); return c.json({ card }, 201); }); routes.get("/api/cards", async (c) => { const space = c.req.param("space") || c.req.query("space") || "default"; const cardType = c.req.query("type"); const limit = Math.min(Number(c.req.query("limit")) || 50, 200); let query = "SELECT * FROM rfiles.memory_cards WHERE shared_space = $1"; const params: any[] = [space]; if (cardType) { query += " AND card_type = $2"; params.push(cardType); } query += " ORDER BY position, created_at DESC LIMIT $" + (params.length + 1); params.push(limit); const rows = await sql.unsafe(query, params); return c.json({ cards: rows, total: rows.length }); }); routes.patch("/api/cards/:id", async (c) => { const body = await c.req.json<{ title?: string; body?: string; card_type?: string; tags?: string[]; position?: number }>(); const sets: string[] = []; const params: any[] = []; let idx = 1; if (body.title !== undefined) { sets.push(`title = $${idx}`); params.push(body.title); idx++; } if (body.body !== undefined) { sets.push(`body = $${idx}`); params.push(body.body); idx++; } if (body.card_type !== undefined) { sets.push(`card_type = $${idx}`); params.push(body.card_type); idx++; } if (body.tags !== undefined) { sets.push(`tags = $${idx}::jsonb`); params.push(JSON.stringify(body.tags)); idx++; } if (body.position !== undefined) { sets.push(`position = $${idx}`); params.push(body.position); idx++; } if (sets.length === 0) return c.json({ error: "No fields to update" }, 400); sets.push(`updated_at = NOW()`); params.push(c.req.param("id")); const [card] = await sql.unsafe( `UPDATE rfiles.memory_cards SET ${sets.join(", ")} WHERE id = $${idx} RETURNING *`, params ); if (!card) return c.json({ error: "Card not found" }, 404); return c.json({ card }); }); routes.delete("/api/cards/:id", async (c) => { const [card] = await sql.unsafe("DELETE FROM rfiles.memory_cards WHERE id = $1 RETURNING id", [c.req.param("id")]); if (!card) return c.json({ error: "Card not found" }, 404); return c.json({ message: "Deleted" }); }); // ── Page route ── routes.get("/", (c) => { const spaceSlug = c.req.param("space") || "demo"; return c.html(renderShell({ title: `${spaceSlug} — Files | rSpace`, moduleId: "files", spaceSlug, modules: getModuleInfoList(), theme: "light", styles: ``, body: ``, scripts: ``, })); }); export const filesModule: RSpaceModule = { id: "files", name: "rFiles", icon: "\uD83D\uDCC1", description: "File sharing, share links, and memory cards", routes, standaloneDomain: "rfiles.online", };