diff --git a/components/form.php b/components/form.php index f69af6f..6118b01 100644 --- a/components/form.php +++ b/components/form.php @@ -1,5 +1,7 @@ ', require __DIR__ . '/navigation-guard.php', ''; +echo ''; $errorMessage = Yii::t('app', 'Возникла ошибка'); $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 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 uploadDotsTimer = null; 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() { if (uploadPending) return false; uploadPending = true; + uploadAttempt++; + window.PublicNavigationGuard.activate(); uploadDots = 1; renderUploadButton(); 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(''); } - function redirectToTour(clientKey) { - let targetUrl = redirectUrlBase + encodeURIComponent(clientKey); - if (redirectUrlUtm) targetUrl += '&' + redirectUrlUtm; - window.location.href = targetUrl; + function redirectToTour(record) { + let targetUrl = record.meta.redirectBase + encodeURIComponent(record.key); + if (record.meta.redirectUtm) targetUrl += '&' + record.meta.redirectUtm; + window.PublicNavigationGuard.allowNavigation(() => { window.location.href = targetUrl; }); } - function rejectOversizedFile(file) { + async function rejectOversizedFile(file, saved = null) { 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(); formData.append('rejection_code', '105'); formData.append('original_name', file.name || 'upload'); formData.append('original_size', String(file.size || 0)); formData.append('client_key', clientKey); 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.setRequestHeader('Accept', 'application/json'); - xhr.onload = () => { + xhr.onload = async () => { 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; } showUploadError(genericErrorMsg); }; - xhr.onerror = () => showUploadError(genericErrorMsg); + xhr.onerror = () => showUploadError(genericErrorMsg, true); xhr.send(formData); + } catch (_) { showUploadError(genericErrorMsg); } } - function uploadFile(file) { + function uploadFile(file, saved = null) { + const attempt = uploadAttempt; // Тот же обход нестабильного Android FileProvider, что на основном сайте. 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)); } - function uploadPreparedFile(file, bytes) { + function uploadPreparedFile(file, bytes, record) { const xhr = new XMLHttpRequest(); const formData = new FormData(); // Ключ загрузки: по нему сайт передаёт её в сессию посетителя (/api/images/public/claim, годен 30 мин). // randomUUID есть в HTTPS-контексте; где его нет (старый Safari), есть getRandomValues. - const clientKey = createClientKey(); + const clientKey = record.key; formData.append('file', bytes, file.name || 'upload'); 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.withCredentials = true; 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) { let response; 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 : '') - redirectToTour(clientKey); + try { await recovery.accepted({id: imageId}); redirectToTour(record); } + catch (_) { showUploadError(genericErrorMsg); } } else { showUploadError(genericErrorMsg); } }; xhr.onerror = () => { - showUploadError(genericErrorMsg); + showUploadError(genericErrorMsg, true); }; xhr.send(formData); } - function showUploadError(msg) { + function showUploadError(msg, keepRecovery = false) { + if (leavingUploadPage) return; + uploadAttempt++; + if (!keepRecovery) clearRecovery(); resetUploadPending(); + window.PublicNavigationGuard.allowNavigation(); form.show(); if (typeof preview !== 'undefined' && preview) preview.showError(msg); 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(); } + })(); })(); diff --git a/components/navigation-guard.php b/components/navigation-guard.php new file mode 100644 index 0000000..3f4c44c --- /dev/null +++ b/components/navigation-guard.php @@ -0,0 +1,70 @@ + { + 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; diff --git a/components/upload-recovery.php b/components/upload-recovery.php new file mode 100644 index 0000000..9add68a --- /dev/null +++ b/components/upload-recovery.php @@ -0,0 +1,187 @@ + { + 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;