diff --git a/README.md b/README.md index 25691ef..7901fe7 100644 --- a/README.md +++ b/README.md @@ -1,208 +1,145 @@ -# Галерея фотографий (PHP) +# Галерея фото (PHP + MySQL) -Локальный проект галереи, который: +Актуальная версия проекта — это MySQL-контур с двумя основными точками входа: -- читает категории и фото из `photos/` (туда можно загружать по FTP), -- при каждом открытии страницы проверяет, появились ли новые/обновлённые фото, -- создаёт и обновляет превью в `thumbs/`, -- показывает категории и фото в веб-интерфейсе, -- открывает большую фотографию в лайтбоксе. +- `index.php` — публичная витрина (разделы, тематики, карточки фото, комментарии), +- `admin.php?token=...` — закрытая админка (управление разделами/тематиками/фото/пользователями/комментариями/настройками). + +`index-mysql.php` и `admin-mysql.php` оставлены как алиасы для обратной совместимости. + +## Что умеет проект + +- Иерархия каталога: разделы + тематики (2 уровня). +- Для фото поддерживаются версии `before` и `after`. +- Версия `after` на публичной части отдается с watermark (текст, яркость и угол настраиваются в админке). +- Комментарии доступны только по персональной viewer-ссылке. +- Для карточек каталога используются превью из `thumbs/`. +- Превью создаются/обновляются автоматически при загрузке, замене и повороте изображения. ## Структура ```text -photo-gallery/ -├─ index.php # основной скрипт: индексация + HTML -├─ style.css # стили (material-like, строгий) -├─ app.js # лайтбокс + защита от простого скачивания -├─ deploy.php # webhook-триггер деплоя -├─ admin.php # закрытая админка (папки/фото/сортировка) -├─ deploy-config.php.example # пример конфига webhook -├─ photos/ # исходные фото по категориям (папкам) -├─ thumbs/ # автогенерируемые превью -└─ data/ - ├─ last_indexed.txt # timestamp последней индексации - └─ sort.json # порядок категорий и фото +photo.andr33v.ru/ +├─ index.php # публичная витрина +├─ admin.php # админка по токену +├─ index-mysql.php # alias -> index.php +├─ admin-mysql.php # alias -> admin.php +├─ deploy.php # webhook деплоя +├─ style.css # базовые стили +├─ favicon.svg +├─ config.php.example # шаблон конфига БД +├─ deploy-config.php.example # шаблон токена/настроек деплоя +├─ lib/ +│ ├─ db.php # PDO + загрузка config.php +│ ├─ db_gallery.php # доступ к данным галереи +│ ├─ thumbs.php # генерация/чтение/удаление превью +│ ├─ admin_http.php # JSON-ответы админки +│ ├─ admin_get_actions.php # GET-экшены админки +│ ├─ admin_post_actions.php # POST-экшены админки +│ └─ admin_helpers.php # helper-функции админки +├─ migrations/ +│ ├─ 001_init.sql +│ ├─ 002_site_settings.sql +│ ├─ 003_comment_users_plain_token.sql +│ └─ 004_topics.sql +├─ scripts/ +│ ├─ migrate.php # запуск миграций +│ ├─ generate_thumbs.php # backfill превью +│ └─ deploy.sh # deploy на shared hosting +├─ photos/ # оригиналы (runtime) +├─ thumbs/ # превью (runtime) +└─ data/ # runtime (логи/служебные файлы) ``` -## Как работает индексация - -1. Скрипт читает `data/last_indexed.txt`. -2. Сканирует `photos/<категория>/`. -3. Для каждого изображения (`jpg/jpeg/png/webp/gif`): - - если файл новее `last_indexed`, - - или превью не существует, - - или превью старее оригинала, - тогда создаётся/обновляется превью (`.jpg`) в `thumbs/<категория>/`. -4. Записывает новый timestamp в `last_indexed.txt`. - -Индексация выполняется **на каждом обращении к `index.php`**. - -Также на публичной странице категории показываются с обложкой (берётся превью первой фотографии по текущей сортировке). - ## Требования -- PHP 8.2+ (8.3 тоже ок) -- Расширение GD **или** Imagick - - если есть Imagick — будет использоваться он, - - иначе используется GD. +- PHP 8.2+ +- MySQL 8+ (или совместимая MariaDB) +- PHP-расширения: + - `pdo_mysql` (обязательно), + - `gd` или `imagick` (для watermark/превью). -## Локальный запуск +## Быстрый старт -Из папки `photo-gallery`: - -```bash -php -S 127.0.0.1:8080 -``` - -## MySQL конфиг и миграции (этап перехода на БД) - -### Быстрый запуск на хостинге (Timeweb) - -1. Создать базу MySQL в панели хостинга. -2. Подтянуть проект из git. -3. Создать `config.php` из шаблона: +1. Создай `config.php`: ```bash cp config.php.example config.php ``` -4. Заполнить в `config.php` параметры подключения к БД. -5. Запустить миграции: +2. Заполни доступы к БД в `config.php`. + +3. Прогон миграций: ```bash php scripts/migrate.php ``` -После этого проект должен работать. - -### Про кодировку - -Проект использует `utf8mb4`: -- в PDO DSN (`charset=utf8mb4`), -- в миграциях для таблиц (`CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`). - -Если база на хостинге по умолчанию с другой кодировкой, это обычно не мешает, т.к. таблицы создаются миграциями с нужной кодировкой. - -Файлы: -- `lib/db.php` — подключение PDO -- `migrations/*.sql` — схема БД -- `scripts/migrate.php` — runner миграций - -Открыть в браузере: - -- `http://127.0.0.1:8080` - -## Загрузка фото - -Через FTP кладите файлы в: - -- `photos/Свадьба/001.jpg` -- `photos/Портреты/img_10.png` -- и т.д. - -Папка верхнего уровня = категория. - -## Деплой (Timeweb shared hosting) - -В проекте есть скрипт: - -- `scripts/deploy.sh` - -Он: - -1. делает `git fetch`, -2. жёстко переключает код на `origin/`, -3. сохраняет runtime-папки (`photos`, `thumbs`, `data`), -4. создаёт `data/last_indexed.txt` при первом запуске. - -Запуск на хостинге: +4. Локальный запуск: ```bash -cd ~/www/photo-gallery -bash scripts/deploy.sh +php -S 127.0.0.1:8080 ``` -По умолчанию ветка `main`. Для другой ветки: +5. Открой: -```bash -BRANCH=master bash scripts/deploy.sh -``` +- `http://127.0.0.1:8080` — публичная часть, +- `http://127.0.0.1:8080/admin.php?token=` — админка (после настройки `deploy-config.php`). -## Админка загрузки (по токену) +## Настройка админки -Новый контур на MySQL (основной): +Админка использует `token` из `deploy-config.php`. -- `admin.php?token=...` — админка -- `index.php` — публичная витрина + комментарии - -Совместимость: -- `admin-mysql.php` и `index-mysql.php` оставлены как алиасы на новые основные файлы. - -Что уже есть в MySQL-контуре: -- создание разделов, -- сценарий загрузки: сначала выбор раздела, затем массовая загрузка только фото "до", -- после загрузки автоматический prefill имени (code_name) из имени файла, -- для каждой карточки фото можно отредактировать: имя, сортировку, комментарий и добавить/заменить фото "после", -- запись в таблицы `sections`, `photos`, `photo_files`, -- персональные комментаторы (генерация ссылок + повторный просмотр/перевыпуск ссылки), -- плоские комментарии к фото, -- удаление комментариев админом, -- watermark на выдаче версии "после". -- превью каталога (`thumbs/`) генерируются при загрузке/замене файла и после поворота. - - -Админка использует тот же `token`, что и `deploy.php`, из файла `deploy-config.php`. - -Ссылка входа: - -```text -https://<домен>/admin.php?token=<твой_секрет> -``` - -В админке можно: -- создавать папки-категории, -- переименовывать/удалять категории, -- задавать порядок (индекс сортировки) категорий, -- загружать фото в выбранную папку, -- переименовывать/удалять фото, -- задавать порядок (индекс сортировки) фото внутри категории, -- видеть превью фото в таблице админки, -- автоматически очищать `data/sort.json` от несуществующих папок/файлов. - -Ограничения загрузки: -- только изображения: JPG/PNG/WEBP/GIF, -- максимум 3 МБ на файл, -- MIME-тип и расширение проверяются на сервере. - -Загрузка новых фото выполняется в секции выбранной категории (чтобы нельзя было загрузить в неправильную папку). - -## Удалённый запуск деплоя по ссылке (webhook) - -1. На хостинге создай конфиг из примера: +Создай файл: ```bash cp deploy-config.php.example deploy-config.php ``` -2. Заполни в `deploy-config.php` минимум: -- `token` (длинный секрет) -- при желании `allowed_ips` -- при желании `basic_auth_user/basic_auth_pass` +Минимально заполни: -3. Запуск деплоя: +- `token` — длинный случайный секрет. -```text -https://<домен>/deploy.php?token=<твой_секрет> +В админке доступны разделы: + +- Разделы, +- Тематики, +- Фото, +- Пользователи, +- Комментарии, +- Настройки (welcome + watermark параметры). + +## Превью и watermark + +- Публичный каталог карточек грузит превью через `?action=thumb&file_id=...`. +- Превью создаются автоматически в `thumbs/`: + - при загрузке файлов, + - при замене файлов, + - после поворота. +- Для старых данных можно догенерировать превью: + +```bash +php scripts/generate_thumbs.php ``` -Рекомендация: включить IP whitelist и Basic Auth. +- Версия `after` в публичке отдается с watermark (`?action=image&file_id=...`). + +## Деплой + +Webhook: + +- `deploy.php?token=` + +Скрипт `scripts/deploy.sh`: + +1. делает `git fetch --all --prune`, +2. переключает код на `origin/` через `git reset --hard`, +3. сохраняет runtime-папки (`photos`, `thumbs`, `data`). + +Важно: деплой-скрипт перетирает рабочие изменения в репозитории на сервере. ## Примечания -- Превью генерируются в формате JPEG с качеством ~82. -- Для разового backfill превью можно запустить: `php scripts/generate_thumbs.php`. -- При первом заходе на большую папку возможно небольшое ожидание (генерация превью). -- CSS/JS и favicon подключаются с cache-busting параметром `?v=`, чтобы после деплоя пользователю не приходилось чистить кеш вручную. -- В футере публичной страницы есть ненавязчивое авторство со ссылкой: `https://t.me/andr33vru`. -- Для production обычно лучше вынести индексацию в cron/очередь, но для текущей задачи это intentionally on-request. +- Проект принудительно редиректит на HTTPS и non-www через `.htaccess`. +- Для production рекомендуется ограничить webhook по IP и/или Basic Auth (`deploy-config.php`). +- Если `config.php` отсутствует, приложение корректно падает с ошибкой подключения к БД. diff --git a/admin.php b/admin.php index bd278a0..3be8708 100644 --- a/admin.php +++ b/admin.php @@ -4,6 +4,10 @@ declare(strict_types=1); require_once __DIR__ . '/lib/db_gallery.php'; require_once __DIR__ . '/lib/thumbs.php'; +require_once __DIR__ . '/lib/admin_http.php'; +require_once __DIR__ . '/lib/admin_helpers.php'; +require_once __DIR__ . '/lib/admin_get_actions.php'; +require_once __DIR__ . '/lib/admin_post_actions.php'; const MAX_UPLOAD_BYTES = 3 * 1024 * 1024; @@ -21,30 +25,8 @@ if ($tokenExpected === '' || !hash_equals($tokenExpected, $tokenIncoming)) { } $requestAction = (string)($_REQUEST['action'] ?? ''); -if ($_SERVER['REQUEST_METHOD'] === 'GET' && $requestAction === 'photo_comments') { - $photoId = (int)($_GET['photo_id'] ?? 0); - if ($photoId < 1) { - http_response_code(400); - header('Content-Type: application/json; charset=utf-8'); - echo json_encode(['ok' => false, 'message' => 'Некорректный photo_id'], JSON_UNESCAPED_UNICODE); - exit; - } - - $photo = photoById($photoId); - if (!$photo) { - http_response_code(404); - header('Content-Type: application/json; charset=utf-8'); - echo json_encode(['ok' => false, 'message' => 'Фото не найдено'], JSON_UNESCAPED_UNICODE); - exit; - } - - header('Content-Type: application/json; charset=utf-8'); - echo json_encode([ - 'ok' => true, - 'photo' => ['id' => (int)$photo['id'], 'code_name' => (string)$photo['code_name']], - 'comments' => commentsByPhoto($photoId), - ], JSON_UNESCAPED_UNICODE); - exit; +if ($_SERVER['REQUEST_METHOD'] === 'GET') { + adminHandleGetAction($requestAction); } $message = ''; @@ -56,327 +38,14 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { || strtolower((string)($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '')) === 'xmlhttprequest'; try { - if ($action === 'create_section') { - $name = trim((string)($_POST['name'] ?? '')); - if ($name === '') throw new RuntimeException('Название раздела пустое'); - $sort = nextSectionSortOrder(); - sectionCreate($name, $sort); - $message = 'Раздел создан'; - } - - if ($action === 'update_section') { - $sectionId = (int)($_POST['section_id'] ?? 0); - $name = trim((string)($_POST['name'] ?? '')); - $sort = (int)($_POST['sort_order'] ?? 1000); - if ($sectionId < 1) throw new RuntimeException('Некорректный раздел'); - if ($name === '') throw new RuntimeException('Название раздела пустое'); - if (!sectionById($sectionId)) throw new RuntimeException('Раздел не найден'); - sectionUpdate($sectionId, $name, $sort); - $message = 'Раздел обновлён'; - if ($isAjax) { - header('Content-Type: application/json; charset=utf-8'); - echo json_encode(['ok' => true, 'message' => $message], JSON_UNESCAPED_UNICODE); - exit; - } - } - - if ($action === 'create_topic') { - $name = trim((string)($_POST['name'] ?? '')); - $parentId = (int)($_POST['parent_id'] ?? 0); - $parent = null; - if ($name === '') throw new RuntimeException('Название тематики пустое'); - - if ($parentId > 0) { - $parent = topicById($parentId); - if (!$parent) throw new RuntimeException('Родительская тематика не найдена'); - if (!empty($parent['parent_id'])) { - throw new RuntimeException('Разрешено только 2 уровня вложенности тематик'); - } - } - - $sort = nextTopicSortOrder($parentId > 0 ? $parentId : null); - topicCreate($name, $parentId > 0 ? $parentId : null, $sort); - $message = 'Тематика создана'; - } - - if ($action === 'update_topic') { - $topicId = (int)($_POST['topic_id'] ?? 0); - $name = trim((string)($_POST['name'] ?? '')); - $sort = (int)($_POST['sort_order'] ?? 1000); - - if ($topicId < 1) throw new RuntimeException('Некорректная тематика'); - if ($name === '') throw new RuntimeException('Название тематики пустое'); - - $topic = topicById($topicId); - if (!$topic) throw new RuntimeException('Тематика не найдена'); - - $currentParentId = isset($topic['parent_id']) && $topic['parent_id'] !== null ? (int)$topic['parent_id'] : null; - topicUpdate($topicId, $name, $currentParentId, $sort); - $message = 'Тематика обновлена'; - if ($isAjax) { - header('Content-Type: application/json; charset=utf-8'); - echo json_encode(['ok' => true, 'message' => $message], JSON_UNESCAPED_UNICODE); - exit; - } - } - - if ($action === 'delete_topic') { - $topicId = (int)($_POST['topic_id'] ?? 0); - if ($topicId < 1) throw new RuntimeException('Некорректная тематика'); - if (!topicById($topicId)) throw new RuntimeException('Тематика не найдена'); - - topicDelete($topicId); - $message = 'Тематика удалена'; - } - - if ($action === 'delete_section') { - $sectionId = (int)($_POST['section_id'] ?? 0); - if ($sectionId < 1) throw new RuntimeException('Некорректный раздел'); - if (!sectionById($sectionId)) throw new RuntimeException('Раздел не найден'); - - removeSectionImageFiles($sectionId); - sectionDelete($sectionId); - deleteSectionStorage($sectionId); - $message = 'Раздел удалён'; - } - - if ($action === 'update_settings' || $action === 'update_welcome') { - $text = trim((string)($_POST['welcome_text'] ?? '')); - $wmText = trim((string)($_POST['watermark_text'] ?? 'photo.andr33v.ru')); - $wmBrightness = (int)($_POST['watermark_brightness'] ?? 35); - $wmAngle = (int)($_POST['watermark_angle'] ?? -28); - - if ($wmText === '') { - $wmText = 'photo.andr33v.ru'; - } - $wmBrightness = max(5, min(100, $wmBrightness)); - $wmAngle = max(-75, min(75, $wmAngle)); - - settingSet('welcome_text', $text); - settingSet('watermark_text', $wmText); - settingSet('watermark_brightness', (string)$wmBrightness); - settingSet('watermark_angle', (string)$wmAngle); - $message = 'Настройки сохранены'; - } - - if ($action === 'upload_before_bulk') { - $sectionId = (int)($_POST['section_id'] ?? 0); - if ($sectionId < 1 || !sectionById($sectionId)) throw new RuntimeException('Выбери раздел'); - if (!isset($_FILES['before_bulk'])) throw new RuntimeException('Файлы не переданы'); - - $result = saveBulkBefore($_FILES['before_bulk'], $sectionId); - $message = 'Загружено: ' . $result['ok']; + $result = adminHandlePostAction($action, $isAjax, __DIR__); + $message = (string)($result['message'] ?? ''); + if (isset($result['errors']) && is_array($result['errors']) && $result['errors'] !== []) { $errors = array_merge($errors, $result['errors']); } - - if ($action === 'photo_update') { - $photoId = (int)($_POST['photo_id'] ?? 0); - $code = trim((string)($_POST['code_name'] ?? '')); - $sort = (int)($_POST['sort_order'] ?? 1000); - $descr = trim((string)($_POST['description'] ?? '')); - $descr = $descr !== '' ? $descr : null; - - if ($photoId < 1) throw new RuntimeException('Некорректный photo_id'); - if ($code === '') throw new RuntimeException('Код фото пустой'); - - $st = db()->prepare('UPDATE photos SET code_name=:c, sort_order=:s, description=:d WHERE id=:id'); - $st->execute(['c' => $code, 's' => $sort, 'd' => $descr, 'id' => $photoId]); - - if (isset($_FILES['after']) && (int)($_FILES['after']['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_NO_FILE) { - $p = photoById($photoId); - if (!$p) throw new RuntimeException('Фото не найдено'); - $oldAfterPath = (string)($p['after_path'] ?? ''); - $up = saveSingleImage($_FILES['after'], $code . 'р', (int)$p['section_id']); - photoFileUpsert($photoId, 'after', $up['path'], $up['mime'], $up['size']); - - if ($oldAfterPath !== '' && $oldAfterPath !== $up['path']) { - deleteThumbBySourcePath(__DIR__, $oldAfterPath); - $oldAbs = __DIR__ . '/' . ltrim($oldAfterPath, '/'); - if (is_file($oldAbs)) { - @unlink($oldAbs); - } - } - } - - $message = 'Фото обновлено'; - if ($isAjax) { - header('Content-Type: application/json; charset=utf-8'); - echo json_encode(['ok' => true, 'message' => $message], JSON_UNESCAPED_UNICODE); - exit; - } - } - - if ($action === 'upload_after_file') { - $photoId = (int)($_POST['photo_id'] ?? 0); - if ($photoId < 1) throw new RuntimeException('Некорректный photo_id'); - if (!isset($_FILES['after'])) throw new RuntimeException('Файл не передан'); - - $photo = photoById($photoId); - if (!$photo) throw new RuntimeException('Фото не найдено'); - - $oldAfterPath = (string)($photo['after_path'] ?? ''); - $up = saveSingleImage($_FILES['after'], (string)$photo['code_name'] . 'р', (int)$photo['section_id']); - photoFileUpsert($photoId, 'after', $up['path'], $up['mime'], $up['size']); - - if ($oldAfterPath !== '' && $oldAfterPath !== $up['path']) { - deleteThumbBySourcePath(__DIR__, $oldAfterPath); - $oldAbs = __DIR__ . '/' . ltrim($oldAfterPath, '/'); - if (is_file($oldAbs)) { - @unlink($oldAbs); - } - } - - $updatedPhoto = photoById($photoId); - $afterFileId = (int)($updatedPhoto['after_file_id'] ?? 0); - $previewUrl = $afterFileId > 0 ? ('index.php?action=image&file_id=' . $afterFileId . '&v=' . rawurlencode((string)time())) : ''; - - $message = 'Фото после обновлено'; - if ($isAjax) { - header('Content-Type: application/json; charset=utf-8'); - echo json_encode([ - 'ok' => true, - 'message' => $message, - 'photo_id' => $photoId, - 'preview_url' => $previewUrl, - ], JSON_UNESCAPED_UNICODE); - exit; - } - } - - if ($action === 'attach_photo_topic') { - $photoId = (int)($_POST['photo_id'] ?? 0); - $topicId = (int)($_POST['topic_id'] ?? 0); - if ($photoId < 1 || !photoById($photoId)) throw new RuntimeException('Фото не найдено'); - if ($topicId < 1 || !topicById($topicId)) throw new RuntimeException('Тематика не найдена'); - - photoTopicAttach($photoId, $topicId); - $topics = array_map(static fn(array $t): array => [ - 'id' => (int)$t['id'], - 'full_name' => (string)$t['full_name'], - ], photoTopicsByPhotoId($photoId)); - $message = 'Тематика добавлена'; - - if ($isAjax) { - header('Content-Type: application/json; charset=utf-8'); - echo json_encode(['ok' => true, 'message' => $message, 'photo_id' => $photoId, 'topics' => $topics], JSON_UNESCAPED_UNICODE); - exit; - } - } - - if ($action === 'detach_photo_topic') { - $photoId = (int)($_POST['photo_id'] ?? 0); - $topicId = (int)($_POST['topic_id'] ?? 0); - if ($photoId < 1 || !photoById($photoId)) throw new RuntimeException('Фото не найдено'); - if ($topicId < 1) throw new RuntimeException('Тематика не найдена'); - - photoTopicDetach($photoId, $topicId); - $topics = array_map(static fn(array $t): array => [ - 'id' => (int)$t['id'], - 'full_name' => (string)$t['full_name'], - ], photoTopicsByPhotoId($photoId)); - $message = 'Тематика удалена'; - - if ($isAjax) { - header('Content-Type: application/json; charset=utf-8'); - echo json_encode(['ok' => true, 'message' => $message, 'photo_id' => $photoId, 'topics' => $topics], JSON_UNESCAPED_UNICODE); - exit; - } - } - - if ($action === 'photo_delete') { - $photoId = (int)($_POST['photo_id'] ?? 0); - if ($photoId > 0) { - $p = photoById($photoId); - if ($p) { - foreach (['before_path', 'after_path'] as $k) { - if (!empty($p[$k])) { - deleteThumbBySourcePath(__DIR__, (string)$p[$k]); - $abs = __DIR__ . '/' . ltrim((string)$p[$k], '/'); - if (is_file($abs)) @unlink($abs); - } - } - } - $st = db()->prepare('DELETE FROM photos WHERE id=:id'); - $st->execute(['id' => $photoId]); - $message = 'Фото удалено'; - } - } - - if ($action === 'rotate_photo_file') { - $photoId = (int)($_POST['photo_id'] ?? 0); - $kind = (string)($_POST['kind'] ?? ''); - $direction = (string)($_POST['direction'] ?? 'right'); - if ($photoId < 1) throw new RuntimeException('Некорректный photo_id'); - if (!in_array($kind, ['before', 'after'], true)) throw new RuntimeException('Некорректный тип файла'); - - $photo = photoById($photoId); - if (!$photo) throw new RuntimeException('Фото не найдено'); - - $pathKey = $kind === 'before' ? 'before_path' : 'after_path'; - $relPath = (string)($photo[$pathKey] ?? ''); - if ($relPath === '') throw new RuntimeException('Файл отсутствует'); - - $absPath = __DIR__ . '/' . ltrim($relPath, '/'); - if (!is_file($absPath)) throw new RuntimeException('Файл не найден на диске'); - - $degrees = $direction === 'left' ? -90 : 90; - rotateImageOnDisk($absPath, $degrees); - ensureThumbForSource(__DIR__, $relPath); - - $st = db()->prepare('UPDATE photo_files SET updated_at=CURRENT_TIMESTAMP WHERE photo_id=:pid AND kind=:kind'); - $st->execute(['pid' => $photoId, 'kind' => $kind]); - - $message = 'Изображение повернуто'; - if ($isAjax) { - header('Content-Type: application/json; charset=utf-8'); - echo json_encode(['ok' => true, 'message' => $message, 'photo_id' => $photoId, 'kind' => $kind], JSON_UNESCAPED_UNICODE); - exit; - } - } - - if ($action === 'create_commenter') { - $displayName = trim((string)($_POST['display_name'] ?? '')); - if ($displayName === '') throw new RuntimeException('Укажи имя комментатора'); - $u = commenterCreate($displayName); - $link = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http') . '://' . ($_SERVER['HTTP_HOST'] ?? 'localhost') . '/?viewer=' . urlencode($u['token']); - $message = 'Комментатор создан: ' . $u['display_name'] . ' | ссылка: ' . $link; - } - - if ($action === 'delete_commenter') { - $id = (int)($_POST['id'] ?? 0); - if ($id > 0) { - commenterDelete($id); - $message = 'Комментатор удалён (доступ отозван)'; - } - } - - if ($action === 'regenerate_commenter_token') { - $id = (int)($_POST['id'] ?? 0); - if ($id > 0) { - $token = commenterRegenerateToken($id); - $link = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http') . '://' . ($_SERVER['HTTP_HOST'] ?? 'localhost') . '/?viewer=' . urlencode($token); - $message = 'Токен обновлён | ссылка: ' . $link; - } - } - - if ($action === 'delete_comment') { - $id = (int)($_POST['id'] ?? 0); - if ($id > 0) { - commentDelete($id); - $message = 'Комментарий удалён'; - if ($isAjax) { - header('Content-Type: application/json; charset=utf-8'); - echo json_encode(['ok' => true, 'message' => $message], JSON_UNESCAPED_UNICODE); - exit; - } - } - } } catch (Throwable $e) { if ($isAjax) { - header('Content-Type: application/json; charset=utf-8'); - http_response_code(400); - echo json_encode(['ok' => false, 'message' => $e->getMessage()], JSON_UNESCAPED_UNICODE); - exit; + adminJsonResponse(['ok' => false, 'message' => $e->getMessage()], 400); } $errors[] = $e->getMessage(); } @@ -427,328 +96,6 @@ try { function h(string $v): string { return htmlspecialchars($v, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); } function assetUrl(string $path): string { $f=__DIR__ . '/' . ltrim($path,'/'); $v=is_file($f)?(string)filemtime($f):(string)time(); return $path . '?v=' . rawurlencode($v); } - -function commentCountsByPhotoIds(array $photoIds): array -{ - $photoIds = array_values(array_unique(array_map('intval', $photoIds))); - if ($photoIds === []) { - return []; - } - - $placeholders = implode(',', array_fill(0, count($photoIds), '?')); - $st = db()->prepare("SELECT photo_id, COUNT(*) AS cnt FROM photo_comments WHERE photo_id IN ($placeholders) GROUP BY photo_id"); - $st->execute($photoIds); - - $map = []; - foreach ($st->fetchAll() as $row) { - $map[(int)$row['photo_id']] = (int)$row['cnt']; - } - return $map; -} - -function commentsSearch(string $photoQuery, string $userQuery, int $limit = 200): array -{ - $limit = max(1, min(500, $limit)); - $where = []; - $params = []; - - if ($photoQuery !== '') { - $where[] = 'p.code_name LIKE :photo'; - $params['photo'] = '%' . $photoQuery . '%'; - } - if ($userQuery !== '') { - $where[] = 'COALESCE(u.display_name, "") LIKE :user'; - $params['user'] = '%' . $userQuery . '%'; - } - - $sql = 'SELECT c.id, c.photo_id, c.comment_text, c.created_at, p.code_name, u.display_name - FROM photo_comments c - JOIN photos p ON p.id=c.photo_id - LEFT JOIN comment_users u ON u.id=c.user_id'; - - if ($where !== []) { - $sql .= ' WHERE ' . implode(' AND ', $where); - } - - $sql .= ' ORDER BY c.id DESC LIMIT ' . $limit; - $st = db()->prepare($sql); - $st->execute($params); - return $st->fetchAll(); -} - -function buildTopicTree(array $topics): array -{ - $roots = []; - $children = []; - - foreach ($topics as $topic) { - $pid = isset($topic['parent_id']) && $topic['parent_id'] !== null ? (int)$topic['parent_id'] : 0; - if ($pid === 0) { - $roots[] = $topic; - continue; - } - if (!isset($children[$pid])) { - $children[$pid] = []; - } - $children[$pid][] = $topic; - } - - foreach ($roots as &$root) { - $rootId = (int)$root['id']; - $root['children'] = $children[$rootId] ?? []; - } - unset($root); - - return $roots; -} - -function nextSectionSortOrder(): int -{ - $sort = (int)db()->query('SELECT COALESCE(MAX(sort_order), 990) + 10 FROM sections')->fetchColumn(); - return max(10, $sort); -} - -function nextTopicSortOrder(?int $parentId): int -{ - $st = db()->prepare('SELECT COALESCE(MAX(sort_order), 990) + 10 FROM topics WHERE parent_id <=> :pid'); - $st->bindValue('pid', $parentId, $parentId === null ? PDO::PARAM_NULL : PDO::PARAM_INT); - $st->execute(); - $sort = (int)$st->fetchColumn(); - return max(10, $sort); -} - -function saveBulkBefore(array $files, int $sectionId): array -{ - $ok = 0; - $errors = []; - - $names = $files['name'] ?? []; - $tmp = $files['tmp_name'] ?? []; - $sizes = $files['size'] ?? []; - $errs = $files['error'] ?? []; - if (!is_array($names)) { - $names = [$names]; - $tmp = [$tmp]; - $sizes = [$sizes]; - $errs = [$errs]; - } - - foreach ($names as $i => $orig) { - $file = [ - 'name' => $orig, - 'tmp_name' => $tmp[$i] ?? '', - 'size' => $sizes[$i] ?? 0, - 'error' => $errs[$i] ?? UPLOAD_ERR_NO_FILE, - ]; - - try { - $base = (string)pathinfo((string)$orig, PATHINFO_FILENAME); - $base = trim(preg_replace('/[^\p{L}\p{N}._-]+/u', '_', $base) ?? 'photo', '._-'); - if ($base === '') $base = 'photo'; - - $codeName = nextUniqueCodeName($base); - $photoId = photoCreate($sectionId, $codeName, null, nextSortOrderForSection($sectionId)); - $saved = saveSingleImage($file, $codeName, $sectionId); - photoFileUpsert($photoId, 'before', $saved['path'], $saved['mime'], $saved['size']); - $ok++; - } catch (Throwable $e) { - $errors[] = (string)$orig . ': ' . $e->getMessage(); - } - } - - return ['ok' => $ok, 'errors' => $errors]; -} - -function saveSingleImage(array $file, string $baseName, int $sectionId): array -{ - $allowedMime = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/webp' => 'webp', 'image/gif' => 'gif']; - $err = (int)($file['error'] ?? UPLOAD_ERR_NO_FILE); - if ($err !== UPLOAD_ERR_OK) throw new RuntimeException('Ошибка загрузки'); - $size = (int)($file['size'] ?? 0); - if ($size < 1 || $size > MAX_UPLOAD_BYTES) throw new RuntimeException('Превышен лимит 3 МБ'); - - $tmp = (string)($file['tmp_name'] ?? ''); - if (!is_uploaded_file($tmp)) throw new RuntimeException('Некорректный источник'); - - $mime = mime_content_type($tmp) ?: ''; - if (!isset($allowedMime[$mime])) throw new RuntimeException('Недопустимый тип файла'); - - $safeBase = preg_replace('/[^\p{L}\p{N}._-]+/u', '_', $baseName) ?? 'photo'; - $safeBase = trim($safeBase, '._-'); - if ($safeBase === '') $safeBase = 'photo'; - - $ext = $allowedMime[$mime]; - $dir = __DIR__ . '/photos/section_' . $sectionId; - if (!is_dir($dir)) mkdir($dir, 0775, true); - $name = uniqueName($dir, $safeBase, $ext); - $dest = $dir . '/' . $name; - - if (!move_uploaded_file($tmp, $dest)) throw new RuntimeException('Не удалось сохранить файл'); - - $storedRelPath = 'photos/section_' . $sectionId . '/' . $name; - ensureThumbForSource(__DIR__, $storedRelPath); - - return [ - 'path' => $storedRelPath, - 'mime' => $mime, - 'size' => $size, - ]; -} - -function uniqueName(string $dir, string $base, string $ext): string -{ - $i = 0; - do { - $name = $i === 0 ? "{$base}.{$ext}" : "{$base}_{$i}.{$ext}"; - $i++; - } while (is_file($dir . '/' . $name)); - return $name; -} - -function deleteSectionStorage(int $sectionId): void -{ - $dir = __DIR__ . '/photos/section_' . $sectionId; - if (!is_dir($dir)) { - return; - } - - deleteDirRecursive($dir); -} - -function deleteDirRecursive(string $dir): void -{ - $items = scandir($dir); - if (!is_array($items)) { - return; - } - - foreach ($items as $item) { - if ($item === '.' || $item === '..') { - continue; - } - - $path = $dir . '/' . $item; - if (is_dir($path)) { - deleteDirRecursive($path); - continue; - } - - @unlink($path); - } - - @rmdir($dir); -} - -function removeSectionImageFiles(int $sectionId): void -{ - $st = db()->prepare('SELECT pf.file_path - FROM photo_files pf - JOIN photos p ON p.id = pf.photo_id - WHERE p.section_id = :sid'); - $st->execute(['sid' => $sectionId]); - $paths = $st->fetchAll(PDO::FETCH_COLUMN); - if (!is_array($paths)) { - return; - } - - foreach ($paths as $path) { - if (!is_string($path) || $path === '') { - continue; - } - - $abs = __DIR__ . '/' . ltrim($path, '/'); - if (is_file($abs)) { - @unlink($abs); - } - deleteThumbBySourcePath(__DIR__, $path); - } -} - -function rotateImageOnDisk(string $path, int $degrees): void -{ - $mime = mime_content_type($path) ?: ''; - if (!in_array($mime, ['image/jpeg', 'image/png', 'image/webp', 'image/gif'], true)) { - throw new RuntimeException('Недопустимый тип файла для поворота'); - } - - if (extension_loaded('imagick')) { - $im = new Imagick($path); - $im->setImageOrientation(Imagick::ORIENTATION_TOPLEFT); - $im->rotateImage(new ImagickPixel('none'), $degrees); - $im->setImagePage(0, 0, 0, 0); - if ($mime === 'image/jpeg') { - $im->setImageCompressionQuality(92); - } - $im->writeImage($path); - $im->clear(); - $im->destroy(); - return; - } - - $src = match ($mime) { - 'image/jpeg' => @imagecreatefromjpeg($path), - 'image/png' => @imagecreatefrompng($path), - 'image/webp' => function_exists('imagecreatefromwebp') ? @imagecreatefromwebp($path) : false, - 'image/gif' => @imagecreatefromgif($path), - default => false, - }; - if (!$src) { - throw new RuntimeException('Не удалось открыть изображение'); - } - - $bgColor = 0; - if ($mime === 'image/png' || $mime === 'image/webp') { - $bgColor = imagecolorallocatealpha($src, 0, 0, 0, 127); - } - - $rotated = imagerotate($src, -$degrees, $bgColor); - if (!$rotated) { - imagedestroy($src); - throw new RuntimeException('Не удалось повернуть изображение'); - } - - if ($mime === 'image/png' || $mime === 'image/webp') { - imagealphablending($rotated, false); - imagesavealpha($rotated, true); - } - - $ok = match ($mime) { - 'image/jpeg' => imagejpeg($rotated, $path, 92), - 'image/png' => imagepng($rotated, $path), - 'image/webp' => function_exists('imagewebp') ? imagewebp($rotated, $path, 92) : false, - 'image/gif' => imagegif($rotated, $path), - default => false, - }; - - imagedestroy($src); - imagedestroy($rotated); - - if (!$ok) { - throw new RuntimeException('Не удалось сохранить повернутое изображение'); - } -} - -function nextSortOrderForSection(int $sectionId): int -{ - $st = db()->prepare('SELECT COALESCE(MAX(sort_order),0)+10 FROM photos WHERE section_id=:sid'); - $st->execute(['sid' => $sectionId]); - return (int)$st->fetchColumn(); -} - -function nextUniqueCodeName(string $base): string -{ - $candidate = $base; - $i = 1; - while (true) { - $st = db()->prepare('SELECT 1 FROM photos WHERE code_name=:c LIMIT 1'); - $st->execute(['c' => $candidate]); - if (!$st->fetchColumn()) { - return $candidate; - } - $candidate = $base . '_' . $i; - $i++; - } -} ?> diff --git a/lib/admin_get_actions.php b/lib/admin_get_actions.php new file mode 100644 index 0000000..a680321 --- /dev/null +++ b/lib/admin_get_actions.php @@ -0,0 +1,26 @@ + false, 'message' => 'Некорректный photo_id'], 400); + } + + $photo = photoById($photoId); + if (!$photo) { + adminJsonResponse(['ok' => false, 'message' => 'Фото не найдено'], 404); + } + + adminJsonResponse([ + 'ok' => true, + 'photo' => ['id' => (int)$photo['id'], 'code_name' => (string)$photo['code_name']], + 'comments' => commentsByPhoto($photoId), + ]); +} diff --git a/lib/admin_helpers.php b/lib/admin_helpers.php new file mode 100644 index 0000000..0d8df95 --- /dev/null +++ b/lib/admin_helpers.php @@ -0,0 +1,343 @@ +prepare("SELECT photo_id, COUNT(*) AS cnt FROM photo_comments WHERE photo_id IN ($placeholders) GROUP BY photo_id"); + $st->execute($photoIds); + + $map = []; + foreach ($st->fetchAll() as $row) { + $map[(int)$row['photo_id']] = (int)$row['cnt']; + } + return $map; +} + +function commentsSearch(string $photoQuery, string $userQuery, int $limit = 200): array +{ + $limit = max(1, min(500, $limit)); + $where = []; + $params = []; + + if ($photoQuery !== '') { + $where[] = 'p.code_name LIKE :photo'; + $params['photo'] = '%' . $photoQuery . '%'; + } + if ($userQuery !== '') { + $where[] = 'COALESCE(u.display_name, "") LIKE :user'; + $params['user'] = '%' . $userQuery . '%'; + } + + $sql = 'SELECT c.id, c.photo_id, c.comment_text, c.created_at, p.code_name, u.display_name + FROM photo_comments c + JOIN photos p ON p.id=c.photo_id + LEFT JOIN comment_users u ON u.id=c.user_id'; + + if ($where !== []) { + $sql .= ' WHERE ' . implode(' AND ', $where); + } + + $sql .= ' ORDER BY c.id DESC LIMIT ' . $limit; + $st = db()->prepare($sql); + $st->execute($params); + return $st->fetchAll(); +} + +function buildTopicTree(array $topics): array +{ + $roots = []; + $children = []; + + foreach ($topics as $topic) { + $pid = isset($topic['parent_id']) && $topic['parent_id'] !== null ? (int)$topic['parent_id'] : 0; + if ($pid === 0) { + $roots[] = $topic; + continue; + } + if (!isset($children[$pid])) { + $children[$pid] = []; + } + $children[$pid][] = $topic; + } + + foreach ($roots as &$root) { + $rootId = (int)$root['id']; + $root['children'] = $children[$rootId] ?? []; + } + unset($root); + + return $roots; +} + +function nextSectionSortOrder(): int +{ + $sort = (int)db()->query('SELECT COALESCE(MAX(sort_order), 990) + 10 FROM sections')->fetchColumn(); + return max(10, $sort); +} + +function nextTopicSortOrder(?int $parentId): int +{ + $st = db()->prepare('SELECT COALESCE(MAX(sort_order), 990) + 10 FROM topics WHERE parent_id <=> :pid'); + $st->bindValue('pid', $parentId, $parentId === null ? PDO::PARAM_NULL : PDO::PARAM_INT); + $st->execute(); + $sort = (int)$st->fetchColumn(); + return max(10, $sort); +} + +function saveBulkBefore(array $files, int $sectionId): array +{ + $ok = 0; + $errors = []; + + $names = $files['name'] ?? []; + $tmp = $files['tmp_name'] ?? []; + $sizes = $files['size'] ?? []; + $errs = $files['error'] ?? []; + if (!is_array($names)) { + $names = [$names]; + $tmp = [$tmp]; + $sizes = [$sizes]; + $errs = [$errs]; + } + + foreach ($names as $i => $orig) { + $file = [ + 'name' => $orig, + 'tmp_name' => $tmp[$i] ?? '', + 'size' => $sizes[$i] ?? 0, + 'error' => $errs[$i] ?? UPLOAD_ERR_NO_FILE, + ]; + + try { + $base = (string)pathinfo((string)$orig, PATHINFO_FILENAME); + $base = trim(preg_replace('/[^\p{L}\p{N}._-]+/u', '_', $base) ?? 'photo', '._-'); + if ($base === '') { + $base = 'photo'; + } + + $codeName = nextUniqueCodeName($base); + $photoId = photoCreate($sectionId, $codeName, null, nextSortOrderForSection($sectionId)); + $saved = saveSingleImage($file, $codeName, $sectionId); + photoFileUpsert($photoId, 'before', $saved['path'], $saved['mime'], $saved['size']); + $ok++; + } catch (Throwable $e) { + $errors[] = (string)$orig . ': ' . $e->getMessage(); + } + } + + return ['ok' => $ok, 'errors' => $errors]; +} + +function saveSingleImage(array $file, string $baseName, int $sectionId): array +{ + $allowedMime = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/webp' => 'webp', 'image/gif' => 'gif']; + $err = (int)($file['error'] ?? UPLOAD_ERR_NO_FILE); + if ($err !== UPLOAD_ERR_OK) { + throw new RuntimeException('Ошибка загрузки'); + } + $size = (int)($file['size'] ?? 0); + if ($size < 1 || $size > MAX_UPLOAD_BYTES) { + throw new RuntimeException('Превышен лимит 3 МБ'); + } + + $tmp = (string)($file['tmp_name'] ?? ''); + if (!is_uploaded_file($tmp)) { + throw new RuntimeException('Некорректный источник'); + } + + $mime = mime_content_type($tmp) ?: ''; + if (!isset($allowedMime[$mime])) { + throw new RuntimeException('Недопустимый тип файла'); + } + + $safeBase = preg_replace('/[^\p{L}\p{N}._-]+/u', '_', $baseName) ?? 'photo'; + $safeBase = trim($safeBase, '._-'); + if ($safeBase === '') { + $safeBase = 'photo'; + } + + $ext = $allowedMime[$mime]; + $root = dirname(__DIR__); + $dir = $root . '/photos/section_' . $sectionId; + if (!is_dir($dir)) { + mkdir($dir, 0775, true); + } + $name = uniqueName($dir, $safeBase, $ext); + $dest = $dir . '/' . $name; + + if (!move_uploaded_file($tmp, $dest)) { + throw new RuntimeException('Не удалось сохранить файл'); + } + + $storedRelPath = 'photos/section_' . $sectionId . '/' . $name; + ensureThumbForSource($root, $storedRelPath); + + return [ + 'path' => $storedRelPath, + 'mime' => $mime, + 'size' => $size, + ]; +} + +function uniqueName(string $dir, string $base, string $ext): string +{ + $i = 0; + do { + $name = $i === 0 ? "{$base}.{$ext}" : "{$base}_{$i}.{$ext}"; + $i++; + } while (is_file($dir . '/' . $name)); + return $name; +} + +function deleteSectionStorage(int $sectionId): void +{ + $dir = dirname(__DIR__) . '/photos/section_' . $sectionId; + if (!is_dir($dir)) { + return; + } + + deleteDirRecursive($dir); +} + +function deleteDirRecursive(string $dir): void +{ + $items = scandir($dir); + if (!is_array($items)) { + return; + } + + foreach ($items as $item) { + if ($item === '.' || $item === '..') { + continue; + } + + $path = $dir . '/' . $item; + if (is_dir($path)) { + deleteDirRecursive($path); + continue; + } + + @unlink($path); + } + + @rmdir($dir); +} + +function removeSectionImageFiles(int $sectionId): void +{ + $st = db()->prepare('SELECT pf.file_path + FROM photo_files pf + JOIN photos p ON p.id = pf.photo_id + WHERE p.section_id = :sid'); + $st->execute(['sid' => $sectionId]); + $paths = $st->fetchAll(PDO::FETCH_COLUMN); + if (!is_array($paths)) { + return; + } + + $root = dirname(__DIR__); + foreach ($paths as $path) { + if (!is_string($path) || $path === '') { + continue; + } + + $abs = $root . '/' . ltrim($path, '/'); + if (is_file($abs)) { + @unlink($abs); + } + deleteThumbBySourcePath($root, $path); + } +} + +function rotateImageOnDisk(string $path, int $degrees): void +{ + $mime = mime_content_type($path) ?: ''; + if (!in_array($mime, ['image/jpeg', 'image/png', 'image/webp', 'image/gif'], true)) { + throw new RuntimeException('Недопустимый тип файла для поворота'); + } + + if (extension_loaded('imagick')) { + $im = new Imagick($path); + $im->setImageOrientation(Imagick::ORIENTATION_TOPLEFT); + $im->rotateImage(new ImagickPixel('none'), $degrees); + $im->setImagePage(0, 0, 0, 0); + if ($mime === 'image/jpeg') { + $im->setImageCompressionQuality(92); + } + $im->writeImage($path); + $im->clear(); + $im->destroy(); + return; + } + + $src = match ($mime) { + 'image/jpeg' => @imagecreatefromjpeg($path), + 'image/png' => @imagecreatefrompng($path), + 'image/webp' => function_exists('imagecreatefromwebp') ? @imagecreatefromwebp($path) : false, + 'image/gif' => @imagecreatefromgif($path), + default => false, + }; + if (!$src) { + throw new RuntimeException('Не удалось открыть изображение'); + } + + $bgColor = 0; + if ($mime === 'image/png' || $mime === 'image/webp') { + $bgColor = imagecolorallocatealpha($src, 0, 0, 0, 127); + } + + $rotated = imagerotate($src, -$degrees, $bgColor); + if (!$rotated) { + imagedestroy($src); + throw new RuntimeException('Не удалось повернуть изображение'); + } + + if ($mime === 'image/png' || $mime === 'image/webp') { + imagealphablending($rotated, false); + imagesavealpha($rotated, true); + } + + $ok = match ($mime) { + 'image/jpeg' => imagejpeg($rotated, $path, 92), + 'image/png' => imagepng($rotated, $path), + 'image/webp' => function_exists('imagewebp') ? imagewebp($rotated, $path, 92) : false, + 'image/gif' => imagegif($rotated, $path), + default => false, + }; + + imagedestroy($src); + imagedestroy($rotated); + + if (!$ok) { + throw new RuntimeException('Не удалось сохранить повернутое изображение'); + } +} + +function nextSortOrderForSection(int $sectionId): int +{ + $st = db()->prepare('SELECT COALESCE(MAX(sort_order),0)+10 FROM photos WHERE section_id=:sid'); + $st->execute(['sid' => $sectionId]); + return (int)$st->fetchColumn(); +} + +function nextUniqueCodeName(string $base): string +{ + $candidate = $base; + $i = 1; + while (true) { + $st = db()->prepare('SELECT 1 FROM photos WHERE code_name=:c LIMIT 1'); + $st->execute(['c' => $candidate]); + if (!$st->fetchColumn()) { + return $candidate; + } + $candidate = $base . '_' . $i; + $i++; + } +} diff --git a/lib/admin_http.php b/lib/admin_http.php new file mode 100644 index 0000000..6da3694 --- /dev/null +++ b/lib/admin_http.php @@ -0,0 +1,11 @@ + true, 'message' => $message]); + } + break; + } + + case 'create_topic': { + $name = trim((string)($_POST['name'] ?? '')); + $parentId = (int)($_POST['parent_id'] ?? 0); + + if ($name === '') { + throw new RuntimeException('Название тематики пустое'); + } + + if ($parentId > 0) { + $parent = topicById($parentId); + if (!$parent) { + throw new RuntimeException('Родительская тематика не найдена'); + } + if (!empty($parent['parent_id'])) { + throw new RuntimeException('Разрешено только 2 уровня вложенности тематик'); + } + } + + $sort = nextTopicSortOrder($parentId > 0 ? $parentId : null); + topicCreate($name, $parentId > 0 ? $parentId : null, $sort); + $message = 'Тематика создана'; + break; + } + + case 'update_topic': { + $topicId = (int)($_POST['topic_id'] ?? 0); + $name = trim((string)($_POST['name'] ?? '')); + $sort = (int)($_POST['sort_order'] ?? 1000); + + if ($topicId < 1) { + throw new RuntimeException('Некорректная тематика'); + } + if ($name === '') { + throw new RuntimeException('Название тематики пустое'); + } + + $topic = topicById($topicId); + if (!$topic) { + throw new RuntimeException('Тематика не найдена'); + } + + $currentParentId = isset($topic['parent_id']) && $topic['parent_id'] !== null ? (int)$topic['parent_id'] : null; + topicUpdate($topicId, $name, $currentParentId, $sort); + $message = 'Тематика обновлена'; + if ($isAjax) { + adminJsonResponse(['ok' => true, 'message' => $message]); + } + break; + } + + case 'delete_topic': { + $topicId = (int)($_POST['topic_id'] ?? 0); + if ($topicId < 1) { + throw new RuntimeException('Некорректная тематика'); + } + if (!topicById($topicId)) { + throw new RuntimeException('Тематика не найдена'); + } + + topicDelete($topicId); + $message = 'Тематика удалена'; + break; + } + + case 'delete_section': { + $sectionId = (int)($_POST['section_id'] ?? 0); + if ($sectionId < 1) { + throw new RuntimeException('Некорректный раздел'); + } + if (!sectionById($sectionId)) { + throw new RuntimeException('Раздел не найден'); + } + + removeSectionImageFiles($sectionId); + sectionDelete($sectionId); + deleteSectionStorage($sectionId); + $message = 'Раздел удалён'; + break; + } + + case 'update_settings': + case 'update_welcome': { + $text = trim((string)($_POST['welcome_text'] ?? '')); + $wmText = trim((string)($_POST['watermark_text'] ?? 'photo.andr33v.ru')); + $wmBrightness = (int)($_POST['watermark_brightness'] ?? 35); + $wmAngle = (int)($_POST['watermark_angle'] ?? -28); + + if ($wmText === '') { + $wmText = 'photo.andr33v.ru'; + } + $wmBrightness = max(5, min(100, $wmBrightness)); + $wmAngle = max(-75, min(75, $wmAngle)); + + settingSet('welcome_text', $text); + settingSet('watermark_text', $wmText); + settingSet('watermark_brightness', (string)$wmBrightness); + settingSet('watermark_angle', (string)$wmAngle); + $message = 'Настройки сохранены'; + break; + } + + case 'upload_before_bulk': { + $sectionId = (int)($_POST['section_id'] ?? 0); + if ($sectionId < 1 || !sectionById($sectionId)) { + throw new RuntimeException('Выбери раздел'); + } + if (!isset($_FILES['before_bulk'])) { + throw new RuntimeException('Файлы не переданы'); + } + + $result = saveBulkBefore($_FILES['before_bulk'], $sectionId); + $message = 'Загружено: ' . $result['ok']; + $errors = array_merge($errors, $result['errors']); + break; + } + + case 'photo_update': { + $photoId = (int)($_POST['photo_id'] ?? 0); + $code = trim((string)($_POST['code_name'] ?? '')); + $sort = (int)($_POST['sort_order'] ?? 1000); + $descr = trim((string)($_POST['description'] ?? '')); + $descr = $descr !== '' ? $descr : null; + + if ($photoId < 1) { + throw new RuntimeException('Некорректный photo_id'); + } + if ($code === '') { + throw new RuntimeException('Код фото пустой'); + } + + $st = db()->prepare('UPDATE photos SET code_name=:c, sort_order=:s, description=:d WHERE id=:id'); + $st->execute(['c' => $code, 's' => $sort, 'd' => $descr, 'id' => $photoId]); + + if (isset($_FILES['after']) && (int)($_FILES['after']['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_NO_FILE) { + $p = photoById($photoId); + if (!$p) { + throw new RuntimeException('Фото не найдено'); + } + $oldAfterPath = (string)($p['after_path'] ?? ''); + $up = saveSingleImage($_FILES['after'], $code . 'р', (int)$p['section_id']); + photoFileUpsert($photoId, 'after', $up['path'], $up['mime'], $up['size']); + + if ($oldAfterPath !== '' && $oldAfterPath !== $up['path']) { + deleteThumbBySourcePath($projectRoot, $oldAfterPath); + $oldAbs = $projectRoot . '/' . ltrim($oldAfterPath, '/'); + if (is_file($oldAbs)) { + @unlink($oldAbs); + } + } + } + + $message = 'Фото обновлено'; + if ($isAjax) { + adminJsonResponse(['ok' => true, 'message' => $message]); + } + break; + } + + case 'upload_after_file': { + $photoId = (int)($_POST['photo_id'] ?? 0); + if ($photoId < 1) { + throw new RuntimeException('Некорректный photo_id'); + } + if (!isset($_FILES['after'])) { + throw new RuntimeException('Файл не передан'); + } + + $photo = photoById($photoId); + if (!$photo) { + throw new RuntimeException('Фото не найдено'); + } + + $oldAfterPath = (string)($photo['after_path'] ?? ''); + $up = saveSingleImage($_FILES['after'], (string)$photo['code_name'] . 'р', (int)$photo['section_id']); + photoFileUpsert($photoId, 'after', $up['path'], $up['mime'], $up['size']); + + if ($oldAfterPath !== '' && $oldAfterPath !== $up['path']) { + deleteThumbBySourcePath($projectRoot, $oldAfterPath); + $oldAbs = $projectRoot . '/' . ltrim($oldAfterPath, '/'); + if (is_file($oldAbs)) { + @unlink($oldAbs); + } + } + + $updatedPhoto = photoById($photoId); + $afterFileId = (int)($updatedPhoto['after_file_id'] ?? 0); + $previewUrl = $afterFileId > 0 ? ('index.php?action=image&file_id=' . $afterFileId . '&v=' . rawurlencode((string)time())) : ''; + + $message = 'Фото после обновлено'; + if ($isAjax) { + adminJsonResponse([ + 'ok' => true, + 'message' => $message, + 'photo_id' => $photoId, + 'preview_url' => $previewUrl, + ]); + } + break; + } + + case 'attach_photo_topic': { + $photoId = (int)($_POST['photo_id'] ?? 0); + $topicId = (int)($_POST['topic_id'] ?? 0); + if ($photoId < 1 || !photoById($photoId)) { + throw new RuntimeException('Фото не найдено'); + } + if ($topicId < 1 || !topicById($topicId)) { + throw new RuntimeException('Тематика не найдена'); + } + + photoTopicAttach($photoId, $topicId); + $topics = array_map(static fn(array $t): array => [ + 'id' => (int)$t['id'], + 'full_name' => (string)$t['full_name'], + ], photoTopicsByPhotoId($photoId)); + $message = 'Тематика добавлена'; + + if ($isAjax) { + adminJsonResponse(['ok' => true, 'message' => $message, 'photo_id' => $photoId, 'topics' => $topics]); + } + break; + } + + case 'detach_photo_topic': { + $photoId = (int)($_POST['photo_id'] ?? 0); + $topicId = (int)($_POST['topic_id'] ?? 0); + if ($photoId < 1 || !photoById($photoId)) { + throw new RuntimeException('Фото не найдено'); + } + if ($topicId < 1) { + throw new RuntimeException('Тематика не найдена'); + } + + photoTopicDetach($photoId, $topicId); + $topics = array_map(static fn(array $t): array => [ + 'id' => (int)$t['id'], + 'full_name' => (string)$t['full_name'], + ], photoTopicsByPhotoId($photoId)); + $message = 'Тематика удалена'; + + if ($isAjax) { + adminJsonResponse(['ok' => true, 'message' => $message, 'photo_id' => $photoId, 'topics' => $topics]); + } + break; + } + + case 'photo_delete': { + $photoId = (int)($_POST['photo_id'] ?? 0); + if ($photoId > 0) { + $p = photoById($photoId); + if ($p) { + foreach (['before_path', 'after_path'] as $k) { + if (!empty($p[$k])) { + deleteThumbBySourcePath($projectRoot, (string)$p[$k]); + $abs = $projectRoot . '/' . ltrim((string)$p[$k], '/'); + if (is_file($abs)) { + @unlink($abs); + } + } + } + } + $st = db()->prepare('DELETE FROM photos WHERE id=:id'); + $st->execute(['id' => $photoId]); + $message = 'Фото удалено'; + } + break; + } + + case 'rotate_photo_file': { + $photoId = (int)($_POST['photo_id'] ?? 0); + $kind = (string)($_POST['kind'] ?? ''); + $direction = (string)($_POST['direction'] ?? 'right'); + if ($photoId < 1) { + throw new RuntimeException('Некорректный photo_id'); + } + if (!in_array($kind, ['before', 'after'], true)) { + throw new RuntimeException('Некорректный тип файла'); + } + + $photo = photoById($photoId); + if (!$photo) { + throw new RuntimeException('Фото не найдено'); + } + + $pathKey = $kind === 'before' ? 'before_path' : 'after_path'; + $relPath = (string)($photo[$pathKey] ?? ''); + if ($relPath === '') { + throw new RuntimeException('Файл отсутствует'); + } + + $absPath = $projectRoot . '/' . ltrim($relPath, '/'); + if (!is_file($absPath)) { + throw new RuntimeException('Файл не найден на диске'); + } + + $degrees = $direction === 'left' ? -90 : 90; + rotateImageOnDisk($absPath, $degrees); + ensureThumbForSource($projectRoot, $relPath); + + $st = db()->prepare('UPDATE photo_files SET updated_at=CURRENT_TIMESTAMP WHERE photo_id=:pid AND kind=:kind'); + $st->execute(['pid' => $photoId, 'kind' => $kind]); + + $message = 'Изображение повернуто'; + if ($isAjax) { + adminJsonResponse(['ok' => true, 'message' => $message, 'photo_id' => $photoId, 'kind' => $kind]); + } + break; + } + + case 'create_commenter': { + $displayName = trim((string)($_POST['display_name'] ?? '')); + if ($displayName === '') { + throw new RuntimeException('Укажи имя комментатора'); + } + $u = commenterCreate($displayName); + $link = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http') . '://' . ($_SERVER['HTTP_HOST'] ?? 'localhost') . '/?viewer=' . urlencode($u['token']); + $message = 'Комментатор создан: ' . $u['display_name'] . ' | ссылка: ' . $link; + break; + } + + case 'delete_commenter': { + $id = (int)($_POST['id'] ?? 0); + if ($id > 0) { + commenterDelete($id); + $message = 'Комментатор удалён (доступ отозван)'; + } + break; + } + + case 'regenerate_commenter_token': { + $id = (int)($_POST['id'] ?? 0); + if ($id > 0) { + $token = commenterRegenerateToken($id); + $link = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http') . '://' . ($_SERVER['HTTP_HOST'] ?? 'localhost') . '/?viewer=' . urlencode($token); + $message = 'Токен обновлён | ссылка: ' . $link; + } + break; + } + + case 'delete_comment': { + $id = (int)($_POST['id'] ?? 0); + if ($id > 0) { + commentDelete($id); + $message = 'Комментарий удалён'; + if ($isAjax) { + adminJsonResponse(['ok' => true, 'message' => $message]); + } + } + break; + } + } + + return ['message' => $message, 'errors' => $errors]; +}