// 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 };