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 = '';
+ 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 = ``;
+ 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
+
+
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 ? `` : '';
+ $('#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 => ``).join('')}
+ ${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; };