diff options
Diffstat (limited to 'web/releases.php')
| -rw-r--r-- | web/releases.php | 262 |
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 | ||
| 2 | declare(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 | |||
| 22 | const 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 | |||
| 37 | const PLATFORMS = [ | ||
| 38 | 'linux' => 'Linux', | ||
| 39 | 'windows' => 'Windows', | ||
| 40 | 'macos' => 'macOS', | ||
| 41 | ]; | ||
| 42 | |||
| 43 | const 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. | ||
| 50 | const PLATFORM_ORDER = ['linux' => 0, 'windows' => 1, 'macos' => 2]; | ||
| 51 | const 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 | */ | ||
| 63 | function 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}>} */ | ||
| 101 | function 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 | */ | ||
| 121 | function 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 | */ | ||
| 140 | function 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 | */ | ||
| 194 | function 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 | } | ||
