Passwords: add and edit logins and notes, delete to trash, password generator, text Send
build-container / build (push) Successful in 6m41s
build-container / build (push) Successful in 6m41s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
70dc75d688
commit
4752aca43c
2 files changed
+149
-10
No files matched your search
+58
-6
@@ -21,14 +21,17 @@ 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 } = {}) {
|
||||
function bw(uid, args, { session, password, input, timeout = 45e3 } = {}) {
|
||||
const env = { PATH: process.env.PATH, HOME: process.env.HOME || '/tmp', BITWARDENCLI_APPDATA_DIR: appdata(uid), BW_NOINTERACTION: '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 run = () => new Promise((resolve, reject) => {
|
||||
const child = 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));
|
||||
});
|
||||
if (input != null) child.stdin.end(input); // (a Send's text goes in on stdin, so it never shows in the process list)
|
||||
});
|
||||
const prev = chains.get(uid) || Promise.resolve(), p = prev.then(run, run);
|
||||
chains.set(uid, p.catch(() => {}));
|
||||
return p;
|
||||
@@ -45,6 +48,49 @@ const asLocked = e => { if (!e.code && /session|locked|not logged in|unauthentic
|
||||
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) });
|
||||
|
||||
const int = (v, lo, hi, d) => { const n = parseInt(v, 10); return Number.isFinite(n) ? Math.min(hi, Math.max(lo, n)) : d; };
|
||||
/** A new random password or passphrase from bw (needs no unlocked vault). */
|
||||
async function generate(uid, q) {
|
||||
const args = ['generate'];
|
||||
if (q.get('type') === 'passphrase') {
|
||||
args.push('--passphrase', '--words', String(int(q.get('words'), 3, 20, 4)), '--separator', String(q.get('separator') || '-').slice(0, 1));
|
||||
if (q.get('capitalize') === '1') args.push('--capitalize'); if (q.get('number') === '1') args.push('--includeNumber');
|
||||
} else {
|
||||
const flags = ['upper', 'lower', 'number', 'special'].filter(k => q.get(k) !== '0').map(k => ({ upper: 'u', lower: 'l', number: 'n', special: 's' })[k]);
|
||||
args.push('-' + (flags.join('') || 'l'), '--length', String(int(q.get('length'), 5, 128, 16)));
|
||||
}
|
||||
return { value: (await bw(uid, args, { timeout: 15e3 })).trim() };
|
||||
}
|
||||
const enc = o => Buffer.from(JSON.stringify(o)).toString('base64');
|
||||
const clip = (v, n) => String(v == null ? '' : v).slice(0, n);
|
||||
/** Make or change a login or secure note. A change starts from the entry as it is now, so custom fields and anything not shown here are kept. */
|
||||
async function saveItem(uid, b, key) {
|
||||
const name = clip(b.name, 1000).trim(); if (!name) throw fail(400, 'Give the entry a name');
|
||||
const folderId = b.folderId && ID.test(b.folderId) ? b.folderId : null;
|
||||
const uris = (Array.isArray(b.uris) ? b.uris : []).map(u => clip(u, 1000).trim()).filter(Boolean).slice(0, 10);
|
||||
let item;
|
||||
if (b.id) { if (!ID.test(b.id)) throw fail(400, 'Bad item'); item = parse(await bw(uid, ['get', 'item', b.id], { session: key })); }
|
||||
else { const type = +b.type === 2 ? 2 : 1; item = { organizationId: null, collectionIds: null, type, fields: [], reprompt: 0, login: type === 1 ? {} : undefined, secureNote: type === 2 ? { type: 0 } : undefined }; }
|
||||
item.name = name; item.notes = clip(b.notes, 10000) || null; item.folderId = folderId; item.favorite = !!b.favorite;
|
||||
if (item.type === 1) {
|
||||
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) }));
|
||||
bw(uid, ['sync'], { session: key }).catch(() => {});
|
||||
return { id: out.id };
|
||||
}
|
||||
const sendBrief = x => ({ id: x.id, name: x.name || '', url: x.accessUrl || '', expires: x.deletionDate || '', views: x.accessCount || 0, max: x.maxAccessCount || 0, hasPassword: !!x.password || !!x.hasPassword, disabled: !!x.disabled });
|
||||
/** A text Send: a link that shows the text once (or a few times) and then goes away. */
|
||||
async function createSend(uid, b, key) {
|
||||
const text = String(b.text || '').slice(0, 10000); if (!text.trim()) throw fail(400, 'Type what you want to send');
|
||||
const days = int(b.days, 1, 31, 7), max = int(b.max, 0, 9999, 0);
|
||||
const body = { type: 0, name: String(b.name || '').trim().slice(0, 100) || 'Text from NE Hub', notes: null, text: { text, hidden: !!b.hidden }, file: null, key: null,
|
||||
maxAccessCount: max || null, deletionDate: new Date(Date.now() + days * 864e5).toISOString(), expirationDate: null, password: b.password ? String(b.password).slice(0, 200) : null, disabled: false, hideEmail: false };
|
||||
const out = parse(await bw(uid, ['send', 'create'], { session: key, input: Buffer.from(JSON.stringify(body)).toString('base64') }));
|
||||
return sendBrief(out);
|
||||
}
|
||||
|
||||
async function status(uid) {
|
||||
let st = { status: 'unauthenticated' }; try { st = parse(await bw(uid, ['status'], { timeout: 15e3 })); } catch {}
|
||||
const s = sessions.get(uid);
|
||||
@@ -86,6 +132,12 @@ async function handle(req, res) {
|
||||
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 === '/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 === '/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); } }
|
||||
if (p === '/sends/delete' && req.method === 'POST') { const { id } = JSON.parse(await readBody(req, 1e4) || '{}'); if (!ID.test(id || '')) throw fail(400, 'Bad item'); try { await bw(uid, ['send', 'delete', id], { 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);
|
||||
@@ -102,7 +154,7 @@ async function handle(req, res) {
|
||||
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,
|
||||
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,
|
||||
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 })) });
|
||||
|
||||
Reference in new issue
Block a user