diff --git a/components/form.php b/components/form.php index 64c94d8..ece777c 100644 --- a/components/form.php +++ b/components/form.php @@ -194,12 +194,6 @@ $basePickerAccept = 'image/jpeg,image/png,image/gif,image/webp,image/bmp,image/a uploadFile(file); } - function createClientKey() { - return crypto.randomUUID - ? crypto.randomUUID() - : Array.from(crypto.getRandomValues(new Uint8Array(16)), (b) => b.toString(16).padStart(2, '0')).join(''); - } - function redirectToTour(record) { let targetUrl = record.meta.redirectBase + encodeURIComponent(record.key); if (record.meta.redirectUtm) targetUrl += '&' + record.meta.redirectUtm; @@ -313,7 +307,10 @@ $basePickerAccept = 'image/jpeg,image/png,image/gif,image/webp,image/bmp,image/a resetUploadPending(); window.PublicNavigationGuard.allowNavigation(); form.show(); - if (typeof preview !== 'undefined' && preview) preview.showError(msg); + if (typeof preview !== 'undefined' && preview) { + preview.show(); preview.showError(msg); + preview.el.scrollIntoView({block: 'nearest'}); + } fileInput.value = ''; } window.addEventListener('pagehide', () => { leavingUploadPage = true; }); diff --git a/components/upload-recovery.php b/components/upload-recovery.php index 9add68a..fa51d37 100644 --- a/components/upload-recovery.php +++ b/components/upload-recovery.php @@ -11,9 +11,9 @@ return <<<'JS' let storageInitialized = false; const initializeStorage = () => { if (storageInitialized) return; - // window.open копирует sessionStorage родителя. Новый документ с opener - // начинает собственный слот; настоящий Reload этой вкладки сохраняет его. - if (window.opener && performance.getEntriesByType('navigation')[0]?.type === 'navigate') { + // Дублирование вкладки копирует sessionStorage даже без opener. Новый + // заход отвязывает только указатели: IndexedDB исходной вкладки не трогаем. + if (performance.getEntriesByType('navigation')[0]?.type !== 'reload') { for (const name of Object.keys(sessionStorage)) { if (name.startsWith(prefix)) sessionStorage.removeItem(name); } @@ -36,37 +36,54 @@ return <<<'JS' && 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)); + && (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; - try { request = indexedDB.open('zoomzilla-upload-recovery', 1); } catch (error) { reject(error); return; } - let abandoned = false; + 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 = () => reject(request.error || failure('open failed')); - request.onblocked = () => { abandoned = true; reject(failure('database blocked')); }; + request.onerror = () => finish(request.error || failure('open failed')); + request.onblocked = () => finish(failure('database blocked')); request.onsuccess = () => { - if (abandoned) { request.result.close(); return; } request.result.onversionchange = () => request.result.close(); - resolve(request.result); + finish(null, request.result); }; }); const transaction = async callback => { const db = await open(); try { return await new Promise((resolve, reject) => { - let tx, result, cause; + let tx, result, settled = false; 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); } }; + 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; @@ -113,6 +130,10 @@ return <<<'JS' 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); } @@ -124,7 +145,7 @@ return <<<'JS' 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}; + 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);