fix: актуальные ресурсы Zoomzilla и проверяемый выпуск (refs #11)
This commit is contained in:
1 parent
144dda6d88
commit
e9a2a0d568
25 files changed
+1255
-67
No files matched your search
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
/** preland#11: владелец21.10 согласовал только requisites из текущей zoomzilla.ru. */
|
||||
declare(strict_types=1);
|
||||
|
||||
function replaceRequisites(string $raw, string $encoded): string
|
||||
{
|
||||
$depth = 0; $length = strlen($raw);
|
||||
for ($i = 0; $i < $length; $i++) {
|
||||
if ($raw[$i] === '"') {
|
||||
$start = $i++;
|
||||
for (; $i < $length; $i++) {
|
||||
if ($raw[$i] === '\\') { $i++; continue; }
|
||||
if ($raw[$i] === '"') break;
|
||||
}
|
||||
if ($depth !== 1 || json_decode(substr($raw, $start, $i - $start + 1)) !== 'requisites') continue;
|
||||
$cursor = $i + 1;
|
||||
while ($cursor < $length && ctype_space($raw[$cursor])) $cursor++;
|
||||
if (($raw[$cursor] ?? '') !== ':') continue;
|
||||
$cursor++;
|
||||
while ($cursor < $length && ctype_space($raw[$cursor])) $cursor++;
|
||||
$valueStart = $cursor; $nested = 0; $quoted = false;
|
||||
for (; $cursor < $length; $cursor++) {
|
||||
$char = $raw[$cursor];
|
||||
if ($quoted) {
|
||||
if ($char === '\\') $cursor++;
|
||||
elseif ($char === '"') $quoted = false;
|
||||
continue;
|
||||
}
|
||||
if ($char === '"') { $quoted = true; continue; }
|
||||
if (($char === ',' || $char === '}') && $nested === 0) break;
|
||||
if ($char === '[' || $char === '{') $nested++;
|
||||
elseif ($char === ']' || $char === '}') $nested--;
|
||||
}
|
||||
return substr($raw, 0, $valueStart) . $encoded . substr($raw, $cursor);
|
||||
}
|
||||
if ($raw[$i] === '{' || $raw[$i] === '[') $depth++;
|
||||
elseif ($raw[$i] === '}' || $raw[$i] === ']') $depth--;
|
||||
}
|
||||
$end = strrpos($raw, '}');
|
||||
$prefix = substr($raw, 0, $end);
|
||||
return $prefix . (count(json_decode($raw, true)) ? ',' : '') . '"requisites":' . $encoded . substr($raw, $end);
|
||||
}
|
||||
|
||||
try {
|
||||
if ($argc !== 2) throw new RuntimeException('Требуется путь к текущему host config');
|
||||
$path = $argv[1];
|
||||
$raw = file_get_contents($path);
|
||||
$config = json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
|
||||
if (!is_array($config) || !str_starts_with(ltrim($raw), '{')) throw new RuntimeException('Требуется объект config');
|
||||
$expected = json_decode(file_get_contents(dirname(__DIR__) . '/release/requisites.json'), true, 512, JSON_THROW_ON_ERROR);
|
||||
if (!is_array($expected) || !$expected || array_filter($expected, static fn($line) => !is_string($line) || trim($line) === '')) throw new RuntimeException('Некорректный согласованный источник requisites');
|
||||
if (($config['requisites'] ?? null) === $expected) { echo "requisites актуальны: конфиг не изменён\n"; exit(0); }
|
||||
$patched = replaceRequisites($raw, json_encode($expected, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR));
|
||||
$actual = json_decode($patched, true, 512, JSON_THROW_ON_ERROR);
|
||||
$config['requisites'] = $expected;
|
||||
// JSON-порядок ключей не меняет контракт, остальные значения должны совпасть.
|
||||
if ($actual != $config) throw new RuntimeException('Изменились несогласованные поля');
|
||||
if (file_put_contents($path, $patched, LOCK_EX) === false) throw new RuntimeException('Запись config не удалась');
|
||||
echo "Изменён только requisites по решению владельца preland#11\n"; exit(10);
|
||||
} catch (Throwable $error) { fwrite(STDERR, $error->getMessage() . "\n"); exit(2); }
|
||||
@@ -0,0 +1,141 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user