SHiNE-server/shine-UI/js/services/arweave-avatar-cache-service.js

228 lines
7.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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('Ошибка очистки кэша аватарок'));
});
}