// ============================================================ // 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 ' + att.filename + '. 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; }