From 4887abc5773b33ab1e213ed5f528dfebd242e2cd Mon Sep 17 00:00:00 2001 From: salmonik Date: Sun, 1 Nov 2026 22:23:00 +0100 Subject: v4.4.0: stable installers that follow the newest published build 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. --- web/README.md | 38 +++---- web/index.php | 308 ++++++------------------------------------------------- web/install.ps1 | 108 +++++++++++++++++++ web/install.sh | 98 ++++++++++++++++++ web/latest.php | 50 +++++++++ web/releases.php | 262 ++++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 568 insertions(+), 296 deletions(-) create mode 100644 web/install.ps1 create mode 100644 web/install.sh create mode 100644 web/latest.php create mode 100644 web/releases.php (limited to 'web') diff --git a/web/README.md b/web/README.md index 1ee794f..13f5f85 100644 --- a/web/README.md +++ b/web/README.md @@ -8,10 +8,14 @@ browser defaults: black text, blue links, monospace checksums. PHP 8.0+. ``` web/ - index.php - bin/gui/ served at /bin/gui/... (app and installers) - bin/client/ served at /bin/client/... - bin/server/ served at /bin/server/... + index.php the download page + latest.php newest-version feed for the installers + releases.php shared scanner and hash cache + install.sh stable Linux installer + install.ps1 stable Windows installer + bin/gui/ apps, served at /bin/gui/... + bin/client/ terminal clients + bin/server/ servers ``` Serve `web/` as the document root. Binaries in `bin/` are gitignored. @@ -23,8 +27,6 @@ those directories is ignored. ``` bin/gui/zagan-bet-gui---[.exe] -bin/gui/zagan-bet-gui----install.sh -bin/gui/zagan-bet-gui----install.ps1 bin/client/zagan-bet-client---[.exe] bin/server/zagan-bet-server---[.exe] ``` @@ -32,12 +34,14 @@ bin/server/zagan-bet-server---[.exe] `` is `MAJOR.MINOR.PATCH`, `` is `linux`, `windows` or `macos`, `` is `x86_64`, `i686` or `aarch64`. -The installers are generated next to their app. They embed the app's SHA-256, -check the file (or download it) and install it for the current user: Linux +The installers are not versioned. They read `latest.php`, pick the newest build +for the machine, verify its SHA-256 and install it for the current user: Linux writes `~/.local/bin/zagan-bet-gui` plus a `.desktop` launcher, Windows writes -`%LOCALAPPDATA%\Programs\Zagan-Bet` plus a Start-menu shortcut. The Windows -installer waits for Enter before closing (`-NoPause` for automation). The page -links the public git repository for the GPL corresponding source. +`%LOCALAPPDATA%\Programs\Zagan-Bet` plus a Start-menu shortcut. Updating the +site only needs `publish.sh`; the installer links never change. Set +`ZAGAN_SITE` (Linux) or `-Site` (Windows) to test against a staging site. The +Windows installer waits for Enter before closing (`-NoPause` for automation). +The page links the public git repository for the GPL corresponding source. ## Publishing a build @@ -49,19 +53,17 @@ From the repository root: It reads the version from `Cargo.toml`, builds every artifact and installs them under `web/bin` with the names above. The GUI is built with the -workspace's `gui-release` profile (LTO, stripped) and the installers are -rendered with Python 3 and the published SHA-256. Run it from a clean tree so +workspace's `gui-release` profile (LTO, stripped). Run it from a clean tree so the release commit is the source the page links to. Override the destination -with `DEST=/srv/www/bin ./scripts/publish.sh`, and the download origin baked -into the installers with `URL_BASE=https://staging.example/ ./scripts/publish.sh`. -`ALLOW_DIRTY=1` skips the clean-tree check for a preview. +with `DEST=/srv/www/bin ./scripts/publish.sh`; `ALLOW_DIRTY=1` skips the +clean-tree check for a preview. The installers are static and need no +regeneration. Builds are per target triple, so these must be installed: `x86_64-unknown-linux-gnu`, `i686-unknown-linux-gnu` and `x86_64-pc-windows-gnu`, plus the mingw linker for the Windows build. Client builds always pass `--no-default-features`, which drops the server's OpenSSL -dependency; the client is rustls-only. The GUI needs Python 3 at publish time -for the installer templates in `scripts/installers/`. +dependency; the client is rustls-only. Cargo itself cannot produce these names: it always writes `target//release/`. The script renames after each build. 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 @@ --[.exe] - * bin/gui/zagan-bet-gui----install.sh - * bin/gui/zagan-bet-gui----install.ps1 - * bin/client/zagan-bet-client---[.exe] - * bin/server/zagan-bet-server---[.exe] - * - * is MAJOR.MINOR.PATCH, is linux|windows|macos and - * is x86_64|i686|aarch64. Example: - * - * bin/gui/zagan-bet-gui-4.3.0-linux-x86_64 - * bin/client/zagan-bet-client-4.3.0-linux-x86_64 - * - * SHA-256 values are cached in a JSON file keyed on size and mtime, so a file - * is only re-read when it changes. The cache lives in the system temp - * directory by default (the web root is often not writable by the PHP user); - * set ZAGAN_HASH_CACHE to put it somewhere else. + * Lists every published app, terminal client and server, with version, size + * and SHA-256. The installers are stable links that ask latest.php for the + * newest build. The GPL source link points at the public git repository. No + * database, no JavaScript, no dependencies. */ -const KINDS = [ - 'gui' => [ - 'title' => 'Desktop app', - 'hint' => 'Windows 10 or newer, or a Linux desktop. No terminal needed.', - ], - 'client' => [ - 'title' => 'Client', - 'hint' => 'Needs a terminal of at least 80×24 cells.', - ], - 'server' => [ - 'title' => 'Server', - 'hint' => 'Generates its own TLS identity on first run.', - ], -]; +$scan = scan_releases(); +$releases = $scan['releases']; +$newest = $scan['newest_mtime']; -const PLATFORMS = [ - 'linux' => 'Linux', - 'windows' => 'Windows', - 'macos' => 'macOS', -]; +$base = rtrim(str_replace('\\', '/', dirname($_SERVER['SCRIPT_NAME'] ?? '/')), '/'); +if ($base === '.' || $base === '/') { + $base = ''; +} -const ARCHES = [ - 'x86_64' => '64-bit', - 'i686' => '32-bit', - 'aarch64' => 'ARM64', -]; +foreach ($releases as $kind => &$found) { + foreach ($found as &$release) { + $release['href'] = $base . '/bin/' . $kind . '/' . rawurlencode($release['file']); + } +} +unset($found, $release); -// Newest version first, then 64-bit builds before 32-bit. -const PLATFORM_ORDER = ['linux' => 0, 'windows' => 1, 'macos' => 2]; -const ARCH_ORDER = ['x86_64' => 0, 'aarch64' => 1, 'i686' => 2]; +$all = array_merge($releases['gui'], $releases['client'], $releases['server']); function e(?string $value): string { @@ -74,220 +45,6 @@ function human_size(int $bytes): string } return $bytes . ' B'; } - -/** - * Hash to use for a published file, reusing the cached value when the file has - * not changed since it was hashed. - * - * A cached hash is valid only when the size and mtime still match and the hash - * was taken at or after the file's mtime, so a restored or touched file is - * re-read. - * - * @param array{files:array} $cache - */ -function cached_hash( - string $key, - string $path, - int $size, - int $mtime, - array &$cache, - bool &$dirty -): ?string { - $entry = $cache['files'][$key] ?? null; - if ( - is_array($entry) - && ($entry['size'] ?? null) === $size - && ($entry['mtime'] ?? null) === $mtime - && (int) ($entry['hashed_at'] ?? 0) >= $mtime - && isset($entry['sha256']) - && is_string($entry['sha256']) - && preg_match('/\A[0-9a-f]{64}\z/', $entry['sha256']) === 1 - ) { - return $entry['sha256']; - } - - $hash = hash_file('sha256', $path); - if ($hash === false) { - return null; - } - - $cache['files'][$key] = [ - 'size' => $size, - 'mtime' => $mtime, - 'sha256' => $hash, - 'hashed_at' => time(), - ]; - $dirty = true; - - return $hash; -} - -/** @return array{files:array} */ -function load_hash_cache(string $path): array -{ - $raw = @file_get_contents($path); - if ($raw === false || $raw === '') { - return ['files' => []]; - } - $decoded = json_decode($raw, true); - if (!is_array($decoded) || !isset($decoded['files']) || !is_array($decoded['files'])) { - return ['files' => []]; - } - - return ['files' => $decoded['files']]; -} - -/** - * Best effort: a read-only web root, a full disk or a racing writer must never - * break the page, so failures are ignored and the next request recomputes. - * - * @param array{files:array} $cache - */ -function save_hash_cache(string $path, array $cache): void -{ - $json = json_encode($cache, JSON_UNESCAPED_SLASHES); - if ($json === false) { - return; - } - $directory = dirname($path); - $temporary = @tempnam($directory, '.zagan-hashes-'); - if ($temporary === false) { - return; - } - if (@file_put_contents($temporary, $json, LOCK_EX) === false || !@rename($temporary, $path)) { - @unlink($temporary); - } -} - -/** - * @return array{version:string,platform:string,arch:string,file:string,size:int,mtime:int,sha256:string}|null - */ -function read_release( - string $kind, - string $directory, - string $entry, - array &$cache, - bool &$dirty -): ?array { - $pattern = '/^zagan-bet-' . preg_quote($kind, '/') - . '-v?(\d+\.\d+\.\d+)-([a-z]+)-([a-z0-9_]+)(-install\.sh|-install\.ps1|\.exe)?$/i'; - if (preg_match($pattern, $entry, $matches) !== 1) { - return null; - } - - $realDirectory = realpath($directory); - $real = realpath($directory . '/' . $entry); - if ($realDirectory === false || $real === false) { - return null; - } - // Never serve anything that resolves outside the published directory. - if (!str_starts_with($real, $realDirectory . DIRECTORY_SEPARATOR)) { - return null; - } - if (!is_file($real)) { - return null; - } - - $size = filesize($real); - $mtime = filemtime($real); - if ($size === false || $mtime === false) { - return null; - } - - $hash = cached_hash($kind . '/' . $entry, $real, $size, $mtime, $cache, $dirty); - if ($hash === null) { - return null; - } - - $suffix = strtolower($matches[4] ?? ''); - $variant = str_starts_with($suffix, '-install.') ? 'installer' : 'binary'; - - return [ - 'version' => $matches[1], - 'platform' => strtolower($matches[2]), - 'arch' => strtolower($matches[3]), - 'variant' => $variant, - 'file' => $entry, - 'size' => $size, - 'mtime' => $mtime, - 'sha256' => $hash, - ]; -} - -$base = rtrim(str_replace('\\', '/', dirname($_SERVER['SCRIPT_NAME'] ?? '/')), '/'); -if ($base === '.' || $base === '/') { - $base = ''; -} - -$cachePath = getenv('ZAGAN_HASH_CACHE'); -if (!is_string($cachePath) || $cachePath === '') { - $cachePath = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR) - . DIRECTORY_SEPARATOR . 'zagan-bet-hashes-' - . substr(hash('sha256', __DIR__), 0, 16) . '.json'; -} -$cache = load_hash_cache($cachePath); -$cacheDirty = false; -$seen = []; - -$releases = []; -$newest = 0; - -foreach (KINDS as $kind => $meta) { - $found = []; - $directory = __DIR__ . '/bin/' . $kind; - - if (is_dir($directory)) { - $entries = scandir($directory); - if ($entries !== false) { - foreach ($entries as $entry) { - if ($entry === '.' || $entry === '..') { - continue; - } - $release = read_release($kind, $directory, $entry, $cache, $cacheDirty); - if ($release === null) { - continue; - } - $seen[$kind . '/' . $entry] = true; - $release['href'] = $base . '/bin/' . $kind . '/' . rawurlencode($release['file']); - $found[] = $release; - $newest = max($newest, $release['mtime']); - } - } - } - - usort($found, static function (array $a, array $b): int { - $byVersion = version_compare($b['version'], $a['version']); - if ($byVersion !== 0) { - return $byVersion; - } - $byPlatform = (PLATFORM_ORDER[$a['platform']] ?? 9) <=> (PLATFORM_ORDER[$b['platform']] ?? 9); - if ($byPlatform !== 0) { - return $byPlatform; - } - $byArch = (ARCH_ORDER[$a['arch']] ?? 9) <=> (ARCH_ORDER[$b['arch']] ?? 9); - if ($byArch !== 0) { - return $byArch; - } - $byVariant = ($a['variant'] === 'installer' ? 1 : 0) <=> ($b['variant'] === 'installer' ? 1 : 0); - if ($byVariant !== 0) { - return $byVariant; - } - return strcmp($a['file'], $b['file']); - }); - - $releases[$kind] = $found; -} - -// Drop cache entries for files that are no longer published. -foreach (array_keys($cache['files']) as $key) { - if (!isset($seen[$key])) { - unset($cache['files'][$key]); - $cacheDirty = true; - } -} -if ($cacheDirty) { - save_hash_cache($cachePath, $cache); -} ?> @@ -308,24 +65,20 @@ if ($cacheDirty) {

