d583a3f7d3
app.js 9363 -> 7962 lines. Three new classic-script modules: - app.attachments.js (250): blob crypto + upload/download UI, pure declarations, loads before app.js - app.autofill.js (276): Win32 combos, title->entry matching, picker, pure declarations, loads before app.js - app.unlock.js (907): Quick Unlock + PIN + recovery code grouped (same "enter without master pw" theme); assigns Bridge.onPinResult / onQuickUnlockResult at top level so it loads AFTER app.js, like app.sync.js Audit viewer stays in app.js (only 65 lines, not worth a file). Clipboard bridge helpers stay too (were interleaved in the quick-unlock section but unrelated). Registered in BuildAssets whitelist + index.html + APP_PARTS. Verified in-app: quick unlock cold-start, attachment upload/download. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
251 lines
9.7 KiB
JavaScript
251 lines
9.7 KiB
JavaScript
// ============================================================
|
|
// app.attachments.js — ENCRYPTED ATTACHMENTS module (extracted from app.js, §3.1)
|
|
// ============================================================
|
|
//
|
|
// Per-entry file crypto + upload/download/slideover UI. Pure declarations
|
|
// (no top-level side effects) → loads BEFORE app.js. Uses state, api,
|
|
// authHeaders, Bridge, toast — all resolved via the shared global scope at
|
|
// call time.
|
|
|
|
// Per-entry files (PDFs, backup-code images, recovery sheets, …)
|
|
// encrypted client-side with the vault key, base64-shipped to the server
|
|
// for opaque storage. Listing returns metadata only — the ciphertext is
|
|
// fetched on demand when the user clicks Download.
|
|
|
|
const ATTACHMENT_MAX_BYTES = 5 * 1024 * 1024; // raw file size cap (5 MB)
|
|
|
|
async function encryptBlobBytes(bytes) {
|
|
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
const ct = await crypto.subtle.encrypt(
|
|
{ name: 'AES-GCM', iv }, state.cryptoKey, bytes);
|
|
return { encrypted: bytesToBase64(ct), iv: bytesToBase64(iv) };
|
|
}
|
|
async function decryptBlobBytes(encB64, ivB64) {
|
|
const ct = base64ToBytes(encB64);
|
|
const iv = base64ToBytes(ivB64);
|
|
const dec = await crypto.subtle.decrypt(
|
|
{ name: 'AES-GCM', iv }, state.cryptoKey, ct);
|
|
return new Uint8Array(dec);
|
|
}
|
|
|
|
function humanFileSize(n) {
|
|
if (n < 1024) return n + ' B';
|
|
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB';
|
|
return (n / 1024 / 1024).toFixed(2) + ' MB';
|
|
}
|
|
|
|
async function uploadAttachment(entryId, file) {
|
|
if (!file) return null;
|
|
if (file.size > ATTACHMENT_MAX_BYTES) {
|
|
toast('File too large (max 5 MB raw)', 'error');
|
|
return null;
|
|
}
|
|
const bytes = new Uint8Array(await file.arrayBuffer());
|
|
const { encrypted, iv } = await encryptBlobBytes(bytes);
|
|
const meta = await api('/entries/' + entryId + '/attachments', {
|
|
method: 'POST',
|
|
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
|
body: JSON.stringify({
|
|
filename: file.name,
|
|
mime: file.type || 'application/octet-stream',
|
|
encrypted_blob: encrypted,
|
|
iv,
|
|
size_bytes: file.size,
|
|
}),
|
|
});
|
|
return meta;
|
|
}
|
|
|
|
async function downloadAttachment(att) {
|
|
// Spinner for large attachments — fetch + decrypt + chunked transfer
|
|
// of a multi-MB file takes a moment before the Save dialog appears.
|
|
const big = (att.size_bytes || 0) > 512 * 1024;
|
|
try {
|
|
if (big) showBusy('Preparing download…');
|
|
const full = await api('/attachments/' + att.id, { headers: authHeaders() });
|
|
const bytes = await decryptBlobBytes(full.encrypted_blob, full.iv);
|
|
if (Bridge.active && typeof Bridge.saveFile === 'function') {
|
|
const res = await Bridge.saveFile(att.filename, bytes, pct => {
|
|
if (big) updateBusy('Preparing download… ' + pct + '%');
|
|
});
|
|
if (big) hideBusy();
|
|
if (res.ok) toast('Saved to ' + res.path);
|
|
else if (res.error) toast('Save failed: ' + res.error, 'error');
|
|
} else {
|
|
if (big) hideBusy();
|
|
// Web fallback: trigger a browser download.
|
|
const blob = new Blob([bytes], { type: att.mime || 'application/octet-stream' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = el('a', { href: url, download: att.filename });
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
setTimeout(() => { URL.revokeObjectURL(url); a.remove(); }, 100);
|
|
}
|
|
} catch (e) {
|
|
if (big) hideBusy();
|
|
toast('Download failed: ' + (e && e.message ? e.message : e), 'error');
|
|
}
|
|
}
|
|
|
|
// Slideover field: header + upload button + list of attachments.
|
|
// Re-renders the list in place when the contents change (upload/delete).
|
|
function soAttachmentsField(entryId) {
|
|
const isStaging = (entryId === null || entryId === undefined);
|
|
const wrap = el('div', { class: 'slideover-field' });
|
|
wrap.appendChild(el('div', { class: 'slideover-field-label' }, 'Attachments'));
|
|
|
|
const addBtn = el('button', {
|
|
class: 'btn btn-ghost btn-sm', type: 'button',
|
|
title: 'Attach a file (encrypted before upload, max 5 MB)',
|
|
});
|
|
addBtn.appendChild(icon('i-paperclip'));
|
|
addBtn.appendChild(document.createTextNode(' Attach file'));
|
|
wrap.appendChild(addBtn);
|
|
|
|
const fileInput = el('input', { type: 'file', style: 'display:none' });
|
|
wrap.appendChild(fileInput);
|
|
|
|
const list = el('div', { class: 'so-attach-list', id: 'soAttachList' });
|
|
wrap.appendChild(list);
|
|
|
|
addBtn.addEventListener('click', ev => {
|
|
ev.stopPropagation();
|
|
fileInput.click();
|
|
});
|
|
fileInput.addEventListener('change', async ev => {
|
|
ev.stopPropagation();
|
|
const file = fileInput.files && fileInput.files[0];
|
|
if (!file) return;
|
|
fileInput.value = ''; // allow re-uploading the same name later
|
|
if (file.size > ATTACHMENT_MAX_BYTES) {
|
|
toast('File too large (max 5 MB raw)', 'error');
|
|
return;
|
|
}
|
|
addBtn.disabled = true;
|
|
try {
|
|
if (isStaging) {
|
|
// Stage in memory — uploaded by soSave after the new
|
|
// entry's id is known.
|
|
const bytes = new Uint8Array(await file.arrayBuffer());
|
|
soState.pendingAttachments.push({
|
|
filename: file.name,
|
|
mime: file.type || 'application/octet-stream',
|
|
size_bytes: file.size,
|
|
bytes,
|
|
});
|
|
soDirtyCheck();
|
|
renderStagedAttachments(list);
|
|
} else {
|
|
await uploadAttachment(entryId, file);
|
|
toast('Attachment uploaded');
|
|
await refreshAttachments(entryId, list);
|
|
}
|
|
} catch (e) {
|
|
toast('Upload failed: ' + e.message, 'error');
|
|
} finally {
|
|
addBtn.disabled = false;
|
|
}
|
|
});
|
|
|
|
if (isStaging) renderStagedAttachments(list);
|
|
else refreshAttachments(entryId, list);
|
|
return wrap;
|
|
}
|
|
|
|
// Renders the pendingAttachments array (new-entry mode). Mirrors the
|
|
// existing-entry layout so users can't tell the difference until they
|
|
// hit Save.
|
|
function renderStagedAttachments(container) {
|
|
container.innerHTML = '';
|
|
const list = soState.pendingAttachments || [];
|
|
if (list.length === 0) {
|
|
container.appendChild(el('div', { class: 'so-attach-empty' },
|
|
'No attachments yet. Files will upload after you save.'));
|
|
return;
|
|
}
|
|
list.forEach((att, idx) => {
|
|
const row = el('div', { class: 'so-attach-row' });
|
|
const info = el('div', { class: 'so-attach-info' });
|
|
info.appendChild(el('div', { class: 'so-attach-name', title: att.filename }, att.filename));
|
|
info.appendChild(el('div', { class: 'so-attach-meta' },
|
|
humanFileSize(att.size_bytes) + ' · ' + (att.mime || '?') +
|
|
' · pending'));
|
|
row.appendChild(info);
|
|
|
|
const del = el('button',
|
|
{ class: 'icon-btn icon-btn-sm', type: 'button', title: 'Remove' });
|
|
del.appendChild(icon('i-trash'));
|
|
del.addEventListener('click', ev => {
|
|
ev.stopPropagation();
|
|
soState.pendingAttachments.splice(idx, 1);
|
|
soDirtyCheck();
|
|
renderStagedAttachments(container);
|
|
});
|
|
row.appendChild(del);
|
|
container.appendChild(row);
|
|
});
|
|
}
|
|
|
|
async function refreshAttachments(entryId, container) {
|
|
container.innerHTML = '';
|
|
let items = [];
|
|
try {
|
|
items = await api('/entries/' + entryId + '/attachments',
|
|
{ headers: authHeaders() });
|
|
} catch (e) {
|
|
container.appendChild(el('div', { class: 'so-attach-empty' },
|
|
'Failed to load attachments'));
|
|
return;
|
|
}
|
|
if (!items || items.length === 0) {
|
|
container.appendChild(el('div', { class: 'so-attach-empty' },
|
|
'No attachments yet.'));
|
|
return;
|
|
}
|
|
items.forEach(att => container.appendChild(buildAttachmentRow(att, entryId, container)));
|
|
}
|
|
|
|
function buildAttachmentRow(att, entryId, listContainer) {
|
|
const row = el('div', { class: 'so-attach-row' });
|
|
const name = el('div', { class: 'so-attach-name', title: att.filename }, att.filename);
|
|
const meta = el('div', { class: 'so-attach-meta' },
|
|
humanFileSize(att.size_bytes) + ' · ' + (att.mime || '?'));
|
|
const info = el('div', { class: 'so-attach-info' });
|
|
info.appendChild(name);
|
|
info.appendChild(meta);
|
|
row.appendChild(info);
|
|
|
|
const dl = el('button', { class: 'icon-btn icon-btn-sm', type: 'button', title: 'Download' });
|
|
dl.appendChild(icon('i-download'));
|
|
dl.addEventListener('click', ev => {
|
|
ev.stopPropagation();
|
|
downloadAttachment(att);
|
|
});
|
|
row.appendChild(dl);
|
|
|
|
const del = el('button', { class: 'icon-btn icon-btn-sm', type: 'button', title: 'Delete' });
|
|
del.appendChild(icon('i-trash'));
|
|
del.addEventListener('click', async ev => {
|
|
ev.stopPropagation();
|
|
const ok = await confirmDialog({
|
|
title: 'Delete attachment?',
|
|
message: 'This will permanently remove <b>' + att.filename + '</b>. There is no trash for attachments.',
|
|
okText: 'Delete',
|
|
danger: true,
|
|
});
|
|
if (!ok) return;
|
|
try {
|
|
await api('/attachments/' + att.id, {
|
|
method: 'DELETE', headers: authHeaders(),
|
|
});
|
|
toast('Attachment deleted');
|
|
await refreshAttachments(entryId, listContainer);
|
|
} catch (e) {
|
|
toast('Delete failed: ' + e.message, 'error');
|
|
}
|
|
});
|
|
row.appendChild(del);
|
|
return row;
|
|
}
|
|
|