feat: готовый бренд ZoomStage и минимальный доменный подвал (refs #12)
deploy-preland / deploy zoomstage.online (push) Successful in 44s

This commit is contained in:
Panfilov Konstantin committed 2026-10-21 17:24:14 +03:00
1 parent a2c3197a16
commit ec087ea636
24 files changed
+365 -237

No files matched your search

+27
View File
@@ -0,0 +1,27 @@
<?php
/** Готовые доменные SVG, без генерации логотипа при запросе страницы. */
declare(strict_types=1);
function prelandBrand(string $root, string $domain): array
{
$catalog = json_decode(file_get_contents($root . '/release/brands.json'), true, 512, JSON_THROW_ON_ERROR);
if (!isset($catalog[$domain])) throw new RuntimeException('Для домена не утверждён бренд: ' . $domain);
$brand = $catalog[$domain];
if (array_keys($brand['logos']) !== ['docs/logo.svg', 'docs/logo-alt.svg']) throw new RuntimeException('Нужна полная пара SVG');
foreach ($brand['logos'] as $target => $record) {
if (!preg_match('~^brands/' . preg_quote($domain, '~') . '/(?:light|dark)\.svg$~D', $record['file'])) throw new RuntimeException('Некорректный путь бренда');
$body = str_replace("\r\n", "\n", file_get_contents($root . '/' . $record['file']));
if (hash('sha256', $body) !== $record['sha256'] || str_contains($body, '<text') || !str_contains($body, '<title>' . $brand['label'] . '</title>')) throw new RuntimeException('Не совпал утверждённый готовый SVG: ' . $target);
}
return $brand;
}
function prelandBrandState(array $actual, array $brand): int
{
$expected = array_map(static fn(array $record): string => $record['sha256'], $brand['logos']);
if ($actual === $expected) return 0;
if (array_keys($actual) !== array_keys($expected)) return 1;
foreach ($expected as $path => $hash) {
if ($actual[$path] !== $hash && $actual[$path] !== ($brand['replace_from'][$path] ?? null)) return 1;
}
return 10;
}
-60
View File
@@ -1,60 +0,0 @@
<?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); }
+12 -23
View File
@@ -1,6 +1,7 @@
<?php
/** preland#11: проверенный состав ресурсов; исполняется до SFTP. */
declare(strict_types=1);
require_once __DIR__ . '/brand-contract.php';
function releaseAssetBytes(string $path, string $bytes): string
{
@@ -20,7 +21,7 @@ function releaseStaticFiles(string $root): array
if ($name === 'docs' && !preg_match('~^docs/[^/]+\.(svg|pdf)$~', $path)) continue;
if ($entry->isFile()) $files[] = $path;
}
} elseif (preg_match('/\.css$/', $name) || $name === 'requisites.html') {
} elseif (preg_match('/\.css$/', $name)) {
$files[] = $name;
}
}
@@ -32,7 +33,9 @@ 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'];
$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);
}
@@ -52,7 +55,7 @@ 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]+/~', $path)));
$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));
@@ -67,6 +70,10 @@ 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');
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;
@@ -88,6 +95,7 @@ function verifyPrimaryAssets(string $root, array $files): void
$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, '~') . '(?:\?[^"\']*)?)["\']~';
@@ -103,17 +111,7 @@ function verifyPrimaryAssets(string $root, array $files): void
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");
fwrite(STDERR, "OK: primary PDF/favicon актуальны\n");
}
if (realpath($_SERVER['SCRIPT_FILENAME'] ?? '') === __FILE__) {
@@ -123,15 +121,6 @@ if (realpath($_SERVER['SCRIPT_FILENAME'] ?? '') === __FILE__) {
$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('Неизвестный режим');
+26
View File
@@ -0,0 +1,26 @@
<?php
/** Отдельная явно согласованная миграция бренда. Обычная code-поставка SVG не пишет. */
declare(strict_types=1);
require __DIR__ . '/brand-contract.php';
try {
$mode = $argv[1] ?? ''; $url = $argv[2] ?? '';
if (!preg_match('~^https://([a-z0-9.-]+)$~D', $url, $match)) throw new RuntimeException('Требуется HTTPS origin цели');
$brand = prelandBrand(dirname(__DIR__), $match[1]);
if ($mode === '--files') {
foreach ($brand['logos'] as $target => $record) echo $record['file'], ' ', $target, "\n";
exit(0);
}
if ($mode !== '--status') throw new RuntimeException('Неизвестный режим бренда');
$actual = []; $expected = [];
foreach ($brand['logos'] as $target => $record) {
$context = stream_context_create(['http' => ['timeout' => 30, 'header' => "Cache-Control: no-cache\r\n"]]);
$body = @file_get_contents($url . '/' . $target . '?brand_check=' . $record['sha256'], false, $context);
if ($body === false) throw new RuntimeException('Не удалось прочитать бренд цели: ' . $target);
$actual[$target] = hash('sha256', str_replace("\r\n", "\n", $body));
$expected[$target] = $record['sha256'];
}
$state = prelandBrandState($actual, $brand);
if ($state === 0) { echo "OK: доменный бренд совпал; SVG не перезаписываются\n"; exit(0); }
if ($state === 10) { echo "Разрешена точная миграция бренда по preland#12\n"; exit(10); }
throw new RuntimeException('На хосте другой бренд: обычный deploy не имеет права его менять');
} catch (Throwable $error) { fwrite(STDERR, $error->getMessage() . "\n"); exit(1); }