summaryrefslogtreecommitdiffstats
path: root/web/index.php
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 /web/index.php
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
Diffstat (limited to '')
-rw-r--r--web/index.php437
1 files changed, 437 insertions, 0 deletions
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>