summaryrefslogtreecommitdiffstats
path: root/web
diff options
context:
space:
mode:
authorsalmonik <salmonik@tuta.io>2026-10-30 00:40:10 +0100
committersalmonik <salmonik@tuta.io>2026-10-30 00:40:10 +0100
commit79588a8bba1648ea4d396a1d1e9884af54e497e2 (patch)
treebcc795839e1b848bb8e4c5ba017aaef6e9399ecb /web
parentadd publish script that names release artifacts from Cargo.toml and the targe... (diff)
downloadzagan-bet-79588a8bba1648ea4d396a1d1e9884af54e497e2.tar.gz
zagan-bet-79588a8bba1648ea4d396a1d1e9884af54e497e2.zip
add deploy script with live verification; cache file hashes by size and mtime
Diffstat (limited to 'web')
-rw-r--r--web/README.md28
-rw-r--r--web/index.php144
2 files changed, 157 insertions, 15 deletions
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
58 58
59Then open <http://127.0.0.1:8899/>. 59Then open <http://127.0.0.1:8899/>.
60 60
61## Deploying
62
63```sh
64./scripts/publish.sh
65./scripts/deploy.sh user@host:/srv/zagan.bet https://zagan.bet/
66```
67
68`deploy.sh` rsyncs `web/` with `--delay-updates`, so a visitor never sees a
69half-written binary, then fetches the live page and every artifact it lists and
70compares the downloaded bytes with the local SHA-256. `--verify-only` skips the
71upload and only checks the live site. Old versions are left on the server;
72prune them by hand.
73
74A local directory works as a target, which is how the verification itself is
75tested:
76
77```sh
78php -S 127.0.0.1:8899 -t /srv/staging
79./scripts/deploy.sh /srv/staging http://127.0.0.1:8899/
80```
81
61## Notes 82## Notes
62 83
63- Hashes are computed per request with `hash_file()`. That is fine for a 84- Hashes are cached in a JSON file keyed on size and mtime, so a file is only
64 handful of builds; add an mtime-keyed cache if the directory grows. 85 read once until it changes. The cache lives in the system temp directory by
86 default because the web root is often not writable by the PHP user; set
87 `ZAGAN_HASH_CACHE` to put it elsewhere. If the cache cannot be written, the
88 page simply hashes every request.
65- The page lists every build it finds, newest version first, so old releases 89- The page lists every build it finds, newest version first, so old releases
66 stay downloadable until you remove them from `bin/`. 90 stay downloadable until you remove them from `bin/`.
67- The checksum block is in `sha256sum` format, so visitors can save it as 91- 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);
19 * bin/client/zagan-bet-client-3.1.1-linux-x86_64 19 * bin/client/zagan-bet-client-3.1.1-linux-x86_64
20 * bin/server/zagan-bet-server-3.1.1-linux-x86_64 20 * bin/server/zagan-bet-server-3.1.1-linux-x86_64
21 * 21 *
22 * The hashes are computed per request with hash_file(). That is a few tens of 22 * SHA-256 values are cached in a JSON file keyed on size and mtime, so a file
23 * milliseconds for a handful of builds; add a cache keyed on mtime if the 23 * is only re-read when it changes. The cache lives in the system temp
24 * directory ever grows. 24 * directory by default (the web root is often not writable by the PHP user);
25 * set ZAGAN_HASH_CACHE to put it somewhere else.
25 */ 26 */
26 27
27const KINDS = [ 28const KINDS = [
@@ -68,10 +69,99 @@ function human_size(int $bytes): string
68} 69}
69 70
70/** 71/**
71 * @return array{version:string,platform:string,arch:string,file:string,size:int,mtime:int,sha256:string}|null 72 * Hash to use for a published file, reusing the cached value when the file has
73 * not changed since it was hashed.
74 *
75 * A cached hash is valid only when the size and mtime still match and the hash
76 * was taken at or after the file's mtime, so a restored or touched file is
77 * re-read.
78 *
79 * @param array{files:array<string,array{size:int,mtime:int,sha256:string,hashed_at:int}>} $cache
80 */
81function cached_hash(
82 string $key,
83 string $path,
84 int $size,
85 int $mtime,
86 array &$cache,
87 bool &$dirty
88): ?string {
89 $entry = $cache['files'][$key] ?? null;
90 if (
91 is_array($entry)
92 && ($entry['size'] ?? null) === $size
93 && ($entry['mtime'] ?? null) === $mtime
94 && (int) ($entry['hashed_at'] ?? 0) >= $mtime
95 && isset($entry['sha256'])
96 && is_string($entry['sha256'])
97 && preg_match('/\A[0-9a-f]{64}\z/', $entry['sha256']) === 1
98 ) {
99 return $entry['sha256'];
100 }
101
102 $hash = hash_file('sha256', $path);
103 if ($hash === false) {
104 return null;
105 }
106
107 $cache['files'][$key] = [
108 'size' => $size,
109 'mtime' => $mtime,
110 'sha256' => $hash,
111 'hashed_at' => time(),
112 ];
113 $dirty = true;
114
115 return $hash;
116}
117
118/** @return array{files:array<string,array{size:int,mtime:int,sha256:string,hashed_at:int}>} */
119function load_hash_cache(string $path): array
120{
121 $raw = @file_get_contents($path);
122 if ($raw === false || $raw === '') {
123 return ['files' => []];
124 }
125 $decoded = json_decode($raw, true);
126 if (!is_array($decoded) || !isset($decoded['files']) || !is_array($decoded['files'])) {
127 return ['files' => []];
128 }
129
130 return ['files' => $decoded['files']];
131}
132
133/**
134 * Best effort: a read-only web root, a full disk or a racing writer must never
135 * break the page, so failures are ignored and the next request recomputes.
136 *
137 * @param array{files:array<string,array{size:int,mtime:int,sha256:string,hashed_at:int}>} $cache
72 */ 138 */
73function read_release(string $kind, string $directory, string $entry): ?array 139function save_hash_cache(string $path, array $cache): void
74{ 140{
141 $json = json_encode($cache, JSON_UNESCAPED_SLASHES);
142 if ($json === false) {
143 return;
144 }
145 $directory = dirname($path);
146 $temporary = @tempnam($directory, '.zagan-hashes-');
147 if ($temporary === false) {
148 return;
149 }
150 if (@file_put_contents($temporary, $json, LOCK_EX) === false || !@rename($temporary, $path)) {
151 @unlink($temporary);
152 }
153}
154
155/**
156 * @return array{version:string,platform:string,arch:string,file:string,size:int,mtime:int,sha256:string}|null
157 */
158function read_release(
159 string $kind,
160 string $directory,
161 string $entry,
162 array &$cache,
163 bool &$dirty
164): ?array {
75 $pattern = '/^zagan-bet-' . preg_quote($kind, '/') 165 $pattern = '/^zagan-bet-' . preg_quote($kind, '/')
76 . '-v?(\d+\.\d+\.\d+)-([a-z]+)-([a-z0-9_]+)(\.exe)?$/i'; 166 . '-v?(\d+\.\d+\.\d+)-([a-z]+)-([a-z0-9_]+)(\.exe)?$/i';
77 if (preg_match($pattern, $entry, $matches) !== 1) { 167 if (preg_match($pattern, $entry, $matches) !== 1) {
@@ -92,8 +182,13 @@ function read_release(string $kind, string $directory, string $entry): ?array
92 } 182 }
93 183
94 $size = filesize($real); 184 $size = filesize($real);
95 $hash = hash_file('sha256', $real); 185 $mtime = filemtime($real);
96 if ($size === false || $hash === false) { 186 if ($size === false || $mtime === false) {
187 return null;
188 }
189
190 $hash = cached_hash($kind . '/' . $entry, $real, $size, $mtime, $cache, $dirty);
191 if ($hash === null) {
97 return null; 192 return null;
98 } 193 }
99 194
@@ -103,7 +198,7 @@ function read_release(string $kind, string $directory, string $entry): ?array
103 'arch' => strtolower($matches[3]), 198 'arch' => strtolower($matches[3]),
104 'file' => $entry, 199 'file' => $entry,
105 'size' => $size, 200 'size' => $size,
106 'mtime' => filemtime($real) ?: 0, 201 'mtime' => $mtime,
107 'sha256' => $hash, 202 'sha256' => $hash,
108 ]; 203 ];
109} 204}
@@ -113,6 +208,16 @@ if ($base === '.' || $base === '/') {
113 $base = ''; 208 $base = '';
114} 209}
115 210
211$cachePath = getenv('ZAGAN_HASH_CACHE');
212if (!is_string($cachePath) || $cachePath === '') {
213 $cachePath = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR)
214 . DIRECTORY_SEPARATOR . 'zagan-bet-hashes-'
215 . substr(hash('sha256', __DIR__), 0, 16) . '.json';
216}
217$cache = load_hash_cache($cachePath);
218$cacheDirty = false;
219$seen = [];
220
116$releases = []; 221$releases = [];
117$newest = 0; 222$newest = 0;
118 223
@@ -127,10 +232,11 @@ foreach (KINDS as $kind => $meta) {
127 if ($entry === '.' || $entry === '..') { 232 if ($entry === '.' || $entry === '..') {
128 continue; 233 continue;
129 } 234 }
130 $release = read_release($kind, $directory, $entry); 235 $release = read_release($kind, $directory, $entry, $cache, $cacheDirty);
131 if ($release === null) { 236 if ($release === null) {
132 continue; 237 continue;
133 } 238 }
239 $seen[$kind . '/' . $entry] = true;
134 $release['href'] = $base . '/bin/' . $kind . '/' . rawurlencode($release['file']); 240 $release['href'] = $base . '/bin/' . $kind . '/' . rawurlencode($release['file']);
135 $found[] = $release; 241 $found[] = $release;
136 $newest = max($newest, $release['mtime']); 242 $newest = max($newest, $release['mtime']);
@@ -156,19 +262,30 @@ foreach (KINDS as $kind => $meta) {
156 262
157 $releases[$kind] = $found; 263 $releases[$kind] = $found;
158} 264}
265
266// Drop cache entries for files that are no longer published.
267foreach (array_keys($cache['files']) as $key) {
268 if (!isset($seen[$key])) {
269 unset($cache['files'][$key]);
270 $cacheDirty = true;
271 }
272}
273if ($cacheDirty) {
274 save_hash_cache($cachePath, $cache);
275}
159?> 276?>
160<!doctype html> 277<!doctype html>
161<html lang="en"> 278<html lang="en">
162<head> 279<head>
163<meta charset="utf-8"> 280<meta charset="utf-8">
164<meta name="viewport" content="width=device-width, initial-scale=1"> 281<meta name="viewport" content="width=device-width, initial-scale=1">
165<title>Download · zagan.bet</title> 282<title>żagań.bet</title>
166<meta name="description" content="Official zagan.bet client and server downloads."> 283<meta name="description" content="Official zagan.bet client and server downloads.">
167<meta http-equiv="Content-Security-Policy" content="default-src 'none'"> 284<meta http-equiv="Content-Security-Policy" content="default-src 'none'">
168</head> 285</head>
169<body> 286<body>
170 287
171<h1>zagan.bet</h1> 288<h1>żagań.bet</h1>
172<p>Official client and server builds.</p> 289<p>Official client and server builds.</p>
173<?php if ($newest > 0): ?> 290<?php if ($newest > 0): ?>
174<p>Builds published <?= e(gmdate('j M Y H:i', $newest)) ?> UTC.</p> 291<p>Builds published <?= e(gmdate('j M Y H:i', $newest)) ?> UTC.</p>
@@ -181,7 +298,7 @@ foreach (KINDS as $kind => $meta) {
181<?php else: ?> 298<?php else: ?>
182<ul> 299<ul>
183<?php foreach ($releases['client'] as $release): ?> 300<?php foreach ($releases['client'] as $release): ?>
184<li><a href="<?= e($release['href']) ?>" download><?= e(PLATFORMS[$release['platform']] ?? ucfirst($release['platform'])) ?> <?= e($release['arch']) ?><?php if (isset(ARCHES[$release['arch']])): ?> (<?= e(ARCHES[$release['arch']]) ?>)<?php endif; ?> — version <?= e($release['version']) ?> — <?= e(human_size($release['size'])) ?></a></li> 301<li><a href="<?= e($release['href']) ?>" download><?= e(PLATFORMS[$release['platform']] ?? ucfirst($release['platform'])) ?> <?= e($release['arch']) ?><?php if (isset(ARCHES[$release['arch']])): ?> (<?= e(ARCHES[$release['arch']]) ?>)<?php endif; ?> v<?= e($release['version']) ?> — <?= e(human_size($release['size'])) ?></a></li>
185<?php endforeach; ?> 302<?php endforeach; ?>
186</ul> 303</ul>
187<?php endif; ?> 304<?php endif; ?>
@@ -193,7 +310,7 @@ foreach (KINDS as $kind => $meta) {
193<?php else: ?> 310<?php else: ?>
194<ul> 311<ul>
195<?php foreach ($releases['server'] as $release): ?> 312<?php foreach ($releases['server'] as $release): ?>
196<li><a href="<?= e($release['href']) ?>" download><?= e(PLATFORMS[$release['platform']] ?? ucfirst($release['platform'])) ?> <?= e($release['arch']) ?><?php if (isset(ARCHES[$release['arch']])): ?> (<?= e(ARCHES[$release['arch']]) ?>)<?php endif; ?> — version <?= e($release['version']) ?> — <?= e(human_size($release['size'])) ?></a></li> 313<li><a href="<?= e($release['href']) ?>" download><?= e(PLATFORMS[$release['platform']] ?? ucfirst($release['platform'])) ?> <?= e($release['arch']) ?><?php if (isset(ARCHES[$release['arch']])): ?> (<?= e(ARCHES[$release['arch']]) ?>)<?php endif; ?> v<?= e($release['version']) ?> — <?= e(human_size($release['size'])) ?></a></li>
197<?php endforeach; ?> 314<?php endforeach; ?>
198</ul> 315</ul>
199<?php endif; ?> 316<?php endif; ?>
@@ -222,5 +339,6 @@ echo e($sums);
222 339
223<p>Windows: run the <code>.exe</code> from Windows Terminal or another real terminal.</p> 340<p>Windows: run the <code>.exe</code> from Windows Terminal or another real terminal.</p>
224 341
342<p>When lost, execute <code>help</code> command inside zagan.bet client. Server binary supports <code>--help</code> flag</p>
225</body> 343</body>
226</html> 344</html>