209 lines
13 KiB
PHP
209 lines
13 KiB
PHP
<?php
|
|
/** Общее восстановление текущей публичной загрузки; UI и навигацией управляет вызывающая форма. */
|
|
return <<<'JS'
|
|
(() => {
|
|
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;
|
|
// Дублирование вкладки копирует sessionStorage даже без opener. Новый
|
|
// заход отвязывает только указатели: IndexedDB исходной вкладки не трогаем.
|
|
if (performance.getEntriesByType('navigation')[0]?.type !== 'reload') {
|
|
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 ArrayBuffer || 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, settled = false;
|
|
const finish = (error, db) => {
|
|
if (settled) { if (db) db.close(); return; }
|
|
settled = true;
|
|
window.clearTimeout(timer);
|
|
if (error) reject(error); else resolve(db);
|
|
};
|
|
const timer = window.setTimeout(() => finish(failure('open timeout')), 10000);
|
|
try { request = indexedDB.open('zoomzilla-upload-recovery', 1); } catch (error) { finish(error); return; }
|
|
request.onupgradeneeded = () => request.result.createObjectStore('uploads');
|
|
request.onerror = () => finish(request.error || failure('open failed'));
|
|
request.onblocked = () => finish(failure('database blocked'));
|
|
request.onsuccess = () => {
|
|
request.result.onversionchange = () => request.result.close();
|
|
finish(null, request.result);
|
|
};
|
|
});
|
|
const transaction = async callback => {
|
|
const db = await open();
|
|
try {
|
|
return await new Promise((resolve, reject) => {
|
|
let tx, result, settled = false;
|
|
try { tx = db.transaction('uploads', 'readwrite'); } catch (error) { reject(error); return; }
|
|
const finish = error => {
|
|
if (settled) return;
|
|
settled = true;
|
|
window.clearTimeout(timer);
|
|
if (error) reject(error); else resolve(result);
|
|
};
|
|
const fail = error => {
|
|
finish(error);
|
|
try { tx.abort(); } catch (_) { /* Транзакция могла уже завершиться. */ }
|
|
};
|
|
const timer = window.setTimeout(() => fail(failure('transaction timeout')), 10000);
|
|
tx.oncomplete = () => finish();
|
|
tx.onabort = () => finish(tx.error || failure('transaction aborted'));
|
|
// WebKit может дать request/transaction error без последующего abort.
|
|
tx.onerror = event => fail(event.target.error || tx.error || failure('transaction failed'));
|
|
try {
|
|
const store = tx.objectStore('uploads');
|
|
// Общая уборка сирот/протухших записей, включая давно закрытые вкладки.
|
|
const cursor = store.openCursor();
|
|
cursor.onsuccess = () => {
|
|
if (settled) return;
|
|
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');
|
|
// ArrayBuffer не зависит от поддержки Blob-файлов в IndexedDB
|
|
// (в WebKit её отсутствие проявляется асинхронным UnknownError).
|
|
const bytes = file === null ? null : await file.arrayBuffer();
|
|
if (generation !== state.generation) throw failure('cancelled');
|
|
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: bytes, 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;
|