diff --git a/.env.example b/.env.example index 9b47805..88c44d1 100644 --- a/.env.example +++ b/.env.example @@ -7,6 +7,9 @@ ONLYOFFICE_URL=https://onlyoffice.necloud.us # Optional: keeps Plex/session cookies valid across restarts. Any long random string. COOKIE_SECRET= AUDIOBOOKS_URL=https://audiobooks.necloud.us +# Passwords tab: your Vaultwarden / Bitwarden server (leave empty to switch the tab off), and minutes of inactivity before a vault locks itself +VAULT_URL=https://key.necloud.us +VAULT_IDLE_MIN=10 # First-time passkey enrolment. If unset, a random code is printed in the server log while no passkey exists. HUB_SETUP_CODE= VERSE_URL=https://versebyversewithnate.us/admin diff --git a/Dockerfile b/Dockerfile index cb07fe5..105d590 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,6 +4,8 @@ COPY package.json package-lock.json ./ RUN npm ci --omit=dev COPY *.js ./ COPY public ./public +# The Bitwarden command-line tool, used by the Passwords tab to read each person's Vaultwarden vault +RUN npm install -g @bitwarden/cli@2024.9.0 && npm cache clean --force ENV PORT=8080 DATA_DIR=/data RUN mkdir -p /data && chown node:node /data VOLUME /data diff --git a/docker-compose.yml b/docker-compose.yml index 00241ae..f5d9d0e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,6 +15,8 @@ services: SEAFILE_URL: ${SEAFILE_URL:-https://seafile.necloud.us} ONLYOFFICE_URL: ${ONLYOFFICE_URL:-https://onlyoffice.necloud.us} AUDIOBOOKS_URL: ${AUDIOBOOKS_URL:-https://audiobooks.necloud.us} + VAULT_URL: ${VAULT_URL:-https://key.necloud.us} # Passwords tab (Vaultwarden through the bundled Bitwarden CLI); leave empty to switch it off + VAULT_IDLE_MIN: ${VAULT_IDLE_MIN:-10} AUDIOBOOKS_LOGIN_LINK: ${AUDIOBOOKS_LOGIN_LINK:-} AUDIOBOOKS_SSO_SECRET: ${AUDIOBOOKS_SSO_SECRET:-} AUDIOBOOKS_SSO_URL: ${AUDIOBOOKS_SSO_URL:-} diff --git a/public/app.js b/public/app.js index 3deea85..8648060 100644 --- a/public/app.js +++ b/public/app.js @@ -109,6 +109,7 @@ const ICONS = { book: '', mic: '', plus: '', + key: '', link: '', check: '', trash: '', @@ -173,8 +174,8 @@ const fmtSize = b => b < 1024 ? b + ' B' : b < 1048576 ? (b / 1024).toFixed(0) + const fmtDate = s => new Date(s * 1000).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); // ---- Navigation -const TABS = [['home', 'Home'], ['family', 'Family'], ['files', 'Files'], ['music', 'Music'], ['video', 'Movies & TV'], ['mail', 'Mail'], ['contacts', 'Contacts'], ['chat', 'Chat'], ['notes', 'Notes'], ['chores', 'Chores'], ['signal', 'Signal'], ['tasks', 'Tasks'], ['recipes', 'Recipes'], ['store', 'Store'], ['calendar', 'Calendar'], ['search', 'Search'], ['links', 'Links'], ['office', 'Office'], ['audio', 'Audiobooks'], ['podcast', 'Podcast'], ['radio', 'Radio'], ['bible', 'Bible'], ['read', 'Books'], ['verse', 'Podcast Admin'], ['settings', 'Settings']]; -const TAB_ICON = { home: 'home', mail: 'mail', signal: 'chat', files: 'files', office: 'doc', audio: 'audio', verse: 'book', podcast: 'mic', radio: 'radio', bible: 'bible', recipes: 'chef', contacts: 'people', chat: 'chat', family: 'people', notes: 'doc', chores: 'check', store: 'store', listen: 'audio', reading: 'book', read: 'book', music: 'music', video: 'film', settings: 'cog', calendar: 'cal', search: 'search', tasks: 'check', links: 'link', planner: 'cal' }; +const TABS = [['home', 'Home'], ['family', 'Family'], ['files', 'Files'], ['music', 'Music'], ['video', 'Movies & TV'], ['mail', 'Mail'], ['contacts', 'Contacts'], ['chat', 'Chat'], ['notes', 'Notes'], ['chores', 'Chores'], ['signal', 'Signal'], ['tasks', 'Tasks'], ['recipes', 'Recipes'], ['store', 'Store'], ['calendar', 'Calendar'], ['search', 'Search'], ['links', 'Links'], ['vault', 'Passwords'], ['office', 'Office'], ['audio', 'Audiobooks'], ['podcast', 'Podcast'], ['radio', 'Radio'], ['bible', 'Bible'], ['read', 'Books'], ['verse', 'Podcast Admin'], ['settings', 'Settings']]; +const TAB_ICON = { home: 'home', mail: 'mail', signal: 'chat', files: 'files', office: 'doc', audio: 'audio', verse: 'book', podcast: 'mic', radio: 'radio', bible: 'bible', recipes: 'chef', contacts: 'people', chat: 'chat', family: 'people', notes: 'doc', chores: 'check', store: 'store', listen: 'audio', reading: 'book', read: 'book', music: 'music', video: 'film', settings: 'cog', calendar: 'cal', search: 'search', tasks: 'check', links: 'link', vault: 'key', planner: 'cal' }; /** The menu entries this person sees: Calendar and Tasks share one "Planner" entry, and the listening and reading tabs share "Listen" and "Read" (each part still needs its own permission). */ const leafOf = id => Object.keys(MERGED).find(k => MERGED[k][1].includes(id)); const sgInChat = () => allowed('chat') && allowed('signal'); // with both, Signal conversations are in the Chat list and there is one entry, not two @@ -186,14 +187,14 @@ const navTabs = () => { const out = [], done = new Set(); for (const t of TABS) const leafTabs = () => TABS.filter(t => t[0] !== 'verse' && t[0] !== 'home' && allowed(t[0])); const tabLabel = id => (TABS.find(x => x[0] === id) || (MERGED[id] ? [0, MERGED[id][0]] : id === 'planner' ? [0, 'Planner'] : [0, '']))[1]; /** Menu entries grouped by kind. Anything not listed lands in the last group so a new tab is never lost. */ -const NAV_GROUPS = [['', ['home', 'family', 'search']], ['Work', ['planner', 'notes', 'chores', 'files', 'office', 'links', 'recipes']], ['Communicate', ['mail', 'contacts', 'chat', 'signal']], ['Media', ['listen', 'reading', 'video', 'store']], ['', ['settings']]]; +const NAV_GROUPS = [['', ['home', 'family', 'search']], ['Work', ['planner', 'notes', 'chores', 'files', 'office', 'links', 'vault', 'recipes']], ['Communicate', ['mail', 'contacts', 'chat', 'signal']], ['Media', ['listen', 'reading', 'video', 'store']], ['', ['settings']]]; const navGroups = (tabs = navTabs()) => { const out = NAV_GROUPS.map(([label, ids]) => ({ label, tabs: ids.map(id => tabs.find(t => t[0] === id)).filter(Boolean) })); const known = new Set(NAV_GROUPS.flatMap(g => g[1])); out[out.length - 2].tabs.push(...tabs.filter(t => !known.has(t[0]))); return out.filter(g => g.tabs.length); }; const sg = { st: null, convs: [], cur: null, unread: 0, es: null, timers: [], reply: null, tray: [], names: {} }; // Signal state -const TAB_COLOR = { home: '#3d6bf0', search: '#6b7280', planner: '#d9453a', tasks: '#d9453a', calendar: '#d9453a', files: '#2f9e6e', office: '#e0742b', links: '#0f9d9a', mail: '#4f7cff', signal: '#3a76f0', listen: '#8a5cf0', music: '#e5a00d', radio: '#7b4fd6', store: '#d9822b', chat: '#2aa6a0', family: '#d9578a', notes: '#e5b800', chores: '#2f9e6e', recipes: '#e0742b', contacts: '#5b8def', podcast: '#c2417a', audio: '#8a5cf0', video: '#c0392b', reading: '#2b8a5e', bible: '#8b5a2b', read: '#2b8a5e', settings: '#6a7280' }; +const TAB_COLOR = { home: '#3d6bf0', search: '#6b7280', planner: '#d9453a', tasks: '#d9453a', calendar: '#d9453a', files: '#2f9e6e', office: '#e0742b', links: '#0f9d9a', vault: '#1f6fb2', mail: '#4f7cff', signal: '#3a76f0', listen: '#8a5cf0', music: '#e5a00d', radio: '#7b4fd6', store: '#d9822b', chat: '#2aa6a0', family: '#d9578a', notes: '#e5b800', chores: '#2f9e6e', recipes: '#e0742b', contacts: '#5b8def', podcast: '#c2417a', audio: '#8a5cf0', video: '#c0392b', reading: '#2b8a5e', bible: '#8b5a2b', read: '#2b8a5e', settings: '#6a7280' }; // The phone's bottom bar shows up to five entries you choose (Edit bottom bar, in the Apps sheet), then "More". const DEFAULT_PINS = ['home', 'files', 'planner', 'listen', 'mail']; const navPins = () => { const have = new Set(navTabs().map(t => t[0])), saved = store.get('nav_pins'); const out = (Array.isArray(saved) && saved.length ? saved : DEFAULT_PINS).filter(id => have.has(id)).slice(0, 5); return out.length ? out : [...have].slice(0, 4); }; @@ -265,7 +266,7 @@ function drawSubnav(name) { const navIdOf = name => name === 'signal' && sgInChat() ? 'chat' : name === 'edit' ? 'files' : (name === 'tasks' || name === 'calendar') ? 'planner' : leafOf(name) || name; let curRoute = ''; -const routesOf = () => ({ video: viewVideo, planner: viewPlanner, home: ME && ME.simple && typeof viewSimple === 'function' ? viewSimple : viewHome, mail: viewMail, files: viewFiles, office: viewOffice, audio: args => args[0] === 'classic' ? viewAudio() : args[0] === 'free' ? (allowed('store') ? location.replace('#/store/audiobooks') : viewFreeBooks()) : viewBooks(args), verse: () => location.replace('#/links'), podcast: args => args[0] === 'vbv' ? viewVbv(args.slice(1)) : viewPodcast(args), radio: () => viewRadio(), recipes: args => viewRecipes(args), store: args => viewStore(args), chat: args => viewChat(args), notes: args => viewNotes(args), family: () => viewFamily(), chores: () => viewChores(), contacts: args => viewContacts(args), bible: args => viewBible(args), listen: () => location.replace('#/' + lastSub('listen')), reading: () => location.replace('#/' + lastSub('reading')), read: args => viewReader(args), music: viewMusic, tasks: viewTasks, search: viewSearch, calendar: viewCalendar, signal: viewSignal, links: viewLinks, settings: viewSettings, edit: viewEdit }); +const routesOf = () => ({ video: viewVideo, planner: viewPlanner, home: ME && ME.simple && typeof viewSimple === 'function' ? viewSimple : viewHome, mail: viewMail, files: viewFiles, office: viewOffice, audio: args => args[0] === 'classic' ? viewAudio() : args[0] === 'free' ? (allowed('store') ? location.replace('#/store/audiobooks') : viewFreeBooks()) : viewBooks(args), verse: () => location.replace('#/links'), podcast: args => args[0] === 'vbv' ? viewVbv(args.slice(1)) : viewPodcast(args), radio: () => viewRadio(), recipes: args => viewRecipes(args), store: args => viewStore(args), chat: args => viewChat(args), notes: args => viewNotes(args), family: () => viewFamily(), chores: () => viewChores(), contacts: args => viewContacts(args), bible: args => viewBible(args), listen: () => location.replace('#/' + lastSub('listen')), reading: () => location.replace('#/' + lastSub('reading')), read: args => viewReader(args), music: viewMusic, tasks: viewTasks, search: viewSearch, calendar: viewCalendar, signal: viewSignal, links: viewLinks, vault: viewVault, settings: viewSettings, edit: viewEdit }); let editor; // Menu on a tablet or computer: collapse it to icons (wide screens), or hide it completely. Remembered on this device. const navPref = () => { try { return JSON.parse(localStorage.getItem('nav_pref')) || {}; } catch { return {}; } }; @@ -347,7 +348,7 @@ function homeApps() { mail: () => tile('#/mail', '#4f7cff', 'mail', 'Mail', mailUnread ? mailUnread + ' unread' : 'Outlook.com'), }; T.bible = () => tile('#/bible', '#8b5a2b', 'bible', 'Bible', 'Berean Standard Bible'); - const HOME_GROUPS = [['Work', ['planner', 'notes', 'chores', 'files', 'office', 'links', 'recipes']], ['Communicate', ['mail', 'contacts', 'chat', 'signal']], ['Listen', ['music', 'radio', 'podcast', 'audio']], ['Read and watch', ['bible', 'read', 'video', 'store']]]; + const HOME_GROUPS = [['Work', ['planner', 'notes', 'chores', 'files', 'office', 'links', 'vault', 'recipes']], ['Communicate', ['mail', 'contacts', 'chat', 'signal']], ['Listen', ['music', 'radio', 'podcast', 'audio']], ['Read and watch', ['bible', 'read', 'video', 'store']]]; return HOME_GROUPS.map(([label, ids]) => [label, ids.filter(id => T[id] && allowed(id) && !(id === 'signal' && sgInChat()))]).filter(g => g[1].length).map(([label, ids]) => `

