diff --git a/README.md b/README.md index 35e632d..e7a8d6f 100644 --- a/README.md +++ b/README.md @@ -104,7 +104,11 @@ https://<домен>/admin.php?token=<твой_секрет> В админке можно: - создавать папки-категории, -- загружать фото в выбранную папку. +- переименовывать/удалять категории, +- задавать порядок (индекс сортировки) категорий, +- загружать фото в выбранную папку, +- переименовывать/удалять фото, +- задавать порядок (индекс сортировки) фото внутри категории. Ограничения загрузки: - только изображения: JPG/PNG/WEBP/GIF, diff --git a/admin.php b/admin.php index ac90297..f1b2fc7 100644 --- a/admin.php +++ b/admin.php @@ -2,33 +2,35 @@ declare(strict_types=1); -const MAX_UPLOAD_BYTES = 3 * 1024 * 1024; // 3 MB +const MAX_UPLOAD_BYTES = 3 * 1024 * 1024; + +$rootDir = __DIR__; +$photosDir = $rootDir . '/photos'; +$thumbsDir = $rootDir . '/thumbs'; +$dataDir = $rootDir . '/data'; +$sortFile = $dataDir . '/sort.json'; + +@mkdir($photosDir, 0775, true); +@mkdir($thumbsDir, 0775, true); +@mkdir($dataDir, 0775, true); $configPath = __DIR__ . '/deploy-config.php'; if (!is_file($configPath)) { http_response_code(500); - header('Content-Type: text/plain; charset=utf-8'); - echo "deploy-config.php not found. Create it from deploy-config.php.example\n"; + echo 'deploy-config.php not found'; exit; } - -/** @var array $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"; + echo 'Forbidden'; exit; } -$photosDir = __DIR__ . '/photos'; -if (!is_dir($photosDir)) { - mkdir($photosDir, 0775, true); -} - +$sortData = loadSortData($sortFile); $message = ''; $errors = []; @@ -36,273 +38,277 @@ 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[] = 'Введите корректное имя папки.'; + $name = sanitizeCategoryName((string)($_POST['category_name'] ?? '')); + if ($name === '') { + $errors[] = 'Некорректное имя папки.'; } else { - $dir = $photosDir . '/' . $safeName; - if (is_dir($dir)) { - $message = 'Папка уже существует.'; - } elseif (mkdir($dir, 0775, true)) { - $message = 'Папка создана: ' . $safeName; - } else { + $dir = $photosDir . '/' . $name; + if (!is_dir($dir) && !mkdir($dir, 0775, true)) { $errors[] = 'Не удалось создать папку.'; + } else { + $message = 'Папка создана: ' . $name; + $sortData['categories'][$name] = nextSortIndex($sortData['categories']); } } } + if ($action === 'category_update') { + $current = sanitizeCategoryName((string)($_POST['category_current'] ?? '')); + $newName = sanitizeCategoryName((string)($_POST['category_new_name'] ?? '')); + $sortIndex = (int)($_POST['category_sort'] ?? 1000); + + if ($current === '' || !is_dir($photosDir . '/' . $current)) { + $errors[] = 'Категория не найдена.'; + } else { + if ($newName !== '' && $newName !== $current) { + $oldDir = $photosDir . '/' . $current; + $newDir = $photosDir . '/' . $newName; + $oldThumb = $thumbsDir . '/' . $current; + $newThumb = $thumbsDir . '/' . $newName; + + if (is_dir($newDir)) { + $errors[] = 'Категория с таким именем уже существует.'; + } else { + rename($oldDir, $newDir); + if (is_dir($oldThumb)) { + @rename($oldThumb, $newThumb); + } + + if (isset($sortData['categories'][$current])) { + $sortData['categories'][$newName] = $sortData['categories'][$current]; + unset($sortData['categories'][$current]); + } + if (isset($sortData['photos'][$current])) { + $sortData['photos'][$newName] = $sortData['photos'][$current]; + unset($sortData['photos'][$current]); + } + $current = $newName; + $message = 'Категория переименована.'; + } + } + + $sortData['categories'][$current] = $sortIndex; + $message = $message ?: 'Категория обновлена.'; + } + } + + if ($action === 'category_delete') { + $category = sanitizeCategoryName((string)($_POST['category_current'] ?? '')); + if ($category === '' || !is_dir($photosDir . '/' . $category)) { + $errors[] = 'Категория не найдена.'; + } else { + rrmdir($photosDir . '/' . $category); + rrmdir($thumbsDir . '/' . $category); + unset($sortData['categories'][$category], $sortData['photos'][$category]); + $message = 'Категория удалена: ' . $category; + } + } + if ($action === 'upload') { - $selectedCategory = sanitizeCategoryName((string)($_POST['category'] ?? '')); - if ($selectedCategory === '') { - $errors[] = 'Выберите папку для загрузки.'; + $category = sanitizeCategoryName((string)($_POST['category'] ?? '')); + if ($category === '' || !is_dir($photosDir . '/' . $category)) { + $errors[] = 'Выберите существующую категорию.'; + } elseif (!isset($_FILES['photos'])) { + $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']; - } - } + $result = handleUploads($_FILES['photos'], $photosDir . '/' . $category, $sortData, $category); + $errors = array_merge($errors, $result['errors']); + if ($result['ok'] > 0) { + $message = 'Загружено: ' . $result['ok']; } } } + + if ($action === 'photo_update') { + $category = sanitizeCategoryName((string)($_POST['category'] ?? '')); + $currentFile = basename((string)($_POST['photo_current'] ?? '')); + $newBase = sanitizeFileBase((string)($_POST['photo_new_name'] ?? '')); + $sortIndex = (int)($_POST['photo_sort'] ?? 1000); + + $src = $photosDir . '/' . $category . '/' . $currentFile; + if ($category === '' || $currentFile === '' || !is_file($src)) { + $errors[] = 'Фото не найдено.'; + } else { + $finalName = $currentFile; + if ($newBase !== '') { + $ext = strtolower(pathinfo($currentFile, PATHINFO_EXTENSION)); + $candidate = uniqueFileNameForRename($photosDir . '/' . $category, $newBase, $ext, $currentFile); + if ($candidate !== $currentFile) { + $dst = $photosDir . '/' . $category . '/' . $candidate; + if (@rename($src, $dst)) { + $oldThumb = $thumbsDir . '/' . $category . '/' . pathinfo($currentFile, PATHINFO_FILENAME) . '.jpg'; + $newThumb = $thumbsDir . '/' . $category . '/' . pathinfo($candidate, PATHINFO_FILENAME) . '.jpg'; + if (is_file($oldThumb)) { + @rename($oldThumb, $newThumb); + } + if (isset($sortData['photos'][$category][$currentFile])) { + $sortData['photos'][$category][$candidate] = $sortData['photos'][$category][$currentFile]; + unset($sortData['photos'][$category][$currentFile]); + } + $finalName = $candidate; + } + } + } + + $sortData['photos'][$category][$finalName] = $sortIndex; + $message = 'Фото обновлено.'; + } + } + + if ($action === 'photo_delete') { + $category = sanitizeCategoryName((string)($_POST['category'] ?? '')); + $file = basename((string)($_POST['photo_current'] ?? '')); + $src = $photosDir . '/' . $category . '/' . $file; + if ($category === '' || $file === '' || !is_file($src)) { + $errors[] = 'Фото не найдено.'; + } else { + @unlink($src); + $thumb = $thumbsDir . '/' . $category . '/' . pathinfo($file, PATHINFO_FILENAME) . '.jpg'; + if (is_file($thumb)) { + @unlink($thumb); + } + unset($sortData['photos'][$category][$file]); + $message = 'Фото удалено.'; + } + } + + saveSortData($sortFile, $sortData); } -$categories = listCategories($photosDir); -$tokenForUrl = urlencode($tokenIncoming); +$categories = listCategories($photosDir, $sortData); +$selectedCategory = sanitizeCategoryName((string)($_GET['edit_category'] ?? ($_POST['category'] ?? ''))); +if ($selectedCategory === '' && $categories !== []) { + $selectedCategory = $categories[0]; +} +$photos = $selectedCategory !== '' ? listPhotos($photosDir, $selectedCategory, $sortData) : []; ?> - - - - - Админка галереи - - - - -
-

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

