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

+116
View File
@@ -0,0 +1,116 @@
<?php
/**
* preland#22: проверка runtime indpic без сравнения с Git.
* Контракт имён/размеров: api 591a67cc8302 components/indpic/IndpicSetStorage.php.
* HTTP проверяет все обязательные файлы; только локальный режим может исключить лишние.
*/
declare(strict_types=1);
function runtimeSetDimensions(): array
{
$files = [];
for ($example = 1; $example <= 5; $example++) {
foreach (['desk' => [[1696, 920], [200, 200]], 'mob' => [[400, 217], [60, 60]]] as $device => [$full, $thumb]) {
foreach (['1-source' => $full, '2-result' => $full, '3-thumb' => $thumb] as $part => $dimensions) {
foreach (['avif', 'webp', 'jpg'] as $extension) $files[sprintf('%02d_%s_%s.%s', $example, $device, $part, $extension)] = $dimensions;
}
}
}
return $files;
}
function runtimeSetNumbers(array $config): array
{
$settings = $config['utm']['indpic'] ?? ['default' => '1', 'weights' => ['1' => 1]];
if (!is_array($settings)) throw new RuntimeException('Некорректный config utm.indpic');
$number = static function ($value): int {
if ((!is_string($value) && !is_int($value)) || !preg_match('/^[1-9]$/D', (string) $value)) throw new RuntimeException('Номер indpic должен быть 1–9');
return (int) $value;
};
$sets = [$number(array_key_exists('default', $settings) ? $settings['default'] : '1')];
$weights = $settings['weights'] ?? [];
if (!is_array($weights)) throw new RuntimeException('Некорректные веса indpic');
foreach ($weights as $variant => $weight) {
if ((!is_int($weight) && !is_float($weight)) || !is_finite((float) $weight) || $weight < 0) throw new RuntimeException('Некорректный вес indpic');
// Даже weight=0 доступен по явной ссылке indpic=abcdN и участвует в legacy random.
$sets[] = $number($variant);
}
$sets = array_values(array_unique($sets)); sort($sets); return $sets;
}
function runtimeLocalReader(string $root): Closure
{
return static function (string $path) use ($root): string {
$bytes = @file_get_contents($root . '/' . $path);
if ($bytes === false) throw new RuntimeException('Не прочитан runtime ресурс ' . $path);
return $bytes;
};
}
function runtimeHttpReader(string $origin): Closure
{
if (!preg_match('~^https://[A-Za-z0-9.-]+$~D', $origin)) throw new RuntimeException('Требуется HTTPS origin');
return static function (string $path) use ($origin): string {
$context = stream_context_create(['http' => ['timeout' => 30, 'follow_location' => 0, 'header' => "Cache-Control: no-cache\r\n"], 'ssl' => ['verify_peer' => true, 'verify_peer_name' => true]]);
$bytes = @file_get_contents($origin . '/' . $path . '?runtime_check=' . bin2hex(random_bytes(8)), false, $context, 0, 20971521);
if ($bytes === false || strlen($bytes) > 20971520 || !preg_match('~^HTTP/\S+ 200(?: |$)~', $http_response_header[0] ?? '')) throw new RuntimeException('Не прочитан runtime ресурс ' . $path);
return $bytes;
};
}
function checkRuntimeSets(array $config, callable $read, ?string $localRoot = null): array
{
if (!class_exists(Imagick::class)) throw new RuntimeException('Для полной проверки indpic требуется Imagick');
$sets = runtimeSetNumbers($config);
$expected = runtimeSetDimensions();
foreach ($sets as $set) {
if ($localRoot !== null) {
$directory = $localRoot . '/' . $set;
if (is_link($directory) || !is_dir($directory)) throw new RuntimeException('Не найден каталог indpic ' . $set);
$actual = [];
foreach (new FilesystemIterator($directory, FilesystemIterator::SKIP_DOTS) as $file) {
if ($file->isLink() || !$file->isFile()) throw new RuntimeException('Лишний элемент indpic ' . $set);
$actual[] = $file->getFilename();
}
$names = array_keys($expected); sort($names); sort($actual);
if ($actual !== $names) throw new RuntimeException('Набор indpic ' . $set . ' должен содержать ровно 90 обязательных файлов');
}
foreach ($expected as $name => [$width, $height]) {
$path = $set . '/' . $name;
$image = new Imagick();
try {
$image->readImageBlob($read($path));
$format = strtolower($image->getImageFormat());
$extension = pathinfo($name, PATHINFO_EXTENSION);
$formats = ['jpg' => ['jpeg', 'jpg'], 'webp' => ['webp'], 'avif' => ['avif', 'heic']];
if (!in_array($format, $formats[$extension], true) || $image->getNumberImages() !== 1 || $image->getImageWidth() !== $width || $image->getImageHeight() !== $height) throw new RuntimeException('Неверный формат/размер изображения');
} catch (Throwable $error) {
throw new RuntimeException('Невалидный runtime indpic ' . $path . ': ' . $error->getMessage(), 0, $error);
} finally { $image->clear(); }
}
}
return $sets;
}
function verifyRemoteRuntimeSets(string $origin): void
{
$read = runtimeHttpReader($origin);
$bytes = $read('config.json');
$config = json_decode($bytes, true, 512, JSON_THROW_ON_ERROR);
if (!is_array($config)) throw new RuntimeException('Некорректный runtime config');
$sets = checkRuntimeSets($config, $read);
if ($read('config.json') !== $bytes) throw new RuntimeException('Runtime config изменился во время проверки; повторите проверку');
fwrite(STDERR, 'OK: runtime indpic ' . implode(',', $sets) . '; все обязательные изображения декодированы, inventory лишних файлов по HTTP недоступен' . "\n");
}
if (realpath($_SERVER['SCRIPT_FILENAME'] ?? '') === __FILE__) {
try {
if (($argv[1] ?? '') === '--remote') verifyRemoteRuntimeSets($argv[2] ?? '');
else {
$root = $argv[1] ?? dirname(__DIR__);
$config = json_decode(runtimeLocalReader($root)('config.json'), true, 512, JSON_THROW_ON_ERROR);
checkRuntimeSets($config, runtimeLocalReader($root), $root);
echo "OK: локальный runtime indpic, ровно 90 файлов в каждом используемом наборе\n";
}
} catch (Throwable $error) { fwrite(STDERR, $error->getMessage() . "\n"); exit(1); }
}
+11 -4
View File
@@ -2,6 +2,7 @@
/** preland#11: проверенный состав ресурсов; исполняется до SFTP. */
declare(strict_types=1);
require_once __DIR__ . '/brand-contract.php';
require_once __DIR__ . '/check-runtime-sets.php';
function releaseAssetBytes(string $path, string $bytes): string
{
@@ -32,7 +33,8 @@ function releaseStaticFiles(string $root): array
function validateReleaseAssets(string $root): array
{
$manifest = json_decode((string) file_get_contents($root . '/release/assets.json'), true, 512, JSON_THROW_ON_ERROR);
$files = $manifest['files'] ?? [];
// Исторические /N/ записи manifest — только архивный снимок, не runtime guard.
$files = array_filter($manifest['files'] ?? [], static fn(string $path): bool => !preg_match('~^[0-9]+/~', $path), ARRAY_FILTER_USE_KEY);
$brands = json_decode(file_get_contents($root . '/release/brands.json'), true, 512, JSON_THROW_ON_ERROR);
foreach (array_keys($brands) as $domain) prelandBrand($root, $domain);
$required = ['docs/logo.svg', 'docs/logo-alt.svg', 'docs/privacy_policy.pdf', 'docs/public_offer.pdf', 'docs/tariffs.pdf', 'docs/user_agreement.pdf'];
@@ -40,7 +42,8 @@ function validateReleaseAssets(string $root): array
if (!isset($files[$path])) throw new RuntimeException('В manifest отсутствует обязательный ресурс ' . $path);
}
$names = array_keys($files); sort($names, SORT_STRING);
if ($names !== releaseStaticFiles($root)) throw new RuntimeException('Состав статических ресурсов отличается от manifest; синхронизируйте актуальный набор');
$static = array_values(array_filter(releaseStaticFiles($root), static fn(string $path): bool => !preg_match('~^[0-9]+/~', $path)));
if ($names !== $static) throw new RuntimeException('Состав статических ресурсов отличается от manifest; синхронизируйте актуальный набор');
foreach ($files as $path => $record) {
if (!preg_match('~^[A-Za-z0-9._/-]+$~', $path) || str_contains($path, '..')) throw new RuntimeException('Недопустимый путь ресурса');
$bytes = releaseAssetBytes($path, (string) file_get_contents($root . '/' . $path));
@@ -55,7 +58,9 @@ function validateReleaseAssets(string $root): array
function releaseFiles(string $root): array
{
$files = array_values(array_filter(releaseStaticFiles($root), static fn(string $path): bool => !preg_match('~^(?:[0-9]+/|docs/logo(?:-alt)?\.svg$)~', $path)));
// Положительный контракт публичных ресурсов: admin/, его state/секреты и /N/ не поставляются.
$files = array_values(array_filter(releaseStaticFiles($root), static fn(string $path): bool =>
preg_match('~^(?:[^/]+\.css|docs/[^/]+\.pdf|favicon/[^/]+\.(?:ico|png|svg|webmanifest)|fonts/[^/]+\.(?:woff2?|ttf|otf)|images/(?:[A-Za-z0-9._-]+/)*[A-Za-z0-9._-]+\.(?:avif|webp|jpe?g|png|gif|svg))$~D', $path) === 1));
foreach (['.htaccess', 'index.php', 'i18n.php', 'template.php'] as $path) $files[] = $path;
foreach (['components', 'messages'] as $directory) {
$iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root . '/' . $directory, FilesystemIterator::SKIP_DOTS));
@@ -70,13 +75,15 @@ function releaseFiles(string $root): array
function verifyRemoteReleaseAssets(array $files, string $base, bool $setsOnly): void
{
if (!preg_match('~^https://[A-Za-z0-9.-]+$~', $base)) throw new RuntimeException('Требуется HTTPS origin');
verifyRemoteRuntimeSets($base);
if ($setsOnly) return;
if (!$setsOnly) {
$brand = prelandBrand(dirname(__DIR__), (string) parse_url($base, PHP_URL_HOST));
foreach ($brand['logos'] as $path => $record) $files[$path]['sha256'] = $record['sha256'];
}
$count = 0;
foreach ($files as $path => $record) {
if ($setsOnly && !preg_match('~^[0-9]+/~', $path)) continue;
if (preg_match('~^[0-9]+/~', $path)) continue;
$context = stream_context_create(['http' => ['timeout' => 30, 'header' => "Cache-Control: no-cache\r\n"], 'ssl' => ['verify_peer' => true, 'verify_peer_name' => true]]);
$bytes = @file_get_contents($base . '/' . $path . '?release_sha=' . $record['sha256'], false, $context);
if ($bytes === false || hash('sha256', releaseAssetBytes($path, $bytes)) !== $record['sha256']) {