${esc(label)}

${ids.map(id => T[id]()).join('')}
`).join(''); } diff --git a/public/index.html b/public/index.html index b19cc16..e28190d 100644 --- a/public/index.html +++ b/public/index.html @@ -83,6 +83,7 @@ + diff --git a/public/vault-view.js b/public/vault-view.js new file mode 100644 index 0000000..12e1813 --- /dev/null +++ b/public/vault-view.js @@ -0,0 +1,87 @@ +'use strict'; +// ---- Passwords: your Vaultwarden vault. The vault stays end-to-end encrypted; unlocking asks for your master password, which goes to the server's +// Bitwarden tool only to open it for a few idle minutes. Lists never carry passwords: a password, code or note is fetched only when you open that entry. +const vtReq = async (p, body) => { + const r = await fetch('/api/vault' + p, body === undefined ? {} : { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }); + const j = await r.json().catch(() => ({})); + if (!r.ok) throw Object.assign(new Error(j.error || 'Request failed (' + r.status + ')'), j, { status: r.status }); + return j; +}; +const vtState = { q: '', folder: '', items: null, folders: [] }; +const vtType = { 1: 'Login', 2: 'Note', 3: 'Card', 4: 'Identity' }; +let vtClip = 0; +/** Copy something private, and clear the clipboard again after half a minute if it is still ours. */ +async function vtCopy(text, what) { + try { await navigator.clipboard.writeText(text); toast(what + ' copied (cleared in 30 seconds)'); clearTimeout(vtClip); vtClip = setTimeout(async () => { try { if ((await navigator.clipboard.readText()) === text) await navigator.clipboard.writeText(''); } catch { try { await navigator.clipboard.writeText(''); } catch {} } }, 30000); } + catch { toast('Could not copy'); } +} +const vtHost = u => { try { return new URL(/^[a-z]+:\/\//i.test(u) ? u : 'https://' + u).hostname.replace(/^www\./, ''); } catch { return ''; } }; +const vtSafeUrl = u => { try { const x = new URL(/^[a-z]+:\/\//i.test(u) ? u : 'https://' + u); return /^https?:$/.test(x.protocol) ? x.href : ''; } catch { return ''; } }; + +async function viewVault() { + const v = $('#view'); v.innerHTML = '

Passwords

Loading…

'; + let st; try { st = await vtReq('/status'); } catch (e) { v.innerHTML = `

