Josef Straßl 1 месяц назад
Родитель
Сommit
86f4a2f579

+ 1 - 0
.gitignore

@@ -2,4 +2,5 @@ config.php
 .codex
 data/reservations.php
 data/logs/
+data/updates/
 data/orders.json

+ 81 - 0
admin/settings.php

@@ -1,6 +1,79 @@
 <?php
 require_once __DIR__ . "/../config.php";
 require_once __DIR__ . "/../includes/functions.php";
+require_once __DIR__ . "/../includes/version.php";
+
+if (!defined("UPDATE_MANIFEST_URL")) {
+    define("UPDATE_MANIFEST_URL", "");
+}
+
+function settingsUpdaterVersionCompareValue(string $version): string
+{
+    return ltrim(trim($version), "vV");
+}
+
+function settingsGetUpdaterStatus(): array
+{
+    $manifestUrl = trim((string) UPDATE_MANIFEST_URL);
+    if ($manifestUrl === "") {
+        return [
+            "label" => "Update-Ziel ist nicht konfiguriert.",
+            "available" => false,
+            "version" => "",
+        ];
+    }
+
+    $context = stream_context_create([
+        "http" => [
+            "method" => "GET",
+            "timeout" => 3,
+            "ignore_errors" => true,
+            "header" => "User-Agent: PSA-Orderform-Settings/" . APP_VERSION . "\r\n",
+        ],
+    ]);
+
+    $body = @file_get_contents($manifestUrl, false, $context);
+    if ($body === false) {
+        return [
+            "label" => "Update-Status konnte nicht geladen werden.",
+            "available" => false,
+            "version" => "",
+        ];
+    }
+
+    $manifest = json_decode($body, true);
+    if (!is_array($manifest)) {
+        return [
+            "label" => "Update-Status ist ungültig.",
+            "available" => false,
+            "version" => "",
+        ];
+    }
+
+    $version = trim((string) ($manifest["version"] ?? $manifest["latest"] ?? ""));
+    if (!preg_match('/^v\d+\.\d+\.\d+$/', $version)) {
+        return [
+            "label" => "Update-Version ist ungültig.",
+            "available" => false,
+            "version" => "",
+        ];
+    }
+
+    $available =
+        version_compare(
+            settingsUpdaterVersionCompareValue($version),
+            settingsUpdaterVersionCompareValue(APP_VERSION),
+            ">",
+        );
+
+    return [
+        "label" => $available
+            ? "Update verfügbar: " . $version
+            : "Kein Update verfügbar.",
+        "available" => $available,
+        "version" => $version,
+    ];
+}
 
 if (empty($_SESSION['admin_logged_in'])) {
     header("Location: login.php");
@@ -36,6 +109,7 @@ if ($_SERVER['REQUEST_METHOD'] === "POST" && isset($_POST['save_settings'])) {
 }
 
 $settings = getSystemSettings();
+$updaterStatus = settingsGetUpdaterStatus();
 
 $bodyClass = "admin-page";
 include __DIR__ . "/../includes/header.php";
@@ -79,4 +153,11 @@ include __DIR__ . "/../includes/header.php";
     </form>
 </div>
 
+<div class="panel panel-lg mt-4">
+    <h3>Updater</h3>
+    <p>Installierte Version: <?php echo escape(APP_VERSION); ?></p>
+    <p>Update-Status: <?php echo escape($updaterStatus["label"]); ?></p>
+    <p><a href="updater.php" class="btn btn-secondary">Updater öffnen</a></p>
+</div>
+
 <?php include __DIR__ . "/../includes/footer.php"; ?>

+ 465 - 0
admin/updater.php

@@ -0,0 +1,465 @@
+<?php
+
+require_once __DIR__ . "/../config.php";
+require_once __DIR__ . "/../includes/version.php";
+
+if (empty($_SESSION["admin_logged_in"])) {
+    header("Location: login.php");
+    exit();
+}
+
+if (!defined("UPDATE_MANIFEST_URL")) {
+    define("UPDATE_MANIFEST_URL", "");
+}
+if (!defined("UPDATE_WORK_DIR")) {
+    define("UPDATE_WORK_DIR", DATA_DIR . "updates/work/");
+}
+if (!defined("UPDATE_BACKUP_DIR")) {
+    define("UPDATE_BACKUP_DIR", DATA_DIR . "updates/backups/");
+}
+
+$appRoot = realpath(__DIR__ . "/..");
+$messages = [];
+$errors = [];
+
+function updaterEscape($value): string
+{
+    return htmlspecialchars((string) $value, ENT_QUOTES, "UTF-8");
+}
+
+function updaterCsrfToken(): string
+{
+    if (empty($_SESSION["updater_csrf_token"])) {
+        $_SESSION["updater_csrf_token"] = bin2hex(random_bytes(32));
+    }
+
+    return $_SESSION["updater_csrf_token"];
+}
+
+function updaterValidateCsrfToken(string $token): bool
+{
+    return !empty($_SESSION["updater_csrf_token"]) &&
+        hash_equals($_SESSION["updater_csrf_token"], $token);
+}
+
+function updaterVersionToCompare(string $version): string
+{
+    return ltrim(trim($version), "vV");
+}
+
+function updaterIsVersion(string $version): bool
+{
+    return preg_match('/^v\d+\.\d+\.\d+$/', $version) === 1;
+}
+
+function updaterEnsureDirectory(string $dir): void
+{
+    if (!is_dir($dir) && !mkdir($dir, 02775, true) && !is_dir($dir)) {
+        throw new RuntimeException("Directory cannot be created: " . $dir);
+    }
+
+    @chmod($dir, 02775);
+}
+
+function updaterRemoveDirectory(string $dir): void
+{
+    if (!is_dir($dir)) {
+        return;
+    }
+
+    $items = new RecursiveIteratorIterator(
+        new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS),
+        RecursiveIteratorIterator::CHILD_FIRST,
+    );
+
+    foreach ($items as $item) {
+        if ($item->isDir()) {
+            rmdir($item->getPathname());
+        } else {
+            unlink($item->getPathname());
+        }
+    }
+
+    rmdir($dir);
+}
+
+function updaterHttpGet(string $url, int $timeout = 30): string
+{
+    if (!filter_var($url, FILTER_VALIDATE_URL)) {
+        throw new RuntimeException("Invalid URL: " . $url);
+    }
+
+    $context = stream_context_create([
+        "http" => [
+            "method" => "GET",
+            "timeout" => $timeout,
+            "ignore_errors" => true,
+            "header" => "User-Agent: PSA-Orderform-Updater/" . APP_VERSION . "\r\n",
+        ],
+    ]);
+
+    $body = @file_get_contents($url, false, $context);
+    $status = 0;
+    $responseHeaders = function_exists("http_get_last_response_headers")
+        ? http_get_last_response_headers()
+        : [];
+    if (is_array($responseHeaders)) {
+        foreach ($responseHeaders as $header) {
+            if (preg_match('/^HTTP\/\S+\s+(\d+)/', $header, $matches)) {
+                $status = (int) $matches[1];
+            }
+        }
+    }
+
+    if ($body === false || ($status >= 400 && $status < 600)) {
+        throw new RuntimeException(
+            "HTTP request failed" . ($status > 0 ? " with status " . $status : "") . ".",
+        );
+    }
+
+    return $body;
+}
+
+function updaterFetchManifest(): array
+{
+    $url = trim((string) UPDATE_MANIFEST_URL);
+    if ($url === "") {
+        throw new RuntimeException("UPDATE_MANIFEST_URL is not configured.");
+    }
+
+    $body = updaterHttpGet($url, 15);
+    $manifest = json_decode($body, true);
+    if (!is_array($manifest)) {
+        throw new RuntimeException("Manifest response is not valid JSON.");
+    }
+
+    $version = trim((string) ($manifest["version"] ?? $manifest["latest"] ?? ""));
+    $packageUrl = trim((string) ($manifest["package_url"] ?? ""));
+    $sha256 = strtolower(trim((string) ($manifest["sha256"] ?? "")));
+    $size = isset($manifest["size"]) ? (int) $manifest["size"] : 0;
+    $publishedAt = trim((string) ($manifest["published_at"] ?? ""));
+
+    if (!updaterIsVersion($version)) {
+        throw new RuntimeException("Manifest version is invalid.");
+    }
+    if (!filter_var($packageUrl, FILTER_VALIDATE_URL)) {
+        throw new RuntimeException("Manifest package URL is invalid.");
+    }
+    if (!preg_match('/^[a-f0-9]{64}$/', $sha256)) {
+        throw new RuntimeException("Manifest checksum is invalid.");
+    }
+
+    return [
+        "version" => $version,
+        "package_url" => $packageUrl,
+        "sha256" => $sha256,
+        "size" => $size,
+        "published_at" => $publishedAt,
+    ];
+}
+
+function updaterDownloadPackage(array $manifest, string $targetFile): void
+{
+    updaterEnsureDirectory(dirname($targetFile));
+
+    $data = updaterHttpGet($manifest["package_url"], 120);
+    if ($data === "") {
+        throw new RuntimeException("Downloaded package is empty.");
+    }
+
+    if (file_put_contents($targetFile, $data, LOCK_EX) === false) {
+        throw new RuntimeException("Downloaded package cannot be written.");
+    }
+
+    if ($manifest["size"] > 0 && filesize($targetFile) !== $manifest["size"]) {
+        unlink($targetFile);
+        throw new RuntimeException("Downloaded package size mismatch.");
+    }
+
+    $actualHash = strtolower(hash_file("sha256", $targetFile) ?: "");
+    if ($actualHash !== $manifest["sha256"]) {
+        unlink($targetFile);
+        throw new RuntimeException("Package checksum mismatch.");
+    }
+}
+
+function updaterValidateZipEntry(string $entry): bool
+{
+    $entry = str_replace("\\", "/", $entry);
+    $normalized = trim($entry, "/");
+
+    if (
+        $normalized === "" ||
+        str_contains($entry, "\0") ||
+        str_starts_with($entry, "/") ||
+        preg_match('/^[A-Za-z]:\//', $entry)
+    ) {
+        return false;
+    }
+
+    foreach (explode("/", $normalized) as $segment) {
+        if ($segment === "" || $segment === "." || $segment === "..") {
+            return false;
+        }
+    }
+
+    return true;
+}
+
+function updaterExtractPackage(string $zipFile, string $stageDir): void
+{
+    if (!class_exists("ZipArchive")) {
+        throw new RuntimeException("PHP ZipArchive extension is not available.");
+    }
+
+    updaterRemoveDirectory($stageDir);
+    updaterEnsureDirectory($stageDir);
+
+    $zip = new ZipArchive();
+    if ($zip->open($zipFile) !== true) {
+        throw new RuntimeException("Downloaded package is not a readable ZIP file.");
+    }
+
+    $hasAppFile = false;
+    for ($i = 0; $i < $zip->numFiles; $i++) {
+        $name = (string) $zip->getNameIndex($i);
+        if (!updaterValidateZipEntry($name)) {
+            $zip->close();
+            throw new RuntimeException("ZIP contains an unsafe path: " . $name);
+        }
+
+        if (
+            $name === "index.php" ||
+            str_starts_with($name, "admin/") ||
+            str_starts_with($name, "includes/")
+        ) {
+            $hasAppFile = true;
+        }
+    }
+
+    if (!$hasAppFile) {
+        $zip->close();
+        throw new RuntimeException("ZIP does not look like an app-root release package.");
+    }
+
+    if (!$zip->extractTo($stageDir)) {
+        $zip->close();
+        throw new RuntimeException("ZIP package cannot be extracted.");
+    }
+
+    $zip->close();
+}
+
+function updaterRelativePath(string $path, string $baseDir): string
+{
+    return ltrim(str_replace("\\", "/", substr($path, strlen($baseDir))), "/");
+}
+
+function updaterShouldSkipPath(string $relativePath): bool
+{
+    $relativePath = trim(str_replace("\\", "/", $relativePath), "/");
+
+    return $relativePath === "" ||
+        $relativePath === "config.php" ||
+        $relativePath === "data" ||
+        str_starts_with($relativePath, "data/") ||
+        $relativePath === ".git" ||
+        str_starts_with($relativePath, ".git/");
+}
+
+function updaterCopyWithBackup(string $stageDir, string $appRoot, string $backupDir): array
+{
+    updaterEnsureDirectory($backupDir);
+
+    $copied = 0;
+    $backedUp = 0;
+    $skipped = 0;
+
+    $items = new RecursiveIteratorIterator(
+        new RecursiveDirectoryIterator($stageDir, FilesystemIterator::SKIP_DOTS),
+        RecursiveIteratorIterator::SELF_FIRST,
+    );
+
+    foreach ($items as $item) {
+        $relativePath = updaterRelativePath($item->getPathname(), $stageDir);
+        if (updaterShouldSkipPath($relativePath)) {
+            $skipped++;
+            continue;
+        }
+
+        $targetPath = $appRoot . DIRECTORY_SEPARATOR . $relativePath;
+
+        if ($item->isDir()) {
+            updaterEnsureDirectory($targetPath);
+            continue;
+        }
+
+        updaterEnsureDirectory(dirname($targetPath));
+
+        if (file_exists($targetPath)) {
+            $backupPath = $backupDir . DIRECTORY_SEPARATOR . $relativePath;
+            updaterEnsureDirectory(dirname($backupPath));
+            if (!copy($targetPath, $backupPath)) {
+                throw new RuntimeException("Cannot back up file: " . $relativePath);
+            }
+            $backedUp++;
+        }
+
+        if (!copy($item->getPathname(), $targetPath)) {
+            throw new RuntimeException("Cannot deploy file: " . $relativePath);
+        }
+
+        @chmod($targetPath, fileperms($item->getPathname()) & 0777);
+        $copied++;
+    }
+
+    return [
+        "copied" => $copied,
+        "backed_up" => $backedUp,
+        "skipped" => $skipped,
+    ];
+}
+
+function updaterDeploy(array $manifest, string $appRoot): array
+{
+    $runId = date("Ymd-His");
+    $workDir = rtrim((string) UPDATE_WORK_DIR, "/\\") . DIRECTORY_SEPARATOR . $runId;
+    $stageDir = $workDir . DIRECTORY_SEPARATOR . "stage";
+    $zipFile = $workDir . DIRECTORY_SEPARATOR . "package.zip";
+    $backupDir = rtrim((string) UPDATE_BACKUP_DIR, "/\\") .
+        DIRECTORY_SEPARATOR .
+        $runId .
+        "-" .
+        $manifest["version"];
+
+    updaterEnsureDirectory($workDir);
+    updaterDownloadPackage($manifest, $zipFile);
+    updaterExtractPackage($zipFile, $stageDir);
+    $result = updaterCopyWithBackup($stageDir, $appRoot, $backupDir);
+
+    updaterRemoveDirectory($workDir);
+
+    return [
+        "backup_dir" => $backupDir,
+        "copied" => $result["copied"],
+        "backed_up" => $result["backed_up"],
+        "skipped" => $result["skipped"],
+    ];
+}
+
+$manifest = null;
+$updateAvailable = false;
+
+try {
+    $manifest = updaterFetchManifest();
+    $updateAvailable =
+        version_compare(
+            updaterVersionToCompare($manifest["version"]),
+            updaterVersionToCompare(APP_VERSION),
+            ">",
+        );
+} catch (Throwable $exception) {
+    $errors[] = $exception->getMessage();
+}
+
+if ($_SERVER["REQUEST_METHOD"] === "POST") {
+    if (!updaterValidateCsrfToken((string) ($_POST["csrf_token"] ?? ""))) {
+        $errors[] = "Invalid token. Please reload the page and try again.";
+    } elseif ($appRoot === false) {
+        $errors[] = "Application root cannot be resolved.";
+    } else {
+        try {
+            $manifest = updaterFetchManifest();
+            $force = !empty($_POST["force_redeploy"]);
+            $updateAvailable =
+                version_compare(
+                    updaterVersionToCompare($manifest["version"]),
+                    updaterVersionToCompare(APP_VERSION),
+                    ">",
+                );
+
+            if (!$updateAvailable && !$force) {
+                throw new RuntimeException(
+                    "No newer update is available. Enable force redeployment to deploy this package anyway.",
+                );
+            }
+
+            $result = updaterDeploy($manifest, $appRoot);
+            $messages[] = "Deployment finished.";
+            $messages[] = "Files copied: " . $result["copied"];
+            $messages[] = "Files backed up: " . $result["backed_up"];
+            $messages[] = "Skipped preserved paths: " . $result["skipped"];
+            $messages[] = "Backup directory: " . $result["backup_dir"];
+        } catch (Throwable $exception) {
+            $errors[] = $exception->getMessage();
+        }
+    }
+}
+
+?>
+<!DOCTYPE html>
+<html lang="de">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>Updater</title>
+</head>
+<body>
+    <h1>Updater</h1>
+
+    <p><a href="settings.php">Back to settings</a></p>
+
+    <?php foreach ($messages as $message): ?>
+        <p><strong><?php echo updaterEscape($message); ?></strong></p>
+    <?php endforeach; ?>
+
+    <?php foreach ($errors as $error): ?>
+        <p><strong>Error:</strong> <?php echo updaterEscape($error); ?></p>
+    <?php endforeach; ?>
+
+    <table border="1" cellpadding="6" cellspacing="0">
+        <tbody>
+            <tr>
+                <th align="left">Installed version</th>
+                <td><?php echo updaterEscape(APP_VERSION); ?></td>
+            </tr>
+            <tr>
+                <th align="left">Update target URL</th>
+                <td><?php echo updaterEscape(UPDATE_MANIFEST_URL); ?></td>
+            </tr>
+            <tr>
+                <th align="left">Available version</th>
+                <td><?php echo updaterEscape($manifest["version"] ?? "Unavailable"); ?></td>
+            </tr>
+            <tr>
+                <th align="left">Package URL</th>
+                <td><?php echo updaterEscape($manifest["package_url"] ?? "Unavailable"); ?></td>
+            </tr>
+            <tr>
+                <th align="left">SHA-256</th>
+                <td><?php echo updaterEscape($manifest["sha256"] ?? "Unavailable"); ?></td>
+            </tr>
+            <tr>
+                <th align="left">Published at</th>
+                <td><?php echo updaterEscape($manifest["published_at"] ?? "Unavailable"); ?></td>
+            </tr>
+            <tr>
+                <th align="left">Update available</th>
+                <td><?php echo $updateAvailable ? "Yes" : "No"; ?></td>
+            </tr>
+        </tbody>
+    </table>
+
+    <h2>Manual deployment</h2>
+    <form method="POST">
+        <input type="hidden" name="csrf_token" value="<?php echo updaterEscape(updaterCsrfToken()); ?>">
+        <p>
+            <label>
+                <input type="checkbox" name="force_redeploy" value="1">
+                Force redeployment
+            </label>
+        </p>
+        <button type="submit" name="deploy_update" value="1">Deploy update</button>
+    </form>
+</body>
+</html>

