// People who can sign in, what each may use, and a private data folder for each. // The first account (the admin) is created by the setup code or by migrating a single-user install; the admin adds everyone else. const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const { AsyncLocalStorage } = require('async_hooks'); const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data'); const USERS_FILE = path.join(DATA_DIR, 'users.json'); const USERS_DIR = path.join(DATA_DIR, 'users'); fs.mkdirSync(DATA_DIR, { recursive: true }); // Tabs an admin can switch on or off for a person. Home and Settings are always available to everyone (Settings only shows their own). const TABS = ['files', 'office', 'music', 'audio', 'podcast', 'verse', 'mail', 'signal', 'calendar', 'tasks', 'search', 'links']; const ALWAYS = ['home', 'settings']; const TAB_LABELS = { files: 'Files', office: 'Office', music: 'Music', audio: 'Audiobooks', podcast: 'Podcast', verse: 'Podcast Admin', mail: 'Mail', signal: 'Signal', calendar: 'Calendar', tasks: 'Tasks', search: 'Search', links: 'Links' }; let cache = null; const load = () => { if (cache) return cache; try { cache = JSON.parse(fs.readFileSync(USERS_FILE, 'utf8')); } catch { cache = { users: [] }; } if (!Array.isArray(cache.users)) cache.users = []; return cache; }; const save = () => { const tmp = USERS_FILE + '.tmp'; fs.writeFileSync(tmp, JSON.stringify(cache, null, 2), { mode: 0o600 }); fs.renameSync(tmp, USERS_FILE); }; // ---- The person making the current request, available to any module without passing it around const als = new AsyncLocalStorage(); const runAs = (user, fn) => als.run({ user }, fn); const current = () => (als.getStore() || {}).user || null; const dirOf = id => { const d = path.join(USERS_DIR, id); fs.mkdirSync(d, { recursive: true, mode: 0o700 }); return d; }; const dir = () => { const u = current(); if (!u) throw new Error('No signed-in user'); return dirOf(u.id); }; const all = () => load().users; const get = id => all().find(u => u.id === id) || null; const firstAdmin = () => all().find(u => u.role === 'admin' && !u.disabled) || null; const adminCount = () => all().filter(u => u.role === 'admin' && !u.disabled).length; const cleanTabs = t => [...new Set((Array.isArray(t) ? t : []).filter(x => TABS.includes(x)))]; const cleanName = n => String(n || '').replace(/\s+/g, ' ').trim().slice(0, 40); function create({ name, role = 'user', tabs = [], handle }) { const n = cleanName(name); if (!n) throw new Error('Give the person a name'); if (all().some(u => u.name.toLowerCase() === n.toLowerCase())) throw new Error('Someone with that name already exists'); const u = { id: 'u' + crypto.randomBytes(5).toString('hex'), name: n, role: role === 'admin' ? 'admin' : 'user', tabs: role === 'admin' ? [...TABS] : cleanTabs(tabs), handle: handle || crypto.randomBytes(32).toString('base64url'), epoch: 1, disabled: false, created: Date.now() }; all().push(u); save(); dirOf(u.id); return u; } function update(id, patch) { const u = get(id); if (!u) throw new Error('Person not found'); if (patch.name !== undefined) { const n = cleanName(patch.name); if (!n) throw new Error('Give the person a name'); if (all().some(x => x.id !== id && x.name.toLowerCase() === n.toLowerCase())) throw new Error('Someone with that name already exists'); u.name = n; } if (patch.tabs !== undefined) u.tabs = cleanTabs(patch.tabs); if (patch.role !== undefined) { const role = patch.role === 'admin' ? 'admin' : 'user'; if (u.role === 'admin' && role !== 'admin' && adminCount() <= 1) throw new Error('There must be at least one admin'); u.role = role; if (role === 'admin') u.tabs = [...TABS]; } if (patch.disabled !== undefined) { const d = !!patch.disabled; if (d && u.role === 'admin' && adminCount() <= 1) throw new Error('There must be at least one admin'); u.disabled = d; u.epoch++; // disabling also signs them out everywhere } if (patch.signOutEverywhere) u.epoch++; save(); return u; } function remove(id) { const u = get(id); if (!u) throw new Error('Person not found'); if (u.role === 'admin' && adminCount() <= 1) throw new Error('There must be at least one admin'); cache.users = all().filter(x => x.id !== id); save(); fs.rmSync(path.join(USERS_DIR, id), { recursive: true, force: true }); } const can = (u, tab) => !!u && (ALWAYS.includes(tab) || u.role === 'admin' || (u.tabs || []).includes(tab)); const allowedTabs = u => !u ? [] : [...ALWAYS, ...(u.role === 'admin' ? TABS : (u.tabs || []).filter(t => TABS.includes(t)))]; const publicView = u => ({ id: u.id, name: u.name, role: u.role, tabs: allowedTabs(u), disabled: !!u.disabled, created: u.created }); // ---- A single-user install becomes "the admin" without anyone doing anything: their passkey, tasks, events, settings, // iCloud connection and calendar links move into their own folder. (Copied, so the originals stay as a backup.) const LEGACY = ['settings.json', 'tasks.json', 'events.json', 'feed.json', 'calendars.json', 'icloud.json']; function migrateLegacy() { if (fs.existsSync(USERS_FILE)) return; let legacy = null; try { legacy = JSON.parse(fs.readFileSync(path.join(DATA_DIR, 'passkeys.json'), 'utf8')); } catch {} const hasFiles = LEGACY.some(f => fs.existsSync(path.join(DATA_DIR, f))); if (!(legacy && legacy.creds && legacy.creds.length) && !hasFiles) return; // fresh install: the setup code creates the first admin const admin = create({ name: 'Admin', role: 'admin', handle: legacy && legacy.userId }); const d = dirOf(admin.id); for (const f of LEGACY) { const src = path.join(DATA_DIR, f); if (fs.existsSync(src) && !fs.existsSync(path.join(d, f))) fs.copyFileSync(src, path.join(d, f)); } console.log(`[users] Migrated the existing single-user data to the admin account (${admin.id}). The original files in ${DATA_DIR} were left in place as a backup.`); } migrateLegacy(); module.exports = { TABS, TAB_LABELS, ALWAYS, runAs, current, dir, dirOf, all, get, firstAdmin, adminCount, create, update, remove, can, allowedTabs, publicView, cleanTabs };