From 8a0b5aad55e691ba439f4b30f686c7880b051ff6 Mon Sep 17 00:00:00 2001 From: Alex Assistant Date: Fri, 20 Feb 2026 14:20:01 +0300 Subject: [PATCH] Add MySQL public gallery, token commenters and watermark for restored photos --- README.md | 13 ++- admin-mysql.php | 88 ++++++++++++++++--- index-mysql.php | 211 +++++++++++++++++++++++++++++++++++++++++++++ lib/db_gallery.php | 101 +++++++++++++++++++++- 4 files changed, 395 insertions(+), 18 deletions(-) create mode 100644 index-mysql.php diff --git a/README.md b/README.md index a2aa9dd..a0955fb 100644 --- a/README.md +++ b/README.md @@ -118,15 +118,20 @@ BRANCH=master bash scripts/deploy.sh ## Админка загрузки (по токену) -Новый контур на MySQL (этап 1): +Новый контур на MySQL: -- `admin-mysql.php?token=...` +- `admin-mysql.php?token=...` — админка +- `index-mysql.php` — публичная витрина + комментарии -Что уже есть в MySQL-админке: +Что уже есть в MySQL-контуре: - создание разделов, - загрузка фото "до" + опционально "после", - запись в таблицы `sections`, `photos`, `photo_files`, -- просмотр разделов и загруженных фото. +- просмотр разделов и загруженных фото, +- персональные комментаторы (генерация ссылок), +- плоские комментарии к фото, +- удаление комментариев админом, +- watermark на выдаче версии "после". Админка использует тот же `token`, что и `deploy.php`, из файла `deploy-config.php`. diff --git a/admin-mysql.php b/admin-mysql.php index 0f10800..6620e55 100644 --- a/admin-mysql.php +++ b/admin-mysql.php @@ -35,9 +35,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { if ($action === 'create_section') { $name = trim((string)($_POST['name'] ?? '')); $sort = (int)($_POST['sort_order'] ?? 1000); - if ($name === '') { - throw new RuntimeException('Название раздела пустое'); - } + if ($name === '') throw new RuntimeException('Название раздела пустое'); sectionCreate($name, $sort); $message = 'Раздел создан'; } @@ -52,9 +50,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { if ($sectionId < 1) throw new RuntimeException('Выбери раздел'); if ($codeName === '') throw new RuntimeException('Укажи код фото (например АВФ1)'); if (!isset($_FILES['before'])) throw new RuntimeException('Файл "до" обязателен'); - - $section = sectionById($sectionId); - if (!$section) throw new RuntimeException('Раздел не найден'); + if (!sectionById($sectionId)) throw new RuntimeException('Раздел не найден'); $photoId = photoCreate($sectionId, $codeName, $description, $sortOrder); @@ -68,6 +64,30 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { $message = 'Фото добавлено'; } + + 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') . '/index-mysql.php?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 === 'delete_comment') { + $id = (int)($_POST['id'] ?? 0); + if ($id > 0) { + commentDelete($id); + $message = 'Комментарий удалён'; + } + } } catch (Throwable $e) { $errors[] = $e->getMessage(); } @@ -76,6 +96,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { $sections = sectionsAll(); $activeSectionId = (int)($_GET['section_id'] ?? ($sections[0]['id'] ?? 0)); $photos = $activeSectionId > 0 ? photosBySection($activeSectionId) : []; +$commenters = commentersAll(); +$latestComments = commentsLatest(80); 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); } @@ -128,12 +150,13 @@ function uniqueName(string $dir, string $base, string $ext): string Админка (MySQL) + - +
-

Админка (MySQL, этап 1)

-

← в галерею

+

Админка (MySQL)

+

Открыть публичную MySQL-галерею

@@ -162,6 +185,31 @@ function uniqueName(string $dir, string $base, string $ext): string +
+

Комментаторы (персональные ссылки)

+
+ + + +
+ + + + + + + + + +
IDИмяСтатусДействие
+
+ + +
+
+

После создания ссылки токен показывается один раз в зелёном сообщении.

+
+

Разделы

@@ -178,12 +226,32 @@ function uniqueName(string $dir, string $base, string $ext): string - +
IDНазваниеПорядокФото
+ +
+

Последние комментарии

