From 0a8d0ade23ab85aaaef76e6db834c938efdb0249 Mon Sep 17 00:00:00 2001 From: Alex Assistant Date: Thu, 19 Feb 2026 17:08:27 +0300 Subject: [PATCH] Add token-protected admin panel for categories and photo uploads --- README.md | 20 ++++ admin.php | 308 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 328 insertions(+) create mode 100644 admin.php diff --git a/README.md b/README.md index 449e973..35e632d 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ photo-gallery/ ├─ style.css # стили (material-like, строгий) ├─ app.js # лайтбокс ├─ deploy.php # webhook-триггер деплоя +├─ admin.php # закрытая админка (папки + загрузка фото) ├─ deploy-config.php.example # пример конфига webhook ├─ photos/ # исходные фото по категориям (папкам) ├─ thumbs/ # автогенерируемые превью @@ -91,6 +92,25 @@ bash scripts/deploy.sh BRANCH=master bash scripts/deploy.sh ``` +## Админка загрузки (по токену) + +Админка использует тот же `token`, что и `deploy.php`, из файла `deploy-config.php`. + +Ссылка входа: + +```text +https://<домен>/admin.php?token=<твой_секрет> +``` + +В админке можно: +- создавать папки-категории, +- загружать фото в выбранную папку. + +Ограничения загрузки: +- только изображения: JPG/PNG/WEBP/GIF, +- максимум 3 МБ на файл, +- MIME-тип и расширение проверяются на сервере. + ## Удалённый запуск деплоя по ссылке (webhook) 1. На хостинге создай конфиг из примера: diff --git a/admin.php b/admin.php new file mode 100644 index 0000000..ac90297 --- /dev/null +++ b/admin.php @@ -0,0 +1,308 @@ + $config */ +$config = require $configPath; +$tokenExpected = (string)($config['token'] ?? ''); + +$tokenIncoming = (string)($_REQUEST['token'] ?? ''); +if ($tokenExpected === '' || !hash_equals($tokenExpected, $tokenIncoming)) { + http_response_code(403); + header('Content-Type: text/plain; charset=utf-8'); + echo "Forbidden: invalid token\n"; + exit; +} + +$photosDir = __DIR__ . '/photos'; +if (!is_dir($photosDir)) { + mkdir($photosDir, 0775, true); +} + +$message = ''; +$errors = []; + +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $action = (string)($_POST['action'] ?? ''); + + if ($action === 'create_category') { + $rawName = trim((string)($_POST['category_name'] ?? '')); + $safeName = sanitizeCategoryName($rawName); + + if ($safeName === '') { + $errors[] = 'Введите корректное имя папки.'; + } else { + $dir = $photosDir . '/' . $safeName; + if (is_dir($dir)) { + $message = 'Папка уже существует.'; + } elseif (mkdir($dir, 0775, true)) { + $message = 'Папка создана: ' . $safeName; + } else { + $errors[] = 'Не удалось создать папку.'; + } + } + } + + if ($action === 'upload') { + $selectedCategory = sanitizeCategoryName((string)($_POST['category'] ?? '')); + if ($selectedCategory === '') { + $errors[] = 'Выберите папку для загрузки.'; + } else { + $categoryDir = $photosDir . '/' . $selectedCategory; + if (!is_dir($categoryDir)) { + $errors[] = 'Выбранная папка не существует.'; + } else { + if (!isset($_FILES['photos'])) { + $errors[] = 'Файлы не переданы.'; + } else { + $result = handleUploads($_FILES['photos'], $categoryDir); + $errors = array_merge($errors, $result['errors']); + if ($result['ok'] > 0) { + $message = 'Загружено файлов: ' . $result['ok']; + } + } + } + } + } +} + +$categories = listCategories($photosDir); +$tokenForUrl = urlencode($tokenIncoming); + +?> + + + + + Админка галереи + + + + +
+

Админка загрузки

+ + + +
+ + + +
+ + +
+
+

Создать папку (категорию)

