From f18d9dd7a95b2e465dd4f232d017803125fb152b Mon Sep 17 00:00:00 2001 From: nmemmert <67961292+nmemmert@users.noreply.github.com> Date: Tue, 20 Oct 2026 16:35:14 -0400 Subject: [PATCH] Multiple people: an admin adds users and chooses which tabs each may use - 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//); 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 --- .gitea/workflows/build.yml | 1 + Dockerfile | 2 +- README.md | 5 + admin.js | 37 ++++++ auth.js | 138 ++++++++++++--------- cal.js | 32 +++-- caldav.js | 12 +- public/app.js | 162 +++++++++++++++++++------ search.js | 6 +- server.js | 97 +++++++++------ settings.js | 10 +- tasks.js | 8 +- tools/smoke-test.js | 11 +- tools/users-test.js | 242 +++++++++++++++++++++++++++++++++++++ users.js | 93 ++++++++++++++ vault.js | 12 ++ 16 files changed, 703 insertions(+), 165 deletions(-) create mode 100644 admin.js create mode 100644 tools/users-test.js create mode 100644 users.js create mode 100644 vault.js diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index 05dd07e..ad0ce18 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -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 diff --git a/Dockerfile b/Dockerfile index 02123a6..fed1d97 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/README.md b/README.md index dd4c572..9dd41ae 100644 --- a/README.md +++ b/README.md @@ -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//`). 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 diff --git a/admin.js b/admin.js new file mode 100644 index 0000000..7aad6b5 --- /dev/null +++ b/admin.js @@ -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 }; diff --git a/auth.js b/auth.js index a60eb3c..677282c 100644 --- a/auth.js +++ b/auth.js @@ -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 }; diff --git a/cal.js b/cal.js index 5e71174..b84fc71 100644 --- a/cal.js +++ b/cal.js @@ -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/.ics : public on purpose (Outlook/Google/Apple fetch it without a session); the token is the secret. */ +/** GET /cal/feed/.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) diff --git a/caldav.js b/caldav.js index 2f29b9a..7b15a19 100644 --- a/caldav.js +++ b/caldav.js @@ -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')); diff --git a/public/app.js b/public/app.js index 9e78751..c3c2fb2 100644 --- a/public/app.js +++ b/public/app.js @@ -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]) => `${icon(TAB_ICON[id])}${label}`; const brand = `
NE Hub
`; + 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('') + - `${icon('more')}More`; - $('#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 ? `${icon('more')}More` : ''); + 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', '', `
${TABS.filter(t => !PRIMARY.includes(t[0])).map(([id, label]) => ` + const el = openSheet('More', '', `
${TABS.filter(t => !PRIMARY.includes(t[0]) && allowed(t[0])).map(([id, label]) => ` ${icon(TAB_ICON[id])}${label}${icon('chev', 'chev')}`).join('')}
`); 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 = `

${greet}

- ${icon('search')}Search files, music, libraries - ${t ? `

Now playing

` : ''} + ${allowed('search') ? `${icon('search')}Search tasks, events, files, music` : ''} + ${t && allowed('music') ? `

Now playing

` : ''}
- ${recents.length ? `

Recent files

${recents.map((r, i) => ``).join('')}
` : ''} + ${recents.length && allowed('files') ? `

Recent files

${recents.map((r, i) => ``).join('')}
` : ''}

Apps

- ${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') : ''}
-

Mail

+ ${allowed('mail') ? `

Mail

${MAIL.map(m => tile(mailUrl(m, 'inbox'), m.color, 'mail', m.name, 'Open inbox', true)).join('')} -
`; +
` : ''}
`; 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 => ``).join(''); + $('#people').querySelectorAll('button[data-p]').forEach(b => b.onclick = () => personSheet(r.users.find(u => u.id === b.dataset.p))); + } catch (e) { box.innerHTML = `
${esc(e.message)}
`; } +} + +const tabBoxes = (chosen, disabled) => `
${peopleTabs.map(t => ``).join('')}
`; +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, '', `

On ${esc(name)}'s device, open ${esc(location.origin)}, tap New device? Use an invite code on the sign-in page, and enter:

+

${esc(code)}

+

It works once and expires in ${minutes} minutes. They will then create their own passkey.

+
`); + 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', '', `
+ ${tabBoxes(['tasks', 'calendar', 'search'])} +

They always get Home and Settings, and their own private tasks, events and settings. You can change this later.

+
`); + 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', `
+ ${admin ? '

Admins can use everything.

' : `${tabBoxes(u.tabsChosen)}`} +
+
+ + + ${self ? '' : ``} + + ${self ? '' : ``}
`); + 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 = `

Settings

-

Storage

Checking…
+ ${ME ? `

Signed in as

${esc(ME.name)}${isAdmin ? 'Admin · can use everything and manage people' : 'Can use: ' + esc(tabNames || 'Home and Settings only')}
` : ''} + ${isAdmin ? `

People

Loading…
+
+

Storage

Checking…
` : ''}

Passkeys

Loading…
-

Connections

-
Seafile${esc(cfg.seafile)} · ${signed ? 'signed in' : 'signed out'}
-
PlexChecking…
-
OnlyOffice${esc(cfg.onlyoffice)}
+

Connections

+
Seafile${esc(cfg.seafile)} · ${signed ? 'signed in' : 'signed out'}
+
PlexChecking…
+
OnlyOffice${esc(cfg.onlyoffice)}
- ${signed ? '
' : ''} -

Mail accounts

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.

+ ${signed ? '
' : ''}
+

Mail accounts

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.

${MAIL.filter(m => m.id !== 'icloud').map(m => ` `).join('')}

iCloud Mail can't take a hint; use Keychain autofill or stay signed in.

-

Audiobooks sign-in

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.

+

Audiobooks sign-in

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.

-

Install

iPhone/iPad: Share → Add to Home Screen. Desktop Chrome/Edge: install icon in the address bar.

`; +

Install

iPhone/iPad: Share → Add to Home Screen. Desktop Chrome/Edge: install icon in the address bar.

`; + // 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) => `
${t}${d}
`; 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(); diff --git a/search.js b/search.js index ba1a9b7..8e749ed 100644 --- a/search.js +++ b/search.js @@ -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) }); diff --git a/server.js b/server.js index cf3ceb5..f634107 100644 --- a/server.js +++ b/server.js @@ -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