rspace-online/modules/files/mod.ts

360 lines
14 KiB
TypeScript

/**
* 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,
};