Parent controls for children, name-tile sign-in, restricted Learn search, simple mode
build-container / build (push) Successful in 3m52s
build-container / build (push) Successful in 3m52s
- Sign-in page shows name tiles on remembered devices; tap a name, then PIN or passkey - Music and Movies are separate on the server; Plex can be locked to a child's managed profile - Activity log, weekly summary and alerts to parents; per-area and weekend limits - Requests from children (Ask button) answered in Settings; bonus screen time from rewards - Restricted Learn search (Simple English Wikipedia, blocked words) replaces web search for children - Simple mode (big-button home) for young children or grandparents - Child usage kept in memory instead of read from disk on every request - Tests and README updated Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
aac6f6efc2
commit
301e8017d9
34 files changed
+466
-51
No files matched your search
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user