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++; } }