Files
preland/admin/src/IndpicSetStorage.php
T
2026-10-25 11:33:56 +03:00

385 lines
18 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace PrelandAdmin;
use FilesystemIterator;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use RuntimeException;
use ZipArchive;
/**
* Файловый контракт Set демонстрационных изображений Index.
* Перенесено из API 591a67cc8302 components/indpic/IndpicSetStorage.php (preland#22).
*
* Компонент не знает о БД: он только проверяет и атомарно заменяет содержимое
* каталогов `images/indpic/<номер>`. Это позволяет одинаково применять его к
* Set, добавленному через SFTP или ZIP CPanel.
*/
final class IndpicSetStorage
{
/** @var array<string, array{width: int, height: int}> */
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<string, array{width: int, height: int}> */
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);
}
}