Parent controls for children, name-tile sign-in, restricted Learn search, simple mode
build-container / build (push) Successful in 3m52s
build-container / build (push) Successful in 3m52s
- Sign-in page shows name tiles on remembered devices; tap a name, then PIN or passkey - Music and Movies are separate on the server; Plex can be locked to a child's managed profile - Activity log, weekly summary and alerts to parents; per-area and weekend limits - Requests from children (Ask button) answered in Settings; bonus screen time from rewards - Restricted Learn search (Simple English Wikipedia, blocked words) replaces web search for children - Simple mode (big-button home) for young children or grandparents - Child usage kept in memory instead of read from disk on every request - Tests and README updated Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
aac6f6efc2
commit
301e8017d9
34 files changed
+466
-51
No files matched your search
@@ -76,18 +76,19 @@ const isAuthed = req => !!currentUser(req);
|
||||
const KIOSK = 30 * 60e3;
|
||||
const devices = require('./devices');
|
||||
const deviceOf = req => { const [id, sig] = String(cookies(req).hub_device || '').split('.'); return id && sig && safeEq(sig, mac('d' + id)) && devices.has(id) ? id : null; };
|
||||
const lifeFor = req => deviceOf(req) ? KIOSK : LIFETIME;
|
||||
const deviceKind = req => { const id = deviceOf(req), d = id && devices.get(id); return d ? d.kind || 'family' : null; };
|
||||
const lifeFor = req => deviceKind(req) === 'family' ? KIOSK : LIFETIME;
|
||||
const sessionCookie = (req, user, life = lifeFor(req)) => { const exp = Date.now() + life;
|
||||
return `hub_session=${user.id}.${user.epoch}.${exp}.${mac(`s${user.id}.${user.epoch}.${exp}`)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${Math.round(life / 1000)}${isHttps(req) ? '; Secure' : ''}`; };
|
||||
const deviceCookie = (req, id) => `hub_device=${id}.${mac('d' + id)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${3650 * 86400}${isHttps(req) ? '; Secure' : ''}`;
|
||||
const enrollDevice = (req, res, name, by) => { const d = devices.add(name, by); addCookie(res, deviceCookie(req, d.id)); return d; };
|
||||
const enrollDevice = (req, res, name, by, kind, byId) => { const d = devices.add(name, by, kind, byId); addCookie(res, deviceCookie(req, d.id)); return d; };
|
||||
const forgetDevice = (req, res) => { const id = deviceOf(req); if (id) devices.remove(id); addCookie(res, 'hub_device=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0'); };
|
||||
// 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, user) { addCookie(res, sessionCookie(req, user)); }
|
||||
function startSession(req, res, user) { addCookie(res, sessionCookie(req, user)); try { require('./activity').log(user.id, 'signin', deviceKind(req) === 'family' ? 'on a family device' : deviceKind(req) === 'trusted' ? 'on a remembered device' : 'on a device'); } catch {} }
|
||||
/** 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) {
|
||||
if (deviceOf(req)) { const e = Number(String(cookies(req).hub_session || '').split('.')[2]); if (!(e - Date.now() > KIOSK - 60e3)) addCookie(res, sessionCookie(req, user, KIOSK)); return; }
|
||||
if (deviceKind(req) === 'family') { const e = Number(String(cookies(req).hub_session || '').split('.')[2]); if (!(e - Date.now() > KIOSK - 60e3)) addCookie(res, sessionCookie(req, user, KIOSK)); return; }
|
||||
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));
|
||||
@@ -213,8 +214,10 @@ async function handle(req, res) {
|
||||
}
|
||||
if (p === '/api/auth/login/options' && req.method === 'POST') {
|
||||
if (!store.creds.length) return send(res, 400, { error: 'No passkeys set up yet' });
|
||||
const options = await generateAuthenticationOptions({ rpID, userVerification: 'preferred' });
|
||||
return send(res, 200, { cid: newChallenge(options.challenge, 'login'), options });
|
||||
const only = body.uid && users.get(String(body.uid)), mine = only ? credsOf(only.id) : [];
|
||||
if (only && !mine.length) return send(res, 400, { error: only.name + ' has no passkey yet. Use the PIN, or add a passkey in Settings.' });
|
||||
const options = await generateAuthenticationOptions({ rpID, userVerification: 'preferred', ...(only ? { allowCredentials: mine.map(c => ({ id: c.id, transports: c.transports })) } : {}) }); // (a person picked from the name tiles: only their own passkeys are offered)
|
||||
return send(res, 200, { cid: newChallenge(options.challenge, 'login', only ? { uid: only.id } : undefined), options });
|
||||
}
|
||||
if (p === '/api/auth/login/verify' && req.method === 'POST') {
|
||||
if (throttled(req)) return send(res, 429, { error: 'Too many attempts. Wait a minute.' });
|
||||
@@ -223,6 +226,7 @@ async function handle(req, res) {
|
||||
const user = cred && users.get(cred.uid);
|
||||
if (!rec || rec.kind !== 'login' || !cred || !user) { audit.log('sign-in failed', { detail: 'passkey not recognised' }, req); return send(res, 400, { error: 'Passkey not recognised' }); }
|
||||
if (user.disabled) return send(res, 403, { error: 'This account has been turned off' });
|
||||
if (rec.extra && rec.extra.uid && rec.extra.uid !== user.id) return send(res, 400, { error: 'That passkey belongs to someone else' });
|
||||
const v = await verifyAuthenticationResponse({
|
||||
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 },
|
||||
@@ -257,4 +261,4 @@ async function handle(req, res) {
|
||||
const passkeyCount = uid => credsOf(uid).length;
|
||||
function removeCredsOf(uid) { store.creds = store.creds.filter(c => c.uid !== uid); save(); delete recovery[uid]; saveRecovery(); }
|
||||
|
||||
module.exports = { deviceOf, enrollDevice, forgetDevice, startSession, handle, currentUser, isAuthed, refresh, addCookie, secret, SESSION_DAYS, newInvite, INVITE_MINUTES, passkeyCount, removeCredsOf };
|
||||
module.exports = { deviceKind, deviceOf, enrollDevice, forgetDevice, startSession, handle, currentUser, isAuthed, refresh, addCookie, secret, SESSION_DAYS, newInvite, INVITE_MINUTES, passkeyCount, removeCredsOf };
|
||||
Reference in new issue
Block a user