build-container / build (push) Successful in 6m44s
- signal.js: talks to signal-cli-rest-api (a linked device of your Signal account); keeps conversations in /data/signal; live updates; send/receive text, files, replies, reactions, groups, receipts - Signal tab and a floating chat panel on every screen, shown only on desktop-class windows; new Signal permission per person - compose: signal bridge container sharing the hub's network - tools/signal-test.js (fake bridge), README Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
94 lines
6.0 KiB
JavaScript
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', 'signal', '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', signal: 'Signal', 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 };
|