Files
hub/kidlimits.js
T
nmemmertandClaude Sonnet 5.5 301e8017d9
build-container / build (push) Successful in 3m52s
Parent controls for children, name-tile sign-in, restricted Learn search, simple mode
- 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>
2026-10-26 16:49:48 -04:00

74 lines
8.8 KiB
JavaScript

// Child accounts: a daily time limit and a bedtime, enforced here on the server (so a child cannot get round them by changing the device's clock or reloading), with a way to ask
// a parent for more time. Time counts while the app is open and visible (the app pings once a minute). A locked account can still reach its own status and the "ask a parent" button.
const fs = require('fs'), path = require('path');
const atomicWrite = require('./atomic');
const users = require('./users');
const push = require('./push');
const activity = require('./activity');
const { readBody } = require('./body');
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data'), FILE = () => path.join(DATA_DIR, 'kid-usage.json');
const json = (res, code, o) => { res.writeHead(code, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(o)); };
const fail = (code, message) => Object.assign(new Error(message), { code });
// Every request from a child account asks where they stand, so the usage is kept in memory and only written to disk when it changes (one process serves everyone).
let mem = null, memFile = '';
const load = () => { if (mem && memFile === FILE()) return mem; memFile = FILE(); try { const j = JSON.parse(fs.readFileSync(FILE(), 'utf8')); mem = j && typeof j === 'object' ? j : {}; } catch { mem = {}; } return mem; };
const save = d => { mem = d; memFile = FILE(); atomicWrite(FILE(), JSON.stringify(d), { mode: 0o600 }); };
const hm = /^([01]\d|2[0-3]):[0-5]\d$/;
const AREAS = ['video', 'music', 'listen', 'read', 'chat'], AREA_LABEL = { video: 'Movies and TV', music: 'Music', listen: 'Podcasts, audiobooks and radio', read: 'Books', chat: 'Chat' };
const minutes = v => { const n = Math.round(+v) || 0; return n <= 0 ? 0 : Math.max(15, Math.min(720, n)); };
/** What a child account may be set to: { on, limitMin (0 = no limit), weekendMin (0 = same as other days), areaLimits: { video, music, ... } in minutes, bedtime: { on, from, to }, safe }. */
function cleanKid(k) {
if (!k || typeof k !== 'object') return undefined; const b = k.bedtime || {}, al = {};
for (const a of AREAS) { const n = minutes(k.areaLimits && k.areaLimits[a]); if (n) al[a] = n; }
return { on: !!k.on, limitMin: minutes(k.limitMin), weekendMin: minutes(k.weekendMin), areaLimits: al, bedtime: { on: !!b.on, from: hm.test(b.from) ? b.from : '20:30', to: hm.test(b.to) ? b.to : '07:00' }, safe: k.safe !== false };
}
const weekend = day => { const d = new Date(day + 'T12:00:00Z').getUTCDay(); return d === 0 || d === 6; };
/** Which area of the app a request belongs to, when the address says (a limit on that area closes only that area). */
const areaOfPath = p => /^\/api\/music\//.test(p) ? 'music' : /^\/plex\/video\//.test(p) ? 'video' : /^\/api\/(podcast|vbv|ab|audiobooks|radio)\//.test(p) ? 'listen' : /^\/api\/read\//.test(p) ? 'read' : /^\/api\/(chat|signal)\//.test(p) ? 'chat' : null;
const tzOf = uid => { try { return JSON.parse(fs.readFileSync(path.join(users.dirOf(uid), 'push.json'), 'utf8')).tz || process.env.HUB_TZ || 'UTC'; } catch { return process.env.HUB_TZ || 'UTC'; } };
/** Where this child stands right now. */
function status(u) {
const k = u && u.kid; if (!k || !k.on || u.role === 'admin') return { on: false };
const now = push.localNow(tzOf(u.id)), day = load()[u.id] && load()[u.id][now.day] || { secs: 0, extra: 0 }, base = weekend(now.day) && k.weekendMin ? k.weekendMin : k.limitMin, limit = base ? base + (day.extra || 0) : 0, used = Math.floor((day.secs || 0) / 60);
const areas = {}, lockedAreas = []; for (const a of AREAS) { const lim = (k.areaLimits || {})[a]; if (!lim) continue; const um = Math.floor(((day.areas || {})[a] || 0) / 60); areas[a] = { used: um, limit: lim, left: Math.max(0, lim - um) }; if (um >= lim) lockedAreas.push(a); }
const q = k.bedtime || {}, asleep = !!q.on && (q.from <= q.to ? now.hm >= q.from && now.hm < q.to : now.hm >= q.from || now.hm < q.to);
const locked = asleep ? 'bedtime' : limit && used >= limit ? 'time' : null;
return { on: true, used, limit, left: limit ? Math.max(0, limit - used) : null, areas, lockedAreas, extra: day.extra || 0, bedtime: q.on ? { from: q.from, to: q.to } : null, locked, day: now.day };
}
function ping(u, area) {
const k = u && u.kid; if (!k || !k.on || u.role === 'admin') return status(u); const st = status(u); if (st.locked) return st;
const d = load(), now = push.localNow(tzOf(u.id)), mine = d[u.id] || (d[u.id] = {}), day = mine[now.day] || (mine[now.day] = { secs: 0, extra: 0, last: 0 }), t = Date.now();
const delta = day.last ? Math.min(90, Math.max(0, (t - day.last) / 1000)) : 30; day.secs += delta; day.last = t; if (AREAS.includes(area) || area === 'other') { day.areas = day.areas || {}; day.areas[area] = (day.areas[area] || 0) + delta; }
for (const k2 of Object.keys(mine)) if (k2 < new Date(t - 40 * 864e5).toISOString().slice(0, 10)) delete mine[k2]; save(d);
const after = status(u); if (after.locked) activity.alertParents(u, 'limit', after.locked === 'bedtime' ? 'Bedtime has started and the app is locked' : 'Used all of today\'s time');
for (const a of after.lockedAreas) if (!st.lockedAreas.includes(a)) activity.alertParents(u, 'limit', 'Used all of today\'s ' + AREA_LABEL[a] + ' time'); return after;
}
/** Minutes per day (newest first) with the split by area, for the parents' view. */
function usageByDay(uid, days = 7) { const m = load()[uid] || {}; return Object.keys(m).sort().reverse().slice(0, days).map(day => ({ day, minutes: Math.floor((m[day].secs || 0) / 60), extra: m[day].extra || 0, areas: Object.fromEntries(Object.entries(m[day].areas || {}).map(([a, s]) => [a, Math.floor(s / 60)])) })); }
function give(uid, minutes) { const u = users.get(uid); if (!u || !u.kid || !u.kid.on) throw fail(400, 'That is not a child account'); const d = load(), now = push.localNow(tzOf(uid)), mine = d[uid] || (d[uid] = {}), day = mine[now.day] || (mine[now.day] = { secs: 0, extra: 0, last: 0 }); day.extra = Math.max(0, Math.min(600, (day.extra || 0) + Math.round(+minutes || 0))); save(d); return status(u); }
const ALLOWED_WHILE_LOCKED = /^\/api\/(me|kid(\/|$)|csp-report|push\/)/;
/** For the server: should this request be refused because the account is locked? Returns the status when it should. */
function lockedFor(u, pathname) {
if (!u || !u.kid || !u.kid.on || u.role === 'admin' || ALLOWED_WHILE_LOCKED.test(pathname)) return null; const st = status(u); if (st.locked) return st;
const a = areaOfPath(pathname); return a && st.lockedAreas.includes(a) ? { ...st, locked: 'area:' + a, area: a, label: AREA_LABEL[a] } : null;
}
async function handle(req, res, me, hooks = {}) {
const u = new URL(req.url, 'http://x'), p = u.pathname.slice('/api/kid'.length) || '/';
try {
if (p === '/status' && req.method === 'GET') return json(res, 200, status(me));
if (p === '/ping' && req.method === 'POST') { const b = JSON.parse(await readBody(req, 2e3) || '{}'); return json(res, 200, ping(me, String(b.area || ''))); }
if (p === '/activity' && req.method === 'POST') { const b = JSON.parse(await readBody(req, 4e3) || '{}'); activity.log(me.id, String(b.kind || ''), b.title); return json(res, 200, { ok: true }); } // (what a child played or read, reported by the app)
if (p === '/activity' && req.method === 'GET') { // the parents' view of one child
if (me.role !== 'admin') throw fail(403, 'Only an admin can see this'); const t = users.get(u.searchParams.get('user') || ''); if (!t || !activity.isKid(t)) throw fail(404, 'Child not found'); const days = Math.max(1, Math.min(30, +u.searchParams.get('days') || 7));
return json(res, 200, { name: t.name, days: usageByDay(t.id, days), recent: activity.recent(t.id, days).slice(0, 200), labels: AREA_LABEL });
}
if (p === '/ask' && req.method === 'POST') { if (!me.kid || !me.kid.on) throw fail(400, 'This is for child accounts'); const b = JSON.parse(await readBody(req, 1e4) || '{}'), text = String(b.text || '').slice(0, 300).trim() || 'Can I have a little more time, please?'; const n = hooks.askParents ? hooks.askParents(me, text) : 0; return json(res, 200, { ok: true, told: n }); }
if (p === '/extra' && req.method === 'POST') { if (me.role !== 'admin') throw fail(403, 'Only a parent (an admin) can give more time'); const b = JSON.parse(await readBody(req, 1e4) || '{}'); return json(res, 200, give(String(b.user || ''), b.minutes)); }
if (p === '/usage' && req.method === 'GET') { if (me.role !== 'admin') throw fail(403, 'Only an admin can see this'); const t = users.get(u.searchParams.get('user') || ''); if (!t) throw fail(404, 'Person not found'); return json(res, 200, status(t)); }
return json(res, 404, { error: 'Not found' });
} catch (e) { return json(res, e.code >= 400 && e.code < 600 ? e.code : 400, { error: e instanceof SyntaxError ? 'Bad request' : e.message }); }
}
module.exports = { handle, status, ping, give, lockedFor, cleanKid, usageByDay, AREAS, AREA_LABEL };