summaryrefslogtreecommitdiffstats
path: root/web/index.php
diff options
context:
space:
mode:
Diffstat (limited to '')
-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>