summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsalmonik <salmonik@tuta.io>2026-10-30 00:04:50 +0100
committersalmonik <salmonik@tuta.io>2026-10-30 00:04:50 +0100
commit4c1cc1175fd3bb030fe90ec734116e63f24f793e (patch)
tree70bcb27f41f95bd924fa027c0c629e01b7585a25
parentadd static download page for client and server builds (diff)
downloadzagan-bet-4c1cc1175fd3bb030fe90ec734116e63f24f793e.tar.gz
zagan-bet-4c1cc1175fd3bb030fe90ec734116e63f24f793e.zip
make the download page dynamic: scan builds, show versions and SHA-256
-rw-r--r--web/README.md64
-rw-r--r--web/bin/.gitignore2
-rw-r--r--web/index.html213
-rw-r--r--web/index.php437
4 files changed, 503 insertions, 213 deletions
diff --git a/web/README.md b/web/README.md
new file mode 100644
index 0000000..3c6ee08
--- /dev/null
+++ b/web/README.md
@@ -0,0 +1,64 @@
1# Download page
2
3`index.php` scans `bin/client` and `bin/server` on every request and renders
4whatever builds it finds, with version, size and SHA-256. No database, no
5JavaScript, no dependencies. PHP 8.0+.
6
7## Layout
8
9```
10web/
11 index.php
12 bin/client/ served at /bin/client/...
13 bin/server/ served at /bin/server/...
14```
15
16Serve `web/` as the document root. Binaries in `bin/` are gitignored.
17
18## Naming
19
20The page only lists files that match the release pattern; anything else in
21those directories is ignored.
22
23```
24zagan-bet-client-<version>-<platform>-<arch>[.exe]
25zagan-bet-server-<version>-<platform>-<arch>
26```
27
28`<version>` is `MAJOR.MINOR.PATCH`, `<platform>` is `linux`, `windows` or
29`macos`, `<arch>` is `x86_64`, `i686` or `aarch64`.
30
31## Publishing a build
32
33From the repository root, with the version taken from `Cargo.toml`:
34
35```sh
36V=$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -1)
37
38cargo build --release --target x86_64-unknown-linux-gnu
39cargo build --release --bin client --no-default-features --target i686-unknown-linux-gnu
40cargo build --release --bin client --target x86_64-pc-windows-gnu
41
42cp "target/x86_64-unknown-linux-gnu/release/server" "web/bin/server/zagan-bet-server-$V-linux-x86_64"
43cp "target/x86_64-unknown-linux-gnu/release/client" "web/bin/client/zagan-bet-client-$V-linux-x86_64"
44cp "target/i686-unknown-linux-gnu/release/client" "web/bin/client/zagan-bet-client-$V-linux-i686"
45cp "target/x86_64-pc-windows-gnu/release/client.exe" "web/bin/client/zagan-bet-client-$V-windows-x86_64.exe"
46```
47
48`--no-default-features` drops the server's OpenSSL dependency, which is what
49the 32-bit client build would otherwise fail on.
50
51## Preview locally
52
53```sh
54php -S 127.0.0.1:8899 -t web
55```
56
57Then open <http://127.0.0.1:8899/>.
58
59## Notes
60
61- Hashes are computed per request with `hash_file()`. That is fine for a
62 handful of builds; add an mtime-keyed cache if the directory grows.
63- The page lists every build it finds, newest version first, so old releases
64 stay downloadable until you remove them from `bin/`.
diff --git a/web/bin/.gitignore b/web/bin/.gitignore
new file mode 100644
index 0000000..d6b7ef3
--- /dev/null
+++ b/web/bin/.gitignore
@@ -0,0 +1,2 @@
1*
2!.gitignore
diff --git a/web/index.html b/web/index.html
deleted file mode 100644
index c9ad8d5..0000000
--- a/web/index.html
+++ /dev/null
@@ -1,213 +0,0 @@
1<!doctype html>
2<!--
3 zagan.bet download page.
4
5 Pure HTML + CSS: no scripts, no external fonts, no trackers.
6
7 Drop the release builds next to this page under the document root:
8
9 /index.html this file
10 /bin/client/zagan-bet-client-linux-i686
11 /bin/client/zagan-bet-client-linux-x86_64
12 /bin/client/zagan-bet-client-windows-x86_64.exe
13 /bin/server/zagan-bet-server-linux-x86_64
14
15 The links are root-absolute, so serve this directory as the document root.
16-->
17<html lang="en">
18<head>
19<meta charset="utf-8">
20<meta name="viewport" content="width=device-width, initial-scale=1">
21<title>Download · zagan.bet</title>
22<meta name="description" content="Official zagan.bet client and server downloads.">
23<link rel="icon" href="data:,">
24<style>
25 :root {
26 --bg: #0b0e11;
27 --panel: #11171d;
28 --line: #222c35;
29 --text: #dde6ec;
30 --muted: #8b9aa6;
31 --accent: #5fd0e0;
32 --win: #6ee7a0;
33 --radius: 10px;
34 }
35
36 * { box-sizing: border-box; }
37
38 body {
39 margin: 0;
40 background: var(--bg);
41 color: var(--text);
42 font: 16px/1.55 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
43 }
44
45 code, pre, .mono {
46 font-family: ui-monospace, "SF Mono", "Cascadia Mono", Menlo, Consolas, monospace;
47 }
48
49 .wrap {
50 max-width: 880px;
51 margin: 0 auto;
52 padding: 3rem 1.25rem 4rem;
53 }
54
55 header { margin-bottom: 2.5rem; }
56
57 h1 {
58 margin: 0 0 .35rem;
59 font: 700 2rem/1.1 ui-monospace, "SF Mono", Menlo, Consolas, monospace;
60 letter-spacing: -.02em;
61 }
62
63 h1 span { color: var(--accent); }
64
65 .tagline { margin: 0; color: var(--muted); }
66
67 section { margin-top: 2.5rem; }
68
69 h2 {
70 margin: 0 0 .9rem;
71 font-size: .8rem;
72 font-weight: 600;
73 letter-spacing: .12em;
74 text-transform: uppercase;
75 color: var(--muted);
76 border-bottom: 1px solid var(--line);
77 padding-bottom: .6rem;
78 }
79
80 .hint { margin: -.3rem 0 1rem; color: var(--muted); font-size: .9rem; }
81
82 .grid {
83 list-style: none;
84 margin: 0;
85 padding: 0;
86 display: grid;
87 grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
88 gap: .9rem;
89 }
90
91 .card {
92 background: var(--panel);
93 border: 1px solid var(--line);
94 border-radius: var(--radius);
95 padding: 1rem;
96 display: flex;
97 flex-direction: column;
98 gap: .35rem;
99 }
100
101 .card h3 { margin: 0; font-size: 1rem; }
102
103 .card .arch { margin: 0; color: var(--muted); font-size: .85rem; }
104
105 .card .btn { margin-top: .7rem; }
106
107 .btn {
108 display: block;
109 text-align: center;
110 text-decoration: none;
111 padding: .55rem .8rem;
112 border-radius: 7px;
113 background: var(--accent);
114 color: #06222a;
115 font-weight: 600;
116 font-size: .92rem;
117 }
118
119 .btn:hover { background: #86e2ee; }
120
121 .btn:focus-visible {
122 outline: 2px solid var(--win);
123 outline-offset: 2px;
124 }
125
126 pre {
127 margin: 0 0 1rem;
128 padding: .85rem 1rem;
129 overflow-x: auto;
130 background: var(--panel);
131 border: 1px solid var(--line);
132 border-radius: var(--radius);
133 font-size: .88rem;
134 line-height: 1.6;
135 }
136
137 pre .c { color: var(--muted); }
138
139 footer {
140 margin-top: 3rem;
141 padding-top: 1.5rem;
142 border-top: 1px solid var(--line);
143 color: var(--muted);
144 font-size: .88rem;
145 }
146
147 footer p { margin: .6rem 0 0; }
148
149 a { color: var(--accent); }
150</style>
151</head>
152<body>
153<div class="wrap">
154
155 <header>
156 <h1>zagan<span>.bet</span></h1>
157 <p class="tagline">Terminal betting. Slots, roulette, blackjack and crash.</p>
158 </header>
159
160 <main>
161 <section>
162 <h2>Client</h2>
163 <p class="hint">Needs a terminal of at least 80×24 cells.</p>
164 <ul class="grid">
165 <li class="card">
166 <h3>Linux</h3>
167 <p class="arch">x86_64 · 64-bit</p>
168 <a class="btn" href="/bin/client/zagan-bet-client-linux-x86_64" download>Download</a>
169 </li>
170 <li class="card">
171 <h3>Linux</h3>
172 <p class="arch">i686 · 32-bit</p>
173 <a class="btn" href="/bin/client/zagan-bet-client-linux-i686" download>Download</a>
174 </li>
175 <li class="card">
176 <h3>Windows</h3>
177 <p class="arch">x86_64 · 64-bit</p>
178 <a class="btn" href="/bin/client/zagan-bet-client-windows-x86_64.exe" download>Download</a>
179 </li>
180 </ul>
181 </section>
182
183 <section>
184 <h2>Server</h2>
185 <p class="hint">One build, Linux x86_64. Generates its own TLS identity on first run.</p>
186 <ul class="grid">
187 <li class="card">
188 <h3>Linux</h3>
189 <p class="arch">x86_64 · 64-bit</p>
190 <a class="btn" href="/bin/server/zagan-bet-server-linux-x86_64" download>Download</a>
191 </li>
192 </ul>
193 </section>
194 </main>
195
196 <footer>
197 <h2>Running it</h2>
198 <pre><code><span class="c"># client</span>
199chmod +x zagan-bet-client-linux-x86_64
200./zagan-bet-client-linux-x86_64</code></pre>
201
202 <pre><code><span class="c"># server: first run creates identity.p12, key.pem and cert.pem</span>
203chmod +x zagan-bet-server-linux-x86_64
204./zagan-bet-server-linux-x86_64 --init
205./zagan-bet-server-linux-x86_64 -i 0.0.0.0 -p 11525</code></pre>
206
207 <p>Windows: run the <code>.exe</code> from Windows Terminal or another real terminal.</p>
208 <p>Verify a download before running it: <code>sha256sum &lt;file&gt;</code> (Linux) or <code>Get-FileHash &lt;file&gt;</code> (Windows).</p>
209 </footer>
210
211</div>
212</body>
213</html>
diff --git a/web/index.php b/web/index.php
new file mode 100644
index 0000000..cd77dad
--- /dev/null
+++ b/web/index.php
@@ -0,0 +1,437 @@
1<?php
2declare(strict_types=1);
3
4/**
5 * zagan.bet download page.
6 *
7 * Scans ./bin/client and ./bin/server and renders every build it finds, with
8 * its version, size and SHA-256. No database, no JavaScript, no dependencies.
9 *
10 * Expected file names (anything else is ignored):
11 *
12 * bin/client/zagan-bet-client-<version>-<platform>-<arch>
13 * bin/client/zagan-bet-client-<version>-<platform>-<arch>.exe
14 * bin/server/zagan-bet-server-<version>-<platform>-<arch>
15 *
16 * <version> is MAJOR.MINOR.PATCH, <platform> is linux|windows|macos and
17 * <arch> is x86_64|i686|aarch64. Example:
18 *
19 * bin/client/zagan-bet-client-3.1.1-linux-x86_64
20 * bin/server/zagan-bet-server-3.1.1-linux-x86_64
21 *
22 * The hashes are computed per request with hash_file(). That is a few tens of
23 * milliseconds for a handful of builds; add a cache keyed on mtime if the
24 * directory ever grows.
25 */
26
27const KINDS = [
28 'client' => [
29 'title' => 'Client',
30 'hint' => 'Needs a terminal of at least 80×24 cells.',
31 ],
32 'server' => [
33 'title' => 'Server',
34 'hint' => 'Generates its own TLS identity on first run.',
35 ],
36];
37
38const PLATFORMS = [
39 'linux' => 'Linux',
40 'windows' => 'Windows',
41 'macos' => 'macOS',
42];
43
44const ARCHES = [
45 'x86_64' => '64-bit',
46 'i686' => '32-bit',
47 'aarch64' => 'ARM64',
48];
49
50// Newest version first, then 64-bit builds before 32-bit.
51const PLATFORM_ORDER = ['linux' => 0, 'windows' => 1, 'macos' => 2];
52const ARCH_ORDER = ['x86_64' => 0, 'aarch64' => 1, 'i686' => 2];
53
54function e(?string $value): string
55{
56 return htmlspecialchars($value ?? '', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
57}
58
59function human_size(int $bytes): string
60{
61 if ($bytes >= 1048576) {
62 return number_format($bytes / 1048576, 1) . ' MB';
63 }
64 if ($bytes >= 1024) {
65 return number_format($bytes / 1024) . ' KB';
66 }
67 return $bytes . ' B';
68}
69
70/**
71 * @return array{version:string,platform:string,arch:string,file:string,size:int,mtime:int,sha256:string}|null
72 */
73function read_release(string $kind, string $directory, string $entry): ?array
74{
75 $pattern = '/^zagan-bet-' . preg_quote($kind, '/')
76 . '-v?(\d+\.\d+\.\d+)-([a-z]+)-([a-z0-9_]+)(\.exe)?$/i';
77 if (preg_match($pattern, $entry, $matches) !== 1) {
78 return null;
79 }
80
81 $realDirectory = realpath($directory);
82 $real = realpath($directory . '/' . $entry);
83 if ($realDirectory === false || $real === false) {
84 return null;
85 }
86 // Never serve anything that resolves outside the published directory.
87 if (!str_starts_with($real, $realDirectory . DIRECTORY_SEPARATOR)) {
88 return null;
89 }
90 if (!is_file($real)) {
91 return null;
92 }
93
94 $size = filesize($real);
95 $hash = hash_file('sha256', $real);
96 if ($size === false || $hash === false) {
97 return null;
98 }
99
100 return [
101 'version' => $matches[1],
102 'platform' => strtolower($matches[2]),
103 'arch' => strtolower($matches[3]),
104 'file' => $entry,
105 'size' => $size,
106 'mtime' => filemtime($real) ?: 0,
107 'sha256' => $hash,
108 ];
109}
110
111$base = rtrim(str_replace('\\', '/', dirname($_SERVER['SCRIPT_NAME'] ?? '/')), '/');
112if ($base === '.' || $base === '/') {
113 $base = '';
114}
115
116$releases = [];
117$newest = 0;
118
119foreach (KINDS as $kind => $meta) {
120 $found = [];
121 $directory = __DIR__ . '/bin/' . $kind;
122
123 if (is_dir($directory)) {
124 $entries = scandir($directory);
125 if ($entries !== false) {
126 foreach ($entries as $entry) {
127 if ($entry === '.' || $entry === '..') {
128 continue;
129 }
130 $release = read_release($kind, $directory, $entry);
131 if ($release === null) {
132 continue;
133 }
134 $release['href'] = $base . '/bin/' . $kind . '/' . rawurlencode($release['file']);
135 $found[] = $release;
136 $newest = max($newest, $release['mtime']);
137 }
138 }
139 }
140
141 usort($found, static function (array $a, array $b): int {
142 $byVersion = version_compare($b['version'], $a['version']);
143 if ($byVersion !== 0) {
144 return $byVersion;
145 }
146 $byPlatform = (PLATFORM_ORDER[$a['platform']] ?? 9) <=> (PLATFORM_ORDER[$b['platform']] ?? 9);
147 if ($byPlatform !== 0) {
148 return $byPlatform;
149 }
150 $byArch = (ARCH_ORDER[$a['arch']] ?? 9) <=> (ARCH_ORDER[$b['arch']] ?? 9);
151 if ($byArch !== 0) {
152 return $byArch;
153 }
154 return strcmp($a['file'], $b['file']);
155 });
156
157 $releases[$kind] = $found;
158}
159?>
160<!doctype html>
161<html lang="en">
162<head>
163<meta charset="utf-8">
164<meta name="viewport" content="width=device-width, initial-scale=1">
165<title>Download · zagan.bet</title>
166<meta name="description" content="Official zagan.bet client and server downloads.">
167<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src data:">
168<link rel="icon" href="data:,">
169<style>
170 :root {
171 --bg: #0b0e11;
172 --panel: #11171d;
173 --line: #222c35;
174 --text: #dde6ec;
175 --muted: #8b9aa6;
176 --accent: #5fd0e0;
177 --win: #6ee7a0;
178 --radius: 10px;
179 }
180
181 * { box-sizing: border-box; }
182
183 body {
184 margin: 0;
185 background: var(--bg);
186 color: var(--text);
187 font: 16px/1.55 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
188 }
189
190 code, pre, .mono {
191 font-family: ui-monospace, "SF Mono", "Cascadia Mono", Menlo, Consolas, monospace;
192 }
193
194 .wrap {
195 max-width: 880px;
196 margin: 0 auto;
197 padding: 3rem 1.25rem 4rem;
198 }
199
200 header { margin-bottom: 2.5rem; }
201
202 h1 {
203 margin: 0 0 .35rem;
204 font: 700 2rem/1.1 ui-monospace, "SF Mono", Menlo, Consolas, monospace;
205 letter-spacing: -.02em;
206 }
207
208 h1 span { color: var(--accent); }
209
210 .tagline { margin: 0; color: var(--muted); }
211
212 .stamp { margin: .6rem 0 0; color: var(--muted); font-size: .8rem; }
213
214 section { margin-top: 2.5rem; }
215
216 h2 {
217 margin: 0 0 .9rem;
218 font-size: .8rem;
219 font-weight: 600;
220 letter-spacing: .12em;
221 text-transform: uppercase;
222 color: var(--muted);
223 border-bottom: 1px solid var(--line);
224 padding-bottom: .6rem;
225 }
226
227 .hint { margin: -.3rem 0 1rem; color: var(--muted); font-size: .9rem; }
228
229 .empty { color: var(--muted); font-size: .9rem; }
230
231 .grid {
232 list-style: none;
233 margin: 0;
234 padding: 0;
235 display: grid;
236 grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
237 gap: .9rem;
238 }
239
240 .card {
241 background: var(--panel);
242 border: 1px solid var(--line);
243 border-radius: var(--radius);
244 padding: 1rem;
245 display: flex;
246 flex-direction: column;
247 gap: .3rem;
248 }
249
250 .card-head {
251 display: flex;
252 align-items: baseline;
253 justify-content: space-between;
254 gap: .5rem;
255 }
256
257 .card h3 { margin: 0; font-size: 1rem; }
258
259 .ver {
260 font-size: .75rem;
261 color: var(--accent);
262 border: 1px solid var(--line);
263 border-radius: 999px;
264 padding: .1rem .5rem;
265 white-space: nowrap;
266 }
267
268 .card .arch { margin: 0; color: var(--muted); font-size: .85rem; }
269
270 .card .meta { margin: 0; color: var(--muted); font-size: .8rem; }
271
272 .card .btn { margin-top: .7rem; }
273
274 .btn {
275 display: block;
276 text-align: center;
277 text-decoration: none;
278 padding: .55rem .8rem;
279 border-radius: 7px;
280 background: var(--accent);
281 color: #06222a;
282 font-weight: 600;
283 font-size: .92rem;
284 }
285
286 .btn:hover { background: #86e2ee; }
287
288 .btn:focus-visible,
289 summary:focus-visible {
290 outline: 2px solid var(--win);
291 outline-offset: 2px;
292 }
293
294 pre {
295 margin: 0 0 1rem;
296 padding: .85rem 1rem;
297 overflow-x: auto;
298 background: var(--panel);
299 border: 1px solid var(--line);
300 border-radius: var(--radius);
301 font-size: .88rem;
302 line-height: 1.6;
303 }
304
305 pre .c { color: var(--muted); }
306
307 .table-wrap { overflow-x: auto; }
308
309 table {
310 width: 100%;
311 border-collapse: collapse;
312 font-size: .82rem;
313 }
314
315 th, td {
316 text-align: left;
317 padding: .55rem .7rem;
318 border-bottom: 1px solid var(--line);
319 vertical-align: top;
320 }
321
322 th {
323 color: var(--muted);
324 font-weight: 600;
325 font-size: .75rem;
326 letter-spacing: .06em;
327 text-transform: uppercase;
328 white-space: nowrap;
329 }
330
331 td.file { word-break: break-all; }
332 td.sum { word-break: break-all; color: var(--muted); }
333 td.size { white-space: nowrap; color: var(--muted); }
334
335 footer {
336 margin-top: 3rem;
337 padding-top: 1.5rem;
338 border-top: 1px solid var(--line);
339 color: var(--muted);
340 font-size: .88rem;
341 }
342
343 footer p { margin: .6rem 0 0; }
344
345 a { color: var(--accent); }
346</style>
347</head>
348<body>
349<div class="wrap">
350
351 <header>
352 <h1>zagan<span>.bet</span></h1>
353 <p class="tagline">Terminal betting. Slots, roulette, blackjack and crash.</p>
354 <?php if ($newest > 0): ?>
355 <p class="stamp">Builds published <?= e(gmdate('j M Y H:i', $newest)) ?> UTC</p>
356 <?php endif; ?>
357 </header>
358
359 <main>
360 <?php foreach (KINDS as $kind => $meta): ?>
361 <section>
362 <h2><?= e($meta['title']) ?></h2>
363 <p class="hint"><?= e($meta['hint']) ?></p>
364
365 <?php if ($releases[$kind] === []): ?>
366 <p class="empty">Nothing published yet.</p>
367 <?php else: ?>
368 <ul class="grid">
369 <?php foreach ($releases[$kind] as $release): ?>
370 <li class="card">
371 <div class="card-head">
372 <h3><?= e(PLATFORMS[$release['platform']] ?? ucfirst($release['platform'])) ?></h3>
373 <span class="ver">v<?= e($release['version']) ?></span>
374 </div>
375 <p class="arch">
376 <?= e($release['arch']) ?><?php if (isset(ARCHES[$release['arch']])): ?>
377 · <?= e(ARCHES[$release['arch']]) ?>
378 <?php endif; ?>
379 </p>
380 <p class="meta"><?= e(human_size($release['size'])) ?></p>
381 <a class="btn" href="<?= e($release['href']) ?>" download>Download</a>
382 </li>
383 <?php endforeach; ?>
384 </ul>
385 <?php endif; ?>
386 </section>
387 <?php endforeach; ?>
388
389 <?php $all = array_merge($releases['client'], $releases['server']); ?>
390 <?php if ($all !== []): ?>
391 <section>
392 <h2>Checksums</h2>
393 <p class="hint">SHA-256 of every published file.</p>
394 <div class="table-wrap">
395 <table>
396 <thead>
397 <tr>
398 <th>File</th>
399 <th>Version</th>
400 <th>Size</th>
401 <th>SHA-256</th>
402 </tr>
403 </thead>
404 <tbody>
405 <?php foreach ($all as $release): ?>
406 <tr>
407 <td class="file mono"><?= e($release['file']) ?></td>
408 <td><?= e($release['version']) ?></td>
409 <td class="size"><?= e(human_size($release['size'])) ?></td>
410 <td class="sum mono"><?= e($release['sha256']) ?></td>
411 </tr>
412 <?php endforeach; ?>
413 </tbody>
414 </table>
415 </div>
416 </section>
417 <?php endif; ?>
418 </main>
419
420 <footer>
421 <h2>Running it</h2>
422 <pre><code><span class="c"># client (use the file you downloaded)</span>
423chmod +x &lt;downloaded-client&gt;
424./&lt;downloaded-client&gt;</code></pre>
425
426 <pre><code><span class="c"># server: first run creates identity.p12, key.pem and cert.pem</span>
427chmod +x &lt;downloaded-server&gt;
428./&lt;downloaded-server&gt; --init
429./&lt;downloaded-server&gt; -i 0.0.0.0 -p 11525</code></pre>
430
431 <p>Windows: run the <code>.exe</code> from Windows Terminal or another real terminal.</p>
432 <p>Verify a download before running it: <code>sha256sum &lt;file&gt;</code> (Linux) or <code>Get-FileHash &lt;file&gt;</code> (Windows).</p>
433 </footer>
434
435</div>
436</body>
437</html>