Passwords

${esc(e.message)}

`; return; } + if (!location.hash.startsWith('#/vault')) return; + st.unlocked ? vtList(st) : vtUnlock(st); +} + +function vtUnlock(st, need2fa) { + const v = $('#view'), host = vtHost((typeof cfg !== 'undefined' && cfg.vault) || st.url); + v.innerHTML = `

Passwords

+
+

Unlock your vault on ${esc(host)}. Your master password is only used to open it and is never saved.

+ ${st.loggedIn ? `

Signed in as ${esc(st.email)}

` : ''} + + ${need2fa ? '' : ''} + +
${st.loggedIn ? '' : ''}
+

No account yet? Create one on ${esc(host)}, then come back here to unlock it.

+
`; + const f = $('#vtf'); setTimeout(() => (f.querySelector(need2fa ? '#vtc' : st.loggedIn ? '#vtp' : '#vte') || {}).focus?.(), 0); + if ($('#vtout')) $('#vtout').onclick = () => confirmSheet('Use a different account?', 'This signs the hub out of the vault. Nothing is deleted from the vault itself.', 'Sign out of vault', async () => { try { await vtReq('/forget', {}); viewVault(); } catch (e) { toast(e.message); } }); + f.onsubmit = async e => { + e.preventDefault(); $('#vterr').textContent = ''; $('#vtgo').disabled = true; + try { const body = { password: $('#vtp').value }; if ($('#vte')) body.email = $('#vte').value; if ($('#vtc')) body.code = $('#vtc').value; const r = await vtReq('/unlock', body); $('#vtp').value = ''; vtState.items = null; vtList(r); } + catch (err) { if (err.need2fa) return vtUnlock({ ...st, email: st.email || ($('#vte') && $('#vte').value) }, true) || void 0; $('#vterr').textContent = err.message; $('#vtgo').disabled = false; } + }; +} + +async function vtList(st) { + const v = $('#view'); + v.innerHTML = `

