From 301e8017d911c4df1b0d223ff343190c91a40ccf Mon Sep 17 00:00:00 2001 From: nmemmert <67961292+nmemmert@users.noreply.github.com> Date: Mon, 26 Oct 2026 16:49:48 -0400 Subject: [PATCH] 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 --- README.md | 12 ++++++++++ activity.js | 46 +++++++++++++++++++++++++++++++++++ admin.js | 2 +- auth.js | 18 ++++++++------ board.js | 17 ++++++++++--- chores.js | 4 ++-- devices.js | 8 ++++--- kidlimits.js | 47 ++++++++++++++++++++++++++---------- kidsearch.js | 50 +++++++++++++++++++++++++++++++++++++++ package.json | 2 +- public/app.js | 25 +++++++++++++++++--- public/boot.js | 2 +- public/chores-view.js | 5 ++-- public/device-ui.js | 2 ++ public/index.html | 1 + public/login.html | 7 ++++++ public/login.js | 34 +++++++++++++++++++++++++- public/player.js | 1 + public/reader.js | 1 + public/search-view.js | 13 ++++++---- public/settings-family.js | 37 +++++++++++++++++++++++++++++ public/settings.js | 6 +++-- public/simple-mode.js | 12 ++++++++++ public/style.css | 11 +++++++++ public/video.js | 1 + public/webauthn.js | 4 ++-- push.js | 2 ++ requests.js | 41 ++++++++++++++++++++++++++++++++ server.js | 41 ++++++++++++++++++++++++++++---- tools/board-test.js | 9 +++++++ tools/kidcontrols-test.js | 33 ++++++++++++++++++++++++++ tools/kidlimits-test.js | 2 +- tools/kidsearch-test.js | 18 ++++++++++++++ users.js | 3 ++- 34 files changed, 466 insertions(+), 51 deletions(-) create mode 100644 activity.js create mode 100644 kidsearch.js create mode 100644 public/simple-mode.js create mode 100644 requests.js create mode 100644 tools/kidcontrols-test.js create mode 100644 tools/kidsearch-test.js diff --git a/README.md b/README.md index f2f4766..927172d 100644 --- a/README.md +++ b/README.md @@ -190,12 +190,24 @@ Files are kept in the data folder under `mystore/` (up to 1 GB each; extensions **Child accounts** (admin screen, per person): the Store, Free Books and podcasts show only child-safe books and non-explicit shows, LibriVox opens on children's books, and an optional daily time limit and bedtime are enforced by the server. A child who runs out of time sees a lock screen and can ask a parent, which sends a message in Chat; an admin can give extra minutes. +**More parent controls** (all under Settings → Family, and in a child's settings under Admin → People): +- **Per-part and weekend limits:** besides the daily limit, a child can have a different weekend time and a limit on one part of the app (Movies and TV, Music, Podcasts/audiobooks/radio, Books, Chat). When one part's time is up only that part closes. +- **Movies and Music are separate on the server:** a person with only Music cannot open Plex movies or shows (and the other way round), including by typing the address. +- **Plex lock:** a parent can lock a child's Plex to the managed profile it is on, which forgets the owner sign-in the hub kept for switching, so they cannot change profile or server. Plex's own limits for that profile (ratings, libraries, labels) still apply. +- **Restricted search for children:** the open-web search and its links are gone for a child account. In Search they get *Learn*: answers from Simple English Wikipedia only (written for young readers), shown inside the hub so they never land on another site. A built-in list of blocked words, plus any words a parent adds (Settings → Family → Blocked search words), stops a search before it is made and hides any result that mentions them; a blocked search tells the parents. It lowers the risk but is not perfect, so every search also shows in the activity view. +- **Activity:** each child's week: minutes per part of the app, what they played, watched, read and searched, sign-ins, and blocked attempts. Chat messages are never logged. A weekly summary for each child arrives in the parents' Notifications on Monday morning, and parents are told when time runs out, when something is blocked, or when a child asks for something. +- **Asking:** a child's *Ask* button sends a request (a movie, a book, a part of the app, more minutes); a parent answers Yes or No and the child is told. A yes can open that part of the app and add the minutes. +- **Bonus screen time:** a weekly reward can come with extra minutes, added to the day when the parent marks it given. +- **Simple mode:** for young children or grandparents: no menus, a Home screen of big buttons and a big Home button. + **Genre search**: in the Store, Free Books and LibriVox you can browse and search books and audiobooks by genre (Fiction, Mystery, History, Children's, Christian and more). LibriVox genres use an index the server builds in the background the first time. **Search** covers mail, tasks, events, notes, chat messages, contacts, chores, recipes, your books, files, music, video, podcasts and the web, and only shows what the person has access to. **Notifications** (the bell on Home and Family): a list of what you have not seen yet: chat messages (one line per conversation, with a count), reminders for tasks, events and mail, and family announcements. Tap one to open it; mark all read or clear. Kept per person in `inbox.json`. +**Name tiles on the sign-in page:** on a device you have remembered (Settings → Family → *Remember this device*, or any family device) the sign-in page shows everyone's name, children marked KID. Tap yours, then enter your PIN (it signs in as soon as the last digit is typed) or use your passkey; a child with no PIN just taps. Any other browser still shows only *Sign in with passkey*, so strangers see no names and cannot guess PINs. A remembered personal device gets a normal long session; a family device keeps its short sessions. *Forget* switches it off again. + **Family devices** (Settings, under Passkeys): for a kitchen tablet or the family computer. An admin turns on *Make this a family device* while signed in on it; from then on it opens on the **family board** (announcement, today's family calendar, chores with progress, shopping list, family tasks and notes) and a row of name tiles, children marked **KID**. Tap your name: a child signs in with one tap, everyone else enters a PIN they set in Settings (five wrong tries lock that person for five minutes; there is also a passkey link). A label with your name and **Switch user** stays on screen, and the device goes back to the board after five idle minutes (music playing keeps it signed in). Sessions on a family device last 30 minutes at a time, not months. Turn it off from the same place. Each board card has a **+** to add to the shopping list, the family tasks, a shared checklist (pick which) or the Family calendar (all day, or at a time): write it, tap your name (PIN unless you are a child) and it is added as you, if that part of the hub is turned on for you. Chores on the board are tappable: tap one (up-for-grabs chores ask *I did it* or *Take it for later*), tap your name, and enter your PIN if you are not a child; the chore is then done or claimed as you, with all the usual chore rules, without signing in. The admin picks who appears on the board (a **Show on the family board** switch in each person's settings; people left off do not get a tile or show in family chore progress). The Family calendar and Family task list are shared by everyone and appear on the board. On a tablet or with the menu collapsed, the menu rail shows icons only (the name appears on hover) when the window is not tall enough for a label under every icon. diff --git a/activity.js b/activity.js new file mode 100644 index 0000000..f010aa9 --- /dev/null +++ b/activity.js @@ -0,0 +1,46 @@ +// What a child does, for the parents: a short log of what they played, read, searched and when they signed in, plus alerts to the parents when something needs them +// (the time is up, a request, something blocked). Kept in DATA_DIR/activity.json. Chat messages are never logged here. +const fs = require('fs'), path = require('path'); +const atomicWrite = require('./atomic'); +const users = require('./users'); +const inbox = require('./inbox'); + +const FILE = () => path.join(process.env.DATA_DIR || path.join(__dirname, 'data'), 'activity.json'); +const MAX_PER_CHILD = 800, KEEP_DAYS = 60; +let cache = null; +const db = () => cache || (cache = (() => { try { const j = JSON.parse(fs.readFileSync(FILE(), 'utf8')); return j && typeof j === 'object' ? j : {}; } catch { return {}; } })()); +const save = () => { try { fs.mkdirSync(path.dirname(FILE()), { recursive: true }); atomicWrite(FILE(), JSON.stringify(db()), { mode: 0o600 }); } catch (e) { console.warn('[activity]', e.message); } }; +const str = (v, n) => String(v == null ? '' : v).replace(/[\u0000-\u001f]/g, ' ').trim().slice(0, n); +const isKid = u => !!(u && u.kid && u.kid.on && u.role !== 'admin'); +const KINDS = ['play', 'watch', 'listen', 'read', 'search', 'signin', 'chore', 'add', 'blocked', 'other']; + +/** Note something a child did. The same thing twice within ten minutes counts once (a song restarting, a page reloading). */ +function log(uid, kind, title) { + const u = users.get(uid); if (!isKid(u)) return; kind = KINDS.includes(kind) ? kind : 'other'; title = str(title, 160); if (!title && kind !== 'signin') return; + const l = db()[uid] || (db()[uid] = []), now = Date.now(), last = l.slice(-6).find(e => e.kind === kind && e.title === title && now - e.t < 10 * 60e3); if (last) return; + l.push({ t: now, kind, title }); const cut = now - KEEP_DAYS * 864e5; while (l.length && (l.length > MAX_PER_CHILD || l[0].t < cut)) l.shift(); save(); +} +const recent = (uid, days = 7) => (db()[uid] || []).filter(e => e.t >= Date.now() - days * 864e5).sort((a, b) => b.t - a.t); + +const alerted = new Map(); // child + kind -> when a parent was last told, so a repeated attempt is one alert, not twenty +/** Tell every parent (admin) in their Notifications list: `kind` is 'limit', 'request' or 'blocked'. */ +function alertParents(kid, kind, text) { + const key = kid.id + ':' + kind + ':' + text; if (Date.now() - (alerted.get(key) || 0) < 30 * 60e3) return; alerted.set(key, Date.now()); if (alerted.size > 500) alerted.clear(); + if (kind === 'blocked') log(kid.id, 'blocked', text); + for (const a of users.all()) if (!a.disabled && a.role === 'admin') inbox.add(a.id, { kind: 'kid', title: kid.name, body: text, url: '#/settings', tag: 'kid:' + kid.id + ':' + kind }); +} + +/** Once a week (Monday morning) every parent gets a short summary of each child's week in their Notifications. */ +function weeklyDigest(now = new Date()) { + const kids = users.all().filter(u => !u.disabled && isKid(u)); if (!kids.length) return false; const wk = now.toISOString().slice(0, 10), monday = new Date(now); monday.setUTCDate(monday.getUTCDate() - ((monday.getUTCDay() + 6) % 7)); + const key = monday.toISOString().slice(0, 10); if (now.getUTCDay() !== 1 || now.getUTCHours() < 8 || db()._digest === key) return false; db()._digest = key; save(); + const { usageByDay, AREA_LABEL } = require('./kidlimits'); + for (const k of kids) { + const days = usageByDay(k.id, 7), mins = days.reduce((n, d) => n + d.minutes, 0), by = {}; for (const d of days) for (const [a, m] of Object.entries(d.areas)) by[a] = (by[a] || 0) + m; + const ev = recent(k.id, 7), count = kind => ev.filter(e => e.kind === kind).length, h = m => m >= 60 ? Math.floor(m / 60) + ' h ' + (m % 60) + ' min' : m + ' min'; + const parts = [h(mins) + ' in the app', ...Object.entries(by).filter(([a, m]) => m >= 5 && AREA_LABEL[a]).map(([a, m]) => AREA_LABEL[a] + ' ' + h(m)), count('play') + count('watch') + count('listen') ? (count('play') + count('watch') + count('listen')) + ' things played' : '', count('search') ? count('search') + ' searches' : '', count('blocked') ? count('blocked') + ' blocked attempts' : ''].filter(Boolean); + for (const a of users.all()) if (!a.disabled && a.role === 'admin') inbox.add(a.id, { kind: 'kid', title: k.name + ': last week', body: parts.join(' · '), url: '#/settings', tag: 'digest:' + k.id + ':' + key }); + } + return true; +} +module.exports = { log, recent, alertParents, isKid, weeklyDigest }; diff --git a/admin.js b/admin.js index e73ea5a..6192c10 100644 --- a/admin.js +++ b/admin.js @@ -26,7 +26,7 @@ async function handle(req, res, me) { 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' }); audit.log('person changed', { who: me.name, detail: u.name + (b.disabled !== undefined ? (b.disabled ? ' (turned off)' : ' (turned on)') : '') + (b.signOutEverywhere ? ' (signed out everywhere)' : '') + (b.tabs ? ' (tabs)' : '') + (b.role ? ' (role)' : '') }, req); - users.update(id, { name: b.name, tabs: b.tabs, role: b.role, disabled: b.disabled, signOutEverywhere: b.signOutEverywhere, kid: b.kid, board: b.board }); + users.update(id, { name: b.name, tabs: b.tabs, role: b.role, disabled: b.disabled, signOutEverywhere: b.signOutEverywhere, kid: b.kid, board: b.board, simple: b.simple }); return json(res, 200, { user: row(users.get(id)) }); } if (!action && req.method === 'DELETE') { diff --git a/auth.js b/auth.js index 779ad62..1d7156e 100644 --- a/auth.js +++ b/auth.js @@ -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 }; diff --git a/board.js b/board.js index 0f1fa68..25b1c08 100644 --- a/board.js +++ b/board.js @@ -52,7 +52,7 @@ async function board(day) { } async function handle(req, res) { - const u = new URL(req.url, 'http://x'), p = u.pathname, me = auth.currentUser(req), dev = auth.deviceOf(req); + const u = new URL(req.url, 'http://x'), p = u.pathname, me = auth.currentUser(req), anyDev = auth.deviceOf(req), dev = auth.deviceKind(req) === 'family' ? anyDev : null; // (dev: a family device; anyDev: any remembered device) try { if (p === '/api/board' && req.method === 'GET') { if (!dev) throw fail(403, 'This is not a family device'); const day = u.searchParams.get('day'); return json(res, 200, await board(ymdOk(day) ? day : new Date().toISOString().slice(0, 10))); } if (p === '/api/board/chore' && req.method === 'POST') { // a person on the shared screen takes a chore, ticks it off, or undoes it, without signing in @@ -74,13 +74,20 @@ async function handle(req, res) { identify(t, b.pin, req); const r = Readable.from([Buffer.from(JSON.stringify(plan[3]))]); r.url = plan[2]; r.method = 'POST'; r.headers = {}; return users.runAs(t, () => plan[1].handle(r, res)); // (as that person, so that person's own rules apply) } + if (p === '/api/login/people' && req.method === 'GET') { // the sign-in page's name tiles: only on a remembered device (family or a person's own) + if (!anyDev) throw fail(403, 'This device is not remembered'); return json(res, 200, { people: users.all().filter(u => !u.disabled).map((u, i) => ({ id: u.id, name: u.name, kid: isKid(u), pin: hasPin(u.id), pinLen: (db()[u.id] || {}).n || 0, passkey: auth.passkeyCount(u.id) > 0, color: palette[i % palette.length] })) }); + } + if (p === '/api/login/pin' && req.method === 'POST') { // sign in from a name tile with a PIN (a child with no PIN just taps) + if (!anyDev) throw fail(403, 'This device is not remembered'); const b = JSON.parse(await readBody(req, 5e3) || '{}'), t = users.get(String(b.uid)); + if (!t || t.disabled) throw fail(404, 'Person not found'); identify(t, b.pin, req); auth.startSession(req, res, t); audit.log('sign-in', { who: t.name, detail: 'name tile' }, req); return json(res, 200, { ok: true }); + } if (p === '/api/board/switch' && req.method === 'POST') { if (!dev) throw fail(403, 'This is not a family device'); const b = JSON.parse(await readBody(req, 5e3) || '{}'), t = users.get(String(b.uid)); if (!t || !onBoard(t)) throw fail(404, 'Person not found'); identify(t, b.pin, req); auth.startSession(req, res, t); audit.log('family device: picked a profile', { who: t.name }, req); return json(res, 200, { ok: true }); } - if (p === '/api/device' && req.method === 'GET') { if (!me) throw fail(401, 'Not signed in'); return json(res, 200, { device: !!dev, idleMin: idleMin(), idleChoices: IDLE_CHOICES, pin: hasPin(me.id), devices: me.role === 'admin' ? devices.load() : undefined }); } + if (p === '/api/device' && req.method === 'GET') { if (!me) throw fail(401, 'Not signed in'); return json(res, 200, { device: !!dev, trusted: auth.deviceKind(req) === 'trusted', idleMin: idleMin(), idleChoices: IDLE_CHOICES, pin: hasPin(me.id), devices: me.role === 'admin' ? devices.load() : undefined }); } if (p === '/api/device/pin' && (req.method === 'POST' || req.method === 'DELETE')) { if (!me) throw fail(401, 'Not signed in'); if (req.method === 'DELETE') { delete db()[me.id]; savePins(); return json(res, 200, { pin: false }); } const b = JSON.parse(await readBody(req, 5e3) || '{}'); setPin(me.id, b.pin); return json(res, 200, { pin: true }); @@ -93,8 +100,12 @@ async function handle(req, res) { if (!me || me.role !== 'admin') throw fail(403, 'Only an admin can do this'); const b = JSON.parse(await readBody(req, 5e3) || '{}'); auth.enrollDevice(req, res, b.name, me.name); audit.log('family device added', { who: me.name, detail: String(b.name || '').slice(0, 40) }, req); res.setHeader('Set-Cookie', [].concat(res.getHeader('Set-Cookie') || [], 'hub_session=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0')); return json(res, 200, { ok: true }); } + if (p === '/api/device/trust' && req.method === 'POST') { // a person remembers their own device, so the sign-in page shows name tiles on it + if (!me) throw fail(401, 'Not signed in'); if (anyDev) return json(res, 200, { ok: true }); auth.enrollDevice(req, res, '', me.name, 'trusted', me.id); audit.log('device remembered', { who: me.name }, req); return json(res, 200, { ok: true }); + } if (p === '/api/device/remove' && req.method === 'POST') { // switch one off: this screen, or another from the list - if (!me || me.role !== 'admin') throw fail(403, 'Only an admin can do this'); const b = JSON.parse(await readBody(req, 5e3) || '{}'); + if (!me) throw fail(401, 'Not signed in'); const b = JSON.parse(await readBody(req, 5e3) || '{}'), here = anyDev && devices.get(anyDev); + if (me.role !== 'admin' && !(here && here.kind === 'trusted' && here.byId === me.id && !b.id)) throw fail(403, 'Only an admin can do this'); if (b.id) devices.remove(String(b.id)); else auth.forgetDevice(req, res); audit.log('family device removed', { who: me.name }, req); return json(res, 200, { ok: true }); } return json(res, 404, { error: 'Not found' }); diff --git a/chores.js b/chores.js index fb9ad25..ea17fe2 100644 --- a/chores.js +++ b/chores.js @@ -79,7 +79,7 @@ function week(day) { const out = [...per.values()].map(e => ({ ...e, done: e.done.sort((a, b) => a.date.localeCompare(b.date)), tiers: tiers.filter(t => forMe(t, e.id)).map(t => t.id), earned: tiers.filter(t => forMe(t, e.id) && e.points >= t.points).map(t => t.id), given: d.given.filter(g => g.week === from && g.user === e.id).map(g => g.reward) })).sort((a, b) => b.points - a.points || b.count - a.count || a.name.localeCompare(b.name)); return { from, to, tiers, people: out }; } -const cleanReward = (b, old) => { const title = str(b.title, 80); if (!title) throw fail(400, 'Say what the reward is'); const points = Math.round(+b.points); if (!(points >= 1 && points <= 1000)) throw fail(400, 'Choose the points needed (1 to 1000)'); const r = { id: old ? old.id : newId(), points, title }; const who = Array.isArray(b.for) ? [...new Set(b.for.map(String))].filter(liveKid).slice(0, 30) : null; if (who) { if (!who.length) throw fail(400, 'Choose who it is for (child accounts)'); r.for = who; } return r; }; +const cleanReward = (b, old) => { const title = str(b.title, 80); if (!title) throw fail(400, 'Say what the reward is'); const points = Math.round(+b.points); if (!(points >= 1 && points <= 1000)) throw fail(400, 'Choose the points needed (1 to 1000)'); const bonus = Math.max(0, Math.min(240, Math.round(+b.minutes) || 0)), r = { id: old ? old.id : newId(), points, title }; if (bonus) r.minutes = bonus; /* (optional: extra screen time that comes with the reward) */ const who = Array.isArray(b.for) ? [...new Set(b.for.map(String))].filter(liveKid).slice(0, 30) : null; if (who) { if (!who.length) throw fail(400, 'Choose who it is for (child accounts)'); r.for = who; } return r; }; async function handle(req, res) { const u = new URL(req.url, 'http://x'), p = u.pathname.slice('/api/chores'.length) || '/', me = users.current(), q = u.searchParams, admin = me.role === 'admin'; @@ -122,7 +122,7 @@ async function handle(req, res) { if (p === '/give' && req.method === 'POST') { // note that a reward was handed over (admin), or take it back if (!admin) throw fail(403, 'Only an admin can do this'); const b = JSON.parse(await readBody(req, 5e3) || '{}'), d = db(), wk = monday(ymdOk(b.week) ? b.week : day), i = d.given.findIndex(g => g.week === wk && g.user === String(b.user) && g.reward === String(b.reward)); if (b.undo) { if (i >= 0) d.given.splice(i, 1); save(); return json(res, 200, { ok: true }); } - const w = week(wk), e = w.people.find(x => x.id === String(b.user)); if (!e || !e.earned.includes(String(b.reward))) throw fail(400, 'They have not earned that one'); if (i < 0) d.given.push({ week: wk, user: e.id, reward: String(b.reward), at: Date.now(), by: me.id }); if (d.given.length > 3000) d.given.splice(0, d.given.length - 3000); save(); return json(res, 200, { ok: true }); + const w = week(wk), e = w.people.find(x => x.id === String(b.user)); if (!e || !e.earned.includes(String(b.reward))) throw fail(400, 'They have not earned that one'); if (i < 0) { d.given.push({ week: wk, user: e.id, reward: String(b.reward), at: Date.now(), by: me.id }); const rw = d.rewards.find(x => x.id === String(b.reward)); if (rw && rw.minutes) { try { require('./kidlimits').give(e.id, rw.minutes); } catch {} } } /* (a reward with bonus screen time adds it to today) */ if (d.given.length > 3000) d.given.splice(0, d.given.length - 3000); save(); return json(res, 200, { ok: true }); } if (p === '/summary' && req.method === 'GET') return json(res, 200, summary(q.get('range') === 'month' ? 'month' : 'week', day)); return json(res, 404, { error: 'Not found' }); diff --git a/devices.js b/devices.js index 3a81580..f1046ad 100644 --- a/devices.js +++ b/devices.js @@ -1,4 +1,5 @@ -// Family devices: shared screens (a kitchen tablet, the family computer) that open on the family board instead of a sign-in. An admin makes a device one +// Trusted devices: a browser that is remembered on the sign-in page, so it shows name tiles. A family device (kind 'family') is a shared screen that opens +// on the family board and signs people out quickly; a 'trusted' one is a person's own device. Family devices: shared screens (a kitchen tablet, the family computer) that open on the family board instead of a sign-in. An admin makes a device one // while signed in on it; the device keeps a signed cookie, and this list is what lets an admin switch one off again. Kept in DATA_DIR/devices.json. const fs = require('fs'), path = require('path'), crypto = require('crypto'); const atomicWrite = require('./atomic'); @@ -6,6 +7,7 @@ const FILE = () => path.join(process.env.DATA_DIR || path.join(__dirname, 'data' const load = () => { try { const j = JSON.parse(fs.readFileSync(FILE(), 'utf8')); return Array.isArray(j.devices) ? j.devices : []; } catch { return []; } }; const save = l => { fs.mkdirSync(path.dirname(FILE()), { recursive: true }); atomicWrite(FILE(), JSON.stringify({ devices: l }), { mode: 0o600 }); }; const has = id => load().some(d => d.id === id); -const add = (name, by) => { const d = { id: crypto.randomBytes(9).toString('base64url'), name: String(name || 'Family device').slice(0, 40), by, created: Date.now() }; save([...load(), d]); return d; }; +const get = id => load().find(d => d.id === id) || null; +const add = (name, by, kind = 'family', byId = '') => { const d = { id: crypto.randomBytes(9).toString('base64url'), name: String(name || (kind === 'family' ? 'Family device' : 'My device')).slice(0, 40), by, byId, kind, created: Date.now() }; save([...load(), d]); return d; }; const remove = id => save(load().filter(d => d.id !== id)); -module.exports = { load, has, add, remove }; +module.exports = { load, has, get, add, remove }; diff --git a/kidlimits.js b/kidlimits.js index 90e7847..e081149 100644 --- a/kidlimits.js +++ b/kidlimits.js @@ -4,47 +4,70 @@ 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 }); -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 }); +// 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$/; -/** What a child account may be set to: { on, limitMin (0 = no limit), bedtime: { on, from, to }, safe }. */ +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 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 }; + 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 }, limit = k.limitMin ? k.limitMin + (day.extra || 0) : 0, used = Math.floor((day.secs || 0) / 60); + 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, extra: day.extra || 0, bedtime: q.on ? { from: q.from, to: q.to } : null, locked, day: now.day }; + 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) { +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(); - 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); + 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); return st.locked ? st : null; } +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') return json(res, 200, ping(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 }; +module.exports = { handle, status, ping, give, lockedFor, cleanKid, usageByDay, AREAS, AREA_LABEL }; diff --git a/kidsearch.js b/kidsearch.js new file mode 100644 index 0000000..d9e5111 --- /dev/null +++ b/kidsearch.js @@ -0,0 +1,50 @@ +// A restricted web search for children: it only ever asks Simple English Wikipedia (written for young readers) and shows the answer inside the hub, so a child never +// lands on an unfiltered site. A built-in list of words, plus any words a parent adds, stops a search before it is made and hides any result that mentions them. +// This lowers the risk; it is not perfect, which is why every search is also in the parents' activity view. +const fs = require('fs'), path = require('path'); +const atomicWrite = require('./atomic'); +const users = require('./users'); +const activity = require('./activity'); +const { readBody } = require('./body'); + +const FILE = () => path.join(process.env.DATA_DIR || path.join(__dirname, 'data'), 'kidsearch.json'); +const API = 'https://simple.wikipedia.org/w/api.php', UA = 'NE-Hub/2.0 (family hub; kids search)'; +const BASE = ['porn', 'porno', 'pornography', 'sex', 'sexual', 'sexy', 'nude', 'nudes', 'naked', 'nsfw', 'xxx', 'erotic', 'fetish', 'orgasm', 'masturbation', 'genitals', 'vagina', 'penis', 'breasts', 'rape', 'molest', 'prostitute', 'prostitution', 'escort', 'gore', 'torture', 'suicide', 'self-harm', 'cutting', 'cocaine', 'heroin', 'meth', 'methamphetamine', 'marijuana', 'cannabis', 'weed', 'lsd', 'ecstasy', 'opioid', 'overdose', 'gambling', 'casino', 'betting', 'hentai', 'onlyfans', 'tinder', 'hookup', 'bomb making', 'how to kill', 'school shooting', 'terrorist']; +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 clean = w => String(w || '').toLowerCase().replace(/[^a-z0-9 '-]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 40); +let extra = null; +const words = () => extra || (extra = (() => { try { const j = JSON.parse(fs.readFileSync(FILE(), 'utf8')); return Array.isArray(j.words) ? j.words.map(clean).filter(Boolean).slice(0, 200) : []; } catch { return []; } })()); +const setWords = l => { extra = [...new Set(l.map(clean).filter(Boolean))].slice(0, 200); fs.mkdirSync(path.dirname(FILE()), { recursive: true }); atomicWrite(FILE(), JSON.stringify({ words: extra }), { mode: 0o600 }); return extra; }; +const esc = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +/** Does the text contain a blocked word (whole words, so "sextant" or "class" are fine)? */ +const blocked = text => { const t = ' ' + String(text || '').toLowerCase().replace(/[^a-z0-9' -]/g, ' ').replace(/\s+/g, ' ') + ' '; return [...BASE, ...words()].some(w => new RegExp('(^|[^a-z0-9])' + esc(w) + '(?![a-z0-9])').test(t)); }; + +const get = async params => { const r = await fetch(API + '?' + new URLSearchParams({ format: 'json', ...params }), { headers: { 'User-Agent': UA, Accept: 'application/json' }, signal: AbortSignal.timeout(8000) }); if (!r.ok) throw fail(502, 'The kids search could not be reached right now'); return r.json(); }; +async function search(q) { + const j = await get({ action: 'query', generator: 'search', gsrsearch: q, gsrlimit: '8', prop: 'extracts', exintro: '1', explaintext: '1', exlimit: 'max', exchars: '420' }); + return Object.values((j.query && j.query.pages) || {}).sort((a, b) => a.index - b.index).map(p => ({ title: p.title, extract: String(p.extract || '').trim() })).filter(p => p.extract && !blocked(p.title + ' ' + p.extract)).slice(0, 5); +} +async function page(title) { + const j = await get({ action: 'query', prop: 'extracts', explaintext: '1', titles: title, redirects: '1' }), p = Object.values((j.query && j.query.pages) || {})[0]; + if (!p || p.missing !== undefined || !p.extract) throw fail(404, 'No page like that'); if (blocked(p.title + ' ' + p.extract.slice(0, 4000))) throw fail(403, 'That page is not available'); + return { title: p.title, text: p.extract.slice(0, 12000) }; +} + +async function handle(req, res) { + const u = new URL(req.url, 'http://x'), p = u.pathname.slice('/api/kidsearch'.length) || '/', me = users.current(), admin = me.role === 'admin'; + try { + if (p === '/words') { // the extra words a parent blocks (the built-in list is not shown) + if (!admin) throw fail(403, 'Only a parent (an admin) can do this'); if (req.method === 'POST') { const b = JSON.parse(await readBody(req, 8e3) || '{}'); return json(res, 200, { words: setWords(Array.isArray(b.words) ? b.words : String(b.words || '').split(/[,\n]/)) }); } + return json(res, 200, { words: words() }); + } + if (p === '/' && req.method === 'GET') { + const q = String(u.searchParams.get('q') || '').replace(/\s+/g, ' ').trim().slice(0, 100); if (q.length < 2) return json(res, 200, { results: [] }); + if (blocked(q)) { if (activity.isKid(me)) activity.alertParents(me, 'blocked', 'searched for something blocked: "' + q + '"'); return json(res, 200, { results: [], blocked: true, message: 'That search is not available. You can ask a parent.' }); } + return json(res, 200, { results: await search(q), source: 'Simple English Wikipedia' }); + } + if (p === '/page' && req.method === 'GET') return json(res, 200, await page(String(u.searchParams.get('title') || '').slice(0, 120))); + return json(res, 404, { error: 'Not found' }); + } catch (e) { return json(res, e.code >= 400 && e.code < 600 ? e.code : e instanceof SyntaxError ? 400 : 502, { error: e instanceof SyntaxError ? 'Bad request' : e.message }); } +} +module.exports = { handle, blocked, setWords, words, BASE }; diff --git a/package.json b/package.json index 8526bb0..20f4056 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "main": "server.js", "scripts": { "start": "node server.js", - "test": "for t in smoke tasks push users cal plex player lyrics signal mail mail-imap files podcast abapi abdirect books store librivox radio vbv savefiles bible lrclib visualizer body transcript recipes contacts mystore caldav musicdata screentime chat notes chores kidlimits search inbox family board chores-view; do node tools/$t-test.js || exit 1; done", + "test": "for t in smoke tasks push users cal plex player lyrics signal mail mail-imap files podcast abapi abdirect books store librivox radio vbv savefiles bible lrclib visualizer body transcript recipes contacts mystore caldav musicdata screentime chat notes chores kidlimits search inbox family board chores-view kidcontrols kidsearch; do node tools/$t-test.js || exit 1; done", "audit": "npm audit --omit=dev --audit-level=high" }, "keywords": [], diff --git a/public/app.js b/public/app.js index a81a337..d4fa730 100644 --- a/public/app.js +++ b/public/app.js @@ -4,7 +4,7 @@ const _fetch = window.fetch.bind(window); window.fetch = async (...a) => { const r = await _fetch(...a); if (r.status === 401 && r.headers.get('x-hub-auth') === 'required') location.replace('/login'); - if (r.status === 403 && r.headers.get('x-hub-limit') && typeof ME !== 'undefined' && ME && ME.kid) r.clone().json().then(j => { kidUi.st = j.status || { on: true, locked: j.limit }; kidPaint(); }).catch(() => {}); // (a child account whose time is up: show the lock screen) + if (r.status === 403 && r.headers.get('x-hub-limit') && typeof ME !== 'undefined' && ME && ME.kid) r.clone().json().then(j => { if (String(r.headers.get('x-hub-limit')).startsWith('area:')) { toast(j.error || 'That time is up for today'); return; } kidUi.st = j.status || { on: true, locked: j.limit }; kidPaint(); }).catch(() => {}); /* (an area's time is up: say so; the whole app locks only when the day's time or bedtime is up) */ // (a child account whose time is up: show the lock screen) return r; }; const $ = s => document.querySelector(s); @@ -257,7 +257,7 @@ function drawSubnav(name) { const navIdOf = name => name === 'edit' ? 'files' : (name === 'tasks' || name === 'calendar') ? 'planner' : leafOf(name) || name; let curRoute = ''; -const routesOf = () => ({ video: viewVideo, planner: viewPlanner, home: viewHome, mail: viewMail, files: viewFiles, office: viewOffice, audio: args => args[0] === 'classic' ? viewAudio() : args[0] === 'free' ? (allowed('store') ? location.replace('#/store/audiobooks') : viewFreeBooks()) : viewBooks(args), verse: () => location.replace('#/links'), podcast: args => args[0] === 'vbv' ? viewVbv(args.slice(1)) : viewPodcast(args), radio: () => viewRadio(), recipes: args => viewRecipes(args), store: args => viewStore(args), chat: args => viewChat(args), notes: args => viewNotes(args), family: () => viewFamily(), chores: () => viewChores(), contacts: args => viewContacts(args), bible: args => viewBible(args), listen: () => location.replace('#/' + lastSub('listen')), reading: () => location.replace('#/' + lastSub('reading')), read: args => viewReader(args), music: viewMusic, tasks: viewTasks, search: viewSearch, calendar: viewCalendar, signal: viewSignal, links: viewLinks, settings: viewSettings, edit: viewEdit }); +const routesOf = () => ({ video: viewVideo, planner: viewPlanner, home: ME && ME.simple && typeof viewSimple === 'function' ? viewSimple : viewHome, mail: viewMail, files: viewFiles, office: viewOffice, audio: args => args[0] === 'classic' ? viewAudio() : args[0] === 'free' ? (allowed('store') ? location.replace('#/store/audiobooks') : viewFreeBooks()) : viewBooks(args), verse: () => location.replace('#/links'), podcast: args => args[0] === 'vbv' ? viewVbv(args.slice(1)) : viewPodcast(args), radio: () => viewRadio(), recipes: args => viewRecipes(args), store: args => viewStore(args), chat: args => viewChat(args), notes: args => viewNotes(args), family: () => viewFamily(), chores: () => viewChores(), contacts: args => viewContacts(args), bible: args => viewBible(args), listen: () => location.replace('#/' + lastSub('listen')), reading: () => location.replace('#/' + lastSub('reading')), read: args => viewReader(args), music: viewMusic, tasks: viewTasks, search: viewSearch, calendar: viewCalendar, signal: viewSignal, links: viewLinks, settings: viewSettings, edit: viewEdit }); let editor; // Menu on a tablet or computer: collapse it to icons (wide screens), or hide it completely. Remembered on this device. const navPref = () => { try { return JSON.parse(localStorage.getItem('nav_pref')) || {}; } catch { return {}; } }; @@ -400,9 +400,28 @@ async function walkSearch(repos, term) { const kidUi = { st: null, timer: null, told: new Set() }; async function kidStart() { if (!ME || !ME.kid || !ME.kid.on || kidUi.timer) return; - const tick = async () => { try { kidUi.st = document.visibilityState === 'visible' ? await jfetch('/api/kid/ping', { method: 'POST' }) : await jfetch('/api/kid/status'); } catch (e) { return; } kidPaint(); }; + kidAskButton(); + const tick = async () => { try { kidUi.st = document.visibilityState === 'visible' ? await jfetch('/api/kid/ping', { method: 'POST', body: JSON.stringify({ area: kidArea() }) }) : await jfetch('/api/kid/status'); } catch (e) { return; } kidPaint(); }; await tick(); kidUi.timer = setInterval(tick, 60e3); document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') tick(); }); } +/** Which part of the app the child is in, so the parents can see where the time goes (and limit one part). */ +function kidArea() { const r = (location.hash.slice(2).split('/')[0] || 'home'), h = location.hash; return r === 'video' ? 'video' : r === 'music' ? 'music' : /^(audio|podcast|radio|listen|vbv)$/.test(r) || /^#\/store\/(audiobooks|radio)/.test(h) ? 'listen' : /^(read|reading|bible)$/.test(r) || /^#\/store\/books/.test(h) ? 'read' : /^(chat|signal)$/.test(r) ? 'chat' : 'other'; } +const actSeen = new Map(); +/** Tell the server what a child just played, read or watched (for the parents' view). Only child accounts report. */ +function actLog(kind, title) { if (!ME || !ME.kid || !ME.kid.on || !title) return; const k = kind + '|' + title, now = Date.now(); if (now - (actSeen.get(k) || 0) < 10 * 60e3) return; actSeen.set(k, now); jfetch('/api/kid/activity', { method: 'POST', body: JSON.stringify({ kind, title }) }).catch(() => {}); } +/** A small "Ask" button for a child: ask a parent for something (a movie, a book, a part of the hub, more time). */ +function kidAskButton() { + if (document.getElementById('kidreq')) return; const b = document.createElement('button'); b.id = 'kidreq'; b.type = 'button'; b.textContent = '🙋 Ask'; b.setAttribute('aria-label', 'Ask a parent for something'); document.body.appendChild(b); + b.onclick = () => { + const off = (typeof TABS !== 'undefined' ? TABS : []).filter(t => !allowed(t[0]) && !['settings', 'home', 'family'].includes(t[0])); + const el = openSheet('Ask a parent', 'Tell them what you would like. They will see it and answer.', `
+ ${off.length ? `` : ''} + +
`); + el.querySelector('#krx').onclick = closeSheet; + el.querySelector('#krf').onsubmit = async e => { e.preventDefault(); try { await jfetch('/api/kid/requests', { method: 'POST', body: JSON.stringify({ text: el.querySelector('#krt').value, tab: el.querySelector('#krb') ? el.querySelector('#krb').value : '', minutes: +el.querySelector('#krm').value }) }); closeSheet(); toast('Sent. A parent will answer.'); } catch (x) { toast(x.message); } }; + }; +} function kidPaint() { const st = kidUi.st; if (!st || !st.on) return; let chip = document.getElementById('kidchip'); if (st.left !== null && !st.locked) { diff --git a/public/boot.js b/public/boot.js index a0847f6..a24746c 100644 --- a/public/boot.js +++ b/public/boot.js @@ -18,7 +18,7 @@ setInterval(checkForUpdate, 10 * 60e3); await loadMe(); await loadSettings(); - sgStart(); if (typeof chStart === 'function') chStart(); if (typeof kidStart === 'function') kidStart(); if (typeof devStart === 'function') devStart(); + sgStart(); if (typeof chStart === 'function') chStart(); if (typeof kidStart === 'function') kidStart(); if (typeof devStart === 'function') devStart(); if (typeof simpleStart === 'function') simpleStart(); addEventListener('hashchange', route); restorePlayer(); route(); diff --git a/public/chores-view.js b/public/chores-view.js index 55622e4..16dd13c 100644 --- a/public/chores-view.js +++ b/public/chores-view.js @@ -52,7 +52,7 @@ function crManage() { const when = c => CR_REPEAT[c.repeat] + (c.repeat === 'weekly' && c.days && c.days.length ? ' · ' + c.days.map(x => CR_DAYS[x]).join(' ') : '') + (c.repeat === 'monthly' ? ' · day ' + (c.dom || 1) : '') + (c.repeat === 'once' ? ' · ' + c.date : ''); const el = openSheet('Manage chores', 'Make jobs for the family and choose who does them', `
${d.chores.map(c => ``).join('') || '

No chores yet.

'}
-

Weekly rewards

${d.rewards.map(r => ``).join('') || '

No rewards yet.

'}
+

Weekly rewards

${d.rewards.map(r => ``).join('') || '

No rewards yet.

'}
`); el.querySelector('#rwnew').onclick = () => { closeSheet(); crReward(null); }; el.querySelectorAll('[data-rw]').forEach(b => b.onclick = () => { closeSheet(); crReward(d.rewards.find(r => r.id === b.dataset.rw)); }); el.querySelector('#crnew').onclick = () => { closeSheet(); crEdit(null); }; @@ -60,11 +60,12 @@ function crManage() { } function crReward(r) { const el = openSheet(r ? 'Edit reward' : 'New reward', 'Anyone who reaches the points in a week earns it. Points start again each Monday.', `
+
${cr.data.people.filter(p => p.kid).map(p => ``).join('')}
${r ? '' : ''}
`); const rsync = () => { el.querySelector('#rwpp').hidden = el.querySelector('#rwall').checked; }; el.querySelector('#rwall').onchange = rsync; rsync(); el.querySelector('#rwx').onclick = closeSheet; if (r) el.querySelector('#rwdel').onclick = async () => { try { await crPost('/reward', { id: r.id, remove: true }); closeSheet(); crRefresh(); } catch (e) { toast(e.message); } }; - el.querySelector('#rwf').onsubmit = async e => { e.preventDefault(); try { await crPost('/reward', { id: r && r.id, title: el.querySelector('#rwt').value, points: +el.querySelector('#rwp').value, for: el.querySelector('#rwall').checked ? 'all' : [...el.querySelectorAll('#rwpp [data-p]:checked')].map(i => i.dataset.p) }); closeSheet(); crRefresh(); } catch (x) { el.querySelector('#rwe').textContent = x.message; } }; + el.querySelector('#rwf').onsubmit = async e => { e.preventDefault(); try { await crPost('/reward', { id: r && r.id, title: el.querySelector('#rwt').value, points: +el.querySelector('#rwp').value, minutes: +el.querySelector('#rwm').value || 0, for: el.querySelector('#rwall').checked ? 'all' : [...el.querySelectorAll('#rwpp [data-p]:checked')].map(i => i.dataset.p) }); closeSheet(); crRefresh(); } catch (x) { el.querySelector('#rwe').textContent = x.message; } }; } function crEdit(c) { const d = cr.data, x = c || { title: '', notes: '', repeat: 'daily', days: [], dom: 1, date: crYmd(new Date()), assign: 'anyone', points: 0, active: true }, chosen = new Set(x.assign === 'anyone' ? [] : x.assign); diff --git a/public/device-ui.js b/public/device-ui.js index d046b2b..3490a29 100644 --- a/public/device-ui.js +++ b/public/device-ui.js @@ -16,10 +16,12 @@ async function devSettings(box) { if (!box) return; let r; try { r = await jfetch('/api/device'); } catch { return; } const admin = ME && ME.role === 'admin', post = (p, b) => jfetch('/api/device/' + p, { method: 'POST', body: JSON.stringify(b || {}) }); box.innerHTML = `

Shared screens

+ ${r.device ? '' : `
${r.trusted ? 'This device is remembered' : 'Remember this device'}${r.trusted ? 'The sign-in page shows everyone\'s name on this device. Tap yours, then use your PIN or passkey.' : 'Show name tiles on the sign-in page of this device, so you can tap your name and use your PIN or passkey. Only do this on a device you trust.'}
`}
${r.pin ? 'Your PIN is set' : 'Set a PIN'}On a shared family screen, you pick your name and enter this PIN (children can just tap their name). 4 to 8 digits.${r.pin ? '' : ''}
${admin ? `
Back to the board afterWhen nobody touches a family device for this long, it signs the person out and shows the family board. Music or a video playing keeps it signed in.
${r.device ? 'This is a family device' : 'Make this a family device'}${r.device ? 'It opens on the family board and signs people out after a few idle minutes.' : 'For a kitchen tablet or the family computer: it opens on the family board, and everyone picks their name to use it. You will be signed out here.'}
` : ''}
`; box.querySelector('#devpin').onclick = () => { const el = openSheet('Your PIN', 'Used to pick your name on a shared family screen.', '
'); el.querySelector('#pns').onclick = async () => { try { await post('pin', { pin: el.querySelector('#pn').value }); closeSheet(); toast('PIN saved'); devSettings(box); } catch (e) { toast(e.message); } }; }; + const tr = box.querySelector('#devtrust'); if (tr) tr.onclick = async () => { try { await post(r.trusted ? 'remove' : 'trust'); toast(r.trusted ? 'This device is forgotten' : 'This device is remembered'); devSettings(box); } catch (e) { toast(e.message); } }; const idle = box.querySelector('#devidle'); if (idle) idle.onchange = async () => { try { await post('idle', { minutes: +idle.value }); toast('Saved'); } catch (e) { toast(e.message); } }; const x = box.querySelector('#devpinx'); if (x) x.onclick = async () => { try { await jfetch('/api/device/pin', { method: 'DELETE' }); devSettings(box); } catch (e) { toast(e.message); } }; const t = box.querySelector('#devtoggle'); if (t) t.onclick = () => r.device ? confirmSheet('Turn off family device?', 'This screen goes back to the normal sign-in.', 'Turn off', async () => { try { await post('remove'); location.replace('/login'); } catch (e) { toast(e.message); } }) : confirmSheet('Make this a family device?', 'You will be signed out, and this screen opens on the family board from now on.', 'Make it', async () => { try { await post('enable', { name: /iPad/.test(navigator.userAgent) ? 'iPad' : 'Family screen' }); location.replace('/board'); } catch (e) { toast(e.message); } }); diff --git a/public/index.html b/public/index.html index 4c90e4a..97c3bc5 100644 --- a/public/index.html +++ b/public/index.html @@ -104,6 +104,7 @@ + diff --git a/public/login.html b/public/login.html index c1109bc..482e95a 100644 --- a/public/login.html +++ b/public/login.html @@ -49,6 +49,13 @@ .box img { width: 72px; height: 72px; border-radius: 18px; margin-bottom: 16px; } .box .btn { width: 100%; justify-content: center; min-height: 52px; font-size: 17px; } .box form { text-align: left; } + .box.wide { max-width: 560px; } + .tiles { display: flex; gap: 12px; flex-wrap: wrap; justify-content: center; margin: 14px 0; } + .tile { display: flex; flex-direction: column; align-items: center; gap: 6px; width: 104px; padding: 12px 6px; border-radius: 18px; background: var(--panel); border: 1px solid var(--line); color: var(--text); cursor: pointer; font: inherit; } + .tile:active { transform: scale(.97); } .tile .av { width: 60px; height: 60px; border-radius: 50%; color: #fff; display: grid; place-items: center; font-size: 26px; font-weight: 700; } .tile b { font-size: 16px; } .tile small { min-height: 14px; } + .kidtag { background: #2f9e6e; color: #fff; border-radius: 8px; padding: 1px 7px; font-size: 11px; font-weight: 700; } + .pad { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; max-width: 300px; margin: 10px auto 0; } .pad button { height: 64px; font-size: 26px; border-radius: 16px; background: var(--panel2); color: var(--text); border: 1px solid var(--line); font-family: inherit; } + .dots { display: flex; justify-content: center; gap: 12px; margin: 12px 0; min-height: 18px; } .dots i { width: 16px; height: 16px; border-radius: 50%; background: var(--line); } .dots i.on { background: var(--accent); } diff --git a/public/login.js b/public/login.js index b1d4068..d5b44d1 100644 --- a/public/login.js +++ b/public/login.js @@ -6,10 +6,42 @@ async function signIn() { try { await passkeyLogin(); location.replace('/'); } catch (e) { fail(e); } } +// ---- Name tiles: on a remembered device (a family screen or a person's own device) the sign-in page shows who lives here. Tap your name, then use your PIN or passkey. +const escH = t => String(t == null ? '' : t).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); +async function tilesLogin() { + let r; try { r = await fetch('/api/login/people'); } catch { return false; } if (!r.ok) return false; + const { people } = await r.json(); if (!people.length) return false; + document.querySelector('.box').classList.add('wide'); $('#sub').textContent = "Who's signing in? Tap your name"; + const av = p => `${escH((p.name.trim()[0] || '?').toUpperCase())}`; + const home = () => { $('#err').textContent = ''; $('#sub').textContent = "Who's signing in? Tap your name"; + $('#ui').innerHTML = `
${people.map(p => ``).join('')}
+

Sign in another way (any passkey, invite or recovery code)

`; + $('#ui').querySelectorAll('.tile').forEach(t => t.onclick = () => who(people.find(p => p.id === t.dataset.id))); + $('#other').onclick = e => { e.preventDefault(); classicLogin(); }; }; + const who = p => { + $('#err').textContent = ''; $('#sub').textContent = ''; + const tile = `
${av(p)}${escH(p.name)}${p.kid ? 'KID' : ''}
`; + const back = '

Not you? Back to the names

', passkeyBtn = p.passkey ? '

' : ''; + const done = () => location.replace('/'), pkgo = async () => { $('#err').textContent = ''; try { await passkeyLogin(p.id); done(); } catch (e) { fail(e); } }; + const wire = () => { const b = $('#back'); if (b) b.onclick = e => { e.preventDefault(); home(); }; const k = $('#pk'); if (k) k.onclick = pkgo; }; + if (p.kid && !p.pin) { $('#ui').innerHTML = `${tile}

Sign in as ${escH(p.name)}?

${passkeyBtn}${back}`; wire(); $('#yes').onclick = async () => { try { await pinPost(p.id); done(); } catch (e) { fail(e); } }; return; } + if (!p.pin) { $('#ui').innerHTML = `${tile}

${escH(p.name)} has not set a PIN yet.

${p.passkey ? '' : '

Set a PIN or add a passkey in Settings on a device where you are signed in.

'}${back}`; wire(); return; } + let pin = ''; $('#ui').innerHTML = `${tile}

Enter your PIN

${[1, 2, 3, 4, 5, 6, 7, 8, 9, '⌫', 0, '✓'].map(k => ``).join('')}
${passkeyBtn}${back}`; wire(); + const paint = () => { $('#dots').innerHTML = Array.from({ length: Math.max(4, pin.length) }, (_, i) => ``).join(''); }; paint(); + $('#ui').querySelectorAll('.pad button').forEach(b => b.onclick = async () => { let k = b.dataset.k; $('#err').textContent = ''; if (k === '⌫') pin = pin.slice(0, -1); else if (k !== '✓' && pin.length < 8) { pin += k; if (p.pinLen && pin.length === p.pinLen) k = '✓'; } if (k === '✓') { try { await pinPost(p.id, pin); done(); return; } catch (e) { fail(e); pin = ''; } } paint(); }); + }; + const pinPost = async (uid, pin) => { const r = await fetch('/api/login/pin', { method: 'POST', body: JSON.stringify({ uid, pin }) }), j = await r.json().catch(() => ({})); if (!r.ok) throw new Error(j.error || 'Could not sign in'); }; + home(); return true; +} async function init() { - if (!window.PublicKeyCredential) { $('#sub').textContent = ''; return fail(new Error('This browser does not support passkeys.')); } const st = await (await fetch('/api/auth/state')).json(); if (st.authed) return location.replace('/'); + if (st.registered && await tilesLogin()) return; // (a remembered device: name tiles, and the PIN works even where passkeys do not) + if (!window.PublicKeyCredential) { $('#sub').textContent = ''; return fail(new Error('This browser does not support passkeys.')); } + classicLogin(st); +} +async function classicLogin(st) { + if (!st) st = await (await fetch('/api/auth/state')).json(); if (st.registered) { $('#sub').textContent = 'Sign in with your passkey'; $('#ui').innerHTML = ''; diff --git a/public/player.js b/public/player.js index d146bb5..cfe9f10 100644 --- a/public/player.js +++ b/public/player.js @@ -68,6 +68,7 @@ function loadCur(delay = 220) { if (tok !== loadTok) return; const hit = tc.m.get(t.src); pl.a.src = hit && !pl.alt.has(t.key) ? hit.url : srcOf(t); + if (typeof actLog === 'function') actLog(/podcast|audiobook|\/ab\/|radio/.test(String(t.src || '')) ? 'listen' : 'play', (t.artist ? t.artist + ' - ' : '') + (t.title || '')); pl.a.play().catch(e => { if (e && e.name === 'NotAllowedError') { pl.buf = false; syncUI(); toast('Tap play to start'); } }); tcKick(); const n = pl.q[pl.i + 1]; if (n) new Image().src = px.art(n.thumb, 600); diff --git a/public/reader.js b/public/reader.js index 4e9f028..404fa50 100644 --- a/public/reader.js +++ b/public/reader.js @@ -232,6 +232,7 @@ async function rdOpen(id) { notes = await rdApi('/book/' + id + '/notes'); } catch (e) { if (!location.hash.startsWith('#/read')) return; document.body.classList.remove('editing'); v.innerHTML = `
${icon('back')}Books

${esc(e.message)}

`; return; } if (!location.hash.startsWith('#/read/open')) return; + if (typeof actLog === 'function') actLog('read', book.title || 'A book'); new RdReader(book, notes).start(); } diff --git a/public/search-view.js b/public/search-view.js index 922e3b8..6c0e3e0 100644 --- a/public/search-view.js +++ b/public/search-view.js @@ -45,9 +45,10 @@ async function searchBooks(term) { } function viewSearch() { + const kidAcct = !!(ME && ME.kid && ME.kid.on); // (a child's web search is the restricted Learn search, and there are no links out to the open web) $('#view').innerHTML = `

Search

- `; const q = $('#q'); q.focus(); @@ -65,7 +66,7 @@ function viewSearch() { jfetch('/api/plex/status').then(st => { plexSt = st; showSources(); }).catch(() => showSources()); showSources(); // Duck.ai and DuckDuckGo refuse to be shown inside other apps, so these hand the question over in a new tab. - const webLinks = () => { const t = encodeURIComponent(q.value.trim()); $('#duckai').href = 'https://duck.ai/' + (t ? '?q=' + t : ''); $('#ddg').href = 'https://duckduckgo.com/' + (t ? '?q=' + t : ''); }; + const webLinks = () => { if (kidAcct) return; const t = encodeURIComponent(q.value.trim()); $('#duckai').href = 'https://duck.ai/' + (t ? '?q=' + t : ''); $('#ddg').href = 'https://duckduckgo.com/' + (t ? '?q=' + t : ''); }; q.oninput = () => { webLinks(); clearTimeout(timer); timer = setTimeout(() => runSearch(q.value.trim(), ++seq), 350); }; async function runSearch(term, mySeq) { const res = $('#res'); @@ -81,7 +82,7 @@ function viewSearch() { plexReady ? searchVideo(term) : Promise.reject(new Error('not connected')), allowed('podcast') ? searchPodcasts(term) : Promise.reject(new Error('not allowed')), allowed('audio') ? searchBooks(term) : Promise.reject(new Error('not allowed')), - jfetch('/api/ddg?q=' + enc(term)), + jfetch((kidAcct ? '/api/kidsearch?q=' : '/api/ddg?q=') + enc(term)), jfetch('/api/search?q=' + enc(term)), allowed('mail') && cfg.mail ? jfetch('/api/mail/messages?folder=inbox&q=' + enc(term)) : Promise.reject(new Error('not allowed')), allowed('read') ? jfetch('/api/read/list') : Promise.reject(new Error('not allowed')), @@ -106,8 +107,9 @@ function viewSearch() { const podHtml = (pods.status === 'fulfilled' ? pods.value : []).map(s => `${esc(pdF(s, 'title'))}Podcast${pdF(s, 'author') ? ' · ' + esc(pdF(s, 'author')) : ''}`).join(''); const bookHtml = (books.status === 'fulfilled' ? books.value : []).map(b => `${esc(abTitle(b))}Audiobook${abAuthor(b) ? ' · ' + esc(abAuthor(b)) : ''}`).join(''); const mailHtml = (mailR.status === 'fulfilled' ? mailR.value.items || [] : []).slice(0, 8).map(m => `${icon('mail')}${esc(m.subject)}${esc((m.from && (m.from.name || m.from.address)) || '')} · ${esc(new Date(m.date).toLocaleDateString())}`).join(''); - const d = ddg.status === 'fulfilled' ? ddg.value : {}; - const card = (d.text || d.answer) ? `
+ const d = ddg.status === 'fulfilled' && !kidAcct ? ddg.value : {}, kd = ddg.status === 'fulfilled' && kidAcct ? ddg.value : {}; + const kidCard = !kidAcct ? '' : kd.blocked ? `

${esc(kd.message || 'That search is not available.')}

` : (kd.results || []).length ? `

Learn

${kd.results.map((r, i) => ``).join('')}
From Simple English Wikipedia` : ''; + const card = kidAcct ? kidCard : (d.text || d.answer) ? `
${d.image ? `` : ''}
From DuckDuckGo${d.source ? ' · ' + esc(d.source) : ''} ${d.heading ? `
${esc(d.heading)}
` : ''}${d.answer ? `
${esc(d.answer)}
` : ''} @@ -142,6 +144,7 @@ function viewSearch() { const linkHtml = allowed('links') ? allLinks().filter(l => (l.title + ' ' + l.url + ' ' + (l.group || '')).toLowerCase().includes(lower)).slice(0, 8).map(l => `${icon('link')}${esc(l.title || l.url)}${esc(new URL(safeUrl(l.url)).host)}${l.group ? ' · ' + esc(l.group) : ''}`).join('') : ''; res.innerHTML = card + famHtml + sec('Mail', mailHtml) + sec('Tasks', taskHtml) + sec('Links', linkHtml) + sec('Events', eventHtml) + sec('Libraries', libHtml) + sec('Files', fileHtml) + sec('Music', musicHtml) + (podHtml ? sec('Podcasts', podHtml) : '') + (bookHtml ? sec('Audiobooks', bookHtml) : '') + (videoHtml ? `

Movies & TV

${videoHtml}
` : '') + notes; if (!famHtml && !taskHtml && !linkHtml && !eventHtml && !libHtml && !fileHtml && !musicHtml && !videoHtml && !podHtml && !bookHtml && !mailHtml && !card) res.innerHTML = '

No results. The line above shows what was searched.

' + notes; + res.querySelectorAll('[data-ks]').forEach(b => b.onclick = async () => { const r = kd.results[+b.dataset.ks]; try { const pg = await jfetch('/api/kidsearch/page?title=' + enc(r.title)); openSheet(pg.title, 'From Simple English Wikipedia', `
${esc(pg.text)}
`); } catch (e) { toast(e.message); } }); res.querySelectorAll('button[data-f]').forEach(b => b.onclick = () => { const [r, p, n, dd] = JSON.parse(b.dataset.f); openFile(r, p, n, dd); }); res.querySelectorAll('a[data-book]').forEach(a => a.onclick = e => { e.preventDefault(); const b = (books.value || []).find(x => abId(x) === a.dataset.book); if (b) abSheet(b); }); res.querySelectorAll('button[data-t]').forEach(b => b.onclick = () => playQueue([JSON.parse(b.dataset.t)], 0)); diff --git a/public/settings-family.js b/public/settings-family.js index 3ee1840..b7ff697 100644 --- a/public/settings-family.js +++ b/public/settings-family.js @@ -10,13 +10,50 @@ async function famSettings(box) { if (!box) return; const admin = ME && ME.role === 'admin', can = t => typeof allowed === 'function' && allowed(t); box.innerHTML = `

You

${can('chat') ? famRow('fs-chat', 'chat', 'Chat', 'Quiet hours for phone alerts' + (admin ? ', and the family announcement' : '')) : ''}
${admin && can('chores') ? `

Chores

${famRow('fs-chores', 'check', 'Chores and weekly rewards', 'Make chores, choose who does them, set the points and the reward tiers')}
` : ''} + ${admin ? '

Children

What each child did this week, and Plex.

Loading…
' + famRow('fs-words', 'search', 'Blocked search words', 'A child\'s search only uses Simple English Wikipedia and a built-in list of blocked words. Add your own words here.') + '
' : ''} ${admin ? '

Family board

Who has a name tile on the shared family screen and shows in its chore progress.

Loading…
' : ''}`; const c = box.querySelector('#fs-chat'); if (c) c.onclick = () => chSettings(); const k = box.querySelector('#fs-chores'); if (k) k.onclick = famManageChores; if (typeof devSettings === 'function') devSettings(box.querySelector('#fs-dev')); + if (admin) { famRequests(box.querySelector('#fs-req')); famKids(box.querySelector('#fs-kids')); const w = box.querySelector('#fs-words'); if (w) w.onclick = famWords; } if (admin) try { const r = await jfetch('/api/users'), b = box.querySelector('#fs-board'); if (!b) return; b.innerHTML = r.users.filter(u => !u.disabled).map(u => ``).join(''); b.querySelectorAll('input[data-u]').forEach(i => i.onchange = async () => { try { await jfetch('/api/users/' + encodeURIComponent(i.dataset.u), { method: 'PUT', body: JSON.stringify({ board: i.checked }) }); } catch (e) { toast(e.message); i.checked = !i.checked; } }); } catch (e) { const b = box.querySelector('#fs-board'); if (b) b.innerHTML = `
${esc(e.message)}
`; } } + +// ---- Requests from children: what they asked for, with Yes / No. +async function famRequests(box) { + if (!box) return; let r; try { r = await jfetch('/api/kid/requests'); } catch { box.innerHTML = ''; return; } + const open = r.requests.filter(x => x.status === 'open'); if (!open.length) { box.innerHTML = ''; return; } + const when = t => new Date(t).toLocaleString([], { weekday: 'short', hour: 'numeric', minute: '2-digit' }); + box.innerHTML = `

Asking you (${open.length})

${open.map(q => `
${esc(q.userName)}: ${esc(q.text)}${when(q.at)}${q.tab ? ' · wants ' + esc((TABS.find(t => t[0] === q.tab) || [0, q.tab])[1]) : ''}${q.minutes ? ' · +' + q.minutes + ' min' : ''}
`).join('')}
`; + box.querySelectorAll('[data-q]').forEach(b => b.onclick = async () => { const yes = !!b.dataset.y; try { await jfetch('/api/kid/requests/' + b.dataset.q, { method: 'POST', body: JSON.stringify({ approve: yes }) }); toast(yes ? 'Said yes' : 'Said no'); famRequests(box); } catch (e) { toast(e.message); } }); +} +// ---- One row per child: their week (minutes by area, what they played, searched and tried), and Plex locking. +async function famKids(box) { + if (!box) return; let r; try { r = await jfetch('/api/users'); } catch (e) { box.innerHTML = `
${esc(e.message)}
`; return; } + const kids = r.users.filter(u => u.kid && u.kid.on && !u.disabled); + box.innerHTML = kids.map(u => ``).join('') || '
No child accounts yet. Turn on This is a child for a person under Admin → People.
'; + box.querySelectorAll('[data-k]').forEach(b => b.onclick = () => famChild(kids.find(u => u.id === b.dataset.k))); +} +async function famChild(u) { + const el = openSheet(u.name, 'Last 7 days', '

Loading…

'); let a, px; + try { [a, px] = await Promise.all([jfetch('/api/kid/activity?user=' + encodeURIComponent(u.id) + '&days=7'), jfetch('/api/kid/plex-lock?user=' + encodeURIComponent(u.id))]); } catch (e) { el.querySelector('#fcb').innerHTML = `

${esc(e.message)}

`; return; } + const h = m => m >= 60 ? Math.floor(m / 60) + ' h ' + (m % 60) + ' min' : m + ' min', total = a.days.reduce((n, d) => n + d.minutes, 0), by = {}; a.days.forEach(d => Object.entries(d.areas).forEach(([k, m]) => { by[k] = (by[k] || 0) + m; })); + const icn = { play: '🎵', watch: '🎬', listen: '🎧', read: '📖', search: '🔎', signin: '🔑', chore: '✅', add: '➕', blocked: '⛔' }, max = Math.max(1, ...a.days.map(d => d.minutes)); + const day = d => new Date(d + 'T12:00:00').toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' }); + el.querySelector('#fcb').innerHTML = `
${h(total)} in the app${Object.entries(by).filter(([k, m]) => m > 0 && a.labels[k]).map(([k, m]) => esc(a.labels[k]) + ' ' + h(m)).join(' · ') || 'No time recorded yet'}
+ ${a.days.map(d => `
${esc(day(d.day))}
${h(d.minutes)}
`).join('')}
+

Plex

${px.connected ? (px.profile ? 'Using Plex as ' + esc(px.profile) : 'Connected to Plex') : 'Not connected to Plex'}${px.locked ? 'Locked to this profile: they cannot switch profiles or servers.' : 'They can switch Plex profiles if the other profile has no PIN. Lock it once you are on their managed profile.'}
+

What they did

${a.recent.map(e => `
${icn[e.kind] || '•'}${esc(e.title || e.kind)}${esc(new Date(e.t).toLocaleString([], { weekday: 'short', hour: 'numeric', minute: '2-digit' }))}
`).join('') || '

Nothing yet.

'}
`; + el.querySelector('#fcpl').onclick = async () => { try { await jfetch('/api/kid/plex-lock', { method: 'POST', body: JSON.stringify({ user: u.id, locked: !px.locked }) }); toast(px.locked ? 'Unlocked' : 'Locked to this profile'); closeSheet(); famChild(u); } catch (e) { toast(e.message); } }; +} + +// ---- The words a parent blocks from a child's search (on top of a built-in list). +async function famWords() { + let r; try { r = await jfetch('/api/kidsearch/words'); } catch (e) { return toast(e.message); } + const el = openSheet('Blocked search words', 'A child searches Simple English Wikipedia only. Any search or result with one of these words (or a built-in blocked word) is hidden. One per line or separated by commas.', `
`); + el.querySelector('#fwx').onclick = closeSheet; el.querySelector('#fwf').onsubmit = async e => { e.preventDefault(); try { await jfetch('/api/kidsearch/words', { method: 'POST', body: JSON.stringify({ words: el.querySelector('#fww').value }) }); closeSheet(); toast('Saved'); } catch (x) { toast(x.message); } }; +} diff --git a/public/settings.js b/public/settings.js index 601a3ae..a09543e 100644 --- a/public/settings.js +++ b/public/settings.js @@ -42,10 +42,12 @@ async function addPersonSheet() { 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', `
+ const el = openSheet(u.name, admin ? 'Admin' : u.disabled ? 'Turned off' : 'User', ` ${admin ? '

Admins can use everything.

' : `${tabBoxes(u.tabsChosen)}

Child account

+ +
${[['video', 'Movies and TV'], ['music', 'Music'], ['listen', 'Podcasts, audiobooks, radio'], ['read', 'Books'], ['chat', 'Chat']].map(([k, l]) => `
${l}
`).join('')}
@@ -65,7 +67,7 @@ function personSheet(u) { kon.onchange = () => { el.querySelector('#kopt').hidden = !kon.checked; }; fillUse(); el.querySelectorAll('#kuse [data-m]').forEach(b => b.onclick = async () => { try { await jfetch('/api/kid/extra', { method: 'POST', body: JSON.stringify({ user: u.id, minutes: +b.dataset.m }) }); toast('Added ' + b.dataset.m + ' minutes for today'); fillUse(); } catch (err) { toast(err.message); } }); } - el.querySelector('#uf').onsubmit = e => { e.preventDefault(); act(() => put({ name: el.querySelector('#un').value, board: el.querySelector('#ubd').checked, ...(admin ? {} : { tabs: pickedTabs(el), kid: { on: el.querySelector('#kon').checked, limitMin: +el.querySelector('#klim').value || 0, bedtime: { on: el.querySelector('#kbo').checked, from: el.querySelector('#kbf').value, to: el.querySelector('#kbt').value }, safe: el.querySelector('#ksf').checked } }) }), 'Saved'); }; + el.querySelector('#uf').onsubmit = e => { e.preventDefault(); act(() => put({ name: el.querySelector('#un').value, board: el.querySelector('#ubd').checked, simple: admin ? undefined : el.querySelector('#usm').checked, ...(admin ? {} : { tabs: pickedTabs(el), kid: { on: el.querySelector('#kon').checked, limitMin: +el.querySelector('#klim').value || 0, weekendMin: +el.querySelector('#kwe').value || 0, areaLimits: Object.fromEntries([...el.querySelectorAll('#kal [data-a]')].map(i => [i.dataset.a, +i.value || 0])), bedtime: { on: el.querySelector('#kbo').checked, from: el.querySelector('#kbf').value, to: el.querySelector('#kbt').value }, safe: el.querySelector('#ksf').checked } }) }), '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'); diff --git a/public/simple-mode.js b/public/simple-mode.js new file mode 100644 index 0000000..6c117dc --- /dev/null +++ b/public/simple-mode.js @@ -0,0 +1,12 @@ +'use strict'; +// ---- Simple mode: for young children or grandparents. A parent turns it on for a person; the app then has no menus, just a Home screen of big buttons for the few +// things they can use, and a big Home button to get back. +function simpleStart() { + if (!ME || !ME.simple) return; document.body.classList.add('simple'); + if (!document.getElementById('simplehome')) { const b = document.createElement('a'); b.id = 'simplehome'; b.href = '#/home'; b.innerHTML = icon('home') + 'Home'; document.body.appendChild(b); } + const sync = () => { const h = document.getElementById('simplehome'); if (h) h.hidden = !location.hash || location.hash === '#/' || location.hash === '#/home'; }; addEventListener('hashchange', sync); sync(); +} +function viewSimple() { + const tabs = leafTabs().filter(t => !['settings', 'search', 'family', 'files', 'office', 'links', 'planner'].includes(t[0]) && t[0] !== 'home'), h = new Date().getHours(), greet = h < 12 ? 'Good morning' : h < 18 ? 'Good afternoon' : 'Good evening'; + $('#view').innerHTML = `

${greet}${ME ? ', ' + esc(ME.name.split(' ')[0]) : ''}!

${tabs.map(([id, label]) => `${icon(TAB_ICON[id] || 'menu')}${esc(label)}`).join('') || '

Nothing is turned on yet. Ask a parent.

'}
`; +} diff --git a/public/style.css b/public/style.css index 4c1e655..bcec3a1 100644 --- a/public/style.css +++ b/public/style.css @@ -1063,3 +1063,14 @@ body.vizopen { overflow: hidden; } .crtick { position: absolute; top: 0; width: 2px; height: 100%; background: var(--text); opacity: .55; } .crearn { display: flex; align-items: center; gap: 10px; margin: 6px 0 0 52px; padding: 8px 10px; border-radius: 12px; background: var(--panel2); } .crearn .btn { min-height: 34px; padding: 0 12px; } .crdet { margin: 6px 0 0 52px; } .crdet summary { cursor: pointer; color: var(--muted); font-size: 14px; } .crwk { padding: 8px 0; border-bottom: 1px solid var(--line); } .crwk:last-child { border-bottom: 0; } + +#kidreq { position: fixed; left: 12px; bottom: calc(env(safe-area-inset-bottom, 0px) + 74px); z-index: 55; min-height: 40px; padding: 0 16px; border-radius: 22px; border: 1px solid var(--line); background: var(--panel); color: var(--text); font: inherit; font-weight: 600; box-shadow: 0 4px 14px rgba(0,0,0,.3); cursor: pointer; } +@media (min-width: 900px) { #kidreq { bottom: 16px; left: 236px; } } + +/* simple mode: no menus, big buttons */ +body.simple #nav, body.simple #navbar, body.simple #subnav, body.simple #chatfab, body.simple .fab { display: none !important; } +body.simple #shell { flex-direction: column; } +.simplewrap { max-width: 920px; } .simplewrap h1 { font-size: 34px; margin: 12px 0 18px; } +.simplegrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 16px; } +.simpletile { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; min-height: 170px; border-radius: 28px; color: #fff; text-decoration: none; font-size: 24px; font-weight: 700; box-shadow: 0 6px 18px rgba(0,0,0,.3); } .simpletile svg { width: 64px; height: 64px; } +#simplehome { position: fixed; left: 14px; bottom: calc(env(safe-area-inset-bottom, 0px) + 14px); z-index: 60; display: flex; align-items: center; gap: 10px; padding: 14px 24px; border-radius: 32px; background: var(--accent); color: var(--accent-t, #fff); font-size: 22px; font-weight: 700; text-decoration: none; box-shadow: 0 6px 18px rgba(0,0,0,.4); } #simplehome[hidden] { display: none; } #simplehome svg { width: 30px; height: 30px; } diff --git a/public/video.js b/public/video.js index 09e562e..2ec2b57 100644 --- a/public/video.js +++ b/public/video.js @@ -226,6 +226,7 @@ async function videoWatch(id) { videoStop(); try { pl.a.pause(); } catch {} if (!vdHooked) { vdHooked = true; pl.a.addEventListener('play', () => { if (vp) videoStop(); }); } // one thing at a time: music starting ends the video + if (typeof actLog === 'function') actLog('watch', (m.grandparentTitle ? m.grandparentTitle + ': ' : '') + (m.title || '')); const media = m.Media[0], part = media.Part[0], streams = part.Stream || [], fromStart = store.get('video_from_start') === id; if (fromStart) store.del('video_from_start'); const el = document.createElement('video'); el.playsInline = true; el.autoplay = true; el.id = 'vv'; diff --git a/public/webauthn.js b/public/webauthn.js index 2898d78..841d870 100644 --- a/public/webauthn.js +++ b/public/webauthn.js @@ -22,8 +22,8 @@ async function passkeyRegister(setupCode, name, inviteCode) { clientExtensionResults: cred.getClientExtensionResults(), authenticatorAttachment: cred.authenticatorAttachment, } }); } -async function passkeyLogin() { - const { cid, options } = await authPost('/api/auth/login/options'); +async function passkeyLogin(uid) { + const { cid, options } = await authPost('/api/auth/login/options', uid ? { uid } : undefined); options.challenge = b64u.dec(options.challenge); (options.allowCredentials || []).forEach(c => { c.id = b64u.dec(c.id); }); const a = await navigator.credentials.get({ publicKey: options }); diff --git a/push.js b/push.js index 9c264e4..df772e3 100644 --- a/push.js +++ b/push.js @@ -47,8 +47,10 @@ async function send(dir, st, uid, payload) { } let lastEvents = 0; +let lastDigest = 0; /** One pass over everyone: runs every minute. */ async function tick(now = new Date(), force = false) { + if (now.getTime() - lastDigest >= 10 * 60e3) { lastDigest = now.getTime(); try { require('./activity').weeklyDigest(now); } catch (e) { console.warn('[push] weekly summary failed:', e.message); } } // (each child's week, to the parents, on Monday morning) const checkEvents = force || now.getTime() - lastEvents >= 4.5 * 60e3; if (checkEvents) lastEvents = now.getTime(); for (const u of users.all()) { diff --git a/requests.js b/requests.js new file mode 100644 index 0000000..1a003bc --- /dev/null +++ b/requests.js @@ -0,0 +1,41 @@ +// "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 }; diff --git a/server.js b/server.js index 4640b37..36a22f3 100644 --- a/server.js +++ b/server.js @@ -45,6 +45,7 @@ const savefiles = require('./savefiles'); // "Save to Files": copy books, lesson const vbv = require('./vbv'); // Verse By Verse Ministry teaching (free Bible studies) const recipes = require('./recipes'); // recipes and the shopping list, from Mealie (MEALIE_URL, MEALIE_TOKEN) const contacts = require('./contacts'); // each person's address book (mail suggestions, calendar birthdays, vCard import and export) +const activity = require('./activity'), alertParents = activity.alertParents; const kidlimits = require('./kidlimits'); // child accounts: a daily time limit and bedtime, and asking a parent for more time const screentime = require('./screentime'); // fewer web addresses for iPad Screen Time: a report-only policy, the list of what devices reached, and a picture pass-through const chores = require('./chores'); // chores for the family, with points @@ -340,6 +341,10 @@ async function plexHeal() { async function plexApi(req, res) { const u = new URL(req.url, 'http://x'), st = plexLoad(), token = st.token; try { + const me0 = users.current(); // a child whose Plex a parent has locked to their own profile cannot switch profiles, change server, or sign out and in again + if (me0 && me0.kid && me0.kid.on && me0.role !== 'admin' && st.locked && ['/api/plex/switch', '/api/plex/users', '/api/plex/pin', '/api/plex/logout', '/api/plex/server', '/api/plex/servers'].includes(u.pathname)) { + alertParents(me0, 'blocked', 'tried to change the Plex profile'); return json(res, 403, { error: 'A parent has locked Plex to this profile' }); + } if (u.pathname === '/api/plex/status') { return json(res, 200, { signedIn: !!token, server: st.srv ? st.name || 'Plex' : null, user: st.user ? { id: st.user.id, title: st.user.title, thumb: st.user.thumb, admin: st.user.admin } : null, canEdit: !!token && await plexCanEdit(st) }); } @@ -464,6 +469,22 @@ async function plexCanEdit(st) { editOk.set(k, { t: Date.now(), ok }); return ok; } +// A person with Music but not Movies (or the other way round) may only reach their own kind of library through the proxy. The kind of a library or an item is asked +// of the Plex server once and remembered for a few minutes; anything that cannot be told apart (a file part, a picture) is left alone. +const plexKinds = new Map(); +async function plexKindOf(url, st) { + const m = /^\/plex\/library\/(?:sections\/(\d+)|metadata\/(\d+))/.exec(url); if (/^\/plex\/video\//.test(url)) return 'video'; if (!m || !st.srv || !st.stk) return null; + const key = (m[1] ? 's' : 'm') + (m[1] || m[2]) + '@' + st.srv, hit = plexKinds.get(key); if (hit && Date.now() - hit.at < 5 * 60e3) return hit.kind; + let kind = null; try { + const r = await fetch(st.srv + (m[1] ? '/library/sections/' + m[1] : '/library/metadata/' + m[2]), { headers: { ...PLEX_HEADERS, 'X-Plex-Token': st.stk, Accept: 'application/json' }, signal: AbortSignal.timeout(4000) }); + if (r.ok) { const j = (await r.json()).MediaContainer || {}, t = m[1] ? ((j.Directory || [])[0] || {}).type || j.type : ((j.Metadata || [])[0] || {}).type; kind = ['movie', 'show', 'season', 'episode', 'clip'].includes(t) ? 'video' : ['artist', 'album', 'track'].includes(t) ? 'music' : null; } + } catch {} + if (plexKinds.size > 2000) plexKinds.clear(); plexKinds.set(key, { kind, at: Date.now() }); return kind; +} +async function plexAreaDenied(user, url) { + const music = users.can(user, 'music'), video = users.can(user, 'video'); if (music && video) return false; + const kind = await plexKindOf(url, plexLoad()); return (kind === 'video' && !video) || (kind === 'music' && !music); +} async function plexProxy(req, res) { const st = plexLoad(); if (!st.srv || !st.stk) { res.writeHead(401); return res.end(); } @@ -577,6 +598,7 @@ const ACCESS = [ [/^\/api\/cal\//, ['calendar']], [/^\/api\/tasks/, ['tasks']], [/^\/api\/(search|ddg)$/, ['search']], + [/^\/api\/kidsearch(\/|$)/, ['search']], ]; const forbidden = (res, msg = 'You do not have access to this') => { res.writeHead(403, { 'content-type': 'application/json' }); return res.end(JSON.stringify({ error: msg })); }; @@ -605,7 +627,7 @@ async function handleRequest(req, res) { if (pathname === '/healthz') { res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' }); return res.end('{"ok":true}'); } if (crossSite(req) && pathname !== '/api/oo/callback') return forbidden(res, 'Cross-site request refused'); if (pathname.startsWith('/api/auth/')) return auth.handle(req, res); - if (pathname === '/api/board' || pathname.startsWith('/api/board/') || pathname === '/api/device' || pathname.startsWith('/api/device/')) return board.handle(req, res); // family devices: the board and picking a profile (each checks who is asking) + if (pathname === '/api/board' || pathname.startsWith('/api/board/') || pathname === '/api/device' || pathname.startsWith('/api/device/') || pathname.startsWith('/api/login/')) return board.handle(req, res); // family devices: the board and picking a profile (each checks who is asking) if (pathname === '/api/mail/callback' && mail.outlookOn()) return mail.callback(req, res); // Microsoft sends the person back here; the one-time state says who they are if (pathname.startsWith('/cal/feed/')) return cal.feed(req, res); // subscribe feed: protected by its own secret token, not the session // Everything else needs a passkey session, except the sign-in page and its assets, @@ -619,12 +641,13 @@ async function handleRequest(req, res) { return serveStatic(req, res); } if (/^\/(api|sf|plex)\//.test(pathname)) { res.writeHead(401, { 'content-type': 'application/json', 'x-hub-auth': 'required' }); return res.end('{"error":"Sign in required"}'); } - res.writeHead(302, { location: auth.deviceOf(req) ? '/board' : '/login' }); return res.end(); + res.writeHead(302, { location: auth.deviceKind(req) === 'family' ? '/board' : '/login' }); return res.end(); } // From here on, everything runs "as" this person: their own folder, and only the tabs they were given. return users.runAs(user, async () => { const lock = /^\/(api|sf|plex)\//.test(pathname) ? kidlimits.lockedFor(user, pathname) : null; // (a child whose time is up or whose bedtime it is) - if (lock) { res.writeHead(403, { 'content-type': 'application/json', 'x-hub-limit': lock.locked, 'cache-control': 'no-store' }); return res.end(JSON.stringify({ error: lock.locked === 'bedtime' ? 'It is bedtime' : 'Time is up for today', limit: lock.locked, status: lock })); } + if (lock) { res.writeHead(403, { 'content-type': 'application/json', 'x-hub-limit': lock.locked, 'cache-control': 'no-store' }); return res.end(JSON.stringify({ error: lock.locked === 'bedtime' ? 'It is bedtime' : lock.area ? lock.label + ' time is up for today' : 'Time is up for today', limit: lock.locked, area: lock.area, status: lock })); } + if (user.kid && user.kid.on && req.method === 'GET' && /search/.test(pathname) && !/^\/api\/(chat|contacts|notes)/.test(pathname)) { const q = (new URL(req.url, 'http://x').searchParams.get('q') || '').trim(); if (q.length >= 2) activity.log(user.id, 'search', q); } // (what a child looks for, for the parents' view) const rule = ACCESS.find(([re]) => re.test(pathname)); if (rule && !rule[1].some(t => users.can(user, t))) return forbidden(res); if ((pathname === '/api/system' || pathname === '/api/health' || pathname === '/api/audit' || pathname.startsWith('/api/users')) && user.role !== 'admin') return forbidden(res, 'Only an admin can do this'); @@ -638,7 +661,8 @@ async function handleRequest(req, res) { } if (req.method === 'POST' && req.url === '/api/oo/config') return OO_OK ? ooConfig(req, res) : forbidden(res, 'In-app editing needs ONLYOFFICE_JWT_SECRET to be set on the server'); if (pathname === '/api/backup') return user.role === 'admin' ? backup.handle(req, res, user.name) : forbidden(res, 'Only an admin can do this'); - if (pathname === '/api/ddg') return ddgAnswer(req, res); + if (pathname === '/api/kidsearch' || pathname.startsWith('/api/kidsearch/')) return require('./kidsearch').handle(req, res); + if (pathname === '/api/ddg') { if (user.kid && user.kid.on && user.role !== 'admin') return forbidden(res, 'Web search is not available on a child account'); return ddgAnswer(req, res); } if (pathname.startsWith('/api/push/')) return push.handle(req, res); if (pathname === '/api/audit') { res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' }); return res.end(JSON.stringify({ events: require('./audit').recent(200) })); } if (pathname === '/api/health') { // everything an admin wants to know at a glance @@ -670,6 +694,14 @@ async function handleRequest(req, res) { if (pathname.startsWith('/api/podcast/')) return podcast.handle(req, res); if (pathname.startsWith('/api/read/')) return books.handle(req, res); if (pathname.startsWith('/api/radio/')) return radio.handle(req, res); + if (pathname === '/api/kid/plex-lock') { // a parent locks a child's Plex to the profile it is on (this also forgets the owner sign-in the hub kept for switching) + if (user.role !== 'admin') return forbidden(res, 'Only a parent (an admin) can do this'); + const body = req.method === 'POST' ? JSON.parse((await readBody(req)) || '{}') : {}, target = users.get(String(body.user || new URL(req.url, 'http://x').searchParams.get('user') || '')); + if (!target || !target.kid || !target.kid.on) return json(res, 400, { error: 'That is not a child account' }); + const out = users.runAs(target, () => { const st = plexLoad(); if (req.method === 'POST') { if (body.locked) { delete st.mainToken; st.locked = true; } else delete st.locked; if (st.token || st.locked) plexSave(st); } return { connected: !!st.token, profile: st.user ? st.user.title : null, locked: !!st.locked }; }); + return json(res, 200, out); + } + if (pathname === '/api/kid/requests' || pathname.startsWith('/api/kid/requests/')) return require('./requests').handle(req, res, user); if (pathname.startsWith('/api/kid/')) return kidlimits.handle(req, res, user, { askParents: chat.askParents }); if (pathname === '/api/csp-report' || pathname === '/api/img' || pathname === '/api/screentime') return screentime.handle(req, res, user); if (pathname === '/api/chores' || pathname.startsWith('/api/chores/')) return chores.handle(req, res); @@ -685,6 +717,7 @@ async function handleRequest(req, res) { if (pathname.startsWith('/api/signal/')) return signal.enabled() ? signal.handle(req, res) : forbidden(res, 'Signal is not set up on this server'); if (pathname.startsWith('/api/mail/')) return mail.handle(req, res); if (req.url.startsWith('/api/plex/')) return plexApi(req, res); + if (req.url.startsWith('/plex/') && await plexAreaDenied(user, req.url)) { alertParents(user, 'blocked', 'tried to open ' + (users.can(user, 'video') ? 'music' : 'a movie or show')); return forbidden(res, 'That part of Plex is not turned on for you'); } if (req.url.startsWith('/plex/') && (req.method === 'GET' || (['POST', 'PUT', 'DELETE'].includes(req.method) && PLEX_WRITABLE_URL(req.method, req.url) && (PLEX_PLAYLISTS.test(req.url) || (req.method === 'PUT' && PLEX_PERSONAL.test(req.url)) || await plexCanEdit(plexLoad()))))) return plexProxy(req, res); if (req.url.startsWith('/sf/')) return proxySeafile(req, res); return serveStatic(req, res); diff --git a/tools/board-test.js b/tools/board-test.js index 2b45918..cdecf04 100644 --- a/tools/board-test.js +++ b/tools/board-test.js @@ -38,6 +38,15 @@ const hub = http.createServer((req, res) => req.url.startsWith('/api/chores') ? assert.equal((await call('/api/device', {}, session(ann) + '; ' + dev)).json.idleMin, 5, 'five idle minutes unless changed'); assert.equal((await post('/api/device/idle', { minutes: 2 }, session(bob))).status, 403); assert.equal((await post('/api/device/idle', { minutes: 7 }, session(ann))).status, 400, 'only the offered choices'); assert.equal((await post('/api/device/idle', { minutes: 2 }, session(ann))).status, 200); assert.equal((await call('/api/device', {}, session(ann) + '; ' + dev)).json.idleMin, 2, 'the new idle time sticks'); assert.equal((await call('/api/device', {}, session(ann) + '; ' + dev)).json.devices.length, 1); r = await post('/api/device/remove', {}, session(ann) + '; ' + dev); assert.equal(r.status, 200); assert.equal((await call('/api/board', {}, dev)).status, 403, 'switched off: the old cookie no longer works'); + // a person's own remembered device: name tiles and PIN sign-in, but not the family board or short sessions + const cy = users.create({ name: 'Cy', role: 'user', tabs: ['chat'], handle: 'Yy' }); board.setPin(cy.id, '135790'); + assert.equal((await call('/api/login/people')).status, 403, 'a stranger gets no names'); + r = await post('/api/device/trust', {}, session(ann)); assert.equal(r.status, 200); const own = r.set.find(c => c.startsWith('hub_device=')).split(';')[0]; assert.ok(!r.set.some(c => c.startsWith('hub_session=;')), 'remembering a device does not sign you out'); + r = await call('/api/login/people', {}, own); assert.equal(r.status, 200); assert.ok(r.json.people.find(p => p.name === 'Cy').pinLen === 6 && r.json.people.find(p => p.name === 'Kai').kid, 'tiles show names, kids and PIN length'); + assert.equal((await call('/api/board', {}, own)).status, 403, 'a remembered personal device is not a family board'); + assert.equal((await post('/api/login/pin', { uid: cy.id, pin: '000000' }, own)).status, 401); r = await post('/api/login/pin', { uid: cy.id, pin: '135790' }, own); assert.equal(r.status, 200); const full = r.set.find(c => c.startsWith('hub_session=')); assert.ok(!/Max-Age=1800/.test(full) && /Max-Age=\d{7}/.test(full), 'a normal long session on your own device'); + assert.equal((await post('/api/login/pin', { uid: kid.id }, own)).status, 200, 'a child taps their name'); assert.equal((await post('/api/login/pin', { uid: bob.id }, own)).status === 200, false, 'an adult with no valid PIN cannot'); assert.equal((await post('/api/login/pin', { uid: cy.id, pin: '135790' }, '')).status, 403, 'and only from a remembered device'); + assert.equal((await post('/api/device/remove', {}, session(cy) + '; ' + own)).status, 403, 'someone else cannot forget Ann\'s device'); assert.equal((await post('/api/device/remove', {}, session(ann) + '; ' + own)).status, 200); assert.equal((await call('/api/login/people', {}, own)).status, 403, 'forgotten: the tiles are gone'); console.log('board ok'); } catch (e) { console.error(e); process.exitCode = 1; } hub.close(); setTimeout(() => process.exit(process.exitCode || 0), 50); diff --git a/tools/kidcontrols-test.js b/tools/kidcontrols-test.js new file mode 100644 index 0000000..74334be --- /dev/null +++ b/tools/kidcontrols-test.js @@ -0,0 +1,33 @@ +// Parent controls for children: per-area and weekend limits, the activity log and alerts to parents, requests, bonus screen time from rewards, the weekly summary. +const assert = require('assert'), fs = require('fs'), os = require('os'), path = require('path'), { Readable } = require('stream'); +process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'nehub-kidctl-')); process.env.HUB_TZ = 'UTC'; +const users = require('../users'), kid = require('../kidlimits'), activity = require('../activity'), inbox = require('../inbox'), requests = require('../requests'), chores = require('../chores'); +const ann = users.create({ name: 'Ann', role: 'admin', handle: 'YQ' }), kai = users.create({ name: 'Kai', role: 'user', tabs: ['chat', 'chores', 'music'], handle: 'Yg' }); +users.update(kai.id, { kid: { on: true, limitMin: 120, weekendMin: 240, areaLimits: { video: 15, music: 30, bogus: 5 } } }); +const call = (who, url, method = 'GET', body, mod = requests) => new Promise(ok => { const q = Readable.from(body ? [Buffer.from(JSON.stringify(body))] : []); q.url = url; q.method = method; q.headers = {}; users.runAs(who, () => mod.handle(q, { writeHead(c) { this.c = c; }, end(d) { ok({ status: this.c, json: JSON.parse(d) }); } }, who)); }); +(async () => { + const k = users.get(kai.id).kid; assert.deepEqual(k.areaLimits, { video: 15, music: 30 }, 'only real areas, at least 15 minutes'); assert.equal(k.weekendMin, 240); + // per-area time: pings add to the area; the area closes by itself while the rest stays open + let st = kid.status(users.get(kai.id)); assert.deepEqual(st.lockedAreas, []); assert.equal(st.areas.music.limit, 30); + const realNow = Date.now; let off = 0; Date.now = () => realNow() + off; for (let i = 0; i < 40; i++) { off += 60e3; kid.ping(users.get(kai.id), 'music'); } Date.now = realNow; // (a minute passes between pings) st = kid.status(users.get(kai.id)); assert.ok(st.areas.music.used >= 30 && st.lockedAreas.includes('music'), 'music time used up'); assert.equal(st.locked, null, 'but the app as a whole is open'); + assert.equal(kid.lockedFor(users.get(kai.id), '/api/music/x').area, 'music', 'music requests are refused'); assert.equal(kid.lockedFor(users.get(kai.id), '/api/notes/'), null, 'other things still work'); assert.equal(kid.lockedFor(users.get(kai.id), '/api/read/list'), null); + assert.ok(inbox.load(ann.id).some(i => /Music time/.test(i.body)), 'the parent is told'); + assert.equal(kid.usageByDay(kai.id, 7)[0].areas.music >= 30, true, 'the parents see minutes per area'); + // activity: logged, de-duplicated, only for children, alerts rate-limited + activity.log(kai.id, 'play', 'Song A'); activity.log(kai.id, 'play', 'Song A'); activity.log(kai.id, 'search', 'dinosaurs'); activity.log(ann.id, 'play', 'Not a child'); assert.equal(activity.recent(kai.id).filter(e => e.title === 'Song A').length, 1, 'the same thing twice counts once'); assert.equal(activity.recent(ann.id).length, 0, 'adults are not logged'); + activity.alertParents(users.get(kai.id), 'blocked', 'tried to change the Plex profile'); const n0 = inbox.load(ann.id).length; activity.alertParents(users.get(kai.id), 'blocked', 'tried to change the Plex profile'); assert.equal(inbox.load(ann.id).length, n0, 'the same alert is not repeated'); + assert.equal((await call(ann, '/api/kid/activity?user=' + kai.id, 'GET', null, kid)).json.recent.length >= 2, true); assert.equal((await call(kai, '/api/kid/activity?user=' + kai.id, 'GET', null, kid)).status, 403, 'a child cannot read the log'); + assert.equal((await call(kai, '/api/kid/activity', 'POST', { kind: 'watch', title: 'A movie' }, kid)).status, 200); assert.ok(activity.recent(kai.id).some(e => e.title === 'A movie' && e.kind === 'watch'), 'the app reports what a child plays'); + // requests: ask, parent answers, a yes opens a tab and gives minutes, a no just tells them + assert.equal((await call(ann, '/api/kid/requests', 'POST', { text: 'x' })).status, 400, 'only children ask'); let r = (await call(kai, '/api/kid/requests', 'POST', { text: 'Can I watch movies?', tab: 'video', minutes: 20 })).json; assert.equal(r.status, 'open'); assert.ok(inbox.load(ann.id).some(i => /Kai is asking/.test(i.title))); + assert.equal((await call(kai, '/api/kid/requests/' + r.id, 'POST', { approve: true })).status, 403, 'a child cannot approve their own'); const before = kid.status(users.get(kai.id)).limit; assert.equal((await call(ann, '/api/kid/requests/' + r.id, 'POST', { approve: true, note: 'Weekend only' })).json.status, 'approved'); + assert.ok(users.can(users.get(kai.id), 'video'), 'a yes opens the part of the hub'); assert.equal(kid.status(users.get(kai.id)).limit, before + 20, 'and adds the minutes'); assert.ok(inbox.load(kai.id).some(i => /^Yes: /.test(i.title)), 'the child is told'); + r = (await call(kai, '/api/kid/requests', 'POST', { text: 'A new game' })).json; assert.equal((await call(ann, '/api/kid/requests/' + r.id, 'POST', { approve: false, note: 'Not now' })).json.status, 'denied'); assert.equal((await call(ann, '/api/kid/requests/' + r.id, 'POST', { approve: true })).status, 400, 'answered once'); + // a reward with bonus screen time adds it when the parent marks it given + users.update(kai.id, { tabs: [...users.get(kai.id).tabs, 'chores'] }); await call(ann, '/api/chores', 'POST', { title: 'Dishes', repeat: 'daily', assign: 'anyone', points: 10 }, chores); const today = new Date().toISOString().slice(0, 10), ch = (await call(ann, '/api/chores?date=' + today, 'GET', null, chores)).json.chores[0]; + await call(kai, '/api/chores/done', 'POST', { chore: ch.id, date: today }, chores); const rw = (await call(ann, '/api/chores/reward', 'POST', { title: 'Extra time', points: 5, minutes: 30 }, chores)).json; assert.equal(rw.minutes, 30); + const lim0 = kid.status(users.get(kai.id)).limit; await call(ann, '/api/chores/give', 'POST', { user: kai.id, reward: rw.id }, chores); assert.equal(kid.status(users.get(kai.id)).limit, lim0 + 30, 'the bonus minutes are added'); + // the weekly summary goes to the parents once, on Monday morning + const mon = new Date('2026-10-26T09:00:00Z'); assert.equal(activity.weeklyDigest(new Date('2026-10-27T09:00:00Z')), false, 'only on Monday'); assert.equal(activity.weeklyDigest(mon), true); assert.ok(inbox.load(ann.id).some(i => /Kai: last week/.test(i.title))); assert.equal(activity.weeklyDigest(mon), false, 'once a week'); + console.log('kidcontrols ok'); process.exit(0); +})().catch(e => { console.error(e); process.exit(1); }); diff --git a/tools/kidlimits-test.js b/tools/kidlimits-test.js index ccdcb2d..c157bfc 100644 --- a/tools/kidlimits-test.js +++ b/tools/kidlimits-test.js @@ -5,7 +5,7 @@ const users = require('../users'), kid = require('../kidlimits'), chat = require const ann = users.create({ name: 'Ann', role: 'admin', handle: 'YQ' }), sam = users.create({ name: 'Sam', role: 'user', tabs: ['music'], handle: 'Yg' }), pat = users.create({ name: 'Pat', role: 'user', tabs: ['music'], handle: 'Yw' }); const asAdmin = (u, fn) => users.runAs(u, fn); assert.throws(() => users.update(ann.id, { kid: { on: true } }), /admin cannot be a child/, 'an admin cannot be a child account'); -users.update(sam.id, { kid: { on: true, limitMin: 3, bedtime: { on: false, from: '20:30', to: '07:00' } } }); const s0 = users.get(sam.id).kid; assert.deepEqual(s0, { on: true, limitMin: 15, bedtime: { on: false, from: '20:30', to: '07:00' }, safe: true }, 'a limit is at least 15 minutes; safe search is on unless turned off'); +users.update(sam.id, { kid: { on: true, limitMin: 3, bedtime: { on: false, from: '20:30', to: '07:00' } } }); const s0 = users.get(sam.id).kid; assert.deepEqual(s0, { on: true, limitMin: 15, weekendMin: 0, areaLimits: {}, bedtime: { on: false, from: '20:30', to: '07:00' }, safe: true }, 'a limit is at least 15 minutes; safe search is on unless turned off'); assert.equal(users.publicView(users.get(sam.id)).kid.limitMin, 15); assert.equal(users.publicView(users.get(pat.id)).kid, undefined, 'only child accounts carry the settings'); assert.deepEqual(kid.status(users.get(pat.id)), { on: false }); assert.equal(kid.lockedFor(users.get(pat.id), '/api/music/x'), null); // time counts from pings (one a minute while the app is open) diff --git a/tools/kidsearch-test.js b/tools/kidsearch-test.js new file mode 100644 index 0000000..e87da7e --- /dev/null +++ b/tools/kidsearch-test.js @@ -0,0 +1,18 @@ +// The restricted search for children: blocked words (built in and added by a parent), results that mention them are hidden, and nothing but Simple English Wikipedia is asked. +const assert = require('assert'), fs = require('fs'), os = require('os'), path = require('path'), { Readable } = require('stream'); +process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'nehub-kidsearch-')); +const users = require('../users'), ks = require('../kidsearch'), inbox = require('../inbox'); +const ann = users.create({ name: 'Ann', role: 'admin', handle: 'YQ' }), kai = users.create({ name: 'Kai', role: 'user', tabs: ['search'], handle: 'Yg' }); users.update(kai.id, { kid: { on: true } }); +const asked = []; global.fetch = async (url) => { asked.push(String(url)); const u = new URL(url), q = u.searchParams.get('gsrsearch'), t = u.searchParams.get('titles'); + const pages = q ? { 1: { title: 'Dinosaur', index: 1, extract: 'Dinosaurs lived long ago.' }, 2: { title: 'Casino', index: 2, extract: 'A place for gambling.' }, 3: { title: 'Tyrannosaurus', index: 3, extract: 'A big dinosaur with a secret word: zebra.' } } : { 9: { title: t, extract: 'Long text about ' + t } }; + return { ok: true, json: async () => ({ query: { pages } }) }; }; +const call = (who, url, method = 'GET', body) => new Promise(ok => { const q = Readable.from(body ? [Buffer.from(JSON.stringify(body))] : []); q.url = url; q.method = method; q.headers = {}; users.runAs(who, () => ks.handle(q, { writeHead(c) { this.c = c; }, end(d) { ok({ status: this.c, json: JSON.parse(d) }); } })); }); +(async () => { + assert.ok(ks.blocked('free porn') && ks.blocked('To Kill a Mockingbird') === false && ks.blocked('sexual stuff') && !ks.blocked('Essex county') && !ks.blocked('a sextant and a class'), 'whole words only, any case'); + let r = await call(users.get(kai.id), '/api/kidsearch?q=dinosaurs'); assert.equal(r.status, 200); assert.deepEqual(r.json.results.map(x => x.title), ['Dinosaur', 'Tyrannosaurus'], 'a result that mentions a blocked word (gambling) is hidden'); assert.ok(asked.every(u => u.startsWith('https://simple.wikipedia.org/')), 'only Simple English Wikipedia is asked'); + r = await call(users.get(kai.id), '/api/kidsearch?q=porn'); assert.ok(r.json.blocked && r.json.results.length === 0, 'a blocked search is not made'); assert.equal(asked.filter(u => /porn/.test(u)).length, 0); assert.ok(inbox.load(ann.id).some(i => /searched for something blocked/.test(i.body)), 'the parent is told'); + assert.equal((await call(users.get(kai.id), '/api/kidsearch/words', 'POST', { words: 'zebra' })).status, 403, 'a child cannot change the list'); assert.deepEqual((await call(ann, '/api/kidsearch/words', 'POST', { words: 'Zebra, unicorn\nZEBRA' })).json.words, ['zebra', 'unicorn'], 'a parent adds words'); + r = await call(users.get(kai.id), '/api/kidsearch?q=dinosaurs'); assert.deepEqual(r.json.results.map(x => x.title), ['Dinosaur'], 'and results with those words go too'); assert.ok((await call(users.get(kai.id), '/api/kidsearch?q=a unicorn')).json.blocked); + r = await call(users.get(kai.id), '/api/kidsearch/page?title=Dinosaur'); assert.equal(r.json.title, 'Dinosaur'); assert.equal((await call(users.get(kai.id), '/api/kidsearch/page?title=Casino')).status, 403, 'a blocked page cannot be opened either'); + console.log('kidsearch ok'); process.exit(0); +})().catch(e => { console.error(e); process.exit(1); }); diff --git a/users.js b/users.js index 569ebaf..e23a40c 100644 --- a/users.js +++ b/users.js @@ -62,6 +62,7 @@ function update(id, patch) { } if (patch.kid !== undefined) { const k = require('./kidlimits').cleanKid(patch.kid); if (u.role === 'admin' && k && k.on) throw new Error('An admin cannot be a child account'); if (k) u.kid = k; else delete u.kid; } if (patch.board !== undefined) { if (patch.board === false) u.board = false; else delete u.board; } // whether they are on the family board and in family chore progress + if (patch.simple !== undefined) { if (patch.simple) u.simple = true; else delete u.simple; } /* simple mode: a big-button layout with few choices */ if (patch.signOutEverywhere) u.epoch++; save(); return u; } @@ -75,7 +76,7 @@ function remove(id) { const can = (u, tab) => !!u && (ALWAYS.includes(tab) || u.role === 'admin' || (u.tabs || []).includes(tab)); const allowedTabs = u => !u ? [] : [...ALWAYS, ...(u.role === 'admin' ? TABS : (u.tabs || []).filter(t => TABS.includes(t)))]; -const publicView = u => ({ id: u.id, name: u.name, role: u.role, tabs: allowedTabs(u), disabled: !!u.disabled, created: u.created, ...(u.kid && u.kid.on ? { kid: u.kid } : {}), board: u.board !== false }); +const publicView = u => ({ id: u.id, name: u.name, role: u.role, tabs: allowedTabs(u), disabled: !!u.disabled, created: u.created, ...(u.kid && u.kid.on ? { kid: u.kid } : {}), board: u.board !== false, simple: !!u.simple }); // ---- A single-user install becomes "the admin" without anyone doing anything: their passkey, tasks, events, settings, // iCloud connection and calendar links move into their own folder. (Copied, so the originals stay as a backup.)