'use strict'; // ---------- the vault: pages that open only with a passphrase ---------- // The server holds the vault's key only while it is unlocked, and forgets it when you lock it, sign out // or leave it alone for a quarter of an hour. Its pages never show in the ordinary notebook's lists, search // or download, and they cannot link to it. const VAULT = '/api/vault'; function vaultView(arg) { const root = h('div', { class: 'page vault' }); let ed = null, dead = false; const view = { el: root, title: 'Vault', dispose() { dead = true; ed?.dispose?.(); } }; const lock = async () => { await flushAll(); await api('POST', VAULT + '/lock'); location.hash = '#/vault'; show(); }; function passField(label, autocomplete) { const input = h('input', { type: 'password', class: 'vault-pass', autocomplete, 'aria-label': label, placeholder: label, required: true }); return input; } // one form for creating and for unlocking function gate({ title, text, fields, submit, label, extra }) { const err = h('p', { class: 'err small', role: 'alert', hidden: true }); const form = h('form', { class: 'vault-form', onsubmit: async e => { e.preventDefault(); err.hidden = true; try { await submit(); } catch (x) { err.textContent = x.message; err.hidden = false; fields[0].select(); } } }, ...fields, err, h('button', { class: 'btn', type: 'submit' }, icon('lock', 16), label)); fill(root, h('div', { class: 'vault-gate' }, card('', h('div', { class: 'vault-icon' }, icon('lock', 22)), h('h1', { class: 'login-h' }, title), h('p', { class: 'dim center-text' }, text), form, extra))); setTimeout(() => fields[0].focus()); } function create() { const a = passField('Passphrase', 'new-password'), b = passField('Type it again', 'new-password'); gate({ title: 'Make your vault', text: 'A place for pages that need more than a sign-in. They are locked with a passphrase, and Mako cannot read them until you unlock them.', fields: [a, b], label: 'Create vault', extra: h('p', { class: 'notice notice-warning small' }, icon('alert', 16), 'If you forget the passphrase, the pages in the vault cannot be recovered. Nobody can reset it, not even whoever runs Mako.'), async submit() { if (a.value !== b.value) throw new Error('The two passphrases are different.'); await api('POST', VAULT, { passphrase: a.value }); a.value = b.value = ''; show(); }, }); } function unlock() { const a = passField('Passphrase', 'current-password'); gate({ title: 'The vault is locked', text: 'Type your passphrase to open it. It locks again when you leave it alone for a while, or when you sign out.', fields: [a], label: 'Unlock', async submit() { try { await api('POST', VAULT + '/unlock', { passphrase: a.value }); } catch (e) { throw new Error(e.data?.code === 'wrong_passphrase' ? 'That is not the passphrase. Try again.' : e.message); } a.value = ''; show(); }, }); } // the pages in the vault, or its trash async function list(trash) { fill(root, skeleton()); const r = await api('GET', VAULT + '/notes?limit=200&view=' + (trash ? 'trash' : 'all')); if (dead) return; const rows = r.notes.map(n => h('li', { class: 'vault-row' }, h('a', { class: 'vault-name', href: '#/vault/' + n.id }, icon('notes', 16), h('span', { class: 'clip1' }, n.name)), h('span', { class: 'sub' }, fmtTime(n.updated)), trash ? [ btn('Restore', 'restore', act(async () => { await api('POST', `${VAULT}/notes/${n.id}/restore`); list(true); })), btn('Delete for good', 'trash', act(async () => { if (!await confirmDialog({ title: 'Delete this page for good?', body: `"${n.name}" will be deleted and cannot be brought back.`, confirm: 'Delete page' })) return; await api('DELETE', `${VAULT}/notes/${n.id}?purge=1`); list(true); }), 'ghost danger')] : null)); fill(root, h('div', { class: 'page-h' }, h('div', {}, h('h1', {}, trash ? 'Vault trash' : 'Vault'), h('p', {}, trash ? 'Deleted vault pages wait here until you delete them for good.' : 'Unlocked. These pages are not in search, the sidebar or your download.')), h('div', { class: 'actions' }, trash ? btn('Back to the vault', 'prev', () => list(false)) : [btn('Trash', 'trash', () => list(true)), btn('Lock', 'lock', act(lock))], trash ? null : h('button', { class: 'btn', onclick: () => { location.hash = '#/vault/new'; } }, icon('add', 16), 'New page'))), rows.length ? h('ul', { class: 'vault-list card' }, rows) : emptyState(trash ? 'trash' : 'lock', trash ? 'The vault trash is empty' : 'Nothing in the vault yet', trash ? 'Pages you delete from the vault appear here.' : 'Make a page and it is locked away with the rest.', trash ? null : h('button', { class: 'btn', onclick: () => { location.hash = '#/vault/new'; } }, icon('add', 16), 'New page')), trash && rows.length ? btn('Empty trash', 'trash', act(async () => { if (!await confirmDialog({ title: 'Empty the vault trash?', body: 'Every page in it will be deleted and cannot be brought back.', confirm: 'Empty trash' })) return; await api('DELETE', VAULT + '/trash'); list(true); }), 'ghost danger') : null); } // one page of the vault async function page(id) { fill(root, skeleton()); let note = null; if (id !== 'new') { try { note = await api('GET', `${VAULT}/notes/${encodeURIComponent(id)}`); } catch (e) { if (e.status === 404) return fill(root, emptyState('notes', 'This page is not in the vault', 'It may have been deleted for good, or the link is wrong.', btn('Back to the vault', 'prev', () => { location.hash = '#/vault'; }, 'btn'))); throw e; } if (dead) return; if (note.trashed) return fill(root, h('div', { class: 'notice notice-warning' }, icon('trash', 16), h('span', { class: 'grow' }, 'This page is in the vault trash.'), btn('Restore', 'restore', act(async () => { await api('POST', `${VAULT}/notes/${note.id}/restore`); show(); })))); } const actions = h('div', { class: 'page-actions' }), crumbs = h('nav', { class: 'crumbs', 'aria-label': 'Where this page is' }, h('a', { href: '#/vault' }, 'Vault')); const meta = h('p', { class: 'sub page-meta' }); ed = pageEditor({ note, vault: true, onCreated(c) { note = c; history.replaceState(null, '', '#/vault/' + c.id); document.title = (c.name || 'New page') + ' · Vault · Mako'; draw(); }, onSaved(c) { note = c; meta.textContent = 'Edited ' + fmtTime(c.updated); document.title = (c.name || 'New page') + ' · Vault · Mako'; }, }); ed.onMode = () => draw(); view.toggleMode = () => ed.setMode(ed.mode === 'read' ? ed.lastEdit : 'read'); function draw() { const seg = h('div', { class: 'seg', role: 'group', 'aria-label': 'How the page is shown' }, [['live', 'Live', 'Rendered, except the line you are on'], ['write', 'Edit', 'Plain markdown'], ['read', 'View', 'The finished page']].map(([m, label, tip]) => h('button', { class: 'seg-b' + (ed.mode === m ? ' on' : ''), 'aria-pressed': String(ed.mode === m), title: tip, onclick: () => ed.setMode(m) }, label))); fill(actions, ed.status, h('span', { class: 'grow' }), seg, btn('Lock', 'lock', act(lock)), btn('Delete', 'trash', act(async () => { if (!note) { location.hash = '#/vault'; return; } await ed.flush(); const n = note; await api('DELETE', `${VAULT}/notes/${n.id}`); toast('Moved to the vault trash', false, { label: 'Undo', fn: act(async () => { await api('POST', `${VAULT}/notes/${n.id}/restore`); location.hash = '#/vault/' + n.id; show(); }) }); location.hash = '#/vault'; }), 'ghost danger')); } const pref = store.get('mode'), start = pref === 'read' && !(note && note.body.trim()) ? 'live' : pref || 'live'; fill(root, h('div', { class: 'page-top' }, crumbs, actions), h('div', {}, ed.notice, h('div', { class: 'title-wrap' }, ed.titleEl, ed.titleErr), ed.wrap, meta)); ed.setMode(start); const set = ed.setMode; ed.setMode = m => { store.set('mode', m); set(m); }; if (note) meta.textContent = 'Edited ' + fmtTime(note.updated); draw(); view.title = note?.name || 'New page'; document.title = view.title + ' · Vault · Mako'; if (!note) setTimeout(() => ed.titleEl.focus()); } view.start = act(async () => { fill(root, skeleton()); const v = await api('GET', VAULT); if (dead) return; if (!v.exists) return create(); if (!v.unlocked) return unlock(); return arg ? page(arg) : list(false); }); return view; }