349 lines
15 KiB
PHP
349 lines
15 KiB
PHP
<?php
|
|
require_once __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);
|
|
|
|
$createApiUrl = ($apiHost ?? '') . '/api/images/public/create';
|
|
$uploadPolicyApiUrl = ($apiHost ?? '') . '/api/images/public/upload-policy';
|
|
$redirectUrlBase = $redirect_url_base ?? '';
|
|
$redirectUrlUtm = $redirect_url_utm ?? '';
|
|
$basePickerAccept = 'image/jpeg,image/png,image/gif,image/webp,image/bmp,image/avif,image/heic,image/heif,image/jxl,image/tiff,application/pdf,.pdf,.jpg,.jpeg,.png,.gif,.webp,.bmp,.avif,.heic,.heif,.jxl,.tif,.tiff,.dng';
|
|
|
|
?>
|
|
|
|
<form action="<?= htmlspecialchars($createApiUrl, ENT_QUOTES, 'UTF-8') ?>" class="dropzone-form gradient-dashed-border" id="dropzone-form" data-photo-label="<?= htmlspecialchars(Yii::t('app', 'Выберите фото'), ENT_QUOTES, 'UTF-8') ?>" method="POST" style="background: transparent; border: unset; display: <?= ($show ?? true) ? '' : 'none'; ?>; cursor: pointer;">
|
|
|
|
<div class="dropzone-form-inner">
|
|
<div class="dropzone-form-icon">
|
|
<img src="<?= $assetsUriPrefix; ?>/images/dropzone-icon.svg" alt="" height="65" width="64">
|
|
</div>
|
|
<div id="form-title" class="dropzone-form-text index-upload-heading"><?= Yii::t('app', 'Улучшить изображение') ?></div>
|
|
<div class="dropzone-form-format">
|
|
<div class="dropzone-form-format-label"><?= Yii::t('app', 'Поддерживаемые форматы') ?>:</div>
|
|
<div class="dropzone-form-format-list">
|
|
<div class="dropzone-form-format-item">JPG</div>
|
|
<div class="dropzone-form-format-item">PNG</div>
|
|
<div class="dropzone-form-format-item">HEIC</div>
|
|
<div class="dropzone-form-format-item">WebP</div>
|
|
<div class="dropzone-form-format-item">GIF</div>
|
|
<div class="dropzone-form-format-item" data-format-optional>PDF</div>
|
|
<div class="dropzone-form-format-item">+много</div>
|
|
</div>
|
|
</div>
|
|
<div class="dropzone-form-action">
|
|
<button type="button" class="dropzone-form-button">
|
|
<span data-upload-label><?= Yii::t('app', preg_match('/Android/i', $_SERVER['HTTP_USER_AGENT'] ?? '') ? 'Выберите фото' : 'Загрузить изображение') ?></span>
|
|
<svg width="20" height="21" viewBox="0 0 20 21" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
<path d="M13.3333 14.6667C15.6345 14.6667 17.5 12.8012 17.5 10.5C17.5 8.43079 15.9917 6.71387 14.0145 6.38874C14.0113 6.38821 14.0086 6.38583 14.0078 6.38267C13.4802 4.43373 11.6992 3 9.58333 3C7.05203 3 5 5.05203 5 7.58333C5 7.75477 5.00941 7.924 5.02775 8.09055C5.0282 8.0947 5.0255 8.09856 5.02145 8.09957C3.57289 8.4621 2.5 9.77242 2.5 11.3333C2.5 13.1743 3.99238 14.6667 5.83333 14.6667H6.66667M10 8.83726V18M10 8.83726L7.5 11.3333M10 8.83726L12.5 11.3333" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
|
</svg>
|
|
</button>
|
|
<?php require __DIR__ . '/upload-picker-links.php'; ?>
|
|
</div>
|
|
</div>
|
|
|
|
<input type="file" id="dropzone-file-input" name="file" accept="<?= htmlspecialchars($basePickerAccept, ENT_QUOTES, 'UTF-8') ?>" style="display: none;">
|
|
<span data-upload-markup-ready hidden></span>
|
|
</form>
|
|
|
|
<script>
|
|
const form = {
|
|
el: document.getElementById('dropzone-form'),
|
|
titleEl: document.getElementById('form-title'),
|
|
|
|
hide() {
|
|
window.scrollTo(0, 0);
|
|
window.scrollTo({ top: 0, behavior: 'instant' });
|
|
if (this.el) this.el.style.display = 'none';
|
|
},
|
|
show() {
|
|
if (this.el) this.el.style.display = '';
|
|
},
|
|
setTitle(html) {
|
|
if (this.titleEl) {
|
|
this.titleEl.classList.remove('index-upload-heading');
|
|
this.titleEl.innerHTML = html;
|
|
}
|
|
}
|
|
};
|
|
|
|
const utmData = <?= $utmJson ?>;
|
|
const redirectUrlBase = <?= json_encode($redirectUrlBase, JSON_UNESCAPED_UNICODE) ?>;
|
|
const redirectUrlUtm = <?= json_encode($redirectUrlUtm, JSON_UNESCAPED_UNICODE) ?>;
|
|
const createApiUrl = <?= json_encode($createApiUrl, JSON_UNESCAPED_UNICODE) ?>;
|
|
const uploadPolicyApiUrl = <?= json_encode($uploadPolicyApiUrl, JSON_UNESCAPED_UNICODE) ?>;
|
|
|
|
const genericErrorMsg = <?= json_encode($errorMessage, JSON_UNESCAPED_UNICODE) ?>;
|
|
|
|
(function initNativeUploader() {
|
|
const formEl = document.getElementById('dropzone-form');
|
|
const fileInput = document.getElementById('dropzone-file-input');
|
|
if (!formEl || !fileInput) return;
|
|
|
|
// `accept` — только удобство picker. Любой drop/подменённое расширение
|
|
// отправляется в content-based Tour gate, где и показывается controlled error.
|
|
let pickerAccept = <?= json_encode($basePickerAccept, JSON_UNESCAPED_UNICODE) ?>;
|
|
let maxUploadBytes = 0;
|
|
window.ZoomzillaUpload.bindPicker(formEl, fileInput, () => pickerAccept, files => handleSelectedFile(files[0]));
|
|
if (uploadPolicyApiUrl && window.fetch) {
|
|
fetch(uploadPolicyApiUrl, { credentials: 'omit', cache: 'no-store' })
|
|
.then(response => response.ok ? response.json() : null)
|
|
.then(policy => {
|
|
if (typeof policy?.picker_accept === 'string' && policy.picker_accept !== '') {
|
|
pickerAccept = policy.picker_accept;
|
|
fileInput.accept = /Android/i.test(navigator.userAgent) ? 'image/*' : pickerAccept;
|
|
}
|
|
if (policy?.pdf_observation_enabled === false) formEl.querySelector('[data-files-picker]').textContent = <?= json_encode(Yii::t('app', 'Файлы (другие форматы)'), JSON_UNESCAPED_UNICODE) ?>;
|
|
if (Number.isSafeInteger(policy?.max_file_size_bytes) && policy.max_file_size_bytes > 0) {
|
|
maxUploadBytes = policy.max_file_size_bytes;
|
|
}
|
|
})
|
|
.catch(() => {});
|
|
}
|
|
|
|
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');
|
|
const uploadButtonLabel = uploadButton?.querySelector('[data-upload-label]');
|
|
const uploadButtonDefaultLabel = uploadButtonLabel?.textContent || '';
|
|
|
|
function renderUploadButton() {
|
|
window.ZoomzillaUpload.renderLoadingLabel(uploadButtonLabel, uploadDots, <?= json_encode(Yii::t('app', 'Загрузка'), JSON_UNESCAPED_UNICODE) ?>);
|
|
}
|
|
|
|
function beginUploadPending() {
|
|
if (uploadPending) return false;
|
|
uploadPending = true;
|
|
window.ZoomzillaUpload.setPending(formEl, true);
|
|
uploadAttempt++;
|
|
window.PublicNavigationGuard.activate();
|
|
uploadDots = 1;
|
|
renderUploadButton();
|
|
if (uploadButton) {
|
|
uploadButton.disabled = true;
|
|
uploadButton.setAttribute('aria-busy', 'true');
|
|
}
|
|
uploadDotsTimer = window.setInterval(() => {
|
|
uploadDots = (uploadDots + 1) % 6;
|
|
renderUploadButton();
|
|
}, 500);
|
|
return true;
|
|
}
|
|
|
|
function resetUploadPending() {
|
|
uploadPending = false;
|
|
window.ZoomzillaUpload.setPending(formEl, false);
|
|
if (uploadDotsTimer !== null) window.clearInterval(uploadDotsTimer);
|
|
uploadDotsTimer = null;
|
|
if (uploadButtonLabel) uploadButtonLabel.textContent = uploadButtonLabel.dataset.pickerLabel || uploadButtonDefaultLabel;
|
|
if (uploadButton) {
|
|
uploadButton.disabled = false;
|
|
uploadButton.removeAttribute('aria-busy');
|
|
}
|
|
}
|
|
|
|
formEl.addEventListener('click', (e) => {
|
|
if (uploadPending) {
|
|
e.preventDefault();
|
|
return;
|
|
}
|
|
if (e.target !== fileInput) {
|
|
fileInput.click();
|
|
}
|
|
});
|
|
|
|
['dragenter', 'dragover'].forEach(eventName => {
|
|
formEl.addEventListener(eventName, (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
formEl.classList.add('dragover');
|
|
});
|
|
});
|
|
|
|
['dragleave', 'dragend', 'drop'].forEach(eventName => {
|
|
formEl.addEventListener(eventName, (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
formEl.classList.remove('dragover');
|
|
});
|
|
});
|
|
|
|
formEl.addEventListener('drop', (e) => {
|
|
if (uploadPending) return;
|
|
const files = e.dataTransfer?.files;
|
|
if (files && files.length > 0) {
|
|
handleSelectedFile(files[0]);
|
|
}
|
|
});
|
|
|
|
fileInput.addEventListener('change', () => {
|
|
if (uploadPending) return;
|
|
if (fileInput.files && fileInput.files.length > 0) {
|
|
handleSelectedFile(fileInput.files[0]);
|
|
}
|
|
});
|
|
|
|
function handleSelectedFile(file) {
|
|
if (maxUploadBytes > 0 && file.size > maxUploadBytes) {
|
|
rejectOversizedFile(file);
|
|
return;
|
|
}
|
|
if (!beginUploadPending()) return;
|
|
uploadFile(file);
|
|
}
|
|
|
|
function redirectToTour(record) {
|
|
let targetUrl = record.meta.redirectBase + encodeURIComponent(record.key);
|
|
if (record.meta.redirectUtm) targetUrl += '&' + record.meta.redirectUtm;
|
|
const shownTheme = window.ZoomzillaTheme.current();
|
|
targetUrl += '&shown_theme=' + encodeURIComponent(shownTheme);
|
|
window.PublicNavigationGuard.allowNavigation(() => { window.location.href = targetUrl; });
|
|
}
|
|
|
|
async function rejectOversizedFile(file, saved = null) {
|
|
if (!beginUploadPending()) return;
|
|
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', record.meta.actionUrl, true);
|
|
xhr.withCredentials = true;
|
|
xhr.setRequestHeader('Accept', 'application/json');
|
|
xhr.onload = async () => {
|
|
if (attempt !== uploadAttempt || leavingUploadPage) return;
|
|
if (xhr.status >= 200 && xhr.status < 300) {
|
|
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 = () => { if (attempt === uploadAttempt && !leavingUploadPage) showUploadError(genericErrorMsg, true); };
|
|
xhr.send(formData);
|
|
} catch (_) { if (attempt === uploadAttempt && !leavingUploadPage) showUploadError(genericErrorMsg); }
|
|
}
|
|
|
|
function uploadFile(file, saved = null) {
|
|
const attempt = uploadAttempt;
|
|
// Тот же обход нестабильного Android FileProvider, что на основном сайте.
|
|
window.ZoomzillaUpload.prepare(file, maxUploadBytes || 15 * 1024 * 1024)
|
|
.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(() => { if (attempt === uploadAttempt && !leavingUploadPage) showUploadError(genericErrorMsg); });
|
|
}
|
|
function uploadPreparedFile(file, bytes, record) {
|
|
const attempt = uploadAttempt;
|
|
const xhr = new XMLHttpRequest();
|
|
const formData = new FormData();
|
|
// Ключ загрузки: по нему сайт передаёт её в сессию посетителя (/api/images/public/claim, годен 30 мин).
|
|
// randomUUID есть в HTTPS-контексте; где его нет (старый Safari), есть getRandomValues.
|
|
const clientKey = record.key;
|
|
formData.append('file', bytes, file.name || 'upload');
|
|
formData.append('client_key', clientKey);
|
|
|
|
const actionUrl = record.meta.actionUrl;
|
|
xhr.open('POST', actionUrl, true);
|
|
xhr.withCredentials = true;
|
|
xhr.setRequestHeader('Accept', 'application/json');
|
|
|
|
xhr.upload.onprogress = (e) => {
|
|
if (e.lengthComputable && typeof process !== 'undefined' && process) {
|
|
const progress = Math.round((e.loaded / e.total) * 100);
|
|
process.setStatus(progress);
|
|
}
|
|
};
|
|
|
|
xhr.onload = async () => {
|
|
if (attempt !== uploadAttempt || leavingUploadPage) return;
|
|
if (xhr.status >= 200 && xhr.status < 300) {
|
|
let response;
|
|
try {
|
|
response = JSON.parse(xhr.responseText);
|
|
} catch (e) {
|
|
showUploadError(genericErrorMsg);
|
|
return;
|
|
}
|
|
|
|
const imageId = response?.id || response?.data?.id || response?.image?.id || response?.image_id;
|
|
if (!imageId) {
|
|
showUploadError(genericErrorMsg);
|
|
return;
|
|
}
|
|
|
|
if (typeof process !== 'undefined' && process) {
|
|
process.setStatus(100);
|
|
process.setStage(1);
|
|
}
|
|
|
|
// Переход: $redirect_url_base . client_key . ($redirect_url_utm ? '&' . $redirect_url_utm : '')
|
|
try { await recovery.accepted({id: imageId}); redirectToTour(record); }
|
|
catch (_) { showUploadError(genericErrorMsg); }
|
|
} else {
|
|
showUploadError(genericErrorMsg);
|
|
}
|
|
};
|
|
|
|
xhr.onerror = () => {
|
|
if (attempt === uploadAttempt && !leavingUploadPage) showUploadError(genericErrorMsg, true);
|
|
};
|
|
|
|
xhr.send(formData);
|
|
}
|
|
|
|
function showUploadError(msg, keepRecovery = false) {
|
|
if (leavingUploadPage) return;
|
|
uploadAttempt++;
|
|
if (!keepRecovery) clearRecovery();
|
|
resetUploadPending();
|
|
form.show();
|
|
if (typeof preview !== 'undefined' && preview) {
|
|
preview.show(); preview.showError(msg);
|
|
if (!preview.sourceEl.getAttribute('src')) preview.sourceEl.style.display = 'none';
|
|
}
|
|
// Release sentinel восстанавливает scroll history. Показываем сообщение
|
|
// после этого перехода, иначе браузер тут же возвращает верх страницы.
|
|
window.PublicNavigationGuard.allowNavigation(() => requestAnimationFrame(() => {
|
|
if (typeof preview !== 'undefined' && preview) preview.upscaleErrorOverlay.scrollIntoView({block: 'center', behavior: 'instant'});
|
|
}));
|
|
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>
|