feat: единая админка runtime-конфигов и indpic (#22)
deploy-preland / deploy zoomstage.online (push) Successful in 1m1s

This commit is contained in:
Panfilov Konstantin committed 2026-10-25 11:33:56 +03:00
1 parent 5b6b2d2053
commit 9515c1c62b
52 files changed
+2459 -34

No files matched your search

+384
View File
@@ -0,0 +1,384 @@
<?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);
}
}