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%
7.0 KB · 182 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/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