Passwords

+

Loading…

+

Locks itself after a few idle minutes. Open the full vault to add or change entries.

`; + const lockedOut = () => { vtState.items = null; vtUnlock({ ...st, unlocked: false }); }; + $('#vtlock').onclick = async () => { try { await vtReq('/lock', {}); } catch {} lockedOut(); toast('Vault locked'); }; + $('#vtsync').onclick = async () => { try { await vtReq('/sync', {}); await load(); toast('Refreshed'); } catch (e) { e.locked ? lockedOut() : toast(e.message); } }; + let timer; $('#vtq').oninput = e => { vtState.q = e.target.value; clearTimeout(timer); timer = setTimeout(load, 250); }; + async function load() { + try { const r = await vtReq('/items?q=' + encodeURIComponent(vtState.q.trim())); if (!location.hash.startsWith('#/vault')) return; vtState.items = r.items; vtState.folders = r.folders; draw(); } + catch (e) { if (e.locked) return lockedOut(); $('#vtl').innerHTML = `

${esc(e.message)}

`; } + } + function draw() { + const fo = vtState.folders; $('#vtfo').innerHTML = fo.length ? `
All${fo.map(f => `${esc(f.name)}`).join('')}
` : ''; + $('#vtfo').querySelectorAll('a').forEach(a => a.onclick = e => { e.preventDefault(); vtState.folder = a.dataset.f; draw(); }); + const rows = vtState.items.filter(i => !vtState.folder || i.folderId === vtState.folder); + $('#vtl').innerHTML = rows.length ? `
${rows.map(i => ``).join('')}
` : `