+ + + + + + + + + + +
IDФотоПользовательКомментарий
+
+ + +
+
+
diff --git a/index-mysql.php b/index-mysql.php new file mode 100644 index 0000000..006b9f5 --- /dev/null +++ b/index-mysql.php @@ -0,0 +1,211 @@ + 0 && $text !== '') { + $u = commenterByToken($token); + if ($u) { + commentAdd($photoId, (int)$u['id'], mb_substr($text, 0, 1000)); + } + } + + $redirect = './index-mysql.php?photo_id=' . $photoId; + if ($token !== '') { + $redirect .= '&viewer=' . urlencode($token); + } + header('Location: ' . $redirect); + exit; +} + +$sections = sectionsAll(); +$activeSectionId = (int)($_GET['section_id'] ?? 0); +$activePhotoId = (int)($_GET['photo_id'] ?? 0); + +if ($activePhotoId > 0) { + $photo = photoById($activePhotoId); + if (!$photo) { + http_response_code(404); + $photo = null; + } + $comments = $photo ? commentsByPhoto($activePhotoId) : []; +} else { + $photo = null; + $comments = []; +} + +$photos = $activeSectionId > 0 ? photosBySection($activeSectionId) : []; + +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 serveImage(): never +{ + $fileId = (int)($_GET['file_id'] ?? 0); + if ($fileId < 1) { + http_response_code(404); + exit; + } + + $f = photoFileById($fileId); + if (!$f) { + http_response_code(404); + exit; + } + + $abs = __DIR__ . '/' . ltrim((string)$f['file_path'], '/'); + if (!is_file($abs)) { + http_response_code(404); + exit; + } + + $kind = (string)$f['kind']; + if ($kind !== 'after') { + header('Content-Type: ' . ((string)$f['mime_type'] ?: 'application/octet-stream')); + header('Content-Length: ' . (string)filesize($abs)); + header('Cache-Control: private, max-age=60'); + header('X-Robots-Tag: noindex, nofollow'); + readfile($abs); + exit; + } + + outputWatermarked($abs, (string)$f['mime_type']); +} + +function outputWatermarked(string $path, string $mime): never +{ + $text = 'photo.andr33v.ru'; + + if (extension_loaded('imagick')) { + $im = new Imagick($path); + $draw = new ImagickDraw(); + $draw->setFillColor(new ImagickPixel('rgba(255,255,255,0.22)')); + $draw->setFontSize(max(18, (int)($im->getImageWidth() / 24))); + $draw->setGravity(Imagick::GRAVITY_SOUTHEAST); + $im->annotateImage($draw, 20, 24, -15, $text); + header('Content-Type: ' . ($mime !== '' ? $mime : 'image/jpeg')); + $im->setImageCompressionQuality(88); + echo $im; + $im->clear(); + $im->destroy(); + exit; + } + + [$w, $h, $type] = @getimagesize($path) ?: [0,0,0]; + if ($w < 1 || $h < 1) { + readfile($path); + exit; + } + + $img = match ($type) { + IMAGETYPE_JPEG => imagecreatefromjpeg($path), + IMAGETYPE_PNG => imagecreatefrompng($path), + IMAGETYPE_GIF => imagecreatefromgif($path), + IMAGETYPE_WEBP => function_exists('imagecreatefromwebp') ? imagecreatefromwebp($path) : null, + default => null, + }; + + if (!$img) { + readfile($path); + exit; + } + + $font = 5; + $color = imagecolorallocatealpha($img, 255, 255, 255, 90); + $x = max(5, $w - (imagefontwidth($font) * strlen($text)) - 15); + $y = max(5, $h - imagefontheight($font) - 12); + imagestring($img, $font, $x, $y, $text, $color); + + header('Content-Type: image/jpeg'); + imagejpeg($img, null, 88); + imagedestroy($img); + exit; +} +?> + + + + + Фотогалерея (MySQL) + + + + + +
+

Фотогалерея

Простая галерея, которая управляется через файловый менеджер.

+
+ +
+ 0 && $photo): ?> +
+

← к разделу

+

+

+
+
До обработки
+
После обработки (watermark)
+
+ +

Комментарии

+ +
+ + + + +

+
+ +

Комментарии может оставлять только пользователь с персональной ссылкой.

+ + + +
·
+ +
+ +
+

Фотографии

+ +

Выберите раздел слева.

+ +

В разделе пока нет фотографий.

+ +
+ + + +

