diff --git a/README.md b/README.md index ba7d2e7..ec3fd9b 100644 --- a/README.md +++ b/README.md @@ -178,3 +178,5 @@ Files are kept in the data folder under `mystore/` (up to 1 GB each; extensions - **Also:** reorder the queue with the arrows; *More like this* on an artist or song; an About note on albums and artists (Plex's own summary); **Save offline** on an album (plays with no signal); the player takes its colors from the album cover; **Share a line** in the lyrics makes a picture of the lyric over the cover; Featured shows a verse of the day when you have the Bible tab. - Not included: crossfade or gapless (the player already keeps the next songs downloaded for quick changes), searching lyrics by a line, and a speaker picker. +**iPad and Screen Time:** Settings, Admin, *iPad and Screen Time* explains the simple setup (Screen Time, Content Restrictions, Web Content: **Limit Adult Websites**, and add only the hub's address under *Always Allow*), lists the web addresses the app needs (the hub, plus the document editor for the Office tab) for anyone using **Allowed Websites Only**, shows other addresses devices were actually seen reaching, and has a *Check this device* button to run on the iPad. The hub sends a report-only Content-Security-Policy (nothing is blocked) so devices report any other address a page loads, and pictures from other sites (podcast search covers, Plex Home pictures, a Verse By Verse cover, search answers) are passed through the hub (`/api/img`) so the browser only talks to the hub for them. Extensions and Sites you add may still reach their own addresses. If the document editor is put behind the same address as the hub (a reverse proxy path), even that one drops off the list. + diff --git a/docs/v2-roadmap.md b/docs/v2-roadmap.md new file mode 100644 index 0000000..d7fa5d9 --- /dev/null +++ b/docs/v2-roadmap.md @@ -0,0 +1,91 @@ +# NE Hub 2.0 roadmap + +A family-first hub. Status: `[ ]` planned, `[~]` in progress, `[x]` done. Size: S (a day or less), M (a few days), L (a week or more). +Decided: no photos tab, no voice or video calling (text messaging only). + +## 1. Family Chat (a built-in messenger; text only) +A messenger inside the hub for the family, so family chats do not need Signal. Every hub account is a contact, and a "Family" group exists from the start. Family chats and Signal chats show in one inbox (labelled by source); people outside the family still use Signal. Messages are kept on the family's own server. + +**Phase 1: the basics (L)** +- [ ] Direct chats and group chats (names, members, add and remove people) +- [ ] Text with links, emoji and line breaks +- [ ] Attachments: photos, video, files (stored on the server) +- [ ] Replies (quote), reactions +- [ ] Unread counts, search, drafts that survive closing the app +- [ ] Live delivery in the open app, push notifications in the background +- [ ] Mute and archive per chat + +**Phase 2: feels like Signal (M)** +- [ ] Edit and delete for everyone +- [ ] Sent, delivered and read ticks +- [ ] Typing indicators and online presence +- [ ] Disappearing messages (a timer per chat) +- [ ] @mentions in groups, pinned messages, forward a message + +**Phase 3: richer messages (M)** +- [ ] Voice notes (record and play, with a waveform) +- [ ] Link previews (fetched safely by the server) +- [ ] Polls +- [ ] GIF or sticker picker (optional) +- [ ] Share a place as a link + +**Family extras (M)** +- [ ] Turn a message into a task, an event or a shopping item in one tap +- [ ] Share a song, recipe, book or podcast episode from the hub into a chat +- [ ] Pinned family announcement on Home ("dinner at 6") +- [ ] Kid accounts can only message family; parents can set quiet hours + +**Privacy (decide first)** +- [ ] Open question: server-side storage on the family's own machine to start (TLS + encrypted disk), or end-to-end encryption from day one? Starting without E2EE and adding it later is much harder than choosing now. +- [ ] Per-person notification and privacy settings + +Not planned: voice and video calls, stories, usernames and phone-number lookup, sealed sender. + +## 2. Family core +- [ ] **Family Home (M):** one shared start page: who is around, today's family calendar, shared shopping list and chores, what everyone is listening to or reading +- [ ] **Shared lists and notes (M):** a real Notes and Lists tab the whole family edits (grocery, packing, gift ideas, house to-dos) +- [ ] **Chores and routines (M):** recurring chores per person, check-offs, optional points and a weekly summary; kids see their own +- [ ] **Family calendar (M):** a colour per person, a shared week view, birthdays from Contacts, event invitations inside the family +- [ ] **Meal planning (M):** a weekly plan from Recipes that builds the shopping list (and can flag what is already in the pantry) +- [ ] **Worship and devotion (S):** a shared daily verse, a Bible reading plan with check-ins, a family prayer list + +## 3. Kids and safety +- [ ] **Kid profiles (M):** tabs limited per child, content filters (the Smashwords-style filter, Store, podcasts and music in a clean mode), screen-time limits +- [ ] **Approvals (S):** a child asks to add a book, app or extension and a parent approves +- [ ] **Simple mode (S):** a big-button layout with few tabs for young kids or grandparents + +## 4. Everyday convenience +- [ ] **Search everywhere (M):** one search across mail, tasks, notes, recipes, contacts, books, songs, chats and Bible verses +- [ ] **Continue row (S):** the last audiobook, episode, book, movie and song on Home +- [ ] **Notifications center (M):** one inbox for reminders, chores, new episodes, mail and chat, with quiet hours per person +- [ ] **Tasks 2.0 (M):** projects, subtasks everywhere, due-time reminders, Today and Upcoming views +- [ ] **Mail 2.0 (L):** unified inbox, snooze, templates, attachments saved to Files +- [ ] **Contacts 2.0 (S):** anniversaries and reminders, gift ideas + +## 5. Media +- [ ] **Movies & TV 2.0 (L):** mixes, favorites and stats like Music; a shared watchlist and a "movie night" vote +- [ ] **Podcast and audiobook stats (S):** listening history like Music has +- [ ] **Reading goals (S):** a family reading challenge with streaks; export notes and highlights +- [ ] **Casting (M):** pick AirPlay or Cast targets for audio + +## 6. Reliability (the admin's side) +- [ ] **Browser smoke tests in CI (M):** many bugs this year showed only in the interface +- [ ] **Admin health page (S):** all good or something is down: Seafile, Plex, Mealie, mail, storage +- [ ] **Backup and restore screen (S):** one-click restore, per-person export +- [ ] **Offline mode (L):** tasks, lists, recipes, saved books and music keep working with no signal (worth it if the family travels) +- [ ] **Performance pass (S):** lazy-load the big pieces (the Bible text, the reader libraries) +- [ ] **Accessibility (M):** larger text and high-contrast settings, keyboard navigation everywhere, reduced motion + +## 7. Platform (later) +- [ ] **Extensions 2.0 (L):** more hub data in the bridge (notes, music, recipes), a permissions screen, extension updates +- [ ] **Automations (L):** "when X then Y" rules (a new episode adds a task, a low pantry item adds to Shopping) +- [ ] **Themes (S):** accent colours and an AMOLED dark mode per person + +## Suggested order +1. Family Chat phase 1, Family Home, Shared lists and notes, Chores +2. Kid profiles, Search everywhere, Notifications center, Family Chat phase 2 +3. Meal planning, Family calendar, Family Chat phase 3 and family extras +4. Reliability items alongside everything, so each step stays safe to ship + +## Done already (for reference, 1.x) +Store (books, audiobooks, radio, My Store, extensions), Recipes with a shared shopping list, Contacts, Music (Featured, Mixes, Favorites, Stats, smart and Family playlists), podcast discovery and transcript search, Smashwords with a family filter, and the sidebar and Radio fixes. diff --git a/package.json b/package.json index 76a2662..e4202a7 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; 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; do node tools/$t-test.js || exit 1; done", "audit": "npm audit --omit=dev --audit-level=high" }, "keywords": [], diff --git a/public/index.html b/public/index.html index 2aab080..dae4cde 100644 --- a/public/index.html +++ b/public/index.html @@ -60,6 +60,7 @@ + diff --git a/public/music-extras.js b/public/music-extras.js index ca18f65..ca1c00c 100644 --- a/public/music-extras.js +++ b/public/music-extras.js @@ -82,7 +82,7 @@ async function plexHomeBadge(st) { } function plexSwitchSheet(users) { const el = openSheet('Who is listening?', 'People in your Plex Home. Playlists, ratings and what you have played follow the person you pick.', - `
${users.map((u, i) => ``).join('')}
`); el.querySelector('#sx').onclick = closeSheet; diff --git a/public/podcast-view.js b/public/podcast-view.js index b2f1232..3f0f035 100644 --- a/public/podcast-view.js +++ b/public/podcast-view.js @@ -65,7 +65,7 @@ async function pdHome() { catch (e) { err.textContent = e.status === 502 || e.status === 409 || e.status === 400 ? 'Podgrab could not add that (it may already be there)' : e.message; if (btn) btn.disabled = false; } }; const show = list => { - found = list; res.innerHTML = found.length ? found.map((f, i) => `
${esc(f.title)}${esc(f.author || '')}
`).join('') : '

Nothing found.

'; + found = list; res.innerHTML = found.length ? found.map((f, i) => `
${esc(f.title)}${esc(f.author || '')}
`).join('') : '

Nothing found.

'; res.querySelectorAll('button[data-i]').forEach(b => b.onclick = () => add(found[b.dataset.i].feedUrl, b)); }; pdApi('/discover/topics').then(ts => { const box = el.querySelector('#pdtopics'); if (!box || !ts.length) return; box.innerHTML = ts.map(t => ``).join(''); diff --git a/public/screentime-view.js b/public/screentime-view.js new file mode 100644 index 0000000..7c33feb --- /dev/null +++ b/public/screentime-view.js @@ -0,0 +1,31 @@ +'use strict'; +// ---- iPad and Screen Time (admin): which web addresses the app needs, the ones devices were seen reaching, and a check you run on the device itself. +const stOk = u => /^https?:\/\//i.test(u || '') ? '/api/img?u=' + encodeURIComponent(u) : (u || ''); // a picture from another site, passed through the hub so the browser only talks to the hub +async function screenTimeSheet() { + let d; try { d = await jfetch('/api/screentime'); } catch (e) { return toast(e.message); } + const row = (x, extra = '') => `
${esc(x.host)}${esc(x.why || ((x.what || []).join(', ') + ' · seen ' + x.n + (x.n === 1 ? ' time' : ' times')))}${extra}
`; + const hub = d.required[0].host; + const el = openSheet('iPad and Screen Time', 'Let the hub through Screen Time without adding each site one by one', ` +

The simple way. On the iPad: Settings → Screen Time → Content & Privacy Restrictions → Content Restrictions → Web Content. Choose Limit Adult Websites, then under Always Allow tap Add Website and enter ${esc(hub)}. Nothing else needs adding: the hub carries the music, books, podcasts and pictures itself.

+

Only if you use Allowed Websites Only do you need to add every address below.

+

Addresses the app needs

${d.required.map(x => row(x)).join('')}
+ ${d.seen.length ? `

Others your devices reached

Seen while people used the app (a podcast cover, a weather extension, a site you added). Add only what you want available.

${d.seen.map(x => row(x)).join('')}
` : '

No other addresses have been seen yet. Use the app for a day on the iPad and any others will show up here.

'} +

Only sometimes

${d.optional.map(x => row(x)).join('')}
+
+

“Check this device” tries each address from the device you are on, and shows which ones Screen Time (or the network) is blocking. Run it on the iPad.

+ ${d.seen.length ? '
' : ''}`); + el.querySelector('.sheet').classList.add('wide'); + el.querySelectorAll('[data-c]').forEach(b => b.onclick = () => { navigator.clipboard.writeText(b.dataset.c).then(() => toast('Copied ' + b.dataset.c), () => toast(b.dataset.c)); }); + const all = [...d.required, ...d.seen, ...d.optional].map(x => x.host); + el.querySelector('#stcopy').onclick = () => navigator.clipboard.writeText(all.join('\n')).then(() => toast('Copied ' + all.length + ' addresses'), () => toast('Could not copy')); + el.querySelector('#stchk').onclick = async e => { + const b = e.currentTarget; b.disabled = true; b.textContent = 'Checking…'; + await Promise.all(all.map(async h => { + const mark = el.querySelector(`.stres[data-h="${CSS.escape(h)}"]`); if (!mark) return; + try { await fetch(h === location.hostname ? '/manifest.webmanifest' : 'https://' + h + '/', { mode: 'no-cors', cache: 'no-store', signal: AbortSignal.timeout(8000) }); mark.textContent = ' ✓'; mark.style.color = 'var(--accent)'; mark.title = 'Reachable'; } + catch { mark.textContent = ' ✗'; mark.style.color = 'var(--danger)'; mark.title = 'Blocked or not reachable from this device'; } + })); + b.disabled = false; b.textContent = 'Check again'; + }; + if (el.querySelector('#stclr')) el.querySelector('#stclr').onclick = async () => { try { await jfetch('/api/screentime', { method: 'DELETE' }); closeSheet(); toast('Cleared'); } catch (x) { toast(x.message); } }; +} diff --git a/public/search-view.js b/public/search-view.js index 4c1e5bd..945f23b 100644 --- a/public/search-view.js +++ b/public/search-view.js @@ -107,7 +107,7 @@ function viewSearch() { 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) ? `
- ${d.image ? `` : ''} + ${d.image ? `` : ''}
From DuckDuckGo${d.source ? ' · ' + esc(d.source) : ''} ${d.heading ? `
${esc(d.heading)}
` : ''}${d.answer ? `
${esc(d.answer)}
` : ''} ${d.text ? `

${esc(d.text)}

` : ''}${d.url ? `Read more` : ''}
` : ''; diff --git a/public/settings.js b/public/settings.js index 96bea3a..cad4aeb 100644 --- a/public/settings.js +++ b/public/settings.js @@ -95,6 +95,7 @@ function viewSettings() {

Health

Checking…

Storage

Checking…
+

iPad and Screen Time

Allow the hub on an iPad with Screen Time web limits, without adding every site one by one.

Download a backup

A .tar.gz of everything in /data (accounts, passkeys, tasks, events, settings, Signal history). Keep it private: it holds the session secret and encrypted sign-in tokens. Restore by unpacking it into the data volume.

` : ''} ${isAdmin ? `

Activity

Loading…
` : ''} `; @@ -130,7 +131,7 @@ function viewSettings() { if (st.signedIn) { const b = $('#plexout'); b.hidden = false; b.onclick = async () => { await jfetch('/api/plex/logout', { method: 'POST' }); pl.a.pause(); pl.q = []; pl.i = -1; renderMini(); toast('Signed out of Plex'); viewSettings(); }; } }).catch(() => { const el = $('#plexst'); if (el) el.textContent = 'Unavailable'; }); const lk = $('#lookups'); if (lk) { lk.checked = store.get('lookups', true) !== false; lk.onchange = () => { store.set('lookups', lk.checked); toast(lk.checked ? 'Lookups on' : 'Lookups off'); }; } - if (isAdmin) { loadPeople(); $('#addperson').onclick = () => addPersonSheet(); } + if (isAdmin) { loadPeople(); $('#addperson').onclick = () => addPersonSheet(); $('#stbtn').onclick = () => screenTimeSheet(); } if (isAdmin) jfetch('/api/health').then(h => { const row = (t, d, bad) => `
${bad ? '⚠ ' : '✓ '}${t}${d}
`; const gb = n => (n / 1e9).toFixed(1) + ' GB', days = t => Math.floor((Date.now() - t) / 864e5); diff --git a/public/sw.js b/public/sw.js index e144578..80bd9c6 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,5 +1,5 @@ // App-shell cache only; Seafile/API traffic always goes to the network. -const CACHE = 'hub-v57'; +const CACHE = 'hub-v58'; const SHARE = 'hub-share'; // files shared into the app from other apps, held until the person picks a folder const SHELL = ['/icon.svg', '/manifest.webmanifest', '/icons/icon-192.png', '/icons/apple-touch-icon.png']; diff --git a/public/vbv-view.js b/public/vbv-view.js index b4a4500..79d1897 100644 --- a/public/vbv-view.js +++ b/public/vbv-view.js @@ -17,7 +17,7 @@ pl.a.addEventListener('pause', () => vbSave()); pl.a.addEventListener('ended', ( const vbLeft = key => { const p = vbProg[key]; return p && p.pos > 10 && p.dur && p.pos < p.dur - 30 ? p : null; }; const vbDone = key => { const p = vbProg[key]; return !!(p && p.dur && p.pos >= p.dur - 30); }; const vbDate = d => { const t = Date.parse(d); return t > 0 ? new Date(t).toLocaleDateString([], { month: 'short', day: 'numeric', year: 'numeric' }) : ''; }; -const vbCover = (s, big) => ``; +const vbCover = (s, big) => ``; async function viewVbv(args = []) { const v = $('#view'); v.innerHTML = '

Verse By Verse

Loading…

'; diff --git a/screentime.js b/screentime.js new file mode 100644 index 0000000..76c3f91 --- /dev/null +++ b/screentime.js @@ -0,0 +1,64 @@ +// 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 }; diff --git a/server.js b/server.js index 449c142..12c9863 100644 --- a/server.js +++ b/server.js @@ -43,6 +43,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 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 musicdata = require('./musicdata'); // what was played, favorites, smart and Family playlists for Music const mystore = require('./mystore'); // My Store: your own books, music, apps and extensions for everyone to use const radio = require('./radio'); // internet radio (Radio Browser directory, The Journey FM) @@ -575,6 +576,7 @@ function securityHeaders(res) { res.setHeader('X-Content-Type-Options', 'nosniff'); res.setHeader('Referrer-Policy', 'same-origin'); res.setHeader('Content-Security-Policy', "frame-ancestors 'self'; base-uri 'self'; object-src 'none'; form-action 'self'"); + res.setHeader('Content-Security-Policy-Report-Only', screentime.policy()); // (reports only: nothing is blocked) res.setHeader('Permissions-Policy', 'camera=(), geolocation=(), payment=()'); if (process.env.PUBLIC_URL && process.env.PUBLIC_URL.startsWith('https://')) res.setHeader('Strict-Transport-Security', 'max-age=15552000'); } @@ -654,6 +656,7 @@ 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/csp-report' || pathname === '/api/img' || pathname === '/api/screentime') return screentime.handle(req, res, user); if (pathname.startsWith('/api/music/')) return musicdata.handle(req, res); if (pathname === '/api/store/my' || pathname.startsWith('/api/store/my/')) return mystore.handle(req, res); if (pathname === '/api/recipes' || pathname.startsWith('/api/recipes/')) return recipes.handle(req, res); diff --git a/tools/screentime-test.js b/tools/screentime-test.js new file mode 100644 index 0000000..8a6ee76 --- /dev/null +++ b/tools/screentime-test.js @@ -0,0 +1,24 @@ +// Screen Time helper: the report-only policy, what devices report reaching, the admin overview, and the picture pass-through refusing what it should. +const http = require('http'), assert = require('assert'), fs = require('fs'), os = require('os'), path = require('path'); +process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'nehub-st-')); process.env.ONLYOFFICE_URL = 'https://docs.example.org'; +const users = require('../users'), st = require('../screentime'); +const people = { a: users.create({ name: 'Ann', role: 'admin', handle: 'YQ' }), b: users.create({ name: 'Bob', role: 'user', handle: 'Yg' }) }; +const rep = (uri, dir = 'img-src') => JSON.stringify({ 'csp-report': { 'blocked-uri': uri, 'effective-directive': dir, 'violated-directive': dir + " 'self'" } }); +const hub = http.createServer((req, res) => { const me = req.headers['x-user'] === 'b' ? people.b : people.a; users.runAs(me, () => st.handle(req, res, me)); }).listen(0, async () => { + const port = hub.address().port, B = 'http://127.0.0.1:' + port, call = (p, o = {}) => fetch(B + p, { ...o, headers: { 'x-forwarded-host': 'hub.example.org', ...(o.headers || {}) } }); + try { + const pol = st.policy(); assert.match(pol, /report-uri \/api\/csp-report/); assert.match(pol, /frame-src[^;]*https:\/\/docs\.example\.org/, 'the document editor is allowed'); assert.match(pol, /img-src 'self' data: blob:;/, 'pictures only from the hub'); + for (const [u, d] of [['https://is1-ssl.mzstatic.com/a.jpg', 'img-src'], ['https://is1-ssl.mzstatic.com/b.jpg', 'img-src'], ['https://api.open-meteo.com/v1/forecast?x=1', 'connect-src'], ['inline', 'script-src'], ['data', 'img-src'], ['https://hub.example.org/x.png', 'img-src'], ['http://localhost:3000/x', 'img-src']]) assert.equal((await call('/api/csp-report', { method: 'POST', body: rep(u, d) })).status, 204); + assert.equal((await call('/api/csp-report', { method: 'POST', body: 'not json' })).status, 204, 'a garbled report is ignored, not an error'); + let o = await (await call('/api/screentime')).json(); + assert.equal(o.required[0].host, 'hub.example.org'); assert.deepEqual(o.required.map(x => x.host), ['hub.example.org', 'docs.example.org'], 'the hub and the editor'); + assert.deepEqual(o.seen.map(x => [x.host, x.n]), [['is1-ssl.mzstatic.com', 2], ['api.open-meteo.com', 1]], 'outside addresses are counted; the hub itself, local addresses and non-addresses are left out'); assert.deepEqual(o.seen[1].what, ['connect']); + assert.equal((await call('/api/screentime', { headers: { 'x-user': 'b' } })).status, 403, 'only an admin sees the list'); + o = await (await call('/api/screentime', { method: 'DELETE' })).json(); assert.deepEqual(o.seen, []); + // pictures from other sites + assert.equal((await call('/api/img?u=notaurl')).status, 400); assert.equal((await call('/api/img?u=' + encodeURIComponent('http://example.com/a.png'))).status, 502, 'only https'); + assert.equal((await call('/api/img?u=' + encodeURIComponent('https://127.0.0.1/a.png'))).status, 502, 'never a private address'); assert.equal((await call('/api/img?u=' + encodeURIComponent('https://localhost/a.png'))).status, 502); + console.log('screentime ok'); + } catch (e) { console.error(e); process.exitCode = 1; } + hub.close(); setTimeout(() => process.exit(process.exitCode || 0), 50); +});