${vtState.q ? 'Nothing matches.' : 'Your vault is empty.'}

`; + $('#vtl').querySelectorAll('.row').forEach(b => b.onclick = () => vtOpen(b.dataset.id, lockedOut)); + } + if (vtState.items) draw(); load(); +} + +async function vtOpen(id, lockedOut) { + let it; try { it = await vtReq('/item/' + encodeURIComponent(id)); } catch (e) { return e.locked ? lockedOut() : toast(e.message); } + const field = (label, value, key, secret) => value ? `
${esc(label)}
${secret ? '••••••••••' : esc(value)}${secret ? `` : ''}
` : ''; + const vals = { user: it.username, pass: it.password, totp: it.totp }; + const extra = []; if (it.card) { vals.cnum = it.card.number; vals.ccode = it.card.code; } + (it.fields || []).forEach((f, n) => { vals['f' + n] = f.value; }); + const links = it.uris.map(vtSafeUrl).filter(Boolean); + const el = openSheet(it.name || 'Entry', vtType[it.type] || '', `${field('Username', it.username, 'user')}${field('Password', it.password, 'pass', true)}${field('One-time code', it.totp, 'totp')} + ${it.card ? field('Cardholder', it.card.holder, 'chold') + field('Card number', it.card.number, 'cnum', true) + field('Security code', it.card.code, 'ccode', true) + field('Expires', [it.card.expMonth, it.card.expYear].filter(Boolean).join('/'), 'cexp') : ''} + ${(it.fields || []).map((f, n) => field(f.name || 'Field', f.value, 'f' + n, f.hidden)).join('')} + ${links.map(u => `
Website
`).join('')} + ${it.notes ? `
Notes
${esc(it.notes)}
` : ''}`); + Object.assign(vals, { chold: it.card && it.card.holder, cexp: it.card && [it.card.expMonth, it.card.expYear].filter(Boolean).join('/') }); + el.querySelectorAll('.vtcp').forEach(b => b.onclick = () => vtCopy(vals[b.dataset.k], b.dataset.w)); + el.querySelectorAll('.vtshow').forEach(b => b.onclick = () => { const s = el.querySelector(`.vtv[data-k="${b.dataset.k}"]`), on = b.textContent === 'Show'; s.textContent = on ? vals[b.dataset.k] : '••••••••••'; b.textContent = on ? 'Hide' : 'Show'; }); +} diff --git a/pwvault.js b/pwvault.js new file mode 100644 index 0000000..c2a5bad --- /dev/null +++ b/pwvault.js @@ -0,0 +1,115 @@ +// Passwords (not vault.js, which encrypts stored tokens): each person's Vaultwarden / Bitwarden passwords, through the Bitwarden command-line tool (`bw`). +// The vault is end-to-end encrypted, so the hub only ever holds what a person types in to unlock it: the master password goes to `bw` through its +// environment (never the command line, never disk), and the session key `bw` hands back lives in memory only and is dropped after a few idle minutes. +// Each person gets their own `bw` data folder, so one person's unlocked vault is never reachable from another's session. +const fs = require('fs'), path = require('path'), { execFile } = require('child_process'); +const users = require('./users'); +const { readBody } = require('./body'); + +const URL_ = (process.env.VAULT_URL || '').replace(/\/+$/, ''); +const BW = process.env.BW_BIN || 'bw'; +const IDLE_MS = Math.max(1, +process.env.VAULT_IDLE_MIN || 10) * 60e3; +const enabled = () => !!URL_; + +const sessions = new Map(); // user id -> { key, last } +const chains = new Map(); // user id -> the last bw call, so one person's calls run one at a time +const fails = new Map(); // user id -> { n, until }, slows down password guessing + +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 ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +const appdata = uid => { const d = path.join(users.dirOf(uid), 'bw'); fs.mkdirSync(d, { recursive: true, mode: 0o700 }); return d; }; +/** Run `bw` as one person. Secrets travel in the environment only. */ +function bw(uid, args, { session, password, code, timeout = 45e3 } = {}) { + const env = { PATH: process.env.PATH, HOME: process.env.HOME || '/tmp', BITWARDENCLI_APPDATA_DIR: appdata(uid), BW_NOINTERACTION: 'true', BW_QUIET: 'true', NODE_NO_WARNINGS: '1' }; + if (session) env.BW_SESSION = session; + if (password != null) env.BW_PASSWORD = password; + const run = () => new Promise((resolve, reject) => execFile(BW, args, { env, timeout, maxBuffer: 16e6 }, (err, stdout, stderr) => { + if (err) { const e = new Error(String(stderr || err.message).trim().split('\n').pop() || 'The vault did not answer'); e.stdout = stdout; return reject(e); } + resolve(String(stdout)); + })); + const prev = chains.get(uid) || Promise.resolve(), p = prev.then(run, run); + chains.set(uid, p.catch(() => {})); + return p; +} +const parse = s => { try { return JSON.parse(s); } catch { throw fail(502, 'The vault gave an unexpected answer'); } }; + +const lock = uid => { const s = sessions.get(uid); sessions.delete(uid); if (s) bw(uid, ['lock']).catch(() => {}); }; +setInterval(() => { const now = Date.now(); for (const [uid, s] of sessions) if (now - s.last > IDLE_MS) lock(uid); }, 30e3).unref(); + +const keyOf = uid => { const s = sessions.get(uid); if (!s) throw fail(401, 'The vault is locked', { locked: true }); s.last = Date.now(); return s.key; }; +/** A bw failure for something that needs an unlocked vault: if bw says the session is bad, treat it as locked. */ +const asLocked = e => { if (!e.code && /session|locked|not logged in|unauthenticated/i.test(e.message)) return fail(401, 'The vault is locked', { locked: true }); return e; }; + +const brief = i => ({ id: i.id, name: i.name || '', type: i.type, favorite: !!i.favorite, folderId: i.folderId || null, + username: i.login ? i.login.username || '' : '', uri: i.login && i.login.uris && i.login.uris[0] ? i.login.uris[0].uri || '' : '', hasTotp: !!(i.login && i.login.totp) }); + +async function status(uid) { + let st = { status: 'unauthenticated' }; try { st = parse(await bw(uid, ['status'], { timeout: 15e3 })); } catch {} + const s = sessions.get(uid); + return { enabled: true, url: URL_, unlocked: !!s && st.status === 'unlocked', email: st.userEmail || '', loggedIn: st.status !== 'unauthenticated' }; +} + +async function unlock(uid, b) { + const f = fails.get(uid); if (f && f.until > Date.now()) throw fail(429, 'Too many tries. Wait a few minutes and try again.'); + const password = String(b.password || ''); if (!password) throw fail(400, 'Enter your master password'); + const st = await status(uid); let out; + try { + if (!st.loggedIn) { + const email = String(b.email || '').trim(); if (!/^[^\s@]+@[^\s@]+$/.test(email)) throw fail(400, 'Enter the email address you use for the vault'); + await bw(uid, ['config', 'server', URL_]); + const args = ['login', email, '--passwordenv', 'BW_PASSWORD', '--raw']; + if (b.code) args.push('--method', '0', '--code', String(b.code).replace(/\s/g, '').slice(0, 12)); // 0 = authenticator app + out = await bw(uid, args, { password }); + } else out = await bw(uid, ['unlock', '--passwordenv', 'BW_PASSWORD', '--raw'], { password }); + } catch (e) { + if (e.code) throw e; + if (/two-?step|two-?factor|2fa|code/i.test(e.message) && !b.code) throw fail(400, 'Enter the 6-digit code from your authenticator app', { need2fa: true }); + const n = ((fails.get(uid) || {}).n || 0) + 1; fails.set(uid, { n, until: n >= 5 ? Date.now() + 10 * 60e3 : 0 }); if (n >= 5) fails.get(uid).n = 0; + throw fail(401, /invalid|incorrect|password|username/i.test(e.message) ? 'That password did not work' : 'Could not unlock: ' + e.message); + } + fails.delete(uid); + const key = out.trim(); if (!key) throw fail(502, 'The vault did not give a session'); + sessions.set(uid, { key, last: Date.now() }); + await bw(uid, ['sync'], { session: key }).catch(() => {}); + return status(uid); +} + +async function handle(req, res) { + try { + const u = users.current(); if (!u) throw fail(401, 'Sign in first'); + if (!enabled()) throw fail(404, 'The vault is not set up (VAULT_URL)'); + const uid = u.id, p = new URL(req.url, 'http://x').pathname.replace(/^\/api\/vault/, '') || '/', q = new URL(req.url, 'http://x').searchParams; + if (p === '/status' && req.method === 'GET') return json(res, 200, await status(uid)); + if (p === '/unlock' && req.method === 'POST') return json(res, 200, await unlock(uid, JSON.parse(await readBody(req, 1e4) || '{}'))); + if (p === '/lock' && req.method === 'POST') { lock(uid); return json(res, 200, { ok: true }); } + if (p === '/forget' && req.method === 'POST') { lock(uid); await bw(uid, ['logout']).catch(() => {}); return json(res, 200, { ok: true }); } // sign this account out of the hub completely + if (p === '/sync' && req.method === 'POST') { try { await bw(uid, ['sync'], { session: keyOf(uid) }); } catch (e) { throw asLocked(e); } return json(res, 200, { ok: true }); } + if (p === '/items' && req.method === 'GET') { + const key = keyOf(uid), search = String(q.get('q') || '').slice(0, 100).trim(), args = ['list', 'items']; + if (search) args.push('--search=' + search); + try { const items = parse(await bw(uid, args, { session: key })).filter(i => i.type === 1 || i.type === 2 || i.type === 3 || i.type === 4); // login, note, card, identity + let folders = []; try { folders = parse(await bw(uid, ['list', 'folders'], { session: key })).filter(f => f.id).map(f => ({ id: f.id, name: f.name })); } catch {} + return json(res, 200, { items: items.map(brief).sort((a, b) => (b.favorite - a.favorite) || a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })).slice(0, 1000), folders }); } + catch (e) { throw asLocked(e); } + } + let m; + if ((m = /^\/item\/([^/]+)$/.exec(p)) && req.method === 'GET') { // one entry in full, only when a person opens it + if (!ID.test(m[1])) throw fail(400, 'Bad item'); + const key = keyOf(uid); + try { + const i = parse(await bw(uid, ['get', 'item', m[1]], { session: key })), l = i.login || {}; + let totp = ''; if (l.totp) { try { totp = (await bw(uid, ['get', 'totp', m[1]], { session: key })).trim(); } catch {} } + const c = i.card || {}; + return json(res, 200, { id: i.id, name: i.name || '', type: i.type, notes: i.notes || '', username: l.username || '', password: l.password || '', totp, + uris: (l.uris || []).map(x => x.uri).filter(Boolean), + card: i.type === 3 ? { holder: c.cardholderName || '', brand: c.brand || '', number: c.number || '', code: c.code || '', expMonth: c.expMonth || '', expYear: c.expYear || '' } : null, + fields: (i.fields || []).filter(f => f.type !== 3).map(f => ({ name: f.name || '', value: f.value || '', hidden: f.type === 1 })) }); + } catch (e) { throw asLocked(e); } + } + 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, ...(e.extra || {}) }); } +} + +module.exports = { handle, enabled, url: () => URL_ }; diff --git a/server.js b/server.js index 57b9407..7df27d6 100644 --- a/server.js +++ b/server.js @@ -44,6 +44,7 @@ const bible = require('./bible'); // Bible: each person's place, highlights const savefiles = require('./savefiles'); // "Save to Files": copy books, lessons and audiobooks into the person's Seafile 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 pwvault = require('./pwvault'); // each person's Vaultwarden passwords, through the Bitwarden CLI (VAULT_URL) const contacts = require('./contacts'); // each person's address book (mail suggestions, calendar birthdays, vCard import and export) const activity = require('./activity'), alertParents = activity.alertParents; const kidlimits = require('./kidlimits'); // child accounts: a daily time limit and bedtime, and asking a parent for more time @@ -596,6 +597,7 @@ const ACCESS = [ [/^\/api\/store\/my(\/|$)/, ['store']], [/^\/api\/recipes(\/|$)/, ['recipes']], [/^\/api\/contacts(\/|$)/, ['contacts']], + [/^\/api\/vault(\/|$)/, ['vault']], [/^\/api\/signal\//, ['signal']], [/^\/api\/lyrics$/, ['music', 'radio']], [/^\/api\/transcript(\/search)?$/, ['podcast', 'audio']], @@ -663,7 +665,7 @@ async function handleRequest(req, res) { const c = t => users.can(user, t); res.writeHead(200, { 'content-type': 'application/json' }); return res.end(JSON.stringify({ seafile: c('files') || c('office') ? SEAFILE_URL : '', onlyoffice: c('office') ? ONLYOFFICE_URL : '', audiobooks: c('audio') ? AUDIOBOOKS_URL : '', verse: c('verse') ? VERSE_URL : '', - podcast: c('podcast') ? PODCAST_URL : '', transcribe: (c('podcast') || c('audio')) && process.env.TRANSCRIBE_API_URL ? '1' : '', signal: c('signal') && signal.enabled() ? '1' : '', lyrics: (c('music') || c('radio')) && lyrics.enabled() ? '1' : '', mail: c('mail') ? '1' : '', outlook: c('mail') && mail.outlookOn() ? '1' : '', mailAdmin: user.role === 'admin' ? '1' : '', editor: (c('office') || c('files')) && OO_OK, jwt: !!JWT_SECRET, ooReady: OO_OK, build: BUILD })); + podcast: c('podcast') ? PODCAST_URL : '', transcribe: (c('podcast') || c('audio')) && process.env.TRANSCRIBE_API_URL ? '1' : '', signal: c('signal') && signal.enabled() ? '1' : '', lyrics: (c('music') || c('radio')) && lyrics.enabled() ? '1' : '', vault: c('vault') && pwvault.enabled() ? pwvault.url() : '', mail: c('mail') ? '1' : '', outlook: c('mail') && mail.outlookOn() ? '1' : '', mailAdmin: user.role === 'admin' ? '1' : '', editor: (c('office') || c('files')) && OO_OK, jwt: !!JWT_SECRET, ooReady: OO_OK, build: BUILD })); } if (req.method === 'POST' && req.url === '/api/oo/config') return OO_OK ? ooConfig(req, res) : forbidden(res, 'In-app editing needs ONLYOFFICE_JWT_SECRET to be set on the server'); if (pathname === '/api/listening/family') { if (req.method === 'POST') { const b = JSON.parse(await require('./body').readBody(req, 1e3) || '{}'); res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' }); return res.end(JSON.stringify(require('./progress').setFamilyGoal(b.goal))); } res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' }); return res.end(JSON.stringify(require('./progress').familyGoal())); } @@ -728,6 +730,7 @@ async function handleRequest(req, res) { 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); + if (pathname === '/api/vault' || pathname.startsWith('/api/vault/')) return pwvault.handle(req, res); if (pathname === '/api/contacts' || pathname.startsWith('/api/contacts/')) return contacts.handle(req, res); if (pathname.startsWith('/api/vbv/')) return vbv.handle(req, res); if (pathname.startsWith('/api/save/')) return savefiles.handle(req, res); diff --git a/users.js b/users.js index bb5222e..0f6a893 100644 --- a/users.js +++ b/users.js @@ -11,9 +11,9 @@ const USERS_DIR = path.join(DATA_DIR, 'users'); fs.mkdirSync(DATA_DIR, { recursive: true }); // Tabs an admin can switch on or off for a person. Home and Settings are always available to everyone (Settings only shows their own). -const TABS = ['files', 'office', 'music', 'video', 'audio', 'podcast', 'radio', 'chat', 'notes', 'chores', 'store', 'recipes', 'contacts', 'bible', 'read', 'verse', 'mail', 'signal', 'calendar', 'tasks', 'search', 'links']; +const TABS = ['files', 'office', 'music', 'video', 'audio', 'podcast', 'radio', 'chat', 'notes', 'chores', 'store', 'recipes', 'contacts', 'bible', 'read', 'verse', 'mail', 'signal', 'calendar', 'tasks', 'search', 'links', 'vault']; const ALWAYS = ['home', 'family', 'settings']; -const TAB_LABELS = { files: 'Files', office: 'Office', music: 'Music', video: 'Movies & TV', audio: 'Audiobooks', podcast: 'Podcast', radio: 'Radio', chat: 'Chat', notes: 'Notes', chores: 'Chores', store: 'Store', recipes: 'Recipes', contacts: 'Contacts', bible: 'Bible', read: 'Books', verse: 'Podcast Admin (in Links)', mail: 'Mail', signal: 'Signal', calendar: 'Calendar', tasks: 'Tasks', search: 'Search', links: 'Links' }; +const TAB_LABELS = { files: 'Files', office: 'Office', music: 'Music', video: 'Movies & TV', audio: 'Audiobooks', podcast: 'Podcast', radio: 'Radio', chat: 'Chat', notes: 'Notes', chores: 'Chores', store: 'Store', recipes: 'Recipes', contacts: 'Contacts', bible: 'Bible', read: 'Books', verse: 'Podcast Admin (in Links)', mail: 'Mail', signal: 'Signal', calendar: 'Calendar', tasks: 'Tasks', search: 'Search', links: 'Links', vault: 'Passwords' }; let cache = null; const load = () => { if (cache) return cache; try { cache = JSON.parse(fs.readFileSync(USERS_FILE, 'utf8')); } catch { cache = { users: [] }; } if (!Array.isArray(cache.users)) cache.users = []; return cache; };