import { buildArweaveDataUrl, sha256HexFromArrayBuffer, validateArweaveTxId, validateSha256Hex, } from './arweave-file-service.js'; const DB_NAME = 'shine-ui-avatar-cache'; const DB_VERSION = 1; const STORE = 'avatars'; const MAX_ITEMS = 200; const MAX_BYTES = 50 * 1024 * 1024; function openDb() { return new Promise((resolve, reject) => { if (typeof indexedDB === 'undefined') { reject(new Error('IndexedDB недоступен')); return; } const request = indexedDB.open(DB_NAME, DB_VERSION); request.onupgradeneeded = () => { const db = request.result; if (!db.objectStoreNames.contains(STORE)) { db.createObjectStore(STORE, { keyPath: 'txId' }); } }; request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error || new Error('Ошибка открытия IndexedDB')); }); } async function withStore(mode, runner) { const db = await openDb(); try { return await new Promise((resolve, reject) => { const tx = db.transaction(STORE, mode); const store = tx.objectStore(STORE); const result = runner(store, tx, resolve, reject); if (result !== undefined) resolve(result); tx.onerror = () => reject(tx.error || new Error('Ошибка транзакции IndexedDB')); }); } finally { db.close(); } } async function getRecord(txId) { return withStore('readonly', (store, _tx, resolve, reject) => { const req = store.get(txId); req.onsuccess = () => resolve(req.result || null); req.onerror = () => reject(req.error || new Error('Ошибка чтения кэша аватарки')); }); } async function putRecord(record) { return withStore('readwrite', (store, tx, resolve, reject) => { store.put(record); tx.oncomplete = () => resolve(true); tx.onerror = () => reject(tx.error || new Error('Ошибка записи кэша аватарки')); }); } async function verifyBlobSha256(blob, expectedSha256Hex) { const expected = String(expectedSha256Hex || '').trim().toLowerCase(); if (!validateSha256Hex(expected)) return true; if (!(blob instanceof Blob)) return false; const actual = await sha256HexFromArrayBuffer(await blob.arrayBuffer()); return actual === expected; } async function getAllRecords() { return withStore('readonly', (store, _tx, resolve, reject) => { const req = store.getAll(); req.onsuccess = () => resolve(Array.isArray(req.result) ? req.result : []); req.onerror = () => reject(req.error || new Error('Ошибка чтения списка кэша аватарок')); }); } async function deleteRecords(keys) { if (!Array.isArray(keys) || !keys.length) return; await withStore('readwrite', (store, tx, resolve, reject) => { keys.forEach((key) => store.delete(key)); tx.oncomplete = () => resolve(true); tx.onerror = () => reject(tx.error || new Error('Ошибка очистки кэша аватарок')); }); } async function ensureCacheLimits() { const records = await getAllRecords(); if (!records.length) return; let totalBytes = 0; records.forEach((row) => { totalBytes += Number(row?.sizeBytes || row?.blob?.size || 0); }); if (records.length <= MAX_ITEMS && totalBytes <= MAX_BYTES) return; const ordered = [...records].sort((a, b) => Number(a?.cachedAtMs || 0) - Number(b?.cachedAtMs || 0)); const deleteKeys = []; let keepCount = records.length; let keepBytes = totalBytes; for (let i = 0; i < ordered.length; i += 1) { if (keepCount <= MAX_ITEMS && keepBytes <= MAX_BYTES) break; const row = ordered[i]; deleteKeys.push(String(row?.txId || '')); keepCount -= 1; keepBytes -= Number(row?.sizeBytes || row?.blob?.size || 0); } await deleteRecords(deleteKeys.filter(Boolean)); } async function fetchAvatarBlob({ gateway, txId }) { const url = buildArweaveDataUrl({ gateway, txId }); const response = await fetch(url, { method: 'GET' }); if (!response.ok) { throw new Error(`Не удалось загрузить аватар (${response.status} ${response.statusText})`); } const sourceBlob = await response.blob(); const sourceType = String(sourceBlob?.type || '').toLowerCase(); if (sourceType.startsWith('image/')) { return sourceBlob; } const bytes = new Uint8Array(await sourceBlob.arrayBuffer()); const detectedType = detectImageMime(bytes); if (detectedType) { return new Blob([bytes], { type: detectedType }); } // Старые аватары могли быть загружены без корректного Content-Type. // Возвращаем исходный blob, чтобы браузер попробовал декодировать сам. return sourceBlob; } function detectImageMime(bytes) { if (!(bytes instanceof Uint8Array) || bytes.length < 12) return ''; if ( bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47 && bytes[4] === 0x0d && bytes[5] === 0x0a && bytes[6] === 0x1a && bytes[7] === 0x0a ) return 'image/png'; if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return 'image/jpeg'; if ( bytes[0] === 0x52 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x46 && bytes[8] === 0x57 && bytes[9] === 0x45 && bytes[10] === 0x42 && bytes[11] === 0x50 ) return 'image/webp'; return ''; } async function getBlobFromCacheOrGateway({ gateway, txId, expectedSha256Hex = '' }) { const expected = String(expectedSha256Hex || '').trim().toLowerCase(); try { const cached = await getRecord(txId); if (cached?.blob instanceof Blob) { if (!validateSha256Hex(expected)) return cached.blob; const cacheSha = String(cached?.sha256Hex || '').trim().toLowerCase(); if (cacheSha && cacheSha === expected) { return cached.blob; } const ok = await verifyBlobSha256(cached.blob, expected); if (ok) { return cached.blob; } // кэш повреждён или не совпадает с ожидаемым хэшем: удаляем и перекачиваем await deleteRecords([txId]); } } catch { // ignore IndexedDB errors and fallback to fetch } const blob = await fetchAvatarBlob({ gateway, txId }); if (validateSha256Hex(expected)) { const ok = await verifyBlobSha256(blob, expected); if (!ok) { throw new Error('SHA256_MISMATCH'); } } const computedSha256Hex = await sha256HexFromArrayBuffer(await blob.arrayBuffer()); const record = { txId, blob, contentType: String(blob.type || 'application/octet-stream'), sizeBytes: Number(blob.size || 0), sha256Hex: computedSha256Hex, cachedAtMs: Date.now(), }; try { await putRecord(record); await ensureCacheLimits(); } catch { // ignore cache write errors } return blob; } export async function getCachedAvatarObjectUrl({ gateway, txId, expectedSha256Hex = '' }) { const cleanTxId = String(txId || '').trim(); if (!validateArweaveTxId(cleanTxId)) { throw new Error('Некорректный Transaction ID Arweave'); } const blob = await getBlobFromCacheOrGateway({ gateway, txId: cleanTxId, expectedSha256Hex, }); return URL.createObjectURL(blob); } export async function clearAvatarCache() { await withStore('readwrite', (store, tx, resolve, reject) => { store.clear(); tx.oncomplete = () => resolve(true); tx.onerror = () => reject(tx.error || new Error('Ошибка очистки кэша аватарок')); }); }