feat(ext/sw): upload_attachment + download_attachment router handlers
Both popup-only. upload_attachment encrypts via WASM, putBlobs via GitHost (Git Data API fallback for >900 KB), persists the AttachmentRef on the item + manifest summaries. Duplicate uploads (same content = same id from sha256) return the existing ref without a re-upload. download_attachment reads + decrypts and returns plaintext bytes for the popup to wrap in a Blob. 4 new router tests (accept × 2, reject × 2). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4,7 +4,7 @@
|
||||
/// via sender.url === popup.html (or setup.html for save_setup).
|
||||
|
||||
import type { PopupMessage, Response } from '../../shared/messages';
|
||||
import type { Item, ItemId, Manifest, VaultConfig, SetupState, DeviceSettings, TotpConfig } from '../../shared/types';
|
||||
import type { Item, ItemId, Manifest, VaultConfig, SetupState, DeviceSettings, TotpConfig, AttachmentRef } from '../../shared/types';
|
||||
import { DEFAULT_DEVICE_SETTINGS } from '../../shared/types';
|
||||
import type { GitHost } from '../git-host';
|
||||
import { createGitHost, base64ToUint8Array } from '../git-host';
|
||||
@@ -201,6 +201,91 @@ export async function handle(
|
||||
await saveBlacklist(bl.filter((h) => h !== msg.hostname));
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
case 'upload_attachment': {
|
||||
try {
|
||||
const handle = session.getCurrent();
|
||||
if (!handle || !state.gitHost) return { ok: false, error: 'vault_locked' };
|
||||
|
||||
// Look up the per-attachment size cap from vault settings (if any).
|
||||
let maxBytes = BigInt(Number.MAX_SAFE_INTEGER);
|
||||
try {
|
||||
const settings = await vault.fetchAndDecryptSettings(state.gitHost, handle);
|
||||
const cap = settings.attachment_caps?.per_attachment_max_bytes;
|
||||
if (cap !== undefined) maxBytes = BigInt(cap);
|
||||
} catch {
|
||||
// If settings are unavailable, proceed uncapped.
|
||||
}
|
||||
|
||||
const plaintext = new Uint8Array(msg.bytes);
|
||||
const encrypted = state.wasm.attachment_encrypt(handle, plaintext, maxBytes);
|
||||
// encrypted: EncryptedAttachment — exposes .aid (string) and .bytes (Uint8Array)
|
||||
const aid: string = encrypted.aid;
|
||||
const encBytes: Uint8Array = encrypted.bytes;
|
||||
|
||||
// Duplicate-id check: same content hash → same id → no-op re-upload.
|
||||
let existingRef: AttachmentRef | undefined;
|
||||
try {
|
||||
const item = await vault.fetchAndDecryptItem(state.gitHost, handle, msg.itemId);
|
||||
existingRef = item.attachments.find((a) => a.id === aid);
|
||||
} catch {
|
||||
// Item not yet persisted or not found — will fail at addAttachmentToItem below.
|
||||
}
|
||||
if (existingRef) {
|
||||
return { ok: true, data: { attachment: existingRef } };
|
||||
}
|
||||
|
||||
// Store the encrypted blob.
|
||||
const blobPath = `attachments/${aid}.bin`;
|
||||
await state.gitHost.putBlob(blobPath, encBytes, `attach: ${msg.filename}`);
|
||||
|
||||
// Build the AttachmentRef and append it to the item + manifest.
|
||||
const ref: AttachmentRef = {
|
||||
id: aid,
|
||||
filename: msg.filename,
|
||||
mime_type: msg.mimeType,
|
||||
size: plaintext.length,
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
await vault.addAttachmentToItem(state.gitHost, handle, msg.itemId, ref);
|
||||
|
||||
return { ok: true, data: { attachment: ref } };
|
||||
} catch (e) {
|
||||
console.error('[relicario] upload_attachment failed', e);
|
||||
return { ok: false, error: 'upload_failed' };
|
||||
}
|
||||
}
|
||||
|
||||
case 'download_attachment': {
|
||||
try {
|
||||
const handle = session.getCurrent();
|
||||
if (!handle || !state.gitHost) return { ok: false, error: 'vault_locked' };
|
||||
|
||||
// Fetch the item to recover the filename + mime type from the AttachmentRef.
|
||||
const item = await vault.fetchAndDecryptItem(state.gitHost, handle, msg.itemId);
|
||||
const ref = item.attachments.find((a) => a.id === msg.attachmentId);
|
||||
if (!ref) return { ok: false, error: 'attachment_not_found' };
|
||||
|
||||
const blobPath = `attachments/${msg.attachmentId}.bin`;
|
||||
const encBytes = await state.gitHost.getBlob(blobPath);
|
||||
const decrypted = state.wasm.attachment_decrypt(handle, encBytes);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
data: {
|
||||
bytes: decrypted.buffer.slice(
|
||||
decrypted.byteOffset,
|
||||
decrypted.byteOffset + decrypted.byteLength,
|
||||
) as ArrayBuffer,
|
||||
filename: ref.filename,
|
||||
mimeType: ref.mime_type,
|
||||
},
|
||||
};
|
||||
} catch (e) {
|
||||
console.error('[relicario] download_attachment failed', e);
|
||||
return { ok: false, error: 'download_failed' };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user