Multiple people: an admin adds users and chooses which tabs each may use
build-container / build (push) Successful in 2m20s

- Accounts: the setup code (or an existing single-user install, migrated automatically) creates the admin;
  the admin adds people in Settings -> People and gives each a one-time invite code to enrol their own passkey
- Each person's tasks, NE Hub events and subscribe link, settings, calendar links, iCloud connection and Plex
  sign-in are private, stored in their own folder (/data/users/<id>/); Plex sign-in moved from browser cookies
  into that folder (encrypted), so it also follows them across devices
- Tab access is enforced by the server (403) and reflected in the app (nav, Home, Settings, search, /api/config
  hides addresses of tabs a person cannot use); browser storage is namespaced per person
- Admin tools: change tabs, invite a device, sign out everywhere (session epoch), turn off, make/remove admin,
  delete a person and their data; the last admin cannot be removed
- Sessions carry the person (uid.epoch.expiry.signature); old single-user cookies still work as the admin
- tools/users-test.js (83 checks: isolation, access limits, invites, sign-out/disable/delete, Plex and iCloud
  per person, migration) now runs in the Gitea workflow before the image is built

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-20 16:35:14 -04:00
1 parent 489ea0aaf0
commit f18d9dd7a9
16 files changed
+703 -165

No files matched your search

+20 -12
View File
@@ -8,10 +8,11 @@ const crypto = require('crypto');
const ics = require('./ics');
const caldav = require('./caldav');
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data');
const FILE = path.join(DATA_DIR, 'calendars.json');
const load = () => { try { return JSON.parse(fs.readFileSync(FILE, 'utf8')); } catch { return []; } };
const save = v => { fs.mkdirSync(DATA_DIR, { recursive: true }); fs.writeFileSync(FILE, JSON.stringify(v, null, 2), { mode: 0o600 }); };
const users = require('./users');
const here = f => path.join(users.dir(), f); // each person's own files
const FILE_NAME = 'calendars.json';
const load = () => { try { return JSON.parse(fs.readFileSync(here(FILE_NAME), 'utf8')); } catch { return []; } };
const save = v => fs.writeFileSync(here(FILE_NAME), JSON.stringify(v, null, 2), { mode: 0o600 });
// ---- Fetching, with SSRF protection: https only, and never to loopback/private/link-local addresses.
const isPrivate = ip => {
@@ -221,25 +222,32 @@ function expand(events, win) { // win: { from, to (ms), fd, td (local YYYY-MM-DD
}
// ---- The NE Hub calendar: events created in the app, stored here, and published as a subscribe feed.
const EV_FILE = path.join(DATA_DIR, 'events.json'), FEED_FILE = path.join(DATA_DIR, 'feed.json');
const EV_FILE = 'events.json', FEED_FILE = 'feed.json';
const readJ = (f, d) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch { return d; } };
const writeJ = (f, v) => { fs.mkdirSync(DATA_DIR, { recursive: true }); fs.writeFileSync(f, JSON.stringify(v, null, 2), { mode: 0o600 }); };
const loadLocal = () => readJ(EV_FILE, []);
const writeJ = (f, v) => fs.writeFileSync(here(f), JSON.stringify(v, null, 2), { mode: 0o600 });
const loadLocal = () => readJ(here(EV_FILE), []);
const LOCAL = { id: 'local', name: 'NE Hub', color: '#4f7cff' };
function feedToken(reset) {
let t = readJ(FEED_FILE, {}).token;
let t = readJ(here(FEED_FILE), {}).token;
if (!t || reset) { t = crypto.randomBytes(24).toString('hex'); writeJ(FEED_FILE, { token: t }); }
return t;
}
const localIcs = () => ics.buildCalendar(loadLocal().map(e => ({ uid: e.uid, fields: e.fields })));
/** GET /cal/feed/<token>.ics : public on purpose (Outlook/Google/Apple fetch it without a session); the token is the secret. */
/** GET /cal/feed/<token>.ics : public on purpose (Outlook/Google/Apple fetch it without a session); the token is the secret.
* Every person has their own token, and it only ever shows that person's own NE Hub events. */
function feed(req, res) {
const m = /^\/cal\/feed\/([a-f0-9]+)\.ics$/.exec(new URL(req.url, 'http://x').pathname);
const want = Buffer.from(feedToken()), got = Buffer.from(m ? m[1] : '');
if (!m || want.length !== got.length || !crypto.timingSafeEqual(want, got)) { res.writeHead(404); return res.end('Not found'); }
const got = Buffer.from(m ? m[1] : '');
let owner = null;
if (m) for (const u of users.all()) {
if (u.disabled) continue;
const want = Buffer.from(String(readJ(path.join(users.dirOf(u.id), FEED_FILE), {}).token || ''));
if (want.length && want.length === got.length && crypto.timingSafeEqual(want, got)) { owner = u; break; }
}
if (!owner) { res.writeHead(404); return res.end('Not found'); }
res.writeHead(200, { 'content-type': 'text/calendar; charset=utf-8', 'cache-control': 'private, max-age=300' });
res.end(localIcs());
res.end(users.runAs(owner, () => localIcs()));
}
// ---- HTTP API (called by server.js, behind the passkey gate)