`. Это позволяет одинаково применять его к * Set, добавленному через SFTP или ZIP CPanel. */ final class IndpicSetStorage { /** @var array */ private const DIMENSIONS = [ '01_desk_1-source.avif' => ['width' => 1696, 'height' => 920], '01_desk_1-source.webp' => ['width' => 1696, 'height' => 920], '01_desk_1-source.jpg' => ['width' => 1696, 'height' => 920], '01_desk_2-result.avif' => ['width' => 1696, 'height' => 920], '01_desk_2-result.webp' => ['width' => 1696, 'height' => 920], '01_desk_2-result.jpg' => ['width' => 1696, 'height' => 920], '01_desk_3-thumb.avif' => ['width' => 200, 'height' => 200], '01_desk_3-thumb.webp' => ['width' => 200, 'height' => 200], '01_desk_3-thumb.jpg' => ['width' => 200, 'height' => 200], '01_mob_1-source.avif' => ['width' => 400, 'height' => 217], '01_mob_1-source.webp' => ['width' => 400, 'height' => 217], '01_mob_1-source.jpg' => ['width' => 400, 'height' => 217], '01_mob_2-result.avif' => ['width' => 400, 'height' => 217], '01_mob_2-result.webp' => ['width' => 400, 'height' => 217], '01_mob_2-result.jpg' => ['width' => 400, 'height' => 217], '01_mob_3-thumb.avif' => ['width' => 60, 'height' => 60], '01_mob_3-thumb.webp' => ['width' => 60, 'height' => 60], '01_mob_3-thumb.jpg' => ['width' => 60, 'height' => 60], '02_desk_1-source.avif' => ['width' => 1696, 'height' => 920], '02_desk_1-source.webp' => ['width' => 1696, 'height' => 920], '02_desk_1-source.jpg' => ['width' => 1696, 'height' => 920], '02_desk_2-result.avif' => ['width' => 1696, 'height' => 920], '02_desk_2-result.webp' => ['width' => 1696, 'height' => 920], '02_desk_2-result.jpg' => ['width' => 1696, 'height' => 920], '02_desk_3-thumb.avif' => ['width' => 200, 'height' => 200], '02_desk_3-thumb.webp' => ['width' => 200, 'height' => 200], '02_desk_3-thumb.jpg' => ['width' => 200, 'height' => 200], '02_mob_1-source.avif' => ['width' => 400, 'height' => 217], '02_mob_1-source.webp' => ['width' => 400, 'height' => 217], '02_mob_1-source.jpg' => ['width' => 400, 'height' => 217], '02_mob_2-result.avif' => ['width' => 400, 'height' => 217], '02_mob_2-result.webp' => ['width' => 400, 'height' => 217], '02_mob_2-result.jpg' => ['width' => 400, 'height' => 217], '02_mob_3-thumb.avif' => ['width' => 60, 'height' => 60], '02_mob_3-thumb.webp' => ['width' => 60, 'height' => 60], '02_mob_3-thumb.jpg' => ['width' => 60, 'height' => 60], '03_desk_1-source.avif' => ['width' => 1696, 'height' => 920], '03_desk_1-source.webp' => ['width' => 1696, 'height' => 920], '03_desk_1-source.jpg' => ['width' => 1696, 'height' => 920], '03_desk_2-result.avif' => ['width' => 1696, 'height' => 920], '03_desk_2-result.webp' => ['width' => 1696, 'height' => 920], '03_desk_2-result.jpg' => ['width' => 1696, 'height' => 920], '03_desk_3-thumb.avif' => ['width' => 200, 'height' => 200], '03_desk_3-thumb.webp' => ['width' => 200, 'height' => 200], '03_desk_3-thumb.jpg' => ['width' => 200, 'height' => 200], '03_mob_1-source.avif' => ['width' => 400, 'height' => 217], '03_mob_1-source.webp' => ['width' => 400, 'height' => 217], '03_mob_1-source.jpg' => ['width' => 400, 'height' => 217], '03_mob_2-result.avif' => ['width' => 400, 'height' => 217], '03_mob_2-result.webp' => ['width' => 400, 'height' => 217], '03_mob_2-result.jpg' => ['width' => 400, 'height' => 217], '03_mob_3-thumb.avif' => ['width' => 60, 'height' => 60], '03_mob_3-thumb.webp' => ['width' => 60, 'height' => 60], '03_mob_3-thumb.jpg' => ['width' => 60, 'height' => 60], '04_desk_1-source.avif' => ['width' => 1696, 'height' => 920], '04_desk_1-source.webp' => ['width' => 1696, 'height' => 920], '04_desk_1-source.jpg' => ['width' => 1696, 'height' => 920], '04_desk_2-result.avif' => ['width' => 1696, 'height' => 920], '04_desk_2-result.webp' => ['width' => 1696, 'height' => 920], '04_desk_2-result.jpg' => ['width' => 1696, 'height' => 920], '04_desk_3-thumb.avif' => ['width' => 200, 'height' => 200], '04_desk_3-thumb.webp' => ['width' => 200, 'height' => 200], '04_desk_3-thumb.jpg' => ['width' => 200, 'height' => 200], '04_mob_1-source.avif' => ['width' => 400, 'height' => 217], '04_mob_1-source.webp' => ['width' => 400, 'height' => 217], '04_mob_1-source.jpg' => ['width' => 400, 'height' => 217], '04_mob_2-result.avif' => ['width' => 400, 'height' => 217], '04_mob_2-result.webp' => ['width' => 400, 'height' => 217], '04_mob_2-result.jpg' => ['width' => 400, 'height' => 217], '04_mob_3-thumb.avif' => ['width' => 60, 'height' => 60], '04_mob_3-thumb.webp' => ['width' => 60, 'height' => 60], '04_mob_3-thumb.jpg' => ['width' => 60, 'height' => 60], '05_desk_1-source.avif' => ['width' => 1696, 'height' => 920], '05_desk_1-source.webp' => ['width' => 1696, 'height' => 920], '05_desk_1-source.jpg' => ['width' => 1696, 'height' => 920], '05_desk_2-result.avif' => ['width' => 1696, 'height' => 920], '05_desk_2-result.webp' => ['width' => 1696, 'height' => 920], '05_desk_2-result.jpg' => ['width' => 1696, 'height' => 920], '05_desk_3-thumb.avif' => ['width' => 200, 'height' => 200], '05_desk_3-thumb.webp' => ['width' => 200, 'height' => 200], '05_desk_3-thumb.jpg' => ['width' => 200, 'height' => 200], '05_mob_1-source.avif' => ['width' => 400, 'height' => 217], '05_mob_1-source.webp' => ['width' => 400, 'height' => 217], '05_mob_1-source.jpg' => ['width' => 400, 'height' => 217], '05_mob_2-result.avif' => ['width' => 400, 'height' => 217], '05_mob_2-result.webp' => ['width' => 400, 'height' => 217], '05_mob_2-result.jpg' => ['width' => 400, 'height' => 217], '05_mob_3-thumb.avif' => ['width' => 60, 'height' => 60], '05_mob_3-thumb.webp' => ['width' => 60, 'height' => 60], '05_mob_3-thumb.jpg' => ['width' => 60, 'height' => 60], ]; private readonly ?\Closure $imageInspector; public function __construct(private readonly string $root, ?callable $imageInspector = null) { $this->imageInspector = $imageInspector === null ? null : \Closure::fromCallable($imageInspector); } /** @return array */ public static function expectedDimensions(): array { return self::DIMENSIONS; } public function isComplete(int $set): bool { try { $this->validateDirectory($set); return true; } catch (RuntimeException) { return false; } } /** @throws RuntimeException если каталог не соответствует контракту Set. */ public function validateDirectory(int $set): void { $directory = $this->directory($set); if (!is_dir($directory)) { throw new RuntimeException("Папка Set {$set} не найдена."); } $actual = []; $errors = []; foreach (new FilesystemIterator($directory, FilesystemIterator::SKIP_DOTS) as $item) { if ($item->isLink() || !$item->isFile()) { $errors[] = "Лишний элемент в Set {$set}: {$item->getFilename()}."; continue; } $actual[$item->getFilename()] = $item->getPathname(); } $expected = self::DIMENSIONS; foreach (array_keys($expected) as $filename) { if (!isset($actual[$filename])) { $errors[] = "Отсутствует обязательный файл {$filename}."; } } foreach (array_keys($actual) as $filename) { if (!isset($expected[$filename])) { $errors[] = "Лишний файл {$filename}."; } } foreach ($expected as $filename => $dimensions) { if (!isset($actual[$filename])) { continue; } try { $actualDimensions = $this->dimensions($actual[$filename]); if ($actualDimensions !== $dimensions) { $errors[] = sprintf( 'Неверный размер %s: %d×%d, ожидалось %d×%d.', $filename, $actualDimensions['width'], $actualDimensions['height'], $dimensions['width'], $dimensions['height'] ); } } catch (RuntimeException $exception) { $errors[] = $exception->getMessage(); } } if ($errors !== []) { throw new RuntimeException(implode("\n", $errors)); } } /** * Полностью и атомарно заменяет Set распакованным ZIP после проверки всех файлов. * * @throws RuntimeException */ public function replaceFromZip(int $set, string $zipPath): void { $this->assertSetNumber($set); if (!is_file($zipPath)) { throw new RuntimeException('ZIP-файл не получен.'); } if (!class_exists(ZipArchive::class)) { throw new RuntimeException('На сервере не доступна поддержка ZIP.'); } $stagingParent = $this->root . DIRECTORY_SEPARATOR . '.staging'; $staging = $stagingParent . DIRECTORY_SEPARATOR . $set . '-' . bin2hex(random_bytes(8)); $stagedSet = $staging . DIRECTORY_SEPARATOR . $set; if (!is_dir($stagingParent) && !mkdir($stagingParent, 0775, true) && !is_dir($stagingParent)) { throw new RuntimeException('Не удалось создать staging-каталог Set.'); } if (!mkdir($stagedSet, 0775, true)) { throw new RuntimeException('Не удалось подготовить staging-каталог Set.'); } try { $this->extractZip($zipPath, $set, $stagedSet); $this->validateDirectoryPath($stagedSet, $set); $this->activateStagedSet($set, $stagedSet); } catch (\Throwable $exception) { if (is_dir($staging)) { $this->removeDirectory($staging); } throw $exception instanceof RuntimeException ? $exception : new RuntimeException('Не удалось заменить Set ZIP-архивом.', 0, $exception); } if (is_dir($staging)) { $this->removeDirectory($staging); } } private function extractZip(string $zipPath, int $set, string $target): void { $zip = new ZipArchive(); if ($zip->open($zipPath) !== true) { throw new RuntimeException('Не удалось открыть ZIP-архив.'); } try { $prefix = $set . '/'; $files = []; $directories = []; for ($index = 0; $index < $zip->numFiles; $index++) { $name = (string) $zip->getNameIndex($index); if ($name === '' || str_contains($name, '\\') || str_starts_with($name, '/') || str_contains($name, '../')) { throw new RuntimeException('ZIP содержит недопустимую структуру файлов.'); } if ($name === $prefix) { $directories[] = $name; continue; } if (!str_starts_with($name, $prefix) || str_contains(substr($name, strlen($prefix)), '/')) { throw new RuntimeException('ZIP содержит недопустимую структуру файлов.'); } $filename = substr($name, strlen($prefix)); if (!isset(self::DIMENSIONS[$filename]) || isset($files[$filename])) { throw new RuntimeException('ZIP содержит лишний или повторяющийся файл.'); } $files[$filename] = $index; } // Некоторые ZIP-клиенты (в частности проводник Windows) не добавляют // отдельную запись корневого каталога: достаточно 90 файлов с единым // префиксом "<номер Set>/". Вложенные каталоги всё равно отклоняются выше. if (count($directories) > 1 || count($files) !== count(self::DIMENSIONS)) { throw new RuntimeException("ZIP должен содержать папку {$set} и ровно 90 файлов в ней."); } foreach (array_keys(self::DIMENSIONS) as $filename) { if (!isset($files[$filename])) { throw new RuntimeException("В ZIP отсутствует обязательный файл {$filename}."); } $stream = $zip->getStream($prefix . $filename); if ($stream === false) { throw new RuntimeException("Не удалось прочитать файл {$filename} из ZIP."); } $destination = $target . DIRECTORY_SEPARATOR . $filename; $output = fopen($destination, 'wb'); if ($output === false) { fclose($stream); throw new RuntimeException("Не удалось подготовить файл {$filename}."); } $copied = stream_copy_to_stream($stream, $output, 8388609); fclose($output); fclose($stream); if ($copied === false || $copied > 8388608) { throw new RuntimeException("Не удалось распаковать файл {$filename}."); } chmod($destination, 0644); } } finally { $zip->close(); } } private function activateStagedSet(int $set, string $stagedSet): void { $destination = $this->directory($set); $backup = $this->root . DIRECTORY_SEPARATOR . '.backup-' . $set . '-' . bin2hex(random_bytes(8)); $hadDestination = is_dir($destination); if ($hadDestination && !rename($destination, $backup)) { throw new RuntimeException('Не удалось подготовить замену текущего Set.'); } if (!rename($stagedSet, $destination)) { if ($hadDestination) { rename($backup, $destination); } throw new RuntimeException('Не удалось активировать новый Set.'); } if ($hadDestination) { $this->removeDirectory($backup); } } /** @return array{width: int, height: int} */ private function dimensions(string $path): array { if ($this->imageInspector !== null) { $dimensions = ($this->imageInspector)($path); if (!is_array($dimensions) || !isset($dimensions['width'], $dimensions['height'])) { throw new RuntimeException('Проверка изображения вернула некорректный размер.'); } return ['width' => (int) $dimensions['width'], 'height' => (int) $dimensions['height']]; } if (!class_exists(\Imagick::class)) { throw new RuntimeException('На сервере не доступна проверка изображений Imagick.'); } try { $image = new \Imagick(); $image->readImage($path); $format = strtoupper($image->getImageFormat()); $extension = strtolower(pathinfo($path, PATHINFO_EXTENSION)); $allowed = ['jpg'=>['JPEG','JPG'], 'webp'=>['WEBP'], 'avif'=>['AVIF','HEIC']]; if (!in_array($format, $allowed[$extension] ?? [], true) || $image->getNumberImages() !== 1) { throw new RuntimeException('Формат изображения не совпадает с расширением либо содержит несколько кадров.'); } $dimensions = ['width' => $image->getImageWidth(), 'height' => $image->getImageHeight()]; $image->clear(); $image->destroy(); return $dimensions; } catch (\Throwable $exception) { throw new RuntimeException('Файл ' . basename($path) . ' не является корректным изображением.', 0, $exception); } } private function validateDirectoryPath(string $directory, int $set): void { $storage = new self(dirname($directory), $this->imageInspector); $storage->validateDirectory($set); } private function directory(int $set): string { $this->assertSetNumber($set); return $this->root . DIRECTORY_SEPARATOR . $set; } private function assertSetNumber(int $set): void { if ($set < 1 || $set > 9) { throw new RuntimeException('Номер Set должен быть от 1 до 9.'); } } private function removeDirectory(string $directory): void { $iterator = new RecursiveIteratorIterator( new RecursiveDirectoryIterator($directory, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST ); foreach ($iterator as $item) { $item->isDir() ? rmdir($item->getPathname()) : unlink($item->getPathname()); } rmdir($directory); } }