Files
hub/notes.js
T

142 lines
17 KiB
JavaScript

// Notes and lists: notes and checklists that are private, shared with the whole family, or shared with chosen people (who can be given view-only access).
// Checklist items are changed one at a time (tick, add, edit, move, remove), so two people ticking things at once never overwrite each other.
// Notes also have tags, a reminder, photos and files, a short edit history and a trash (30 days). Kept in DATA_DIR/notes.json, files in DATA_DIR/note-files.
const fs = require('fs'), path = require('path'), crypto = require('crypto');
const atomicWrite = require('./atomic');
const users = require('./users');
const { readBody } = require('./body');
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data'), FILE = () => path.join(DATA_DIR, 'notes.json'), FILES = () => path.join(DATA_DIR, 'note-files');
const COLORS = ['', 'red', 'orange', 'yellow', 'green', 'teal', 'blue', 'purple', 'pink'];
const MAX_NOTES = 3000, MAX_ITEMS = 300, MAX_BODY = 50000, MAX_FILE = 8e6, MAX_FILES = 12, MAX_TAGS = 10, MAX_HIST = 20, TRASH_DAYS = 30;
const FILE_TYPES = { 'image/png': 1, 'image/jpeg': 1, 'image/gif': 1, 'image/webp': 1, 'application/pdf': 1, 'text/plain': 1 }; // shown in the browser; anything else is only ever downloaded
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, extra) => Object.assign(new Error(message), { code, extra });
const str = (v, n) => String(v == null ? '' : v).replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, '').slice(0, n);
const newId = () => crypto.randomBytes(6).toString('hex');
const DAY = /^\d{4}-\d{2}-\d{2}$/;
let cache = null;
const all = () => cache || (cache = (() => { try { const j = JSON.parse(fs.readFileSync(FILE(), 'utf8')); return Array.isArray(j.notes) ? j.notes : []; } catch { return []; } })());
const save = () => { fs.mkdirSync(path.dirname(FILE()), { recursive: true }); atomicWrite(FILE(), JSON.stringify({ notes: all() }), { mode: 0o600 }); };
const rmFiles = n => { try { fs.rmSync(path.join(FILES(), n.id), { recursive: true, force: true }); } catch { /* already gone */ } };
/** Notes in the trash for more than a month are removed for good. */
function purge() { const cut = Date.now() - TRASH_DAYS * 864e5, gone = all().filter(n => n.deleted && n.deleted < cut); if (!gone.length) return; gone.forEach(rmFiles); cache = all().filter(n => !gone.includes(n)); save(); }
const live = uid => { const u = users.get(uid); return !!u && !u.disabled && users.can(u, 'notes'); };
const sees = (n, me) => n.owner === me.id || (n.share === 'family' && users.can(me, 'notes')) || (Array.isArray(n.share) && n.share.includes(me.id));
const canEdit = (n, me) => n.owner === me.id || me.role === 'admin' || (n.share === 'family' ? !n.familyRo : !(n.ro || []).includes(me.id));
const nameOf = uid => { const u = users.get(uid); return u ? u.name : 'Someone'; };
const view = (n, me) => ({ id: n.id, type: n.type, title: n.title, body: n.body, items: n.items, owner: n.owner, ownerName: nameOf(n.owner), mine: n.owner === me.id, share: n.share, sharedWith: Array.isArray(n.share) ? n.share.map(id => ({ id, name: nameOf(id), ro: (n.ro || []).includes(id) })) : [],
familyRo: !!n.familyRo, canEdit: canEdit(n, me), color: n.color, pinned: n.pinned, archived: n.archived, deleted: n.deleted || 0, tags: n.tags || [], remind: n.reminded ? 0 : n.remind || 0, files: n.files || [], history: (n.history || []).length,
created: n.created, updated: n.updated, updatedByName: n.updatedBy ? nameOf(n.updatedBy) : '', rev: n.rev });
const cleanShare = (v, me) => v === 'family' ? 'family' : Array.isArray(v) ? [...new Set(v.map(String))].filter(id => id !== me.id && live(id)).slice(0, 30) : 'me';
const cleanTags = v => [...new Set((Array.isArray(v) ? v : []).map(t => str(t, 24).trim().toLowerCase().replace(/^#+/, '').replace(/\s+/g, '-')).filter(Boolean))].slice(0, MAX_TAGS);
const touch = (n, me) => { n.updated = Date.now(); n.updatedBy = me.id; n.rev = (n.rev || 0) + 1; };
const find = (id, me, ownerOnly, edit) => {
const n = all().find(x => x.id === id); if (!n || !sees(n, me)) throw fail(404, 'Note not found');
if (ownerOnly && n.owner !== me.id && me.role !== 'admin') throw fail(403, 'Only the person who made it, or an admin, can do that');
if (edit && !canEdit(n, me)) throw fail(403, 'You can read this but not change it');
return n;
};
/** Keep the previous title and text so a bad edit can be undone. One snapshot per person per couple of minutes, so typing does not fill the history. */
function snapshot(n, me, force) {
const last = (n.history || [])[0]; if (n.type !== 'note' || (!n.body && !n.title)) return; if (!force && last && last.by === me.id && Date.now() - last.t < 120e3) return;
if (last && last.title === n.title && last.body === n.body) return;
n.history = [{ t: Date.now(), by: me.id, title: n.title, body: n.body }, ...(n.history || [])].slice(0, MAX_HIST);
}
const tplFile = () => path.join(users.dir(), 'note-templates.json');
const tplLoad = () => { try { const j = JSON.parse(fs.readFileSync(tplFile(), 'utf8')); return Array.isArray(j) ? j : []; } catch { return []; } };
const readRaw = (req, max) => new Promise((ok, no) => { const c = []; let n = 0, done = false; req.on('data', d => { if (done) return; n += d.length; if (n > max) { done = true; const e = new Error('That file is too large (8 MB at most)'); e.status = 413; return no(e); } c.push(d); }); req.on('end', () => { if (!done) ok(Buffer.concat(c)); }); req.on('error', e => { if (!done) no(e); }); });
async function handle(req, res) {
const u = new URL(req.url, 'http://x'), p = u.pathname.slice('/api/notes'.length) || '/', me = users.current();
try {
let m; purge();
if (p === '/' && req.method === 'GET') return json(res, 200, { notes: all().filter(n => sees(n, me) && (!n.deleted || n.owner === me.id || me.role === 'admin')).sort((a, b) => (b.pinned - a.pinned) || b.updated - a.updated).map(n => view(n, me)), people: users.all().filter(x => x.id !== me.id && live(x.id)).map(x => ({ id: x.id, name: x.name })) });
if (p === '/templates' && req.method === 'GET') return json(res, 200, { templates: tplLoad() });
if (p === '/templates' && req.method === 'POST') {
const b = JSON.parse(await readBody(req, 5e4) || '{}'), l = tplLoad(), name = str(b.name, 60).trim(), items = (Array.isArray(b.items) ? b.items : []).map(t => str(t, 300).trim()).filter(Boolean).slice(0, MAX_ITEMS);
if (!name || !items.length) throw fail(400, 'A template needs a name and some items'); if (l.length >= 30) throw fail(400, 'That is plenty of templates');
const t = { id: newId(), name, items }; l.push(t); atomicWrite(tplFile(), JSON.stringify(l), { mode: 0o600 }); return json(res, 200, t);
}
if (p === '/templates/delete' && req.method === 'POST') { const { id } = JSON.parse(await readBody(req, 1e4) || '{}'); atomicWrite(tplFile(), JSON.stringify(tplLoad().filter(t => t.id !== id)), { mode: 0o600 }); return json(res, 200, { ok: true }); }
if (p === '/empty-trash' && req.method === 'POST') { const mine = all().filter(n => n.deleted && (n.owner === me.id || me.role === 'admin')); mine.forEach(rmFiles); cache = all().filter(n => !mine.includes(n)); save(); return json(res, 200, { removed: mine.length }); }
if (p === '/' && req.method === 'POST') { // create, or change the note's own fields
const b = JSON.parse(await readBody(req, MAX_BODY + 5e4) || '{}');
if (!b.id) {
if (all().length >= MAX_NOTES) throw fail(400, 'That is plenty of notes');
const type = b.type === 'list' ? 'list' : 'note', n = { id: newId(), type, title: str(b.title, 120).trim(), body: type === 'note' ? str(b.body, MAX_BODY) : '', items: type === 'list' ? (Array.isArray(b.items) ? b.items : []).map(t => str(t, 300).trim()).filter(Boolean).slice(0, MAX_ITEMS).map(text => ({ id: newId(), text, done: false })) : [], owner: me.id, share: cleanShare(b.share === undefined ? 'family' : b.share, me), tags: cleanTags(b.tags), color: COLORS.includes(b.color) ? b.color : '', pinned: false, archived: false, created: Date.now(), updated: Date.now(), updatedBy: me.id, rev: 1 };
all().push(n); save(); return json(res, 200, view(n, me));
}
const ownerOnly = b.share !== undefined || b.ro !== undefined || b.familyRo !== undefined || b.deleted !== undefined, n = find(String(b.id), me, ownerOnly, b.deleted === undefined);
if (b.rev !== undefined && +b.rev !== n.rev && (b.title !== undefined || b.body !== undefined)) throw fail(409, 'Someone else changed this note while you were editing', { note: view(n, me) });
if (b.restore !== undefined) { const h = (n.history || [])[+b.restore]; if (!h) throw fail(404, 'That version is gone'); snapshot(n, me, true); n.title = h.title; n.body = h.body; }
if ((b.title !== undefined && str(b.title, 120).trim() !== n.title) || (b.body !== undefined && n.type === 'note' && str(b.body, MAX_BODY) !== n.body)) snapshot(n, me);
if (b.title !== undefined) n.title = str(b.title, 120).trim(); if (b.body !== undefined && n.type === 'note') n.body = str(b.body, MAX_BODY);
if (b.share !== undefined) n.share = cleanShare(b.share, me);
if (b.ro !== undefined) n.ro = (Array.isArray(b.ro) ? b.ro : []).map(String).filter(id => Array.isArray(n.share) && n.share.includes(id)); if (b.familyRo !== undefined) n.familyRo = !!b.familyRo;
if (b.color !== undefined && COLORS.includes(b.color)) n.color = b.color;
if (b.pinned !== undefined) n.pinned = !!b.pinned; if (b.archived !== undefined) n.archived = !!b.archived;
if (b.tags !== undefined) n.tags = cleanTags(b.tags);
if (b.remind !== undefined) { const t = +b.remind; if (t && (!Number.isFinite(t) || t < Date.now() - 60e3 || t > Date.now() + 400 * 864e5)) throw fail(400, 'Pick a time in the future'); n.remind = t || 0; n.remindFor = me.id; n.reminded = false; }
if (b.deleted === false) n.deleted = 0;
touch(n, me); save(); return json(res, 200, view(n, me));
}
if ((m = /^\/([0-9a-f]{12})$/.exec(p)) && req.method === 'DELETE') { // into the trash first; from the trash it is gone for good
const n = find(m[1], me, true);
if (n.deleted || u.searchParams.get('forever') === '1') { rmFiles(n); cache = all().filter(x => x !== n); } else { n.deleted = Date.now(); n.pinned = false; touch(n, me); }
save(); return json(res, 200, { ok: true });
}
if ((m = /^\/([0-9a-f]{12})\/history$/.exec(p)) && req.method === 'GET') { const n = find(m[1], me); return json(res, 200, { versions: (n.history || []).map((h, i) => ({ i, t: h.t, by: nameOf(h.by), title: h.title, snippet: h.body.slice(0, 160) })) }); }
if ((m = /^\/([0-9a-f]{12})\/files$/.exec(p)) && req.method === 'POST') { // one photo or file, sent as the raw request body
const n = find(m[1], me, false, true); if ((n.files || []).length >= MAX_FILES) throw fail(400, 'That is plenty of files on one note');
const type = String(req.headers['content-type'] || '').split(';')[0].trim().toLowerCase(), name = str(u.searchParams.get('name'), 120).replace(/[\\/]/g, '_').trim() || 'file';
const data = await readRaw(req, MAX_FILE); if (!data.length) throw fail(400, 'That file is empty');
const f = { id: newId(), name, type: FILE_TYPES[type] ? type : 'application/octet-stream', size: data.length, by: me.id, at: Date.now() };
fs.mkdirSync(path.join(FILES(), n.id), { recursive: true, mode: 0o700 }); atomicWrite(path.join(FILES(), n.id, f.id), data, { mode: 0o600 });
n.files = [...(n.files || []), f]; touch(n, me); save(); return json(res, 200, view(n, me));
}
if ((m = /^\/([0-9a-f]{12})\/files\/([0-9a-f]{12})$/.exec(p)) && req.method === 'GET') {
const n = find(m[1], me), f = (n.files || []).find(x => x.id === m[2]); if (!f) throw fail(404, 'File not found');
let data; try { data = fs.readFileSync(path.join(FILES(), n.id, f.id)); } catch { throw fail(404, 'File not found'); }
res.writeHead(200, { 'content-type': f.type, 'content-length': data.length, 'cache-control': 'private, max-age=3600', 'x-content-type-options': 'nosniff', 'content-security-policy': "default-src 'none'; img-src 'self'; style-src 'unsafe-inline'", 'content-disposition': (/^image\//.test(f.type) || f.type === 'application/pdf' ? 'inline' : 'attachment') + `; filename="${f.name.replace(/[^\w.\- ]/g, '_')}"` });
return res.end(data);
}
if ((m = /^\/([0-9a-f]{12})\/files\/delete$/.exec(p)) && req.method === 'POST') {
const n = find(m[1], me, false, true), { id } = JSON.parse(await readBody(req, 1e4) || '{}'); n.files = (n.files || []).filter(f => f.id !== id);
try { fs.rmSync(path.join(FILES(), n.id, String(id).replace(/[^0-9a-f]/g, '')), { force: true }); } catch { /* already gone */ } touch(n, me); save(); return json(res, 200, view(n, me));
}
if ((m = /^\/([0-9a-f]{12})\/items$/.exec(p)) && req.method === 'POST') { // one change to a checklist
const n = find(m[1], me, false, true); if (n.type !== 'list') throw fail(400, 'That is not a checklist'); const b = JSON.parse(await readBody(req, 2e4) || '{}'), it = id => n.items.find(x => x.id === String(id));
if (b.text !== undefined && !b.edit) { const t = str(b.text, 300).trim(); if (!t) throw fail(400, 'Write the item'); if (n.items.length >= MAX_ITEMS) throw fail(400, 'That is a long list'); const x = { id: newId(), text: t, done: false, addedBy: me.name }; if (DAY.test(b.due || '')) x.due = b.due; n.items.push(x); }
else if (b.toggle) { const x = it(b.toggle); if (!x) throw fail(404, 'Item not found'); x.done = !!b.done; if (x.done) { x.by = me.id; x.byName = me.name; x.at = Date.now(); } else { delete x.by; delete x.byName; delete x.at; } }
else if (b.edit) { const x = it(b.edit), t = str(b.text, 300).trim(); if (!x || !t) throw fail(400, 'Write the item'); x.text = t; }
else if (b.setDue) { const x = it(b.setDue); if (!x) throw fail(404, 'Item not found'); if (DAY.test(b.due || '')) x.due = b.due; else delete x.due; }
else if (b.indent) { const x = it(b.indent); if (!x) throw fail(404, 'Item not found'); const i = n.items.indexOf(x); if (b.level && i > 0) x.indent = 1; else delete x.indent; }
else if (b.move) { const x = it(b.move); if (!x) throw fail(404, 'Item not found'); n.items = n.items.filter(y => y !== x); const at = b.before ? n.items.findIndex(y => y.id === String(b.before)) : -1; n.items.splice(at < 0 ? n.items.length : at, 0, x); if (n.items[0] === x) delete x.indent; }
else if (b.remove) n.items = n.items.filter(x => x.id !== String(b.remove));
else if (b.clearDone) n.items = n.items.filter(x => !x.done);
else if (b.uncheckAll) n.items.forEach(x => { x.done = false; delete x.by; delete x.byName; delete x.at; });
else throw fail(400, 'Nothing to do');
touch(n, me); save(); return json(res, 200, view(n, me));
}
return json(res, 404, { error: 'Not found' });
} catch (e) { return json(res, e.code >= 400 && e.code < 600 ? e.code : e.status === 413 ? 413 : 400, { error: e instanceof SyntaxError ? 'Bad request' : e.message, ...(e.extra || {}) }); }
}
/** Notes and lists this person can see that mention the words (or a tag): [{ id, type, title, snippet, share }]. */
function search(me, q) {
const needle = q.toLowerCase(), out = [];
for (const n of all()) { if (!sees(n, me) || n.archived || n.deleted) continue; const text = n.type === 'list' ? n.items.map(i => i.text).join(' · ') : n.body, hay = (n.title + ' ' + text + ' ' + (n.tags || []).map(t => '#' + t).join(' ')).toLowerCase(); if (!hay.includes(needle)) continue;
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);
}
/** Notes and lists shared with the whole family, for the family board. */
const familyNotes = () => all().filter(n => n.share === 'family' && !n.archived && !n.deleted).sort((a, b) => (b.pinned - a.pinned) || b.updated - a.updated).slice(0, 20).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 || '', items: n.type === 'list' ? n.items.slice(0, 80).map(i => ({ text: i.text, done: !!i.done, by: i.addedBy || '' })) : [], body: n.type === 'note' ? n.body.slice(0, 400) : '' }));
/** Reminders whose time has come (each fires once): [{ uid, id, title }]. Called every minute by the push timer. */
function dueReminders(now = Date.now()) {
const out = []; for (const n of all()) if (n.remind && !n.reminded && !n.deleted && n.remind <= now) { n.reminded = true; out.push({ uid: n.remindFor || n.owner, id: n.id, title: n.title || (n.type === 'list' ? 'Checklist' : 'Note') }); }
if (out.length) save(); return out;
}
module.exports = { handle, search, familyNotes, dueReminders };