{ if (window.ZoomzillaUploadRecovery) return; const ttl = 30 * 60 * 1000; const prefix = 'zoomzilla-upload-recovery:'; const slots = new Map(); const randomKey = () => Array.from(crypto.getRandomValues(new Uint8Array(16)), byte => byte.toString(16).padStart(2, '0')).join(''); const failure = message => new Error('Upload recovery: ' + message); let storageInitialized = false; const initializeStorage = () => { if (storageInitialized) return; // window.open копирует sessionStorage родителя. Новый документ с opener // начинает собственный слот; настоящий Reload этой вкладки сохраняет его. if (window.opener && performance.getEntriesByType('navigation')[0]?.type === 'navigate') { for (const name of Object.keys(sessionStorage)) { if (name.startsWith(prefix)) sessionStorage.removeItem(name); } } storageInitialized = true; }; // Только ограниченный JSON: не теряем молча поля, NaN или функции при сериализации. const json = value => { const text = JSON.stringify(value, (key, item) => { if (item === undefined || typeof item === 'function' || typeof item === 'symbol' || typeof item === 'bigint' || (typeof item === 'number' && !Number.isFinite(item))) throw failure('invalid JSON'); return item; }); if (!text || text.length > 65536) throw failure('metadata too large'); return JSON.parse(text); }; const valid = record => { try { return record && typeof record.key === 'string' && record.key.length > 0 && record.key.length <= 200 && Number.isFinite(record.createdAt) && record.createdAt <= Date.now() && record.expiresAt === record.createdAt + ttl && record.expiresAt > Date.now() && typeof record.sourceUrl === 'string' && record.sourceUrl.length <= 16384 && typeof record.slot === 'string' && json(record.meta) !== undefined && (record.result === null ? (record.blob === null || (record.blob instanceof Blob && record.fileInfo && typeof record.fileInfo.name === 'string' && typeof record.fileInfo.type === 'string' && Number.isFinite(record.fileInfo.lastModified))) : (record.blob === null && record.fileInfo === null && json(record.result) !== undefined)); } catch (_) { return false; } }; const open = () => new Promise((resolve, reject) => { let request; try { request = indexedDB.open('zoomzilla-upload-recovery', 1); } catch (error) { reject(error); return; } let abandoned = false; request.onupgradeneeded = () => request.result.createObjectStore('uploads'); request.onerror = () => reject(request.error || failure('open failed')); request.onblocked = () => { abandoned = true; reject(failure('database blocked')); }; request.onsuccess = () => { if (abandoned) { request.result.close(); return; } request.result.onversionchange = () => request.result.close(); resolve(request.result); }; }); const transaction = async callback => { const db = await open(); try { return await new Promise((resolve, reject) => { let tx, result, cause; try { tx = db.transaction('uploads', 'readwrite'); } catch (error) { reject(error); return; } tx.oncomplete = () => resolve(result); tx.onabort = () => reject(cause || tx.error || failure('transaction aborted')); tx.onerror = () => { /* onabort даёт окончательный результат всей транзакции. */ }; const fail = error => { cause = error; try { tx.abort(); } catch (_) { reject(error); } }; try { const store = tx.objectStore('uploads'); // Общая уборка сирот/протухших записей, включая давно закрытые вкладки. const cursor = store.openCursor(); cursor.onsuccess = () => { try { const entry = cursor.result; if (!entry) return; if (!valid(entry.value)) entry.delete(); entry.continue(); } catch (error) { fail(error); } }; callback(store, value => { result = value; }, fail); } catch (error) { fail(error); } }); } finally { db.close(); } }; const unpack = record => ({ key: record.key, createdAt: record.createdAt, expiresAt: record.expiresAt, sourceUrl: record.sourceUrl, meta: json(record.meta), result: record.result === null ? null : json(record.result), // Structured clone может вернуть Blob без File-прототипа: метаданные хранятся отдельно. file: record.blob === null ? null : new File([record.blob], record.fileInfo.name, {type: record.fileInfo.type, lastModified: record.fileInfo.lastModified}) }); window.ZoomzillaUploadRecovery = { create({scope, target}) { if (typeof scope !== 'string' || !scope || typeof target !== 'string' || !target) throw failure('scope and target required'); const slot = prefix + 'slot:' + JSON.stringify([scope, target]); if (!slots.has(slot)) slots.set(slot, {queue: Promise.resolve(), generation: 0}); const state = slots.get(slot); const enqueue = operation => { const pending = state.queue.then(operation); state.queue = pending.catch(() => {}); return pending; }; const get = (store, id, done, fail) => { if (!id) { done(null); return; } const request = store.get(id); request.onsuccess = () => { try { done(request.result || null); } catch (error) { fail(error); } }; }; const current = record => valid(record) && record.slot === slot; return { remember(file, meta, key) { const generation = state.generation; return enqueue(async () => { if (generation !== state.generation) throw failure('cancelled'); initializeStorage(); const previous = sessionStorage.getItem(slot); // Неудачная новая попытка никогда не восстанавливает прежний файл. sessionStorage.removeItem(slot); const metadata = json(meta); if (file !== null && !(file instanceof File)) throw failure('prepared File required'); if (key !== undefined && (typeof key !== 'string' || !key || key.length > 200)) throw failure('invalid key'); let tab = sessionStorage.getItem(prefix + 'tab'); if (!tab) { tab = randomKey(); sessionStorage.setItem(prefix + 'tab', tab); } const id = tab + ':' + randomKey(); let record; await transaction((store, done, fail) => get(store, previous, old => { const resumed = old && old.key === key; if (resumed && !current(old)) throw failure('expired resume'); const createdAt = resumed ? old.createdAt : Date.now(); record = {slot, key: key || randomKey(), createdAt, expiresAt: createdAt + ttl, sourceUrl: resumed ? old.sourceUrl : location.href, meta: metadata, blob: file, fileInfo: file === null ? null : {name: file.name, type: file.type, lastModified: file.lastModified}, result: null}; if (previous) store.delete(previous); store.put(record, id); done(null); }, fail)); if (generation !== state.generation) { await transaction(store => store.delete(id)); throw failure('cancelled'); } try { sessionStorage.setItem(slot, id); } catch (error) { // Если указатель недоступен, не оставляем Blob до конца TTL. try { await transaction(store => store.delete(id)); } catch (_) { /* TTL уберёт сироту. */ } throw error; } return unpack(record); }); }, load() { const generation = state.generation; return enqueue(async () => { if (generation !== state.generation) return null; initializeStorage(); const id = sessionStorage.getItem(slot); const record = await transaction((store, done, fail) => get(store, id, stored => { if (stored && current(stored)) { done(stored); return; } if (id) store.delete(id); done(null); }, fail)); if (generation !== state.generation) return null; if (!record) { if (sessionStorage.getItem(slot) === id) sessionStorage.removeItem(slot); return null; } return unpack(record); }); }, accepted(result) { const generation = state.generation; return enqueue(async () => { initializeStorage(); const receipt = json(result); if (receipt === null) throw failure('receipt required'); const id = sessionStorage.getItem(slot); return transaction((store, done, fail) => get(store, id, record => { if (generation !== state.generation || !current(record)) throw failure('missing pending upload'); record.blob = null; record.fileInfo = null; record.result = receipt; store.put(record, id); done(unpack(record)); }, fail)); }); }, clear() { state.generation++; let id; try { initializeStorage(); id = sessionStorage.getItem(slot); sessionStorage.removeItem(slot); } catch (error) { return Promise.reject(error); } return enqueue(() => transaction(store => { if (id) store.delete(id); })); } }; } }; })(); JS;