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

+61 -36
View File
@@ -14,6 +14,9 @@ const cal = require('./cal');
const tasks = require('./tasks');
const settings = require('./settings');
const search = require('./search');
const users = require('./users');
const admin = require('./admin');
const vault = require('./vault')('nehub-plex');
const VERSE_URL = process.env.VERSE_URL || 'https://versebyversewithnate.us/admin';
const PODCAST_URL = process.env.PODCAST_URL || 'https://podcast.necloud.us/';
const PUBLIC = path.join(__dirname, 'public');
@@ -157,19 +160,15 @@ async function ooCallback(req, res) {
// ---- Plex Music: sign in via plex.tv PIN (no password handled here). Tokens live in HttpOnly cookies, and
// /plex/* is proxied to the chosen server with the token added server-side, so <audio>/<img> work without exposing it.
const COOKIE_SECRET = auth.secret;
const PLEX_HEADERS = {
Accept: 'application/json', 'X-Plex-Client-Identifier': process.env.PLEX_CLIENT_ID || 'ne-hub-web',
'X-Plex-Product': 'NE Hub', 'X-Plex-Version': '1.0', 'X-Plex-Device-Name': 'NE Hub', 'X-Plex-Platform': 'Web',
};
const mac = v => crypto.createHmac('sha256', COOKIE_SECRET).update(v).digest('base64url');
const signed = v => v + '.' + mac(v);
const unsigned = v => { const i = String(v || '').lastIndexOf('.'); return i > 0 && mac(v.slice(0, i)) === v.slice(i + 1) ? v.slice(0, i) : null; };
const parseCookies = req => Object.fromEntries((req.headers.cookie || '').split(/; */).filter(Boolean).map(c => { const i = c.indexOf('='); return [c.slice(0, i), decodeURIComponent(c.slice(i + 1))]; }));
const setCookies = (req, res, obj, maxAge = 31536000) => {
const secure = (req.headers['x-forwarded-proto'] || '') === 'https' ? '; Secure' : '';
for (const [k, v] of Object.entries(obj)) auth.addCookie(res, `${k}=${encodeURIComponent(v)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${v === '' ? 0 : maxAge}${secure}`);
};
// Each person's Plex sign-in is stored (encrypted) in their own folder, so it follows them to every device.
const plexFile = () => require('path').join(users.dir(), 'plex.json');
const plexLoad = () => { try { return vault.unseal(JSON.parse(require('fs').readFileSync(plexFile(), 'utf8')).data) || {}; } catch { return {}; } };
const plexSave = o => require('fs').writeFileSync(plexFile(), JSON.stringify({ data: vault.seal(o) }), { mode: 0o600 });
const plexWipe = () => { try { require('fs').unlinkSync(plexFile()); } catch {} };
const plexTv = async (path, token, opts = {}) => {
const r = await fetch('https://plex.tv' + path, { ...opts, headers: { ...PLEX_HEADERS, ...(token ? { 'X-Plex-Token': token } : {}) } });
if (!r.ok) throw new Error('plex.tv ' + r.status);
@@ -177,10 +176,10 @@ const plexTv = async (path, token, opts = {}) => {
};
async function plexApi(req, res) {
const u = new URL(req.url, 'http://x'), ck = parseCookies(req), token = ck.plex_token;
const u = new URL(req.url, 'http://x'), st = plexLoad(), token = st.token;
try {
if (u.pathname === '/api/plex/status') {
return json(res, 200, { signedIn: !!token, server: unsigned(ck.plex_srv) ? ck.plex_name || 'Plex' : null });
return json(res, 200, { signedIn: !!token, server: st.srv ? st.name || 'Plex' : null });
}
if (u.pathname === '/api/plex/pin' && req.method === 'POST') {
const pin = await plexTv('/api/v2/pins?strong=true', null, { method: 'POST' });
@@ -190,7 +189,7 @@ async function plexApi(req, res) {
if (u.pathname === '/api/plex/pin' && req.method === 'GET') {
const pin = await plexTv(`/api/v2/pins/${encodeURIComponent(u.searchParams.get('id'))}?code=${encodeURIComponent(u.searchParams.get('code'))}`);
if (!pin.authToken) return json(res, 200, { ok: false });
setCookies(req, res, { plex_token: pin.authToken });
plexSave({ token: pin.authToken }); // a fresh sign-in; the server is chosen again
return json(res, 200, { ok: true });
}
if (!token) return json(res, 401, { error: 'Not signed in to Plex' });
@@ -227,7 +226,7 @@ async function plexApi(req, res) {
let mid; try { mid = JSON.parse(body).MediaContainer?.machineIdentifier; } catch { mid = /machineIdentifier="([^"]+)"/.exec(body)?.[1]; }
if (!mid) { tried.push(`${c.label}: answered, but not like a Plex server`); continue; }
if (String(mid).toLowerCase() !== String(id).toLowerCase()) { tried.push(`${c.label}: a different server answered (id ${String(mid).slice(0, 6)}…, wanted ${String(id).slice(0, 6)}…)`); continue; }
setCookies(req, res, { plex_srv: signed(c.uri), plex_stk: r.accessToken, plex_name: r.name });
plexSave({ ...st, srv: c.uri, stk: r.accessToken, name: r.name });
return json(res, 200, { ok: true, name: r.name, via: c.label });
} catch (e) { tried.push(`${c.label}: ${e.cause?.code || e.name || e.message}`); }
}
@@ -241,7 +240,7 @@ async function plexApi(req, res) {
return json(res, 502, { error: `Could not reach ${r.name} (id ${String(id).slice(0, 6)}…). ` + summary });
}
if (u.pathname === '/api/plex/logout' && req.method === 'POST') {
setCookies(req, res, { plex_token: '', plex_srv: '', plex_stk: '', plex_name: '' });
plexWipe();
return json(res, 200, { ok: true });
}
json(res, 404, { error: 'Not found' });
@@ -249,7 +248,7 @@ async function plexApi(req, res) {
}
async function plexProxy(req, res) {
const ck = parseCookies(req), base = unsigned(ck.plex_srv), tok = ck.plex_stk;
const st = plexLoad(), base = st.srv, tok = st.stk;
if (!base || !tok) { res.writeHead(401); return res.end(); }
const headers = { ...PLEX_HEADERS, 'X-Plex-Token': tok, Accept: req.headers.accept || 'application/json', 'Accept-Encoding': 'identity' };
if (req.headers.range) headers.range = req.headers.range;
@@ -281,9 +280,10 @@ function storageInfo() {
let mounted = null, source = null; // null = cannot tell (not Linux)
try { const l = fs.readFileSync('/proc/self/mountinfo', 'utf8').split('\n').find(x => x.split(' ')[4] === dir); mounted = !!l; if (l) source = classifyMount(l); } catch {}
let writable = true; try { const f = path.join(dir, '.write-test'); fs.writeFileSync(f, 'x'); fs.unlinkSync(f); } catch { writable = false; }
const count = (f, pick) => { try { return pick(JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8'))); } catch { return 0; } };
return { dir, mounted, source, writable, passkeys: count('passkeys.json', j => (j.creds || []).length), calendarLinks: count('calendars.json', j => j.length),
events: count('events.json', j => j.length), settings: count('settings.json', j => Object.keys(j).length), icloud: fs.existsSync(path.join(dir, 'icloud.json')), secretFromEnv: !!process.env.COOKIE_SECRET,
const count = (root, f, pick) => { try { return pick(JSON.parse(fs.readFileSync(path.join(root, f), 'utf8'))); } catch { return 0; } };
const udir = (() => { try { return users.dir(); } catch { const a = users.firstAdmin(); return a ? users.dirOf(a.id) : dir; } })(); // the viewer's own folder
return { dir, mounted, source, writable, people: users.all().length, passkeys: count(dir, 'passkeys.json', j => (j.creds || []).length), calendarLinks: count(udir, 'calendars.json', j => j.length),
events: count(udir, 'events.json', j => j.length), settings: count(udir, 'settings.json', j => Object.keys(j).length), icloud: fs.existsSync(path.join(udir, 'icloud.json')), secretFromEnv: !!process.env.COOKIE_SECRET,
startedAt: STARTED };
}
const STARTED = Date.now();
@@ -315,34 +315,59 @@ async function ddgAnswer(req, res) {
if (!JWT_SECRET) console.warn('WARNING: ONLYOFFICE_JWT_SECRET not set. In-app editing only works if the Document Server has JWT disabled, and save callbacks are unauthenticated. Testing only.');
// Which tab(s) each part of the server belongs to. A person needs at least one of them (admins have all).
const ACCESS = [
[/^\/sf\//, ['files', 'office']],
[/^\/api\/oo\/config$/, ['office', 'files']],
[/^\/(api\/plex\/|plex\/)/, ['music']],
[/^\/api\/cal\//, ['calendar']],
[/^\/api\/tasks/, ['tasks']],
[/^\/api\/(search|ddg)$/, ['search']],
];
const forbidden = (res, msg = 'You do not have access to this') => { res.writeHead(403, { 'content-type': 'application/json' }); return res.end(JSON.stringify({ error: msg })); };
async function handleRequest(req, res) {
const pathname = new URL(req.url, 'http://x').pathname;
if (pathname.startsWith('/api/auth/')) return auth.handle(req, res);
if (pathname.startsWith('/cal/feed/')) return cal.feed(req, res); // subscribe feed: protected by its own secret token, not the session
// Everything else needs a passkey session, except the sign-in page and its assets,
// and OnlyOffice's save callback (which carries its own signed token).
if (auth.isAuthed(req)) auth.refresh(req, res);
const user = auth.currentUser(req);
if (user) auth.refresh(req, res, user);
const open = ['/login', '/login.js', '/webauthn.js', '/style.css', '/icon.svg', '/manifest.webmanifest', '/sw.js', '/api/oo/callback'];
if (!open.includes(pathname) && !pathname.startsWith('/icons/') && !auth.isAuthed(req)) {
if (!user) {
if (open.includes(pathname) || pathname.startsWith('/icons/')) {
if (req.method === 'POST' && req.url.startsWith('/api/oo/callback')) return ooCallback(req, res);
return serveStatic(req, res);
}
if (/^\/(api|sf|plex)\//.test(pathname)) { res.writeHead(401, { 'content-type': 'application/json', 'x-hub-auth': 'required' }); return res.end('{"error":"Sign in required"}'); }
res.writeHead(302, { location: '/login' }); return res.end();
}
if (req.url === '/api/config') {
res.writeHead(200, { 'content-type': 'application/json' });
return res.end(JSON.stringify({ seafile: SEAFILE_URL, onlyoffice: ONLYOFFICE_URL, audiobooks: AUDIOBOOKS_URL, verse: VERSE_URL, podcast: PODCAST_URL, editor: true, jwt: !!JWT_SECRET }));
}
if (req.method === 'POST' && req.url === '/api/oo/config') return ooConfig(req, res);
if (req.method === 'POST' && req.url.startsWith('/api/oo/callback')) return ooCallback(req, res);
if (pathname === '/api/ddg') return ddgAnswer(req, res);
if (pathname === '/api/system') { res.writeHead(200, { 'content-type': 'application/json' }); return res.end(JSON.stringify(storageInfo())); }
if (pathname === '/api/settings') return settings.handle(req, res);
if (pathname === '/api/search') return search.handle(req, res);
if (req.url.startsWith('/api/tasks')) return tasks.handle(req, res);
if (req.url.startsWith('/api/cal/')) return cal.handle(req, res);
if (req.url.startsWith('/api/plex/')) return plexApi(req, res);
if (req.url.startsWith('/plex/') && req.method === 'GET') return plexProxy(req, res);
if (req.url.startsWith('/sf/')) return proxySeafile(req, res);
serveStatic(req, res);
// From here on, everything runs "as" this person: their own folder, and only the tabs they were given.
return users.runAs(user, async () => {
const rule = ACCESS.find(([re]) => re.test(pathname));
if (rule && !rule[1].some(t => users.can(user, t))) return forbidden(res);
if ((pathname === '/api/system' || pathname.startsWith('/api/users')) && user.role !== 'admin') return forbidden(res, 'Only an admin can do this');
if (pathname === '/api/me') { res.writeHead(200, { 'content-type': 'application/json' }); return res.end(JSON.stringify({ ...users.publicView(user), passkeys: auth.passkeyCount(user.id), people: user.role === 'admin' ? users.all().length : undefined })); }
if (pathname.startsWith('/api/users')) return admin.handle(req, res, user);
if (req.url === '/api/config') { // only reveal the addresses of things this person may use
const c = t => users.can(user, t);
res.writeHead(200, { 'content-type': 'application/json' });
return res.end(JSON.stringify({ seafile: c('files') || c('office') ? SEAFILE_URL : '', onlyoffice: c('office') ? ONLYOFFICE_URL : '', audiobooks: c('audio') ? AUDIOBOOKS_URL : '', verse: c('verse') ? VERSE_URL : '',
podcast: c('podcast') ? PODCAST_URL : '', editor: c('office') || c('files'), jwt: !!JWT_SECRET }));
}
if (req.method === 'POST' && req.url === '/api/oo/config') return ooConfig(req, res);
if (pathname === '/api/ddg') return ddgAnswer(req, res);
if (pathname === '/api/system') { res.writeHead(200, { 'content-type': 'application/json' }); return res.end(JSON.stringify(storageInfo())); }
if (pathname === '/api/settings') return settings.handle(req, res);
if (pathname === '/api/search') return search.handle(req, res);
if (req.url.startsWith('/api/tasks')) return tasks.handle(req, res);
if (req.url.startsWith('/api/cal/')) return cal.handle(req, res);
if (req.url.startsWith('/api/plex/')) return plexApi(req, res);
if (req.url.startsWith('/plex/') && req.method === 'GET') return plexProxy(req, res);
if (req.url.startsWith('/sf/')) return proxySeafile(req, res);
return serveStatic(req, res);
});
}
// A bug or bad input in one request must never take the whole server down: log it and answer that request with an error.