const BLOB_NONCE_DOMAIN = 'cass-blob-nonce-v1';
const CACHE_CONFIG = {
MAX_ENTRIES: 50, MAX_SIZE_BYTES: 50 * 1024 * 1024, };
let manifest = null;
let isManifestLoaded = false;
let manifestLoadPromise = null;
let manifestLoadEpoch = 0;
let attachmentEpoch = 0;
const blobCache = new Map();
const blobLoadPromises = new Map();
const blobUrlPromises = new Map();
let cacheSize = 0;
const lruOrder = [];
function isCurrentEpoch(epoch) {
return epoch === attachmentEpoch;
}
function createAttachmentError(message, code) {
const error = new Error(message);
error.code = code;
return error;
}
function createInvalidationError() {
return createAttachmentError('Attachment request invalidated', 'ATTACHMENT_REQUEST_INVALIDATED');
}
function shouldCacheManifestAbsence(error) {
return error?.code === 'ATTACHMENT_MANIFEST_ABSENT';
}
export async function initAttachments(dek, exportId) {
const epoch = attachmentEpoch;
if (isManifestLoaded) {
return manifest;
}
if (manifestLoadPromise && manifestLoadEpoch === epoch) {
return manifestLoadPromise;
}
manifestLoadEpoch = epoch;
manifestLoadPromise = (async () => {
try {
const loadedManifest = await loadManifest(dek, exportId);
if (!isCurrentEpoch(epoch)) {
throw createInvalidationError();
}
manifest = loadedManifest;
isManifestLoaded = true;
return manifest;
} catch (error) {
if (error?.code !== 'ATTACHMENT_REQUEST_INVALIDATED') {
console.warn('[Attachments] No attachments found or manifest failed:', error.message);
}
if (isCurrentEpoch(epoch)) {
manifest = null;
isManifestLoaded = shouldCacheManifestAbsence(error);
}
if (shouldCacheManifestAbsence(error)) {
return null;
}
throw error;
} finally {
if (manifestLoadEpoch === epoch) {
manifestLoadPromise = null;
}
}
})();
return manifestLoadPromise;
}
async function loadManifest(dek, exportId) {
const response = await fetch('./blobs/manifest.enc');
if (!response.ok) {
if (response.status === 404) {
throw createAttachmentError('Manifest not found', 'ATTACHMENT_MANIFEST_ABSENT');
}
throw createAttachmentError(
`Failed to load attachment manifest: ${response.status}`,
'ATTACHMENT_MANIFEST_FETCH_FAILED'
);
}
const ciphertext = new Uint8Array(await response.arrayBuffer());
const nonce = await deriveBlobNonce('manifest');
const dekKey = await crypto.subtle.importKey(
'raw',
dek,
{ name: 'AES-GCM' },
false,
['decrypt']
);
const plaintext = await crypto.subtle.decrypt(
{
name: 'AES-GCM',
iv: nonce,
additionalData: exportId,
},
dekKey,
ciphertext
);
const decoder = new TextDecoder();
const manifestJson = decoder.decode(plaintext);
let parsedManifest;
try {
parsedManifest = JSON.parse(manifestJson);
} catch (error) {
throw createAttachmentError(
`Invalid attachment manifest JSON: ${error.message}`,
'ATTACHMENT_MANIFEST_INVALID'
);
}
return validateManifest(parsedManifest);
}
export function hasAttachments() {
return manifest !== null && manifest.entries?.length > 0;
}
export function getManifest() {
return manifest;
}
export function getMessageAttachments(messageId) {
if (!manifest?.entries) {
return [];
}
return manifest.entries.filter(entry => entry.message_id === messageId);
}
export async function loadBlob(hash, dek, exportId) {
const epoch = attachmentEpoch;
const normalizedHash = normalizeBlobHash(hash);
const cached = blobCache.get(normalizedHash);
if (cached) {
updateLru(normalizedHash);
return cached.data;
}
const inFlight = blobLoadPromises.get(normalizedHash);
if (inFlight?.epoch === epoch) {
return inFlight.promise;
}
let loadPromise;
loadPromise = (async () => {
const response = await fetch(`./blobs/${normalizedHash}.bin`);
if (!response.ok) {
throw new Error(`Blob not found: ${normalizedHash}`);
}
const ciphertext = new Uint8Array(await response.arrayBuffer());
const nonce = await deriveBlobNonce(normalizedHash);
const dekKey = await crypto.subtle.importKey(
'raw',
dek,
{ name: 'AES-GCM' },
false,
['decrypt']
);
const hashBytes = hexToBytes(normalizedHash);
const aad = new Uint8Array(exportId.length + hashBytes.length);
aad.set(exportId);
aad.set(hashBytes, exportId.length);
const plaintext = await crypto.subtle.decrypt(
{
name: 'AES-GCM',
iv: nonce,
additionalData: aad,
},
dekKey,
ciphertext
);
const data = new Uint8Array(plaintext);
if (!isCurrentEpoch(epoch)) {
throw createInvalidationError();
}
cacheBlob(normalizedHash, data);
return data;
})().finally(() => {
const current = blobLoadPromises.get(normalizedHash);
if (current?.epoch === epoch && current.promise === loadPromise) {
blobLoadPromises.delete(normalizedHash);
}
});
blobLoadPromises.set(normalizedHash, { epoch, promise: loadPromise });
return loadPromise;
}
export async function loadBlobAsUrl(hash, mimeType, dek, exportId) {
const epoch = attachmentEpoch;
const normalizedHash = normalizeBlobHash(hash);
const normalizedMimeType = normalizeAttachmentMimeType(mimeType);
const urlCacheKey = blobUrlCacheKey(normalizedHash, normalizedMimeType);
const cached = blobCache.get(normalizedHash);
const cachedUrl = cached?.objectUrls.get(normalizedMimeType);
if (cachedUrl) {
updateLru(normalizedHash);
return cachedUrl;
}
const inFlight = blobUrlPromises.get(urlCacheKey);
if (inFlight?.epoch === epoch) {
return inFlight.promise;
}
let urlPromise;
urlPromise = (async () => {
const data = await loadBlob(normalizedHash, dek, exportId);
const cachedEntry = blobCache.get(normalizedHash);
const existingUrl = cachedEntry?.objectUrls.get(normalizedMimeType);
if (existingUrl) {
updateLru(normalizedHash);
return existingUrl;
}
const blob = new Blob([data], { type: normalizedMimeType });
const url = URL.createObjectURL(blob);
if (!isCurrentEpoch(epoch)) {
URL.revokeObjectURL(url);
throw createInvalidationError();
}
const cacheEntry = blobCache.get(normalizedHash);
if (!cacheEntry) {
URL.revokeObjectURL(url);
throw createAttachmentError(
'Attachment cache entry missing after blob load',
'ATTACHMENT_CACHE_INCONSISTENT'
);
}
cacheEntry.objectUrls.set(normalizedMimeType, url);
updateLru(normalizedHash);
return url;
})().finally(() => {
const current = blobUrlPromises.get(urlCacheKey);
if (current?.epoch === epoch && current.promise === urlPromise) {
blobUrlPromises.delete(urlCacheKey);
}
});
blobUrlPromises.set(urlCacheKey, { epoch, promise: urlPromise });
return urlPromise;
}
async function deriveBlobNonce(identifier) {
const encoder = new TextEncoder();
const salt = encoder.encode(BLOB_NONCE_DOMAIN);
const ikm = encoder.encode(identifier);
const info = encoder.encode('nonce');
const baseKey = await crypto.subtle.importKey(
'raw',
ikm,
'HKDF',
false,
['deriveBits']
);
const nonceBits = await crypto.subtle.deriveBits(
{
name: 'HKDF',
hash: 'SHA-256',
salt: salt,
info: info,
},
baseKey,
96 );
return new Uint8Array(nonceBits);
}
function hexToBytes(hex) {
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < hex.length; i += 2) {
bytes[i / 2] = parseInt(hex.substr(i, 2), 16);
}
return bytes;
}
function normalizeBlobHash(hash) {
if (typeof hash !== 'string') {
throw new Error('Attachment hash must be a string');
}
const normalized = hash.trim().toLowerCase();
if (!/^[0-9a-f]{64}$/.test(normalized)) {
throw new Error('Attachment hash must be 64 hex characters');
}
return normalized;
}
function normalizeAttachmentMimeType(mimeType) {
const normalized = typeof mimeType === 'string'
? mimeType.trim()
: String(mimeType ?? '').trim();
if (!normalized || /[\0\r\n]/.test(normalized)) {
throw new Error('Attachment MIME type must be non-empty without control characters');
}
return normalized;
}
function blobUrlCacheKey(hash, mimeType) {
return `${hash}\0${mimeType}`;
}
function validateManifest(rawManifest) {
if (!rawManifest || typeof rawManifest !== 'object' || Array.isArray(rawManifest)) {
throw new Error('Attachment manifest must be an object');
}
if (!Array.isArray(rawManifest.entries)) {
throw new Error('Attachment manifest entries must be an array');
}
if (
rawManifest.total_size_bytes !== null
&& rawManifest.total_size_bytes !== undefined
&& (!Number.isSafeInteger(rawManifest.total_size_bytes) || rawManifest.total_size_bytes < 0)
) {
throw new Error('Attachment manifest total_size_bytes must be a non-negative integer');
}
return {
...rawManifest,
entries: rawManifest.entries.map((entry, index) => validateManifestEntry(entry, index)),
};
}
function validateManifestEntry(entry, index) {
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
throw new Error(`Attachment entry ${index} must be an object`);
}
if (typeof entry.filename !== 'string' || entry.filename.length === 0 || entry.filename.includes('\0')) {
throw new Error(`Attachment entry ${index} has an invalid filename`);
}
if (
typeof entry.mime_type !== 'string'
|| entry.mime_type.trim().length === 0
|| /[\0\r\n]/.test(entry.mime_type)
) {
throw new Error(`Attachment entry ${index} has an invalid MIME type`);
}
if (!Number.isSafeInteger(entry.size_bytes) || entry.size_bytes < 0) {
throw new Error(`Attachment entry ${index} has an invalid size`);
}
if (!Number.isSafeInteger(entry.message_id) || entry.message_id < 0) {
throw new Error(`Attachment entry ${index} has an invalid message ID`);
}
return {
...entry,
hash: normalizeBlobHash(entry.hash),
mime_type: entry.mime_type.trim(),
};
}
function cacheBlob(hash, data) {
if (blobCache.has(hash)) {
updateLru(hash);
return;
}
while (
blobCache.size >= CACHE_CONFIG.MAX_ENTRIES ||
cacheSize + data.length > CACHE_CONFIG.MAX_SIZE_BYTES
) {
if (lruOrder.length === 0) break;
evictOldest();
}
blobCache.set(hash, {
data,
objectUrls: new Map(),
size: data.length,
});
cacheSize += data.length;
lruOrder.push(hash);
}
function updateLru(hash) {
const idx = lruOrder.indexOf(hash);
if (idx > -1) {
lruOrder.splice(idx, 1);
lruOrder.push(hash);
}
}
function evictOldest() {
const hash = lruOrder.shift();
if (!hash) return;
const entry = blobCache.get(hash);
if (entry) {
for (const objectUrl of entry.objectUrls.values()) {
URL.revokeObjectURL(objectUrl);
}
cacheSize -= entry.size;
blobCache.delete(hash);
}
}
export function clearCache() {
for (const entry of blobCache.values()) {
for (const objectUrl of entry.objectUrls.values()) {
URL.revokeObjectURL(objectUrl);
}
}
blobCache.clear();
lruOrder.length = 0;
cacheSize = 0;
}
export function reset() {
attachmentEpoch += 1;
clearCache();
manifest = null;
isManifestLoaded = false;
manifestLoadPromise = null;
blobLoadPromises.clear();
blobUrlPromises.clear();
manifestLoadEpoch = attachmentEpoch;
}
export function getCacheStats() {
return {
entries: blobCache.size,
sizeBytes: cacheSize,
maxEntries: CACHE_CONFIG.MAX_ENTRIES,
maxSizeBytes: CACHE_CONFIG.MAX_SIZE_BYTES,
};
}
export function createAttachmentElement(entry, dek, exportId) {
const container = document.createElement('div');
container.className = 'attachment';
container.dataset.hash = entry.hash;
container.dataset.mimeType = entry.mime_type;
if (entry.mime_type.startsWith('image/')) {
return createImageAttachment(entry, dek, exportId);
} else if (entry.mime_type === 'application/pdf') {
return createPdfAttachment(entry, dek, exportId);
} else {
return createDownloadAttachment(entry, dek, exportId);
}
}
function createImageAttachment(entry, dek, exportId) {
const container = document.createElement('figure');
container.className = 'attachment attachment-image';
const placeholder = document.createElement('div');
placeholder.className = 'attachment-placeholder';
placeholder.innerHTML = `
<span class="attachment-icon">🖼️</span>
<span class="attachment-name">${escapeHtml(entry.filename)}</span>
<span class="attachment-size">${formatSize(entry.size_bytes)}</span>
`;
const loading = document.createElement('div');
loading.className = 'attachment-loading hidden';
loading.innerHTML = '<div class="spinner"></div>';
const img = document.createElement('img');
img.className = 'attachment-img hidden';
img.alt = entry.filename;
const caption = document.createElement('figcaption');
caption.className = 'attachment-caption';
caption.textContent = entry.filename;
container.appendChild(placeholder);
container.appendChild(loading);
container.appendChild(img);
container.appendChild(caption);
const observer = new IntersectionObserver(async (observerEntries) => {
const [observerEntry] = observerEntries;
if (observerEntry.isIntersecting) {
observer.disconnect();
await loadImageAttachment(container, img, observerEntry.target.dataset.hash, observerEntry.target.dataset.mimeType, dek, exportId, placeholder, loading);
}
}, { rootMargin: '100px' });
container.dataset.hash = entry.hash;
container.dataset.mimeType = entry.mime_type;
observer.observe(container);
placeholder.addEventListener('click', async () => {
observer.disconnect();
await loadImageAttachment(container, img, entry.hash, entry.mime_type, dek, exportId, placeholder, loading);
});
return container;
}
async function loadImageAttachment(container, img, hash, mimeType, dek, exportId, placeholder, loading) {
try {
placeholder.classList.add('hidden');
loading.classList.remove('hidden');
const url = await loadBlobAsUrl(hash, mimeType, dek, exportId);
await waitForImageLoad(img, url);
loading.classList.add('hidden');
img.classList.remove('hidden');
container.classList.add('loaded');
} catch (error) {
if (error?.code === 'ATTACHMENT_REQUEST_INVALIDATED') {
return;
}
console.error('[Attachments] Failed to load image:', error);
loading.classList.add('hidden');
placeholder.classList.remove('hidden');
placeholder.innerHTML = `
<span class="attachment-icon">⚠️</span>
<span class="attachment-error">Failed to load</span>
`;
}
}
function waitForImageLoad(img, url) {
return new Promise((resolve, reject) => {
const cleanup = () => {
img.onload = null;
img.onerror = null;
};
const handleLoad = () => {
cleanup();
resolve();
};
const handleError = () => {
cleanup();
reject(new Error('Image failed to load'));
};
img.onload = handleLoad;
img.onerror = handleError;
img.src = url;
if (img.complete && (!('naturalWidth' in img) || img.naturalWidth > 0)) {
handleLoad();
}
});
}
function createPdfAttachment(entry, dek, exportId) {
const container = document.createElement('div');
container.className = 'attachment attachment-pdf';
container.innerHTML = `
<span class="attachment-icon">📄</span>
<span class="attachment-name">${escapeHtml(entry.filename)}</span>
<span class="attachment-size">${formatSize(entry.size_bytes)}</span>
<button class="attachment-download" type="button">Download</button>
`;
const downloadBtn = container.querySelector('.attachment-download');
downloadBtn.addEventListener('click', async () => {
await downloadAttachment(entry, dek, exportId);
});
return container;
}
function createDownloadAttachment(entry, dek, exportId) {
const container = document.createElement('div');
container.className = 'attachment attachment-file';
container.innerHTML = `
<span class="attachment-icon">📎</span>
<span class="attachment-name">${escapeHtml(entry.filename)}</span>
<span class="attachment-size">${formatSize(entry.size_bytes)}</span>
<button class="attachment-download" type="button">Download</button>
`;
const downloadBtn = container.querySelector('.attachment-download');
downloadBtn.addEventListener('click', async () => {
await downloadAttachment(entry, dek, exportId);
});
return container;
}
async function downloadAttachment(entry, dek, exportId) {
try {
const url = await loadBlobAsUrl(entry.hash, entry.mime_type, dek, exportId);
const a = document.createElement('a');
a.href = url;
a.download = entry.filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
} catch (error) {
if (error?.code === 'ATTACHMENT_REQUEST_INVALIDATED') {
return;
}
console.error('[Attachments] Failed to download:', error);
alert('Failed to download attachment');
}
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function formatSize(bytes) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
export default {
initAttachments,
hasAttachments,
getManifest,
getMessageAttachments,
loadBlob,
loadBlobAsUrl,
createAttachmentElement,
clearCache,
reset,
getCacheStats,
};