+ 6 - 0
config.sample.php

@@ -55,6 +55,12 @@ define('CATEGORIES_FILE', DATA_DIR . 'categories.json');
 define('FAQ_FILE', DATA_DIR . 'faq.json');
 define('UPLOADS_URL', SITE_URL . '/data/uploads');
 
+// Manual update settings
+// Point this to the central update server's manifest.php endpoint.
+define('UPDATE_MANIFEST_URL', 'https://dev.med0.de/psa/updater/manifest.php');
+define('UPDATE_WORK_DIR', DATA_DIR . 'updates/work/');
+define('UPDATE_BACKUP_DIR', DATA_DIR . 'updates/backups/');
+
 // Session settings
 if (session_status() === PHP_SESSION_NONE) {
     $isHttps =

+ 3 - 0
includes/version.php

@@ -0,0 +1,3 @@
+<?php
+
+define("APP_VERSION", "v1.3.2");

+ 14 - 0
update-server/.htaccess

@@ -0,0 +1,14 @@
+Options -Indexes
+
+<IfModule mod_authz_core.c>
+    <FilesMatch "^(config\.php|manifest\.json|.*\.zip)$">
+        Require all denied
+    </FilesMatch>
+</IfModule>
+
+<IfModule !mod_authz_core.c>
+    <FilesMatch "^(config\.php|manifest\.json|.*\.zip)$">
+        Order allow,deny
+        Deny from all
+    </FilesMatch>
+</IfModule>

+ 36 - 0
update-server/README.md

@@ -0,0 +1,36 @@
+# PSA Orderform Update Server
+
+This folder can be deployed as the central update server.
+
+Release ZIPs can be managed through `manage.php`:
+
+1. Copy `config.sample.php` to `config.php`.
+2. Change `UPDATE_SERVER_PASSWORD`.
+3. Open `index.php` or `manage.php`, log in with the configured password, and upload a ZIP with a `vX.Y.Z` version.
+
+The management UI stores the ZIP under `packages/`, calculates SHA-256 and size, and updates `manifest.json`.
+
+Manual release management is also possible:
+
+1. Put the package under `packages/`, for example `packages/psa-orderform-v1.3.3.zip`.
+2. Calculate its SHA-256 checksum and byte size.
+3. Edit `manifest.json` and set `latest` to the version clients should install.
+
+Example release entry:
+
+```json
+{
+    "latest": "v1.3.3",
+    "releases": {
+        "v1.3.3": {
+            "version": "v1.3.3",
+            "package": "packages/psa-orderform-v1.3.3.zip",
+            "sha256": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
+            "size": 123456,
+            "published_at": "2026-06-23T00:00:00+00:00"
+        }
+    }
+}
+```
+
+Clients should use `manifest.php`, not `manifest.json`, as their `UPDATE_MANIFEST_URL`.

+ 7 - 0
update-server/config.sample.php

@@ -0,0 +1,7 @@
+<?php
+
+// Copy this file to config.php and change the password before deploying.
+define("UPDATE_SERVER_PASSWORD", "change-me");
+
+// Optional stronger alternative:
+// define("UPDATE_SERVER_PASSWORD_HASH", "$2y$10$replace-this-with-a-precomputed-password-hash");

+ 4 - 0
update-server/index.php

@@ -0,0 +1,4 @@
+<?php
+
+header("Location: manage.php");
+exit;

+ 400 - 0
update-server/manage.php

@@ -0,0 +1,400 @@
+<?php
+
+declare(strict_types=1);
+
+$baseDir = __DIR__;
+$configFile = $baseDir . "/config.php";
+$manifestFile = $baseDir . "/manifest.json";
+$packagesDir = $baseDir . "/packages";
+
+if (is_file($configFile)) {
+    require_once $configFile;
+}
+
+if (session_status() === PHP_SESSION_NONE) {
+    ini_set("session.use_strict_mode", "1");
+    ini_set("session.cookie_httponly", "1");
+    ini_set("session.cookie_samesite", "Lax");
+    session_start();
+}
+
+$messages = [];
+$errors = [];
+
+function updateManageEscape($value): string
+{
+    return htmlspecialchars((string) $value, ENT_QUOTES, "UTF-8");
+}
+
+function updateManageVersionIsValid(string $version): bool
+{
+    return preg_match('/^v\d+\.\d+\.\d+$/', $version) === 1;
+}
+
+function updateManagePasswordConfigured(): bool
+{
+    return defined("UPDATE_SERVER_PASSWORD_HASH") || defined("UPDATE_SERVER_PASSWORD");
+}
+
+function updateManagePasswordMatches(string $password): bool
+{
+    if (defined("UPDATE_SERVER_PASSWORD_HASH")) {
+        return password_verify($password, (string) UPDATE_SERVER_PASSWORD_HASH);
+    }
+
+    if (defined("UPDATE_SERVER_PASSWORD")) {
+        return hash_equals((string) UPDATE_SERVER_PASSWORD, $password);
+    }
+
+    return false;
+}
+
+function updateManageIsLoggedIn(): bool
+{
+    return !empty($_SESSION["update_server_logged_in"]);
+}
+
+function updateManageCsrfToken(): string
+{
+    if (empty($_SESSION["update_server_csrf_token"])) {
+        $_SESSION["update_server_csrf_token"] = bin2hex(random_bytes(32));
+    }
+
+    return $_SESSION["update_server_csrf_token"];
+}
+
+function updateManageCsrfIsValid(string $token): bool
+{
+    return !empty($_SESSION["update_server_csrf_token"]) &&
+        hash_equals($_SESSION["update_server_csrf_token"], $token);
+}
+
+function updateManageEnsureDirectory(string $dir): void
+{
+    if (!is_dir($dir) && !mkdir($dir, 02775, true) && !is_dir($dir)) {
+        throw new RuntimeException("Directory cannot be created: " . $dir);
+    }
+
+    @chmod($dir, 02775);
+}
+
+function updateManageReadManifest(string $manifestFile): array
+{
+    if (!is_file($manifestFile)) {
+        return ["latest" => "", "releases" => []];
+    }
+
+    $decoded = json_decode((string) file_get_contents($manifestFile), true);
+    if (!is_array($decoded)) {
+        throw new RuntimeException("Manifest is not valid JSON.");
+    }
+
+    return [
+        "latest" => trim((string) ($decoded["latest"] ?? "")),
+        "releases" => isset($decoded["releases"]) && is_array($decoded["releases"])
+            ? $decoded["releases"]
+            : [],
+    ];
+}
+
+function updateManageWriteManifest(string $manifestFile, array $manifest): void
+{
+    $json = json_encode(
+        $manifest,
+        JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE,
+    );
+    if ($json === false) {
+        throw new RuntimeException("Manifest cannot be encoded.");
+    }
+
+    $tmpFile = $manifestFile . ".tmp";
+    if (file_put_contents($tmpFile, $json . PHP_EOL, LOCK_EX) === false) {
+        throw new RuntimeException("Manifest cannot be written.");
+    }
+
+    @chmod($tmpFile, 0664);
+    if (!rename($tmpFile, $manifestFile)) {
+        @unlink($tmpFile);
+        throw new RuntimeException("Manifest cannot be saved.");
+    }
+
+    @chmod($manifestFile, 0664);
+}
+
+function updateManagePackageFileName(string $version): string
+{
+    return "psa-orderform-" . $version . ".zip";
+}
+
+function updateManageUploadedFileIsZip(array $file): bool
+{
+    $name = strtolower((string) ($file["name"] ?? ""));
+    $tmpName = (string) ($file["tmp_name"] ?? "");
+
+    if (!str_ends_with($name, ".zip") || !is_uploaded_file($tmpName)) {
+        return false;
+    }
+
+    $handle = fopen($tmpName, "rb");
+    if ($handle === false) {
+        return false;
+    }
+
+    $signature = fread($handle, 4);
+    fclose($handle);
+
+    return $signature === "PK\x03\x04" || $signature === "PK\x05\x06" || $signature === "PK\x07\x08";
+}
+
+function updateManagePublishUpload(
+    string $version,
+    array $file,
+    string $manifestFile,
+    string $packagesDir,
+): void {
+    if (!updateManageVersionIsValid($version)) {
+        throw new RuntimeException("Version must use the format vX.Y.Z.");
+    }
+
+    if (($file["error"] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
+        throw new RuntimeException("Upload failed with error code " . (string) ($file["error"] ?? "unknown") . ".");
+    }
+
+    if (!updateManageUploadedFileIsZip($file)) {
+        throw new RuntimeException("Uploaded file must be a ZIP package.");
+    }
+
+    updateManageEnsureDirectory($packagesDir);
+
+    $fileName = updateManagePackageFileName($version);
+    $targetPath = $packagesDir . DIRECTORY_SEPARATOR . $fileName;
+    if (!move_uploaded_file((string) $file["tmp_name"], $targetPath)) {
+        throw new RuntimeException("Uploaded package cannot be stored.");
+    }
+
+    @chmod($targetPath, 0664);
+
+    $sha256 = strtolower(hash_file("sha256", $targetPath) ?: "");
+    $size = filesize($targetPath);
+    if (!preg_match('/^[a-f0-9]{64}$/', $sha256) || $size === false || $size <= 0) {
+        @unlink($targetPath);
+        throw new RuntimeException("Stored package could not be verified.");
+    }
+
+    $manifest = updateManageReadManifest($manifestFile);
+    $manifest["latest"] = $version;
+    $manifest["releases"][$version] = [
+        "version" => $version,
+        "package" => "packages/" . $fileName,
+        "sha256" => $sha256,
+        "size" => $size,
+        "published_at" => date(DATE_ATOM),
+    ];
+
+    ksort($manifest["releases"]);
+    updateManageWriteManifest($manifestFile, $manifest);
+}
+
+function updateManageSetLatest(string $version, string $manifestFile): void
+{
+    if (!updateManageVersionIsValid($version)) {
+        throw new RuntimeException("Invalid release version.");
+    }
+
+    $manifest = updateManageReadManifest($manifestFile);
+    if (!isset($manifest["releases"][$version])) {
+        throw new RuntimeException("Release is not present in the manifest.");
+    }
+
+    $manifest["latest"] = $version;
+    updateManageWriteManifest($manifestFile, $manifest);
+}
+
+function updateManageDeleteRelease(
+    string $version,
+    string $manifestFile,
+    string $baseDir,
+): void {
+    if (!updateManageVersionIsValid($version)) {
+        throw new RuntimeException("Invalid release version.");
+    }
+
+    $manifest = updateManageReadManifest($manifestFile);
+    if (!isset($manifest["releases"][$version])) {
+        throw new RuntimeException("Release is not present in the manifest.");
+    }
+
+    $package = trim((string) ($manifest["releases"][$version]["package"] ?? ""));
+    unset($manifest["releases"][$version]);
+    if ($manifest["latest"] === $version) {
+        $manifest["latest"] = "";
+    }
+
+    updateManageWriteManifest($manifestFile, $manifest);
+
+    if ($package !== "" && !str_contains($package, "\0") && !str_starts_with($package, "/")) {
+        $packagePath = realpath($baseDir . "/" . $package);
+        $packagesPath = realpath($baseDir . "/packages");
+        if (
+            $packagePath !== false &&
+            $packagesPath !== false &&
+            str_starts_with($packagePath, $packagesPath . DIRECTORY_SEPARATOR) &&
+            is_file($packagePath)
+        ) {
+            unlink($packagePath);
+        }
+    }
+}
+
+if ($_SERVER["REQUEST_METHOD"] === "POST") {
+    $action = (string) ($_POST["action"] ?? "");
+
+    if ($action === "login") {
+        if (!updateManagePasswordConfigured()) {
+            $errors[] = "No password is configured.";
+        } elseif (updateManagePasswordMatches((string) ($_POST["password"] ?? ""))) {
+            session_regenerate_id(true);
+            $_SESSION["update_server_logged_in"] = true;
+            $messages[] = "Logged in.";
+        } else {
+            $errors[] = "Wrong password.";
+        }
+    } elseif ($action === "logout") {
+        unset($_SESSION["update_server_logged_in"], $_SESSION["update_server_csrf_token"]);
+        $messages[] = "Logged out.";
+    } elseif (!updateManageIsLoggedIn()) {
+        $errors[] = "Login required.";
+    } elseif (!updateManageCsrfIsValid((string) ($_POST["csrf_token"] ?? ""))) {
+        $errors[] = "Invalid token. Please reload the page and try again.";
+    } else {
+        try {
+            if ($action === "upload") {
+                updateManagePublishUpload(
+                    trim((string) ($_POST["version"] ?? "")),
+                    $_FILES["package"] ?? [],
+                    $manifestFile,
+                    $packagesDir,
+                );
+                $messages[] = "Release uploaded and published.";
+            } elseif ($action === "set_latest") {
+                updateManageSetLatest(trim((string) ($_POST["version"] ?? "")), $manifestFile);
+                $messages[] = "Latest release updated.";
+            } elseif ($action === "delete") {
+                updateManageDeleteRelease(trim((string) ($_POST["version"] ?? "")), $manifestFile, $baseDir);
+                $messages[] = "Release deleted.";
+            }
+        } catch (Throwable $exception) {
+            $errors[] = $exception->getMessage();
+        }
+    }
+}
+
+try {
+    $manifest = updateManageReadManifest($manifestFile);
+} catch (Throwable $exception) {
+    $manifest = ["latest" => "", "releases" => []];
+    $errors[] = $exception->getMessage();
+}
+
+$releases = $manifest["releases"];
+krsort($releases);
+
+?>
+<!DOCTYPE html>
+<html lang="de">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>Update Management</title>
+</head>
+<body>
+    <h1>Update Management</h1>
+
+    <?php foreach ($messages as $message): ?>
+        <p><strong><?php echo updateManageEscape($message); ?></strong></p>
+    <?php endforeach; ?>
+
+    <?php foreach ($errors as $error): ?>
+        <p><strong>Error:</strong> <?php echo updateManageEscape($error); ?></p>
+    <?php endforeach; ?>
+
+    <?php if (!updateManageIsLoggedIn()): ?>
+        <form method="POST">
+            <input type="hidden" name="action" value="login">
+            <p>
+                <label for="password">Password</label><br>
+                <input type="password" id="password" name="password" required>
+            </p>
+            <button type="submit">Login</button>
+        </form>
+    <?php else: ?>
+        <form method="POST">
+            <input type="hidden" name="action" value="logout">
+            <button type="submit">Logout</button>
+        </form>
+
+        <h2>Upload release</h2>
+        <form method="POST" enctype="multipart/form-data">
+            <input type="hidden" name="action" value="upload">
+            <input type="hidden" name="csrf_token" value="<?php echo updateManageEscape(updateManageCsrfToken()); ?>">
+            <p>
+                <label for="version">Version</label><br>
+                <input type="text" id="version" name="version" required placeholder="v1.3.3" pattern="v[0-9]+\.[0-9]+\.[0-9]+">
+            </p>
+            <p>
+                <label for="package">ZIP package</label><br>
+                <input type="file" id="package" name="package" accept=".zip,application/zip" required>
+            </p>
+            <button type="submit">Upload and publish</button>
+        </form>
+
+        <h2>Current manifest</h2>
+        <p>Latest: <?php echo updateManageEscape($manifest["latest"] !== "" ? $manifest["latest"] : "none"); ?></p>
+        <p>Manifest endpoint: <a href="manifest.php">manifest.php</a></p>
+
+        <?php if (empty($releases)): ?>
+            <p>No releases configured.</p>
+        <?php else: ?>
+            <table border="1" cellpadding="6" cellspacing="0">
+                <thead>
+                    <tr>
+                        <th>Version</th>
+                        <th>Package</th>
+                        <th>SHA-256</th>
+                        <th>Size</th>
+                        <th>Published</th>
+                        <th>Actions</th>
+                    </tr>
+                </thead>
+                <tbody>
+                    <?php foreach ($releases as $version => $release): ?>
+                        <tr>
+                            <td><?php echo updateManageEscape($version); ?></td>
+                            <td><?php echo updateManageEscape($release["package"] ?? ""); ?></td>
+                            <td><?php echo updateManageEscape($release["sha256"] ?? ""); ?></td>
+                            <td><?php echo updateManageEscape($release["size"] ?? ""); ?></td>
+                            <td><?php echo updateManageEscape($release["published_at"] ?? ""); ?></td>
+                            <td>
+                                <?php if ($manifest["latest"] !== $version): ?>
+                                    <form method="POST" style="display:inline">
+                                        <input type="hidden" name="action" value="set_latest">
+                                        <input type="hidden" name="csrf_token" value="<?php echo updateManageEscape(updateManageCsrfToken()); ?>">
+                                        <input type="hidden" name="version" value="<?php echo updateManageEscape($version); ?>">
+                                        <button type="submit">Set latest</button>
+                                    </form>
+                                <?php endif; ?>
+                                <form method="POST" style="display:inline" onsubmit="return confirm('Delete this release?');">
+                                    <input type="hidden" name="action" value="delete">
+                                    <input type="hidden" name="csrf_token" value="<?php echo updateManageEscape(updateManageCsrfToken()); ?>">
+                                    <input type="hidden" name="version" value="<?php echo updateManageEscape($version); ?>">
+                                    <button type="submit">Delete</button>
+                                </form>
+                            </td>
+                        </tr>
+                    <?php endforeach; ?>
+                </tbody>
+            </table>
+        <?php endif; ?>
+    <?php endif; ?>
+</body>
+</html>

+ 4 - 0
update-server/manifest.json

@@ -0,0 +1,4 @@
+{
+    "latest": "",
+    "releases": {}
+}

+ 111 - 0
update-server/manifest.php

@@ -0,0 +1,111 @@
+<?php
+
+declare(strict_types=1);
+
+header("Content-Type: application/json; charset=utf-8");
+header("Cache-Control: no-store");
+
+$baseDir = __DIR__;
+$manifestFile = $baseDir . "/manifest.json";
+
+function updateServerRespond(int $status, array $payload): void
+{
+    http_response_code($status);
+    echo json_encode(
+        $payload,
+        JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE,
+    );
+    exit;
+}
+
+function updateServerValidateVersion(string $version): bool
+{
+    return preg_match('/^v\d+\.\d+\.\d+$/', $version) === 1;
+}
+
+function updateServerNormalizePackageUrl(string $version): string
+{
+    $scheme = "http";
+    if (
+        (!empty($_SERVER["HTTPS"]) && $_SERVER["HTTPS"] !== "off") ||
+        (isset($_SERVER["SERVER_PORT"]) && (int) $_SERVER["SERVER_PORT"] === 443) ||
+        strtolower((string) ($_SERVER["HTTP_X_FORWARDED_PROTO"] ?? "")) === "https"
+    ) {
+        $scheme = "https";
+    }
+
+    $host = $_SERVER["HTTP_HOST"] ?? "localhost";
+    $scriptDir = rtrim(str_replace("\\", "/", dirname($_SERVER["SCRIPT_NAME"] ?? "")), "/");
+
+    return $scheme .
+        "://" .
+        $host .
+        ($scriptDir === "" ? "" : $scriptDir) .
+        "/package.php?version=" .
+        rawurlencode($version);
+}
+
+if (!is_file($manifestFile)) {
+    updateServerRespond(500, ["error" => "Manifest file is missing."]);
+}
+
+$decoded = json_decode((string) file_get_contents($manifestFile), true);
+if (!is_array($decoded)) {
+    updateServerRespond(500, ["error" => "Manifest file is not valid JSON."]);
+}
+
+$latest = trim((string) ($decoded["latest"] ?? ""));
+$releases = isset($decoded["releases"]) && is_array($decoded["releases"])
+    ? $decoded["releases"]
+    : [];
+
+if ($latest === "" || !updateServerValidateVersion($latest)) {
+    updateServerRespond(404, ["error" => "No valid latest release is configured."]);
+}
+
+if (!isset($releases[$latest]) || !is_array($releases[$latest])) {
+    updateServerRespond(404, ["error" => "Latest release entry is missing."]);
+}
+
+$release = $releases[$latest];
+$version = trim((string) ($release["version"] ?? $latest));
+$package = trim((string) ($release["package"] ?? ""));
+$sha256 = strtolower(trim((string) ($release["sha256"] ?? "")));
+$publishedAt = trim((string) ($release["published_at"] ?? ""));
+$size = isset($release["size"]) ? (int) $release["size"] : 0;
+
+if ($version !== $latest || !updateServerValidateVersion($version)) {
+    updateServerRespond(500, ["error" => "Latest release version is invalid."]);
+}
+
+if ($package === "" || str_contains($package, "\0") || str_starts_with($package, "/")) {
+    updateServerRespond(500, ["error" => "Latest release package path is invalid."]);
+}
+
+if (!preg_match('/^[a-f0-9]{64}$/', $sha256)) {
+    updateServerRespond(500, ["error" => "Latest release checksum is invalid."]);
+}
+
+$packagePath = realpath($baseDir . "/" . $package);
+$packagesDir = realpath($baseDir . "/packages");
+if (
+    $packagePath === false ||
+    $packagesDir === false ||
+    !str_starts_with($packagePath, $packagesDir . DIRECTORY_SEPARATOR) ||
+    !is_file($packagePath)
+) {
+    updateServerRespond(404, ["error" => "Latest release package is missing."]);
+}
+
+if ($size <= 0) {
+    $size = filesize($packagePath) ?: 0;
+}
+
+updateServerRespond(200, [
+    "latest" => $latest,
+    "version" => $version,
+    "package_url" => updateServerNormalizePackageUrl($version),
+    "sha256" => $sha256,
+    "size" => $size,
+    "published_at" => $publishedAt,
+]);

+ 85 - 0
update-server/package.php

@@ -0,0 +1,85 @@
+<?php
+
+declare(strict_types=1);
+
+$baseDir = __DIR__;
+$manifestFile = $baseDir . "/manifest.json";
+
+function updateServerPackageError(int $status, string $message): void
+{
+    http_response_code($status);
+    header("Content-Type: text/plain; charset=utf-8");
+    echo $message;
+    exit;
+}
+
+function updateServerPackageValidVersion(string $version): bool
+{
+    return preg_match('/^v\d+\.\d+\.\d+$/', $version) === 1;
+}
+
+$version = trim((string) ($_GET["version"] ?? ""));
+if (!updateServerPackageValidVersion($version)) {
+    updateServerPackageError(400, "Invalid version.");
+}
+
+if (!is_file($manifestFile)) {
+    updateServerPackageError(500, "Manifest file is missing.");
+}
+
+$decoded = json_decode((string) file_get_contents($manifestFile), true);
+if (!is_array($decoded)) {
+    updateServerPackageError(500, "Manifest file is not valid JSON.");
+}
+
+$releases = isset($decoded["releases"]) && is_array($decoded["releases"])
+    ? $decoded["releases"]
+    : [];
+if (!isset($releases[$version]) || !is_array($releases[$version])) {
+    updateServerPackageError(404, "Release is not configured.");
+}
+
+$release = $releases[$version];
+$releaseVersion = trim((string) ($release["version"] ?? $version));
+$sha256 = strtolower(trim((string) ($release["sha256"] ?? "")));
+$package = trim((string) ($release["package"] ?? ""));
+if ($releaseVersion !== $version || !updateServerPackageValidVersion($releaseVersion)) {
+    updateServerPackageError(500, "Release version is invalid.");
+}
+if (!preg_match('/^[a-f0-9]{64}$/', $sha256)) {
+    updateServerPackageError(500, "Release checksum is invalid.");
+}
+if ($package === "" || str_contains($package, "\0") || str_starts_with($package, "/")) {
+    updateServerPackageError(500, "Release package path is invalid.");
+}
+
+$packagePath = realpath($baseDir . "/" . $package);
+$packagesDir = realpath($baseDir . "/packages");
+if (
+    $packagePath === false ||
+    $packagesDir === false ||
+    !str_starts_with($packagePath, $packagesDir . DIRECTORY_SEPARATOR) ||
+    !is_file($packagePath)
+) {
+    updateServerPackageError(404, "Release package is missing.");
+}
+
+if (strtolower(pathinfo($packagePath, PATHINFO_EXTENSION)) !== "zip") {
+    updateServerPackageError(500, "Release package is not a ZIP file.");
+}
+
+$fileName = basename($packagePath);
+$fileSize = filesize($packagePath);
+$handle = fopen($packagePath, "rb");
+if ($fileSize === false || $handle === false) {
+    updateServerPackageError(500, "Release package cannot be opened.");
+}
+
+header("Content-Type: application/zip");
+header("Content-Disposition: attachment; filename=\"" . addcslashes($fileName, "\"\\") . "\"");
+header("Content-Length: " . (string) $fileSize);
+header("Cache-Control: public, max-age=300");
+header("X-Content-Type-Options: nosniff");
+
+fpassthru($handle);
+fclose($handle);

+ 2 - 0
update-server/packages/.gitignore

@@ -0,0 +1,2 @@
+*.zip
+!.gitkeep

+ 1 - 0
update-server/packages/.gitkeep

@@ -0,0 +1 @@
+