Desktop app

Windows 10 or newer, or a Linux desktop. No terminal needed.

- $r['variant'] === 'binary')); ?> - $r['variant'] === 'installer')); ?> - +

Nothing published yet.

- - +

Installers

-

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.

+

Downloads the newest build, checks its SHA-256 and installs it for your user.

@@ -353,7 +106,6 @@ if ($cacheDirty) { -

SHA-256

On Linux use sha256sum <file>. On Windows use Get-FileHash <file>.

@@ -372,11 +124,11 @@ echo e($sums);

Running it

Desktop app on Linux:

-
chmod +x zagan-bet-gui-*-linux-x86_64-install.sh
-./zagan-bet-gui-*-linux-x86_64-install.sh
+
chmod +x install.sh
+./install.sh

Desktop app on Windows (PowerShell):

-
powershell -ExecutionPolicy Bypass -File .\zagan-bet-gui-*-windows-x86_64-install.ps1
-

The installers verify the SHA-256 before installing. You can also run the downloaded app directly.

+
powershell -ExecutionPolicy Bypass -File .\install.ps1
+

The installers fetch the newest build, verify its SHA-256, install it for your user and add a launcher.

Terminal client:

chmod +x <downloaded-client>
 ./<downloaded-client>
diff --git a/web/install.ps1 b/web/install.ps1 new file mode 100644 index 0000000..92eef83 --- /dev/null +++ b/web/install.ps1 @@ -0,0 +1,108 @@ +# Zagan-Bet installer for Windows. +# +# Asks the site which build is newest, verifies its SHA-256, installs it for +# the current user and adds a Start-menu shortcut. Point it at a staging site +# with -Site http://127.0.0.1:8899; add -NoPause for automation. +# +# Run it with: +# powershell -ExecutionPolicy Bypass -File .\install.ps1 +param( + [string]$Site = "https://zagan.2137697.xyz", + [switch]$NoPause +) + +$ErrorActionPreference = "Stop" + +function Finish([int]$Code) { + if (-not $NoPause) { + Read-Host "Press Enter to close" | Out-Null + } + exit $Code +} + +function Stop-Install([string]$Message) { + Write-Host "install: $Message" -ForegroundColor Red + Finish 1 +} + +try { + $latest = (Invoke-WebRequest -Uri "$Site/latest.php" -UseBasicParsing).Content +} catch { + Stop-Install "could not read $Site/latest.php: $_" +} + +$line = ($latest -split "`r?`n") | + Where-Object { $_ -match '^gui windows x86_64 ' } | + Select-Object -First 1 +if (-not $line) { + Stop-Install "no Windows build is published yet" +} + +$parts = $line.Trim() -split '\s+' +if ($parts.Count -lt 6) { + Stop-Install "the latest-build file is malformed" +} +$Version = $parts[3] +$Binary = $parts[4] +$Sha256 = $parts[5] +$Url = "$Site/bin/gui/$Binary" + +Write-Host "Newest Zagan-Bet is $Version." + +$here = Split-Path -Parent $MyInvocation.MyCommand.Path +$candidates = @( + (Join-Path $here $Binary), + (Join-Path (Get-Location) $Binary), + (Join-Path $env:USERPROFILE "Downloads\$Binary") +) +$sourceFile = $null +foreach ($candidate in $candidates) { + if (Test-Path -LiteralPath $candidate) { + $sourceFile = $candidate + break + } +} + +$temporary = $null +if (-not $sourceFile) { + $temporary = Join-Path ([System.IO.Path]::GetTempPath()) $Binary + Write-Host "Downloading $Binary..." + try { + Invoke-WebRequest -Uri $Url -OutFile $temporary -UseBasicParsing + } catch { + Stop-Install "download failed: $_" + } + $sourceFile = $temporary +} + +try { + $actual = (Get-FileHash -LiteralPath $sourceFile -Algorithm SHA256).Hash.ToLowerInvariant() + if ($actual -ne $Sha256.ToLowerInvariant()) { + Stop-Install "checksum mismatch: expected $Sha256, got $actual" + } + + $installDir = Join-Path $env:LOCALAPPDATA "Programs\Zagan-Bet" + New-Item -ItemType Directory -Force -Path $installDir | Out-Null + $target = Join-Path $installDir "zagan-bet-gui.exe" + Copy-Item -LiteralPath $sourceFile -Destination $target -Force + + $startMenu = Join-Path $env:APPDATA "Microsoft\Windows\Start Menu\Programs" + New-Item -ItemType Directory -Force -Path $startMenu | Out-Null + $shortcutPath = Join-Path $startMenu "Zagan-Bet.lnk" + $shell = New-Object -ComObject WScript.Shell + $shortcut = $shell.CreateShortcut($shortcutPath) + $shortcut.TargetPath = $target + $shortcut.WorkingDirectory = $installDir + $shortcut.IconLocation = "$target,0" + $shortcut.Description = "Slots, roulette, blackjack and crash" + $shortcut.Save() + + Write-Host "Installed Zagan-Bet $Version to $target" + Write-Host "A Start-menu shortcut was created." +} finally { + if ($temporary -and (Test-Path -LiteralPath $temporary)) { + Remove-Item -LiteralPath $temporary -Force + } +} + +Finish 0 diff --git a/web/install.sh b/web/install.sh new file mode 100644 index 0000000..998b262 --- /dev/null +++ b/web/install.sh @@ -0,0 +1,98 @@ +#!/usr/bin/env bash +# Zagan-Bet installer for Linux. +# +# Asks the site which build is newest, verifies its SHA-256, installs it for +# the current user and adds a launcher. Point it at a staging site with +# ZAGAN_SITE=http://127.0.0.1:8899. +set -euo pipefail + +SITE=${ZAGAN_SITE:-https://zagan.2137697.xyz} +PLATFORM=linux +ARCH=x86_64 + +say() { printf '%s\n' "$*"; } +die() { printf 'install: %s\n' "$*" >&2; exit 1; } + +command -v curl >/dev/null 2>&1 || die "curl is required" +command -v sha256sum >/dev/null 2>&1 || die "sha256sum is required" + +latest=$(curl -fsSL --max-time 30 "$SITE/latest.php") \ + || die "could not read $SITE/latest.php" +line=$(printf '%s\n' "$latest" \ + | awk -v p="$PLATFORM" -v a="$ARCH" '$1 == "gui" && $2 == p && $3 == a { print; exit }') +[ -n "$line" ] || die "no Linux build is published yet" + +version=$(printf '%s\n' "$line" | awk '{print $4}') +binary=$(printf '%s\n' "$line" | awk '{print $5}') +sha=$(printf '%s\n' "$line" | awk '{print $6}') +[ -n "$version" ] && [ -n "$binary" ] && [ -n "$sha" ] \ + || die "the latest-build file is malformed" + +say "Newest Zagan-Bet is $version." + +here=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +source_file="" +for candidate in "$here/$binary" "./$binary" "$HOME/Downloads/$binary"; do + if [ -f "$candidate" ]; then + source_file=$candidate + break + fi +done + +cleanup="" +if [ -z "$source_file" ]; then + cleanup=$(mktemp) || die "cannot create a temporary file" + say "Downloading $binary..." + curl -fsSL --max-time 300 "$SITE/bin/gui/$binary" -o "$cleanup" \ + || die "download failed" + source_file=$cleanup +fi +trap 'if [ -n "$cleanup" ]; then rm -f "$cleanup"; fi' EXIT + +actual=$(sha256sum "$source_file" | awk '{print $1}') +[ "$actual" = "$sha" ] \ + || die "checksum mismatch: expected $sha, got $actual" + +bin_dir="${XDG_BIN_HOME:-$HOME/.local/bin}" +data_dir="${XDG_DATA_HOME:-$HOME/.local/share}" +app="$bin_dir/zagan-bet-gui" +desktop_dir="$data_dir/applications" +icon_dir="$data_dir/icons/hicolor/scalable/apps" + +mkdir -p "$bin_dir" "$desktop_dir" "$icon_dir" +install -m 0755 "$source_file" "$app" + +cat > "$icon_dir/zagan-bet.svg" <<'ICON' + + + + + +ICON + +cat > "$desktop_dir/zagan-bet.desktop" </dev/null 2>&1; then + update-desktop-database "$desktop_dir" >/dev/null 2>&1 || true +fi +if command -v gtk-update-icon-cache >/dev/null 2>&1; then + gtk-update-icon-cache -q "$data_dir/icons/hicolor" >/dev/null 2>&1 || true +fi + +say "Installed Zagan-Bet $version to $app" +say "A launcher was added to your applications menu." +case ":$PATH:" in + *":$bin_dir:"*) ;; + *) say "Add $bin_dir to PATH to start it as 'zagan-bet-gui'." ;; +esac diff --git a/web/latest.php b/web/latest.php new file mode 100644 index 0000000..3e7368c --- /dev/null +++ b/web/latest.php @@ -0,0 +1,50 @@ + + * gui windows x86_64 4.4.0 zagan-bet-gui-4.4.0-windows-x86_64.exe + * client ... + * server ... + * + * Only the newest version is listed. The installers verify the SHA-256 they + * read here against the bytes they download. + */ + +require __DIR__ . '/releases.php'; + +$scan = scan_releases(); +$all = array_merge( + $scan['releases']['gui'], + $scan['releases']['client'], + $scan['releases']['server'] +); + +$newest = '0.0.0'; +foreach ($all as $release) { + if (version_compare($release['version'], $newest, '>')) { + $newest = $release['version']; + } +} + +header('Content-Type: text/plain; charset=utf-8'); +header('Cache-Control: no-cache'); + +echo 'version ' . $newest . "\n"; +foreach ($all as $release) { + if ($release['version'] !== $newest) { + continue; + } + echo $release['kind'] . ' ' + . $release['platform'] . ' ' + . $release['arch'] . ' ' + . $release['version'] . ' ' + . $release['file'] . ' ' + . $release['sha256'] . "\n"; +} 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 @@ +/ (anything else is ignored): + * + * bin/gui/zagan-bet-gui---[.exe] + * bin/client/zagan-bet-client---[.exe] + * bin/server/zagan-bet-server---[.exe] + * + * is MAJOR.MINOR.PATCH, is linux|windows|macos and + * is x86_64|i686|aarch64. + * + * SHA-256 values are cached in a JSON file keyed on size and mtime, so a file + * is only re-read when it changes. The cache lives in the system temp + * directory by default (the web root is often not writable by the PHP user); + * set ZAGAN_HASH_CACHE to put it somewhere else. + */ + +const KINDS = [ + 'gui' => [ + 'title' => 'Desktop app', + 'hint' => 'Windows 10 or newer, or a Linux desktop. No terminal needed.', + ], + 'client' => [ + 'title' => 'Client', + 'hint' => 'Needs a terminal of at least 80×24 cells.', + ], + 'server' => [ + 'title' => 'Server', + 'hint' => 'Generates its own TLS identity on first run.', + ], +]; + +const PLATFORMS = [ + 'linux' => 'Linux', + 'windows' => 'Windows', + 'macos' => 'macOS', +]; + +const ARCHES = [ + 'x86_64' => '64-bit', + 'i686' => '32-bit', + 'aarch64' => 'ARM64', +]; + +// Newest version first, then 64-bit builds before 32-bit. +const PLATFORM_ORDER = ['linux' => 0, 'windows' => 1, 'macos' => 2]; +const ARCH_ORDER = ['x86_64' => 0, 'aarch64' => 1, 'i686' => 2]; + +/** + * Hash to use for a published file, reusing the cached value when the file has + * not changed since it was hashed. + * + * A cached hash is valid only when the size and mtime still match and the hash + * was taken at or after the file's mtime, so a restored or touched file is + * re-read. + * + * @param array{files:array} $cache + */ +function cached_hash( + string $key, + string $path, + int $size, + int $mtime, + array &$cache, + bool &$dirty +): ?string { + $entry = $cache['files'][$key] ?? null; + if ( + is_array($entry) + && ($entry['size'] ?? null) === $size + && ($entry['mtime'] ?? null) === $mtime + && (int) ($entry['hashed_at'] ?? 0) >= $mtime + && isset($entry['sha256']) + && is_string($entry['sha256']) + && preg_match('/\A[0-9a-f]{64}\z/', $entry['sha256']) === 1 + ) { + return $entry['sha256']; + } + + $hash = hash_file('sha256', $path); + if ($hash === false) { + return null; + } + + $cache['files'][$key] = [ + 'size' => $size, + 'mtime' => $mtime, + 'sha256' => $hash, + 'hashed_at' => time(), + ]; + $dirty = true; + + return $hash; +} + +/** @return array{files:array} */ +function load_hash_cache(string $path): array +{ + $raw = @file_get_contents($path); + if ($raw === false || $raw === '') { + return ['files' => []]; + } + $decoded = json_decode($raw, true); + if (!is_array($decoded) || !isset($decoded['files']) || !is_array($decoded['files'])) { + return ['files' => []]; + } + + return ['files' => $decoded['files']]; +} + +/** + * Best effort: a read-only web root, a full disk or a racing writer must never + * break the page, so failures are ignored and the next request recomputes. + * + * @param array{files:array} $cache + */ +function save_hash_cache(string $path, array $cache): void +{ + $json = json_encode($cache, JSON_UNESCAPED_SLASHES); + if ($json === false) { + return; + } + $directory = dirname($path); + $temporary = @tempnam($directory, '.zagan-hashes-'); + if ($temporary === false) { + return; + } + if (@file_put_contents($temporary, $json, LOCK_EX) === false || !@rename($temporary, $path)) { + @unlink($temporary); + } +} + +/** + * @return array{kind:string,version:string,platform:string,arch:string,file:string,size:int,mtime:int,sha256:string}|null + */ +function read_release( + string $kind, + string $directory, + string $entry, + array &$cache, + bool &$dirty +): ?array { + $pattern = '/^zagan-bet-' . preg_quote($kind, '/') + . '-v?(\d+\.\d+\.\d+)-([a-z]+)-([a-z0-9_]+)(\.exe)?$/i'; + if (preg_match($pattern, $entry, $matches) !== 1) { + return null; + } + + $realDirectory = realpath($directory); + $real = realpath($directory . '/' . $entry); + if ($realDirectory === false || $real === false) { + return null; + } + // Never serve anything that resolves outside the published directory. + if (!str_starts_with($real, $realDirectory . DIRECTORY_SEPARATOR)) { + return null; + } + if (!is_file($real)) { + return null; + } + + $size = filesize($real); + $mtime = filemtime($real); + if ($size === false || $mtime === false) { + return null; + } + + $hash = cached_hash($kind . '/' . $entry, $real, $size, $mtime, $cache, $dirty); + if ($hash === null) { + return null; + } + + return [ + 'kind' => $kind, + 'version' => $matches[1], + 'platform' => strtolower($matches[2]), + 'arch' => strtolower($matches[3]), + 'file' => $entry, + 'size' => $size, + 'mtime' => $mtime, + 'sha256' => $hash, + ]; +} + +/** + * Scan every kind and return its releases, newest version first. + * + * @return array{releases:array>,newest_mtime:int} + */ +function scan_releases(): array +{ + $cachePath = getenv('ZAGAN_HASH_CACHE'); + if (!is_string($cachePath) || $cachePath === '') { + $cachePath = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR) + . DIRECTORY_SEPARATOR . 'zagan-bet-hashes-' + . substr(hash('sha256', __DIR__), 0, 16) . '.json'; + } + $cache = load_hash_cache($cachePath); + $dirty = false; + $seen = []; + + $releases = []; + $newestMtime = 0; + + foreach (KINDS as $kind => $meta) { + $found = []; + $directory = __DIR__ . '/bin/' . $kind; + + if (is_dir($directory)) { + $entries = scandir($directory); + if ($entries !== false) { + foreach ($entries as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + $release = read_release($kind, $directory, $entry, $cache, $dirty); + if ($release === null) { + continue; + } + $seen[$kind . '/' . $entry] = true; + $found[] = $release; + $newestMtime = max($newestMtime, $release['mtime']); + } + } + } + + usort($found, static function (array $a, array $b): int { + $byVersion = version_compare($b['version'], $a['version']); + if ($byVersion !== 0) { + return $byVersion; + } + $byPlatform = (PLATFORM_ORDER[$a['platform']] ?? 9) <=> (PLATFORM_ORDER[$b['platform']] ?? 9); + if ($byPlatform !== 0) { + return $byPlatform; + } + $byArch = (ARCH_ORDER[$a['arch']] ?? 9) <=> (ARCH_ORDER[$b['arch']] ?? 9); + if ($byArch !== 0) { + return $byArch; + } + return strcmp($a['file'], $b['file']); + }); + + $releases[$kind] = $found; + } + + // Drop cache entries for files that are no longer published. + foreach (array_keys($cache['files']) as $key) { + if (!isset($seen[$key])) { + unset($cache['files'][$key]); + $dirty = true; + } + } + if ($dirty) { + save_hash_cache($cachePath, $cache); + } + + return ['releases' => $releases, 'newest_mtime' => $newestMtime]; +} -- cgit v1.2.3