Parcourir la source

Codex attempt to unify updater

Josef Straßl il y a 1 mois
Parent
commit
a0bd994c4b

+ 3 - 1
admin/settings.php

@@ -29,7 +29,9 @@ function settingsGetUpdaterStatus(): array
             "method" => "GET",
             "timeout" => 3,
             "ignore_errors" => true,
-            "header" => "User-Agent: PSA-Orderform-Settings/" . APP_VERSION . "\r\n",
+            "header" => "User-Agent: " .
+                (defined("UPDATE_USER_AGENT") ? UPDATE_USER_AGENT : "Simple-PHP-Updater/" . APP_VERSION) .
+                "\r\n",
         ],
     ]);
 

+ 54 - 382
admin/updater.php

@@ -1,7 +1,10 @@
 <?php
 
+declare(strict_types=1);
+
 require_once __DIR__ . "/../config.php";
 require_once __DIR__ . "/../includes/version.php";
+require_once __DIR__ . "/../includes/updater.php";
 
 if (empty($_SESSION["admin_logged_in"])) {
     header("Location: login.php");
@@ -17,10 +20,20 @@ if (!defined("UPDATE_WORK_DIR")) {
 if (!defined("UPDATE_BACKUP_DIR")) {
     define("UPDATE_BACKUP_DIR", DATA_DIR . "updates/backups/");
 }
+if (!defined("UPDATE_PRESERVE_PATHS")) {
+    define("UPDATE_PRESERVE_PATHS", ["config.php", "data/", ".git/"]);
+}
+if (!defined("UPDATE_REQUIRED_PACKAGE_PATHS")) {
+    define("UPDATE_REQUIRED_PACKAGE_PATHS", ["index.php", "admin/", "includes/"]);
+}
+if (!defined("UPDATE_USER_AGENT")) {
+    define("UPDATE_USER_AGENT", "Simple-PHP-Updater/" . APP_VERSION);
+}
 
-$appRoot = realpath(__DIR__ . "/..");
 $messages = [];
 $errors = [];
+$manifest = null;
+$updateAvailable = false;
 
 function updaterEscape($value): string
 {
@@ -42,398 +55,57 @@ function updaterValidateCsrfToken(string $token): bool
         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",
-        ],
+try {
+    $appRoot = realpath(__DIR__ . "/..");
+    $updater = new SimpleUpdater([
+        "manifest_url" => UPDATE_MANIFEST_URL,
+        "work_dir" => UPDATE_WORK_DIR,
+        "backup_dir" => UPDATE_BACKUP_DIR,
+        "current_version" => APP_VERSION,
+        "app_root" => $appRoot === false ? "" : $appRoot,
+        "user_agent" => UPDATE_USER_AGENT,
+        "preserve_paths" => UPDATE_PRESERVE_PATHS,
+        "required_package_paths" => UPDATE_REQUIRED_PACKAGE_PATHS,
     ]);
 
-    $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 : "") . ".",
-        );
+    try {
+        $manifest = $updater->fetchManifest();
+        $updateAvailable = $updater->updateAvailable($manifest);
+    } catch (Throwable $exception) {
+        $errors[] = $exception->getMessage();
     }
 
-    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);
+    if ($_SERVER["REQUEST_METHOD"] === "POST") {
+        if (!updaterValidateCsrfToken((string) ($_POST["csrf_token"] ?? ""))) {
+            $errors[] = "Invalid token. Please reload the page and try again.";
+        } else {
+            try {
+                $manifest = $updater->fetchManifest();
+                $force = !empty($_POST["force_redeploy"]);
+                $updateAvailable = $updater->updateAvailable($manifest);
+
+                if (!$updateAvailable && !$force) {
+                    throw new RuntimeException(
+                        "No newer update is available. Enable force redeployment to deploy this package anyway.",
+                    );
+                }
+
+                $result = $updater->deploy($manifest);
+                $messages[] = "Deployment finished.";
+                $messages[] = "Files copied: " . $result["copied"];
+                $messages[] = "Files backed up: " . $result["backed_up"];
+                $messages[] = "Old backup directories removed: " . $result["removed_backups"];
+                $messages[] = "Skipped preserved paths: " . $result["skipped"];
+                $messages[] = "Backup directory: " . $result["backup_dir"];
+            } catch (Throwable $exception) {
+                $errors[] = $exception->getMessage();
             }
-            $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 updaterCleanupOldBackups(string $keepBackupDir): int
-{
-    $backupRoot = rtrim((string) UPDATE_BACKUP_DIR, "/\\");
-    if (!is_dir($backupRoot)) {
-        return 0;
-    }
-
-    $keepRealPath = realpath($keepBackupDir);
-    $backupRootRealPath = realpath($backupRoot);
-    if ($keepRealPath === false || $backupRootRealPath === false) {
-        return 0;
-    }
-
-    $removed = 0;
-    $items = new DirectoryIterator($backupRootRealPath);
-    foreach ($items as $item) {
-        if ($item->isDot() || !$item->isDir()) {
-            continue;
-        }
-
-        $path = $item->getPathname();
-        if (realpath($path) === $keepRealPath) {
-            continue;
-        }
-
-        updaterRemoveDirectory($path);
-        if (is_dir($path)) {
-            throw new RuntimeException("Old backup directory could not be removed: " . $path);
-        }
-        $removed++;
     }
-
-    return $removed;
-}
-
-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);
-    $removedBackups = updaterCleanupOldBackups($backupDir);
-
-    return [
-        "backup_dir" => $backupDir,
-        "copied" => $result["copied"],
-        "backed_up" => $result["backed_up"],
-        "removed_backups" => $removedBackups,
-        "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[] = "Old backup directories removed: " . $result["removed_backups"];
-            $messages[] = "Skipped preserved paths: " . $result["skipped"];
-            $messages[] = "Backup directory: " . $result["backup_dir"];
-        } catch (Throwable $exception) {
-            $errors[] = $exception->getMessage();
-        }
-    }
-}
-
 ?>
 <!DOCTYPE html>
 <html lang="de">

