/** * ───────────────────────────────────────────── * SPB Drive — Personal Cloud Drive * ───────────────────────────────────────────── * Author : Simon-Pierre Boucher * Contact : contact@spboucher.ai * File : src/shares/requests.mjs * Purpose : File requests — public links that receive uploads into a folder * (base58 tokens, expiry, max-file quota, close/revoke) * License : MIT © Simon-Pierre Boucher * ───────────────────────────────────────────── */ import { timingSafeEqual } from 'node:crypto'; import { getDb, logActivity } from '../db/db.mjs'; import { mustGetNode } from '../storage/nodes.mjs'; import { makeToken } from './shares.mjs'; // 58^12 ≈ 1.4e21 ≈ 70 bits of entropy — stronger than shares since a // request link WRITES into the drive. const TOKEN_LEN = 12; /** * Create a file request pointing at a folder. * @param {{label?: string|null, expiresAt?: number|null, maxFiles?: number|null, ip?: string}} opts */ export function createRequest(folderId, opts = {}) { const folder = mustGetNode(folderId); if (folder.type !== 'folder' || folder.trashed_at) { throw Object.assign(new Error('Target must be a live folder'), { statusCode: 400 }); } const token = makeToken(TOKEN_LEN); const info = getDb() .prepare( `INSERT INTO file_requests (token, folder_id, label, created, expires_at, max_files) VALUES (?, ?, ?, ?, ?, ?)`, ) .run(token, folderId, opts.label ?? null, Date.now(), opts.expiresAt ?? null, opts.maxFiles ?? null); logActivity('request.create', { nodeId: folderId, detail: token, ip: opts.ip ?? '' }); return getRequestById(Number(info.lastInsertRowid)); } export function getRequestById(id) { return getDb().prepare('SELECT * FROM file_requests WHERE id = ?').get(id) ?? null; } /** Constant-time token lookup (row returned regardless of validity). */ export function getRequestByToken(token) { if (typeof token !== 'string' || token.length !== TOKEN_LEN) return null; const row = getDb().prepare('SELECT * FROM file_requests WHERE token = ?').get(token); if (!row) return null; const a = Buffer.from(row.token); const b = Buffer.from(token); if (a.length !== b.length || !timingSafeEqual(a, b)) return null; return row; } /** * @returns {{ok: true} | {ok: false, reason: 'missing'|'closed'|'expired'|'full'|'gone'}} */ export function requestValidity(request) { if (!request) return { ok: false, reason: 'missing' }; if (request.closed_at) return { ok: false, reason: 'closed' }; if (request.expires_at && request.expires_at < Date.now()) return { ok: false, reason: 'expired' }; if (request.max_files !== null && request.received >= request.max_files) { return { ok: false, reason: 'full' }; } const folder = getDb().prepare('SELECT * FROM nodes WHERE id = ?').get(request.folder_id); if (!folder || folder.trashed_at) return { ok: false, reason: 'gone' }; return { ok: true }; } /** Count one received file (called on completed public upload). */ export function recordReceived(requestId, { name = '', ip = '' } = {}) { const db = getDb(); db.prepare('UPDATE file_requests SET received = received + 1 WHERE id = ?').run(requestId); const request = getRequestById(requestId); logActivity('request.upload', { nodeId: request?.folder_id, detail: name, ip }); } /** All requests with target-folder info, newest first (manager view). */ export function listRequests() { return getDb() .prepare( `SELECT r.*, n.name AS folder_name FROM file_requests r JOIN nodes n ON n.id = r.folder_id ORDER BY r.created DESC`, ) .all(); } /** Close a request immediately (public link dies on the next call). */ export function closeRequest(id, { ip = '' } = {}) { const request = getRequestById(id); if (!request) return false; getDb().prepare('UPDATE file_requests SET closed_at = ? WHERE id = ?').run(Date.now(), id); logActivity('request.close', { nodeId: request.folder_id, detail: request.token, ip }); return true; } /** Delete closed/expired requests older than 90 days (daily job). */ export function pruneRequests() { const cutoff = Date.now() - 90 * 86_400_000; return getDb() .prepare( `DELETE FROM file_requests WHERE (closed_at IS NOT NULL AND closed_at < ?) OR (expires_at IS NOT NULL AND expires_at < ?)`, ) .run(cutoff, cutoff).changes; }