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

+1
View File
@@ -18,6 +18,7 @@ jobs:
node-version: 22
- run: npm ci
- run: node tools/smoke-test.js
- run: node tools/users-test.js # accounts: private data, tab limits, invites, migration
- uses: docker/setup-buildx-action@v3
+1 -1
View File
@@ -2,7 +2,7 @@ FROM node:22-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY server.js auth.js cal.js ics.js caldav.js tasks.js settings.js search.js ./
COPY server.js auth.js cal.js ics.js caldav.js tasks.js settings.js search.js users.js admin.js vault.js ./
COPY public ./public
ENV PORT=8080 DATA_DIR=/data
RUN mkdir -p /data && chown node:node /data
+5
View File
@@ -24,6 +24,11 @@ e.g. hub.necloud.us. Settings: `SEAFILE_URL`, `ONLYOFFICE_URL` env vars in docke
Office files open in Seafile's own OnlyOffice integration.
- **Edit here**: opens docs inside the app via OnlyOffice. The server signs each editor config with the JWT secret and saves edits back to Seafile.
- **Sign-in**: the whole app sits behind a passkey (WebAuthn). First run: open the site and enter the setup code (`HUB_SETUP_CODE`, or the one printed in the server log) to create a passkey; add more devices from Settings. Passkeys and the session secret live in `/data` (a Docker volume).
- **People**: you (the admin) add people in Settings -> People, choose which tabs each may use (Files, Office, Music, Audio, Podcast, Podcast Admin, Mail,
Calendar, Tasks, Search; Home and Settings are always on), and give them a one-time invite code to create their own passkey. Everyone's tasks, NE Hub events,
settings, iCloud connection, calendar links and Plex sign-in are private to them (`/data/users/<id>/`). Limits are enforced by the server, not just hidden.
You can change a person's tabs, sign them out everywhere, turn them off, or delete them and their data. An existing single-user install becomes the admin
automatically on first start (the original files are left in place as a backup).
- **Files**: also rename, move, share links, image/PDF/video/audio preview, and New document/spreadsheet/presentation.
- **Calendar**: month grid and agenda. Add, edit and delete events:
- **NE Hub calendar**: events you create are stored in `/data/events.json` and published as a private subscribe link
+37
View File
@@ -0,0 +1,37 @@
// The admin's tools for managing people: add someone, choose their tabs, give them a one-time invite code,
// sign them out everywhere, turn them off, or delete them and their data. Admin only (enforced by the caller).
const users = require('./users');
const auth = require('./auth');
const readBody = req => new Promise((ok, no) => { const c = []; req.on('data', d => c.push(d)); req.on('end', () => ok(Buffer.concat(c).toString())); req.on('error', no); });
const json = (res, code, obj) => { res.writeHead(code, { 'content-type': 'application/json' }); res.end(JSON.stringify(obj)); };
const row = u => ({ ...users.publicView(u), tabsChosen: u.tabs || [], passkeys: auth.passkeyCount(u.id) });
async function handle(req, res, me) {
try {
const p = new URL(req.url, 'http://x').pathname.replace(/\/$/, ''), parts = p.split('/'); // ['', 'api', 'users', id?, action?]
const id = parts[3], action = parts[4];
if (!id && req.method === 'GET') return json(res, 200, { users: users.all().map(row), tabs: users.TABS.map(t => ({ id: t, label: users.TAB_LABELS[t] })), invite: { minutes: auth.INVITE_MINUTES } });
if (!id && req.method === 'POST') {
const b = JSON.parse(await readBody(req) || '{}');
const u = users.create({ name: b.name, role: 'user', tabs: b.tabs });
return json(res, 200, { user: row(u), code: auth.newInvite(u.id), minutes: auth.INVITE_MINUTES });
}
const u = users.get(id); if (!u) return json(res, 404, { error: 'Person not found' });
if (action === 'invite' && req.method === 'POST') return json(res, 200, { code: auth.newInvite(u.id), minutes: auth.INVITE_MINUTES }); // for a new device or to recover access
if (!action && req.method === 'PUT') {
const b = JSON.parse(await readBody(req) || '{}');
if (u.id === me.id && b.disabled) return json(res, 400, { error: 'You cannot turn off your own account' });
users.update(id, { name: b.name, tabs: b.tabs, role: b.role, disabled: b.disabled, signOutEverywhere: b.signOutEverywhere });
return json(res, 200, { user: row(users.get(id)) });
}
if (!action && req.method === 'DELETE') {
if (u.id === me.id) return json(res, 400, { error: 'You cannot delete your own account' });
users.remove(id); auth.removeCredsOf(id);
return json(res, 200, { ok: true });
}
json(res, 404, { error: 'Not found' });
} catch (e) { json(res, 400, { error: e.message }); }
}
module.exports = { handle };
+81 -57
View File
@@ -1,6 +1,6 @@
// Passkey (WebAuthn) sign-in that gates the whole app.
// First passkey: enrolled with a setup code (HUB_SETUP_CODE, or a random one printed to the server log).
// After that, further passkeys can only be added from an already signed-in session.
// Passkey (WebAuthn) sign-in that gates the whole app, for one admin and the people they add.
// The first passkey (the admin) is enrolled with a setup code (HUB_SETUP_CODE, or a random one printed to the server log).
// Everyone else enrols with a one-time invite code the admin creates; a signed-in person can also invite one of their own devices.
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
@@ -8,6 +8,7 @@ const {
generateRegistrationOptions, verifyRegistrationResponse,
generateAuthenticationOptions, verifyAuthenticationResponse,
} = require('@simplewebauthn/server');
const users = require('./users');
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data');
fs.mkdirSync(DATA_DIR, { recursive: true });
@@ -23,11 +24,14 @@ if (!secret) {
if (!secret) { secret = crypto.randomBytes(32).toString('hex'); writeJson('secret.json', { secret }); }
}
const store = readJson('passkeys.json', { userId: crypto.randomBytes(32).toString('base64url'), creds: [] });
const store = readJson('passkeys.json', { creds: [] });
if (!Array.isArray(store.creds)) store.creds = [];
const save = () => writeJson('passkeys.json', store);
// Passkeys made before there were accounts belong to the admin.
{ const admin = users.firstAdmin(); let changed = false; for (const c of store.creds) if (!c.uid && admin) { c.uid = admin.id; changed = true; } if (changed) save(); }
const SETUP_CODE = process.env.HUB_SETUP_CODE || crypto.randomBytes(4).toString('hex');
if (!store.creds.length) console.log(`[auth] No passkeys yet. Setup code: ${SETUP_CODE}`);
if (!users.all().length) console.log(`[auth] No accounts yet. Setup code: ${SETUP_CODE}`);
// Sessions last SESSION_DAYS and slide forward: using the app renews them, so you only sign in again after that long idle.
const SESSION_DAYS = Math.max(1, parseInt(process.env.SESSION_DAYS, 10) || 180);
@@ -35,58 +39,67 @@ const LIFETIME = SESSION_DAYS * 864e5;
const mac = v => crypto.createHmac('sha256', secret).update(v).digest('base64url');
const cookies = 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 isHttps = req => (req.headers['x-forwarded-proto'] || '') === 'https' || (process.env.PUBLIC_URL || '').startsWith('https://');
const safeEq = (a, b) => a.length === b.length && crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b));
function isAuthed(req) {
const [exp, sig] = String(cookies(req).hub_session || '').split('.');
if (!exp || !sig) return false;
const want = mac('s' + exp);
return sig.length === want.length && crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(want)) && Number(exp) > Date.now();
/** Session cookie: uid.epoch.expiry.signature. (Older single-user cookies, expiry.signature, count as the admin.) */
function currentUser(req) {
const parts = String(cookies(req).hub_session || '').split('.');
let user = null, exp;
if (parts.length === 4) {
const [uid, epoch, e, sig] = parts; exp = Number(e);
const u = users.get(uid);
if (u && !u.disabled && String(u.epoch) === epoch && safeEq(sig, mac(`s${uid}.${epoch}.${e}`))) user = u;
} else if (parts.length === 2) {
const [e, sig] = parts; exp = Number(e);
if (safeEq(sig, mac('s' + e))) user = users.firstAdmin();
}
return user && exp > Date.now() ? user : null;
}
const sessionCookie = req => { const exp = Date.now() + LIFETIME;
return `hub_session=${exp}.${mac('s' + exp)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${SESSION_DAYS * 86400}${isHttps(req) ? '; Secure' : ''}`; };
const isAuthed = req => !!currentUser(req);
const sessionCookie = (req, user) => { const exp = Date.now() + LIFETIME;
return `hub_session=${user.id}.${user.epoch}.${exp}.${mac(`s${user.id}.${user.epoch}.${exp}`)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${SESSION_DAYS * 86400}${isHttps(req) ? '; Secure' : ''}`; };
// Add a Set-Cookie without clobbering ones other code already set on this response.
const addCookie = (res, c) => { const cur = res.getHeader('Set-Cookie'); res.setHeader('Set-Cookie', cur ? [].concat(cur, c) : c); };
function startSession(req, res) { addCookie(res, sessionCookie(req)); }
/** Renew the session cookie when it is more than a day old, so an active session never runs out. */
function refresh(req, res) {
const [exp] = String(cookies(req).hub_session || '').split('.');
if (Number(exp) - Date.now() < LIFETIME - 864e5) addCookie(res, sessionCookie(req));
function startSession(req, res, user) { addCookie(res, sessionCookie(req, user)); }
/** Renew the cookie when it is more than a day old (and upgrade old-style cookies), so an active session never runs out. */
function refresh(req, res, user) {
const parts = String(cookies(req).hub_session || '').split('.');
const exp = Number(parts.length === 4 ? parts[2] : parts[0]);
if (parts.length !== 4 || exp - Date.now() < LIFETIME - 864e5) addCookie(res, sessionCookie(req, user));
}
const challenges = new Map(); // cid -> { challenge, kind, exp }
const challenges = new Map(); // cid -> { challenge, kind, extra, exp }
const newChallenge = (challenge, kind, extra) => {
for (const [k, v] of challenges) if (v.exp < Date.now()) challenges.delete(k);
const cid = crypto.randomBytes(16).toString('base64url');
challenges.set(cid, { challenge, kind, extra, exp: Date.now() + 5 * 60e3 });
return cid;
};
const takeChallenge = (cid, kind) => {
const c = challenges.get(cid); challenges.delete(cid);
return c && c.kind === kind && c.exp > Date.now() ? c.challenge : null;
};
const takeRecord = cid => { const c = challenges.get(cid); challenges.delete(cid); return c && c.exp > Date.now() ? c : null; };
// One-time invite codes: a signed-in device creates one, a new device uses it to enrol its own passkey.
const invites = new Map(); // code -> expiry
// One-time invite codes: they let a device enrol its own passkey for a particular person.
const invites = new Map(); // code -> { uid, exp }
const INVITE_MINUTES = 10;
const newInvite = () => {
for (const [k, v] of invites) if (v < Date.now()) invites.delete(k);
function newInvite(uid) {
for (const [k, v] of invites) if (v.exp < Date.now()) invites.delete(k);
const a = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789'; // no look-alike characters
let code = ''; for (const b of crypto.randomBytes(8)) code += a[b % a.length];
code = code.slice(0, 4) + '-' + code.slice(4);
invites.set(code, Date.now() + INVITE_MINUTES * 60e3);
invites.set(code, { uid, exp: Date.now() + INVITE_MINUTES * 60e3 });
return code;
};
}
const normInvite = c => { const t = String(c || '').toUpperCase().replace(/[^A-Z0-9]/g, ''); return t.length === 8 ? t.slice(0, 4) + '-' + t.slice(4) : ''; };
const inviteValid = c => { const k = normInvite(c); return !!k && (invites.get(k) || 0) > Date.now() ? k : null; };
const inviteFor = c => { const k = normInvite(c), v = k && invites.get(k); return v && v.exp > Date.now() && users.get(v.uid) && !users.get(v.uid).disabled ? { code: k, uid: v.uid } : null; };
const RATE_LIMIT = Math.max(1, parseInt(process.env.AUTH_RATE_LIMIT, 10) || 10); // attempts per minute per address; raised only by the tests
const hits = new Map(); // crude per-IP throttle on anything that checks a secret
const throttled = req => {
const ip = (req.headers['x-forwarded-for'] || req.socket.remoteAddress || '').split(',')[0].trim();
const h = hits.get(ip) || { n: 0, t: Date.now() };
if (Date.now() - h.t > 60e3) { h.n = 0; h.t = Date.now(); }
h.n++; hits.set(ip, h);
return h.n > 10;
return h.n > RATE_LIMIT;
};
// When PUBLIC_URL is set it is the source of truth for the passkey origin, so a reverse proxy that
@@ -102,11 +115,13 @@ const equalStr = (a, b) => { const x = Buffer.from(String(a)), y = Buffer.from(S
const readBody = req => new Promise((ok, no) => { const c = []; req.on('data', d => c.push(d)); req.on('end', () => ok(Buffer.concat(c).toString())); req.on('error', no); });
const send = (res, code, obj) => { res.writeHead(code, { 'content-type': 'application/json', ...(res.getHeader('Set-Cookie') ? { 'Set-Cookie': res.getHeader('Set-Cookie') } : {}) }); res.end(JSON.stringify(obj)); };
const credsOf = uid => store.creds.filter(c => c.uid === uid);
async function handle(req, res) {
const url = new URL(req.url, 'http://x'), p = url.pathname;
try {
if (p === '/api/auth/state') return send(res, 200, { registered: store.creds.length > 0, authed: isAuthed(req) });
const me = currentUser(req);
if (p === '/api/auth/state') return send(res, 200, { registered: store.creds.length > 0, authed: !!me });
if (p === '/api/auth/logout' && req.method === 'POST') {
addCookie(res, 'hub_session=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0');
return send(res, 200, { ok: true });
@@ -115,39 +130,42 @@ async function handle(req, res) {
const { rpID, origin } = rp(req);
if (p === '/api/auth/register/options' && req.method === 'POST') {
const authed = isAuthed(req);
let kind = 'reg-authed', extra;
if (!authed) {
let kind, uid, name, handle;
if (me) { kind = 'reg-authed'; uid = me.id; name = me.name; handle = me.handle; }
else {
if (throttled(req)) return send(res, 429, { error: 'Too many attempts. Wait a minute.' });
if (!store.creds.length && equalStr(body.setupCode || '', SETUP_CODE)) kind = 'reg-setup';
else if (body.inviteCode && inviteValid(body.inviteCode)) { kind = 'reg-invite'; extra = inviteValid(body.inviteCode); }
const inv = body.inviteCode && inviteFor(body.inviteCode);
if (!users.all().length && equalStr(body.setupCode || '', SETUP_CODE)) { kind = 'reg-setup'; name = 'Admin'; handle = crypto.randomBytes(32).toString('base64url'); }
else if (inv) { kind = 'reg-invite'; uid = inv.uid; const u = users.get(uid); name = u.name; handle = u.handle; }
else return send(res, 403, { error: body.inviteCode ? 'That invite code is wrong or has expired' : 'Wrong setup code' });
}
const options = await generateRegistrationOptions({
rpName: 'NE Hub', rpID, userName: 'NE Hub owner', userID: Buffer.from(store.userId, 'base64url'),
rpName: 'NE Hub', rpID, userName: name, userID: Buffer.from(handle, 'base64url'),
attestationType: 'none',
excludeCredentials: store.creds.map(c => ({ id: c.id, transports: c.transports })),
excludeCredentials: (uid ? credsOf(uid) : []).map(c => ({ id: c.id, transports: c.transports })),
authenticatorSelection: { residentKey: 'required', userVerification: 'preferred' },
});
return send(res, 200, { cid: newChallenge(options.challenge, kind, extra), options });
return send(res, 200, { cid: newChallenge(options.challenge, kind, { uid, handle, code: body.inviteCode && inviteFor(body.inviteCode) ? inviteFor(body.inviteCode).code : undefined }), options });
}
if (p === '/api/auth/register/verify' && req.method === 'POST') {
const authed = isAuthed(req);
if (!authed && throttled(req)) return send(res, 429, { error: 'Too many attempts. Wait a minute.' });
if (!me && throttled(req)) return send(res, 429, { error: 'Too many attempts. Wait a minute.' });
const rec = takeRecord(body.cid);
if (!rec) return send(res, 400, { error: 'Request expired, try again' });
const okKind = rec.kind === 'reg-authed' ? authed
: rec.kind === 'reg-setup' ? !store.creds.length
: rec.kind === 'reg-invite' ? !!inviteValid(rec.extra) : false;
const ex = rec.extra || {};
const okKind = rec.kind === 'reg-authed' ? !!me && me.id === ex.uid
: rec.kind === 'reg-setup' ? !users.all().length
: rec.kind === 'reg-invite' ? !!inviteFor(ex.code) && !!users.get(ex.uid) : false;
if (!okKind) return send(res, 403, { error: 'Not allowed' });
const v = await verifyRegistrationResponse({ response: body.response, expectedChallenge: rec.challenge, expectedOrigin: origin, expectedRPID: rpID });
if (!v.verified) return send(res, 400, { error: 'Could not verify passkey' });
let user = ex.uid ? users.get(ex.uid) : null;
if (rec.kind === 'reg-setup') user = users.create({ name: 'Admin', role: 'admin', handle: ex.handle });
const c = v.registrationInfo.credential;
store.creds.push({ id: c.id, publicKey: Buffer.from(c.publicKey).toString('base64url'), counter: c.counter, transports: c.transports || [],
name: String(body.name || 'Passkey').slice(0, 40), created: Date.now() });
name: String(body.name || 'Passkey').slice(0, 40), created: Date.now(), uid: user.id });
save();
if (rec.kind === 'reg-invite') invites.delete(rec.extra); // single use
if (!authed) startSession(req, res);
if (rec.kind === 'reg-invite') invites.delete(ex.code); // single use
if (!me) startSession(req, res, user);
return send(res, 200, { ok: true });
}
if (p === '/api/auth/login/options' && req.method === 'POST') {
@@ -157,32 +175,38 @@ async function handle(req, res) {
}
if (p === '/api/auth/login/verify' && req.method === 'POST') {
if (throttled(req)) return send(res, 429, { error: 'Too many attempts. Wait a minute.' });
const challenge = takeChallenge(body.cid, 'login');
const rec = takeRecord(body.cid);
const cred = store.creds.find(c => c.id === body.response?.id);
if (!challenge || !cred) return send(res, 400, { error: 'Passkey not recognised' });
const user = cred && users.get(cred.uid);
if (!rec || rec.kind !== 'login' || !cred || !user) return send(res, 400, { error: 'Passkey not recognised' });
if (user.disabled) return send(res, 403, { error: 'This account has been turned off' });
const v = await verifyAuthenticationResponse({
response: body.response, expectedChallenge: challenge, expectedOrigin: origin, expectedRPID: rpID,
response: body.response, expectedChallenge: rec.challenge, expectedOrigin: origin, expectedRPID: rpID,
credential: { id: cred.id, publicKey: Buffer.from(cred.publicKey, 'base64url'), counter: cred.counter, transports: cred.transports },
});
if (!v.verified) return send(res, 401, { error: 'Sign-in failed' });
cred.counter = v.authenticationInfo.newCounter; cred.lastUsed = Date.now(); save();
startSession(req, res);
startSession(req, res, user);
return send(res, 200, { ok: true });
}
// Everything below needs a signed-in session.
if (!isAuthed(req)) return send(res, 401, { error: 'Not signed in' });
if (p === '/api/auth/invite' && req.method === 'POST') {
return send(res, 200, { code: newInvite(), minutes: INVITE_MINUTES });
if (!me) return send(res, 401, { error: 'Not signed in' });
if (p === '/api/auth/invite' && req.method === 'POST') { // an invite for one of my own devices
return send(res, 200, { code: newInvite(me.id), minutes: INVITE_MINUTES });
}
if (p === '/api/auth/passkeys' && req.method === 'GET') {
return send(res, 200, store.creds.map(c => ({ id: c.id, name: c.name, created: c.created, lastUsed: c.lastUsed || null })));
return send(res, 200, credsOf(me.id).map(c => ({ id: c.id, name: c.name, created: c.created, lastUsed: c.lastUsed || null })));
}
if (p === '/api/auth/passkeys' && req.method === 'DELETE') {
const mine = credsOf(me.id); if (!mine.some(c => c.id === body.id)) return send(res, 404, { error: 'Passkey not found' });
store.creds = store.creds.filter(c => c.id !== body.id); save();
return send(res, 200, { ok: true, remaining: store.creds.length });
return send(res, 200, { ok: true, remaining: credsOf(me.id).length });
}
send(res, 404, { error: 'Not found' });
} catch (e) { send(res, 400, { error: e.message }); }
}
module.exports = { handle, isAuthed, refresh, addCookie, secret, SESSION_DAYS };
const passkeyCount = uid => credsOf(uid).length;
function removeCredsOf(uid) { store.creds = store.creds.filter(c => c.uid !== uid); save(); }
module.exports = { handle, currentUser, isAuthed, refresh, addCookie, secret, SESSION_DAYS, newInvite, INVITE_MINUTES, passkeyCount, removeCredsOf };
+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)
+6 -6
View File
@@ -6,20 +6,20 @@ const path = require('path');
const crypto = require('crypto');
const auth = require('./auth');
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data');
const FILE = path.join(DATA_DIR, 'icloud.json');
const users = require('./users');
const file = () => path.join(users.dir(), 'icloud.json'); // each person connects their own iCloud
const TEST = process.env.CALDAV_TEST === '1'; // lets the tests talk to a local stand-in server; never set in production
const BASE = TEST ? process.env.CALDAV_BASE : 'https://caldav.icloud.com';
// ---- Encrypted credential store
const key = () => crypto.scryptSync(auth.secret, 'nehub-icloud', 32);
let _key; const key = () => _key || (_key = crypto.scryptSync(auth.secret, 'nehub-icloud', 32));
const seal = obj => { const iv = crypto.randomBytes(12), c = crypto.createCipheriv('aes-256-gcm', key(), iv);
const d = Buffer.concat([c.update(JSON.stringify(obj), 'utf8'), c.final()]); return Buffer.concat([iv, c.getAuthTag(), d]).toString('base64'); };
const unseal = b64 => { const b = Buffer.from(b64, 'base64'), d = crypto.createDecipheriv('aes-256-gcm', key(), b.subarray(0, 12));
d.setAuthTag(b.subarray(12, 28)); return JSON.parse(Buffer.concat([d.update(b.subarray(28)), d.final()]).toString('utf8')); };
function load() { try { return unseal(JSON.parse(fs.readFileSync(FILE, 'utf8')).data); } catch { return null; } }
function save(o) { fs.mkdirSync(DATA_DIR, { recursive: true }); fs.writeFileSync(FILE, JSON.stringify({ data: seal(o) }), { mode: 0o600 }); }
function wipe() { try { fs.unlinkSync(FILE); } catch {} }
function load() { try { return unseal(JSON.parse(fs.readFileSync(file(), 'utf8')).data); } catch { return null; } }
function save(o) { fs.writeFileSync(file(), JSON.stringify({ data: seal(o) }), { mode: 0o600 }); }
function wipe() { try { fs.unlinkSync(file()); } catch {} }
// ---- HTTP + XML
const allowed = u => TEST ? ['localhost', '127.0.0.1'].includes(u.hostname) : u.protocol === 'https:' && (u.hostname === 'icloud.com' || u.hostname.endsWith('.icloud.com'));
+124 -38
View File
@@ -23,18 +23,26 @@ function flushSettings() {
}
const queueSetting = (k, v) => { if (!SYNCED.has(k)) return; outbox.set(k, v); clearTimeout(outTimer); outTimer = setTimeout(flushSettings, 400); };
addEventListener('pagehide', () => { if (outbox.size) flushSettings(); });
// Several people can use the same browser, so everything kept in the browser is filed under the signed-in person.
let ME = null;
const nk = k => (ME ? ME.id + ':' + k : k);
const store = {
get(k, d) { try { return JSON.parse(localStorage.getItem(k)) ?? d; } catch { return d; } },
set(k, v) { try { localStorage.setItem(k, JSON.stringify(v)); } catch {} queueSetting(k, v); },
del(k) { try { localStorage.removeItem(k); } catch {} queueSetting(k, null); },
get(k, d) { try { let v = localStorage.getItem(nk(k)); if (v === null && ME && ME.role === 'admin') v = localStorage.getItem(k); /* kept from before accounts existed */ return JSON.parse(v) ?? d; } catch { return d; } },
set(k, v) { try { localStorage.setItem(nk(k), JSON.stringify(v)); } catch {} queueSetting(k, v); },
del(k) { try { localStorage.removeItem(nk(k)); if (ME && ME.role === 'admin') localStorage.removeItem(k); } catch {} queueSetting(k, null); },
};
async function loadMe() {
try { const r = await fetch('/api/me'); if (r.ok) { ME = await r.json(); try { localStorage.setItem('me_cache', JSON.stringify(ME)); } catch {} } }
catch { try { ME = JSON.parse(localStorage.getItem('me_cache')); } catch {} } // offline: the last known access (the server still decides)
}
const allowed = id => !ME || ME.tabs.includes(id);
/** Pull saved settings from the server into this browser; push up anything only this browser has (first run / migration). */
async function loadSettings() {
try {
const r = await fetch('/api/settings'); if (!r.ok) return;
const { values } = await r.json();
for (const k of SYNCED) {
if (k in values) { try { localStorage.setItem(k, JSON.stringify(values[k])); } catch {} }
if (k in values) { try { localStorage.setItem(nk(k), JSON.stringify(values[k])); } catch {} }
else { const local = store.get(k); if (local !== undefined && local !== null) queueSetting(k, local); }
}
} catch {} // offline: keep using what this browser has
@@ -134,17 +142,18 @@ function renderNav(active = activeTab) {
activeTab = active;
const tab = ([id, label]) => `<a href="#/${id}" class="${id === active ? 'on' : ''}" ${id === active ? 'aria-current="page"' : ''}>${icon(TAB_ICON[id])}<span>${label}</span></a>`;
const brand = `<div class="brand"><img src="/icon.svg" alt=""><span>NE Hub</span></div>`;
const mine = TABS.filter(t => allowed(t[0]));
if (phone.matches) {
const more = !PRIMARY.includes(active);
$('#nav').innerHTML = TABS.filter(t => PRIMARY.includes(t[0])).map(tab).join('') +
`<a href="#" id="more" class="${more ? 'on' : ''}" role="button">${icon('more')}<span>More</span></a>`;
$('#more').onclick = e => { e.preventDefault(); openMore(); };
} else $('#nav').innerHTML = brand + TABS.map(tab).join('');
const rest = mine.filter(t => !PRIMARY.includes(t[0])), more = !PRIMARY.includes(active);
$('#nav').innerHTML = mine.filter(t => PRIMARY.includes(t[0])).map(tab).join('') +
(rest.length ? `<a href="#" id="more" class="${more ? 'on' : ''}" role="button">${icon('more')}<span>More</span></a>` : '');
const mo = $('#more'); if (mo) mo.onclick = e => { e.preventDefault(); openMore(); };
} else $('#nav').innerHTML = brand + mine.map(tab).join('');
}
phone.addEventListener('change', () => renderNav());
function openMore() {
const el = openSheet('More', '', `<div class="list">${TABS.filter(t => !PRIMARY.includes(t[0])).map(([id, label]) => `
const el = openSheet('More', '', `<div class="list">${TABS.filter(t => !PRIMARY.includes(t[0]) && allowed(t[0])).map(([id, label]) => `
<a class="row" href="#/${id}">${icon(TAB_ICON[id])}<span class="grow"><b>${label}</b></span>${icon('chev', 'chev')}</a>`).join('')}</div>`);
el.querySelectorAll('a.row').forEach(a => a.addEventListener('click', closeSheet));
}
@@ -153,7 +162,8 @@ const routes = { home: viewHome, mail: viewMail, files: viewFiles, office: viewO
let editor;
function route() {
const [, id = 'home', ...rest] = location.hash.split('/');
const name = routes[id] ? id : 'home';
let name = routes[id] ? id : 'home';
if (name === 'edit' ? !(allowed('files') || allowed('office')) : !allowed(name)) { name = 'home'; if (location.hash !== '#/home') history.replaceState(null, '', '#/home'); } // not one of their tabs
try { editor?.destroyEditor(); } catch {}
editor = null;
document.body.classList.toggle('editing', name === 'edit');
@@ -176,30 +186,31 @@ function viewHome() {
const recents = store.get('recents', []).slice(0, 5);
const t = typeof cur === 'function' ? cur() : null;
$('#view').innerHTML = `<div class="wrap"><h1>${greet}</h1>
<a class="searchbar" href="#/search">${icon('search')}<span>Search files, music, libraries</span></a>
${t ? `<h2>Now playing</h2><button class="row card2" id="hnp"><img class="art" style="width:48px;height:48px;border-radius:8px" alt="" src="${px.art(t.thumb, 100)}"><span class="grow"><b>${esc(t.title)}</b><small>${esc(t.artist)}</small></span></button>` : ''}
${allowed('search') ? `<a class="searchbar" href="#/search">${icon('search')}<span>Search tasks, events, files, music</span></a>` : ''}
${t && allowed('music') ? `<h2>Now playing</h2><button class="row card2" id="hnp"><img class="art" style="width:48px;height:48px;border-radius:8px" alt="" src="${px.art(t.thumb, 100)}"><span class="grow"><b>${esc(t.title)}</b><small>${esc(t.artist)}</small></span></button>` : ''}
<div id="tcard"></div>
<div id="agenda"></div>
${recents.length ? `<h2>Recent files</h2><div class="list">${recents.map((r, i) => `<button class="row" data-r="${i}"><span class="ic" style="background:${OFFICE_EXT.has(ext(r.name)) ? '#e0742b' : '#6a7280'}">${icon('file')}</span><span class="grow"><b>${esc(r.name)}</b><small>${esc(r.path.slice(0, r.path.lastIndexOf('/')) || '/')}</small></span></button>`).join('')}</div>` : ''}
${recents.length && allowed('files') ? `<h2>Recent files</h2><div class="list">${recents.map((r, i) => `<button class="row" data-r="${i}"><span class="ic" style="background:${OFFICE_EXT.has(ext(r.name)) ? '#e0742b' : '#6a7280'}">${icon('file')}</span><span class="grow"><b>${esc(r.name)}</b><small>${esc(r.path.slice(0, r.path.lastIndexOf('/')) || '/')}</small></span></button>`).join('')}</div>` : ''}
<h2>Apps</h2><div class="grid">
${tile('#/files', '#2f9e6e', 'files', 'Files', 'Seafile')}
${tile('#/music', '#e5a00d', 'music', 'Music', 'Plex')}
${tile('#/calendar', '#d9453a', 'cal', 'Calendar', 'Agenda')}
${tile('#/office', '#e0742b', 'doc', 'Office', 'OnlyOffice')}
${tile('#/audio', '#8a5cf0', 'audio', 'Audio', 'Audiobooks')}
${tile('#/podcast', '#c2417a', 'mic', 'Podcast', 'Podcasts')}
${tile(cfg.verse, '#0f9d9a', 'book', 'Podcast Admin', 'Opens in a new tab', true)}
${tile('#/mail', '#4f7cff', 'mail', 'Mail', '4 accounts')}
${allowed('files') ? tile('#/files', '#2f9e6e', 'files', 'Files', 'Seafile') : ''}
${allowed('music') ? tile('#/music', '#e5a00d', 'music', 'Music', 'Plex') : ''}
${allowed('tasks') ? tile('#/tasks', '#4f7cff', 'check', 'Tasks', 'To-do lists') : ''}
${allowed('calendar') ? tile('#/calendar', '#d9453a', 'cal', 'Calendar', 'Agenda') : ''}
${allowed('office') ? tile('#/office', '#e0742b', 'doc', 'Office', 'OnlyOffice') : ''}
${allowed('audio') ? tile('#/audio', '#8a5cf0', 'audio', 'Audio', 'Audiobooks') : ''}
${allowed('podcast') ? tile('#/podcast', '#c2417a', 'mic', 'Podcast', 'Podcasts') : ''}
${allowed('verse') && cfg.verse ? tile(cfg.verse, '#0f9d9a', 'book', 'Podcast Admin', 'Opens in a new tab', true) : ''}
${allowed('mail') ? tile('#/mail', '#4f7cff', 'mail', 'Mail', '4 accounts') : ''}
</div>
<h2>Mail</h2><div class="grid">
${allowed('mail') ? `<h2>Mail</h2><div class="grid">
${MAIL.map(m => tile(mailUrl(m, 'inbox'), m.color, 'mail', m.name, 'Open inbox', true)).join('')}
</div></div>`;
</div>` : ''}</div>`;
const np = $('#hnp'); if (np) np.onclick = openNow;
$('#view').querySelectorAll('button[data-r]').forEach(b => b.onclick = () => {
const r = recents[b.dataset.r]; openFile(r.repo, r.path, r.name, r.path.slice(0, r.path.lastIndexOf('/')) || '/');
});
renderTasksCard($('#tcard'));
if (typeof renderAgenda === 'function') renderAgenda($('#agenda'), 'today');
if (allowed('tasks')) renderTasksCard($('#tcard'));
if (allowed('calendar') && typeof renderAgenda === 'function') renderAgenda($('#agenda'), 'today');
}
// Name search by listing each library recursively (cached for the session).
@@ -1104,39 +1115,113 @@ function openNow() {
syncUI();
}
// ---- People (admin): who can sign in, which tabs each may use, one-time invite codes
let peopleTabs = [];
async function loadPeople() {
const box = $('#people'); if (!box) return;
try {
const r = await jfetch('/api/users'); peopleTabs = r.tabs;
if (!$('#people')) return;
$('#people').innerHTML = r.users.map(u => `<button class="row" data-p="${esc(u.id)}"><span class="ic" style="background:${u.role === 'admin' ? '#4f7cff' : '#6a7280'}">${esc(u.name.slice(0, 1).toUpperCase())}</span>
<span class="grow"><b>${esc(u.name)}${u.id === ME.id ? ' (you)' : ''}${u.role === 'admin' ? ' · Admin' : ''}${u.disabled ? ' · Off' : ''}</b>
<small style="white-space:normal">${u.passkeys ? u.passkeys + ' passkey' + (u.passkeys === 1 ? '' : 's') : 'No passkey yet (waiting for the invite code)'} · ${u.role === 'admin' ? 'Everything' : u.tabsChosen.map(t => (peopleTabs.find(x => x.id === t) || {}).label || t).join(', ') || 'Home and Settings only'}</small></span>${icon('chev', 'chev')}</button>`).join('');
$('#people').querySelectorAll('button[data-p]').forEach(b => b.onclick = () => personSheet(r.users.find(u => u.id === b.dataset.p)));
} catch (e) { box.innerHTML = `<div class="row"><small class="err">${esc(e.message)}</small></div>`; }
}
const tabBoxes = (chosen, disabled) => `<div class="list" style="background:var(--panel2);border:0">${peopleTabs.map(t => `<label class="row" style="cursor:pointer"><span class="grow"><b>${esc(t.label)}</b></span>
<input type="checkbox" data-tab="${esc(t.id)}" ${chosen.includes(t.id) ? 'checked' : ''} ${disabled ? 'disabled' : ''}></label>`).join('')}</div>`;
const pickedTabs = el => [...el.querySelectorAll('input[data-tab]:checked')].map(i => i.dataset.tab);
function codeSheet(name, code, minutes) {
const el = openSheet('Invite code for ' + name, '', `<p class="sub">On ${esc(name)}'s device, open <b>${esc(location.origin)}</b>, tap <b>New device? Use an invite code</b> on the sign-in page, and enter:</p>
<p style="font:700 34px ui-monospace,monospace;letter-spacing:.08em;text-align:center;margin:14px 0">${esc(code)}</p>
<p class="sub" style="text-align:center">It works once and expires in ${minutes} minutes. They will then create their own passkey.</p>
<div class="bar"><button class="btn ghost" id="cc" style="flex:1;justify-content:center">${icon('copy')}Copy code</button><button class="btn" id="sx" style="flex:1;justify-content:center">Done</button></div>`);
el.querySelector('#sx').onclick = () => { closeSheet(); loadPeople(); };
el.querySelector('#cc').onclick = async () => { try { await navigator.clipboard.writeText(code); toast('Code copied'); } catch { toast('Could not copy'); } };
}
async function addPersonSheet() {
if (!peopleTabs.length) { try { peopleTabs = (await jfetch('/api/users')).tabs; } catch (e) { return toast(e.message); } }
const el = openSheet('Add a person', '', `<form id="pf"><label for="pn">Name</label><input type="text" id="pn" autocomplete="off" required placeholder="Sam">
<label>What can they use?</label>${tabBoxes(['tasks', 'calendar', 'search'])}
<p class="sub" style="margin:8px 0 0">They always get Home and Settings, and their own private tasks, events and settings. You can change this later.</p>
<div class="bar"><button class="btn ghost" type="button" id="sx" style="flex:1;justify-content:center">Cancel</button><button class="btn" style="flex:1;justify-content:center">Create</button></div><div class="err" id="pe"></div></form>`);
el.querySelector('.sheet').classList.add('wide'); el.querySelector('#sx').onclick = closeSheet;
el.querySelector('#pf').onsubmit = async e => {
e.preventDefault(); const btn = el.querySelector('form .btn:not(.ghost)'); btn.disabled = true;
try { const r = await jfetch('/api/users', { method: 'POST', body: JSON.stringify({ name: el.querySelector('#pn').value, tabs: pickedTabs(el) }) }); codeSheet(r.user.name, r.code, r.minutes); }
catch (err) { btn.disabled = false; el.querySelector('#pe').textContent = err.message; }
};
}
function personSheet(u) {
const self = u.id === ME.id, admin = u.role === 'admin';
const el = openSheet(u.name, admin ? 'Admin' : u.disabled ? 'Turned off' : 'User', `<form id="uf"><label for="un">Name</label><input type="text" id="un" value="${esc(u.name)}" autocomplete="off" required>
${admin ? '<p class="sub">Admins can use everything.</p>' : `<label>What can they use?</label>${tabBoxes(u.tabsChosen)}`}
<div class="bar"><button class="btn ghost" type="button" id="sx" style="flex:1;justify-content:center">Close</button><button class="btn" style="flex:1;justify-content:center">Save</button></div><div class="err" id="ue"></div></form>
<div class="list" style="background:var(--panel2);border:0;margin-top:6px">
<button class="row" id="u-inv">${icon('copy')}<span class="grow"><b>Invite code for a new device</b><small>Also the way back in if they lose their device</small></span></button>
<button class="row" id="u-out">${icon('back')}<span class="grow"><b>Sign out everywhere</b></span></button>
${self ? '' : `<button class="row" id="u-off">${icon('more')}<span class="grow"><b>${u.disabled ? 'Turn back on' : 'Turn off'}</b><small>${u.disabled ? 'Let them sign in again' : 'Signs them out and blocks sign-in; their data is kept'}</small></span></button>`}
<button class="row" id="u-role">${icon('check')}<span class="grow"><b>${admin ? 'Remove admin' : 'Make admin'}</b></span></button>
${self ? '' : `<button class="row dng" id="u-del">${icon('trash')}<span class="grow"><b>Delete person and their data</b></span></button>`}</div>`);
el.querySelector('.sheet').classList.add('wide'); el.querySelector('#sx').onclick = closeSheet;
const act = async (fn, msg) => { try { await fn(); if (msg) toast(msg); closeSheet(); loadPeople(); } catch (err) { toast(err.message); } };
const put = body => jfetch('/api/users/' + encodeURIComponent(u.id), { method: 'PUT', body: JSON.stringify(body) });
el.querySelector('#uf').onsubmit = e => { e.preventDefault(); act(() => put({ name: el.querySelector('#un').value, ...(admin ? {} : { tabs: pickedTabs(el) }) }), 'Saved'); };
el.querySelector('#u-inv').onclick = async () => { try { const r = await jfetch('/api/users/' + encodeURIComponent(u.id) + '/invite', { method: 'POST', body: '{}' }); codeSheet(u.name, r.code, r.minutes); } catch (err) { toast(err.message); } };
el.querySelector('#u-out').onclick = () => act(() => put({ signOutEverywhere: true }), u.name + ' was signed out everywhere');
const off = el.querySelector('#u-off'); if (off) off.onclick = () => act(() => put({ disabled: !u.disabled }), u.disabled ? 'Turned back on' : 'Turned off');
el.querySelector('#u-role').onclick = () => act(() => put({ role: admin ? 'user' : 'admin' }), admin ? 'No longer an admin' : 'Now an admin');
const del = el.querySelector('#u-del'); if (del) del.onclick = () => confirmSheet('Delete ' + u.name + '?', 'Their tasks, events, settings and sign-ins are permanently deleted.', 'Delete', () => act(() => jfetch('/api/users/' + encodeURIComponent(u.id), { method: 'DELETE' }), 'Deleted'));
}
function viewSettings() {
const signed = !!sf.token();
const isAdmin = ME && ME.role === 'admin';
const tabNames = ME ? TABS.filter(t => ME.tabs.includes(t[0]) && !['home', 'settings'].includes(t[0])).map(t => t[1]).join(', ') : '';
$('#view').innerHTML = `<div class="wrap"><h1>Settings</h1>
<h2>Storage</h2><div class="list" id="stor"><div class="row"><span class="grow"><small>Checking…</small></span></div></div>
${ME ? `<h2>Signed in as</h2><div class="list"><div class="row"><span class="grow"><b>${esc(ME.name)}</b><small style="white-space:normal">${isAdmin ? 'Admin · can use everything and manage people' : 'Can use: ' + esc(tabNames || 'Home and Settings only')}</small></span></div></div>` : ''}
${isAdmin ? `<h2>People</h2><div class="list" id="people"><div class="row"><span class="grow"><small>Loading…</small></span></div></div>
<div class="bar"><button class="btn" id="addperson">Add a person</button></div>
<h2>Storage</h2><div class="list" id="stor"><div class="row"><span class="grow"><small>Checking…</small></span></div></div>` : ''}
<h2>Passkeys</h2><div class="list" id="pks"><div class="row"><span class="grow"><small>Loading…</small></span></div></div>
<div class="bar"><button class="btn ghost" id="addpk">Add a passkey</button><button class="btn ghost" id="invpk">Invite a device</button><button class="btn danger" id="hubout">Sign out of NE Hub</button></div>
<h2>Connections</h2><div class="list">
<div class="row"><span class="grow"><b>Seafile</b><small>${esc(cfg.seafile)} · ${signed ? 'signed in' : 'signed out'}</small></span></div>
<div class="row"><span class="grow"><b>Plex</b><small id="plexst">Checking…</small></span><button class="btn danger" id="plexout" hidden>Sign out</button></div>
<div class="row"><span class="grow"><b>OnlyOffice</b><small>${esc(cfg.onlyoffice)}</small></span></div>
<div id="sec-conn"><h2>Connections</h2><div class="list">
<div class="row" id="row-sf"><span class="grow"><b>Seafile</b><small>${esc(cfg.seafile)} · ${signed ? 'signed in' : 'signed out'}</small></span></div>
<div class="row" id="row-plex"><span class="grow"><b>Plex</b><small id="plexst">Checking…</small></span><button class="btn danger" id="plexout" hidden>Sign out</button></div>
<div class="row" id="row-oo"><span class="grow"><b>OnlyOffice</b><small>${esc(cfg.onlyoffice)}</small></span></div>
</div>
${signed ? '<div class="bar"><button class="btn danger" id="out">Sign out of Seafile</button></div>' : ''}
<h2>Mail accounts</h2><p class="sub">Your address for each account, so its inbox and new-message buttons open the right mailbox. Saved on your server, so every device sees them. Passwords are never saved here.</p>
${signed ? '<div class="bar"><button class="btn danger" id="out">Sign out of Seafile</button></div>' : ''}</div>
<div id="sec-mail"><h2>Mail accounts</h2><p class="sub">Your address for each account, so its inbox and new-message buttons open the right mailbox. Saved on your server, so every device sees them. Passwords are never saved here.</p>
<div class="card" style="max-width:none">${MAIL.filter(m => m.id !== 'icloud').map(m => `<label for="h-${m.id}">${esc(m.name)}</label>
<input type="email" id="h-${m.id}" data-id="${m.id}" placeholder="you@example.com" autocomplete="off" autocapitalize="off" value="${esc(mailHints()[m.id] || '')}">`).join('')}
<p class="sub" style="margin-top:12px">iCloud Mail can't take a hint; use Keychain autofill or stay signed in.</p></div>
<h2>Audiobooks sign-in</h2><p class="sub">Saved on your server (encrypted) and shared with all your devices. Leave blank to skip. Use the copy buttons on the Audio tab to paste them into the site's sign-in form.</p>
</div><div id="sec-audio"><h2>Audiobooks sign-in</h2><p class="sub">Saved on your server (encrypted) and shared with all your devices. Leave blank to skip. Use the copy buttons on the Audio tab to paste them into the site's sign-in form.</p>
<div class="card" style="max-width:none"><label for="ab-u">Username</label>
<input type="text" id="ab-u" autocomplete="off" autocapitalize="off" value="${esc(abCreds().u)}">
<label for="ab-p">Password</label>
<input type="password" id="ab-p" autocomplete="new-password" value="${esc(abCreds().p)}"></div>
<h2>Install</h2><p class="sub">iPhone/iPad: Share → Add to Home Screen. Desktop Chrome/Edge: install icon in the address bar.</p></div>`;
</div><h2>Install</h2><p class="sub">iPhone/iPad: Share → Add to Home Screen. Desktop Chrome/Edge: install icon in the address bar.</p></div>`;
// only show what this person may use
const hide = (cond, sel) => { if (cond) document.querySelectorAll(sel).forEach(e => e.remove()); };
hide(!allowed('mail'), '#sec-mail'); hide(!allowed('audio'), '#sec-audio'); hide(!(allowed('files') || allowed('office')), '#row-sf, #out');
hide(!allowed('music'), '#row-plex'); hide(!allowed('office'), '#row-oo');
if (!document.querySelector('#sec-conn .list .row')) hide(true, '#sec-conn'); // nothing to show here for this person
document.querySelectorAll('input[data-id]').forEach(i => i.oninput = () => {
const h = mailHints(); const v = i.value.trim(); if (v) h[i.dataset.id] = v; else delete h[i.dataset.id]; store.set('mail_hints', h);
});
const saveAb = () => store.set('ab_creds', { u: $('#ab-u').value, p: $('#ab-p').value });
$('#ab-u').oninput = saveAb; $('#ab-p').oninput = saveAb;
jfetch('/api/plex/status').then(st => {
if ($('#ab-u')) { $('#ab-u').oninput = saveAb; $('#ab-p').oninput = saveAb; }
if (allowed('music')) jfetch('/api/plex/status').then(st => {
const el = $('#plexst'); if (!el) return;
el.textContent = st.signedIn ? (st.server ? 'Signed in · ' + st.server : 'Signed in · no server chosen') : 'Signed out';
if (st.signedIn) { const b = $('#plexout'); b.hidden = false; b.onclick = async () => { await jfetch('/api/plex/logout', { method: 'POST' }); pl.a.pause(); pl.q = []; pl.i = -1; renderMini(); toast('Signed out of Plex'); viewSettings(); }; }
}).catch(() => { const el = $('#plexst'); if (el) el.textContent = 'Unavailable'; });
jfetch('/api/system').then(i => {
if (isAdmin) { loadPeople(); $('#addperson').onclick = () => addPersonSheet(); }
if (isAdmin) jfetch('/api/system').then(i => {
const anon = i.source && i.source.kind === 'anonymous', ok = i.mounted !== false && !anon && i.writable;
const row = (t, d, bad) => `<div class="row"><span class="grow"><b>${t}</b><small style="white-space:normal;${bad ? 'color:var(--danger)' : ''}">${d}</small></span></div>`;
const where = !i.source ? '' : i.source.kind === 'folder' ? ' · host folder ' + esc(i.source.name) : i.source.kind === 'named' ? ' · volume "' + esc(i.source.name) + '"' : '';
@@ -1450,6 +1535,7 @@ function dirSheet(repo, path, name, dir) {
// ---- Boot
(async () => {
try { cfg = { ...cfg, ...(await (await fetch('/api/config')).json()) }; } catch {}
await loadMe();
await loadSettings();
addEventListener('hashchange', route);
restorePlayer();
+4 -2
View File
@@ -2,6 +2,7 @@
// because those need the person's own Seafile / Plex sign-in.
const cal = require('./cal');
const tasks = require('./tasks');
const users = require('./users');
const json = (res, code, obj) => { res.writeHead(code, { 'content-type': 'application/json' }); res.end(JSON.stringify(obj)); };
const ymd = d => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
@@ -11,11 +12,12 @@ async function handle(req, res) {
if (q.length < 2) return json(res, 200, { tasks: [], events: [], errors: [] });
const errors = [];
let foundTasks = [], foundEvents = [];
try {
const me = users.current();
if (users.can(me, 'tasks')) try {
foundTasks = (await tasks.allTasks()).filter(t => (t.title + ' ' + t.notes).toLowerCase().includes(q))
.sort((a, b) => (a.done - b.done) || (a.due || '9999').localeCompare(b.due || '9999')).slice(0, 8);
} catch (e) { errors.push('Tasks: ' + e.message); }
try { // two months back to just over a year ahead
if (users.can(me, 'calendar')) try { // two months back to just over a year ahead
const now = new Date(), from = new Date(now); from.setDate(from.getDate() - 60); from.setHours(0, 0, 0, 0);
const to = new Date(now); to.setDate(to.getDate() + 400); to.setHours(23, 59, 59, 999);
const r = await cal.collectEvents({ from: from.getTime(), to: to.getTime(), fd: ymd(from), td: ymd(to) });
+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.
+5 -5
View File
@@ -5,22 +5,22 @@ const path = require('path');
const crypto = require('crypto');
const auth = require('./auth');
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data');
const FILE = path.join(DATA_DIR, 'settings.json');
const users = require('./users');
const file = () => path.join(users.dir(), 'settings.json'); // each person has their own
// Only these keys are accepted. (Per-device things like the Seafile sign-in token and the music queue stay in the browser.)
const KEYS = new Set(['mail_hints', 'ab_creds', 'plex_lib', 'office_loc', 'cal_default', 'cal_view', 'cal_range', 'task_list', 'task_show_done', 'recents']);
const SECRET = new Set(['ab_creds']);
const MAX_VALUE = 20000; // bytes of JSON per key
const key = () => crypto.scryptSync(auth.secret, 'nehub-settings', 32);
let _key; const key = () => _key || (_key = crypto.scryptSync(auth.secret, 'nehub-settings', 32));
const seal = v => { const iv = crypto.randomBytes(12), c = crypto.createCipheriv('aes-256-gcm', key(), iv);
const d = Buffer.concat([c.update(JSON.stringify(v), 'utf8'), c.final()]); return { _sealed: Buffer.concat([iv, c.getAuthTag(), d]).toString('base64') }; };
const unseal = o => { try { const b = Buffer.from(o._sealed, 'base64'), d = crypto.createDecipheriv('aes-256-gcm', key(), b.subarray(0, 12));
d.setAuthTag(b.subarray(12, 28)); return JSON.parse(Buffer.concat([d.update(b.subarray(28)), d.final()]).toString('utf8')); } catch { return undefined; } };
const load = () => { try { return JSON.parse(fs.readFileSync(FILE, 'utf8')); } catch { return {}; } };
const save = o => { fs.mkdirSync(DATA_DIR, { recursive: true }); const tmp = FILE + '.tmp'; fs.writeFileSync(tmp, JSON.stringify(o, null, 2), { mode: 0o600 }); fs.renameSync(tmp, FILE); }; // write-then-rename: a crash never leaves half a file
const load = () => { try { return JSON.parse(fs.readFileSync(file(), 'utf8')); } catch { return {}; } };
const save = o => { const f = file(), tmp = f + '.tmp'; fs.writeFileSync(tmp, JSON.stringify(o, null, 2), { mode: 0o600 }); fs.renameSync(tmp, f); }; // write-then-rename: a crash never leaves half a file
const readBody = req => new Promise((ok, no) => { const c = []; let n = 0; req.on('data', d => { n += d.length; if (n > 100000) { no(new Error('Too large')); req.destroy(); } else c.push(d); }); req.on('end', () => ok(Buffer.concat(c).toString())); req.on('error', no); });
const json = (res, code, obj) => { res.writeHead(code, { 'content-type': 'application/json' }); res.end(JSON.stringify(obj)); };
+4 -4
View File
@@ -7,10 +7,10 @@ const ics = require('./ics');
const cal = require('./cal');
const caldav = require('./caldav');
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data');
const FILE = path.join(DATA_DIR, 'tasks.json');
const load = () => { try { return JSON.parse(fs.readFileSync(FILE, 'utf8')); } catch { return { lists: [{ id: 'inbox', name: 'Tasks' }], tasks: [] }; } };
const save = d => { fs.mkdirSync(DATA_DIR, { recursive: true }); fs.writeFileSync(FILE, JSON.stringify(d, null, 2), { mode: 0o600 }); };
const users = require('./users');
const file = () => path.join(users.dir(), 'tasks.json'); // each person has their own
const load = () => { try { return JSON.parse(fs.readFileSync(file(), 'utf8')); } catch { return { lists: [{ id: 'inbox', name: 'Tasks' }], tasks: [] }; } };
const save = d => fs.writeFileSync(file(), JSON.stringify(d, null, 2), { mode: 0o600 });
const readBody = req => new Promise((ok, no) => { const c = []; req.on('data', d => c.push(d)); req.on('end', () => ok(Buffer.concat(c).toString())); req.on('error', no); });
const json = (res, code, obj) => { res.writeHead(code, { 'content-type': 'application/json' }); res.end(JSON.stringify(obj)); };
const isLocal = id => String(id || '').startsWith('local:');
+7 -4
View File
@@ -17,19 +17,22 @@ const targets = [...new Set([...src.matchAll(/return (\w+)\(req, res\)/g)].map(m
const missing = targets.filter(n => !new RegExp(`(async )?function ${n}\\b|const ${n} =`).test(src));
ok('every function the router calls is defined', !missing.length, missing);
const child = spawn(process.execPath, ['server.js'], { cwd: root, env: { ...process.env, PORT: String(port), DATA_DIR: dir, COOKIE_SECRET: 'smoke-'.repeat(8), HUB_SETUP_CODE: 'smoke', SEAFILE_URL: 'http://127.0.0.1:9' } });
// Seed one admin account so the test can sign in without a passkey ceremony (the cookie is signed with the test's own secret).
const SECRET = 'smoke-'.repeat(8), UID = 'usmoke';
fs.writeFileSync(path.join(dir, 'users.json'), JSON.stringify({ users: [{ id: UID, name: 'Admin', role: 'admin', tabs: [], handle: 'aGFuZGxl', epoch: 1, disabled: false, created: Date.now() }] }));
const child = spawn(process.execPath, ['server.js'], { cwd: root, env: { ...process.env, PORT: String(port), DATA_DIR: dir, COOKIE_SECRET: SECRET, HUB_SETUP_CODE: 'smoke', SEAFILE_URL: 'http://127.0.0.1:9' } });
let alive = true, log = ''; child.on('exit', () => { alive = false; }); child.stdout.on('data', d => log += d); child.stderr.on('data', d => log += d);
const wait = ms => new Promise(r => setTimeout(r, ms));
(async () => {
for (let i = 0; i < 50; i++) { try { await fetch(base + '/login'); break; } catch { await wait(100); } }
const exp = Date.now() + 864e5, sig = crypto.createHmac('sha256', 'smoke-'.repeat(8)).update('s' + exp).digest('base64url');
const cookie = `hub_session=${exp}.${sig}`;
const exp = Date.now() + 864e5, sig = crypto.createHmac('sha256', SECRET).update(`s${UID}.1.${exp}`).digest('base64url');
const cookie = `hub_session=${UID}.1.${exp}.${sig}`;
const get = async (p, method = 'GET', body) => { try { const r = await fetch(base + p, { method, redirect: 'manual', headers: { cookie, 'content-type': 'application/json' }, body: body && JSON.stringify(body) }); await r.text(); return r.status; } catch { return -1; } };
const pages = ['/', '/login', '/app.js', '/style.css', '/sw.js', '/manifest.webmanifest', '/icons/icon-192.png', '/icons/apple-touch-icon.png'];
const api = ['/api/config', '/api/system', '/api/settings', '/api/auth/state', '/api/auth/passkeys', '/api/tasks/lists', '/api/tasks?list=local:inbox', '/api/tasks/summary',
'/api/cal/list', '/api/cal/icloud', '/api/cal/feed', '/api/search?q=ab', '/api/search?q=a', '/api/ddg?q=einstein', '/api/ddg?q=a', '/api/plex/status',
'/api/me', '/api/users', '/api/cal/list', '/api/cal/icloud', '/api/cal/feed', '/api/search?q=ab', '/api/search?q=a', '/api/ddg?q=einstein', '/api/ddg?q=a', '/api/plex/status',
`/api/cal/events?from=${Date.now()}&to=${Date.now() + 864e5}&fd=2026-01-01&td=2026-01-02`];
const bad = [];
for (const p of [...pages, ...api]) { const st = await get(p); if (st === 500 || st === -1 || !alive) bad.push(`${p} -> ${alive ? st : 'server died'}`); }
+242
View File
@@ -0,0 +1,242 @@
#!/usr/bin/env node
// End-to-end test of accounts: admin setup, inviting people, private data, tab access, sign-out/disable/delete, and
// migration of a single-user install. Passkeys are made by a small software authenticator (no browser needed).
// Run: node tools/users-test.js
const { spawn } = require('child_process');
const fs = require('fs'), os = require('os'), path = require('path'), crypto = require('crypto');
const root = path.join(__dirname, '..');
let failed = 0;
const ok = (n, c, x) => { console.log((c ? 'PASS ' : 'FAIL ') + n + (c ? '' : ' -> ' + JSON.stringify(x))); if (!c) failed++; };
const wait = ms => new Promise(r => setTimeout(r, ms));
const SECRET = 'users-test-secret-'.repeat(3);
// ---- software authenticator (WebAuthn "none" attestation, ES256)
const enc = b => Buffer.from(b).toString('base64url'), dec = s => Buffer.from(s, 'base64url'), sha = b => crypto.createHash('sha256').update(b).digest();
const head = (mt, n) => n < 24 ? Buffer.from([mt << 5 | n]) : n < 256 ? Buffer.from([mt << 5 | 24, n]) : Buffer.from([mt << 5 | 25, n >> 8, n & 255]);
const cb = v => typeof v === 'number' ? (v >= 0 ? head(0, v) : head(1, -1 - v)) : Buffer.isBuffer(v) ? Buffer.concat([head(2, v.length), v]) : typeof v === 'string' ? Buffer.concat([head(3, Buffer.byteLength(v)), Buffer.from(v)]) : v instanceof Map ? Buffer.concat([head(5, v.size), ...[...v].flatMap(([k, x]) => [cb(k), cb(x)])]) : null;
const device = () => { const { publicKey, privateKey } = crypto.generateKeyPairSync('ec', { namedCurve: 'P-256' }); return { jwk: publicKey.export({ format: 'jwk' }), priv: privateKey, id: crypto.randomBytes(32), n: 0 }; };
function start(dataDir, port, extraEnv = {}) {
const child = spawn(process.execPath, ['server.js'], { cwd: root, env: { ...process.env, ...extraEnv, PORT: String(port), DATA_DIR: dataDir, COOKIE_SECRET: SECRET, HUB_SETUP_CODE: 'setup-code', AUTH_RATE_LIMIT: '10000', SEAFILE_URL: 'http://127.0.0.1:9' } });
const s = { child, log: '', alive: true }; child.on('exit', () => { s.alive = false; }); child.stdout.on('data', d => s.log += d); child.stderr.on('data', d => s.log += d);
return s;
}
async function ready(base) { for (let i = 0; i < 60; i++) { try { await fetch(base + '/login'); return; } catch { await wait(100); } } throw new Error('server did not start'); }
function client(base) {
let cookie = '';
const c = {
get cookie() { return cookie; }, set cookie(v) { cookie = v; },
async call(p, body, method) {
const r = await fetch(base + p, { method: method || (body ? 'POST' : 'GET'), redirect: 'manual', headers: { cookie, 'content-type': 'application/json' }, body: body ? JSON.stringify(body) : undefined });
const set = r.headers.getSetCookie ? r.headers.getSetCookie() : []; const s = set.find(x => x.startsWith('hub_session=')); if (s) cookie = s.split(';')[0];
let json = {}; try { json = JSON.parse(await r.text()); } catch {}
return { status: r.status, json, set };
},
};
return c;
}
const RPID = 'localhost';
async function register(c, dev, extra, name, origin) {
let r = await c.call('/api/auth/register/options', extra); if (r.status !== 200) return r;
const { cid, options } = r.json;
const cd = JSON.stringify({ type: 'webauthn.create', challenge: options.challenge, origin, crossOrigin: false });
const cose = new Map([[1, 2], [3, -7], [-1, 1], [-2, dec(dev.jwk.x)], [-3, dec(dev.jwk.y)]]);
const ad = Buffer.concat([sha(RPID), Buffer.from([0x45]), Buffer.alloc(4), Buffer.alloc(16), Buffer.from([0, dev.id.length]), dev.id, cb(cose)]);
const att = cb(new Map([['fmt', 'none'], ['attStmt', new Map()], ['authData', ad]]));
return c.call('/api/auth/register/verify', { cid, name, response: { id: enc(dev.id), rawId: enc(dev.id), type: 'public-key', response: { clientDataJSON: enc(cd), attestationObject: enc(att), transports: ['internal'] }, clientExtensionResults: {} } });
}
async function login(c, dev, origin) {
const lo = await c.call('/api/auth/login/options', {}); if (lo.status !== 200) return lo;
const cd = JSON.stringify({ type: 'webauthn.get', challenge: lo.json.options.challenge, origin, crossOrigin: false });
dev.n++; const ad = Buffer.concat([sha(RPID), Buffer.from([0x05]), Buffer.from([0, 0, 0, dev.n])]);
const sig = crypto.sign('sha256', Buffer.concat([ad, sha(cd)]), dev.priv);
return c.call('/api/auth/login/verify', { cid: lo.json.cid, response: { id: enc(dev.id), rawId: enc(dev.id), type: 'public-key', response: { clientDataJSON: enc(cd), authenticatorData: enc(ad), signature: enc(sig) }, clientExtensionResults: {} } });
}
const T = (title, extra) => ({ title, notes: '', due: '', done: false, ...extra });
(async () => {
// ================= Part 1: a fresh install with an admin and two people =================
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'nehub-users-')), port = 20000 + Math.floor(Math.random() * 20000);
const base = `http://localhost:${port}`, srv = start(dir, port); await ready(base);
const admin = client(base), sam = client(base), kim = client(base), anon = client(base);
const dA = device(), dS = device(), dS2 = device(), dK = device();
let r = await register(admin, dA, { setupCode: 'wrong' }, 'Admin laptop', base); ok('wrong setup code is refused', r.status === 403, r);
r = await register(admin, dA, { setupCode: 'setup-code' }, 'Admin laptop', base); ok('the setup code creates the first account and signs it in', r.status === 200 && /^hub_session=u[0-9a-f]+\.1\./.test(admin.cookie), [r.status, admin.cookie.slice(0, 30)]);
r = await admin.call('/api/me'); const me = r.json;
ok('the first account is the admin with every tab', me.role === 'admin' && ['files', 'music', 'calendar', 'tasks', 'verse', 'home', 'settings'].every(t => me.tabs.includes(t)), me);
r = await register(anon, device(), { setupCode: 'setup-code' }, 'x', base); ok('the setup code stops working once an account exists', r.status === 403, r);
await admin.call('/api/tasks', { list: 'local:inbox', fields: T('Admin private task') });
await admin.call('/api/cal/event', { cal: 'local', fields: { title: 'Admin private event', location: '', notes: '', allDay: true, start: '2026-11-03', end: '2026-11-03', tz: 'UTC', repeat: 'none' } });
await admin.call('/api/settings', { key: 'mail_hints', value: { gmail: 'admin@gmail.com' } }, 'PUT');
// ---- add Sam with only Tasks and Calendar
r = await admin.call('/api/users', { name: 'Sam', tabs: ['tasks', 'calendar', 'search'] }); const samUser = r.json.user, samCode = r.json.code;
ok('admin adds a person and gets a one-time code', r.status === 200 && /^[A-Z2-9]{4}-[A-Z2-9]{4}$/.test(samCode) && samUser.tabsChosen.join() === 'tasks,calendar,search', r.json);
r = await admin.call('/api/users', { name: 'sam', tabs: [] }); ok('names must be unique (any case)', r.status === 400, r);
r = await admin.call('/api/users', { name: ' ', tabs: [] }); ok('a name is required', r.status === 400, r);
r = await register(sam, dS, { inviteCode: samCode }, 'Sam phone', base); ok('Sam enrols with the code and is signed in as Sam', r.status === 200 && sam.cookie.startsWith('hub_session=' + samUser.id + '.'), [r.status, sam.cookie.slice(0, 24)]);
r = await register(client(base), device(), { inviteCode: samCode }, 'again', base); ok('the code works only once', r.status === 403, r);
r = await sam.call('/api/me'); ok('Sam is a normal user with only Home, Settings and the tabs chosen', r.json.role === 'user' && r.json.tabs.sort().join() === 'calendar,home,search,settings,tasks', r.json);
// ---- private data
r = await sam.call('/api/tasks?list=local:inbox'); ok("Sam starts with an empty task list (the admin's task is not visible)", r.status === 200 && r.json.length === 0, r.json);
await sam.call('/api/tasks', { list: 'local:inbox', fields: T('Sam private task') });
await sam.call('/api/cal/event', { cal: 'local', fields: { title: 'Sam private event', location: '', notes: '', allDay: true, start: '2026-11-03', end: '2026-11-03', tz: 'UTC', repeat: 'none' } });
await sam.call('/api/settings', { key: 'mail_hints', value: { gmail: 'sam@gmail.com' } }, 'PUT');
const q = '/api/cal/events?from=' + Date.parse('2026-11-01T00:00:00Z') + '&to=' + Date.parse('2026-11-07T00:00:00Z') + '&fd=2026-11-01&td=2026-11-07';
const at = (await admin.call('/api/tasks?list=local:inbox')).json.map(t => t.title), st = (await sam.call('/api/tasks?list=local:inbox')).json.map(t => t.title);
ok('tasks are private to each person', at.join() === 'Admin private task' && st.join() === 'Sam private task', [at, st]);
const ae = (await admin.call(q)).json.events.map(e => e.title), se = (await sam.call(q)).json.events.map(e => e.title);
ok('NE Hub calendar events are private to each person', ae.join() === 'Admin private event' && se.join() === 'Sam private event', [ae, se]);
ok('settings are private to each person', (await admin.call('/api/settings')).json.values.mail_hints.gmail === 'admin@gmail.com' && (await sam.call('/api/settings')).json.values.mail_hints.gmail === 'sam@gmail.com');
const fa = (await admin.call('/api/cal/feed')).json.url, fs_ = (await sam.call('/api/cal/feed')).json.url;
const fetchFeed = async u => { const x = await fetch(u.replace(/^https?:\/\/[^/]+/, base)); return { status: x.status, text: await x.text() }; };
const ta = await fetchFeed(fa), ts = await fetchFeed(fs_);
ok('each person has their own subscribe link, showing only their own events', fa !== fs_ && ta.text.includes('Admin private event') && !ta.text.includes('Sam private event') && ts.text.includes('Sam private event') && !ts.text.includes('Admin private event'), [ta.status, ts.status]);
r = await sam.call('/api/search?q=private'); ok('search shows only the searcher\'s own tasks and events', r.json.tasks.map(t => t.title).join() === 'Sam private task' && r.json.events.map(e => e.title).join() === 'Sam private event', r.json);
// ---- access limits are enforced by the server
for (const [p, label] of [['/sf/api2/ping/', 'Seafile (Files)'], ['/api/plex/status', 'Plex (Music)'], ['/plex/library/sections', 'Plex media']]) {
r = await sam.call(p); ok(`Sam is refused ${label}: 403`, r.status === 403, r.status);
}
r = await sam.call('/api/oo/config', {}); ok('Sam is refused document editing: 403', r.status === 403, r.status);
r = await sam.call('/api/users'); ok('Sam cannot list or manage people: 403', r.status === 403, r.status);
r = await sam.call('/api/users/' + me.id, { disabled: true }, 'PUT'); ok('Sam cannot change the admin: 403', r.status === 403, r.status);
r = await sam.call('/api/system'); ok('Sam cannot see server storage details: 403', r.status === 403, r.status);
r = await sam.call('/api/config'); ok('addresses of unavailable tabs are not revealed to Sam', r.json.seafile === '' && r.json.onlyoffice === '' && r.json.audiobooks === '' && r.json.verse === '' && r.json.podcast === '', r.json);
r = await admin.call('/api/config'); ok('the admin gets every address', r.json.seafile.startsWith('http') && r.json.audiobooks.startsWith('http'), r.json);
r = await sam.call('/api/tasks/lists'); ok('Sam can use the tabs he was given', r.status === 200);
// ---- change someone's access
r = await admin.call('/api/users/' + samUser.id, { tabs: ['tasks', 'calendar', 'search', 'music'] }, 'PUT'); ok('admin adds Music for Sam', r.status === 200 && r.json.user.tabs.includes('music'), r.json);
r = await sam.call('/api/plex/status'); ok('Sam can now use Music right away (no new sign-in needed)', r.status === 200 && r.json.signedIn === false, r);
r = await admin.call('/api/users/' + samUser.id, { tabs: ['tasks', 'search'] }, 'PUT'); r = await sam.call(q); ok('removing Calendar takes effect immediately', r.status === 403, r.status);
r = await sam.call('/api/search?q=private'); ok('and Search no longer reveals his calendar', r.status === 200 && r.json.events.length === 0 && r.json.tasks.length === 1, r.json);
await admin.call('/api/users/' + samUser.id, { tabs: ['tasks', 'calendar', 'search'] }, 'PUT');
// ---- Plex sign-in is stored per person (encrypted), and the proxy uses only the caller's own
const seen = [];
const fakePlex = require('http').createServer((rq, rs) => { seen.push({ url: rq.url, token: rq.headers['x-plex-token'] }); rs.writeHead(200, { 'content-type': 'application/json' }); rs.end(JSON.stringify({ MediaContainer: { Directory: [{ key: '1', type: 'artist', title: 'Music' }] } })); });
await new Promise(res => fakePlex.listen(0, '127.0.0.1', res)); const plexPort = fakePlex.address().port;
const sealPlex = obj => { const key = crypto.scryptSync(SECRET, 'nehub-plex', 32), iv = crypto.randomBytes(12), c = crypto.createCipheriv('aes-256-gcm', key, iv); const d = Buffer.concat([c.update(JSON.stringify(obj), 'utf8'), c.final()]); return Buffer.concat([iv, c.getAuthTag(), d]).toString('base64'); };
await admin.call('/api/users/' + samUser.id, { tabs: ['tasks', 'calendar', 'search', 'music'] }, 'PUT');
fs.writeFileSync(path.join(dir, 'users', me.id, 'plex.json'), JSON.stringify({ data: sealPlex({ token: 'ADMIN-PLEX-TOKEN', srv: 'http://127.0.0.1:' + plexPort, stk: 'ADMIN-SERVER-TOKEN', name: 'Home Plex' }) }));
r = await admin.call('/api/plex/status'); ok("a person's saved Plex sign-in is used: signed in, with the chosen server", r.json.signedIn === true && r.json.server === 'Home Plex', r.json);
r = await sam.call('/api/plex/status'); ok("another person is NOT signed in to the first person's Plex", r.json.signedIn === false && r.json.server === null, r.json);
r = await admin.call('/plex/library/sections'); ok("the admin's Plex requests reach the server with the admin's own server token", r.status === 200 && seen.length === 1 && seen[0].token === 'ADMIN-SERVER-TOKEN' && seen[0].url === '/library/sections', [r.status, seen]);
r = await sam.call('/plex/library/sections'); ok("another person's Plex requests are refused and never reach the admin's server", r.status === 401 && seen.length === 1, [r.status, seen.length]);
ok('the Plex tokens are not readable in the file on disk', !fs.readFileSync(path.join(dir, 'users', me.id, 'plex.json'), 'utf8').includes('ADMIN-PLEX-TOKEN'));
r = await admin.call('/api/plex/logout', {}); r = await admin.call('/api/plex/status'); ok('signing out of Plex removes it', r.json.signedIn === false && !fs.existsSync(path.join(dir, 'users', me.id, 'plex.json')), r.json);
fakePlex.close();
await admin.call('/api/users/' + samUser.id, { tabs: ['tasks', 'calendar', 'search'] }, 'PUT');
// ---- another device for the same person
r = await admin.call('/api/users/' + samUser.id + '/invite', {}); const code2 = r.json.code;
r = await register(sam, dS2, { inviteCode: code2 }, 'Sam tablet', base);
const sam2 = client(base); const lr = await login(sam2, dS2, base);
r = await sam2.call('/api/me'); ok("an invite for Sam's second device signs in as Sam, not as someone new", lr.status === 200 && r.json.id === samUser.id && r.json.role === 'user', r.json);
r = await sam.call('/api/auth/passkeys'); const ar = await admin.call('/api/auth/passkeys');
ok('each person sees only their own passkeys', r.json.length === 2 && ar.json.length === 1, [r.json.length, ar.json.length]);
r = await sam.call('/api/auth/passkeys', { id: ar.json[0].id }, 'DELETE'); ok("nobody can remove another person's passkey", r.status === 404, r);
// ---- sign out everywhere, disable, enable
const oldCookie = sam.cookie;
r = await admin.call('/api/users/' + samUser.id, { signOutEverywhere: true }, 'PUT');
r = await sam.call('/api/me'); ok('"sign out everywhere" ends every existing session of that person', r.status === 401, r.status);
const sam3 = client(base); r = await login(sam3, dS, base); ok('they can sign in again with their passkey afterwards', r.status === 200 && (await sam3.call('/api/me')).json.id === samUser.id);
r = await admin.call('/api/users/' + samUser.id, { disabled: true }, 'PUT');
r = await sam3.call('/api/me'); ok('turning someone off signs them out at once', r.status === 401, r.status);
r = await login(client(base), dS, base); ok('and their passkey no longer works', r.status === 403, r.status);
r = await admin.call('/api/users/' + samUser.id + '/invite', {}); r = await register(client(base), device(), { inviteCode: r.json.code }, 'x', base); ok('an invite for a turned-off account is refused', r.status === 403, r.status);
r = await admin.call('/api/users/' + samUser.id, { disabled: false }, 'PUT'); r = await login(client(base), dS, base); ok('turning them back on restores sign-in', r.status === 200, r.status);
r = await admin.call('/api/users/' + me.id, { disabled: true }, 'PUT'); ok('the admin cannot turn off their own account', r.status === 400, r);
r = await admin.call('/api/users/' + me.id, { role: 'user' }, 'PUT'); ok('the only admin cannot be demoted', r.status === 400, r);
r = await admin.call('/api/users/' + me.id, undefined, 'DELETE'); ok('the admin cannot delete themselves', r.status === 400, r);
// ---- a second person, then delete Sam
r = await admin.call('/api/users', { name: 'Kim', tabs: ['files', 'tasks'] }); const kimUser = r.json.user; r = await register(kim, dK, { inviteCode: r.json.code }, 'Kim phone', base);
r = await kim.call('/sf/api2/ping/'); ok('Kim, who has Files, passes the access check (Seafile itself is unreachable in this test: 502, not 403)', r.status !== 403 && r.status !== 401, r.status);
r = await kim.call('/api/cal/list'); ok('Kim, without Calendar, is refused it', r.status === 403, r.status);
r = await kim.call('/api/search?q=x'); ok('Kim, without Search, is refused it (and the web-answer lookup)', r.status === 403 && (await kim.call('/api/ddg?q=einstein')).status === 403, r.status);
r = await admin.call('/api/users'); ok('the people list shows both, with passkey counts', r.json.users.length === 3 && r.json.users.find(u => u.id === samUser.id).passkeys === 2, r.json.users.map(u => [u.name, u.passkeys]));
const samDir = path.join(dir, 'users', samUser.id); ok("Sam's data folder exists", fs.existsSync(samDir) && fs.existsSync(path.join(samDir, 'tasks.json')));
r = await admin.call('/api/users/' + samUser.id, undefined, 'DELETE'); ok('admin deletes Sam', r.status === 200, r);
r = await sam3.call('/api/me'); ok('his sessions stop working', r.status === 401, r.status);
r = await login(client(base), dS, base); ok('his passkeys are gone', r.status === 400, r.status);
ok("his data folder is deleted, the admin's and Kim's are untouched", !fs.existsSync(samDir) && fs.existsSync(path.join(dir, 'users', me.id, 'tasks.json')) && fs.existsSync(path.join(dir, 'users', kimUser.id)));
r = await admin.call('/api/tasks?list=local:inbox'); ok("the admin's own data is intact", r.json.map(t => t.title).join() === 'Admin private task', r.json);
const mode = f => (fs.statSync(f).mode & 0o777).toString(8);
ok('per-person data files are private (0600)', mode(path.join(dir, 'users', me.id, 'tasks.json')) === '600' && mode(path.join(dir, 'users.json')) === '600', [mode(path.join(dir, 'users.json'))]);
ok('the server survived all of it', srv.alive);
// ---- everything is remembered after a restart
srv.child.kill(); await wait(300); const srv2 = start(dir, port); await ready(base);
r = await admin.call('/api/me'); ok('after a restart the admin is still signed in', r.status === 200 && r.json.role === 'admin', r.status);
r = await kim.call('/api/me'); ok('and Kim, with the same tabs', r.status === 200 && r.json.tabs.includes('files') && !r.json.tabs.includes('calendar'), r.json);
srv2.child.kill();
// ================= Part 2: an existing single-user install becomes the admin =================
const dir2 = fs.mkdtempSync(path.join(os.tmpdir(), 'nehub-migrate-')), port2 = port + 1, base2 = `http://localhost:${port2}`;
const legacyDev = device();
const legacyCred = { id: enc(legacyDev.id), publicKey: 'AAAA', counter: 0, transports: [], name: 'Old laptop', created: Date.now() };
fs.writeFileSync(path.join(dir2, 'passkeys.json'), JSON.stringify({ userId: 'bGVnYWN5LWhhbmRsZQ', creds: [legacyCred] }));
fs.writeFileSync(path.join(dir2, 'tasks.json'), JSON.stringify({ lists: [{ id: 'inbox', name: 'Tasks' }], tasks: [{ id: 't1', list: 'inbox', title: 'Existing task', notes: '', due: '', done: false }] }));
fs.writeFileSync(path.join(dir2, 'settings.json'), JSON.stringify({ task_list: 'local:inbox' }));
fs.writeFileSync(path.join(dir2, 'calendars.json'), JSON.stringify([]));
const srv3 = start(dir2, port2); await ready(base2);
const old = client(base2);
const exp = Date.now() + 864e5, legacySig = crypto.createHmac('sha256', SECRET).update('s' + exp).digest('base64url'); old.cookie = `hub_session=${exp}.${legacySig}`;
r = await old.call('/api/me'); ok('migration: an existing session (old cookie style) still works, as the admin', r.status === 200 && r.json.role === 'admin' && r.json.name === 'Admin', r);
ok('migration: the old cookie is upgraded to the new style', r.set.some(c => /^hub_session=u[0-9a-f]+\.1\./.test(c)), r.set);
r = await old.call('/api/tasks?list=local:inbox'); ok('migration: existing tasks appear for the admin', r.json.map(t => t.title).join() === 'Existing task', r.json);
r = await old.call('/api/settings'); ok('migration: existing settings appear for the admin', r.json.values.task_list === 'local:inbox', r.json);
const pk = JSON.parse(fs.readFileSync(path.join(dir2, 'passkeys.json'), 'utf8')); const um = JSON.parse(fs.readFileSync(path.join(dir2, 'users.json'), 'utf8'));
ok('migration: the existing passkey now belongs to the admin account', pk.creds[0].uid === um.users[0].id && um.users[0].handle === 'bGVnYWN5LWhhbmRsZQ', [pk.creds[0].uid, um.users[0].id]);
ok('migration: the original files were left in place as a backup', fs.existsSync(path.join(dir2, 'tasks.json')) && fs.existsSync(path.join(dir2, 'users', um.users[0].id, 'tasks.json')));
r = await register(client(base2), device(), { setupCode: 'setup-code' }, 'x', base2); ok('migration: the setup code cannot be used to take over a migrated install', r.status === 403, r);
r = await client(base2).call('/api/tasks/lists'); ok('signed-out requests are still refused', r.status === 401, r.status);
ok('the migrated server is still running', srv3.alive);
srv3.child.kill();
// ================= Part 3: iCloud is connected per person =================
const dir3 = fs.mkdtempSync(path.join(os.tmpdir(), 'nehub-icloud-')), port3 = port + 3, base3 = `http://localhost:${port3}`, calPort = port + 4;
const store3 = new Map(); const W = b => `<?xml version="1.0"?><multistatus xmlns="DAV:">${b}</multistatus>`; const esc = t => t.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
store3.set('/9/calendars/home/e1.ics', 'BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:e1\r\nDTSTAMP:20261001T000000Z\r\nSUMMARY:Admin iCloud event\r\nDTSTART;VALUE=DATE:20261103\r\nDTEND;VALUE=DATE:20261104\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n');
store3.set('/9/calendars/rem/t1.ics', 'BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VTODO\r\nUID:t1\r\nDTSTAMP:20261001T000000Z\r\nSUMMARY:Admin iCloud reminder\r\nSTATUS:NEEDS-ACTION\r\nEND:VTODO\r\nEND:VCALENDAR\r\n');
const fakeApple = require('http').createServer((rq, rs) => { const c = []; rq.on('data', d => c.push(d)); rq.on('end', () => {
const body = Buffer.concat(c).toString(), p = new URL(rq.url, 'http://x').pathname, x = b => { rs.writeHead(207, { 'content-type': 'text/xml' }); rs.end(W(b)); };
if (Buffer.from((rq.headers.authorization || '').replace('Basic ', ''), 'base64').toString() !== 'a@icloud.com:abcd-efgh-ijkl-mnop') { rs.writeHead(401); return rs.end(); }
if (rq.method === 'PROPFIND' && p === '/') return x('<response><href>/</href><propstat><prop><current-user-principal><href>/9/principal/</href></current-user-principal></prop><status>HTTP/1.1 200 OK</status></propstat></response>');
if (rq.method === 'PROPFIND' && p === '/9/principal/') return x(`<response><href>/9/principal/</href><propstat><prop><calendar-home-set xmlns="urn:ietf:params:xml:ns:caldav"><href xmlns="DAV:">http://localhost:${calPort}/9/calendars/</href></calendar-home-set></prop><status>HTTP/1.1 200 OK</status></propstat></response>`);
if (rq.method === 'PROPFIND' && p === '/9/calendars/') { const cal = (h, n, comp) => `<response><href>${h}</href><propstat><prop><displayname>${n}</displayname><resourcetype><collection/><calendar xmlns="urn:ietf:params:xml:ns:caldav"/></resourcetype><supported-calendar-component-set xmlns="urn:ietf:params:xml:ns:caldav"><comp name="${comp}"/></supported-calendar-component-set></prop><status>HTTP/1.1 200 OK</status></propstat></response>`; return x(cal('/9/calendars/home/', 'Home', 'VEVENT') + cal('/9/calendars/rem/', 'Reminders', 'VTODO')); }
if (rq.method === 'REPORT') { const dir = p.replace(/\/?$/, '/'), wantTodo = /comp-filter name="VTODO"/.test(body); let o = ''; for (const [k, v] of store3) if (k.startsWith(dir) && wantTodo === /BEGIN:VTODO/.test(v)) o += `<response><href>${k}</href><propstat><prop><getetag>"1"</getetag><calendar-data>${esc(v)}</calendar-data></prop><status>HTTP/1.1 200 OK</status></propstat></response>`; return x(o); }
rs.writeHead(404); rs.end(); }); });
await new Promise(res => fakeApple.listen(calPort, '127.0.0.1', res));
const srv4 = start(dir3, port3, { CALDAV_TEST: '1', CALDAV_BASE: `http://localhost:${calPort}` }); await ready(base3);
const a3 = client(base3), s3 = client(base3), dA3 = device(), dS3 = device();
await register(a3, dA3, { setupCode: 'setup-code' }, 'Admin', base3);
r = await a3.call('/api/users', { name: 'Sam', tabs: ['calendar', 'tasks'] }); await register(s3, dS3, { inviteCode: r.json.code }, 'Sam', base3);
r = await a3.call('/api/cal/icloud/connect', { appleId: 'a@icloud.com', password: 'abcd-efgh-ijkl-mnop' }); ok("the admin connects iCloud (one event calendar, one reminder list)", r.status === 200 && r.json.connected && r.json.calendars.length === 1 && r.json.reminderLists === 1, r.json);
const q3 = '/api/cal/events?from=' + Date.parse('2026-11-01T00:00:00Z') + '&to=' + Date.parse('2026-11-07T00:00:00Z') + '&fd=2026-11-01&td=2026-11-07';
r = await a3.call(q3); ok('the admin sees their iCloud event', r.json.events.some(e => e.title === 'Admin iCloud event'), r.json.events);
r = await s3.call(q3); ok("another person does NOT see the admin's iCloud event", !r.json.events.some(e => e.title === 'Admin iCloud event') && !r.json.errors.length, r.json);
r = await s3.call('/api/cal/icloud'); ok('another person is not connected to iCloud', r.json.connected === false, r.json);
r = await a3.call('/api/tasks/lists'); const remList = r.json.lists.find(l => l.icloud); ok("the admin has the iCloud reminder list", !!remList, r.json);
r = await s3.call('/api/tasks/lists'); ok("another person's task lists do not include the admin's iCloud reminders", !r.json.lists.some(l => l.icloud), r.json);
r = await s3.call('/api/tasks?list=' + encodeURIComponent(remList.id)); ok("and cannot read the admin's iCloud reminders by guessing the list id", r.status === 404, r);
r = await a3.call('/api/tasks?list=' + encodeURIComponent(remList.id)); ok("the admin can read their reminders", r.json.some(t => t.title === 'Admin iCloud reminder'), r.json);
r = await s3.call('/api/cal/event', { cal: remList.id.replace('icloud:', 'icloud:'), fields: { title: 'x', allDay: true, start: '2026-11-03', end: '2026-11-03', tz: 'UTC', repeat: 'none' } }); ok("another person cannot write into the admin's iCloud calendars", r.status === 400, r);
ok("the iCloud login is stored encrypted inside the admin's own folder only", !fs.readFileSync(path.join(dir3, 'users', JSON.parse(fs.readFileSync(path.join(dir3, 'users.json'))).users[0].id, 'icloud.json'), 'utf8').includes('abcd-efgh') && !fs.existsSync(path.join(dir3, 'icloud.json')));
srv4.child.kill(); fakeApple.close();
ok('the iCloud-enabled server ran without errors', !/\[error\]|Error:/.test(srv4.log.replace(/Error: unsupported/g, '')), srv4.log.split('\n').filter(l => /error/i.test(l)).slice(0, 3));
fs.rmSync(dir3, { recursive: true, force: true });
fs.rmSync(dir, { recursive: true, force: true }); fs.rmSync(dir2, { recursive: true, force: true });
if (failed) console.log('\n--- server log (errors) ---\n' + srv.log.split('\n').filter(l => /error|Error/.test(l)).slice(0, 10).join('\n'));
console.log(failed ? `\n${failed} check(s) failed` : '\nAll account checks passed');
process.exit(failed ? 1 : 0);
})().catch(e => { console.error('TEST CRASHED:', e); process.exit(1); });
+93
View File
@@ -0,0 +1,93 @@
// 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: 'Audio', 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 };
+12
View File
@@ -0,0 +1,12 @@
// Small helper to keep a secret value encrypted at rest (AES-256-GCM, key derived from the server secret).
const crypto = require('crypto');
const auth = require('./auth');
module.exports = label => {
let key; const k = () => key || (key = crypto.scryptSync(auth.secret, label, 32));
return {
seal(obj) { const iv = crypto.randomBytes(12), c = crypto.createCipheriv('aes-256-gcm', k(), iv);
const d = Buffer.concat([c.update(JSON.stringify(obj), 'utf8'), c.final()]); return Buffer.concat([iv, c.getAuthTag(), d]).toString('base64'); },
unseal(s) { try { const b = Buffer.from(s, 'base64'), d = crypto.createDecipheriv('aes-256-gcm', k(), b.subarray(0, 12));
d.setAuthTag(b.subarray(12, 28)); return JSON.parse(Buffer.concat([d.update(b.subarray(28)), d.final()]).toString('utf8')); } catch { return null; } },
};
};