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/shares.mjs8 * Purpose : Share links — base58 tokens, expiry, passwords, limits, logging9 * License : MIT © Simon-Pierre Boucher10 * ─────────────────────────────────────────────11 */1213import { randomBytes, timingSafeEqual } from 'node:crypto';14import argon2 from 'argon2';15import { getDb, logActivity } from '../db/db.mjs';16import { mustGetNode } from '../storage/nodes.mjs';1718// 58^10 ≈ 4.3e17 ≈ 58.6 bits of entropy — matches the security checklist.19const BASE58 = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';20const TOKEN_LEN = 10;2122/** Uniform base58 token of the given length (default: share length). */23export function makeToken(len = TOKEN_LEN) {24 const bytes = randomBytes(len * 2);25 let out = '';26 for (let i = 0; out.length < len && i < bytes.length; i += 1) {27 // Rejection sampling keeps the distribution uniform across the alphabet.28 if (bytes[i] < 232) out += BASE58[bytes[i] % 58];29 }30 while (out.length < len) out += BASE58[randomBytes(1)[0] % 58];31 return out;32}3334/**35 * Create a share for a node.36 * @param {{expiresAt?: number|null, password?: string|null, maxDownloads?: number|null,37 * allowDownload?: boolean, label?: string|null, ip?: string}} opts38 */39export async function createShare(nodeId, opts = {}) {40 const node = mustGetNode(nodeId);41 if (node.trashed_at) throw Object.assign(new Error('Cannot share a trashed item'), { statusCode: 400 });42 const token = makeToken();43 const passwordHash = opts.password44 ? await argon2.hash(opts.password, { type: argon2.argon2id })45 : null;46 const info = getDb()47 .prepare(48 `INSERT INTO shares (token, node_id, created, expires_at, password_hash, max_downloads, allow_download, label)49 VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,50 )51 .run(52 token, nodeId, Date.now(), opts.expiresAt ?? null, passwordHash,53 opts.maxDownloads ?? null, opts.allowDownload === false ? 0 : 1, opts.label ?? null,54 );55 logActivity('share.create', { nodeId, detail: token, ip: opts.ip ?? '' });56 return getShareById(Number(info.lastInsertRowid));57}5859/** Update share options (expiry, password, limits, label). */60export async function updateShare(id, opts = {}) {61 const db = getDb();62 const share = getShareById(id);63 if (!share) return null;64 const passwordHash = opts.password === undefined65 ? share.password_hash66 : opts.password67 ? await argon2.hash(opts.password, { type: argon2.argon2id })68 : null;69 db.prepare(70 `UPDATE shares SET expires_at = ?, password_hash = ?, max_downloads = ?, allow_download = ?, label = ?71 WHERE id = ?`,72 ).run(73 opts.expiresAt !== undefined ? opts.expiresAt : share.expires_at,74 passwordHash,75 opts.maxDownloads !== undefined ? opts.maxDownloads : share.max_downloads,76 opts.allowDownload !== undefined ? (opts.allowDownload ? 1 : 0) : share.allow_download,77 opts.label !== undefined ? opts.label : share.label,78 id,79 );80 return getShareById(id);81}8283export function getShareById(id) {84 return getDb().prepare('SELECT * FROM shares WHERE id = ?').get(id) ?? null;85}8687/**88 * Constant-time token lookup.89 * @returns {object|null} the share row (regardless of validity)90 */91export function getShareByToken(token) {92 if (typeof token !== 'string' || token.length !== TOKEN_LEN) return null;93 // Indexed fetch, then constant-time comparison of the candidate.94 const row = getDb().prepare('SELECT * FROM shares WHERE token = ?').get(token);95 if (!row) return null;96 const a = Buffer.from(row.token);97 const b = Buffer.from(token);98 if (a.length !== b.length || !timingSafeEqual(a, b)) return null;99 return row;100}101102/**103 * Validity check used by every public route.104 * @returns {{ok: true} | {ok: false, reason: 'revoked'|'expired'|'exhausted'|'missing'}}105 */106export function shareValidity(share) {107 if (!share) return { ok: false, reason: 'missing' };108 if (share.revoked_at) return { ok: false, reason: 'revoked' };109 if (share.expires_at && share.expires_at < Date.now()) return { ok: false, reason: 'expired' };110 if (share.max_downloads !== null && share.downloads >= share.max_downloads) {111 return { ok: false, reason: 'exhausted' };112 }113 return { ok: true };114}115116/** Check a visitor-supplied password against the share. */117export async function verifySharePassword(share, password) {118 if (!share.password_hash) return true;119 try {120 return await argon2.verify(share.password_hash, password ?? '');121 } catch {122 return false;123 }124}125126/** Record a visit or download (also mirrored into the activity log). */127export function recordShareEvent(shareId, kind, { ip = '', ua = '' } = {}) {128 const db = getDb();129 db.prepare('INSERT INTO share_events (share_id, ts, kind, ip, ua) VALUES (?, ?, ?, ?, ?)')130 .run(shareId, Date.now(), kind, ip, ua.slice(0, 300));131 db.prepare(132 kind === 'download'133 ? 'UPDATE shares SET downloads = downloads + 1 WHERE id = ?'134 : 'UPDATE shares SET visits = visits + 1 WHERE id = ?',135 ).run(shareId);136 const share = getShareById(shareId);137 logActivity(`share.${kind}`, { nodeId: share?.node_id, detail: share?.token, ip });138}139140/** All shares with node info, newest first (share manager). */141export function listShares() {142 return getDb()143 .prepare(144 `SELECT s.*, n.name AS node_name, n.type AS node_type, n.size AS node_size, n.mime AS node_mime145 FROM shares s JOIN nodes n ON n.id = s.node_id146 ORDER BY s.created DESC`,147 )148 .all();149}150151/** Shares attached to one node (info panel). */152export function sharesForNode(nodeId) {153 return getDb().prepare('SELECT * FROM shares WHERE node_id = ? AND revoked_at IS NULL ORDER BY created DESC').all(nodeId);154}155156/** Recent visit/download events for one share. */157export function shareEvents(shareId, limit = 100) {158 return getDb()159 .prepare('SELECT ts, kind, ip, ua FROM share_events WHERE share_id = ? ORDER BY ts DESC LIMIT ?')160 .all(shareId, limit);161}162163/** Revoke immediately (public link dies on the next request). */164export function revokeShare(id, { ip = '' } = {}) {165 const share = getShareById(id);166 if (!share) return false;167 getDb().prepare('UPDATE shares SET revoked_at = ? WHERE id = ?').run(Date.now(), id);168 logActivity('share.revoke', { nodeId: share.node_id, detail: share.token, ip });169 return true;170}171172/** Delete revoked/expired shares older than 90 days (daily job). */173export function pruneShares() {174 const cutoff = Date.now() - 90 * 86_400_000;175 return getDb()176 .prepare(177 `DELETE FROM shares WHERE (revoked_at IS NOT NULL AND revoked_at < ?)178 OR (expires_at IS NOT NULL AND expires_at < ?)`,179 )180 .run(cutoff, cutoff).changes;181}182