SPB Git

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%
4.5 KB · 110 lines javascript
Raw Blame History
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