- + + +Админка галереи + + +
+

Админка галереи

+

← В галерею

+
+
- -
+
+
+

Создать папку

+
+ + +

+
+
+ +
+

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

+
+ + +

+ +

Только JPG/PNG/WEBP/GIF, максимум 3 МБ на файл.

+
+
+ +
+

Категории (редактирование / сортировка / удаление)

+ + + + + + + + + +
КатегорияПорядокНовое имяДействия
+
+ + + +
+ + +
+ + +
+
+
+ +
+

Фото в категории:

+ +

Нет категорий.

+ +

В категории пока нет фото.

+ + + + + + + + + + +
ФотоПорядокНовое имя (без расширения)Действия
+
+ + + +
+ + +
+ + + +
+
- - -
- - -
-
-

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

-
- - -
- - -
- -
-

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

-
- -
-

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

-
- - - -
- - -
- -
- - -
- - -
-

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

-
- -
-

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

- -

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

- -
    - -
  • - -
- -
-
-
- - + +
[],'photos'=>[]]; $d=json_decode((string)file_get_contents($file),true); return is_array($d)?['categories'=>(array)($d['categories']??[]),'photos'=>(array)($d['photos']??[])]:['categories'=>[],'photos'=>[]]; } +function saveSortData(string $file, array $data): void { file_put_contents($file, json_encode($data, JSON_UNESCAPED_UNICODE|JSON_PRETTY_PRINT)); } +function nextSortIndex(array $map): int { return $map===[]?10:((int)max(array_map('intval',$map))+10); } +function listCategories(string $photosDir, array $sortData): array { $out=[]; foreach((@scandir($photosDir)?:[]) as $x){ if($x==='.'||$x==='..')continue; if(is_dir($photosDir.'/'.$x))$out[]=$x; } usort($out, fn($a,$b)=>((int)($sortData['categories'][$a]??1000)<=> (int)($sortData['categories'][$b]??1000)) ?: strnatcasecmp($a,$b)); return $out; } +function listPhotos(string $photosDir, string $category, array $sortData): array { $out=[]; $dir=$photosDir.'/'.$category; foreach((@scandir($dir)?:[]) as $f){ if($f==='.'||$f==='..')continue; $p=$dir.'/'.$f; if(is_file($p)&&isImageExt($f)) $out[]=['file'=>$f,'sort'=>(int)($sortData['photos'][$category][$f]??1000)]; } usort($out, fn($a,$b)=>($a['sort']<=>$b['sort']) ?: strnatcasecmp($a['file'],$b['file'])); return $out; } +function isImageExt(string $file): bool { return in_array(strtolower(pathinfo($file, PATHINFO_EXTENSION)), ['jpg','jpeg','png','webp','gif'], true); } +function rrmdir(string $dir): void { if(!is_dir($dir)) return; $it=scandir($dir)?:[]; foreach($it as $x){ if($x==='.'||$x==='..')continue; $p=$dir.'/'.$x; if(is_dir($p)) rrmdir($p); else @unlink($p);} @rmdir($dir); } +function uniqueFileNameForRename(string $dir,string $base,string $ext,string $current): string{ $n=0; do{ $cand=$n===0?"{$base}.{$ext}":"{$base}_{$n}.{$ext}"; if($cand===$current||!file_exists($dir.'/'.$cand)) return $cand; $n++; }while(true); } +function handleUploads(array $files, string $targetDir, array &$sortData, string $category): array { + $allowedMime=['image/jpeg','image/png','image/webp','image/gif']; $allowedExt=['jpg','jpeg','png','webp','gif']; $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]; } + $finfo=finfo_open(FILEINFO_MIME_TYPE); + foreach($names as $i=>$orig){ if((int)($errs[$i]??UPLOAD_ERR_NO_FILE)!==UPLOAD_ERR_OK){$errors[]="{$orig}: ошибка загрузки";continue;} + $size=(int)($sizes[$i]??0); if($size<1||$size>MAX_UPLOAD_BYTES){$errors[]="{$orig}: >3MB";continue;} + $tmpFile=(string)($tmp[$i]??''); if($tmpFile===''||!is_uploaded_file($tmpFile)){ $errors[]="{$orig}: источник"; continue;} + $mime=$finfo?(string)finfo_file($finfo,$tmpFile):''; if(!in_array($mime,$allowedMime,true)){ $errors[]="{$orig}: тип {$mime}"; continue;} + $ext=strtolower(pathinfo((string)$orig, PATHINFO_EXTENSION)); if(!in_array($ext,$allowedExt,true)){ $errors[]="{$orig}: расширение"; continue;} + $base=sanitizeFileBase(pathinfo((string)$orig, PATHINFO_FILENAME)); if($base==='')$base='photo'; $name=uniqueFileNameForRename($targetDir,$base,$ext,''); + if(!move_uploaded_file($tmpFile,$targetDir.'/'.$name)){ $errors[]="{$orig}: не сохранить"; continue; } + $sortData['photos'][$category][$name]=nextSortIndex((array)($sortData['photos'][$category]??[])); $ok++; } - - return $name; -} - -/** - * @param array $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; + if($finfo)finfo_close($finfo); + return ['ok'=>$ok,'errors'=>$errors]; } diff --git a/index.php b/index.php index a560b05..dfd27ed 100644 --- a/index.php +++ b/index.php @@ -10,8 +10,10 @@ $photosDir = $baseDir . '/photos'; $thumbsDir = $baseDir . '/thumbs'; $dataDir = $baseDir . '/data'; $lastIndexedFile = $dataDir . '/last_indexed.txt'; +$sortFile = $dataDir . '/sort.json'; ensureDirectories([$photosDir, $thumbsDir, $dataDir]); +$sortData = loadSortData($sortFile); $action = $_GET['action'] ?? null; if ($action === 'image') { @@ -21,7 +23,7 @@ if ($action === 'image') { $lastIndexedTimestamp = readLastIndexedTimestamp($lastIndexedFile); $maxTimestamp = $lastIndexedTimestamp; -$categories = scanCategories($photosDir); +$categories = scanCategories($photosDir, $sortData); foreach ($categories as $categoryName => &$images) { $categoryThumbDir = $thumbsDir . '/' . $categoryName; @@ -54,7 +56,12 @@ foreach ($categories as $categoryName => &$images) { } usort($images, static function (array $a, array $b): int { - return $b['mtime'] <=> $a['mtime']; + $bySort = ($a['sort_index'] ?? 0) <=> ($b['sort_index'] ?? 0); + if ($bySort !== 0) { + return $bySort; + } + + return ($b['mtime'] ?? 0) <=> ($a['mtime'] ?? 0); }); } unset($images, $image); @@ -215,9 +222,11 @@ function readLastIndexedTimestamp(string $path): int return ctype_digit($value) ? (int)$value : 0; } -function scanCategories(string $photosDir): array +function scanCategories(string $photosDir, array $sortData): array { $result = []; + $categorySortMap = (array)($sortData['categories'] ?? []); + $photoSortMap = (array)($sortData['photos'] ?? []); $entries = @scandir($photosDir) ?: []; foreach ($entries as $entry) { @@ -245,16 +254,49 @@ function scanCategories(string $photosDir): array $images[] = [ 'filename' => $filename, 'abs_path' => $absPath, + 'sort_index' => (int)(($photoSortMap[$entry][$filename] ?? 1000)), ]; } $result[$entry] = $images; } - ksort($result, SORT_NATURAL | SORT_FLAG_CASE); + uksort($result, static function (string $a, string $b) use ($categorySortMap): int { + $aSort = (int)($categorySortMap[$a] ?? 1000); + $bSort = (int)($categorySortMap[$b] ?? 1000); + + if ($aSort !== $bSort) { + return $aSort <=> $bSort; + } + + return strnatcasecmp($a, $b); + }); + return $result; } +function loadSortData(string $sortFile): array +{ + if (!is_file($sortFile)) { + return ['categories' => [], 'photos' => []]; + } + + $json = file_get_contents($sortFile); + if ($json === false || trim($json) === '') { + return ['categories' => [], 'photos' => []]; + } + + $data = json_decode($json, true); + if (!is_array($data)) { + return ['categories' => [], 'photos' => []]; + } + + return [ + 'categories' => is_array($data['categories'] ?? null) ? $data['categories'] : [], + 'photos' => is_array($data['photos'] ?? null) ? $data['photos'] : [], + ]; +} + function isImage(string $path): bool { $ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));