$version, "package_url" => $packageUrl, "sha256" => $sha256, "size" => $size, "published_at" => $publishedAt, ]; } /** * Checks whether a newer release is available. * * @return array{current: string, latest: string, available: bool, manifest: array} */ function manageUpdateCheck(): array { $manifest = manageUpdateFetchManifest(); $current = manageClientVersion(); $available = $current === "" ? true : version_compare( manageVersionCompareValue($manifest["version"]), manageVersionCompareValue($current), ">", ); return [ "current" => $current, "latest" => $manifest["version"], "available" => $available, "manifest" => $manifest, ]; } // --------------------------------------------------------------------------- // Download and extraction // --------------------------------------------------------------------------- function manageUpdateDownloadPackage(array $manifest, string $targetFile): void { manageEnsureDir(dirname($targetFile)); $version = (string) $manifest["version"]; $response = manageClientRequest( "GET", "package.php?version=" . rawurlencode($version), null, "application/json", (int) MANAGE_HTTP_TIMEOUT_LONG, ); if ($response["status"] < 200 || $response["status"] >= 300) { throw new RuntimeException(manageClientErrorMessage($response["status"], $response["body"])); } if ($response["body"] === "") { throw new RuntimeException("Das heruntergeladene Paket ist leer."); } if (file_put_contents($targetFile, $response["body"], LOCK_EX) === false) { throw new RuntimeException("Das heruntergeladene Paket konnte nicht gespeichert werden."); } if ($manifest["size"] > 0 && filesize($targetFile) !== $manifest["size"]) { unlink($targetFile); throw new RuntimeException("Größe des heruntergeladenen Pakets stimmt nicht überein."); } $actualHash = strtolower(hash_file("sha256", $targetFile) ?: ""); if ($actualHash !== $manifest["sha256"]) { unlink($targetFile); throw new RuntimeException("Prüfsumme des Pakets stimmt nicht überein."); } } // Rejects zip-slip and anything else that would escape the stage directory. function manageUpdateValidateZipEntry(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) === 1 ) { return false; } foreach (explode("/", $normalized) as $segment) { if ($segment === "" || $segment === "." || $segment === "..") { return false; } } return true; } function manageUpdateExtractPackage(string $zipFile, string $stageDir): void { if (!class_exists("ZipArchive")) { throw new RuntimeException("Die PHP-Erweiterung ZipArchive ist nicht verfügbar."); } manageRemoveDir($stageDir); manageEnsureDir($stageDir); $zip = new ZipArchive(); if ($zip->open($zipFile) !== true) { throw new RuntimeException("Das heruntergeladene Paket ist keine lesbare ZIP-Datei."); } $sanityPaths = is_array(MANAGE_UPDATE_SANITY_PATHS) ? MANAGE_UPDATE_SANITY_PATHS : []; $hasAppFile = $sanityPaths === []; for ($i = 0; $i < $zip->numFiles; $i++) { $name = (string) $zip->getNameIndex($i); if (!manageUpdateValidateZipEntry($name)) { $zip->close(); throw new RuntimeException("Das Paket enthält einen unsicheren Pfad: " . $name); } foreach ($sanityPaths as $sanityPath) { $sanityPath = trim(str_replace("\\", "/", (string) $sanityPath), "/"); if ($sanityPath === "") { continue; } if ($name === $sanityPath || str_starts_with($name, $sanityPath . "/")) { $hasAppFile = true; } } } if (!$hasAppFile) { $zip->close(); throw new RuntimeException( "Das Paket sieht nicht wie ein Release dieser Anwendung aus (erwartet: " . implode(", ", array_map("strval", $sanityPaths)) . ").", ); } if (!$zip->extractTo($stageDir)) { $zip->close(); throw new RuntimeException("Das Paket konnte nicht entpackt werden."); } $zip->close(); } // --------------------------------------------------------------------------- // Deployment // --------------------------------------------------------------------------- function manageUpdateRelativePath(string $path, string $baseDir): string { return ltrim(str_replace("\\", "/", substr($path, strlen($baseDir))), "/"); } /** * Whether a path from the package must be left alone. * * A configured entry ending in "/" protects the directory and everything below * it; anything else matches the exact path. */ function manageUpdateShouldSkipPath(string $relativePath): bool { $relativePath = trim(str_replace("\\", "/", $relativePath), "/"); if ($relativePath === "") { return true; } $protected = is_array(MANAGE_UPDATE_PROTECTED_PATHS) ? MANAGE_UPDATE_PROTECTED_PATHS : []; foreach ($protected as $entry) { $entry = str_replace("\\", "/", (string) $entry); $isDirectory = str_ends_with($entry, "/"); $entry = trim($entry, "/"); if ($entry === "") { continue; } if ($relativePath === $entry) { return true; } if ($isDirectory && str_starts_with($relativePath, $entry . "/")) { return true; } // A protected directory named without a trailing slash still protects // its contents; the trailing slash only documents the intent. if (!$isDirectory && str_starts_with($relativePath, $entry . "/")) { return true; } } return false; } function manageUpdateCopyWithBackup(string $stageDir, string $appRoot, string $backupDir): array { manageEnsureDir($backupDir); $copied = 0; $backedUp = 0; $skipped = 0; $items = new RecursiveIteratorIterator( new RecursiveDirectoryIterator($stageDir, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::SELF_FIRST, ); foreach ($items as $item) { $relativePath = manageUpdateRelativePath($item->getPathname(), $stageDir); if (manageUpdateShouldSkipPath($relativePath)) { $skipped++; continue; } $targetPath = $appRoot . DIRECTORY_SEPARATOR . $relativePath; if ($item->isDir()) { manageEnsureDir($targetPath); continue; } manageEnsureDir(dirname($targetPath)); if (file_exists($targetPath)) { $backupPath = $backupDir . DIRECTORY_SEPARATOR . $relativePath; manageEnsureDir(dirname($backupPath)); if (!copy($targetPath, $backupPath)) { throw new RuntimeException("Datei konnte nicht gesichert werden: " . $relativePath); } $backedUp++; } if (!copy($item->getPathname(), $targetPath)) { throw new RuntimeException("Datei konnte nicht ausgerollt werden: " . $relativePath); } @chmod($targetPath, fileperms($item->getPathname()) & 0777); $copied++; } return ["copied" => $copied, "backed_up" => $backedUp, "skipped" => $skipped]; } // Keeps only the backup directory of the run that just finished. function manageUpdateCleanupOldBackups(string $keepBackupDir): int { $backupRoot = rtrim(manageUpdateBackupRoot(), "/\\"); if (!is_dir($backupRoot)) { return 0; } $keepRealPath = realpath($keepBackupDir); $backupRootRealPath = realpath($backupRoot); if ($keepRealPath === false || $backupRootRealPath === false) { return 0; } $removed = 0; foreach (new DirectoryIterator($backupRootRealPath) as $item) { if ($item->isDot() || !$item->isDir()) { continue; } $path = $item->getPathname(); if (realpath($path) === $keepRealPath) { continue; } manageRemoveDir($path); if (is_dir($path)) { throw new RuntimeException("Altes Backup-Verzeichnis konnte nicht entfernt werden: " . $path); } $removed++; } return $removed; } /** * Downloads, verifies and deploys one release, then runs the post-update step. * * $options: * force bool redeploy even when no newer version is available * skip_hook bool deploy files only, run neither migrations nor the callback * * The returned array always reports deployment and post-update separately: * a failed hook does not undo a successful deployment. */ function manageUpdateApply(array $options = []): array { $force = !empty($options["force"]); $skipHook = !empty($options["skip_hook"]); $appRoot = manageClientAppRoot(); $check = manageUpdateCheck(); $manifest = $check["manifest"]; if (!$check["available"] && !$force) { throw new RuntimeException( "Es ist kein neueres Update verfügbar. Mit der Option \"force\" kann dasselbe Paket erneut ausgerollt werden.", ); } $runId = date("Ymd-His"); $workDir = manageUpdateWorkDir() . $runId; $stageDir = $workDir . DIRECTORY_SEPARATOR . "stage"; $zipFile = $workDir . DIRECTORY_SEPARATOR . "package.zip"; $backupDir = manageUpdateBackupRoot() . $runId . "-" . $manifest["version"]; manageEnsureDir($workDir); try { manageUpdateDownloadPackage($manifest, $zipFile); manageUpdateExtractPackage($zipFile, $stageDir); $result = manageUpdateCopyWithBackup($stageDir, $appRoot, $backupDir); } finally { manageRemoveDir($workDir); } $removedBackups = manageUpdateCleanupOldBackups($backupDir); manageClientLog("INFO", "Update deployed", [ "from_version" => $check["current"], "to_version" => $manifest["version"], "copied" => $result["copied"], "backed_up" => $result["backed_up"], "backup_dir" => $backupDir, ]); $report = [ "deployed" => true, "from_version" => $check["current"], "to_version" => $manifest["version"], "version" => manageClientVersion(), "copied" => $result["copied"], "backed_up" => $result["backed_up"], "skipped" => $result["skipped"], "removed_backups" => $removedBackups, "backup_dir" => $backupDir, "hook" => null, ]; if ($skipHook) { $report["hook"] = [ "success" => true, "skipped" => true, "migrations" => ["applied" => [], "pending" => count(manageUpdatePendingMigrations())], ]; return $report; } // The version constant may already be loaded in this process from the old // code, so to_version is taken from the manifest rather than re-read. $report["hook"] = manageUpdateRunPostHook([ "from_version" => $check["current"], "to_version" => $manifest["version"], "backup_dir" => $backupDir, "run_id" => $runId, ]); return $report; }