Files
preland/bin/release-assets.php
T

142 lines
9.1 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);
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) || $name === 'requisites.html') {
$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);
$files = $manifest['files'] ?? [];
$required = ['docs/logo.svg', 'docs/logo-alt.svg', 'docs/privacy_policy.pdf', 'docs/public_offer.pdf', 'docs/tariffs.pdf', 'docs/user_agreement.pdf', 'requisites.html'];
foreach ($required as $path) {
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; синхронизируйте актуальный набор');
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
{
$files = array_values(array_filter(releaseStaticFiles($root), static fn(string $path): bool => !preg_match('~^[0-9]+/~', $path)));
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');
$count = 0;
foreach ($files as $path => $record) {
if ($setsOnly && !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/') && !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 перед выпуском');
}
}
if (!preg_match('~<div[^>]*class=["\'][^"\']*\bh5\b[^"\']*["\'][^>]*>\s*Наши реквизиты\s*</div>(.*?)</div>~su', $html, $block)) throw new RuntimeException('Не найдены текущие реквизиты основного сайта');
preg_match_all('~<p\b[^>]*>(.*?)</p>~su', $block[1], $paragraphs);
$actual = [];
foreach ($paragraphs[1] as $paragraph) {
$paragraph = preg_replace('/\s+/u', ' ', $paragraph);
$text = html_entity_decode(strip_tags(preg_replace('~</?br\b[^>]*>~i', "\n", $paragraph)), ENT_QUOTES, 'UTF-8');
foreach (explode("\n", $text) as $line) if (trim($line) !== '') $actual[] = trim($line);
}
$expected = json_decode(file_get_contents($root . '/release/requisites.json'), true, 512, JSON_THROW_ON_ERROR);
if ($actual !== $expected) throw new RuntimeException('Основной сайт обновил реквизиты; сначала синхронизируйте Git');
fwrite(STDERR, "OK: primary logos/PDF/favicon/requisites актуальны\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 === '--stage-requisites') {
$base = $argv[2] ?? '';
if (!preg_match('~^https://[A-Za-z0-9.-]+$~', $base)) throw new RuntimeException('Требуется HTTPS origin');
$context = stream_context_create(['http' => ['timeout' => 30, 'header' => "Cache-Control: no-cache\r\n"]]);
$actual = json_decode((string) file_get_contents($base . '/config.json?requisites_check=1', false, $context), true, 512, JSON_THROW_ON_ERROR);
$expected = json_decode(file_get_contents($root . '/release/requisites.json'), true, 512, JSON_THROW_ON_ERROR);
if (($actual['requisites'] ?? null) !== $expected) throw new RuntimeException('Host config не получил актуальные requisites');
echo "OK: runtime requisites актуальны\n";
}
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);
}
}