build-container / build (push) Successful in 5m57s
- Admin screen: the simple Screen Time setup, the web addresses the app needs, addresses devices were seen reaching, and a Check this device test - Report-only Content-Security-Policy so devices report any other address a page loads (nothing is blocked) - Pictures from other sites pass through the hub (/api/img): podcast search covers, Plex Home pictures, a VBV cover, search answers - docs/v2-roadmap.md: the family-first 2.0 plan, with Family Chat (text only) and no photos or calls Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
65 lines
5.5 KiB
JavaScript
65 lines
5.5 KiB
JavaScript
// Helping an iPad on Screen Time "Allowed Websites Only": the app should need as few web addresses as possible, and an admin should be able to see exactly which ones.
|
|
// 1. The server sends a report-only Content-Security-Policy that says "only this site"; the browser reports every other address a page tries to load (nothing is blocked),
|
|
// and those are kept here, so the list comes from real use on the family's devices.
|
|
// 2. /api/img passes a picture from another site through the hub, so the browser only ever talks to the hub for it.
|
|
const fs = require('fs'), path = require('path');
|
|
const atomicWrite = require('./atomic');
|
|
const cal = require('./cal');
|
|
const { readBody } = require('./body');
|
|
|
|
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data'), FILE = () => path.join(DATA_DIR, 'hosts-seen.json');
|
|
const MAX_HOSTS = 300, MAX_IMG = 3 * 1024 * 1024;
|
|
const json = (res, code, o) => { res.writeHead(code, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(o)); };
|
|
const load = () => { try { const j = JSON.parse(fs.readFileSync(FILE(), 'utf8')); return j && typeof j === 'object' ? j : {}; } catch { return {}; } };
|
|
const save = o => atomicWrite(FILE(), JSON.stringify(o), { mode: 0o600 });
|
|
const ooHost = () => { try { return new URL(process.env.ONLYOFFICE_URL || 'https://onlyoffice.necloud.us').host; } catch { return ''; } };
|
|
|
|
/** The report-only policy: only this site (and the document editor's), for everything the browser loads from the network. */
|
|
function policy() {
|
|
const oo = (() => { try { return new URL(process.env.ONLYOFFICE_URL || 'https://onlyoffice.necloud.us').origin; } catch { return ''; } })();
|
|
return `default-src 'self' data: blob:; script-src 'self' 'unsafe-inline' 'unsafe-eval' blob: ${oo}; style-src 'self' 'unsafe-inline' ${oo}; img-src 'self' data: blob:; media-src 'self' data: blob:; connect-src 'self' blob: data:; font-src 'self' data:; frame-src 'self' blob: data: ${oo}; worker-src 'self' blob:; report-uri /api/csp-report`;
|
|
}
|
|
/** A browser's report of one address a page loaded from outside: kept as host -> { n, first, last, what }. */
|
|
function record(body, hubHost) {
|
|
let r; try { r = JSON.parse(body)['csp-report']; } catch { return; } if (!r) return;
|
|
let u; try { u = new URL(String(r['blocked-uri'] || r['blockedURI'] || '')); } catch { return; }
|
|
if (!/^https?:$/.test(u.protocol) || u.host === hubHost || /^(localhost|127\.|\[::1\])/.test(u.hostname)) return;
|
|
const db = load(), what = String(r['effective-directive'] || r['violated-directive'] || '').split(/\s/)[0].replace(/-src$/, '') || 'other', now = Date.now(), e = db[u.hostname];
|
|
if (e) { e.n++; e.last = now; if (!e.what.includes(what)) e.what.push(what); } else if (Object.keys(db).length < MAX_HOSTS) db[u.hostname] = { n: 1, first: now, last: now, what: [what] }; else return;
|
|
save(db);
|
|
}
|
|
function overview(req) {
|
|
const hub = req.headers['x-forwarded-host'] || req.headers.host || '', required = [{ host: hub.split(':')[0], why: 'The hub itself. Almost everything the app needs goes through it.' }];
|
|
if (ooHost() && ooHost() !== hub) required.push({ host: ooHost(), why: 'The document editor (the Office tab).' });
|
|
const seen = Object.entries(load()).filter(([h]) => !required.some(r => r.host === h)).map(([host, v]) => ({ host, ...v })).sort((a, b) => b.n - a.n);
|
|
const optional = [{ host: 'plex.tv', why: 'Only the first time, to sign in to Plex (a link that opens in the browser).' }];
|
|
return { required, optional, seen };
|
|
}
|
|
async function image(req, res, u) {
|
|
let url; try { url = new URL(u.searchParams.get('u') || ''); } catch { return json(res, 400, { error: 'Bad address' }); }
|
|
try {
|
|
let r; for (let hop = 0; hop < 4; hop++) {
|
|
await cal.assertPublic(url);
|
|
r = await fetch(url, { headers: { 'user-agent': 'NE-Hub/1.0', accept: 'image/*' }, redirect: 'manual', signal: AbortSignal.timeout(10000) });
|
|
if ([301, 302, 303, 307, 308].includes(r.status) && r.headers.get('location')) { url = new URL(r.headers.get('location'), url); try { await r.body?.cancel(); } catch {} continue; } break;
|
|
}
|
|
const type = (r.headers.get('content-type') || '').split(';')[0].trim().toLowerCase();
|
|
if (!r.ok || !/^image\/(jpeg|png|gif|webp)$/.test(type)) return json(res, 404, { error: 'Not a picture' });
|
|
if ((+r.headers.get('content-length') || 0) > MAX_IMG) return json(res, 413, { error: 'Too large' });
|
|
const chunks = []; let n = 0; for await (const c of r.body) { n += c.length; if (n > MAX_IMG) return json(res, 413, { error: 'Too large' }); chunks.push(c); }
|
|
res.writeHead(200, { 'content-type': type, 'cache-control': 'private, max-age=604800', 'x-content-type-options': 'nosniff' }); return res.end(Buffer.concat(chunks));
|
|
} catch { return json(res, 502, { error: 'Could not get that picture' }); }
|
|
}
|
|
async function handle(req, res, me) {
|
|
const u = new URL(req.url, 'http://x');
|
|
if (u.pathname === '/api/csp-report' && req.method === 'POST') { try { record(await readBody(req, 2e4), req.headers['x-forwarded-host'] || req.headers.host); } catch {} res.writeHead(204); return res.end(); }
|
|
if (u.pathname === '/api/img' && req.method === 'GET') return image(req, res, u);
|
|
if (u.pathname === '/api/screentime') {
|
|
if (!me || me.role !== 'admin') return json(res, 403, { error: 'Only an admin can see this' });
|
|
if (req.method === 'DELETE') { save({}); return json(res, 200, overview(req)); }
|
|
if (req.method === 'GET') return json(res, 200, overview(req));
|
|
}
|
|
return json(res, 404, { error: 'Not found' });
|
|
}
|
|
module.exports = { handle, policy, record, overview };
|