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>
51 lines
5.3 KiB
JavaScript
51 lines
5.3 KiB
JavaScript
// 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 };
|