Multiple people: an admin adds users and chooses which tabs each may use
build-container / build (push) Successful in 2m20s
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:
1 parent
489ea0aaf0
commit
f18d9dd7a9
16 files changed
+703
-165
No files matched your search
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user