Files
preland/components/upload-recovery.php
T

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;