diff --git a/README.md b/README.md index bb59cc6..9c9796d 100644 --- a/README.md +++ b/README.md @@ -196,5 +196,7 @@ Files are kept in the data folder under `mystore/` (up to 1 GB each; extensions **Notifications** (the bell on Home and Family): a list of what you have not seen yet: chat messages (one line per conversation, with a count), reminders for tasks, events and mail, and family announcements. Tap one to open it; mark all read or clear. Kept per person in `inbox.json`. +**Family devices** (Settings, under Passkeys): for a kitchen tablet or the family computer. An admin turns on *Make this a family device* while signed in on it; from then on it opens on the **family board** (announcement, today's family calendar, chores with progress, shopping list, family tasks and notes) and a row of name tiles, children marked **KID**. Tap your name: a child signs in with one tap, everyone else enters a PIN they set in Settings (five wrong tries lock that person for five minutes; there is also a passkey link). A label with your name and **Switch user** stays on screen, and the device goes back to the board after five idle minutes (music playing keeps it signed in). Sessions on a family device last 30 minutes at a time, not months. Turn it off from the same place. The Family calendar and Family task list are shared by everyone and appear on the board. + On a tablet or with the menu collapsed, the menu rail shows icons only (the name appears on hover) when the window is not tall enough for a label under every icon. diff --git a/auth.js b/auth.js index d1eb5f7..779ad62 100644 --- a/auth.js +++ b/auth.js @@ -72,13 +72,22 @@ function currentUser(req) { } const isAuthed = req => !!currentUser(req); -const sessionCookie = (req, user) => { const exp = Date.now() + LIFETIME; - return `hub_session=${user.id}.${user.epoch}.${exp}.${mac(`s${user.id}.${user.epoch}.${exp}`)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${SESSION_DAYS * 86400}${isHttps(req) ? '; Secure' : ''}`; }; +// A family device (a shared screen) is signed in for only a short while after each tap, so whoever used it last is not left signed in. +const KIOSK = 30 * 60e3; +const devices = require('./devices'); +const deviceOf = req => { const [id, sig] = String(cookies(req).hub_device || '').split('.'); return id && sig && safeEq(sig, mac('d' + id)) && devices.has(id) ? id : null; }; +const lifeFor = req => deviceOf(req) ? KIOSK : LIFETIME; +const sessionCookie = (req, user, life = lifeFor(req)) => { const exp = Date.now() + life; + return `hub_session=${user.id}.${user.epoch}.${exp}.${mac(`s${user.id}.${user.epoch}.${exp}`)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${Math.round(life / 1000)}${isHttps(req) ? '; Secure' : ''}`; }; +const deviceCookie = (req, id) => `hub_device=${id}.${mac('d' + id)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${3650 * 86400}${isHttps(req) ? '; Secure' : ''}`; +const enrollDevice = (req, res, name, by) => { const d = devices.add(name, by); addCookie(res, deviceCookie(req, d.id)); return d; }; +const forgetDevice = (req, res) => { const id = deviceOf(req); if (id) devices.remove(id); addCookie(res, 'hub_device=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0'); }; // Add a Set-Cookie without clobbering ones other code already set on this response. const addCookie = (res, c) => { const cur = res.getHeader('Set-Cookie'); res.setHeader('Set-Cookie', cur ? [].concat(cur, c) : c); }; function startSession(req, res, user) { addCookie(res, sessionCookie(req, user)); } /** Renew the cookie when it is more than a day old (and upgrade old-style cookies), so an active session never runs out. */ function refresh(req, res, user) { + if (deviceOf(req)) { const e = Number(String(cookies(req).hub_session || '').split('.')[2]); if (!(e - Date.now() > KIOSK - 60e3)) addCookie(res, sessionCookie(req, user, KIOSK)); return; } const parts = String(cookies(req).hub_session || '').split('.'); const exp = Number(parts.length === 4 ? parts[2] : parts[0]); if (parts.length !== 4 || exp - Date.now() < LIFETIME - 864e5) addCookie(res, sessionCookie(req, user)); @@ -248,4 +257,4 @@ async function handle(req, res) { const passkeyCount = uid => credsOf(uid).length; function removeCredsOf(uid) { store.creds = store.creds.filter(c => c.uid !== uid); save(); delete recovery[uid]; saveRecovery(); } -module.exports = { handle, currentUser, isAuthed, refresh, addCookie, secret, SESSION_DAYS, newInvite, INVITE_MINUTES, passkeyCount, removeCredsOf }; +module.exports = { deviceOf, enrollDevice, forgetDevice, startSession, handle, currentUser, isAuthed, refresh, addCookie, secret, SESSION_DAYS, newInvite, INVITE_MINUTES, passkeyCount, removeCredsOf }; diff --git a/board.js b/board.js new file mode 100644 index 0000000..0f0b4ba --- /dev/null +++ b/board.js @@ -0,0 +1,65 @@ +// The family board and "who is this?": what a family device (see devices.js) shows before anyone signs in, and the way a person picks their name there. +// Children tap their name; everyone else enters a PIN they set for themselves (or signs in with a passkey). The board only ever shows the family's shared things. +const fs = require('fs'), path = require('path'), crypto = require('crypto'); +const atomicWrite = require('./atomic'); +const users = require('./users'), auth = require('./auth'), audit = require('./audit'), devices = require('./devices'); +const cal = require('./cal'), chores = require('./chores'), notes = require('./notes'), tasks = require('./tasks'), recipes = require('./recipes'), chat = require('./chat'); +const { readBody } = require('./body'); + +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 FILE = () => path.join(process.env.DATA_DIR || path.join(__dirname, 'data'), 'pins.json'); +let pins = null; +const db = () => pins || (pins = (() => { try { return JSON.parse(fs.readFileSync(FILE(), 'utf8')); } catch { return {}; } })()); +const savePins = () => { fs.mkdirSync(path.dirname(FILE()), { recursive: true }); atomicWrite(FILE(), JSON.stringify(db()), { mode: 0o600 }); }; +const hash = (pin, salt) => crypto.scryptSync(String(pin), salt, 32).toString('hex'); +const hasPin = uid => !!db()[uid]; +const MAX_FAILS = 5, LOCK_MS = 5 * 60e3; +function setPin(uid, pin) { if (!/^\d{4,8}$/.test(String(pin || ''))) throw fail(400, 'A PIN is 4 to 8 digits'); const salt = crypto.randomBytes(12).toString('hex'); db()[uid] = { s: salt, h: hash(pin, salt), fails: 0, until: 0 }; savePins(); } +function checkPin(uid, pin) { + const r = db()[uid]; if (!r) return false; if (r.until > Date.now()) throw fail(429, 'Too many wrong tries. Wait a few minutes.'); + const ok = /^\d{4,8}$/.test(String(pin || '')) && crypto.timingSafeEqual(Buffer.from(hash(pin, r.s), 'hex'), Buffer.from(r.h, 'hex')); + if (ok) { if (r.fails) { r.fails = 0; savePins(); } return true; } + r.fails = (r.fails || 0) + 1; if (r.fails >= MAX_FAILS) { r.fails = 0; r.until = Date.now() + LOCK_MS; } savePins(); return false; +} +const ymdOk = s => /^\d{4}-\d{2}-\d{2}$/.test(s || ''); +const isKid = u => !!(u.kid && u.kid.on); +const palette = ['#e0742b', '#2f9e6e', '#4f7cff', '#8a5cf0', '#d9453a', '#2aa6a0', '#e5a00d']; +const people = () => users.all().filter(u => !u.disabled).map((u, i) => ({ id: u.id, name: u.name, kid: isKid(u), pin: hasPin(u.id), color: palette[i % palette.length] })); + +async function board(day) { + const t1 = chores.addDays(day, 1), from = new Date(day + 'T00:00:00').getTime() - 864e5, to = new Date(t1 + 'T23:59:59').getTime() + 864e5; + let events = []; try { events = cal.familyEvents({ from, to, fd: day, td: t1 }); } catch {} + return { day, people: people(), announcement: chat.announcement(), events, chores: chores.board(day).map(i => ({ title: i.title, points: i.points, user: i.user, userName: i.userName, done: !!i.done })), shopping: await recipes.shoppingOpen(), notes: notes.familyNotes(), tasks: tasks.familyOpen() }; +} + +async function handle(req, res) { + const u = new URL(req.url, 'http://x'), p = u.pathname, me = auth.currentUser(req), dev = auth.deviceOf(req); + try { + if (p === '/api/board' && req.method === 'GET') { if (!dev) throw fail(403, 'This is not a family device'); const day = u.searchParams.get('day'); return json(res, 200, await board(ymdOk(day) ? day : new Date().toISOString().slice(0, 10))); } + if (p === '/api/board/switch' && req.method === 'POST') { + if (!dev) throw fail(403, 'This is not a family device'); const b = JSON.parse(await readBody(req, 5e3) || '{}'), t = users.get(String(b.uid)); + if (!t || t.disabled) throw fail(404, 'Person not found'); + if (!isKid(t) || hasPin(t.id)) { // a child with no PIN just taps their name; everyone else needs theirs + if (!hasPin(t.id)) throw fail(403, 'Set a PIN first (Settings, on your own device), or sign in with your passkey'); + if (!checkPin(t.id, b.pin)) { audit.log('family device: wrong PIN', { who: t.name }, req); throw fail(401, 'That PIN is not right'); } + } + auth.startSession(req, res, t); audit.log('family device: picked a profile', { who: t.name }, req); return json(res, 200, { ok: true }); + } + if (p === '/api/device' && req.method === 'GET') { if (!me) throw fail(401, 'Not signed in'); return json(res, 200, { device: !!dev, pin: hasPin(me.id), devices: me.role === 'admin' ? devices.load() : undefined }); } + if (p === '/api/device/pin' && (req.method === 'POST' || req.method === 'DELETE')) { + if (!me) throw fail(401, 'Not signed in'); if (req.method === 'DELETE') { delete db()[me.id]; savePins(); return json(res, 200, { pin: false }); } + const b = JSON.parse(await readBody(req, 5e3) || '{}'); setPin(me.id, b.pin); return json(res, 200, { pin: true }); + } + if (p === '/api/device/enable' && req.method === 'POST') { // an admin makes this screen a family device (and it goes back to the board) + if (!me || me.role !== 'admin') throw fail(403, 'Only an admin can do this'); const b = JSON.parse(await readBody(req, 5e3) || '{}'); auth.enrollDevice(req, res, b.name, me.name); audit.log('family device added', { who: me.name, detail: String(b.name || '').slice(0, 40) }, req); + res.setHeader('Set-Cookie', [].concat(res.getHeader('Set-Cookie') || [], 'hub_session=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0')); return json(res, 200, { ok: true }); + } + if (p === '/api/device/remove' && req.method === 'POST') { // switch one off: this screen, or another from the list + if (!me || me.role !== 'admin') throw fail(403, 'Only an admin can do this'); const b = JSON.parse(await readBody(req, 5e3) || '{}'); + if (b.id) devices.remove(String(b.id)); else auth.forgetDevice(req, res); audit.log('family device removed', { who: me.name }, req); return json(res, 200, { ok: true }); + } + return json(res, 404, { error: 'Not found' }); + } catch (e) { return json(res, e.code >= 400 && e.code < 600 ? e.code : 400, { error: e instanceof SyntaxError ? 'Bad request' : e.message }); } +} +module.exports = { handle, board, setPin, hasPin }; diff --git a/cal.js b/cal.js index a5e24cd..c7a9acb 100644 --- a/cal.js +++ b/cal.js @@ -477,4 +477,9 @@ async function handle(req, res) { } catch (e) { json(res, 400, { error: e.message }); } } -module.exports = { collectEvents, unfold, parseLine, parseDate, unesc, handle, feed, parseEvents, expand, zonedToUtc, assertPublic, localIcs }; +/** The Family calendar's events in a window, for the family board (no signed-in person needed). */ +function familyEvents(win) { + const ics_ = ics.buildCalendar(eventsIn(dirFor(FAM)).map(e => ({ uid: e.uid, fields: e.fields }))); + return expand(parseEvents(ics_), win).map(e => ({ ...e, cal: 'Family', color: FAMCAL.color })); +} +module.exports = { familyEvents, collectEvents, unfold, parseLine, parseDate, unesc, handle, feed, parseEvents, expand, zonedToUtc, assertPublic, localIcs }; diff --git a/chat.js b/chat.js index 84fd576..2675298 100644 --- a/chat.js +++ b/chat.js @@ -239,4 +239,4 @@ function searchFor(me, q) { for (const c of convs().filter(x => x.members.includes(me.id))) { const cv = view(c, me.id, st); for (const x of msgs(c.id)) if (!x.deleted && !x.sys && x.text && x.text.toLowerCase().includes(needle)) out.push({ conv: c.id, convName: cv.name, msg: msgView(x, me.id) }); } return out.sort((a, b) => b.msg.ts - a.msg.ts).slice(0, 8); } -module.exports = { handle, totalUnread, purge, parsePreview, askParents, searchFor }; +module.exports = { announcement: () => read(annFile(), null), handle, totalUnread, purge, parsePreview, askParents, searchFor }; diff --git a/devices.js b/devices.js new file mode 100644 index 0000000..3a81580 --- /dev/null +++ b/devices.js @@ -0,0 +1,11 @@ +// Family devices: shared screens (a kitchen tablet, the family computer) that open on the family board instead of a sign-in. An admin makes a device one +// while signed in on it; the device keeps a signed cookie, and this list is what lets an admin switch one off again. Kept in DATA_DIR/devices.json. +const fs = require('fs'), path = require('path'), crypto = require('crypto'); +const atomicWrite = require('./atomic'); +const FILE = () => path.join(process.env.DATA_DIR || path.join(__dirname, 'data'), 'devices.json'); +const load = () => { try { const j = JSON.parse(fs.readFileSync(FILE(), 'utf8')); return Array.isArray(j.devices) ? j.devices : []; } catch { return []; } }; +const save = l => { fs.mkdirSync(path.dirname(FILE()), { recursive: true }); atomicWrite(FILE(), JSON.stringify({ devices: l }), { mode: 0o600 }); }; +const has = id => load().some(d => d.id === id); +const add = (name, by) => { const d = { id: crypto.randomBytes(9).toString('base64url'), name: String(name || 'Family device').slice(0, 40), by, created: Date.now() }; save([...load(), d]); return d; }; +const remove = id => save(load().filter(d => d.id !== id)); +module.exports = { load, has, add, remove }; diff --git a/docs/v2-roadmap.md b/docs/v2-roadmap.md index aa13076..9541dc3 100644 --- a/docs/v2-roadmap.md +++ b/docs/v2-roadmap.md @@ -52,6 +52,7 @@ Not planned: voice and video calls, stories, usernames and phone-number lookup, ## 3. Kids and safety - [x] **Kid profiles (M):** tabs limited per child, content filters (the Smashwords-style filter, Store, podcasts and music in a clean mode), screen-time limits **Done: child accounts get a clean Store, podcasts and daily time limit and bedtime (kidlimits.js); tabs per child already existed.** - [ ] **Approvals (S):** a child asks to add a book, app or extension and a parent approves +- [x] **Family device and profile picker (M):** a shared screen opens on the family board; kids tap their name, everyone else enters a PIN; it returns to the board when idle **Done: board.js, devices.js** - [ ] **Simple mode (S):** a big-button layout with few tabs for young kids or grandparents ## 4. Everyday convenience diff --git a/notes.js b/notes.js index 4c6de5c..f193e6a 100644 --- a/notes.js +++ b/notes.js @@ -65,4 +65,6 @@ function search(me, q) { const at = text.toLowerCase().indexOf(needle); out.push({ id: n.id, type: n.type, title: n.title || (n.type === 'list' ? 'Checklist' : 'Note'), snippet: (at > 20 ? '…' : '') + text.slice(Math.max(0, at - 20), Math.max(0, at - 20) + 90), share: n.share === 'family' ? 'Family' : n.share === 'me' ? 'Just me' : 'Shared' }); } return out.slice(0, 8); } -module.exports = { handle, search }; +/** Notes and lists shared with the whole family, for the family board. */ +const familyNotes = () => all().filter(n => n.share === 'family' && !n.archived).sort((a, b) => (b.pinned - a.pinned) || b.updated - a.updated).slice(0, 6).map(n => ({ id: n.id, type: n.type, title: n.title || (n.type === 'list' ? 'Checklist' : 'Note'), pinned: n.pinned, total: n.items.length, done: n.items.filter(i => i.done).length, next: (n.items.find(i => !i.done) || {}).text || '', body: n.type === 'note' ? n.body.slice(0, 120) : '' })); +module.exports = { handle, search, familyNotes }; diff --git a/package.json b/package.json index c7c46a1..8458988 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 screentime chat notes chores kidlimits search inbox family; 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 chat notes chores kidlimits search inbox family board; do node tools/$t-test.js || exit 1; done", "audit": "npm audit --omit=dev --audit-level=high" }, "keywords": [], diff --git a/public/board.html b/public/board.html new file mode 100644 index 0000000..5331b9c --- /dev/null +++ b/public/board.html @@ -0,0 +1,81 @@ + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +${t}
`; + +function draw() { + const t0 = ymd(new Date()), t1 = ymd(new Date(Date.now() + 864e5)), d = data; + $('#annc').innerHTML = d.announcement ? `Sign in as ${esc(p.name)}?
`; $('#no').onclick = close; $('#yes').onclick = () => go().catch(e => $('#e').textContent = e.message); return; } + if (!p.pin) { box.innerHTML = head + `${esc(p.name)} has not set a PIN yet. Set one in Settings on your own device, or sign in with your passkey.
`; $('#no').onclick = close; return; } + let pin = ''; const paint = () => { $('#dots').innerHTML = Array.from({ length: Math.max(4, pin.length) }, (_, i) => ``).join(''); }; + box.innerHTML = head + `Enter your PIN