+ 4 - 0
config.sample.php

@@ -60,6 +60,10 @@ define('UPLOADS_URL', SITE_URL . '/data/uploads');
 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/');
+define('UPDATE_USER_AGENT', 'PSA-Orderform-Updater');
+// For another PHP project, adjust these two path lists instead of changing the updater code.
+define('UPDATE_PRESERVE_PATHS', ['config.php', 'data/', '.git/']);
+define('UPDATE_REQUIRED_PACKAGE_PATHS', ['index.php', 'admin/', 'includes/']);
 
 // Data backup settings
 define('BACKUP_DIR', DATA_DIR . 'backups/');

+ 6 - 0
docs/CONFIG_REFERENCE.md

@@ -38,6 +38,12 @@
 | `CATEGORIES_FILE` | JSON-Datei für Kategorien |
 | `FAQ_FILE` | JSON-Datei für FAQ-Inhalte |
 | `MANUAL_BACKORDERS_FILE` | JSON-Datei für manuelle Nachbestell-Einträge (ohne Bestellbezug) |
+| `UPDATE_MANIFEST_URL` | URL zum zentralen `manifest.php` des Update-Servers |
+| `UPDATE_WORK_DIR` | Temporäres Arbeitsverzeichnis für heruntergeladene Update-Pakete |
+| `UPDATE_BACKUP_DIR` | Backup-Verzeichnis für Dateien, die bei einem Update überschrieben werden |
+| `UPDATE_USER_AGENT` | Optionaler User-Agent für Manifest- und Paket-Downloads |
+| `UPDATE_PRESERVE_PATHS` | Pfade, die beim Deployment nie überschrieben werden (`config.php`, Datenordner usw.) |
+| `UPDATE_REQUIRED_PACKAGE_PATHS` | Mindestens einer dieser Pfade muss im ZIP enthalten sein, damit es als App-Paket gilt |
 | `BACKUP_DIR` | Lokales Verzeichnis für Daten-Backups (Standard: `DATA_DIR . 'backups/'`) |
 | `BACKUP_LOCAL_RETENTION` | Anzahl lokal aufzubewahrender Backup-ZIPs (Standard: 4) |
 | `BACKUP_AUTO_INTERVAL_SECONDS` | Intervall für Backups durch Admin-Aktivität (Standard: 604800 = wöchentlich; 0 deaktiviert) |