+
+ + +
+ + +
+ +
+

Разрешены буквы/цифры/пробел/._- (остальное отфильтруется).

+
+ +
+

Загрузка фотографий

+
+ + + +
+ + +
+ +
+ + +
+ + +
+

Ограничения: только JPG/PNG/WEBP/GIF, максимум 3 МБ на файл.

+
+ +
+

Текущие категории

+ +

Пока нет категорий.

+ +
    + +
  • + +
+ +
+
+
+ + + $files + * @return array{ok:int,errors:string[]} + */ +function handleUploads(array $files, string $targetDir): array +{ + $allowedMime = [ + 'image/jpeg', + 'image/png', + 'image/webp', + 'image/gif', + ]; + $allowedExt = ['jpg', 'jpeg', 'png', 'webp', 'gif']; + + $ok = 0; + $errors = []; + + $names = $files['name'] ?? []; + $tmpNames = $files['tmp_name'] ?? []; + $sizes = $files['size'] ?? []; + $errs = $files['error'] ?? []; + + if (!is_array($names)) { + $names = [$names]; + $tmpNames = [$tmpNames]; + $sizes = [$sizes]; + $errs = [$errs]; + } + + $finfo = finfo_open(FILEINFO_MIME_TYPE); + + foreach ($names as $i => $originalName) { + $errCode = (int)($errs[$i] ?? UPLOAD_ERR_NO_FILE); + if ($errCode !== UPLOAD_ERR_OK) { + $errors[] = "Файл {$originalName}: ошибка загрузки ({$errCode})."; + continue; + } + + $size = (int)($sizes[$i] ?? 0); + if ($size < 1 || $size > MAX_UPLOAD_BYTES) { + $errors[] = "Файл {$originalName}: превышен лимит 3 МБ."; + continue; + } + + $tmp = (string)($tmpNames[$i] ?? ''); + if ($tmp === '' || !is_uploaded_file($tmp)) { + $errors[] = "Файл {$originalName}: некорректный источник загрузки."; + continue; + } + + $mime = $finfo ? (string)finfo_file($finfo, $tmp) : ''; + if (!in_array($mime, $allowedMime, true)) { + $errors[] = "Файл {$originalName}: недопустимый тип ({$mime})."; + continue; + } + + $ext = strtolower(pathinfo((string)$originalName, PATHINFO_EXTENSION)); + if (!in_array($ext, $allowedExt, true)) { + $errors[] = "Файл {$originalName}: недопустимое расширение."; + continue; + } + + $base = pathinfo((string)$originalName, PATHINFO_FILENAME); + $safeBase = preg_replace('/[^\p{L}\p{N}._-]+/u', '_', $base) ?? 'photo'; + $safeBase = trim($safeBase, '._-'); + if ($safeBase === '') { + $safeBase = 'photo'; + } + + $finalName = uniqueFileName($targetDir, $safeBase, $ext); + $dest = $targetDir . '/' . $finalName; + + if (!move_uploaded_file($tmp, $dest)) { + $errors[] = "Файл {$originalName}: не удалось сохранить."; + continue; + } + + @chmod($dest, 0664); + $ok++; + } + + if ($finfo) { + finfo_close($finfo); + } + + return ['ok' => $ok, 'errors' => $errors]; +} + +function uniqueFileName(string $dir, string $base, string $ext): string +{ + $candidate = $base . '.' . $ext; + $n = 1; + while (file_exists($dir . '/' . $candidate)) { + $candidate = $base . '_' . $n . '.' . $ext; + $n++; + } + return $candidate; +} + +/** + * @return string[] + */ +function listCategories(string $photosDir): array +{ + $out = []; + $items = @scandir($photosDir) ?: []; + foreach ($items as $item) { + if ($item === '.' || $item === '..') { + continue; + } + if (is_dir($photosDir . '/' . $item)) { + $out[] = $item; + } + } + sort($out, SORT_NATURAL | SORT_FLAG_CASE); + return $out; +}