diff options
Diffstat (limited to 'web/index.php')
| -rw-r--r-- | web/index.php | 308 |
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 |
| 2 | declare(strict_types=1); | 2 | declare(strict_types=1); |
| 3 | 3 | ||
| 4 | require __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 | ||
| 31 | const 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 | ||
| 46 | const PLATFORMS = [ | 19 | $base = rtrim(str_replace('\\', '/', dirname($_SERVER['SCRIPT_NAME'] ?? '/')), '/'); |
| 47 | 'linux' => 'Linux', | 20 | if ($base === '.' || $base === '/') { |
| 48 | 'windows' => 'Windows', | 21 | $base = ''; |
| 49 | 'macos' => 'macOS', | 22 | } |
| 50 | ]; | ||
| 51 | 23 | ||
| 52 | const ARCHES = [ | 24 | foreach ($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 | } |
| 29 | unset($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']); |
| 59 | const PLATFORM_ORDER = ['linux' => 0, 'windows' => 1, 'macos' => 2]; | ||
| 60 | const ARCH_ORDER = ['x86_64' => 0, 'aarch64' => 1, 'i686' => 2]; | ||
| 61 | 32 | ||
| 62 | function e(?string $value): string | 33 | function 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 | */ | ||
| 88 | function 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}>} */ | ||
| 126 | function 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 | */ | ||
| 146 | function 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 | */ | ||
| 165 | function 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'] ?? '/')), '/'); | ||
| 218 | if ($base === '.' || $base === '/') { | ||
| 219 | $base = ''; | ||
| 220 | } | ||
| 221 | |||
| 222 | $cachePath = getenv('ZAGAN_HASH_CACHE'); | ||
| 223 | if (!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 | |||
| 235 | foreach (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. | ||
| 282 | foreach (array_keys($cache['files']) as $key) { | ||
| 283 | if (!isset($seen[$key])) { | ||
| 284 | unset($cache['files'][$key]); | ||
| 285 | $cacheDirty = true; | ||
| 286 | } | ||
| 287 | } | ||
| 288 | if ($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 <file></code>. On Windows use <code>Get-FileHash <file></code>.</p> | 111 | <p>On Linux use <code>sha256sum <file></code>. On Windows use <code>Get-FileHash <file></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 <downloaded-client> | 133 | <pre><code>chmod +x <downloaded-client> |
| 382 | ./<downloaded-client></code></pre> | 134 | ./<downloaded-client></code></pre> |
