707 lines
24 KiB
TypeScript
707 lines
24 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">`,
|
|
}));
|
|
});
|
|
|
|
// ── 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<FilesDocExt>(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" },
|
|
],
|
|
};
|