/** * ───────────────────────────────────────────── * SPB Drive — Personal Cloud Drive * ───────────────────────────────────────────── * Author : Simon-Pierre Boucher * Contact : contact@spboucher.ai * File : src/shares/shares.mjs * Purpose : Share links — base58 tokens, expiry, passwords, limits, logging * License : MIT © Simon-Pierre Boucher * ───────────────────────────────────────────── */ import { randomBytes, timingSafeEqual } from 'node:crypto'; import argon2 from 'argon2'; import { getDb, logActivity } from '../db/db.mjs'; import { mustGetNode } from '../storage/nodes.mjs'; // 58^10 ≈ 4.3e17 ≈ 58.6 bits of entropy — matches the security checklist. const BASE58 = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'; const TOKEN_LEN = 10; /** Uniform base58 token of the given length (default: share length). */ export function makeToken(len = TOKEN_LEN) { const bytes = randomBytes(len * 2); let out = ''; for (let i = 0; out.length < len && i < bytes.length; i += 1) { // Rejection sampling keeps the distribution uniform across the alphabet. if (bytes[i] < 232) out += BASE58[bytes[i] % 58]; } while (out.length < len) out += BASE58[randomBytes(1)[0] % 58]; return out; } /** * Create a share for a node. * @param {{expiresAt?: number|null, password?: string|null, maxDownloads?: number|null, * allowDownload?: boolean, label?: string|null, ip?: string}} opts */ export async function createShare(nodeId, opts = {}) { const node = mustGetNode(nodeId); if (node.trashed_at) throw Object.assign(new Error('Cannot share a trashed item'), { statusCode: 400 }); const token = makeToken(); const passwordHash = opts.password ? await argon2.hash(opts.password, { type: argon2.argon2id }) : null; const info = getDb() .prepare( `INSERT INTO shares (token, node_id, created, expires_at, password_hash, max_downloads, allow_download, label) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, ) .run( token, nodeId, Date.now(), opts.expiresAt ?? null, passwordHash, opts.maxDownloads ?? null, opts.allowDownload === false ? 0 : 1, opts.label ?? null, ); logActivity('share.create', { nodeId, detail: token, ip: opts.ip ?? '' }); return getShareById(Number(info.lastInsertRowid)); } /** Update share options (expiry, password, limits, label). */ export async function updateShare(id, opts = {}) { const db = getDb(); const share = getShareById(id); if (!share) return null; const passwordHash = opts.password === undefined ? share.password_hash : opts.password ? await argon2.hash(opts.password, { type: argon2.argon2id }) : null; db.prepare( `UPDATE shares SET expires_at = ?, password_hash = ?, max_downloads = ?, allow_download = ?, label = ? WHERE id = ?`, ).run( opts.expiresAt !== undefined ? opts.expiresAt : share.expires_at, passwordHash, opts.maxDownloads !== undefined ? opts.maxDownloads : share.max_downloads, opts.allowDownload !== undefined ? (opts.allowDownload ? 1 : 0) : share.allow_download, opts.label !== undefined ? opts.label : share.label, id, ); return getShareById(id); } export function getShareById(id) { return getDb().prepare('SELECT * FROM shares WHERE id = ?').get(id) ?? null; } /** * Constant-time token lookup. * @returns {object|null} the share row (regardless of validity) */ export function getShareByToken(token) { if (typeof token !== 'string' || token.length !== TOKEN_LEN) return null; // Indexed fetch, then constant-time comparison of the candidate. const row = getDb().prepare('SELECT * FROM shares 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; } /** * Validity check used by every public route. * @returns {{ok: true} | {ok: false, reason: 'revoked'|'expired'|'exhausted'|'missing'}} */ export function shareValidity(share) { if (!share) return { ok: false, reason: 'missing' }; if (share.revoked_at) return { ok: false, reason: 'revoked' }; if (share.expires_at && share.expires_at < Date.now()) return { ok: false, reason: 'expired' }; if (share.max_downloads !== null && share.downloads >= share.max_downloads) { return { ok: false, reason: 'exhausted' }; } return { ok: true }; } /** Check a visitor-supplied password against the share. */ export async function verifySharePassword(share, password) { if (!share.password_hash) return true; try { return await argon2.verify(share.password_hash, password ?? ''); } catch { return false; } } /** Record a visit or download (also mirrored into the activity log). */ export function recordShareEvent(shareId, kind, { ip = '', ua = '' } = {}) { const db = getDb(); db.prepare('INSERT INTO share_events (share_id, ts, kind, ip, ua) VALUES (?, ?, ?, ?, ?)') .run(shareId, Date.now(), kind, ip, ua.slice(0, 300)); db.prepare( kind === 'download' ? 'UPDATE shares SET downloads = downloads + 1 WHERE id = ?' : 'UPDATE shares SET visits = visits + 1 WHERE id = ?', ).run(shareId); const share = getShareById(shareId); logActivity(`share.${kind}`, { nodeId: share?.node_id, detail: share?.token, ip }); } /** All shares with node info, newest first (share manager). */ export function listShares() { return getDb() .prepare( `SELECT s.*, n.name AS node_name, n.type AS node_type, n.size AS node_size, n.mime AS node_mime FROM shares s JOIN nodes n ON n.id = s.node_id ORDER BY s.created DESC`, ) .all(); } /** Shares attached to one node (info panel). */ export function sharesForNode(nodeId) { return getDb().prepare('SELECT * FROM shares WHERE node_id = ? AND revoked_at IS NULL ORDER BY created DESC').all(nodeId); } /** Recent visit/download events for one share. */ export function shareEvents(shareId, limit = 100) { return getDb() .prepare('SELECT ts, kind, ip, ua FROM share_events WHERE share_id = ? ORDER BY ts DESC LIMIT ?') .all(shareId, limit); } /** Revoke immediately (public link dies on the next request). */ export function revokeShare(id, { ip = '' } = {}) { const share = getShareById(id); if (!share) return false; getDb().prepare('UPDATE shares SET revoked_at = ? WHERE id = ?').run(Date.now(), id); logActivity('share.revoke', { nodeId: share.node_id, detail: share.token, ip }); return true; } /** Delete revoked/expired shares older than 90 days (daily job). */ export function pruneShares() { const cutoff = Date.now() - 90 * 86_400_000; return getDb() .prepare( `DELETE FROM shares WHERE (revoked_at IS NOT NULL AND revoked_at < ?) OR (expires_at IS NOT NULL AND expires_at < ?)`, ) .run(cutoff, cutoff).changes; }