diff --git a/public/vault-view.js b/public/vault-view.js
index d3cb619..8d08eee 100644
--- a/public/vault-view.js
+++ b/public/vault-view.js
@@ -75,15 +75,16 @@ async function vtList(st) {
async function vtOpen(id, lockedOut, reload) {
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 vals = { user: it.username, pass: it.password, 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')}
+ const el = openSheet(it.name || 'Entry', vtType[it.type] || '', `${field('Username', it.username, 'user')}${field('Password', it.password, 'pass', true)}${it.hasTotp ? field('One-time code', '…', '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 ? `` : ''}`);
+ if (it.hasTotp) vtReq('/totp/' + encodeURIComponent(id)).then(r => { vals.totp = r.code; const sp = el.querySelector('.vtv[data-k=totp]'); if (sp) sp.textContent = r.code; }, () => { const sp = el.querySelector('.vtv[data-k=totp]'); if (sp) sp.textContent = 'unavailable'; });
if (it.type === 1 || it.type === 2) el.insertAdjacentHTML('beforeend', '');
if ($('#vted')) $('#vted').onclick = () => { closeSheet(); vtEdit(it, reload, lockedOut); };
Object.assign(vals, { chold: it.card && it.card.holder, cexp: it.card && [it.card.expMonth, it.card.expYear].filter(Boolean).join('/') });
diff --git a/pwvault.js b/pwvault.js
index 23dcd73..a654345 100644
--- a/pwvault.js
+++ b/pwvault.js
@@ -13,6 +13,7 @@ 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 cache = new Map(); // user id -> Map(item id -> item) from the last list, so opening an entry needs no new bw call; dropped on lock, idle and any change
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)); };
@@ -38,7 +39,7 @@ function bw(uid, args, { session, password, input, timeout = 45e3 } = {}) {
}
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(() => {}); };
+const lock = uid => { const s = sessions.get(uid); sessions.delete(uid); cache.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; };
@@ -76,7 +77,7 @@ async function saveItem(uid, b, key) {
const l = item.login = item.login || {}; l.username = clip(b.username, 1000) || null; l.password = clip(b.password, 1000) || null; l.totp = clip(b.totp, 1000).trim() || null;
const old = l.uris || []; l.uris = uris.map(u => ({ ...(old.find(o => o.uri === u) || { match: null }), uri: u }));
}
- const out = parse(await bw(uid, b.id ? ['edit', 'item', b.id] : ['create', 'item'], { session: key, input: enc(item) }));
+ const out = parse(await bw(uid, b.id ? ['edit', 'item', b.id] : ['create', 'item'], { session: key, input: enc(item) })); cache.delete(uid);
bw(uid, ['sync'], { session: key }).catch(() => {});
return { id: out.id };
}
@@ -131,9 +132,9 @@ async function handle(req, res) {
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 === '/sync' && req.method === 'POST') { try { await bw(uid, ['sync'], { session: keyOf(uid) }); cache.delete(uid); } catch (e) { throw asLocked(e); } return json(res, 200, { ok: true }); }
if (p === '/item' && req.method === 'POST') { const key = keyOf(uid); try { return json(res, 200, await saveItem(uid, JSON.parse(await readBody(req, 5e4) || '{}'), key)); } catch (e) { throw asLocked(e); } }
- if (p === '/item/delete' && req.method === 'POST') { const { id } = JSON.parse(await readBody(req, 1e4) || '{}'); if (!ID.test(id || '')) throw fail(400, 'Bad item'); const key = keyOf(uid); try { await bw(uid, ['delete', 'item', id], { session: key }); } catch (e) { throw asLocked(e); } bw(uid, ['sync'], { session: key }).catch(() => {}); return json(res, 200, { ok: true }); } // goes to the vault's trash, where it can still be restored
+ if (p === '/item/delete' && req.method === 'POST') { const { id } = JSON.parse(await readBody(req, 1e4) || '{}'); if (!ID.test(id || '')) throw fail(400, 'Bad item'); const key = keyOf(uid); try { await bw(uid, ['delete', 'item', id], { session: key }); cache.delete(uid); } catch (e) { throw asLocked(e); } bw(uid, ['sync'], { session: key }).catch(() => {}); return json(res, 200, { ok: true }); } // goes to the vault's trash, where it can still be restored
if (p === '/generate' && req.method === 'GET') return json(res, 200, await generate(uid, q));
if (p === '/sends' && req.method === 'GET') { try { return json(res, 200, { sends: parse(await bw(uid, ['send', 'list'], { session: keyOf(uid) })).filter(x => x.type === 0).map(sendBrief) }); } catch (e) { throw asLocked(e); } }
if (p === '/sends' && req.method === 'POST') { const key = keyOf(uid); try { return json(res, 200, await createSend(uid, JSON.parse(await readBody(req, 5e4) || '{}'), key)); } catch (e) { throw asLocked(e); } }
@@ -142,19 +143,19 @@ async function handle(req, res) {
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
+ const cm = cache.get(uid) || new Map(); for (const i of items) cm.set(i.id, i); cache.set(uid, cm);
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 = /^\/totp\/([^/]+)$/.exec(p)) && req.method === 'GET') { if (!ID.test(m[1])) throw fail(400, 'Bad item'); try { return json(res, 200, { code: (await bw(uid, ['get', 'totp', m[1]], { session: keyOf(uid) })).trim() }); } catch (e) { throw asLocked(e); } }
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, totpSeed: l.totp || '', folderId: i.folderId || '', favorite: !!i.favorite,
+ const i = (cache.get(uid) || new Map()).get(m[1]) || parse(await bw(uid, ['get', 'item', m[1]], { session: key })), l = i.login || {}, 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 || '', hasTotp: !!l.totp, totpSeed: l.totp || '', folderId: i.folderId || '', favorite: !!i.favorite,
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 })) });