389 lines
15 KiB
TypeScript
389 lines
15 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";
|
|
import { verifyEncryptIDToken, extractToken } from "@encryptid/sdk/server";
|
|
|
|
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 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 tags = formData.get("tags")?.toString() || "[]";
|
|
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 [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 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 [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);
|
|
if (file.uploaded_by && file.uploaded_by !== 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 token = generateToken();
|
|
const expiresAt = body.expires_in_hours ? new Date(Date.now() + body.expires_in_hours * 3600_000).toISOString() : null;
|
|
const createdBy = claims.sub;
|
|
|
|
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 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 [share] = await sql.unsafe(
|
|
"SELECT s.*, f.uploaded_by FROM rfiles.public_shares s JOIN rfiles.media_files f ON s.media_file_id = f.id WHERE s.id = $1",
|
|
[c.req.param("shareId")]
|
|
);
|
|
if (!share) return c.json({ error: "Share not found" }, 404);
|
|
if (share.uploaded_by && share.uploaded_by !== claims.sub) return c.json({ error: "Not authorized" }, 403);
|
|
|
|
const [revoked] = await sql.unsafe(
|
|
"UPDATE rfiles.public_shares SET is_active = FALSE WHERE id = $1 RETURNING *",
|
|
[c.req.param("shareId")]
|
|
);
|
|
return c.json({ message: "Revoked", share: revoked });
|
|
});
|
|
|
|
// ── 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 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 [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,
|
|
standaloneDomain: "rfiles.online",
|
|
};
|