Files
hub/chat.js
T
nmemmertandClaude Sonnet 5.5 0383da87f5
build-container / build (push) Successful in 4m6s
Chores, Family page, genre search, child accounts, wider Search and Notifications center
- Chores tab with repeats, points and a week/month board
- Family page: calendar, chores, shopping, notes, chat and tasks on one start page
- Genre search in the Store, Free Books and LibriVox
- Child accounts: safe content, daily limit and bedtime, ask a parent
- Search now covers notes, chat, contacts, chores, recipes and your books
- Notifications center: bell with a per-person list of chat, reminders and announcements
- Roadmap, README and tests updated

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 11:48:55 -04:00

243 lines
31 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Family Chat: a messenger built into the hub, for the people who have an account here. Direct chats and groups, text, attachments, replies, reactions, edit and delete,
// unread counts, search, mute and archive, live delivery (server-sent events) and phone push when someone is not looking. Everything is kept on this server, in
// DATA_DIR/chat: conversations and messages as JSON, attachments as files. (Messages are stored readable on the server; end-to-end encryption is a later decision.)
const fs = require('fs'), path = require('path'), crypto = require('crypto');
const { pipeline } = require('stream/promises');
const atomicWrite = require('./atomic');
const users = require('./users');
const inbox = require('./inbox');
const push = require('./push');
const cal = require('./cal'); // (its public-address check, for link previews)
const { readBody } = require('./body');
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data'), ROOT = () => path.join(DATA_DIR, 'chat');
const MAX_TEXT = 8000, MAX_FILE = 100 * 1024 * 1024, MAX_FILES = 10, MAX_MEMBERS = 40, MAX_MSGS = 50000, PAGE = 60;
const EMOJI = /^[\p{Extended_Pictographic}‍️⃣\p{Emoji_Modifier}]{1,12}$/u;
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 str = (v, n) => String(v == null ? '' : v).replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, '').slice(0, n);
const newId = () => crypto.randomBytes(8).toString('hex');
const idOk = s => /^[0-9a-f]{16}$/.test(s || '') || /^d:[0-9a-z]+:[0-9a-z]+$/.test(s || '');
// ---- storage (small enough to hold in memory and write whole)
const cvFile = () => path.join(ROOT(), 'convs.json'), msFile = id => path.join(ROOT(), 'msgs', id.replace(/:/g, '_') + '.json'), flFile = () => path.join(ROOT(), 'files.json'), blob = id => path.join(ROOT(), 'files', id);
const read = (f, d) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch { return d; } };
const write = (f, v) => { fs.mkdirSync(path.dirname(f), { recursive: true, mode: 0o700 }); atomicWrite(f, JSON.stringify(v), { mode: 0o600 }); };
let convsC = null, filesC = null; const msgsC = new Map();
const convs = () => convsC || (convsC = read(cvFile(), []));
const saveConvs = () => write(cvFile(), convs());
const files = () => filesC || (filesC = read(flFile(), {}));
const saveFiles = () => write(flFile(), files());
const msgs = id => { let m = msgsC.get(id); if (!m) { m = read(msFile(id), []); msgsC.set(id, m); if (msgsC.size > 60) msgsC.delete(msgsC.keys().next().value); } return m; };
const saveMsgs = id => write(msFile(id), msgs(id));
// each person's own marks: how far they have read, and which chats they muted or archived
const stFile = uid => path.join(users.dirOf(uid), 'chat-state.json');
const stateOf = uid => { const s = read(stFile(uid), {}); return { read: s.read || {}, delivered: s.delivered || {}, muted: s.muted || [], archived: s.archived || [], quiet: s.quiet || { on: false, from: '21:00', to: '07:00' } }; };
const saveState = (uid, s) => write(stFile(uid), s);
/** Is it quiet time for this person now (in their own time zone, from their reminder settings)? Phone alerts wait; the app still shows messages. */
function quietNow(uid) {
const q = stateOf(uid).quiet; if (!q.on) return false; let tz = 'UTC'; try { tz = JSON.parse(fs.readFileSync(path.join(users.dirOf(uid), 'push.json'), 'utf8')).tz || 'UTC'; } catch { /* the default */ }
const hm = push.localNow(tz).hm; return q.from <= q.to ? hm >= q.from && hm < q.to : hm >= q.from || hm < q.to;
}
const nameOf = uid => { const u = users.get(uid); return u ? u.name : 'Someone'; };
const live = uid => { const u = users.get(uid); return !!u && !u.disabled && users.can(u, 'chat'); };
const people = () => users.all().filter(u => !u.disabled && users.can(u, 'chat')).map(u => ({ id: u.id, name: u.name }));
const convOf = (id, me) => { const c = convs().find(x => x.id === id); if (!c || !c.members.includes(me.id)) throw fail(404, 'Conversation not found'); return c; };
const unreadIn = (c, uid, st) => { const r = st.read[c.id] || 0; return msgs(c.id).filter(m => m.ts > r && m.from !== uid && !m.deleted && !m.sys).length; };
const totalUnread = uid => { const st = stateOf(uid); return convs().filter(c => c.members.includes(uid) && !st.muted.includes(c.id) && !st.archived.includes(c.id)).reduce((n, c) => n + unreadIn(c, uid, st), 0); };
const preview = m => m ? (m.deleted ? 'This message was deleted' : m.sys ? str(m.text, 120) : m.poll ? '📊 ' + m.poll.q : m.att && m.att[0] && m.att[0].voice && !m.text ? '🎤 Voice message' : m.text ? str(m.text, 120) : m.att && m.att.length ? '📎 ' + (m.att[0].name || 'Attachment') : '') : '';
// ---- live updates (server-sent events), one stream per open app
const annFile = () => path.join(ROOT(), 'announcement.json');
const lastSeen = new Map(); // user id -> when they last did anything (kept in memory)
const clients = new Map(); // user id -> Set of responses
const online = uid => (clients.get(uid) || new Set()).size > 0;
function emit(uids, ev, per) { for (const uid of uids) for (const res of clients.get(uid) || []) { try { res.write('data: ' + JSON.stringify(per ? per(uid, ev) : ev) + '\n\n'); } catch { /* closed */ } } }
const view = (c, uid, st) => { const last = msgs(c.id).slice(-1)[0], others = c.members.filter(x => x !== uid), o = c.type === 'direct' ? others[0] : null;
return { ttl: c.ttl || 0, ...(o ? { presence: { online: online(o), seen: lastSeen.get(o) || 0 } } : {}), id: c.id, type: c.type, name: c.type === 'group' ? c.name : nameOf(others[0] || uid), members: c.members.map(x => ({ id: x, name: nameOf(x) })), by: c.by, created: c.created, ts: last ? last.ts : c.created, preview: last ? (last.sys ? '' : c.type === 'group' && last.from !== uid ? nameOf(last.from) + ': ' : last.from === uid ? 'You: ' : '') + preview(last) : '', unread: unreadIn(c, uid, st), muted: st.muted.includes(c.id), archived: st.archived.includes(c.id) }; };
/** sent, delivered or read: for what you sent, from what the others have read and received (everyone, in a group). */
const statusOf = (m, c, uid, sts) => { if (m.from !== uid || m.sys) return undefined; const o = c.members.filter(x => x !== uid); if (!o.length) return 'sent'; if (o.every(x => (sts[x].read[c.id] || 0) >= m.ts)) return 'read'; if (o.every(x => (sts[x].delivered[c.id] || 0) >= m.ts)) return 'delivered'; return 'sent'; };
const stsOf = c => Object.fromEntries(c.members.map(x => [x, stateOf(x)]));
const msgView = (m, uid, c, sts) => ({ id: m.id, ts: m.ts, from: m.from, name: m.sys ? '' : nameOf(m.from), out: m.from === uid, text: m.text, att: m.att, quote: m.quote, react: m.react, edited: m.edited, deleted: m.deleted, sys: m.sys, fwd: m.fwd, mentions: m.mentions, exp: m.exp, link: m.link, poll: m.poll ? { ...m.poll, votes: m.votes || {} } : undefined, ...(c && sts ? { status: statusOf(m, c, uid, sts) } : {}) });
/** Someone has received or read the chat up to now: the senders' ticks change. */
function markDelivered(uid, c, also) { const st = stateOf(uid), last = msgs(c.id).slice(-1)[0]; if (!last) return; let ch = false; if ((st.delivered[c.id] || 0) < last.ts) { st.delivered[c.id] = last.ts; ch = true; } if (also && (st.read[c.id] || 0) < last.ts) { st.read[c.id] = last.ts; ch = true; } if (ch) { saveState(uid, st); emit(c.members.filter(x => x !== uid), { t: 'receipt', conv: c.id }); } }
const TTLS = [0, 3600, 86400, 604800], ttlWords = s => s === 3600 ? '1 hour' : s === 86400 ? '1 day' : s === 604800 ? '1 week' : 'off';
/** Disappearing messages that are due go, with their files. */
function purge() {
const now = Date.now(); let filesChanged = false;
for (const c of convs()) { const list = msgs(c.id), due = list.filter(m => m.exp && m.exp <= now); if (!due.length) continue;
for (const m of due) for (const a of m.att || []) { fs.rm(blob(a.id), { force: true }, () => {}); delete files()[a.id]; filesChanged = true; }
const keep = list.filter(m => !(m.exp && m.exp <= now)); list.length = 0; list.push(...keep); c.pins = (c.pins || []).filter(id => keep.some(m => m.id === id)); saveMsgs(c.id); saveConvs(); emit(c.members, { t: 'upd', conv: c.id }); }
if (filesChanged) saveFiles();
}
setInterval(() => { try { purge(); } catch (e) { console.warn('[chat]', e.message); } }, 60e3).unref();
const mentionsIn = (text, c) => c.type !== 'group' ? [] : c.members.filter(uid => { const n = nameOf(uid); return n && new RegExp('(^|\\s)@' + n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '(?![\\w])', 'i').test(text); });
function send(me, b) {
const c = convOf(String(b.conv || ''), me), text = str(b.text, MAX_TEXT).trim(), ids = (Array.isArray(b.files) ? b.files : []).map(String).slice(0, MAX_FILES), att = [];
for (const id of ids) { const f = files()[id]; if (!f || f.conv !== c.id || f.by !== me.id || f.attached) throw fail(400, 'An attachment is not available (upload it again)'); att.push({ id, name: f.name, type: f.type, size: f.size, ...(f.voice ? { voice: true, wave: f.wave, dur: f.dur } : {}) }); }
if (!text && !att.length) throw fail(400, 'Write something or attach a file');
let quote; if (b.quote) { const q = msgs(c.id).find(x => x.id === String(b.quote)); if (q) quote = { id: q.id, from: q.from, name: nameOf(q.from), text: q.deleted ? '' : str(q.text || (q.att && q.att[0] ? q.att[0].name : ''), 200) }; }
return addMsg(me, c, { text, att, quote });
}
/** Put a message in a chat and tell everyone: the live stream, and a phone notification for those who are away. */
function addMsg(me, c, { text, att = [], quote, fwd, sys, poll }) {
const list = msgs(c.id), last = list[list.length - 1], ts = Math.max(Date.now(), last ? last.ts + 1 : 0), m = { id: newId(), ts, from: sys ? null : me.id, text, att: att.length ? att : undefined, quote, fwd, sys: sys || undefined, poll, react: {}, mentions: sys ? undefined : (mentionsIn(text, c).length ? mentionsIn(text, c) : undefined), exp: c.ttl && !sys ? ts + c.ttl * 1000 : undefined };
list.push(m); if (list.length > MAX_MSGS) list.splice(0, list.length - MAX_MSGS); att.forEach(a => { if (files()[a.id]) files()[a.id].attached = true; }); saveMsgs(c.id); if (att.length) saveFiles();
const body = sys ? text : (c.type === 'group' ? me.name + ': ' : '') + (poll ? '📊 ' + poll.q : text ? str(text, 140) : att[0] && att[0].voice ? '🎤 Voice message' : '📎 ' + att[0].name), title = c.type === 'group' ? c.name : me.name;
emit(c.members, { t: 'msg', conv: c.id, id: m.id }, uid => ({ t: 'msg', conv: c.id, id: m.id, out: uid === me.id, sys: !!sys, mention: !!(m.mentions && m.mentions.includes(uid)), name: title, preview: body, unread: totalUnread(uid) }));
for (const uid of c.members) if (online(uid) && (sys || uid !== me.id)) markDelivered(uid, c); // (what an open app has received counts as delivered)
if (!sys) for (const uid of c.members) if (uid !== me.id && live(uid) && (!stateOf(uid).muted.includes(c.id) || (m.mentions && m.mentions.includes(uid)))) inbox.add(uid, { kind: 'chat', title, body: (m.mentions && m.mentions.includes(uid) ? '@ ' : '') + body, url: '#/chat/' + encodeURIComponent(c.id), tag: 'chat:' + c.id });
if (!sys) for (const uid of c.members) if (uid !== me.id && !online(uid) && live(uid) && (!stateOf(uid).muted.includes(c.id) || (m.mentions && m.mentions.includes(uid))) && !quietNow(uid)) push.notify(uid, { title, body: (m.mentions && m.mentions.includes(uid) ? '@ ' : '') + body, url: '#/chat/' + encodeURIComponent(c.id), tag: 'chat:' + c.id }).catch(() => {});
if (!sys) { const url = /https:\/\/[^\s<>"']+/.exec(text || ''); if (url) previewOf(url[0].replace(/[.,;:!?)\]]+$/, '')).then(p => { if (p && msgs(c.id).includes(m) && !m.deleted) { m.link = p; saveMsgs(c.id); emit(c.members, { t: 'upd', conv: c.id, id: m.id }); } }).catch(() => {}); }
return msgView(m, me.id, c, stsOf(c));
}
// ---- link previews: the server reads the page's own title, description and picture (public https addresses only), so nobody's device contacts the site
const metaOf = (html, names) => { for (const n of names) { const m = new RegExp('<meta[^>]+(?:property|name)=["\']' + n + '["\'][^>]*>', 'i').exec(html), c = m && /content=["']([^"']*)["']/i.exec(m[0]) || (m && /content=([^\s>]+)/i.exec(m[0])); if (c && c[1].trim()) return c[1]; } return ''; };
const unent = t => String(t).replace(/&(#x[0-9a-f]+|#\d+|amp|lt|gt|quot|apos|#39);/gi, (x, e) => ({ amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", '#39': "'" }[e.toLowerCase()] || (e[1] === 'x' || e[1] === 'X' ? String.fromCodePoint(parseInt(e.slice(2), 16)) : e[0] === '#' ? String.fromCodePoint(+e.slice(1)) : x))).replace(/\s+/g, ' ').trim();
/** The preview a page describes about itself: { url, site, title, desc, image } or null. */
function parsePreview(html, base) {
const title = unent(metaOf(html, ['og:title', 'twitter:title']) || (/<title[^>]*>([\s\S]*?)<\/title>/i.exec(html) || [])[1] || ''), desc = unent(metaOf(html, ['og:description', 'twitter:description', 'description'])), site = unent(metaOf(html, ['og:site_name']));
let image = metaOf(html, ['og:image', 'og:image:url', 'twitter:image']); try { image = image ? new URL(unent(image), base).href : ''; if (!/^https:/.test(image)) image = ''; } catch { image = ''; }
if (!title && !desc) return null; return { url: base, site: site.slice(0, 80), title: title.slice(0, 200), desc: desc.slice(0, 300), image: image.slice(0, 500) };
}
const previews = new Map();
async function previewOf(raw) {
const hit = previews.get(raw); if (hit && Date.now() - hit.t < 3600e3) return hit.p;
let url; try { url = new URL(raw); } catch { return null; } let r;
for (let hop = 0; hop < 4; hop++) { await cal.assertPublic(url); r = await fetch(url, { headers: { 'user-agent': 'Mozilla/5.0 (compatible; NE-Hub/1.0; link preview)', accept: 'text/html' }, redirect: 'manual', signal: AbortSignal.timeout(6000) }); 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; }
let p = null; if (r.ok && /html/i.test(r.headers.get('content-type') || '')) { const chunks = []; let n = 0; for await (const c of r.body) { n += c.length; chunks.push(c); if (n > 400000) break; } p = parsePreview(Buffer.concat(chunks).toString('utf8'), url.href); }
previews.set(raw, { t: Date.now(), p }); if (previews.size > 200) previews.delete(previews.keys().next().value); return p;
}
async function handle(req, res) {
const u = new URL(req.url, 'http://x'), p = (() => { try { return decodeURIComponent(u.pathname.slice('/api/chat'.length)) || '/'; } catch { return '/'; } })(), me = users.current(), q = u.searchParams; // (a chat id has colons, which a browser writes as %3A)
try {
let m; lastSeen.set(me.id, Date.now()); purge();
if (p === '/status') return json(res, 200, { ok: true, me: { id: me.id, name: me.name }, unread: totalUnread(me.id) });
if (p === '/people') return json(res, 200, people().filter(x => x.id !== me.id));
if (p === '/events') {
res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-store', connection: 'keep-alive', 'x-accel-buffering': 'no' }); res.write(': hello\n\n');
const set = clients.get(me.id) || new Set(); set.add(res); clients.set(me.id, set);
const ka = setInterval(() => { try { res.write(': \n\n'); } catch { /* closed */ } }, 25000);
res.on('close', () => { clearInterval(ka); set.delete(res); if (!set.size) clients.delete(me.id); lastSeen.set(me.id, Date.now()); }); return;
}
if (p === '/convs' && req.method === 'GET') { const st = stateOf(me.id); return json(res, 200, { convs: convs().filter(c => c.members.includes(me.id)).map(c => view(c, me.id, st)).sort((a, b) => b.ts - a.ts), unread: totalUnread(me.id) }); }
if (p === '/new' && req.method === 'POST') {
const b = JSON.parse(await readBody(req, 1e4) || '{}'); let c;
if (b.type === 'group') {
const name = str(b.name, 60).trim(); if (!name) throw fail(400, 'Give the group a name'); const mem = [...new Set([me.id, ...(Array.isArray(b.members) ? b.members : [])].map(String))].filter(live); if (mem.length < 2) throw fail(400, 'Add at least one other person'); if (mem.length > MAX_MEMBERS) throw fail(400, 'That is a lot of people for one group');
c = { id: newId(), type: 'group', name, members: mem, by: me.id, created: Date.now() }; convs().push(c);
} else {
const other = String(b.with || ''); if (other === me.id || !live(other)) throw fail(400, 'Choose someone from the family'); const id = 'd:' + [me.id, other].sort().join(':'); c = convs().find(x => x.id === id);
if (!c) { c = { id, type: 'direct', members: [me.id, other].sort(), by: me.id, created: Date.now() }; convs().push(c); }
}
saveConvs(); emit(c.members, { t: 'conv', conv: c.id }); return json(res, 200, { conv: view(c, me.id, stateOf(me.id)) });
}
if (p === '/conv/update' && req.method === 'POST') { // rename, add or remove people, leave (groups)
const b = JSON.parse(await readBody(req, 1e4) || '{}'), c = convOf(String(b.id || ''), me); if (c.type !== 'group') throw fail(400, 'Only groups can be changed');
const isOwner = c.by === me.id || me.role === 'admin'; const before = [...c.members];
if (b.name !== undefined) { const n = str(b.name, 60).trim(); if (!n) throw fail(400, 'Give the group a name'); c.name = n; }
for (const id of Array.isArray(b.add) ? b.add : []) if (live(String(id)) && !c.members.includes(String(id)) && c.members.length < MAX_MEMBERS) c.members.push(String(id));
for (const id of Array.isArray(b.remove) ? b.remove : []) { if (!isOwner && String(id) !== me.id) throw fail(403, 'Only the person who made the group, or an admin, can remove others'); c.members = c.members.filter(x => x !== String(id)); }
if (b.leave) c.members = c.members.filter(x => x !== me.id);
if (!c.members.length) convsC = convs().filter(x => x !== c); else if (c.by && !c.members.includes(c.by)) c.by = c.members[0];
saveConvs(); emit([...new Set([...before, ...c.members])], { t: 'conv', conv: c.id }); return json(res, 200, c.members.includes(me.id) ? { conv: view(c, me.id, stateOf(me.id)) } : { left: true });
}
if ((m = /^\/conv\/(\w[\w:]*)$/.exec(p)) && req.method === 'GET') {
const c = convOf(m[1], me), all = msgs(c.id), before = +q.get('before') || Infinity, eligible = all.filter(x => x.ts < before), page = eligible.slice(-PAGE), sts = stsOf(c); markDelivered(me.id, c);
return json(res, 200, { conv: view(c, me.id, stateOf(me.id)), pinned: (c.pins || []).map(id => all.find(x => x.id === id)).filter(x => x && !x.deleted).map(x => msgView(x, me.id, c, sts)), msgs: page.map(x => msgView(x, me.id, c, sts)), more: eligible.length > page.length, names: Object.fromEntries(c.members.map(x => [x, nameOf(x)])) });
}
if (p === '/send' && req.method === 'POST') return json(res, 200, { msg: send(me, JSON.parse(await readBody(req, 5e4) || '{}')) });
if (p === '/upload' && req.method === 'POST') { // the body is the file; ?conv= says which chat it is for
const c = convOf(String(q.get('conv') || ''), me); if ((+req.headers['content-length'] || 0) > MAX_FILE) throw fail(413, 'That file is too large (100 MB at most)');
const id = newId(), type = /^[\w.+-]+\/[\w.+-]+$/.test(String(req.headers['content-type'] || '').split(';')[0].trim()) ? String(req.headers['content-type']).split(';')[0].trim().toLowerCase() : 'application/octet-stream', name = path.basename(str(q.get('name'), 160)).replace(/[\\/:*?"<>|]+/g, '_') || 'file';
fs.mkdirSync(path.dirname(blob(id)), { recursive: true, mode: 0o700 }); let n = 0; req.on('data', d => { n += d.length; if (n > MAX_FILE) req.destroy(fail(413, 'That file is too large')); });
try { await pipeline(req, fs.createWriteStream(blob(id) + '.part', { mode: 0o600 })); } catch (e) { fs.rm(blob(id) + '.part', { force: true }, () => {}); throw e.code === 413 ? e : fail(400, 'The upload did not finish'); }
fs.renameSync(blob(id) + '.part', blob(id)); const voice = q.get('voice') === '1' && /^audio\//.test(type), wave = voice ? String(q.get('wave') || '').split(',').map(Number).filter(v => Number.isFinite(v)).slice(0, 64).map(v => Math.max(0, Math.min(100, Math.round(v)))) : undefined, dur = voice ? Math.max(0, Math.min(900, Math.round(+q.get('dur') || 0))) : undefined;
files()[id] = { conv: c.id, by: me.id, name, type, size: n, ts: Date.now(), ...(voice ? { voice: true, wave, dur } : {}) }; saveFiles(); return json(res, 200, { id, name, type, size: n, voice: voice || undefined });
}
if ((m = /^\/file\/([0-9a-f]{16})$/.exec(p)) && req.method === 'GET') {
const f = files()[m[1]]; if (!f) throw fail(404, 'Not found'); convOf(f.conv, me); let st; try { st = fs.statSync(blob(m[1])); } catch { throw fail(404, 'Not found'); }
const inline = /^(image\/(png|jpeg|gif|webp|heic|heif|avif)|audio\/|video\/|application\/pdf$)/.test(f.type), h = { 'content-type': inline ? f.type : 'application/octet-stream', 'x-content-type-options': 'nosniff', 'cache-control': 'private, max-age=3600', 'accept-ranges': 'bytes', 'content-security-policy': "default-src 'none'; sandbox" };
if (!inline || q.get('dl') === '1') h['content-disposition'] = `attachment; filename="${f.name.replace(/[^\x20-\x7e]/g, '_').replace(/"/g, '')}"; filename*=UTF-8''${encodeURIComponent(f.name)}`;
const rg = /^bytes=(\d*)-(\d*)$/.exec(req.headers.range || '');
if (rg && (rg[1] || rg[2])) { const a = rg[1] ? +rg[1] : Math.max(0, st.size - +rg[2]), b = rg[1] && rg[2] ? Math.min(+rg[2], st.size - 1) : st.size - 1; if (a > b || a >= st.size) { res.writeHead(416, { 'content-range': 'bytes */' + st.size }); return res.end(); } res.writeHead(206, { ...h, 'content-range': `bytes ${a}-${b}/${st.size}`, 'content-length': b - a + 1 }); return fs.createReadStream(blob(m[1]), { start: a, end: b }).pipe(res); }
res.writeHead(200, { ...h, 'content-length': st.size }); return fs.createReadStream(blob(m[1])).pipe(res);
}
if (p === '/react' && req.method === 'POST') {
const b = JSON.parse(await readBody(req, 1e4) || '{}'), c = convOf(String(b.conv || ''), me), x = msgs(c.id).find(y => y.id === String(b.msg)); if (!x || x.deleted) throw fail(404, 'Message not found');
const e = String(b.emoji || ''); if (e && !EMOJI.test(e)) throw fail(400, 'That is not an emoji'); x.react = x.react || {}; if (e) x.react[me.id] = e; else delete x.react[me.id]; saveMsgs(c.id);
emit(c.members, { t: 'upd', conv: c.id, id: x.id }); return json(res, 200, { ok: true });
}
if ((p === '/edit' || p === '/delete') && req.method === 'POST') {
const b = JSON.parse(await readBody(req, 2e4) || '{}'), c = convOf(String(b.conv || ''), me), x = msgs(c.id).find(y => y.id === String(b.msg)); if (!x) throw fail(404, 'Message not found'); if (x.from !== me.id) throw fail(403, 'You can only change your own messages'); if (x.deleted) throw fail(400, 'That message was deleted');
if (p === '/edit') { const t = str(b.text, MAX_TEXT).trim(); if (!t) throw fail(400, 'Write something'); if (Date.now() - x.ts > 48 * 3600e3) throw fail(400, 'Messages can be edited for two days'); x.text = t; x.edited = true; }
else { x.deleted = true; x.text = ''; for (const a of x.att || []) { fs.rm(blob(a.id), { force: true }, () => {}); delete files()[a.id]; } x.att = undefined; x.react = {}; saveFiles(); }
saveMsgs(c.id); emit(c.members, { t: 'upd', conv: c.id, id: x.id }); return json(res, 200, { ok: true });
}
if (p === '/read' && req.method === 'POST') { const b = JSON.parse(await readBody(req, 1e4) || '{}'), c = convOf(String(b.id || ''), me), st = stateOf(me.id), last = msgs(c.id).slice(-1)[0]; st.read[c.id] = last ? last.ts : Date.now(); st.delivered[c.id] = Math.max(st.delivered[c.id] || 0, st.read[c.id]); saveState(me.id, st); emit([me.id], { t: 'read', conv: c.id, unread: totalUnread(me.id) }); emit(c.members.filter(x => x !== me.id), { t: 'receipt', conv: c.id }); return json(res, 200, { ok: true, unread: totalUnread(me.id) }); }
if ((p === '/conv/mute' || p === '/conv/archive') && req.method === 'POST') { const b = JSON.parse(await readBody(req, 1e4) || '{}'), c = convOf(String(b.id || ''), me), st = stateOf(me.id), k = p.endsWith('mute') ? 'muted' : 'archived'; st[k] = st[k].filter(x => x !== c.id); if (b.on) st[k].push(c.id); saveState(me.id, st); emit([me.id], { t: 'conv', conv: c.id, unread: totalUnread(me.id) }); return json(res, 200, { ok: true }); }
if (p === '/announcement' && req.method === 'GET') return json(res, 200, read(annFile(), null));
if (p === '/announcement' && req.method === 'POST') { // the family board on Home: an admin posts or clears it
if (me.role !== 'admin') throw fail(403, 'Only an admin can change the announcement'); const b = JSON.parse(await readBody(req, 1e4) || '{}'), text = str(b.text, 300).trim();
if (!text) { fs.rmSync(annFile(), { force: true }); emit(people().map(x => x.id), { t: 'announce' }); return json(res, 200, null); }
const a = { id: newId(), text, by: me.name, at: Date.now() }; write(annFile(), a); for (const x of people()) if (x.id !== me.id) inbox.add(x.id, { kind: 'announce', title: 'Family announcement', body: text, url: '#/family' }); emit(people().map(x => x.id), { t: 'announce' }); return json(res, 200, a);
}
if (p === '/prefs' && req.method === 'GET') return json(res, 200, { quiet: stateOf(me.id).quiet });
if (p === '/prefs' && req.method === 'POST') { const b = JSON.parse(await readBody(req, 1e4) || '{}'), st = stateOf(me.id), hm = /^([01]\d|2[0-3]):[0-5]\d$/; if (b.quiet) st.quiet = { on: !!b.quiet.on, from: hm.test(b.quiet.from) ? b.quiet.from : st.quiet.from, to: hm.test(b.quiet.to) ? b.quiet.to : st.quiet.to }; saveState(me.id, st); return json(res, 200, { quiet: st.quiet }); }
if (p === '/poll' && req.method === 'POST') {
const b = JSON.parse(await readBody(req, 1e4) || '{}'), c = convOf(String(b.conv || ''), me), q = str(b.question, 200).trim(), opts = (Array.isArray(b.options) ? b.options : []).map(o => str(o, 100).trim()).filter(Boolean).slice(0, 8);
if (!q) throw fail(400, 'Ask a question'); if (opts.length < 2) throw fail(400, 'Give at least two choices');
const msg = addMsg(me, c, { text: '', poll: { q, options: opts.map((t, i) => ({ id: String(i), text: t })), multi: !!b.multi } }); return json(res, 200, { msg });
}
if (p === '/vote' && req.method === 'POST') {
const b = JSON.parse(await readBody(req, 1e4) || '{}'), c = convOf(String(b.conv || ''), me), x = msgs(c.id).find(y => y.id === String(b.msg)); if (!x || !x.poll || x.deleted) throw fail(404, 'Poll not found'); if (x.poll.closed) throw fail(400, 'This poll is closed');
const ids = [...new Set((Array.isArray(b.opts) ? b.opts : []).map(String))].filter(id => x.poll.options.some(o => o.id === id)); if (!x.poll.multi) ids.splice(1);
x.votes = x.votes || {}; if (ids.length) x.votes[me.id] = ids; else delete x.votes[me.id]; saveMsgs(c.id); emit(c.members, { t: 'upd', conv: c.id, id: x.id }); return json(res, 200, { ok: true });
}
if (p === '/poll/close' && req.method === 'POST') { const b = JSON.parse(await readBody(req, 1e4) || '{}'), c = convOf(String(b.conv || ''), me), x = msgs(c.id).find(y => y.id === String(b.msg)); if (!x || !x.poll) throw fail(404, 'Poll not found'); if (x.from !== me.id) throw fail(403, 'Only the person who asked can close it'); x.poll.closed = true; saveMsgs(c.id); emit(c.members, { t: 'upd', conv: c.id, id: x.id }); return json(res, 200, { ok: true }); }
if (p === '/typing' && req.method === 'POST') { const c = convOf(String(JSON.parse(await readBody(req, 1e4) || '{}').conv || ''), me); emit(c.members.filter(x => x !== me.id), { t: 'typing', conv: c.id, from: me.id, name: me.name }); return json(res, 200, { ok: true }); }
if (p === '/conv/ttl' && req.method === 'POST') { // disappearing messages: from now on, new messages go after this long
const b = JSON.parse(await readBody(req, 1e4) || '{}'), c = convOf(String(b.id || ''), me), ttl = +b.ttl || 0; if (!TTLS.includes(ttl)) throw fail(400, 'Choose off, 1 hour, 1 day or 1 week');
if ((c.ttl || 0) !== ttl) { c.ttl = ttl; saveConvs(); addMsg(me, c, { sys: true, text: ttl ? `${me.name} set messages to disappear after ${ttlWords(ttl)}` : `${me.name} turned off disappearing messages` }); }
emit(c.members, { t: 'conv', conv: c.id }); return json(res, 200, { ok: true, ttl });
}
if (p === '/pin' && req.method === 'POST') {
const b = JSON.parse(await readBody(req, 1e4) || '{}'), c = convOf(String(b.conv || ''), me), x = msgs(c.id).find(y => y.id === String(b.msg)); if (!x || x.deleted || x.sys) throw fail(404, 'Message not found');
c.pins = (c.pins || []).filter(id => id !== x.id); if (b.on) { c.pins.push(x.id); if (c.pins.length > 5) c.pins.shift(); } saveConvs(); emit(c.members, { t: 'upd', conv: c.id, id: x.id }); return json(res, 200, { ok: true, pins: c.pins });
}
if (p === '/forward' && req.method === 'POST') { // a copy of a message (and its files) into another chat
const b = JSON.parse(await readBody(req, 1e4) || '{}'), from = convOf(String(b.from || ''), me), to = convOf(String(b.to || ''), me), x = msgs(from.id).find(y => y.id === String(b.msg)); if (!x || x.deleted || x.sys) throw fail(404, 'Message not found');
const att = (x.att || []).map(a => { const id = newId(); try { fs.copyFileSync(blob(a.id), blob(id)); } catch { return null; } files()[id] = { conv: to.id, by: me.id, name: a.name, type: a.type, size: a.size, ts: Date.now(), attached: true }; return { id, name: a.name, type: a.type, size: a.size, ...(a.voice ? { voice: true, wave: a.wave, dur: a.dur } : {}) }; }).filter(Boolean);
if (att.length) saveFiles(); return json(res, 200, { msg: addMsg(me, to, { text: x.text || '', att, fwd: { name: nameOf(x.from) } }) });
}
if (p === '/search' && req.method === 'GET') {
const needle = str(q.get('q'), 100).trim().toLowerCase(); if (needle.length < 2) return json(res, 200, { results: [] }); const out = [], st = stateOf(me.id);
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 json(res, 200, { results: out.sort((a, b) => b.msg.ts - a.msg.ts).slice(0, 40) });
}
return json(res, 404, { error: 'Not found' });
} catch (e) { if (res.headersSent) return res.destroy(); return json(res, e.code >= 400 && e.code < 600 ? e.code : e.status === 413 ? 413 : 400, { error: e instanceof SyntaxError ? 'Bad request' : e.message }); }
}
/** A child asks the parents (the admins) for something: a message in a direct chat with each of them. Returns how many were told. */
function askParents(me, text) {
let n = 0;
for (const a of users.all().filter(u => u.role === 'admin' && !u.disabled && u.id !== me.id)) {
const id = 'd:' + [me.id, a.id].sort().join(':'); let c = convs().find(x => x.id === id); if (!c) { c = { id, type: 'direct', members: [me.id, a.id].sort(), by: me.id, created: Date.now() }; convs().push(c); saveConvs(); }
addMsg(me, c, { text: str(text, 300) }); n++;
}
return n;
}
/** Messages in this person's chats that mention the words. */
function searchFor(me, q) {
const needle = q.toLowerCase(), out = [], st = stateOf(me.id);
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 };