Восстанавливать загрузку30мин после Reload и удерживать Back (#9)

This commit is contained in:
Panfilov Konstantin committed 2026-10-20 18:31:43 +03:00
1 parent f2e6877013
commit d70d396670
3 files changed
+320 -19

No files matched your search

+63 -19
View File
@@ -1,5 +1,7 @@
<?php <?php
require __DIR__ . '/browser-upload.php'; require __DIR__ . '/browser-upload.php';
echo '<script>', require __DIR__ . '/navigation-guard.php', '</script>';
echo '<script>', require __DIR__ . '/upload-recovery.php', '</script>';
$errorMessage = Yii::t('app', 'Возникла ошибка'); $errorMessage = Yii::t('app', 'Возникла ошибка');
$utmJson = json_encode($UTM ?? [], JSON_UNESCAPED_UNICODE); $utmJson = json_encode($UTM ?? [], JSON_UNESCAPED_UNICODE);
@@ -98,6 +100,11 @@ $basePickerAccept = 'image/jpeg,image/png,image/gif,image/webp,image/bmp,image/a
} }
let uploadPending = false; let uploadPending = false;
let leavingUploadPage = false;
let uploadAttempt = 0;
const recovery = window.ZoomzillaUploadRecovery.create({scope: 'preland', target: createApiUrl});
const metadata = () => ({actionUrl: createApiUrl, redirectBase: redirectUrlBase, redirectUtm: redirectUrlUtm});
const clearRecovery = () => recovery.clear().catch(() => {});
let uploadDots = 1; let uploadDots = 1;
let uploadDotsTimer = null; let uploadDotsTimer = null;
const uploadButton = formEl.querySelector('.dropzone-form-button'); const uploadButton = formEl.querySelector('.dropzone-form-button');
@@ -111,6 +118,8 @@ $basePickerAccept = 'image/jpeg,image/png,image/gif,image/webp,image/bmp,image/a
function beginUploadPending() { function beginUploadPending() {
if (uploadPending) return false; if (uploadPending) return false;
uploadPending = true; uploadPending = true;
uploadAttempt++;
window.PublicNavigationGuard.activate();
uploadDots = 1; uploadDots = 1;
renderUploadButton(); renderUploadButton();
if (uploadButton) { if (uploadButton) {
@@ -191,51 +200,62 @@ $basePickerAccept = 'image/jpeg,image/png,image/gif,image/webp,image/bmp,image/a
: Array.from(crypto.getRandomValues(new Uint8Array(16)), (b) => b.toString(16).padStart(2, '0')).join(''); : Array.from(crypto.getRandomValues(new Uint8Array(16)), (b) => b.toString(16).padStart(2, '0')).join('');
} }
function redirectToTour(clientKey) { function redirectToTour(record) {
let targetUrl = redirectUrlBase + encodeURIComponent(clientKey); let targetUrl = record.meta.redirectBase + encodeURIComponent(record.key);
if (redirectUrlUtm) targetUrl += '&' + redirectUrlUtm; if (record.meta.redirectUtm) targetUrl += '&' + record.meta.redirectUtm;
window.location.href = targetUrl; window.PublicNavigationGuard.allowNavigation(() => { window.location.href = targetUrl; });
} }
function rejectOversizedFile(file) { async function rejectOversizedFile(file, saved = null) {
if (!beginUploadPending()) return; if (!beginUploadPending()) return;
const clientKey = createClientKey(); const attempt = uploadAttempt;
try {
const record = saved || await recovery.remember(null, {...metadata(), rejectionCode: 105, name: file.name, size: file.size});
if (attempt !== uploadAttempt || leavingUploadPage) return;
const clientKey = record.key;
const formData = new FormData(); const formData = new FormData();
formData.append('rejection_code', '105'); formData.append('rejection_code', '105');
formData.append('original_name', file.name || 'upload'); formData.append('original_name', file.name || 'upload');
formData.append('original_size', String(file.size || 0)); formData.append('original_size', String(file.size || 0));
formData.append('client_key', clientKey); formData.append('client_key', clientKey);
const xhr = new XMLHttpRequest(); const xhr = new XMLHttpRequest();
xhr.open('POST', createApiUrl || formEl.getAttribute('action') || '/api/images/public/create', true); xhr.open('POST', record.meta.actionUrl, true);
xhr.withCredentials = true; xhr.withCredentials = true;
xhr.setRequestHeader('Accept', 'application/json'); xhr.setRequestHeader('Accept', 'application/json');
xhr.onload = () => { xhr.onload = async () => {
if (xhr.status >= 200 && xhr.status < 300) { if (xhr.status >= 200 && xhr.status < 300) {
redirectToTour(clientKey); try { const result = JSON.parse(xhr.responseText); if (!result?.id) throw new Error(); await recovery.accepted({id: result.id}); redirectToTour(record); }
catch (_) { showUploadError(genericErrorMsg); }
return; return;
} }
showUploadError(genericErrorMsg); showUploadError(genericErrorMsg);
}; };
xhr.onerror = () => showUploadError(genericErrorMsg); xhr.onerror = () => showUploadError(genericErrorMsg, true);
xhr.send(formData); xhr.send(formData);
} catch (_) { showUploadError(genericErrorMsg); }
} }
function uploadFile(file) { function uploadFile(file, saved = null) {
const attempt = uploadAttempt;
// Тот же обход нестабильного Android FileProvider, что на основном сайте. // Тот же обход нестабильного Android FileProvider, что на основном сайте.
window.ZoomzillaUpload.prepare(file, maxUploadBytes || 15 * 1024 * 1024) window.ZoomzillaUpload.prepare(file, maxUploadBytes || 15 * 1024 * 1024)
.then(bytes => uploadPreparedFile(file, bytes)) .then(async bytes => {
if (attempt !== uploadAttempt || leavingUploadPage) return;
const record = saved || await recovery.remember(new File([bytes], file.name, {type: file.type, lastModified: file.lastModified}), metadata());
if (attempt === uploadAttempt && !leavingUploadPage) uploadPreparedFile(file, bytes, record);
})
.catch(() => showUploadError(genericErrorMsg)); .catch(() => showUploadError(genericErrorMsg));
} }
function uploadPreparedFile(file, bytes) { function uploadPreparedFile(file, bytes, record) {
const xhr = new XMLHttpRequest(); const xhr = new XMLHttpRequest();
const formData = new FormData(); const formData = new FormData();
// Ключ загрузки: по нему сайт передаёт её в сессию посетителя (/api/images/public/claim, годен 30 мин). // Ключ загрузки: по нему сайт передаёт её в сессию посетителя (/api/images/public/claim, годен 30 мин).
// randomUUID есть в HTTPS-контексте; где его нет (старый Safari), есть getRandomValues. // randomUUID есть в HTTPS-контексте; где его нет (старый Safari), есть getRandomValues.
const clientKey = createClientKey(); const clientKey = record.key;
formData.append('file', bytes, file.name || 'upload'); formData.append('file', bytes, file.name || 'upload');
formData.append('client_key', clientKey); formData.append('client_key', clientKey);
const actionUrl = createApiUrl || formEl.getAttribute('action') || '/api/images/public/create'; const actionUrl = record.meta.actionUrl;
xhr.open('POST', actionUrl, true); xhr.open('POST', actionUrl, true);
xhr.withCredentials = true; xhr.withCredentials = true;
xhr.setRequestHeader('Accept', 'application/json'); xhr.setRequestHeader('Accept', 'application/json');
@@ -247,7 +267,7 @@ $basePickerAccept = 'image/jpeg,image/png,image/gif,image/webp,image/bmp,image/a
} }
}; };
xhr.onload = () => { xhr.onload = async () => {
if (xhr.status >= 200 && xhr.status < 300) { if (xhr.status >= 200 && xhr.status < 300) {
let response; let response;
try { try {
@@ -269,24 +289,48 @@ $basePickerAccept = 'image/jpeg,image/png,image/gif,image/webp,image/bmp,image/a
} }
// Переход: $redirect_url_base . client_key . ($redirect_url_utm ? '&' . $redirect_url_utm : '') // Переход: $redirect_url_base . client_key . ($redirect_url_utm ? '&' . $redirect_url_utm : '')
redirectToTour(clientKey); try { await recovery.accepted({id: imageId}); redirectToTour(record); }
catch (_) { showUploadError(genericErrorMsg); }
} else { } else {
showUploadError(genericErrorMsg); showUploadError(genericErrorMsg);
} }
}; };
xhr.onerror = () => { xhr.onerror = () => {
showUploadError(genericErrorMsg); showUploadError(genericErrorMsg, true);
}; };
xhr.send(formData); xhr.send(formData);
} }
function showUploadError(msg) { function showUploadError(msg, keepRecovery = false) {
if (leavingUploadPage) return;
uploadAttempt++;
if (!keepRecovery) clearRecovery();
resetUploadPending(); resetUploadPending();
window.PublicNavigationGuard.allowNavigation();
form.show(); form.show();
if (typeof preview !== 'undefined' && preview) preview.showError(msg); if (typeof preview !== 'undefined' && preview) preview.showError(msg);
fileInput.value = ''; fileInput.value = '';
} }
window.addEventListener('pagehide', () => { leavingUploadPage = true; });
window.addEventListener('pageshow', event => {
if (!event.persisted) return;
leavingUploadPage = false;
uploadAttempt++;
const wasUploading = uploadPending;
resetUploadPending(); clearRecovery(); fileInput.value = '';
if (wasUploading) window.PublicNavigationGuard.allowNavigation();
});
(async () => {
try {
const saved = await recovery.load();
if (!saved) return;
if (performance.getEntriesByType('navigation')[0]?.type !== 'reload' || saved.sourceUrl !== location.href || saved.meta.actionUrl !== createApiUrl) { clearRecovery(); return; }
if (saved.result?.id) { beginUploadPending(); redirectToTour(saved); }
else if (saved.file) { beginUploadPending(); uploadFile(saved.file, saved); }
else if (saved.meta.rejectionCode === 105) rejectOversizedFile(saved.meta, saved);
} catch (_) { clearRecovery(); }
})();
})(); })();
</script> </script>
+70
View File
@@ -0,0 +1,70 @@
<?php
// Общий inline-контракт Back для незавершённой публичной операции (api#299/preland#9).
return <<<'JS'
(() => {
let active = false;
let hasHistoryEntry = false;
let releasingHistoryEntry = false;
let returningToSentinel = false;
function pushGuardEntries() {
// Помечаем и текущую запись Tour, и sentinel сверху. Иначе первый Back
// останавливался на немаркированной записи, а второй мог уйти в about:blank.
window.history.replaceState({ publicOperationGuard: true }, '', window.location.href);
window.history.pushState({ publicOperationGuard: true }, '', window.location.href);
hasHistoryEntry = true;
}
function onPopState(event) {
if (releasingHistoryEntry) {
releasingHistoryEntry = false;
return;
}
if (active) {
if (returningToSentinel) {
returningToSentinel = false;
return;
}
returningToSentinel = true;
window.history.go(1);
}
}
function deactivate(onReleased) {
if (!active) {
if (onReleased) {
if (releasingHistoryEntry) {
window.addEventListener('popstate', onReleased, { once: true });
} else {
onReleased();
}
}
return;
}
active = false;
if (hasHistoryEntry) {
hasHistoryEntry = false;
releasingHistoryEntry = true;
if (onReleased) window.addEventListener('popstate', onReleased, { once: true });
window.history.back();
return;
}
if (onReleased) onReleased();
}
window.addEventListener('popstate', onPopState);
window.PublicNavigationGuard = {
activate() {
if (active) return;
releasingHistoryEntry = false;
returningToSentinel = false;
active = true;
pushGuardEntries();
},
deactivate,
allowNavigation: deactivate,
};
})();
JS;
+187
View File
@@ -0,0 +1,187 @@
<?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;
// 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;