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>
42 lines
4.1 KiB
JavaScript
42 lines
4.1 KiB
JavaScript
// "Can I have...": a child asks a parent for something (a movie, a book, an app, access to a part of the hub, more time). Parents see the open requests in
|
|
// Settings > Family and in their Notifications, and answer yes or no; the child is told either way. A yes can also open a part of the hub for them or add minutes.
|
|
const fs = require('fs'), path = require('path'), crypto = require('crypto');
|
|
const atomicWrite = require('./atomic');
|
|
const users = require('./users');
|
|
const inbox = require('./inbox');
|
|
const activity = require('./activity');
|
|
const { readBody } = require('./body');
|
|
|
|
const FILE = () => path.join(process.env.DATA_DIR || path.join(__dirname, 'data'), 'kid-requests.json');
|
|
let cache = null;
|
|
const db = () => cache || (cache = (() => { try { const j = JSON.parse(fs.readFileSync(FILE(), 'utf8')); return Array.isArray(j) ? j : []; } catch { return []; } })());
|
|
const save = () => { fs.mkdirSync(path.dirname(FILE()), { recursive: true }); atomicWrite(FILE(), JSON.stringify(db().slice(-300)), { mode: 0o600 }); };
|
|
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 });
|
|
const str = (v, n) => String(v == null ? '' : v).replace(/[\u0000-\u001f]/g, ' ').trim().slice(0, n);
|
|
|
|
async function handle(req, res, me) {
|
|
const u = new URL(req.url, 'http://x'), p = u.pathname.slice('/api/kid/requests'.length) || '/', admin = me.role === 'admin';
|
|
try {
|
|
if (p === '/' && req.method === 'GET') { // a parent sees everything recent; a child sees their own
|
|
const l = db().filter(r => admin || r.user === me.id).sort((a, b) => b.at - a.at).slice(0, 60); return json(res, 200, { requests: l, open: db().filter(r => r.status === 'open' && (admin || r.user === me.id)).length, tabs: admin ? users.TABS : undefined });
|
|
}
|
|
if (p === '/' && req.method === 'POST') { // a child asks
|
|
if (!activity.isKid(me)) throw fail(400, 'This is for child accounts'); const b = JSON.parse(await readBody(req, 4e3) || '{}'), text = str(b.text, 200); if (!text) throw fail(400, 'Say what you would like');
|
|
const tab = users.TABS.includes(b.tab) && !users.can(me, b.tab) ? b.tab : '', open = db().filter(r => r.user === me.id && r.status === 'open'); if (open.length >= 8) throw fail(400, 'You already have a lot waiting. Ask a parent about those first.');
|
|
const r = { id: crypto.randomBytes(5).toString('hex'), user: me.id, userName: me.name, text, tab, minutes: Math.max(0, Math.min(120, Math.round(+b.minutes) || 0)), status: 'open', at: Date.now() }; db().push(r); save();
|
|
for (const a of users.all()) if (!a.disabled && a.role === 'admin') inbox.add(a.id, { kind: 'kid', title: me.name + ' is asking', body: text, url: '#/settings', tag: 'req:' + r.id });
|
|
return json(res, 200, r);
|
|
}
|
|
let m; if ((m = /^\/([0-9a-f]{10})$/.exec(p)) && req.method === 'POST') { // a parent answers
|
|
if (!admin) throw fail(403, 'Only a parent (an admin) can answer'); const r = db().find(x => x.id === m[1]); if (!r) throw fail(404, 'Request not found'); if (r.status !== 'open') throw fail(400, 'That one is already answered');
|
|
const b = JSON.parse(await readBody(req, 4e3) || '{}'), yes = !!b.approve, kid = users.get(r.user); r.status = yes ? 'approved' : 'denied'; r.note = str(b.note, 200); r.by = me.name; r.decidedAt = Date.now();
|
|
if (yes && kid) { if (r.tab && !users.can(kid, r.tab)) users.update(kid.id, { tabs: [...new Set([...(kid.tabs || []), r.tab])] }); if (r.minutes) { try { require('./kidlimits').give(kid.id, r.minutes); } catch {} } }
|
|
save(); if (kid) inbox.add(kid.id, { kind: 'request', title: (yes ? 'Yes: ' : 'Not this time: ') + r.text, body: r.note || (yes ? me.name + ' said yes' : me.name + ' said no'), url: '#/home', tag: 'ans:' + r.id });
|
|
return json(res, 200, r);
|
|
}
|
|
return json(res, 404, { error: 'Not found' });
|
|
} catch (e) { return json(res, e.code >= 400 && e.code < 600 ? e.code : e instanceof SyntaxError ? 400 : 500, { error: e instanceof SyntaxError ? 'Bad request' : e.message }); }
|
|
}
|
|
module.exports = { handle };
|