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>
This commit is contained in:
@@ -0,0 +1,250 @@
|
||||
// ============================================================
|
||||
// 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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user