Multiple people: an admin adds users and chooses which tabs each may use
build-container / build (push) Successful in 2m20s
build-container / build (push) Successful in 2m20s
- Accounts: the setup code (or an existing single-user install, migrated automatically) creates the admin; the admin adds people in Settings -> People and gives each a one-time invite code to enrol their own passkey - Each person's tasks, NE Hub events and subscribe link, settings, calendar links, iCloud connection and Plex sign-in are private, stored in their own folder (/data/users/<id>/); Plex sign-in moved from browser cookies into that folder (encrypted), so it also follows them across devices - Tab access is enforced by the server (403) and reflected in the app (nav, Home, Settings, search, /api/config hides addresses of tabs a person cannot use); browser storage is namespaced per person - Admin tools: change tabs, invite a device, sign out everywhere (session epoch), turn off, make/remove admin, delete a person and their data; the last admin cannot be removed - Sessions carry the person (uid.epoch.expiry.signature); old single-user cookies still work as the admin - tools/users-test.js (83 checks: isolation, access limits, invites, sign-out/disable/delete, Plex and iCloud per person, migration) now runs in the Gitea workflow before the image is built Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
489ea0aaf0
commit
f18d9dd7a9
16 files changed
+703
-165
No files matched your search
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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,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
@@ -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();
|
||||
|
||||
@@ -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) });
|
||||
|
||||
@@ -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
@@ -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)); };
|
||||
|
||||
|
||||
@@ -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
@@ -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'}`); }
|
||||
|
||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
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); });
|
||||
@@ -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 };
|
||||
@@ -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; } },
|
||||
};
|
||||
};
|
||||
Reference in new issue
Block a user