+ 469 - 0
includes/updater.php

@@ -0,0 +1,469 @@
+<?php
+
+declare(strict_types=1);
+
+class SimpleUpdater
+{
+    private string $manifestUrl;
+    private string $workDir;
+    private string $backupDir;
+    private string $currentVersion;
+    private string $appRoot;
+    private string $userAgent;
+    /** @var string[] */
+    private array $preservePaths;
+    /** @var string[] */
+    private array $requiredPackagePaths;
+
+    /**
+     * @param array{
+     *     manifest_url?: string,
+     *     work_dir?: string,
+     *     backup_dir?: string,
+     *     current_version?: string,
+     *     app_root?: string,
+     *     user_agent?: string,
+     *     preserve_paths?: string[],
+     *     required_package_paths?: string[]
+     * } $config
+     */
+    public function __construct(array $config)
+    {
+        $this->manifestUrl = trim((string) ($config["manifest_url"] ?? ""));
+        $this->workDir = rtrim((string) ($config["work_dir"] ?? ""), "/\\");
+        $this->backupDir = rtrim((string) ($config["backup_dir"] ?? ""), "/\\");
+        $this->currentVersion = trim((string) ($config["current_version"] ?? ""));
+        $this->appRoot = rtrim((string) ($config["app_root"] ?? ""), "/\\");
+        $this->userAgent = trim((string) ($config["user_agent"] ?? "Simple-PHP-Updater"));
+        if ($this->userAgent === "") {
+            $this->userAgent = "Simple-PHP-Updater";
+        }
+        $this->preservePaths = $this->normalizePathList($config["preserve_paths"] ?? null);
+        if ($this->preservePaths === []) {
+            $this->preservePaths = ["config.php", "data/", ".git/"];
+        }
+        $this->requiredPackagePaths = $this->normalizePathList($config["required_package_paths"] ?? null);
+        if ($this->requiredPackagePaths === []) {
+            $this->requiredPackagePaths = ["index.php", "admin/", "includes/"];
+        }
+
+        if ($this->workDir === "") {
+            throw new InvalidArgumentException("Updater work directory is not configured.");
+        }
+        if ($this->backupDir === "") {
+            throw new InvalidArgumentException("Updater backup directory is not configured.");
+        }
+        if ($this->currentVersion === "") {
+            throw new InvalidArgumentException("Current application version is not configured.");
+        }
+        if ($this->appRoot === "" || !is_dir($this->appRoot)) {
+            throw new InvalidArgumentException("Application root cannot be resolved.");
+        }
+    }
+
+    public function getManifestUrl(): string
+    {
+        return $this->manifestUrl;
+    }
+
+    public function getCurrentVersion(): string
+    {
+        return $this->currentVersion;
+    }
+
+    public function fetchManifest(): array
+    {
+        if ($this->manifestUrl === "") {
+            throw new RuntimeException("UPDATE_MANIFEST_URL is not configured.");
+        }
+
+        $body = $this->httpGet($this->manifestUrl, 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 (!self::isVersion($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,
+        ];
+    }
+
+    public function updateAvailable(array $manifest): bool
+    {
+        return version_compare(
+            self::versionToCompare((string) ($manifest["version"] ?? "")),
+            self::versionToCompare($this->currentVersion),
+            ">",
+        );
+    }
+
+    public function deploy(array $manifest): array
+    {
+        $version = (string) ($manifest["version"] ?? "");
+        if (!self::isVersion($version)) {
+            throw new RuntimeException("Manifest version is invalid.");
+        }
+
+        $runId = date("Ymd-His");
+        $workDir = $this->workDir . DIRECTORY_SEPARATOR . $runId;
+        $stageDir = $workDir . DIRECTORY_SEPARATOR . "stage";
+        $zipFile = $workDir . DIRECTORY_SEPARATOR . "package.zip";
+        $backupDir = $this->backupDir .
+            DIRECTORY_SEPARATOR .
+            $runId .
+            "-" .
+            $version;
+
+        try {
+            self::ensureDirectory($workDir);
+            $this->downloadPackage($manifest, $zipFile);
+            $this->extractPackage($zipFile, $stageDir);
+            $result = $this->copyWithBackup($stageDir, $backupDir);
+        } finally {
+            self::removeDirectory($workDir);
+        }
+
+        $removedBackups = $this->cleanupOldBackups($backupDir);
+
+        return [
+            "backup_dir" => $backupDir,
+            "copied" => $result["copied"],
+            "backed_up" => $result["backed_up"],
+            "removed_backups" => $removedBackups,
+            "skipped" => $result["skipped"],
+        ];
+    }
+
+    public static function versionToCompare(string $version): string
+    {
+        return ltrim(trim($version), "vV");
+    }
+
+    public static function isVersion(string $version): bool
+    {
+        return preg_match('/^v\d+\.\d+\.\d+$/', $version) === 1;
+    }
+
+    public static function ensureDirectory(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);
+    }
+
+    public static function removeDirectory(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);
+    }
+
+    private function httpGet(string $url, int $timeout): 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: " . $this->userAgent . "\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;
+    }
+
+    private function downloadPackage(array $manifest, string $targetFile): void
+    {
+        self::ensureDirectory(dirname($targetFile));
+
+        $data = $this->httpGet((string) $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 ((int) $manifest["size"] > 0 && filesize($targetFile) !== (int) $manifest["size"]) {
+            unlink($targetFile);
+            throw new RuntimeException("Downloaded package size mismatch.");
+        }
+
+        $actualHash = strtolower(hash_file("sha256", $targetFile) ?: "");
+        if ($actualHash !== (string) $manifest["sha256"]) {
+            unlink($targetFile);
+            throw new RuntimeException("Package checksum mismatch.");
+        }
+    }
+
+    private function extractPackage(string $zipFile, string $stageDir): void
+    {
+        if (!class_exists("ZipArchive")) {
+            throw new RuntimeException("PHP ZipArchive extension is not available.");
+        }
+
+        self::removeDirectory($stageDir);
+        self::ensureDirectory($stageDir);
+
+        $zip = new ZipArchive();
+        if ($zip->open($zipFile) !== true) {
+            throw new RuntimeException("Downloaded package is not a readable ZIP file.");
+        }
+
+        $hasRequiredPath = false;
+        for ($i = 0; $i < $zip->numFiles; $i++) {
+            $name = (string) $zip->getNameIndex($i);
+            if (!$this->validateZipEntry($name)) {
+                $zip->close();
+                throw new RuntimeException("ZIP contains an unsafe path: " . $name);
+            }
+
+            if ($this->matchesAnyConfiguredPath($name, $this->requiredPackagePaths)) {
+                $hasRequiredPath = true;
+            }
+        }
+
+        if (!$hasRequiredPath) {
+            $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();
+    }
+
+    private function validateZipEntry(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;
+    }
+
+    private function copyWithBackup(string $stageDir, string $backupDir): array
+    {
+        self::ensureDirectory($backupDir);
+
+        $copied = 0;
+        $backedUp = 0;
+        $skipped = 0;
+
+        $items = new RecursiveIteratorIterator(
+            new RecursiveDirectoryIterator($stageDir, FilesystemIterator::SKIP_DOTS),
+            RecursiveIteratorIterator::SELF_FIRST,
+        );
+
+        foreach ($items as $item) {
+            $relativePath = $this->relativePath($item->getPathname(), $stageDir);
+            if ($this->shouldPreservePath($relativePath)) {
+                $skipped++;
+                continue;
+            }
+
+            $targetPath = $this->appRoot . DIRECTORY_SEPARATOR . $relativePath;
+
+            if ($item->isDir()) {
+                self::ensureDirectory($targetPath);
+                continue;
+            }
+
+            self::ensureDirectory(dirname($targetPath));
+
+            if (file_exists($targetPath)) {
+                $backupPath = $backupDir . DIRECTORY_SEPARATOR . $relativePath;
+                self::ensureDirectory(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,
+        ];
+    }
+
+    private function cleanupOldBackups(string $keepBackupDir): int
+    {
+        if (!is_dir($this->backupDir)) {
+            return 0;
+        }
+
+        $keepRealPath = realpath($keepBackupDir);
+        $backupRootRealPath = realpath($this->backupDir);
+        if ($keepRealPath === false || $backupRootRealPath === false) {
+            return 0;
+        }
+
+        $removed = 0;
+        $items = new DirectoryIterator($backupRootRealPath);
+        foreach ($items as $item) {
+            if ($item->isDot() || !$item->isDir()) {
+                continue;
+            }
+
+            $path = $item->getPathname();
+            if (realpath($path) === $keepRealPath) {
+                continue;
+            }
+
+            self::removeDirectory($path);
+            if (is_dir($path)) {
+                throw new RuntimeException("Old backup directory could not be removed: " . $path);
+            }
+            $removed++;
+        }
+
+        return $removed;
+    }
+
+    private function shouldPreservePath(string $relativePath): bool
+    {
+        return $this->matchesAnyConfiguredPath($relativePath, $this->preservePaths);
+    }
+
+    private function matchesAnyConfiguredPath(string $path, array $configuredPaths): bool
+    {
+        $path = trim(str_replace("\\", "/", $path), "/");
+        if ($path === "") {
+            return true;
+        }
+
+        foreach ($configuredPaths as $configuredPath) {
+            $configuredPath = str_replace("\\", "/", $configuredPath);
+            $isDirectoryMatch = str_ends_with($configuredPath, "/");
+            $configuredPath = trim($configuredPath, "/");
+            if ($configuredPath === "") {
+                continue;
+            }
+
+            if ($path === $configuredPath) {
+                return true;
+            }
+
+            if (($isDirectoryMatch || !str_contains($configuredPath, ".")) &&
+                str_starts_with($path, $configuredPath . "/")
+            ) {
+                return true;
+            }
+        }
+
+        return false;
+    }
+
+    private function relativePath(string $path, string $baseDir): string
+    {
+        return ltrim(str_replace("\\", "/", substr($path, strlen($baseDir))), "/");
+    }
+
+    /**
+     * @param mixed $paths
+     * @return string[]
+     */
+    private function normalizePathList($paths): array
+    {
+        if (!is_array($paths)) {
+            return [];
+        }
+
+        $normalized = [];
+        foreach ($paths as $path) {
+            $path = trim(str_replace("\\", "/", (string) $path));
+            if ($path !== "") {
+                $normalized[] = $path;
+            }
+        }
+
+        return $normalized;
+    }
+}

+ 4 - 1
scripts/create-update-zip.sh

@@ -89,8 +89,11 @@ fi
 
 write_current_version "$version"
 
+package_prefix="${UPDATE_PACKAGE_PREFIX:-psa-orderform}"
+[[ "$package_prefix" =~ ^[A-Za-z0-9._-]+$ ]] || die "UPDATE_PACKAGE_PREFIX may only contain letters, digits, dot, underscore and dash."
+
 output_dir='build/updates'
-output_file="${output_dir}/psa-orderform-${version}.zip"
+output_file="${output_dir}/${package_prefix}-${version}.zip"
 tmp_file_list="$(mktemp)"
 
 cleanup() {

+ 7 - 4
update-server/README.md

@@ -1,4 +1,4 @@
-# PSA Orderform Update Server
+# PHP Update Server
 
 This folder can be deployed as the central update server.
 
@@ -6,13 +6,14 @@ 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.
+3. Set `UPDATE_SERVER_PACKAGE_PREFIX` to the project/package name.
+4. 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`.
+1. Put the package under `packages/`, for example `packages/my-app-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.
 
@@ -24,7 +25,7 @@ Example release entry:
     "releases": {
         "v1.3.3": {
             "version": "v1.3.3",
-            "package": "packages/psa-orderform-v1.3.3.zip",
+            "package": "packages/my-app-v1.3.3.zip",
             "sha256": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
             "size": 123456,
             "published_at": "2026-06-23T00:00:00+00:00"
@@ -34,3 +35,5 @@ Example release entry:
 ```
 
 Clients should use `manifest.php`, not `manifest.json`, as their `UPDATE_MANIFEST_URL`.
+
+The server code is intentionally project-agnostic. To reuse it in another PHP project, copy this folder, keep `includes/update-server.php` with the public PHP files, and adjust only `config.php`.

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

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

+ 310 - 0
update-server/includes/update-server.php

@@ -0,0 +1,310 @@
+<?php
+
+declare(strict_types=1);
+
+function updateServerJsonResponse(int $status, array $payload): void
+{
+    http_response_code($status);
+    header("Content-Type: application/json; charset=utf-8");
+    echo json_encode(
+        $payload,
+        JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE,
+    );
+    exit;
+}
+
+function updateServerTextResponse(int $status, string $message): void
+{
+    http_response_code($status);
+    header("Content-Type: text/plain; charset=utf-8");
+    echo $message;
+    exit;
+}
+
+function updateServerVersionIsValid(string $version): bool
+{
+    return preg_match('/^v\d+\.\d+\.\d+$/', $version) === 1;
+}
+
+function updateServerEnsureDirectory(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 updateServerReadManifest(string $manifestFile, bool $allowMissing = false): array
+{
+    if (!is_file($manifestFile)) {
+        if ($allowMissing) {
+            return ["latest" => "", "releases" => []];
+        }
+
+        throw new RuntimeException("Manifest file is missing.");
+    }
+
+    $decoded = json_decode((string) file_get_contents($manifestFile), true);
+    if (!is_array($decoded)) {
+        throw new RuntimeException("Manifest file is not valid JSON.");
+    }
+
+    return [
+        "latest" => trim((string) ($decoded["latest"] ?? "")),
+        "releases" => isset($decoded["releases"]) && is_array($decoded["releases"])
+            ? $decoded["releases"]
+            : [],
+    ];
+}
+
+function updateServerWriteManifest(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 updateServerLatestRelease(array $manifest): array
+{
+    $latest = trim((string) ($manifest["latest"] ?? ""));
+    $releases = isset($manifest["releases"]) && is_array($manifest["releases"])
+        ? $manifest["releases"]
+        : [];
+
+    if ($latest === "" || !updateServerVersionIsValid($latest)) {
+        throw new RuntimeException("No valid latest release is configured.");
+    }
+
+    if (!isset($releases[$latest]) || !is_array($releases[$latest])) {
+        throw new RuntimeException("Latest release entry is missing.");
+    }
+
+    return updateServerNormalizeRelease($latest, $releases[$latest]);
+}
+
+function updateServerReleaseByVersion(array $manifest, string $version): array
+{
+    if (!updateServerVersionIsValid($version)) {
+        throw new InvalidArgumentException("Invalid version.");
+    }
+
+    $releases = isset($manifest["releases"]) && is_array($manifest["releases"])
+        ? $manifest["releases"]
+        : [];
+    if (!isset($releases[$version]) || !is_array($releases[$version])) {
+        throw new RuntimeException("Release is not configured.");
+    }
+
+    return updateServerNormalizeRelease($version, $releases[$version]);
+}
+
+function updateServerNormalizeRelease(string $expectedVersion, array $release): array
+{
+    $version = trim((string) ($release["version"] ?? $expectedVersion));
+    $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 !== $expectedVersion || !updateServerVersionIsValid($version)) {
+        throw new RuntimeException("Release version is invalid.");
+    }
+    if (!preg_match('/^[a-f0-9]{64}$/', $sha256)) {
+        throw new RuntimeException("Release checksum is invalid.");
+    }
+    if ($package === "" || str_contains($package, "\0") || str_starts_with($package, "/")) {
+        throw new RuntimeException("Release package path is invalid.");
+    }
+
+    return [
+        "version" => $version,
+        "package" => $package,
+        "sha256" => $sha256,
+        "size" => $size,
+        "published_at" => $publishedAt,
+    ];
+}
+
+function updateServerPackagePath(string $baseDir, string $package): string
+{
+    $packagePath = realpath($baseDir . "/" . $package);
+    $packagesDir = realpath($baseDir . "/packages");
+    if (
+        $packagePath === false ||
+        $packagesDir === false ||
+        !str_starts_with($packagePath, $packagesDir . DIRECTORY_SEPARATOR) ||
+        !is_file($packagePath)
+    ) {
+        throw new RuntimeException("Release package is missing.");
+    }
+
+    return $packagePath;
+}
+
+function updateServerPackageUrl(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);
+}
+
+function updateServerPackageFileName(string $version): string
+{
+    $prefix = defined("UPDATE_SERVER_PACKAGE_PREFIX")
+        ? (string) UPDATE_SERVER_PACKAGE_PREFIX
+        : "release";
+    $prefix = trim((string) preg_replace('/[^A-Za-z0-9._-]+/', "-", $prefix), ".-_");
+    if ($prefix === "") {
+        $prefix = "release";
+    }
+
+    return $prefix . "-" . $version . ".zip";
+}
+
+function updateServerUploadedFileIsZip(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 updateServerPublishUpload(
+    string $version,
+    array $file,
+    string $manifestFile,
+    string $packagesDir,
+): void {
+    if (!updateServerVersionIsValid($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 (!updateServerUploadedFileIsZip($file)) {
+        throw new RuntimeException("Uploaded file must be a ZIP package.");
+    }
+
+    updateServerEnsureDirectory($packagesDir);
+
+    $fileName = updateServerPackageFileName($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 = updateServerReadManifest($manifestFile, true);
+    $manifest["latest"] = $version;
+    $manifest["releases"][$version] = [
+        "version" => $version,
+        "package" => "packages/" . $fileName,
+        "sha256" => $sha256,
+        "size" => $size,
+        "published_at" => date(DATE_ATOM),
+    ];
+
+    ksort($manifest["releases"]);
+    updateServerWriteManifest($manifestFile, $manifest);
+}
+
+function updateServerSetLatest(string $version, string $manifestFile): void
+{
+    if (!updateServerVersionIsValid($version)) {
+        throw new RuntimeException("Invalid release version.");
+    }
+
+    $manifest = updateServerReadManifest($manifestFile, true);
+    if (!isset($manifest["releases"][$version])) {
+        throw new RuntimeException("Release is not present in the manifest.");
+    }
+
+    $manifest["latest"] = $version;
+    updateServerWriteManifest($manifestFile, $manifest);
+}
+
+function updateServerDeleteRelease(string $version, string $manifestFile, string $baseDir): void
+{
+    if (!updateServerVersionIsValid($version)) {
+        throw new RuntimeException("Invalid release version.");
+    }
+
+    $manifest = updateServerReadManifest($manifestFile, true);
+    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"] = "";
+    }
+
+    updateServerWriteManifest($manifestFile, $manifest);
+
+    if ($package === "" || str_contains($package, "\0") || str_starts_with($package, "/")) {
+        return;
+    }
+
+    try {
+        $packagePath = updateServerPackagePath($baseDir, $package);
+        unlink($packagePath);
+    } catch (Throwable $exception) {
+        return;
+    }
+}

+ 6 - 186
update-server/manage.php

@@ -11,6 +11,8 @@ if (is_file($configFile)) {
     require_once $configFile;
 }
 
+require_once $baseDir . "/includes/update-server.php";
+
 if (session_status() === PHP_SESSION_NONE) {
     ini_set("session.use_strict_mode", "1");
     ini_set("session.cookie_httponly", "1");
@@ -26,11 +28,6 @@ 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");
@@ -69,183 +66,6 @@ function updateManageCsrfIsValid(string $token): bool
         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"] ?? "");
 
@@ -269,7 +89,7 @@ if ($_SERVER["REQUEST_METHOD"] === "POST") {
     } else {
         try {
             if ($action === "upload") {
-                updateManagePublishUpload(
+                updateServerPublishUpload(
                     trim((string) ($_POST["version"] ?? "")),
                     $_FILES["package"] ?? [],
                     $manifestFile,
@@ -277,10 +97,10 @@ if ($_SERVER["REQUEST_METHOD"] === "POST") {
                 );
                 $messages[] = "Release uploaded and published.";
             } elseif ($action === "set_latest") {
-                updateManageSetLatest(trim((string) ($_POST["version"] ?? "")), $manifestFile);
+                updateServerSetLatest(trim((string) ($_POST["version"] ?? "")), $manifestFile);
                 $messages[] = "Latest release updated.";
             } elseif ($action === "delete") {
-                updateManageDeleteRelease(trim((string) ($_POST["version"] ?? "")), $manifestFile, $baseDir);
+                updateServerDeleteRelease(trim((string) ($_POST["version"] ?? "")), $manifestFile, $baseDir);
                 $messages[] = "Release deleted.";
             }
         } catch (Throwable $exception) {
@@ -290,7 +110,7 @@ if ($_SERVER["REQUEST_METHOD"] === "POST") {
 }
 
 try {
-    $manifest = updateManageReadManifest($manifestFile);
+    $manifest = updateServerReadManifest($manifestFile, true);
 } catch (Throwable $exception) {
     $manifest = ["latest" => "", "releases" => []];
     $errors[] = $exception->getMessage();

+ 25 - 101
update-server/manifest.php

@@ -2,110 +2,34 @@
 
 declare(strict_types=1);
 
-header("Content-Type: application/json; charset=utf-8");
+require_once __DIR__ . "/includes/update-server.php";
+
 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."]);
+try {
+    $manifest = updateServerReadManifest($manifestFile);
+    $release = updateServerLatestRelease($manifest);
+    $packagePath = updateServerPackagePath($baseDir, $release["package"]);
+    $size = $release["size"] > 0 ? $release["size"] : (filesize($packagePath) ?: 0);
+
+    updateServerJsonResponse(200, [
+        "latest" => $release["version"],
+        "version" => $release["version"],
+        "package_url" => updateServerPackageUrl($release["version"]),
+        "sha256" => $release["sha256"],
+        "size" => $size,
+        "published_at" => $release["published_at"],
+    ]);
+} catch (Throwable $exception) {
+    $message = $exception->getMessage();
+    $status = in_array($message, [
+        "No valid latest release is configured.",
+        "Latest release entry is missing.",
+        "Release package is missing.",
+    ], true) ? 404 : 500;
+
+    updateServerJsonResponse($status, ["error" => $message]);
 }
-
-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,
-]);

+ 39 - 77
update-server/package.php

@@ -2,84 +2,46 @@
 
 declare(strict_types=1);
 
+require_once __DIR__ . "/includes/update-server.php";
+
 $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.");
+try {
+    $version = trim((string) ($_GET["version"] ?? ""));
+    if (!updateServerVersionIsValid($version)) {
+        updateServerTextResponse(400, "Invalid version.");
+    }
+
+    $manifest = updateServerReadManifest($manifestFile);
+    $release = updateServerReleaseByVersion($manifest, $version);
+    $packagePath = updateServerPackagePath($baseDir, $release["package"]);
+
+    if (strtolower(pathinfo($packagePath, PATHINFO_EXTENSION)) !== "zip") {
+        throw new RuntimeException("Release package is not a ZIP file.");
+    }
+
+    $fileName = basename($packagePath);
+    $fileSize = filesize($packagePath);
+    $handle = fopen($packagePath, "rb");
+    if ($fileSize === false || $handle === false) {
+        throw new RuntimeException("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);
+} catch (Throwable $exception) {
+    $message = $exception->getMessage();
+    $status = in_array($message, [
+        "Release is not configured.",
+        "Release package is missing.",
+    ], true) ? 404 : 500;
+
+    updateServerTextResponse($status, $message);
 }
-
-$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);