merge: sync single config to staging
# Conflicts: # .gitea/workflows/deploy.yml
This commit is contained in:
commit
4fa9eebcfb
10 files changed
+252
-125
No files matched your search
@@ -5,8 +5,9 @@ name: deploy-preland
|
|||||||
# Цель — домен со своим хостингом (список — matrix ниже): реквизиты — секреты репозитория <prefix>_FTP_HOST,
|
# Цель — домен со своим хостингом (список — matrix ниже): реквизиты — секреты репозитория <prefix>_FTP_HOST,
|
||||||
# <prefix>_FTP_USER, <prefix>_FTP_PASS (порт 21). Цели независимы: сбой одного хостинга остальные не останавливает.
|
# <prefix>_FTP_USER, <prefix>_FTP_PASS (порт 21). Цели независимы: сбой одного хостинга остальные не останавливает.
|
||||||
# Заливаются файлы репозитория (git ls-files), кроме служебных (.gitea/, README.md, AGENTS.md, CLAUDE.md, .gitignore) и тех, что живут
|
# Заливаются файлы репозитория (git ls-files), кроме служебных (.gitea/, README.md, AGENTS.md, CLAUDE.md, .gitignore) и тех, что живут
|
||||||
# только на хостинге: config.json (в git его нет) и requisites.html (реквизиты заполняют на месте — пустой файл
|
# только на хостинге: requisites.html (реквизиты заполняют на месте — пустой файл из git их бы затёр).
|
||||||
# из git их бы затёр). На хостинге ничего не удаляется: удалённое из репозитория убирать вручную.
|
# Рабочий config.json единственный и версионируется: он должен попадать в каждую доставку.
|
||||||
|
# На хостинге ничего не удаляется: удалённое из репозитория убирать вручную.
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -26,7 +27,7 @@ jobs:
|
|||||||
include:
|
include:
|
||||||
# name — имя цели, url — адрес преленда (для проверки), prefix — префикс секретов <prefix>_FTP_*,
|
# name — имя цели, url — адрес преленда (для проверки), prefix — префикс секретов <prefix>_FTP_*,
|
||||||
# dir — каталог сайта на FTP ("" — корень FTP, как на beget). Новый домен — новая запись и три секрета;
|
# dir — каталог сайта на FTP ("" — корень FTP, как на beget). Новый домен — новая запись и три секрета;
|
||||||
# config.json (и requisites.html) создают на хостинге цели — без него проверка страницы не пройдёт.
|
# requisites.html создают на хостинге цели. Рабочий config.json приходит из Git.
|
||||||
- name: barbariskatest
|
- name: barbariskatest
|
||||||
url: https://preland.anton.barbariskatest.ru
|
url: https://preland.anton.barbariskatest.ru
|
||||||
prefix: PRELAND
|
prefix: PRELAND
|
||||||
@@ -61,7 +62,7 @@ jobs:
|
|||||||
netrc=$(mktemp); list=$(mktemp)
|
netrc=$(mktemp); list=$(mktemp)
|
||||||
trap 'rm -f "$netrc" "$list"' EXIT
|
trap 'rm -f "$netrc" "$list"' EXIT
|
||||||
printf 'machine %s login %s password %s\n' "$FTP_HOST" "$FTP_USER" "$FTP_PASS" > "$netrc"
|
printf 'machine %s login %s password %s\n' "$FTP_HOST" "$FTP_USER" "$FTP_PASS" > "$netrc"
|
||||||
git ls-files | grep -vE '^(\.gitea/|README\.md$|AGENTS\.md$|CLAUDE\.md$|\.gitignore$|config\.json$|requisites\.html$)' > files.txt
|
git ls-files | grep -vE '^(\.gitea/|README\.md$|AGENTS\.md$|CLAUDE\.md$|\.gitignore$|requisites\.html$)' > files.txt
|
||||||
# имя уходит в адрес ftp:// как есть — пробелы и прочее экранирование не поддерживаем
|
# имя уходит в адрес ftp:// как есть — пробелы и прочее экранирование не поддерживаем
|
||||||
if grep -q '[^A-Za-z0-9._/-]' files.txt; then echo "имена с недопустимыми символами:"; grep '[^A-Za-z0-9._/-]' files.txt; exit 1; fi
|
if grep -q '[^A-Za-z0-9._/-]' files.txt; then echo "имена с недопустимыми символами:"; grep '[^A-Za-z0-9._/-]' files.txt; exit 1; fi
|
||||||
# один curl на все файлы — одно соединение FTP+TLS вместо соединения на файл
|
# один curl на все файлы — одно соединение FTP+TLS вместо соединения на файл
|
||||||
@@ -118,7 +119,7 @@ jobs:
|
|||||||
files=$(mktemp)
|
files=$(mktemp)
|
||||||
trap 'rm -f "$known_hosts" "$batch" "$files"' EXIT
|
trap 'rm -f "$known_hosts" "$batch" "$files"' EXIT
|
||||||
printf '%s\n' "$SFTP_KNOWN_HOSTS" > "$known_hosts"
|
printf '%s\n' "$SFTP_KNOWN_HOSTS" > "$known_hosts"
|
||||||
git ls-files | grep -vE '^(\.gitea/|README\.md$|AGENTS\.md$|CLAUDE\.md$|\.gitignore$|config\.json$|requisites\.html$)' > "$files"
|
git ls-files | grep -vE '^(\.gitea/|README\.md$|AGENTS\.md$|CLAUDE\.md$|\.gitignore$|requisites\.html$)' > "$files"
|
||||||
if grep -q '[^A-Za-z0-9._/-]' "$files"; then echo "имена с недопустимыми символами:"; grep '[^A-Za-z0-9._/-]' "$files"; exit 1; fi
|
if grep -q '[^A-Za-z0-9._/-]' "$files"; then echo "имена с недопустимыми символами:"; grep '[^A-Za-z0-9._/-]' "$files"; exit 1; fi
|
||||||
printf '%s\n' "-mkdir $REMOTE_DIR" > "$batch"
|
printf '%s\n' "-mkdir $REMOTE_DIR" > "$batch"
|
||||||
sed 's#/[^/]*$##' "$files" | grep -v '^$' | sort -u | while read -r dir; do printf '%s\n' "-mkdir $REMOTE_DIR/$dir" >> "$batch"; done
|
sed 's#/[^/]*$##' "$files" | grep -v '^$' | sort -u | while read -r dir; do printf '%s\n' "-mkdir $REMOTE_DIR/$dir" >> "$batch"; done
|
||||||
@@ -177,7 +178,7 @@ jobs:
|
|||||||
files=$(mktemp)
|
files=$(mktemp)
|
||||||
trap 'rm -f "$known_hosts" "$batch" "$files"' EXIT
|
trap 'rm -f "$known_hosts" "$batch" "$files"' EXIT
|
||||||
printf '%s\n' "$SFTP_KNOWN_HOSTS" > "$known_hosts"
|
printf '%s\n' "$SFTP_KNOWN_HOSTS" > "$known_hosts"
|
||||||
git ls-files | grep -vE '^(\.gitea/|README\.md$|AGENTS\.md$|CLAUDE\.md$|\.gitignore$|config\.json$|requisites\.html$)' > "$files"
|
git ls-files | grep -vE '^(\.gitea/|README\.md$|AGENTS\.md$|CLAUDE\.md$|\.gitignore$|requisites\.html$)' > "$files"
|
||||||
if grep -q '[^A-Za-z0-9._/-]' "$files"; then echo "имена с недопустимыми символами:"; grep '[^A-Za-z0-9._/-]' "$files"; exit 1; fi
|
if grep -q '[^A-Za-z0-9._/-]' "$files"; then echo "имена с недопустимыми символами:"; grep '[^A-Za-z0-9._/-]' "$files"; exit 1; fi
|
||||||
printf '%s\n' "-mkdir $REMOTE_DIR" > "$batch"
|
printf '%s\n' "-mkdir $REMOTE_DIR" > "$batch"
|
||||||
sed 's#/[^/]*$##' "$files" | grep -v '^$' | sort -u | while read -r dir; do printf '%s\n' "-mkdir $REMOTE_DIR/$dir" >> "$batch"; done
|
sed 's#/[^/]*$##' "$files" | grep -v '^$' | sort -u | while read -r dir; do printf '%s\n' "-mkdir $REMOTE_DIR/$dir" >> "$batch"; done
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
config.json
|
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
## Перед работой
|
## Перед работой
|
||||||
|
|
||||||
- Прочитать [README.md](README.md), [заметку проекта](../.known-base/Проекты/Преленд%20—%20anton-preland.md),
|
- Прочитать [README.md](README.md), [заметку проекта](../.known-base/Проекты/Преленд%20—%20anton-preland.md),
|
||||||
а для выката — `.gitea/workflows/deploy.yml` и `config.json.example`.
|
а для выката — `.gitea/workflows/deploy.yml` и единственный `config.json`.
|
||||||
- Проверить состояние ветки и чужие изменения в `preland/`; изменения API сайта согласовать
|
- Проверить состояние ветки и чужие изменения в `preland/`; изменения API сайта согласовать
|
||||||
с его контрактом и инструкциями `api/`.
|
с его контрактом и инструкциями `api/`.
|
||||||
|
|
||||||
@@ -21,8 +21,10 @@
|
|||||||
переход напрямую на `/public/upscale` не заменяет передачу владельца через claim.
|
переход напрямую на `/public/upscale` не заменяет передачу владельца через claim.
|
||||||
- `host` задаёт сайт назначения; HTTPS необходим для `crypto.randomUUID()`.
|
- `host` задаёт сайт назначения; HTTPS необходим для `crypto.randomUUID()`.
|
||||||
Не обходить серверные правила доступа к загрузке и результату обработки.
|
Не обходить серверные правила доступа к загрузке и результату обработки.
|
||||||
- Рабочий `config.json` каждой цели и `requisites.html` живут на хостинге: не добавлять
|
- Рабочий `config.json` единственный для всех прилендов: это точная проверенная
|
||||||
их в git, не перезаписывать и не удалять при деплое. В репозитории — только образец конфига.
|
конфигурация `zoomstage.online`, и каждый FTP/SFTP-deploy обязан доставлять её на
|
||||||
|
stage и production. `requisites.html` по-прежнему живёт только на хостинге и не
|
||||||
|
перезаписывается. Не создавать target-specific конфиги или `config.json.example`.
|
||||||
- Коммит в `main` запускает FTP-автодеплой на все цели workflow; откат через `git revert`
|
- Коммит в `main` запускает FTP-автодеплой на все цели workflow; откат через `git revert`
|
||||||
также запускает выкат. Коммит, push и изменения хостинга — только по прямому поручению.
|
также запускает выкат. Коммит, push и изменения хостинга — только по прямому поручению.
|
||||||
- Проверки кода и браузера проводить в Docker без установки ПО на хост.
|
- Проверки кода и браузера проводить в Docker без установки ПО на хост.
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
## Перед работой
|
## Перед работой
|
||||||
|
|
||||||
- Прочитать [README.md](README.md), [заметку проекта](../.known-base/Проекты/Преленд%20—%20anton-preland.md),
|
- Прочитать [README.md](README.md), [заметку проекта](../.known-base/Проекты/Преленд%20—%20anton-preland.md),
|
||||||
а для выката — `.gitea/workflows/deploy.yml` и `config.json.example`.
|
а для выката — `.gitea/workflows/deploy.yml` и единственный `config.json`.
|
||||||
- Проверить состояние ветки и чужие изменения в `preland/`; изменения API сайта согласовать
|
- Проверить состояние ветки и чужие изменения в `preland/`; изменения API сайта согласовать
|
||||||
с его контрактом и инструкциями `api/`.
|
с его контрактом и инструкциями `api/`.
|
||||||
|
|
||||||
@@ -21,8 +21,10 @@
|
|||||||
переход напрямую на `/public/upscale` не заменяет передачу владельца через claim.
|
переход напрямую на `/public/upscale` не заменяет передачу владельца через claim.
|
||||||
- `host` задаёт сайт назначения; HTTPS необходим для `crypto.randomUUID()`.
|
- `host` задаёт сайт назначения; HTTPS необходим для `crypto.randomUUID()`.
|
||||||
Не обходить серверные правила доступа к загрузке и результату обработки.
|
Не обходить серверные правила доступа к загрузке и результату обработки.
|
||||||
- Рабочий `config.json` каждой цели и `requisites.html` живут на хостинге: не добавлять
|
- Рабочий `config.json` единственный для всех прилендов: это точная проверенная
|
||||||
их в git, не перезаписывать и не удалять при деплое. В репозитории — только образец конфига.
|
конфигурация `zoomstage.online`, и каждый FTP/SFTP-deploy обязан доставлять её на
|
||||||
|
stage и production. `requisites.html` по-прежнему живёт только на хостинге и не
|
||||||
|
перезаписывается. Не создавать target-specific конфиги или `config.json.example`.
|
||||||
- Коммит в `main` запускает FTP-автодеплой на все цели workflow; откат через `git revert`
|
- Коммит в `main` запускает FTP-автодеплой на все цели workflow; откат через `git revert`
|
||||||
также запускает выкат. Коммит, push и изменения хостинга — только по прямому поручению.
|
также запускает выкат. Коммит, push и изменения хостинга — только по прямому поручению.
|
||||||
- Проверки кода и браузера проводить в Docker без установки ПО на хост.
|
- Проверки кода и браузера проводить в Docker без установки ПО на хост.
|
||||||
|
|||||||
@@ -61,7 +61,7 @@
|
|||||||
| `requisites.html` | реквизиты для подвала (HTML), если в `config.json` нет `requisites`. Без обоих — **заглушка «ИП Иванов Иван Иванович»** из `template.php` |
|
| `requisites.html` | реквизиты для подвала (HTML), если в `config.json` нет `requisites`. Без обоих — **заглушка «ИП Иванов Иван Иванович»** из `template.php` |
|
||||||
| `images/`, `fonts/`, `favicon/`, `*.css` | статика; `style.min.css` встраивается в страницу |
|
| `images/`, `fonts/`, `favicon/`, `*.css` | статика; `style.min.css` встраивается в страницу |
|
||||||
| `.htaccess` | только сроки кэширования статики (Apache) |
|
| `.htaccess` | только сроки кэширования статики (Apache) |
|
||||||
| `config.json.example` | образец конфига; рабочий `config.json` в git не хранится |
|
| `config.json` | единственный версионируемый рабочий конфиг всех прилендов: точная проверенная конфигурация stage `zoomstage.online`; каждый deploy доставляет его на целевой хост |
|
||||||
|
|
||||||
### `config.json`
|
### `config.json`
|
||||||
|
|
||||||
@@ -87,12 +87,10 @@
|
|||||||
git clone http://git.dira89kv.beget.tech:3000/anton/preland.git && cd preland
|
git clone http://git.dira89kv.beget.tech:3000/anton/preland.git && cd preland
|
||||||
# дальше — загрузка на хостинг: rsync/scp по SSH или FTP-клиент
|
# дальше — загрузка на хостинг: rsync/scp по SSH или FTP-клиент
|
||||||
```
|
```
|
||||||
3. **Конфиг и реквизиты.** На сервере, рядом с `index.php`; реквизиты — ключ `requisites` (иначе в подвале заглушка):
|
3. **Конфиг и реквизиты.** Рабочий `config.json` уже лежит в репозитории и обязателен к доставке на каждый
|
||||||
```bash
|
приленд. Его не заменяют локальным вариантом и не исключают из синхронизации. Реквизиты задаются ключом
|
||||||
cp config.json.example config.json # host — домен сайта, метки — по кампании
|
`requisites` в нём либо runtime-файлом `requisites.html`. 2026-10-05 конфиг пропал после заливки,
|
||||||
```
|
и преленд слал загрузку на собственный домен; это больше не допускается.
|
||||||
`config.json` не в git: при обновлении файлов **не удалять** его (не синхронизировать каталог «зеркалом» без
|
|
||||||
исключения `config.json`). 2026-10-05 после заливки он пропал, и преленд слал загрузку на собственный домен.
|
|
||||||
4. **nginx без Apache** (если хостинг не обрабатывает `.htaccess`):
|
4. **nginx без Apache** (если хостинг не обрабатывает `.htaccess`):
|
||||||
```nginx
|
```nginx
|
||||||
server {
|
server {
|
||||||
@@ -139,14 +137,13 @@
|
|||||||
1. Домен и HTTPS — как в «Деплой на произвольный домен», шаг 1.
|
1. Домен и HTTPS — как в «Деплой на произвольный домен», шаг 1.
|
||||||
2. Секреты `<ПРЕФИКС>_FTP_HOST`, `<ПРЕФИКС>_FTP_USER`, `<ПРЕФИКС>_FTP_PASS` (Gitea → настройки репозитория →
|
2. Секреты `<ПРЕФИКС>_FTP_HOST`, `<ПРЕФИКС>_FTP_USER`, `<ПРЕФИКС>_FTP_PASS` (Gitea → настройки репозитория →
|
||||||
Actions → секреты; порт 21, FTP с TLS). В коде реквизитов нет.
|
Actions → секреты; порт 21, FTP с TLS). В коде реквизитов нет.
|
||||||
3. На хостинге, в каталоге сайта, — `config.json` из `config.json.example`: свой `host`, метки, `requisites`.
|
3. Никаких ручных конфигов не создавать: единственный `config.json` приезжает вместе с кодом.
|
||||||
В репозитории только образец: `config.json` у каждого домена свой и живёт на его хостинге.
|
4. Запись в `matrix` — коммит в `main`, автодеплой выкатит все цели вместе с конфигом.
|
||||||
4. Запись в `matrix` — коммит в `main`, автодеплой выкатит все цели. Пока на новом хостинге нет `config.json`,
|
|
||||||
его проверка падает: загрузка ушла бы на сам преленд.
|
|
||||||
|
|
||||||
**Что заливается.** Файлы репозитория (`git ls-files`), кроме служебных (`.gitea/`, `README.md`, `.gitignore`)
|
**Что заливается.** Каждый deploy получает файлы репозитория (`git ls-files`), кроме служебных
|
||||||
и живущих на хостинге `config.json` и `requisites.html`. На хостинге ничего не удаляется: файлы, убранные
|
(`.gitea/`, `README.md`, `.gitignore`) и живущего на хостинге `requisites.html`. Единственный
|
||||||
из репозитория, удалять вручную.
|
версионируемый `config.json` всегда доставляется вместе с кодом.
|
||||||
|
На хостинге ничего не удаляется: файлы, убранные из репозитория, удалять вручную.
|
||||||
|
|
||||||
**Проверка после заливки.** На странице цели должен быть адрес загрузки на сайте, а не на самом преленде (иначе нет
|
**Проверка после заливки.** На странице цели должен быть адрес загрузки на сайте, а не на самом преленде (иначе нет
|
||||||
`config.json` с `host`), и переход на `claim`. Иначе прогон этой цели падает.
|
`config.json` с `host`), и переход на `claim`. Иначе прогон этой цели падает.
|
||||||
|
|||||||
+94
-80
@@ -7,6 +7,7 @@ $createApiUrl = ($apiHost ?? '') . '/api/images/public/create';
|
|||||||
$uploadPolicyApiUrl = ($apiHost ?? '') . '/api/images/public/upload-policy';
|
$uploadPolicyApiUrl = ($apiHost ?? '') . '/api/images/public/upload-policy';
|
||||||
$redirectUrlBase = $redirect_url_base ?? '';
|
$redirectUrlBase = $redirect_url_base ?? '';
|
||||||
$redirectUrlUtm = $redirect_url_utm ?? '';
|
$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,.jpg,.jpeg,.png,.gif,.webp,.bmp,.avif,.heic,.heif,.jxl,.tif,.tiff,.dng';
|
||||||
|
|
||||||
?>
|
?>
|
||||||
|
|
||||||
@@ -31,7 +32,7 @@ $redirectUrlUtm = $redirect_url_utm ?? '';
|
|||||||
</div>
|
</div>
|
||||||
<div class="dropzone-form-action">
|
<div class="dropzone-form-action">
|
||||||
<button type="button" class="dropzone-form-button">
|
<button type="button" class="dropzone-form-button">
|
||||||
<span><?= Yii::t('app', 'Загрузить изображение') ?></span>
|
<span data-upload-label><?= Yii::t('app', 'Загрузить изображение') ?></span>
|
||||||
<svg width="20" height="21" viewBox="0 0 20 21" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<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"/>
|
<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>
|
</svg>
|
||||||
@@ -39,7 +40,7 @@ $redirectUrlUtm = $redirect_url_utm ?? '';
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<input type="file" id="dropzone-file-input" name="file" style="display: none;">
|
<input type="file" id="dropzone-file-input" name="file" accept="<?= htmlspecialchars($basePickerAccept, ENT_QUOTES, 'UTF-8') ?>" style="display: none;">
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@@ -73,22 +74,68 @@ $redirectUrlUtm = $redirect_url_utm ?? '';
|
|||||||
const fileInput = document.getElementById('dropzone-file-input');
|
const fileInput = document.getElementById('dropzone-file-input');
|
||||||
if (!formEl || !fileInput) return;
|
if (!formEl || !fileInput) return;
|
||||||
|
|
||||||
// `accept` — только удобство picker. Преленд не дублирует Settings:
|
// `accept` — только удобство picker. Любой drop/подменённое расширение
|
||||||
// разрешённый для наблюдения PDF добавляется неблокирующим запросом к API.
|
// отправляется в content-based Tour gate, где и показывается controlled error.
|
||||||
|
let pickerAccept = <?= json_encode($basePickerAccept, JSON_UNESCAPED_UNICODE) ?>;
|
||||||
|
let maxUploadBytes = 0;
|
||||||
if (uploadPolicyApiUrl && window.fetch) {
|
if (uploadPolicyApiUrl && window.fetch) {
|
||||||
fetch(uploadPolicyApiUrl, { credentials: 'omit', cache: 'no-store' })
|
fetch(uploadPolicyApiUrl, { credentials: 'omit', cache: 'no-store' })
|
||||||
.then(response => response.ok ? response.json() : null)
|
.then(response => response.ok ? response.json() : null)
|
||||||
.then(policy => {
|
.then(policy => {
|
||||||
if (policy?.pdf_observation_enabled) {
|
if (typeof policy?.picker_accept === 'string' && policy.picker_accept !== '') {
|
||||||
fileInput.accept += ',.pdf';
|
pickerAccept = policy.picker_accept;
|
||||||
|
fileInput.accept = pickerAccept;
|
||||||
|
}
|
||||||
|
if (Number.isSafeInteger(policy?.max_file_size_bytes) && policy.max_file_size_bytes > 0) {
|
||||||
|
maxUploadBytes = policy.max_file_size_bytes;
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
let columnsSwapped = false;
|
let uploadPending = false;
|
||||||
|
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() {
|
||||||
|
if (uploadButtonLabel) uploadButtonLabel.textContent = 'Загрузка' + '.'.repeat(uploadDots);
|
||||||
|
}
|
||||||
|
|
||||||
|
function beginUploadPending() {
|
||||||
|
if (uploadPending) return false;
|
||||||
|
uploadPending = true;
|
||||||
|
uploadDots = 1;
|
||||||
|
renderUploadButton();
|
||||||
|
if (uploadButton) {
|
||||||
|
uploadButton.disabled = true;
|
||||||
|
uploadButton.setAttribute('aria-busy', 'true');
|
||||||
|
}
|
||||||
|
uploadDotsTimer = window.setInterval(() => {
|
||||||
|
uploadDots = uploadDots === 5 ? 1 : uploadDots + 1;
|
||||||
|
renderUploadButton();
|
||||||
|
}, 500);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetUploadPending() {
|
||||||
|
uploadPending = false;
|
||||||
|
if (uploadDotsTimer !== null) window.clearInterval(uploadDotsTimer);
|
||||||
|
uploadDotsTimer = null;
|
||||||
|
if (uploadButtonLabel) uploadButtonLabel.textContent = uploadButtonDefaultLabel;
|
||||||
|
if (uploadButton) {
|
||||||
|
uploadButton.disabled = false;
|
||||||
|
uploadButton.removeAttribute('aria-busy');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
formEl.addEventListener('click', (e) => {
|
formEl.addEventListener('click', (e) => {
|
||||||
|
if (uploadPending) {
|
||||||
|
e.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (e.target !== fileInput) {
|
if (e.target !== fileInput) {
|
||||||
fileInput.click();
|
fileInput.click();
|
||||||
}
|
}
|
||||||
@@ -111,6 +158,7 @@ $redirectUrlUtm = $redirect_url_utm ?? '';
|
|||||||
});
|
});
|
||||||
|
|
||||||
formEl.addEventListener('drop', (e) => {
|
formEl.addEventListener('drop', (e) => {
|
||||||
|
if (uploadPending) return;
|
||||||
const files = e.dataTransfer?.files;
|
const files = e.dataTransfer?.files;
|
||||||
if (files && files.length > 0) {
|
if (files && files.length > 0) {
|
||||||
handleSelectedFile(files[0]);
|
handleSelectedFile(files[0]);
|
||||||
@@ -118,80 +166,54 @@ $redirectUrlUtm = $redirect_url_utm ?? '';
|
|||||||
});
|
});
|
||||||
|
|
||||||
fileInput.addEventListener('change', () => {
|
fileInput.addEventListener('change', () => {
|
||||||
|
if (uploadPending) return;
|
||||||
if (fileInput.files && fileInput.files.length > 0) {
|
if (fileInput.files && fileInput.files.length > 0) {
|
||||||
handleSelectedFile(fileInput.files[0]);
|
handleSelectedFile(fileInput.files[0]);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function swapColumns() {
|
function handleSelectedFile(file) {
|
||||||
if (columnsSwapped) return;
|
if (maxUploadBytes > 0 && file.size > maxUploadBytes) {
|
||||||
const col5 = document.querySelector('.col-md-5');
|
rejectOversizedFile(file);
|
||||||
const col7 = document.querySelector('.col-md-7');
|
return;
|
||||||
if (col5 && col7) {
|
|
||||||
const temp = document.createElement('div');
|
|
||||||
col5.replaceWith(temp);
|
|
||||||
col7.replaceWith(col5);
|
|
||||||
temp.replaceWith(col7);
|
|
||||||
columnsSwapped = true;
|
|
||||||
}
|
}
|
||||||
|
if (!beginUploadPending()) return;
|
||||||
|
uploadFile(file);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSelectedFile(file) {
|
function createClientKey() {
|
||||||
const objectUrl = URL.createObjectURL(file);
|
return crypto.randomUUID
|
||||||
const img = new Image();
|
? crypto.randomUUID()
|
||||||
|
: Array.from(crypto.getRandomValues(new Uint8Array(16)), (b) => b.toString(16).padStart(2, '0')).join('');
|
||||||
|
}
|
||||||
|
|
||||||
img.onload = () => {
|
function redirectToTour(clientKey) {
|
||||||
const width = img.naturalWidth;
|
let targetUrl = redirectUrlBase + encodeURIComponent(clientKey);
|
||||||
const height = img.naturalHeight;
|
if (redirectUrlUtm) targetUrl += '&' + redirectUrlUtm;
|
||||||
|
window.location.href = targetUrl;
|
||||||
|
}
|
||||||
|
|
||||||
form.hide();
|
function rejectOversizedFile(file) {
|
||||||
swapColumns();
|
if (!beginUploadPending()) return;
|
||||||
|
const clientKey = createClientKey();
|
||||||
if (typeof preview !== 'undefined' && preview) {
|
const formData = new FormData();
|
||||||
const col7 = document.querySelector('.col-md-7');
|
formData.append('rejection_code', '105');
|
||||||
if (col7) col7.appendChild(preview.el);
|
formData.append('original_name', file.name || 'upload');
|
||||||
preview.hide();
|
formData.append('original_size', String(file.size || 0));
|
||||||
preview.setSource(objectUrl);
|
formData.append('client_key', clientKey);
|
||||||
preview.show();
|
const xhr = new XMLHttpRequest();
|
||||||
|
xhr.open('POST', createApiUrl || formEl.getAttribute('action') || '/api/images/public/create', true);
|
||||||
|
xhr.withCredentials = true;
|
||||||
|
xhr.setRequestHeader('Accept', 'application/json');
|
||||||
|
xhr.onload = () => {
|
||||||
|
if (xhr.status >= 200 && xhr.status < 300) {
|
||||||
|
redirectToTour(clientKey);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
showUploadError(genericErrorMsg);
|
||||||
if (typeof process !== 'undefined' && process) {
|
|
||||||
const col5 = document.querySelector('.col-md-5');
|
|
||||||
if (col5) col5.appendChild(process.el);
|
|
||||||
process.setFilename(file.name);
|
|
||||||
process.setSize(file.size);
|
|
||||||
process.setDimensions(width, height);
|
|
||||||
process.setStage(0);
|
|
||||||
process.setStatus(0);
|
|
||||||
process.show();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof examples !== 'undefined' && examples) {
|
|
||||||
examples.hide();
|
|
||||||
}
|
|
||||||
|
|
||||||
uploadFile(file);
|
|
||||||
};
|
};
|
||||||
|
xhr.onerror = () => showUploadError(genericErrorMsg);
|
||||||
img.onerror = () => {
|
xhr.send(formData);
|
||||||
// Browser не обязан декодировать допустимый контейнер. Не отказываем локально:
|
|
||||||
// серверный Tour gate проверит байты и при необходимости даст JPEG-preview.
|
|
||||||
form.hide();
|
|
||||||
swapColumns();
|
|
||||||
if (typeof process !== 'undefined' && process) {
|
|
||||||
const col5 = document.querySelector('.col-md-5');
|
|
||||||
if (col5) col5.appendChild(process.el);
|
|
||||||
process.setFilename(file.name);
|
|
||||||
process.setSize(file.size);
|
|
||||||
process.setDimensions(0, 0);
|
|
||||||
process.setStage(0);
|
|
||||||
process.setStatus(0);
|
|
||||||
process.show();
|
|
||||||
}
|
|
||||||
uploadFile(file);
|
|
||||||
};
|
|
||||||
|
|
||||||
img.src = objectUrl;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function uploadFile(file) {
|
function uploadFile(file) {
|
||||||
@@ -199,9 +221,7 @@ $redirectUrlUtm = $redirect_url_utm ?? '';
|
|||||||
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 = crypto.randomUUID
|
const clientKey = createClientKey();
|
||||||
? crypto.randomUUID()
|
|
||||||
: Array.from(crypto.getRandomValues(new Uint8Array(16)), (b) => b.toString(16).padStart(2, '0')).join('');
|
|
||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
formData.append('client_key', clientKey);
|
formData.append('client_key', clientKey);
|
||||||
|
|
||||||
@@ -239,13 +259,7 @@ $redirectUrlUtm = $redirect_url_utm ?? '';
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Переход: $redirect_url_base . client_key . ($redirect_url_utm ? '&' . $redirect_url_utm : '')
|
// Переход: $redirect_url_base . client_key . ($redirect_url_utm ? '&' . $redirect_url_utm : '')
|
||||||
let targetUrl = redirectUrlBase + encodeURIComponent(clientKey);
|
redirectToTour(clientKey);
|
||||||
if (redirectUrlUtm) {
|
|
||||||
// const sep = targetUrl.includes('?') ? '&' : '?';
|
|
||||||
targetUrl += '&' + redirectUrlUtm;
|
|
||||||
}
|
|
||||||
|
|
||||||
window.location.href = targetUrl;
|
|
||||||
} else {
|
} else {
|
||||||
showUploadError(genericErrorMsg);
|
showUploadError(genericErrorMsg);
|
||||||
}
|
}
|
||||||
@@ -259,7 +273,7 @@ $redirectUrlUtm = $redirect_url_utm ?? '';
|
|||||||
}
|
}
|
||||||
|
|
||||||
function showUploadError(msg) {
|
function showUploadError(msg) {
|
||||||
if (typeof process !== 'undefined' && process) process.hide();
|
resetUploadPending();
|
||||||
form.show();
|
form.show();
|
||||||
if (typeof preview !== 'undefined' && preview) preview.showError(msg);
|
if (typeof preview !== 'undefined' && preview) preview.showError(msg);
|
||||||
fileInput.value = '';
|
fileInput.value = '';
|
||||||
|
|||||||
@@ -11,52 +11,52 @@
|
|||||||
"utm": {
|
"utm": {
|
||||||
"indpic": {
|
"indpic": {
|
||||||
"weights": {
|
"weights": {
|
||||||
"1": 0.2,
|
"1": 1,
|
||||||
"2": 0.3
|
"2": 0
|
||||||
},
|
},
|
||||||
"default": "1"
|
"default": "1"
|
||||||
},
|
},
|
||||||
"theme": {
|
"theme": {
|
||||||
"weights": {
|
"weights": {
|
||||||
"light": 0.5,
|
"light": 0,
|
||||||
"dark": 0.5
|
"dark": 1
|
||||||
},
|
},
|
||||||
"default": "dark"
|
"default": "dark"
|
||||||
},
|
},
|
||||||
"degr": {
|
"degr": {
|
||||||
"weights": {
|
"weights": {
|
||||||
"1": 0.7,
|
"1": 1,
|
||||||
"2": 0.3
|
"2": 0
|
||||||
},
|
},
|
||||||
"default": "1"
|
"default": "1"
|
||||||
},
|
},
|
||||||
"registration": {
|
"registration": {
|
||||||
"weights": {
|
"weights": {
|
||||||
"before": 0.7,
|
"before": 0,
|
||||||
"after": 0.3
|
"after": 1
|
||||||
},
|
},
|
||||||
"default": "before"
|
"default": "after"
|
||||||
},
|
},
|
||||||
"bchoice": {
|
"bchoice": {
|
||||||
"weights": {
|
"weights": {
|
||||||
"a": 0.7,
|
"a": 1,
|
||||||
"b": 0.3
|
"b": 0
|
||||||
},
|
},
|
||||||
"default": "a"
|
"default": "a"
|
||||||
},
|
},
|
||||||
"chb": {
|
"chb": {
|
||||||
"weights": {
|
"weights": {
|
||||||
"a": 0.7,
|
"a": 1,
|
||||||
"b": 0.3
|
"b": 0
|
||||||
},
|
},
|
||||||
"default": "a"
|
"default": "b"
|
||||||
},
|
},
|
||||||
"tp": {
|
"tp": {
|
||||||
"weights": {
|
"weights": {
|
||||||
"1": 0.7,
|
"1": 0,
|
||||||
"2": 0.3
|
"2": 1
|
||||||
},
|
},
|
||||||
"default": "2"
|
"default": "1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
$form = file_get_contents(dirname(__DIR__) . '/components/form.php');
|
||||||
|
$failures = [];
|
||||||
|
|
||||||
|
foreach ([
|
||||||
|
"\$basePickerAccept = 'image/jpeg,image/png,image/gif,image/webp,image/bmp,image/avif,image/heic,image/heif,image/jxl,image/tiff," => 'Preland должен ограничивать штатный picker поддерживаемыми raster-контейнерами.',
|
||||||
|
'accept="<?= htmlspecialchars($basePickerAccept' => 'Preland должен передать допустимые типы в штатный file input.',
|
||||||
|
'picker_accept' => 'Preland должен получать актуальный accept из API policy для PDF.',
|
||||||
|
'max_file_size_bytes' => 'Preland должен получить лимит размера для preflight без передачи гигабайтного файла.',
|
||||||
|
'rejectOversizedFile' => 'Preland обязан создать Tour-отказ без загрузки oversized байтов.',
|
||||||
|
] as $needle => $message) {
|
||||||
|
if ($form === false || !str_contains($form, $needle)) {
|
||||||
|
$failures[] = $message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($failures !== []) {
|
||||||
|
fwrite(STDERR, "FAILED\n- " . implode("\n- ", $failures) . "\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
fwrite(STDOUT, "OK: preland upload admission\n");
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
/** Регрессия: до редиректа на Tour повторная загрузка блокируется для любого формата. */
|
||||||
|
|
||||||
|
$form = (string) file_get_contents(dirname(__DIR__) . '/components/form.php');
|
||||||
|
|
||||||
|
foreach (['function beginUploadPending', 'uploadDots', 'Загрузка', 'uploadPending'] as $needle) {
|
||||||
|
if (!str_contains($form, $needle)) {
|
||||||
|
fwrite(STDERR, "FAIL: preland uploader must contain {$needle}\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "OK: preland upload button state\n";
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Регрессия единственного рабочего конфига приленда.
|
||||||
|
*
|
||||||
|
* Stage-конфиг — единственный канонический конфиг каждого приленда. Он должен
|
||||||
|
* быть в репозитории и попадать во все FTP/SFTP-доставки без исключений.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
$failures = [];
|
||||||
|
$root = dirname(__DIR__);
|
||||||
|
$configPath = $root . '/config.json';
|
||||||
|
$config = is_file($configPath) ? json_decode((string) file_get_contents($configPath), true) : null;
|
||||||
|
|
||||||
|
if (!is_array($config)) {
|
||||||
|
$failures[] = 'Рабочий config.json должен существовать в корне приленда и содержать валидный JSON.';
|
||||||
|
} else {
|
||||||
|
$expected = [
|
||||||
|
'host' => 'zoomzilla.ru',
|
||||||
|
'indpic' => ['1' => 1, '2' => 0, 'default' => '1'],
|
||||||
|
'theme' => ['light' => 0, 'dark' => 1, 'default' => 'dark'],
|
||||||
|
'degr' => ['1' => 1, '2' => 0, 'default' => '1'],
|
||||||
|
'registration' => ['before' => 0, 'after' => 1, 'default' => 'after'],
|
||||||
|
'bchoice' => ['a' => 1, 'b' => 0, 'default' => 'a'],
|
||||||
|
'chb' => ['a' => 1, 'b' => 0, 'default' => 'b'],
|
||||||
|
'tp' => ['1' => 0, '2' => 1, 'default' => '1'],
|
||||||
|
];
|
||||||
|
|
||||||
|
if (($config['host'] ?? null) !== $expected['host']) {
|
||||||
|
$failures[] = 'Рабочий config.json обязан направлять загрузку на zoomzilla.ru.';
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (array_diff_key($expected, ['host' => true]) as $key => $settings) {
|
||||||
|
$actual = $config['utm'][$key] ?? [];
|
||||||
|
if (($actual['weights'] ?? null) !== array_diff_key($settings, ['default' => true])
|
||||||
|
|| ($actual['default'] ?? null) !== $settings['default']) {
|
||||||
|
$failures[] = "Рабочий config.json должен сохранять stage-настройку UTM {$key}.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$workflow = (string) file_get_contents($root . '/.gitea/workflows/deploy.yml');
|
||||||
|
$legacyStart = strpos($workflow, ' deploy:');
|
||||||
|
$stageStart = strpos($workflow, 'deploy-zoomstage:');
|
||||||
|
$productionStart = strpos($workflow, 'deploy-zoompixel-production:');
|
||||||
|
$legacyBlock = $legacyStart === false || $stageStart === false
|
||||||
|
? ''
|
||||||
|
: substr($workflow, $legacyStart, $stageStart - $legacyStart);
|
||||||
|
$stageBlock = $stageStart === false || $productionStart === false
|
||||||
|
? ''
|
||||||
|
: substr($workflow, $stageStart, $productionStart - $stageStart);
|
||||||
|
$productionBlock = $productionStart === false ? '' : substr($workflow, $productionStart);
|
||||||
|
|
||||||
|
foreach ([
|
||||||
|
'legacy FTP' => $legacyBlock,
|
||||||
|
'deploy-zoomstage' => $stageBlock,
|
||||||
|
'deploy-zoompixel-production' => $productionBlock,
|
||||||
|
] as $delivery => $block) {
|
||||||
|
if ($block === '' || str_contains($block, 'config\\.json$')) {
|
||||||
|
$failures[] = "{$delivery} обязан доставлять единый версионируемый config.json.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_file($root . '/config.json.example')) {
|
||||||
|
$failures[] = 'config.json.example не должен существовать: рабочий config.json единственный.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($failures !== []) {
|
||||||
|
fwrite(STDERR, "FAILED\n- " . implode("\n- ", $failures) . "\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
fwrite(STDOUT, "OK: versioned stage config and ZoomPixel delivery\n");
|
||||||
Reference in new issue
Block a user