// 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 { 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 }); const load = () => { try { const j = JSON.parse(fs.readFileSync(FILE(), 'utf8')); return j && typeof j === 'object' ? j : {}; } catch { return {}; } }; const save = d => atomicWrite(FILE(), JSON.stringify(d), { mode: 0o600 }); const hm = /^([01]\d|2[0-3]):[0-5]\d$/; /** What a child account may be set to: { on, limitMin (0 = no limit), bedtime: { on, from, to }, safe }. */ function cleanKid(k) { if (!k || typeof k !== 'object') return undefined; const n = Math.round(+k.limitMin) || 0, b = k.bedtime || {}; return { on: !!k.on, limitMin: n <= 0 ? 0 : Math.max(15, Math.min(720, n)), 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 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 }, limit = k.limitMin ? k.limitMin + (day.extra || 0) : 0, used = Math.floor((day.secs || 0) / 60); 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, extra: day.extra || 0, bedtime: q.on ? { from: q.from, to: q.to } : null, locked, day: now.day }; } function ping(u) { 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(); day.secs += day.last ? Math.min(90, Math.max(0, (t - day.last) / 1000)) : 30; day.last = t; for (const k2 of Object.keys(mine)) if (k2 < new Date(t - 40 * 864e5).toISOString().slice(0, 10)) delete mine[k2]; save(d); return status(u); } 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); return st.locked ? st : 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') return json(res, 200, ping(me)); 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 };