Repository Public
1// Operator commands, run on the node: node dist/cli.mjs <command>2import { randomBytes } from 'node:crypto';3import { existsSync, mkdirSync, writeFileSync } from 'node:fs';4import { dirname } from 'node:path';5import { createInvite } from './auth.ts';6import { driver, masterKey } from './blob.ts';7import { config } from './config.ts';8import { unwrapKey, wrapKey } from './crypto.ts';9import { migrate, migrateDown, MIGRATIONS, sql } from './db.ts';1011const [cmd, ...args] = process.argv.slice(2);12const flag = (name: string) => { const i = args.indexOf(`--${name}`); return i >= 0 ? args[i + 1] : undefined; };13const commands: Record<string, () => Promise<void>> = {14 /** Create the master key file if it does not exist. Back it up: without it stored files cannot be read. */15 async 'init-key'() {16 if (existsSync(config.masterKeyFile)) { console.log(`master key already present: ${config.masterKeyFile}`); return; }17 mkdirSync(dirname(config.masterKeyFile), { recursive: true, mode: 0o700 });18 writeFileSync(config.masterKeyFile, `${randomBytes(32).toString('hex')}\n`, { mode: 0o600, flag: 'wx' });19 console.log(`master key created: ${config.masterKeyFile} — copy it to a safe place now`);20 },21 async migrate() { const a = await migrate(); console.log(a.length ? `applied: ${a.join(', ')}` : 'schema up to date'); },22 async 'migrate-status'() { const rows = await sql<{ id: string }[]>`select id from schema_migrations order by id`.catch(() => []); console.log(`applied: ${rows.map((r) => r.id).join(', ') || 'none'} / known: ${MIGRATIONS.map((m) => m.id).join(', ')}`); },23 /** Reverts the latest migration and DROPS its tables. Requires --yes-drop-data. */24 async 'migrate-down'() {25 if (!args.includes('--yes-drop-data')) { console.error('refused: this drops the tables of the latest migration; add --yes-drop-data after taking a backup'); process.exitCode = 2; return; }26 console.log(`reverted: ${(await migrateDown()) ?? 'nothing'}`);27 },28 /** One-time invitation link (first administrator, or a locked-out owner). */29 async invite() {30 const email = flag('email');31 if (!email) { console.error('usage: invite --email <address> [--admin] [--quota-gb <n>]'); process.exitCode = 2; return; }32 await migrate();33 const [taken] = await sql`select 1 from users where email = ${email}`;34 if (taken) { console.error('an account already uses this address'); process.exitCode = 1; return; }35 const q = flag('quota-gb');36 const inv = await createInvite(sql, { email, role: args.includes('--admin') ? 'admin' : 'user', quotaBytes: q ? Number(q) * 1024 ** 3 : undefined });37 console.log(`${config.origin}/invite/${inv.token}`);38 },39 /** Emergency: new recovery path for an existing account whose passkeys and codes are all lost. Revokes its sessions. */40 async 'reset-access'() {41 const email = flag('email');42 const [u] = await sql<{ id: string }[]>`select id from users where email = ${email ?? ''}`;43 if (!u) { console.error('unknown account'); process.exitCode = 1; return; }44 const code = `${randomBytes(10).toString('hex').toUpperCase().match(/.{4}/g)!.join('-')}`;45 const { sha256hex } = await import('./crypto.ts');46 await sql.begin(async (t) => {47 await t`update sessions set revoked_at = now() where user_id = ${u.id} and revoked_at is null`;48 await t`delete from recovery_codes where user_id = ${u.id}`;49 await t`update users set totp_secret = null, totp_enabled_at = null, totp_last_step = null where id = ${u.id}`;50 await t`insert into recovery_codes (user_id, code_hash) values (${u.id}, ${sha256hex(`spd-recovery-v1:${code.replace(/[^A-Za-z0-9]/g, '').toUpperCase()}`)})`;51 await t`insert into audit_events (actor_kind, owner_id, action) values ('system', ${u.id}, 'account.access_reset')`;52 });53 console.log(`one recovery code for ${email}: ${code}`);54 },55 /** Delete an account with everything it owns (files, links, portals, stored objects). Irreversible. Requires --yes-delete. */56 async 'delete-account'() {57 const email = flag('email');58 const [u] = await sql<{ id: string }[]>`select id from users where email = ${email ?? ''}`;59 if (!u) { console.error('unknown account'); process.exitCode = 1; return; }60 if (!args.includes('--yes-delete')) { console.error('refused: this permanently deletes the account and all its files; add --yes-delete'); process.exitCode = 2; return; }61 const keys = await sql.begin(async (t) => {62 const k = await t<{ storage_key: string }[]>`select storage_key from storage_objects where owner_id = ${u.id}63 union all select p.storage_key from previews p join storage_objects o on o.id = p.object_id where o.owner_id = ${u.id} and p.storage_key is not null`;64 await t`delete from files where owner_id = ${u.id}`;65 await t`delete from folders where owner_id = ${u.id}`;66 await t`delete from storage_objects where owner_id = ${u.id}`;67 await t`delete from users where id = ${u.id}`;68 await t`insert into audit_events (actor_kind, action, target_type, target_id) values ('system', 'account.deleted', 'user', ${u.id})`;69 return k.map((r) => r.storage_key);70 });71 for (const k of keys) await driver.remove(k);72 console.log(`account ${email} deleted, ${keys.length} stored objects removed`);73 },74 /** Re-wrap every data key under a new master key (files are not rewritten). The new key file replaces the old one after success. */75 async 'rotate-master-key'() {76 const next = randomBytes(32), old = masterKey();77 const nextFile = `${config.masterKeyFile}.next`;78 writeFileSync(nextFile, `${next.toString('hex')}\n`, { mode: 0o600, flag: 'wx' });79 let n = 0;80 await sql.begin(async (t) => {81 for (const table of ['storage_objects', 'previews'] as const) {82 const rows = table === 'storage_objects' ? await t<{ k: string; wrapped_key: Buffer }[]>`select id::text as k, wrapped_key from storage_objects for update`83 : await t<{ k: string; wrapped_key: Buffer }[]>`select object_id::text || '|' || kind as k, wrapped_key from previews where wrapped_key is not null for update`;84 for (const r of rows) {85 const w = wrapKey(next, unwrapKey(old, Buffer.from(r.wrapped_key)));86 if (table === 'storage_objects') await t`update storage_objects set wrapped_key = ${w} where id = ${r.k}`;87 else { const [id, kind] = r.k.split('|'); await t`update previews set wrapped_key = ${w} where object_id = ${id!} and kind = ${kind!}`; }88 n++;89 }90 }91 const totp = await t<{ id: string; totp_secret: Buffer }[]>`select id, totp_secret from users where totp_secret is not null for update`;92 const { seal, unseal } = await import('./crypto.ts');93 for (const u of totp) await t`update users set totp_secret = ${seal(next, unseal(old, Buffer.from(u.totp_secret), `totp:${u.id}`), `totp:${u.id}`)} where id = ${u.id}`;94 });95 const { renameSync, copyFileSync } = await import('node:fs');96 copyFileSync(config.masterKeyFile, `${config.masterKeyFile}.previous`); renameSync(nextFile, config.masterKeyFile);97 console.log(`${n} keys re-wrapped. New key: ${config.masterKeyFile} (previous kept as .previous). Restart sp-drive and sp-drive-worker, back up the new key, sessions for open share links are reset.`);98 },99 /** Check that every object's blob exists and decrypts (first block). --deep reads everything and re-hashes. */100 async verify() {101 const { readSealed } = await import('./blob.ts');102 const { createHash } = await import('node:crypto');103 const rows = await sql<{ id: string; storage_key: string; wrapped_key: Buffer; size: string; sha256: string }[]>`select id, storage_key, wrapped_key, size::text, sha256 from storage_objects order by created_at`;104 let bad = 0;105 for (const o of rows) {106 try {107 if (!(await driver.exists(o.storage_key))) throw new Error('missing blob');108 const size = Number(o.size);109 const h = createHash('sha256');110 for await (const c of readSealed({ ...o, size }, 0, args.includes('--deep') ? size - 1 : Math.min(size, 65536) - 1)) h.update(c as Buffer);111 if (args.includes('--deep') && h.digest('hex') !== o.sha256) throw new Error('content hash mismatch');112 } catch (e) { bad++; console.error(`${o.id}: ${(e as Error).message}`); }113 }114 console.log(`${rows.length} objects checked, ${bad} damaged`);115 if (bad) process.exitCode = 1;116 },117};118const run = commands[cmd ?? ''];119if (!run) { console.error(`commands: ${Object.keys(commands).join(', ')}`); process.exit(2); }120await run();121await sql.end({ timeout: 5 });122