|
|
@@ -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>
|