From 79588a8bba1648ea4d396a1d1e9884af54e497e2 Mon Sep 17 00:00:00 2001 From: salmonik Date: Fri, 30 Oct 2026 00:40:10 +0100 Subject: add deploy script with live verification; cache file hashes by size and mtime --- web/README.md | 28 +++++++++++- web/index.php | 144 ++++++++++++++++++++++++++++++++++++++++++++++++++++------ 2 files changed, 157 insertions(+), 15 deletions(-) (limited to 'web') diff --git a/web/README.md b/web/README.md index 5de180d..a82c492 100644 --- a/web/README.md +++ b/web/README.md @@ -58,10 +58,34 @@ php -S 127.0.0.1:8899 -t web Then open . +## Deploying + +```sh +./scripts/publish.sh +./scripts/deploy.sh user@host:/srv/zagan.bet https://zagan.bet/ +``` + +`deploy.sh` rsyncs `web/` with `--delay-updates`, so a visitor never sees a +half-written binary, then fetches the live page and every artifact it lists and +compares the downloaded bytes with the local SHA-256. `--verify-only` skips the +upload and only checks the live site. Old versions are left on the server; +prune them by hand. + +A local directory works as a target, which is how the verification itself is +tested: + +```sh +php -S 127.0.0.1:8899 -t /srv/staging +./scripts/deploy.sh /srv/staging http://127.0.0.1:8899/ +``` + ## Notes -- Hashes are computed per request with `hash_file()`. That is fine for a - handful of builds; add an mtime-keyed cache if the directory grows. +- Hashes are cached in a JSON file keyed on size and mtime, so a file is only + read once until it changes. The cache lives in the system temp directory by + default because the web root is often not writable by the PHP user; set + `ZAGAN_HASH_CACHE` to put it elsewhere. If the cache cannot be written, the + page simply hashes every request. - The page lists every build it finds, newest version first, so old releases stay downloadable until you remove them from `bin/`. - The checksum block is in `sha256sum` format, so visitors can save it as diff --git a/web/index.php b/web/index.php index 361fdcf..fd0a396 100644 --- a/web/index.php +++ b/web/index.php @@ -19,9 +19,10 @@ declare(strict_types=1); * bin/client/zagan-bet-client-3.1.1-linux-x86_64 * bin/server/zagan-bet-server-3.1.1-linux-x86_64 * - * The hashes are computed per request with hash_file(). That is a few tens of - * milliseconds for a handful of builds; add a cache keyed on mtime if the - * directory ever grows. + * SHA-256 values are cached in a JSON file keyed on size and mtime, so a file + * is only re-read when it changes. The cache lives in the system temp + * directory by default (the web root is often not writable by the PHP user); + * set ZAGAN_HASH_CACHE to put it somewhere else. */ const KINDS = [ @@ -68,10 +69,99 @@ function human_size(int $bytes): string } /** - * @return array{version:string,platform:string,arch:string,file:string,size:int,mtime:int,sha256:string}|null + * Hash to use for a published file, reusing the cached value when the file has + * not changed since it was hashed. + * + * A cached hash is valid only when the size and mtime still match and the hash + * was taken at or after the file's mtime, so a restored or touched file is + * re-read. + * + * @param array{files:array} $cache + */ +function cached_hash( + string $key, + string $path, + int $size, + int $mtime, + array &$cache, + bool &$dirty +): ?string { + $entry = $cache['files'][$key] ?? null; + if ( + is_array($entry) + && ($entry['size'] ?? null) === $size + && ($entry['mtime'] ?? null) === $mtime + && (int) ($entry['hashed_at'] ?? 0) >= $mtime + && isset($entry['sha256']) + && is_string($entry['sha256']) + && preg_match('/\A[0-9a-f]{64}\z/', $entry['sha256']) === 1 + ) { + return $entry['sha256']; + } + + $hash = hash_file('sha256', $path); + if ($hash === false) { + return null; + } + + $cache['files'][$key] = [ + 'size' => $size, + 'mtime' => $mtime, + 'sha256' => $hash, + 'hashed_at' => time(), + ]; + $dirty = true; + + return $hash; +} + +/** @return array{files:array} */ +function load_hash_cache(string $path): array +{ + $raw = @file_get_contents($path); + if ($raw === false || $raw === '') { + return ['files' => []]; + } + $decoded = json_decode($raw, true); + if (!is_array($decoded) || !isset($decoded['files']) || !is_array($decoded['files'])) { + return ['files' => []]; + } + + return ['files' => $decoded['files']]; +} + +/** + * Best effort: a read-only web root, a full disk or a racing writer must never + * break the page, so failures are ignored and the next request recomputes. + * + * @param array{files:array} $cache */ -function read_release(string $kind, string $directory, string $entry): ?array +function save_hash_cache(string $path, array $cache): void { + $json = json_encode($cache, JSON_UNESCAPED_SLASHES); + if ($json === false) { + return; + } + $directory = dirname($path); + $temporary = @tempnam($directory, '.zagan-hashes-'); + if ($temporary === false) { + return; + } + if (@file_put_contents($temporary, $json, LOCK_EX) === false || !@rename($temporary, $path)) { + @unlink($temporary); + } +} + +/** + * @return array{version:string,platform:string,arch:string,file:string,size:int,mtime:int,sha256:string}|null + */ +function read_release( + string $kind, + string $directory, + string $entry, + array &$cache, + bool &$dirty +): ?array { $pattern = '/^zagan-bet-' . preg_quote($kind, '/') . '-v?(\d+\.\d+\.\d+)-([a-z]+)-([a-z0-9_]+)(\.exe)?$/i'; if (preg_match($pattern, $entry, $matches) !== 1) { @@ -92,8 +182,13 @@ function read_release(string $kind, string $directory, string $entry): ?array } $size = filesize($real); - $hash = hash_file('sha256', $real); - if ($size === false || $hash === false) { + $mtime = filemtime($real); + if ($size === false || $mtime === false) { + return null; + } + + $hash = cached_hash($kind . '/' . $entry, $real, $size, $mtime, $cache, $dirty); + if ($hash === null) { return null; } @@ -103,7 +198,7 @@ function read_release(string $kind, string $directory, string $entry): ?array 'arch' => strtolower($matches[3]), 'file' => $entry, 'size' => $size, - 'mtime' => filemtime($real) ?: 0, + 'mtime' => $mtime, 'sha256' => $hash, ]; } @@ -113,6 +208,16 @@ if ($base === '.' || $base === '/') { $base = ''; } +$cachePath = getenv('ZAGAN_HASH_CACHE'); +if (!is_string($cachePath) || $cachePath === '') { + $cachePath = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR) + . DIRECTORY_SEPARATOR . 'zagan-bet-hashes-' + . substr(hash('sha256', __DIR__), 0, 16) . '.json'; +} +$cache = load_hash_cache($cachePath); +$cacheDirty = false; +$seen = []; + $releases = []; $newest = 0; @@ -127,10 +232,11 @@ foreach (KINDS as $kind => $meta) { if ($entry === '.' || $entry === '..') { continue; } - $release = read_release($kind, $directory, $entry); + $release = read_release($kind, $directory, $entry, $cache, $cacheDirty); if ($release === null) { continue; } + $seen[$kind . '/' . $entry] = true; $release['href'] = $base . '/bin/' . $kind . '/' . rawurlencode($release['file']); $found[] = $release; $newest = max($newest, $release['mtime']); @@ -156,19 +262,30 @@ foreach (KINDS as $kind => $meta) { $releases[$kind] = $found; } + +// Drop cache entries for files that are no longer published. +foreach (array_keys($cache['files']) as $key) { + if (!isset($seen[$key])) { + unset($cache['files'][$key]); + $cacheDirty = true; + } +} +if ($cacheDirty) { + save_hash_cache($cachePath, $cache); +} ?> -Download · zagan.bet +żagań.bet -

zagan.bet

+

żagań.bet

Official client and server builds.

0): ?>

Builds published UTC.

@@ -181,7 +298,7 @@ foreach (KINDS as $kind => $meta) { @@ -193,7 +310,7 @@ foreach (KINDS as $kind => $meta) { @@ -222,5 +339,6 @@ echo e($sums);

Windows: run the .exe from Windows Terminal or another real terminal.

+

When lost, execute help command inside zagan.bet client. Server binary supports --help flag

-- cgit v1.2.3