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); $files = $manifest['files'] ?? []; $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); 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]+/|docs/logo(?:-alt)?\.svg$)~', $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'); 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; $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); } }