rspace-online/modules/rfiles/mod.ts

668 lines
22 KiB
TypeScript

/**
* 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<string, PublicShare>;
accessLogs: Record<string, AccessLog>;
}
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<FilesDocExt>(docId);
if (!doc) {
doc = Automerge.change(Automerge.init<FilesDocExt>(), '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<FilesDocExt>(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<FilesDocExt>(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<FilesDocExt>(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<FilesDocExt>(docId);
if (!doc?.accessLogs) continue;
const toDelete = Object.values(doc.accessLogs).filter(
(l) => l.accessedAt < cutoff
);
if (toDelete.length > 0) {
_syncServer.changeDoc<FilesDocExt>(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<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");
}
/** Serialize a doc-sourced object for JSON responses (strip Automerge proxies). */
function toPlain<T>(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<FilesDocExt>(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<FilesDocExt>(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<FilesDocExt>(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<FilesDocExt>(docId, `revoke share ${shareId}`, (d) => {
d.shares[shareId].isActive = false;
});
const updated = _syncServer!.getDoc<FilesDocExt>(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<FilesDocExt>(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<FilesDocExt>(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<FilesDocExt>(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<FilesDocExt>(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<FilesDocExt>(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<FilesDocExt>(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<FilesDocExt>(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: `<div class="rapp-nav" style="padding:0 1rem;margin-top:8px"><span class="rapp-nav__title"></span><a href="?view=app" class="rapp-nav__btn--app-toggle">Open Full App</a></div>
<folk-file-browser space="${spaceSlug}"></folk-file-browser>`,
scripts: `<script type="module" src="/modules/rfiles/folk-file-browser.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/rfiles/files.css">`,
}));
});
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,
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" },
],
};