+
+ +
+ +
+ +
+
+
+ + diff --git a/lib/db_gallery.php b/lib/db_gallery.php index 53dfd57..f47a21e 100644 --- a/lib/db_gallery.php +++ b/lib/db_gallery.php @@ -6,15 +6,17 @@ require_once __DIR__ . '/db.php'; function sectionsAll(): array { - return db()->query('SELECT s.*, (SELECT COUNT(*) FROM photos p WHERE p.section_id=s.id) AS photos_count FROM sections s ORDER BY s.sort_order, s.name')->fetchAll(); + $sql = 'SELECT s.*, (SELECT COUNT(*) FROM photos p WHERE p.section_id=s.id) AS photos_count + FROM sections s + ORDER BY s.sort_order, s.name'; + return db()->query($sql)->fetchAll(); } function sectionById(int $id): ?array { $st = db()->prepare('SELECT * FROM sections WHERE id=:id'); $st->execute(['id' => $id]); - $row = $st->fetch(); - return $row ?: null; + return $st->fetch() ?: null; } function sectionCreate(string $name, int $sort): void @@ -25,7 +27,9 @@ function sectionCreate(string $name, int $sort): void function photosBySection(int $sectionId): array { - $sql = 'SELECT p.*, bf.file_path AS before_path, af.file_path AS after_path + $sql = 'SELECT p.*, + bf.id AS before_file_id, bf.file_path AS before_path, + af.id AS after_file_id, af.file_path AS after_path FROM photos p LEFT JOIN photo_files bf ON bf.photo_id=p.id AND bf.kind="before" LEFT JOIN photo_files af ON af.photo_id=p.id AND af.kind="after" @@ -36,6 +40,20 @@ function photosBySection(int $sectionId): array return $st->fetchAll(); } +function photoById(int $photoId): ?array +{ + $sql = 'SELECT p.*, + bf.id AS before_file_id, bf.file_path AS before_path, + af.id AS after_file_id, af.file_path AS after_path + FROM photos p + LEFT JOIN photo_files bf ON bf.photo_id=p.id AND bf.kind="before" + LEFT JOIN photo_files af ON af.photo_id=p.id AND af.kind="after" + WHERE p.id=:id'; + $st = db()->prepare($sql); + $st->execute(['id' => $photoId]); + return $st->fetch() ?: null; +} + function photoCreate(int $sectionId, string $codeName, ?string $description, int $sortOrder): int { $st = db()->prepare('INSERT INTO photos(section_id, code_name, description, sort_order) VALUES (:sid,:code,:descr,:sort)'); @@ -62,3 +80,78 @@ function photoFileUpsert(int $photoId, string $kind, string $path, string $mime, 'size' => $size, ]); } + +function photoFileById(int $fileId): ?array +{ + $st = db()->prepare('SELECT * FROM photo_files WHERE id=:id'); + $st->execute(['id' => $fileId]); + return $st->fetch() ?: null; +} + +function commenterByToken(string $token): ?array +{ + $hash = hash('sha256', $token); + $st = db()->prepare('SELECT * FROM comment_users WHERE token_hash=:h AND is_active=1'); + $st->execute(['h' => $hash]); + return $st->fetch() ?: null; +} + +function commenterCreate(string $displayName): array +{ + $token = bin2hex(random_bytes(16)); + $hash = hash('sha256', $token); + $st = db()->prepare('INSERT INTO comment_users(display_name, token_hash, is_active) VALUES (:n,:h,1)'); + $st->execute(['n' => $displayName, 'h' => $hash]); + + return [ + 'id' => (int)db()->lastInsertId(), + 'display_name' => $displayName, + 'token' => $token, + ]; +} + +function commentersAll(): array +{ + return db()->query('SELECT * FROM comment_users ORDER BY id DESC')->fetchAll(); +} + +function commenterDelete(int $id): void +{ + $st = db()->prepare('DELETE FROM comment_users WHERE id=:id'); + $st->execute(['id' => $id]); +} + +function commentsByPhoto(int $photoId): array +{ + $sql = 'SELECT c.*, u.display_name + FROM photo_comments c + LEFT JOIN comment_users u ON u.id=c.user_id + WHERE c.photo_id=:pid + ORDER BY c.created_at DESC, c.id DESC'; + $st = db()->prepare($sql); + $st->execute(['pid' => $photoId]); + return $st->fetchAll(); +} + +function commentAdd(int $photoId, int $userId, string $text): void +{ + $st = db()->prepare('INSERT INTO photo_comments(photo_id, user_id, comment_text) VALUES (:p,:u,:t)'); + $st->execute(['p' => $photoId, 'u' => $userId, 't' => $text]); +} + +function commentDelete(int $id): void +{ + $st = db()->prepare('DELETE FROM photo_comments WHERE id=:id'); + $st->execute(['id' => $id]); +} + +function commentsLatest(int $limit = 100): array +{ + $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 + ORDER BY c.id DESC + LIMIT ' . (int)$limit; + return db()->query($sql)->fetchAll(); +}