385 lines
18 KiB
PHP
385 lines
18 KiB
PHP
<?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);
|
||
}
|
||
}
|