spb/drive Public
SPB Drive — self-hosted personal cloud drive (files, previews, sharing) on the MacLustr cluster.
JavaScript 82.7%
CSS 10.6%
Nunjucks 3.6%
Shell 1.8%
SQL 1.3%
1/**2 * ─────────────────────────────────────────────3 * SPB Drive — Personal Cloud Drive4 * ─────────────────────────────────────────────5 * Author : Simon-Pierre Boucher6 * Contact : contact@spboucher.ai7 * File : src/shares/requests.mjs8 * Purpose : File requests — public links that receive uploads into a folder9 * (base58 tokens, expiry, max-file quota, close/revoke)10 * License : MIT © Simon-Pierre Boucher11 * ─────────────────────────────────────────────12 */1314import { timingSafeEqual } from 'node:crypto';15import { getDb, logActivity } from '../db/db.mjs';16import { mustGetNode } from '../storage/nodes.mjs';17import { makeToken } from './shares.mjs';1819// 58^12 ≈ 1.4e21 ≈ 70 bits of entropy — stronger than shares since a20// request link WRITES into the drive.21const TOKEN_LEN = 12;2223/**24 * Create a file request pointing at a folder.25 * @param {{label?: string|null, expiresAt?: number|null, maxFiles?: number|null, ip?: string}} opts26 */27export function createRequest(folderId, opts = {}) {28 const folder = mustGetNode(folderId);29 if (folder.type !== 'folder' || folder.trashed_at) {30 throw Object.assign(new Error('Target must be a live folder'), { statusCode: 400 });31 }32 const token = makeToken(TOKEN_LEN);33 const info = getDb()34 .prepare(35 `INSERT INTO file_requests (token, folder_id, label, created, expires_at, max_files)36 VALUES (?, ?, ?, ?, ?, ?)`,37 )38 .run(token, folderId, opts.label ?? null, Date.now(), opts.expiresAt ?? null, opts.maxFiles ?? null);39 logActivity('request.create', { nodeId: folderId, detail: token, ip: opts.ip ?? '' });40 return getRequestById(Number(info.lastInsertRowid));41}4243export function getRequestById(id) {44 return getDb().prepare('SELECT * FROM file_requests WHERE id = ?').get(id) ?? null;45}4647/** Constant-time token lookup (row returned regardless of validity). */48export function getRequestByToken(token) {49 if (typeof token !== 'string' || token.length !== TOKEN_LEN) return null;50 const row = getDb().prepare('SELECT * FROM file_requests WHERE token = ?').get(token);51 if (!row) return null;52 const a = Buffer.from(row.token);53 const b = Buffer.from(token);54 if (a.length !== b.length || !timingSafeEqual(a, b)) return null;55 return row;56}5758/**59 * @returns {{ok: true} | {ok: false, reason: 'missing'|'closed'|'expired'|'full'|'gone'}}60 */61export function requestValidity(request) {62 if (!request) return { ok: false, reason: 'missing' };63 if (request.closed_at) return { ok: false, reason: 'closed' };64 if (request.expires_at && request.expires_at < Date.now()) return { ok: false, reason: 'expired' };65 if (request.max_files !== null && request.received >= request.max_files) {66 return { ok: false, reason: 'full' };67 }68 const folder = getDb().prepare('SELECT * FROM nodes WHERE id = ?').get(request.folder_id);69 if (!folder || folder.trashed_at) return { ok: false, reason: 'gone' };70 return { ok: true };71}7273/** Count one received file (called on completed public upload). */74export function recordReceived(requestId, { name = '', ip = '' } = {}) {75 const db = getDb();76 db.prepare('UPDATE file_requests SET received = received + 1 WHERE id = ?').run(requestId);77 const request = getRequestById(requestId);78 logActivity('request.upload', { nodeId: request?.folder_id, detail: name, ip });79}8081/** All requests with target-folder info, newest first (manager view). */82export function listRequests() {83 return getDb()84 .prepare(85 `SELECT r.*, n.name AS folder_name FROM file_requests r86 JOIN nodes n ON n.id = r.folder_id ORDER BY r.created DESC`,87 )88 .all();89}9091/** Close a request immediately (public link dies on the next call). */92export function closeRequest(id, { ip = '' } = {}) {93 const request = getRequestById(id);94 if (!request) return false;95 getDb().prepare('UPDATE file_requests SET closed_at = ? WHERE id = ?').run(Date.now(), id);96 logActivity('request.close', { nodeId: request.folder_id, detail: request.token, ip });97 return true;98}99100/** Delete closed/expired requests older than 90 days (daily job). */101export function pruneRequests() {102 const cutoff = Date.now() - 90 * 86_400_000;103 return getDb()104 .prepare(105 `DELETE FROM file_requests WHERE (closed_at IS NOT NULL AND closed_at < ?)106 OR (expires_at IS NOT NULL AND expires_at < ?)`,107 )108 .run(cutoff, cutoff).changes;109}110