Files
hub/users.js
T
nmemmertandClaude Sonnet 5.5 de1430928a Files like Finder, richer mini player, in-app Back/Forward, Audio tab renamed Audiobooks
- Files: multi-select, batch move/copy/delete, rename checks, sort, filter, shortcuts, drag in (files and folders) to upload with progress and retries, drag to move, drag a file out to download
- Mini player: previous, seek bar with buffered range, time, shuffle/repeat, buffering spinner, swipe for next/previous, close
- Back/Forward bar that knows what history is possible
- Audio tab is now Audiobooks

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 08:37:58 -04:00

94 lines
6.0 KiB
JavaScript

// 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', 'calendar', 'tasks', 'search'];
const ALWAYS = ['home', 'settings'];
const TAB_LABELS = { files: 'Files', office: 'Office', music: 'Music', audio: 'Audiobooks', podcast: 'Podcast', verse: 'Podcast Admin', mail: 'Mail', calendar: 'Calendar', tasks: 'Tasks', search: 'Search' };
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 };