summaryrefslogtreecommitdiffstats
path: root/web/releases.php
diff options
context:
space:
mode:
Diffstat (limited to 'web/releases.php')
-rw-r--r--web/releases.php262
1 files changed, 262 insertions, 0 deletions
diff --git a/web/releases.php b/web/releases.php
new file mode 100644
index 0000000..b9c7284
--- /dev/null
+++ b/web/releases.php
@@ -0,0 +1,262 @@
1<?php
2declare(strict_types=1);
3
4/**
5 * Shared release scanning for the download page and the latest-build endpoint.
6 *
7 * Expected file names under bin/<kind>/ (anything else is ignored):
8 *
9 * bin/gui/zagan-bet-gui-<version>-<platform>-<arch>[.exe]
10 * bin/client/zagan-bet-client-<version>-<platform>-<arch>[.exe]
11 * bin/server/zagan-bet-server-<version>-<platform>-<arch>[.exe]
12 *
13 * <version> is MAJOR.MINOR.PATCH, <platform> is linux|windows|macos and
14 * <arch> is x86_64|i686|aarch64.
15 *
16 * SHA-256 values are cached in a JSON file keyed on size and mtime, so a file
17 * is only re-read when it changes. The cache lives in the system temp
18 * directory by default (the web root is often not writable by the PHP user);
19 * set ZAGAN_HASH_CACHE to put it somewhere else.
20 */
21
22const KINDS = [
23 'gui' => [
24 'title' => 'Desktop app',
25 'hint' => 'Windows 10 or newer, or a Linux desktop. No terminal needed.',
26 ],
27 'client' => [
28 'title' => 'Client',
29 'hint' => 'Needs a terminal of at least 80×24 cells.',
30 ],
31 'server' => [
32 'title' => 'Server',
33 'hint' => 'Generates its own TLS identity on first run.',
34 ],
35];
36
37const PLATFORMS = [
38 'linux' => 'Linux',
39 'windows' => 'Windows',
40 'macos' => 'macOS',
41];
42
43const ARCHES = [
44 'x86_64' => '64-bit',
45 'i686' => '32-bit',
46 'aarch64' => 'ARM64',
47];
48
49// Newest version first, then 64-bit builds before 32-bit.
50const PLATFORM_ORDER = ['linux' => 0, 'windows' => 1, 'macos' => 2];
51const ARCH_ORDER = ['x86_64' => 0, 'aarch64' => 1, 'i686' => 2];
52
53/**
54 * Hash to use for a published file, reusing the cached value when the file has
55 * not changed since it was hashed.
56 *
57 * A cached hash is valid only when the size and mtime still match and the hash
58 * was taken at or after the file's mtime, so a restored or touched file is
59 * re-read.
60 *
61 * @param array{files:array<string,array{size:int,mtime:int,sha256:string,hashed_at:int}>} $cache
62 */
63function cached_hash(
64 string $key,
65 string $path,
66 int $size,
67 int $mtime,
68 array &$cache,
69 bool &$dirty
70): ?string {
71 $entry = $cache['files'][$key] ?? null;
72 if (
73 is_array($entry)
74 && ($entry['size'] ?? null) === $size
75 && ($entry['mtime'] ?? null) === $mtime
76 && (int) ($entry['hashed_at'] ?? 0) >= $mtime
77 && isset($entry['sha256'])
78 && is_string($entry['sha256'])
79 && preg_match('/\A[0-9a-f]{64}\z/', $entry['sha256']) === 1
80 ) {
81 return $entry['sha256'];
82 }
83
84 $hash = hash_file('sha256', $path);
85 if ($hash === false) {
86 return null;
87 }
88
89 $cache['files'][$key] = [
90 'size' => $size,
91 'mtime' => $mtime,
92 'sha256' => $hash,
93 'hashed_at' => time(),
94 ];
95 $dirty = true;
96
97 return $hash;
98}
99
100/** @return array{files:array<string,array{size:int,mtime:int,sha256:string,hashed_at:int}>} */
101function load_hash_cache(string $path): array
102{
103 $raw = @file_get_contents($path);
104 if ($raw === false || $raw === '') {
105 return ['files' => []];
106 }
107 $decoded = json_decode($raw, true);
108 if (!is_array($decoded) || !isset($decoded['files']) || !is_array($decoded['files'])) {
109 return ['files' => []];
110 }
111
112 return ['files' => $decoded['files']];
113}
114
115/**
116 * Best effort: a read-only web root, a full disk or a racing writer must never
117 * break the page, so failures are ignored and the next request recomputes.
118 *
119 * @param array{files:array<string,array{size:int,mtime:int,sha256:string,hashed_at:int}>} $cache
120 */
121function save_hash_cache(string $path, array $cache): void
122{
123 $json = json_encode($cache, JSON_UNESCAPED_SLASHES);
124 if ($json === false) {
125 return;
126 }
127 $directory = dirname($path);
128 $temporary = @tempnam($directory, '.zagan-hashes-');
129 if ($temporary === false) {
130 return;
131 }
132 if (@file_put_contents($temporary, $json, LOCK_EX) === false || !@rename($temporary, $path)) {
133 @unlink($temporary);
134 }
135}
136
137/**
138 * @return array{kind:string,version:string,platform:string,arch:string,file:string,size:int,mtime:int,sha256:string}|null
139 */
140function read_release(
141 string $kind,
142 string $directory,
143 string $entry,
144 array &$cache,
145 bool &$dirty
146): ?array {
147 $pattern = '/^zagan-bet-' . preg_quote($kind, '/')
148 . '-v?(\d+\.\d+\.\d+)-([a-z]+)-([a-z0-9_]+)(\.exe)?$/i';
149 if (preg_match($pattern, $entry, $matches) !== 1) {
150 return null;
151 }
152
153 $realDirectory = realpath($directory);
154 $real = realpath($directory . '/' . $entry);
155 if ($realDirectory === false || $real === false) {
156 return null;
157 }
158 // Never serve anything that resolves outside the published directory.
159 if (!str_starts_with($real, $realDirectory . DIRECTORY_SEPARATOR)) {
160 return null;
161 }
162 if (!is_file($real)) {
163 return null;
164 }
165
166 $size = filesize($real);
167 $mtime = filemtime($real);
168 if ($size === false || $mtime === false) {
169 return null;
170 }
171
172 $hash = cached_hash($kind . '/' . $entry, $real, $size, $mtime, $cache, $dirty);
173 if ($hash === null) {
174 return null;
175 }
176
177 return [
178 'kind' => $kind,
179 'version' => $matches[1],
180 'platform' => strtolower($matches[2]),
181 'arch' => strtolower($matches[3]),
182 'file' => $entry,
183 'size' => $size,
184 'mtime' => $mtime,
185 'sha256' => $hash,
186 ];
187}
188
189/**
190 * Scan every kind and return its releases, newest version first.
191 *
192 * @return array{releases:array<string,array<int,array{kind:string,version:string,platform:string,arch:string,file:string,size:int,mtime:int,sha256:string}>>,newest_mtime:int}
193 */
194function scan_releases(): array
195{
196 $cachePath = getenv('ZAGAN_HASH_CACHE');
197 if (!is_string($cachePath) || $cachePath === '') {
198 $cachePath = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR)
199 . DIRECTORY_SEPARATOR . 'zagan-bet-hashes-'
200 . substr(hash('sha256', __DIR__), 0, 16) . '.json';
201 }
202 $cache = load_hash_cache($cachePath);
203 $dirty = false;
204 $seen = [];
205
206 $releases = [];
207 $newestMtime = 0;
208
209 foreach (KINDS as $kind => $meta) {
210 $found = [];
211 $directory = __DIR__ . '/bin/' . $kind;
212
213 if (is_dir($directory)) {
214 $entries = scandir($directory);
215 if ($entries !== false) {
216 foreach ($entries as $entry) {
217 if ($entry === '.' || $entry === '..') {
218 continue;
219 }
220 $release = read_release($kind, $directory, $entry, $cache, $dirty);
221 if ($release === null) {
222 continue;
223 }
224 $seen[$kind . '/' . $entry] = true;
225 $found[] = $release;
226 $newestMtime = max($newestMtime, $release['mtime']);
227 }
228 }
229 }
230
231 usort($found, static function (array $a, array $b): int {
232 $byVersion = version_compare($b['version'], $a['version']);
233 if ($byVersion !== 0) {
234 return $byVersion;
235 }
236 $byPlatform = (PLATFORM_ORDER[$a['platform']] ?? 9) <=> (PLATFORM_ORDER[$b['platform']] ?? 9);
237 if ($byPlatform !== 0) {
238 return $byPlatform;
239 }
240 $byArch = (ARCH_ORDER[$a['arch']] ?? 9) <=> (ARCH_ORDER[$b['arch']] ?? 9);
241 if ($byArch !== 0) {
242 return $byArch;
243 }
244 return strcmp($a['file'], $b['file']);
245 });
246
247 $releases[$kind] = $found;
248 }
249
250 // Drop cache entries for files that are no longer published.
251 foreach (array_keys($cache['files']) as $key) {
252 if (!isset($seen[$key])) {
253 unset($cache['files'][$key]);
254 $dirty = true;
255 }
256 }
257 if ($dirty) {
258 save_hash_cache($cachePath, $cache);
259 }
260
261 return ['releases' => $releases, 'newest_mtime' => $newestMtime];
262}