Восстанавливать загрузку30мин после Reload и удерживать Back (#9)
This commit is contained in:
1 parent
f2e6877013
commit
d70d396670
3 files changed
+320
-19
No files matched your search
+63
-19
@@ -1,5 +1,7 @@
|
||||
<?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', 'Возникла ошибка');
|
||||
$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(); }
|
||||
})();
|
||||
})();
|
||||
</script>
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
Reference in new issue
Block a user