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