Files
preland/bin/release-assets.php
T
2026-10-25 11:33:56 +03:00

138 lines
8.6 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
/** 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
{
return preg_match('/\.(svg|css|html|webmanifest)$/', $path) ? str_replace("\r\n", "\n", $bytes) : $bytes;
}
function releaseStaticFiles(string $root): array
{
$files = [];
foreach (scandir($root) as $name) {
if (in_array($name, ['docs', 'favicon', 'fonts', 'images'], true) || ctype_digit($name)) {
$directory = $root . '/' . $name;
if (!is_dir($directory)) continue;
$iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($directory, FilesystemIterator::SKIP_DOTS));
foreach ($iterator as $entry) {
$path = str_replace('\\', '/', substr($entry->getPathname(), strlen($root) + 1));
if ($name === 'docs' && !preg_match('~^docs/[^/]+\.(svg|pdf)$~', $path)) continue;
if ($entry->isFile()) $files[] = $path;
}
} elseif (preg_match('/\.css$/', $name)) {
$files[] = $name;
}
}
sort($files, SORT_STRING);
return $files;
}
function validateReleaseAssets(string $root): array
{
$manifest = json_decode((string) file_get_contents($root . '/release/assets.json'), true, 512, JSON_THROW_ON_ERROR);
// Исторические /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'];
foreach ($required as $path) {
if (!isset($files[$path])) throw new RuntimeException('В manifest отсутствует обязательный ресурс ' . $path);
}
$names = array_keys($files); sort($names, SORT_STRING);
$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));
$blob = sha1('blob ' . strlen($bytes) . chr(0) . $bytes);
if ($blob === '91267982e1ad373b6ebd5fb2b761b34fe2a0c2a5' || preg_match('/WBE\s*70/i', $bytes)) {
throw new RuntimeException('Запрещённая заглушка бренда: ' . $path);
}
if (($record['sha256'] ?? '') !== hash('sha256', $bytes)) throw new RuntimeException('Ресурс не соответствует согласованному снимку: ' . $path);
}
return $files;
}
function releaseFiles(string $root): array
{
// Положительный контракт публичных ресурсов: 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));
foreach ($iterator as $entry) if ($entry->isFile() && $entry->getExtension() === 'php') {
$files[] = str_replace('\\', '/', substr($entry->getPathname(), strlen($root) + 1));
}
}
sort($files, SORT_STRING);
return $files;
}
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 (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']) {
throw new RuntimeException('Хост отличается от Git: ' . $path . '. Сначала синхронизируйте актуальный снимок по задаче.');
}
$count++;
}
fwrite(STDERR, "OK: remote {$count} resources\n");
}
/** Сверка с текущей страницей основного сайта, а не с оставшимся старым asset URL. */
function verifyPrimaryAssets(string $root, array $files): void
{
$origin = 'https://zoomzilla.ru';
$context = stream_context_create(['http' => ['timeout' => 30, 'header' => "Cache-Control: no-cache\r\n"]]);
$html = @file_get_contents($origin . '/?theme=light&preland_asset_check=1', false, $context);
if ($html === false) throw new RuntimeException('Основной сайт недоступен для сверки ресурсов');
foreach ($files as $path => $record) {
if (str_starts_with($path, 'docs/logo')) continue;
if (!str_starts_with($path, 'docs/') && !str_starts_with($path, 'favicon/')) continue;
$name = basename($path === 'docs/logo.svg' ? 'logo-light.svg' : $path);
$pattern = '~(?:src|href)=["\']([^"\']*/' . preg_quote($name, '~') . '(?:\?[^"\']*)?)["\']~';
if (!preg_match($pattern, $html, $match)) {
// Часть размеров favicon ссылается только из manifest; общий каталог тот же.
if (!str_starts_with($path, 'favicon/') || !preg_match('~href=["\']([^"\']*/favicon\.ico)["\']~', $html, $base)) throw new RuntimeException('Текущая ссылка основного сайта не найдена: ' . $path);
$url = dirname($base[1]) . '/' . $name;
} else $url = html_entity_decode($match[1], ENT_QUOTES, 'UTF-8');
if (str_starts_with($url, '/')) $url = $origin . $url;
if (!str_starts_with($url, $origin . '/')) throw new RuntimeException('Неожиданный origin ресурса: ' . $path);
$bytes = @file_get_contents($url, false, $context);
if ($bytes === false || hash('sha256', releaseAssetBytes($path, $bytes)) !== $record['sha256']) {
throw new RuntimeException('Основной сайт обновлён: синхронизируйте ' . $path . ' в Git перед выпуском');
}
}
fwrite(STDERR, "OK: primary PDF/favicon актуальны\n");
}
if (realpath($_SERVER['SCRIPT_FILENAME'] ?? '') === __FILE__) {
try {
$root = dirname(__DIR__);
$files = validateReleaseAssets($root);
$mode = $argv[1] ?? '--check';
if ($mode === '--files') echo implode("\n", releaseFiles($root)), "\n";
elseif ($mode === '--primary') verifyPrimaryAssets($root, $files);
elseif ($mode === '--remote' || $mode === '--stage-sets') verifyRemoteReleaseAssets($files, $argv[2] ?? '', $mode === '--stage-sets');
elseif ($mode === '--check') echo 'OK: release assets ', count($files), "\n";
else throw new RuntimeException('Неизвестный режим');
} catch (Throwable $error) {
fwrite(STDERR, $error->getMessage() . "\n"); exit(1);
}
}