Files
Password-Manager/js/app.attachments.js
T
r-zakarya d583a3f7d3 refactor(js): extract attachments, autofill, unlock modules from app.js (3.1)
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>
2026-07-11 19:28:19 +01:00

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;
}