/** * Files module — file sharing, public share links, memory cards. * Ported from rfiles-online (Django → Bun/Hono). * * All metadata is stored in Automerge documents via SyncServer. * Binary files remain on the filesystem. */ import { Hono } from "hono"; import { resolve } from "node:path"; import { mkdir, writeFile, unlink } from "node:fs/promises"; import { createHash, randomBytes } from "node:crypto"; import * as Automerge from "@automerge/automerge"; import { renderShell, renderExternalAppShell } from "../../server/shell"; import { getModuleInfoList } from "../../shared/module"; import type { RSpaceModule } from "../../shared/module"; import { verifyEncryptIDToken, extractToken } from "@encryptid/sdk/server"; import { renderLanding } from "./landing"; import type { SyncServer } from '../../server/local-first/sync-server'; import { filesSchema, filesDocId } from './schemas'; import type { FilesDoc, MediaFile, MemoryCard } from './schemas'; // ── Extended doc types (shares + access logs live alongside files/cards) ── interface PublicShare { id: string; token: string; mediaFileId: string; createdBy: string | null; expiresAt: number | null; // epoch ms, null = never maxDownloads: number | null; downloadCount: number; isActive: boolean; isPasswordProtected: boolean; passwordHash: string | null; note: string | null; createdAt: number; } interface AccessLog { id: string; mediaFileId: string; shareId: string | null; ipAddress: string | null; userAgent: string | null; accessType: string; accessedAt: number; } /** * Extended doc shape — supplements FilesDoc with shares and access logs. * The base FilesDoc from schemas.ts defines files + memoryCards; * we add shares and accessLogs as additional top-level maps. */ interface FilesDocExt extends FilesDoc { shares: Record; accessLogs: Record; } let _syncServer: SyncServer | null = null; const routes = new Hono(); const FILES_DIR = process.env.FILES_DIR || "/data/files"; // ── Automerge document helpers ── function ensureDoc(space: string, sharedSpace: string = "default"): FilesDocExt { const docId = filesDocId(space, sharedSpace); let doc = _syncServer!.getDoc(docId); if (!doc) { doc = Automerge.change(Automerge.init(), 'init files doc', (d) => { const init = filesSchema.init(); d.meta = init.meta; d.meta.spaceSlug = space; d.meta.sharedSpace = sharedSpace; d.files = {}; d.memoryCards = {}; d.shares = {}; d.accessLogs = {}; }); _syncServer!.setDoc(docId, doc); } // Ensure shares/accessLogs exist on legacy docs that predate these fields if (!doc.shares || !doc.accessLogs) { doc = _syncServer!.changeDoc(docId, 'add shares+logs maps', (d) => { if (!(d as any).shares) (d as any).shares = {}; if (!(d as any).accessLogs) (d as any).accessLogs = {}; })!; } return doc; } // ── Cleanup timers (replace Celery) ── // Deactivate expired shares every hour setInterval(() => { if (!_syncServer) return; try { const now = Date.now(); for (const docId of _syncServer.getDocIds()) { if (!docId.includes(':files:cards:')) continue; const doc = _syncServer.getDoc(docId); if (!doc?.shares) continue; const toDeactivate = Object.values(doc.shares).filter( (s) => s.isActive && s.expiresAt !== null && s.expiresAt < now ); if (toDeactivate.length > 0) { _syncServer.changeDoc(docId, 'deactivate expired shares', (d) => { for (const s of toDeactivate) { if (d.shares[s.id]) d.shares[s.id].isActive = false; } }); console.log(`[Files] Deactivated ${toDeactivate.length} expired shares in ${docId}`); } } } catch (e: any) { console.error("[Files] Cleanup error:", e.message); } }, 3600_000); // Delete access logs older than 90 days, daily setInterval(() => { if (!_syncServer) return; try { const cutoff = Date.now() - 90 * 86400_000; for (const docId of _syncServer.getDocIds()) { if (!docId.includes(':files:cards:')) continue; const doc = _syncServer.getDoc(docId); if (!doc?.accessLogs) continue; const toDelete = Object.values(doc.accessLogs).filter( (l) => l.accessedAt < cutoff ); if (toDelete.length > 0) { _syncServer.changeDoc(docId, 'prune old access logs', (d) => { for (const l of toDelete) { delete d.accessLogs[l.id]; } }); } } } 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"); } /** Serialize a doc-sourced object for JSON responses (strip Automerge proxies). */ function toPlain(obj: T): T { return JSON.parse(JSON.stringify(obj)); } // ── File upload ── routes.post("/api/files", async (c) => { const token = extractToken(c.req.raw.headers); if (!token) return c.json({ error: "Authentication required" }, 401); let claims; try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } const 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 tagsRaw = formData.get("tags")?.toString() || "[]"; let tags: string[] = []; try { tags = JSON.parse(tagsRaw); } catch { tags = []; } const uploadedBy = claims.sub; 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 docId = filesDocId(space, space); ensureDoc(space, space); const mediaFile: MediaFile = { id: fileId, originalFilename: file.name, title, description, mimeType: file.type || "application/octet-stream", fileSize: file.size, fileHash, storagePath, tags, isProcessed: false, processingError: null, uploadedBy, sharedSpace: space, createdAt: Date.now(), updatedAt: Date.now(), }; _syncServer!.changeDoc(docId, `upload file ${fileId}`, (d) => { d.files[fileId] = mediaFile; }); return c.json({ file: toPlain(mediaFile) }, 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; const doc = ensureDoc(space, space); let files = Object.values(doc.files) .filter((f) => f.sharedSpace === space); if (mimeType) { files = files.filter((f) => f.mimeType && f.mimeType.startsWith(mimeType)); } // Sort by createdAt descending files.sort((a, b) => b.createdAt - a.createdAt); const total = files.length; const paged = files.slice(offset, offset + limit); return c.json({ files: toPlain(paged), total, limit, offset }); }); // ── File download ── routes.get("/api/files/:id/download", async (c) => { const fileId = c.req.param("id"); const space = c.req.param("space") || c.req.query("space") || "default"; const doc = ensureDoc(space, space); const file = doc.files[fileId]; if (!file) return c.json({ error: "File not found" }, 404); const fullPath = resolve(FILES_DIR, file.storagePath); 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.mimeType || "application/octet-stream", "Content-Disposition": `attachment; filename="${file.originalFilename}"`, "Content-Length": String(file.fileSize), }, }); }); // ── File detail ── routes.get("/api/files/:id", async (c) => { const fileId = c.req.param("id"); const space = c.req.param("space") || c.req.query("space") || "default"; const doc = ensureDoc(space, space); const file = doc.files[fileId]; if (!file) return c.json({ error: "File not found" }, 404); return c.json({ file: toPlain(file) }); }); // ── File delete ── routes.delete("/api/files/:id", async (c) => { const fileId = c.req.param("id"); const space = c.req.param("space") || c.req.query("space") || "default"; const docId = filesDocId(space, space); const doc = ensureDoc(space, space); const file = doc.files[fileId]; if (!file) return c.json({ error: "File not found" }, 404); try { await unlink(resolve(FILES_DIR, file.storagePath)); } catch {} _syncServer!.changeDoc(docId, `delete file ${fileId}`, (d) => { delete d.files[fileId]; // Also remove any shares referencing this file for (const [sid, share] of Object.entries(d.shares)) { if (share.mediaFileId === fileId) delete d.shares[sid]; } }); return c.json({ message: "Deleted" }); }); // ── Create share link ── routes.post("/api/files/:id/share", async (c) => { const authToken = extractToken(c.req.raw.headers); if (!authToken) return c.json({ error: "Authentication required" }, 401); let claims; try { claims = await verifyEncryptIDToken(authToken); } catch { return c.json({ error: "Invalid token" }, 401); } const fileId = c.req.param("id"); const space = c.req.param("space") || c.req.query("space") || "default"; const docId = filesDocId(space, space); const doc = ensureDoc(space, space); const file = doc.files[fileId]; if (!file) return c.json({ error: "File not found" }, 404); if (file.uploadedBy && file.uploadedBy !== claims.sub) return c.json({ error: "Not authorized" }, 403); const body = await c.req.json<{ expires_in_hours?: number; max_downloads?: number; password?: string; note?: string }>(); const shareToken = generateToken(); const expiresAt = body.expires_in_hours ? Date.now() + body.expires_in_hours * 3600_000 : null; const createdBy = claims.sub; let passwordHash: string | null = null; let isPasswordProtected = false; if (body.password) { passwordHash = await hashPassword(body.password); isPasswordProtected = true; } const shareId = crypto.randomUUID(); const logId = crypto.randomUUID(); const now = Date.now(); const share: PublicShare = { id: shareId, token: shareToken, mediaFileId: fileId, createdBy, expiresAt, maxDownloads: body.max_downloads || null, downloadCount: 0, isActive: true, isPasswordProtected, passwordHash, note: body.note || null, createdAt: now, }; _syncServer!.changeDoc(docId, `create share for file ${fileId}`, (d) => { d.shares[shareId] = share; d.accessLogs[logId] = { id: logId, mediaFileId: fileId, shareId, ipAddress: null, userAgent: null, accessType: 'share_created', accessedAt: now, }; }); return c.json({ share: { ...toPlain(share), url: `/s/${shareToken}` } }, 201); }); // ── List shares for a file ── routes.get("/api/files/:id/shares", async (c) => { const fileId = c.req.param("id"); const space = c.req.param("space") || c.req.query("space") || "default"; const doc = ensureDoc(space, space); const shares = Object.values(doc.shares) .filter((s) => s.mediaFileId === fileId) .sort((a, b) => b.createdAt - a.createdAt); return c.json({ shares: toPlain(shares) }); }); // ── Revoke share ── routes.post("/api/shares/:shareId/revoke", async (c) => { const authToken = extractToken(c.req.raw.headers); if (!authToken) return c.json({ error: "Authentication required" }, 401); let claims; try { claims = await verifyEncryptIDToken(authToken); } catch { return c.json({ error: "Invalid token" }, 401); } const shareId = c.req.param("shareId"); const space = c.req.param("space") || c.req.query("space") || "default"; const docId = filesDocId(space, space); const doc = ensureDoc(space, space); const share = doc.shares[shareId]; if (!share) return c.json({ error: "Share not found" }, 404); // Check authorization via the linked file const file = doc.files[share.mediaFileId]; if (file?.uploadedBy && file.uploadedBy !== claims.sub) return c.json({ error: "Not authorized" }, 403); _syncServer!.changeDoc(docId, `revoke share ${shareId}`, (d) => { d.shares[shareId].isActive = false; }); const updated = _syncServer!.getDoc(docId)!; return c.json({ message: "Revoked", share: toPlain(updated.shares[shareId]) }); }); // ── Public share download ── routes.get("/s/:token", async (c) => { const shareToken = c.req.param("token"); // Find the share across all files docs let foundDocId: string | null = null; let foundShare: PublicShare | null = null; let foundFile: MediaFile | null = null; for (const docId of _syncServer!.getDocIds()) { if (!docId.includes(':files:cards:')) continue; const doc = _syncServer!.getDoc(docId); if (!doc?.shares) continue; for (const s of Object.values(doc.shares)) { if (s.token === shareToken) { foundDocId = docId; foundShare = s; foundFile = doc.files[s.mediaFileId] || null; break; } } if (foundShare) break; } if (!foundShare || !foundFile) return c.json({ error: "Share not found" }, 404); if (!foundShare.isActive) return c.json({ error: "Share has been revoked" }, 410); if (foundShare.expiresAt && foundShare.expiresAt < Date.now()) return c.json({ error: "Share has expired" }, 410); if (foundShare.maxDownloads && foundShare.downloadCount >= foundShare.maxDownloads) return c.json({ error: "Download limit reached" }, 410); if (foundShare.isPasswordProtected) { 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 !== foundShare.passwordHash) return c.json({ error: "Invalid password" }, 401); } const logId = crypto.randomUUID(); 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") || ""; _syncServer!.changeDoc(foundDocId!, `download via share ${foundShare.id}`, (d) => { d.shares[foundShare!.id].downloadCount += 1; d.accessLogs[logId] = { id: logId, mediaFileId: foundShare!.mediaFileId, shareId: foundShare!.id, ipAddress: ip, userAgent: ua.slice(0, 500), accessType: 'download', accessedAt: Date.now(), }; }); const fullPath = resolve(FILES_DIR, foundFile.storagePath); const bunFile = Bun.file(fullPath); if (!await bunFile.exists()) return c.json({ error: "File missing" }, 404); return new Response(bunFile, { headers: { "Content-Type": foundFile.mimeType || "application/octet-stream", "Content-Disposition": `attachment; filename="${foundFile.originalFilename}"`, "Content-Length": String(foundFile.fileSize), }, }); }); // ── Share info (public) ── routes.get("/s/:token/info", async (c) => { const shareToken = c.req.param("token"); let foundShare: PublicShare | null = null; let foundFile: MediaFile | null = null; for (const docId of _syncServer!.getDocIds()) { if (!docId.includes(':files:cards:')) continue; const doc = _syncServer!.getDoc(docId); if (!doc?.shares) continue; for (const s of Object.values(doc.shares)) { if (s.token === shareToken) { foundShare = s; foundFile = doc.files[s.mediaFileId] || null; break; } } if (foundShare) break; } if (!foundShare || !foundFile) return c.json({ error: "Share not found" }, 404); const isValid = foundShare.isActive && (!foundShare.expiresAt || foundShare.expiresAt > Date.now()) && (!foundShare.maxDownloads || foundShare.downloadCount < foundShare.maxDownloads); return c.json({ is_password_protected: foundShare.isPasswordProtected, is_valid: isValid, expires_at: foundShare.expiresAt ? new Date(foundShare.expiresAt).toISOString() : null, downloads_remaining: foundShare.maxDownloads ? foundShare.maxDownloads - foundShare.downloadCount : null, file_info: { filename: foundFile.originalFilename, mime_type: foundFile.mimeType, size: foundFile.fileSize }, note: foundShare.note, }); }); // ── Memory Cards CRUD ── routes.post("/api/cards", async (c) => { const authToken = extractToken(c.req.raw.headers); if (!authToken) return c.json({ error: "Authentication required" }, 401); let claims; try { claims = await verifyEncryptIDToken(authToken); } catch { return c.json({ error: "Invalid token" }, 401); } 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 = claims.sub; const docId = filesDocId(space, space); ensureDoc(space, space); const cardId = crypto.randomUUID(); const now = Date.now(); const card: MemoryCard = { id: cardId, sharedSpace: space, title: body.title, body: body.body || "", cardType: body.card_type || "note", tags: body.tags || [], position: 0, createdBy, createdAt: now, updatedAt: now, }; _syncServer!.changeDoc(docId, `create card ${cardId}`, (d) => { d.memoryCards[cardId] = card; }); return c.json({ card: toPlain(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); const doc = ensureDoc(space, space); let cards = Object.values(doc.memoryCards) .filter((card) => card.sharedSpace === space); if (cardType) { cards = cards.filter((card) => card.cardType === cardType); } // Sort by position ascending, then createdAt descending cards.sort((a, b) => a.position - b.position || b.createdAt - a.createdAt); cards = cards.slice(0, limit); return c.json({ cards: toPlain(cards), total: cards.length }); }); routes.patch("/api/cards/:id", async (c) => { const cardId = c.req.param("id"); const space = c.req.param("space") || c.req.query("space") || "default"; const docId = filesDocId(space, space); const doc = ensureDoc(space, space); const card = doc.memoryCards[cardId]; if (!card) return c.json({ error: "Card not found" }, 404); const body = await c.req.json<{ title?: string; body?: string; card_type?: string; tags?: string[]; position?: number }>(); if (body.title === undefined && body.body === undefined && body.card_type === undefined && body.tags === undefined && body.position === undefined) { return c.json({ error: "No fields to update" }, 400); } _syncServer!.changeDoc(docId, `update card ${cardId}`, (d) => { const c = d.memoryCards[cardId]; if (body.title !== undefined) c.title = body.title; if (body.body !== undefined) c.body = body.body; if (body.card_type !== undefined) c.cardType = body.card_type; if (body.tags !== undefined) c.tags = body.tags; if (body.position !== undefined) c.position = body.position; c.updatedAt = Date.now(); }); const updated = _syncServer!.getDoc(docId)!; return c.json({ card: toPlain(updated.memoryCards[cardId]) }); }); routes.delete("/api/cards/:id", async (c) => { const cardId = c.req.param("id"); const space = c.req.param("space") || c.req.query("space") || "default"; const docId = filesDocId(space, space); const doc = ensureDoc(space, space); if (!doc.memoryCards[cardId]) return c.json({ error: "Card not found" }, 404); _syncServer!.changeDoc(docId, `delete card ${cardId}`, (d) => { delete d.memoryCards[cardId]; }); return c.json({ message: "Deleted" }); }); // ── Page route ── routes.get("/", (c) => { const spaceSlug = c.req.param("space") || "demo"; const view = c.req.query("view"); if (view === "app") { return c.html(renderExternalAppShell({ title: `${spaceSlug} — Seafile | rSpace`, moduleId: "rfiles", spaceSlug, modules: getModuleInfoList(), appUrl: "https://files.rfiles.online", appName: "Seafile", theme: "dark", })); } return c.html(renderShell({ title: `${spaceSlug} — Files | rSpace`, moduleId: "rfiles", spaceSlug, modules: getModuleInfoList(), theme: "dark", body: ` `, scripts: ``, styles: ``, })); }); // ── Seed template data ── function seedTemplateFiles(space: string) { if (!_syncServer) return; const doc = ensureDoc(space, 'default'); if (Object.keys(doc.files).length > 0 || Object.keys(doc.memoryCards).length > 0) return; const docId = filesDocId(space, 'default'); const now = Date.now(); _syncServer.changeDoc(docId, 'seed template files', (d) => { const files: Array<{ name: string; title: string; mime: string; size: number; tags: string[] }> = [ { name: 'project-charter.pdf', title: 'Project Charter', mime: 'application/pdf', size: 245000, tags: ['governance', 'founding'] }, { name: 'logo.svg', title: 'Community Logo', mime: 'image/svg+xml', size: 12400, tags: ['branding', 'design'] }, { name: 'budget-2026.xlsx', title: 'Budget 2026', mime: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', size: 89000, tags: ['finance', 'planning'] }, ]; for (const f of files) { const id = crypto.randomUUID(); d.files[id] = { id, originalFilename: f.name, title: f.title, description: '', mimeType: f.mime, fileSize: f.size, fileHash: null, storagePath: '', tags: f.tags, isProcessed: true, processingError: null, uploadedBy: 'did:demo:seed', sharedSpace: 'default', createdAt: now, updatedAt: now, }; } const mcId = crypto.randomUUID(); d.memoryCards[mcId] = { id: mcId, sharedSpace: 'default', title: 'Welcome', body: 'This is your shared file space. Upload documents, images, and other files to collaborate with your community.', cardType: 'note', tags: ['onboarding'], position: 0, createdBy: 'did:demo:seed', createdAt: now, updatedAt: now, }; }); console.log(`[Files] Template seeded for "${space}": 3 files, 1 memory card`); } export const filesModule: RSpaceModule = { id: "rfiles", name: "rFiles", icon: "📁", description: "File sharing, share links, and memory cards", scoping: { defaultScope: 'space', userConfigurable: false }, docSchemas: [{ pattern: '{space}:files:cards:{sharedSpace}', description: 'Files and memory cards', init: filesSchema.init }], routes, landingPage: renderLanding, seedTemplate: seedTemplateFiles, async onInit(ctx) { _syncServer = ctx.syncServer; }, standaloneDomain: "rfiles.online", externalApp: { url: "https://files.rfiles.online", name: "Seafile" }, feeds: [ { id: "file-activity", name: "File Activity", kind: "data", description: "Upload, download, and share events across the file library", filterable: true, }, { id: "shared-files", name: "Shared Files", kind: "resource", description: "Files available via public share links", }, ], acceptsFeeds: ["data", "resource"], outputPaths: [ { path: "files", name: "Files", icon: "📁", description: "Uploaded files and documents" }, { path: "shares", name: "Shares", icon: "🔗", description: "Public share links" }, ], };