summaryrefslogtreecommitdiffstats
path: root/web/index.php
diff options
context:
space:
mode:
authorsalmonik <salmonik@tuta.io>2026-11-01 22:23:00 +0100
committersalmonik <salmonik@tuta.io>2026-11-01 22:23:00 +0100
commit4887abc5773b33ab1e213ed5f528dfebd242e2cd (patch)
tree8eb2316783a21817cce422c3fb58ec953206dd9a /web/index.php
parentv4.3.1: render Windows through wgpu and keep the installer window open (diff)
downloadzagan-bet-master.tar.gz
zagan-bet-master.zip
v4.4.0: stable installers that follow the newest published buildHEADmaster
Adds web/latest.php: a plain-text feed of the newest version with each artifact's filename and SHA-256. The per-release generated installers are replaced by stable web/install.sh and web/install.ps1; both read the feed, pick the newest build, verify it, install per-user and add a launcher. Publishing a new app version no longer regenerates or versions the installers. publish.sh drops the installer renderer and Python dependency. deploy.sh now also verifies latest.php against the local artifacts and that the served installer scripts match the repository.
Diffstat (limited to 'web/index.php')
-rw-r--r--web/index.php308
1 files changed, 30 insertions, 278 deletions
diff --git a/web/index.php b/web/index.php
index 77edf95..3f0ab92 100644
--- a/web/index.php
+++ b/web/index.php
@@ -1,63 +1,34 @@
1<?php 1<?php
2declare(strict_types=1); 2declare(strict_types=1);
3 3
4require __DIR__ . '/releases.php';
5
4/** 6/**
5 * zagan.bet download page. 7 * zagan.bet download page.
6 * 8 *
7 * Scans ./bin/{gui,client,server} and renders every build it finds, with its 9 * Lists every published app, terminal client and server, with version, size
8 * version, size and SHA-256. The GPL source link points at the public git 10 * and SHA-256. The installers are stable links that ask latest.php for the
9 * repository. No database, no JavaScript, no dependencies. 11 * newest build. The GPL source link points at the public git repository. No
10 * 12 * database, no JavaScript, no dependencies.
11 * Expected file names (anything else is ignored):
12 *
13 * bin/gui/zagan-bet-gui-<version>-<platform>-<arch>[.exe]
14 * bin/gui/zagan-bet-gui-<version>-<platform>-<arch>-install.sh
15 * bin/gui/zagan-bet-gui-<version>-<platform>-<arch>-install.ps1
16 * bin/client/zagan-bet-client-<version>-<platform>-<arch>[.exe]
17 * bin/server/zagan-bet-server-<version>-<platform>-<arch>[.exe]
18 *
19 * <version> is MAJOR.MINOR.PATCH, <platform> is linux|windows|macos and
20 * <arch> is x86_64|i686|aarch64. Example:
21 *
22 * bin/gui/zagan-bet-gui-4.3.0-linux-x86_64
23 * bin/client/zagan-bet-client-4.3.0-linux-x86_64
24 *
25 * SHA-256 values are cached in a JSON file keyed on size and mtime, so a file
26 * is only re-read when it changes. The cache lives in the system temp
27 * directory by default (the web root is often not writable by the PHP user);
28 * set ZAGAN_HASH_CACHE to put it somewhere else.
29 */ 13 */
30 14
31const KINDS = [ 15$scan = scan_releases();
32 'gui' => [ 16$releases = $scan['releases'];
33 'title' => 'Desktop app', 17$newest = $scan['newest_mtime'];
34 'hint' => 'Windows 10 or newer, or a Linux desktop. No terminal needed.',
35 ],
36 'client' => [
37 'title' => 'Client',
38 'hint' => 'Needs a terminal of at least 80×24 cells.',
39 ],
40 'server' => [
41 'title' => 'Server',
42 'hint' => 'Generates its own TLS identity on first run.',
43 ],
44];
45 18
46const PLATFORMS = [ 19$base = rtrim(str_replace('\\', '/', dirname($_SERVER['SCRIPT_NAME'] ?? '/')), '/');
47 'linux' => 'Linux', 20if ($base === '.' || $base === '/') {
48 'windows' => 'Windows', 21 $base = '';
49 'macos' => 'macOS', 22}
50];
51 23
52const ARCHES = [ 24foreach ($releases as $kind => &$found) {
53 'x86_64' => '64-bit', 25 foreach ($found as &$release) {
54 'i686' => '32-bit', 26 $release['href'] = $base . '/bin/' . $kind . '/' . rawurlencode($release['file']);
55 'aarch64' => 'ARM64', 27 }
56]; 28}
29unset($found, $release);
57 30
58// Newest version first, then 64-bit builds before 32-bit. 31$all = array_merge($releases['gui'], $releases['client'], $releases['server']);
59const PLATFORM_ORDER = ['linux' => 0, 'windows' => 1, 'macos' => 2];
60const ARCH_ORDER = ['x86_64' => 0, 'aarch64' => 1, 'i686' => 2];
61 32
62function e(?string $value): string 33function e(?string $value): string
63{ 34{
@@ -74,220 +45,6 @@ function human_size(int $bytes): string
74 } 45 }
75 return $bytes . ' B'; 46 return $bytes . ' B';
76} 47}
77
78/**
79 * Hash to use for a published file, reusing the cached value when the file has
80 * not changed since it was hashed.
81 *
82 * A cached hash is valid only when the size and mtime still match and the hash
83 * was taken at or after the file's mtime, so a restored or touched file is
84 * re-read.
85 *
86 * @param array{files:array<string,array{size:int,mtime:int,sha256:string,hashed_at:int}>} $cache
87 */
88function cached_hash(
89 string $key,
90 string $path,
91 int $size,
92 int $mtime,
93 array &$cache,
94 bool &$dirty
95): ?string {
96 $entry = $cache['files'][$key] ?? null;
97 if (
98 is_array($entry)
99 && ($entry['size'] ?? null) === $size
100 && ($entry['mtime'] ?? null) === $mtime
101 && (int) ($entry['hashed_at'] ?? 0) >= $mtime
102 && isset($entry['sha256'])
103 && is_string($entry['sha256'])
104 && preg_match('/\A[0-9a-f]{64}\z/', $entry['sha256']) === 1
105 ) {
106 return $entry['sha256'];
107 }
108
109 $hash = hash_file('sha256', $path);
110 if ($hash === false) {
111 return null;
112 }
113
114 $cache['files'][$key] = [
115 'size' => $size,
116 'mtime' => $mtime,
117 'sha256' => $hash,
118 'hashed_at' => time(),
119 ];
120 $dirty = true;
121
122 return $hash;
123}
124
125/** @return array{files:array<string,array{size:int,mtime:int,sha256:string,hashed_at:int}>} */
126function load_hash_cache(string $path): array
127{
128 $raw = @file_get_contents($path);
129 if ($raw === false || $raw === '') {
130 return ['files' => []];
131 }
132 $decoded = json_decode($raw, true);
133 if (!is_array($decoded) || !isset($decoded['files']) || !is_array($decoded['files'])) {
134 return ['files' => []];
135 }
136
137 return ['files' => $decoded['files']];
138}
139
140/**
141 * Best effort: a read-only web root, a full disk or a racing writer must never
142 * break the page, so failures are ignored and the next request recomputes.
143 *
144 * @param array{files:array<string,array{size:int,mtime:int,sha256:string,hashed_at:int}>} $cache
145 */
146function save_hash_cache(string $path, array $cache): void
147{
148 $json = json_encode($cache, JSON_UNESCAPED_SLASHES);
149 if ($json === false) {
150 return;
151 }
152 $directory = dirname($path);
153 $temporary = @tempnam($directory, '.zagan-hashes-');
154 if ($temporary === false) {
155 return;
156 }
157 if (@file_put_contents($temporary, $json, LOCK_EX) === false || !@rename($temporary, $path)) {
158 @unlink($temporary);
159 }
160}
161
162/**
163 * @return array{version:string,platform:string,arch:string,file:string,size:int,mtime:int,sha256:string}|null
164 */
165function read_release(
166 string $kind,
167 string $directory,
168 string $entry,
169 array &$cache,
170 bool &$dirty
171): ?array {
172 $pattern = '/^zagan-bet-' . preg_quote($kind, '/')
173 . '-v?(\d+\.\d+\.\d+)-([a-z]+)-([a-z0-9_]+)(-install\.sh|-install\.ps1|\.exe)?$/i';
174 if (preg_match($pattern, $entry, $matches) !== 1) {
175 return null;
176 }
177
178 $realDirectory = realpath($directory);
179 $real = realpath($directory . '/' . $entry);
180 if ($realDirectory === false || $real === false) {
181 return null;
182 }
183 // Never serve anything that resolves outside the published directory.
184 if (!str_starts_with($real, $realDirectory . DIRECTORY_SEPARATOR)) {
185 return null;
186 }
187 if (!is_file($real)) {
188 return null;
189 }
190
191 $size = filesize($real);
192 $mtime = filemtime($real);
193 if ($size === false || $mtime === false) {
194 return null;
195 }
196
197 $hash = cached_hash($kind . '/' . $entry, $real, $size, $mtime, $cache, $dirty);
198 if ($hash === null) {
199 return null;
200 }
201
202 $suffix = strtolower($matches[4] ?? '');
203 $variant = str_starts_with($suffix, '-install.') ? 'installer' : 'binary';
204
205 return [
206 'version' => $matches[1],
207 'platform' => strtolower($matches[2]),
208 'arch' => strtolower($matches[3]),
209 'variant' => $variant,
210 'file' => $entry,
211 'size' => $size,
212 'mtime' => $mtime,
213 'sha256' => $hash,
214 ];
215}
216
217$base = rtrim(str_replace('\\', '/', dirname($_SERVER['SCRIPT_NAME'] ?? '/')), '/');
218if ($base === '.' || $base === '/') {
219 $base = '';
220}
221
222$cachePath = getenv('ZAGAN_HASH_CACHE');
223if (!is_string($cachePath) || $cachePath === '') {
224 $cachePath = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR)
225 . DIRECTORY_SEPARATOR . 'zagan-bet-hashes-'
226 . substr(hash('sha256', __DIR__), 0, 16) . '.json';
227}
228$cache = load_hash_cache($cachePath);
229$cacheDirty = false;
230$seen = [];
231
232$releases = [];
233$newest = 0;
234
235foreach (KINDS as $kind => $meta) {
236 $found = [];
237 $directory = __DIR__ . '/bin/' . $kind;
238
239 if (is_dir($directory)) {
240 $entries = scandir($directory);
241 if ($entries !== false) {
242 foreach ($entries as $entry) {
243 if ($entry === '.' || $entry === '..') {
244 continue;
245 }
246 $release = read_release($kind, $directory, $entry, $cache, $cacheDirty);
247 if ($release === null) {
248 continue;
249 }
250 $seen[$kind . '/' . $entry] = true;
251 $release['href'] = $base . '/bin/' . $kind . '/' . rawurlencode($release['file']);
252 $found[] = $release;
253 $newest = max($newest, $release['mtime']);
254 }
255 }
256 }
257
258 usort($found, static function (array $a, array $b): int {
259 $byVersion = version_compare($b['version'], $a['version']);
260 if ($byVersion !== 0) {
261 return $byVersion;
262 }
263 $byPlatform = (PLATFORM_ORDER[$a['platform']] ?? 9) <=> (PLATFORM_ORDER[$b['platform']] ?? 9);
264 if ($byPlatform !== 0) {
265 return $byPlatform;
266 }
267 $byArch = (ARCH_ORDER[$a['arch']] ?? 9) <=> (ARCH_ORDER[$b['arch']] ?? 9);
268 if ($byArch !== 0) {
269 return $byArch;
270 }
271 $byVariant = ($a['variant'] === 'installer' ? 1 : 0) <=> ($b['variant'] === 'installer' ? 1 : 0);
272 if ($byVariant !== 0) {
273 return $byVariant;
274 }
275 return strcmp($a['file'], $b['file']);
276 });
277
278 $releases[$kind] = $found;
279}
280
281// Drop cache entries for files that are no longer published.
282foreach (array_keys($cache['files']) as $key) {
283 if (!isset($seen[$key])) {
284 unset($cache['files'][$key]);
285 $cacheDirty = true;
286 }
287}
288if ($cacheDirty) {
289 save_hash_cache($cachePath, $cache);
290}
291?> 48?>
292<!doctype html> 49<!doctype html>
293<html lang="en"> 50<html lang="en">
@@ -308,24 +65,20 @@ if ($cacheDirty) {
308 65
309<h2>Desktop app</h2> 66<h2>Desktop app</h2>
310<p>Windows 10 or newer, or a Linux desktop. No terminal needed.</p> 67<p>Windows 10 or newer, or a Linux desktop. No terminal needed.</p>
311<?php $guiBinaries = array_values(array_filter($releases['gui'], static fn (array $r): bool => $r['variant'] === 'binary')); ?> 68<?php if ($releases['gui'] === []): ?>
312<?php $guiInstallers = array_values(array_filter($releases['gui'], static fn (array $r): bool => $r['variant'] === 'installer')); ?>
313<?php if ($guiBinaries === []): ?>
314<p>Nothing published yet.</p> 69<p>Nothing published yet.</p>
315<?php else: ?> 70<?php else: ?>
316<ul> 71<ul>
317<?php foreach ($guiBinaries as $release): ?> 72<?php foreach ($releases['gui'] as $release): ?>
318<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> 73<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>
319<?php endforeach; ?> 74<?php endforeach; ?>
320</ul> 75</ul>
321<?php endif; ?> 76
322<?php if ($guiInstallers !== []): ?>
323<h3>Installers</h3> 77<h3>Installers</h3>
324<p>The installer checks the download, installs Zagan-Bet for your user and adds it to your programs. Run it next to a downloaded app, or on its own to fetch one.</p> 78<p>Downloads the newest build, checks its SHA-256 and installs it for your user.</p>
325<ul> 79<ul>
326<?php foreach ($guiInstallers as $release): ?> 80<li><a href="<?= e($base) ?>/install.sh" download>Linux installer (bash)</a></li>
327<li><a href="<?= e($release['href']) ?>" download><?= e(PLATFORMS[$release['platform']] ?? ucfirst($release['platform'])) ?> <?= e($release['arch']) ?> installer (<?= str_ends_with($release['file'], '.ps1') ? 'PowerShell' : 'bash' ?>) v<?= e($release['version']) ?> — <?= e(human_size($release['size'])) ?></a></li> 81<li><a href="<?= e($base) ?>/install.ps1" download>Windows installer (PowerShell)</a></li>
328<?php endforeach; ?>
329</ul> 82</ul>
330<?php endif; ?> 83<?php endif; ?>
331 84
@@ -353,7 +106,6 @@ if ($cacheDirty) {
353</ul> 106</ul>
354<?php endif; ?> 107<?php endif; ?>
355 108
356<?php $all = array_merge($releases['gui'], $releases['client'], $releases['server']); ?>
357<?php if ($all !== []): ?> 109<?php if ($all !== []): ?>
358<h2>SHA-256</h2> 110<h2>SHA-256</h2>
359<p>On Linux use <code>sha256sum &lt;file&gt;</code>. On Windows use <code>Get-FileHash &lt;file&gt;</code>.</p> 111<p>On Linux use <code>sha256sum &lt;file&gt;</code>. On Windows use <code>Get-FileHash &lt;file&gt;</code>.</p>
@@ -372,11 +124,11 @@ echo e($sums);
372 124
373<h2>Running it</h2> 125<h2>Running it</h2>
374<p>Desktop app on Linux:</p> 126<p>Desktop app on Linux:</p>
375<pre><code>chmod +x zagan-bet-gui-*-linux-x86_64-install.sh 127<pre><code>chmod +x install.sh
376./zagan-bet-gui-*-linux-x86_64-install.sh</code></pre> 128./install.sh</code></pre>
377<p>Desktop app on Windows (PowerShell):</p> 129<p>Desktop app on Windows (PowerShell):</p>
378<pre><code>powershell -ExecutionPolicy Bypass -File .\zagan-bet-gui-*-windows-x86_64-install.ps1</code></pre> 130<pre><code>powershell -ExecutionPolicy Bypass -File .\install.ps1</code></pre>
379<p>The installers verify the SHA-256 before installing. You can also run the downloaded app directly.</p> 131<p>The installers fetch the newest build, verify its SHA-256, install it for your user and add a launcher.</p>
380<p>Terminal client:</p> 132<p>Terminal client:</p>
381<pre><code>chmod +x &lt;downloaded-client&gt; 133<pre><code>chmod +x &lt;downloaded-client&gt;
382./&lt;downloaded-client&gt;</code></pre> 134./&lt;downloaded-client&gt;</code></pre>