updater.php 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503
  1. <?php
  2. require_once __DIR__ . "/../config.php";
  3. require_once __DIR__ . "/../includes/version.php";
  4. if (empty($_SESSION["admin_logged_in"])) {
  5. header("Location: login.php");
  6. exit();
  7. }
  8. if (!defined("UPDATE_MANIFEST_URL")) {
  9. define("UPDATE_MANIFEST_URL", "");
  10. }
  11. if (!defined("UPDATE_WORK_DIR")) {
  12. define("UPDATE_WORK_DIR", DATA_DIR . "updates/work/");
  13. }
  14. if (!defined("UPDATE_BACKUP_DIR")) {
  15. define("UPDATE_BACKUP_DIR", DATA_DIR . "updates/backups/");
  16. }
  17. $appRoot = realpath(__DIR__ . "/..");
  18. $messages = [];
  19. $errors = [];
  20. function updaterEscape($value): string
  21. {
  22. return htmlspecialchars((string) $value, ENT_QUOTES, "UTF-8");
  23. }
  24. function updaterCsrfToken(): string
  25. {
  26. if (empty($_SESSION["updater_csrf_token"])) {
  27. $_SESSION["updater_csrf_token"] = bin2hex(random_bytes(32));
  28. }
  29. return $_SESSION["updater_csrf_token"];
  30. }
  31. function updaterValidateCsrfToken(string $token): bool
  32. {
  33. return !empty($_SESSION["updater_csrf_token"]) &&
  34. hash_equals($_SESSION["updater_csrf_token"], $token);
  35. }
  36. function updaterVersionToCompare(string $version): string
  37. {
  38. return ltrim(trim($version), "vV");
  39. }
  40. function updaterIsVersion(string $version): bool
  41. {
  42. return preg_match('/^v\d+\.\d+\.\d+$/', $version) === 1;
  43. }
  44. function updaterEnsureDirectory(string $dir): void
  45. {
  46. if (!is_dir($dir) && !mkdir($dir, 02775, true) && !is_dir($dir)) {
  47. throw new RuntimeException("Directory cannot be created: " . $dir);
  48. }
  49. @chmod($dir, 02775);
  50. }
  51. function updaterRemoveDirectory(string $dir): void
  52. {
  53. if (!is_dir($dir)) {
  54. return;
  55. }
  56. $items = new RecursiveIteratorIterator(
  57. new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS),
  58. RecursiveIteratorIterator::CHILD_FIRST,
  59. );
  60. foreach ($items as $item) {
  61. if ($item->isDir()) {
  62. rmdir($item->getPathname());
  63. } else {
  64. unlink($item->getPathname());
  65. }
  66. }
  67. rmdir($dir);
  68. }
  69. function updaterHttpGet(string $url, int $timeout = 30): string
  70. {
  71. if (!filter_var($url, FILTER_VALIDATE_URL)) {
  72. throw new RuntimeException("Invalid URL: " . $url);
  73. }
  74. $context = stream_context_create([
  75. "http" => [
  76. "method" => "GET",
  77. "timeout" => $timeout,
  78. "ignore_errors" => true,
  79. "header" => "User-Agent: PSA-Orderform-Updater/" . APP_VERSION . "\r\n",
  80. ],
  81. ]);
  82. $body = @file_get_contents($url, false, $context);
  83. $status = 0;
  84. $responseHeaders = function_exists("http_get_last_response_headers")
  85. ? http_get_last_response_headers()
  86. : [];
  87. if (is_array($responseHeaders)) {
  88. foreach ($responseHeaders as $header) {
  89. if (preg_match('/^HTTP\/\S+\s+(\d+)/', $header, $matches)) {
  90. $status = (int) $matches[1];
  91. }
  92. }
  93. }
  94. if ($body === false || ($status >= 400 && $status < 600)) {
  95. throw new RuntimeException(
  96. "HTTP request failed" . ($status > 0 ? " with status " . $status : "") . ".",
  97. );
  98. }
  99. return $body;
  100. }
  101. function updaterFetchManifest(): array
  102. {
  103. $url = trim((string) UPDATE_MANIFEST_URL);
  104. if ($url === "") {
  105. throw new RuntimeException("UPDATE_MANIFEST_URL is not configured.");
  106. }
  107. $body = updaterHttpGet($url, 15);
  108. $manifest = json_decode($body, true);
  109. if (!is_array($manifest)) {
  110. throw new RuntimeException("Manifest response is not valid JSON.");
  111. }
  112. $version = trim((string) ($manifest["version"] ?? $manifest["latest"] ?? ""));
  113. $packageUrl = trim((string) ($manifest["package_url"] ?? ""));
  114. $sha256 = strtolower(trim((string) ($manifest["sha256"] ?? "")));
  115. $size = isset($manifest["size"]) ? (int) $manifest["size"] : 0;
  116. $publishedAt = trim((string) ($manifest["published_at"] ?? ""));
  117. if (!updaterIsVersion($version)) {
  118. throw new RuntimeException("Manifest version is invalid.");
  119. }
  120. if (!filter_var($packageUrl, FILTER_VALIDATE_URL)) {
  121. throw new RuntimeException("Manifest package URL is invalid.");
  122. }
  123. if (!preg_match('/^[a-f0-9]{64}$/', $sha256)) {
  124. throw new RuntimeException("Manifest checksum is invalid.");
  125. }
  126. return [
  127. "version" => $version,
  128. "package_url" => $packageUrl,
  129. "sha256" => $sha256,
  130. "size" => $size,
  131. "published_at" => $publishedAt,
  132. ];
  133. }
  134. function updaterDownloadPackage(array $manifest, string $targetFile): void
  135. {
  136. updaterEnsureDirectory(dirname($targetFile));
  137. $data = updaterHttpGet($manifest["package_url"], 120);
  138. if ($data === "") {
  139. throw new RuntimeException("Downloaded package is empty.");
  140. }
  141. if (file_put_contents($targetFile, $data, LOCK_EX) === false) {
  142. throw new RuntimeException("Downloaded package cannot be written.");
  143. }
  144. if ($manifest["size"] > 0 && filesize($targetFile) !== $manifest["size"]) {
  145. unlink($targetFile);
  146. throw new RuntimeException("Downloaded package size mismatch.");
  147. }
  148. $actualHash = strtolower(hash_file("sha256", $targetFile) ?: "");
  149. if ($actualHash !== $manifest["sha256"]) {
  150. unlink($targetFile);
  151. throw new RuntimeException("Package checksum mismatch.");
  152. }
  153. }
  154. function updaterValidateZipEntry(string $entry): bool
  155. {
  156. $entry = str_replace("\\", "/", $entry);
  157. $normalized = trim($entry, "/");
  158. if (
  159. $normalized === "" ||
  160. str_contains($entry, "\0") ||
  161. str_starts_with($entry, "/") ||
  162. preg_match('/^[A-Za-z]:\//', $entry)
  163. ) {
  164. return false;
  165. }
  166. foreach (explode("/", $normalized) as $segment) {
  167. if ($segment === "" || $segment === "." || $segment === "..") {
  168. return false;
  169. }
  170. }
  171. return true;
  172. }
  173. function updaterExtractPackage(string $zipFile, string $stageDir): void
  174. {
  175. if (!class_exists("ZipArchive")) {
  176. throw new RuntimeException("PHP ZipArchive extension is not available.");
  177. }
  178. updaterRemoveDirectory($stageDir);
  179. updaterEnsureDirectory($stageDir);
  180. $zip = new ZipArchive();
  181. if ($zip->open($zipFile) !== true) {
  182. throw new RuntimeException("Downloaded package is not a readable ZIP file.");
  183. }
  184. $hasAppFile = false;
  185. for ($i = 0; $i < $zip->numFiles; $i++) {
  186. $name = (string) $zip->getNameIndex($i);
  187. if (!updaterValidateZipEntry($name)) {
  188. $zip->close();
  189. throw new RuntimeException("ZIP contains an unsafe path: " . $name);
  190. }
  191. if (
  192. $name === "index.php" ||
  193. str_starts_with($name, "admin/") ||
  194. str_starts_with($name, "includes/")
  195. ) {
  196. $hasAppFile = true;
  197. }
  198. }
  199. if (!$hasAppFile) {
  200. $zip->close();
  201. throw new RuntimeException("ZIP does not look like an app-root release package.");
  202. }
  203. if (!$zip->extractTo($stageDir)) {
  204. $zip->close();
  205. throw new RuntimeException("ZIP package cannot be extracted.");
  206. }
  207. $zip->close();
  208. }
  209. function updaterRelativePath(string $path, string $baseDir): string
  210. {
  211. return ltrim(str_replace("\\", "/", substr($path, strlen($baseDir))), "/");
  212. }
  213. function updaterShouldSkipPath(string $relativePath): bool
  214. {
  215. $relativePath = trim(str_replace("\\", "/", $relativePath), "/");
  216. return $relativePath === "" ||
  217. $relativePath === "config.php" ||
  218. $relativePath === "data" ||
  219. str_starts_with($relativePath, "data/") ||
  220. $relativePath === ".git" ||
  221. str_starts_with($relativePath, ".git/");
  222. }
  223. function updaterCopyWithBackup(string $stageDir, string $appRoot, string $backupDir): array
  224. {
  225. updaterEnsureDirectory($backupDir);
  226. $copied = 0;
  227. $backedUp = 0;
  228. $skipped = 0;
  229. $items = new RecursiveIteratorIterator(
  230. new RecursiveDirectoryIterator($stageDir, FilesystemIterator::SKIP_DOTS),
  231. RecursiveIteratorIterator::SELF_FIRST,
  232. );
  233. foreach ($items as $item) {
  234. $relativePath = updaterRelativePath($item->getPathname(), $stageDir);
  235. if (updaterShouldSkipPath($relativePath)) {
  236. $skipped++;
  237. continue;
  238. }
  239. $targetPath = $appRoot . DIRECTORY_SEPARATOR . $relativePath;
  240. if ($item->isDir()) {
  241. updaterEnsureDirectory($targetPath);
  242. continue;
  243. }
  244. updaterEnsureDirectory(dirname($targetPath));
  245. if (file_exists($targetPath)) {
  246. $backupPath = $backupDir . DIRECTORY_SEPARATOR . $relativePath;
  247. updaterEnsureDirectory(dirname($backupPath));
  248. if (!copy($targetPath, $backupPath)) {
  249. throw new RuntimeException("Cannot back up file: " . $relativePath);
  250. }
  251. $backedUp++;
  252. }
  253. if (!copy($item->getPathname(), $targetPath)) {
  254. throw new RuntimeException("Cannot deploy file: " . $relativePath);
  255. }
  256. @chmod($targetPath, fileperms($item->getPathname()) & 0777);
  257. $copied++;
  258. }
  259. return [
  260. "copied" => $copied,
  261. "backed_up" => $backedUp,
  262. "skipped" => $skipped,
  263. ];
  264. }
  265. function updaterCleanupOldBackups(string $keepBackupDir): int
  266. {
  267. $backupRoot = rtrim((string) UPDATE_BACKUP_DIR, "/\\");
  268. if (!is_dir($backupRoot)) {
  269. return 0;
  270. }
  271. $keepRealPath = realpath($keepBackupDir);
  272. $backupRootRealPath = realpath($backupRoot);
  273. if ($keepRealPath === false || $backupRootRealPath === false) {
  274. return 0;
  275. }
  276. $removed = 0;
  277. $items = new DirectoryIterator($backupRootRealPath);
  278. foreach ($items as $item) {
  279. if ($item->isDot() || !$item->isDir()) {
  280. continue;
  281. }
  282. $path = $item->getPathname();
  283. if (realpath($path) === $keepRealPath) {
  284. continue;
  285. }
  286. updaterRemoveDirectory($path);
  287. if (is_dir($path)) {
  288. throw new RuntimeException("Old backup directory could not be removed: " . $path);
  289. }
  290. $removed++;
  291. }
  292. return $removed;
  293. }
  294. function updaterDeploy(array $manifest, string $appRoot): array
  295. {
  296. $runId = date("Ymd-His");
  297. $workDir = rtrim((string) UPDATE_WORK_DIR, "/\\") . DIRECTORY_SEPARATOR . $runId;
  298. $stageDir = $workDir . DIRECTORY_SEPARATOR . "stage";
  299. $zipFile = $workDir . DIRECTORY_SEPARATOR . "package.zip";
  300. $backupDir = rtrim((string) UPDATE_BACKUP_DIR, "/\\") .
  301. DIRECTORY_SEPARATOR .
  302. $runId .
  303. "-" .
  304. $manifest["version"];
  305. updaterEnsureDirectory($workDir);
  306. updaterDownloadPackage($manifest, $zipFile);
  307. updaterExtractPackage($zipFile, $stageDir);
  308. $result = updaterCopyWithBackup($stageDir, $appRoot, $backupDir);
  309. updaterRemoveDirectory($workDir);
  310. $removedBackups = updaterCleanupOldBackups($backupDir);
  311. return [
  312. "backup_dir" => $backupDir,
  313. "copied" => $result["copied"],
  314. "backed_up" => $result["backed_up"],
  315. "removed_backups" => $removedBackups,
  316. "skipped" => $result["skipped"],
  317. ];
  318. }
  319. $manifest = null;
  320. $updateAvailable = false;
  321. try {
  322. $manifest = updaterFetchManifest();
  323. $updateAvailable =
  324. version_compare(
  325. updaterVersionToCompare($manifest["version"]),
  326. updaterVersionToCompare(APP_VERSION),
  327. ">",
  328. );
  329. } catch (Throwable $exception) {
  330. $errors[] = $exception->getMessage();
  331. }
  332. if ($_SERVER["REQUEST_METHOD"] === "POST") {
  333. if (!updaterValidateCsrfToken((string) ($_POST["csrf_token"] ?? ""))) {
  334. $errors[] = "Invalid token. Please reload the page and try again.";
  335. } elseif ($appRoot === false) {
  336. $errors[] = "Application root cannot be resolved.";
  337. } else {
  338. try {
  339. $manifest = updaterFetchManifest();
  340. $force = !empty($_POST["force_redeploy"]);
  341. $updateAvailable =
  342. version_compare(
  343. updaterVersionToCompare($manifest["version"]),
  344. updaterVersionToCompare(APP_VERSION),
  345. ">",
  346. );
  347. if (!$updateAvailable && !$force) {
  348. throw new RuntimeException(
  349. "No newer update is available. Enable force redeployment to deploy this package anyway.",
  350. );
  351. }
  352. $result = updaterDeploy($manifest, $appRoot);
  353. $messages[] = "Deployment finished.";
  354. $messages[] = "Files copied: " . $result["copied"];
  355. $messages[] = "Files backed up: " . $result["backed_up"];
  356. $messages[] = "Old backup directories removed: " . $result["removed_backups"];
  357. $messages[] = "Skipped preserved paths: " . $result["skipped"];
  358. $messages[] = "Backup directory: " . $result["backup_dir"];
  359. } catch (Throwable $exception) {
  360. $errors[] = $exception->getMessage();
  361. }
  362. }
  363. }
  364. ?>
  365. <!DOCTYPE html>
  366. <html lang="de">
  367. <head>
  368. <meta charset="UTF-8">
  369. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  370. <title>Updater</title>
  371. </head>
  372. <body>
  373. <h1>Updater</h1>
  374. <p><a href="settings.php">Back to settings</a></p>
  375. <?php foreach ($messages as $message): ?>
  376. <p><strong><?php echo updaterEscape($message); ?></strong></p>
  377. <?php endforeach; ?>
  378. <?php foreach ($errors as $error): ?>
  379. <p><strong>Error:</strong> <?php echo updaterEscape($error); ?></p>
  380. <?php endforeach; ?>
  381. <table border="1" cellpadding="6" cellspacing="0">
  382. <tbody>
  383. <tr>
  384. <th align="left">Installed version</th>
  385. <td><?php echo updaterEscape(APP_VERSION); ?></td>
  386. </tr>
  387. <tr>
  388. <th align="left">Update target URL</th>
  389. <td><?php echo updaterEscape(UPDATE_MANIFEST_URL); ?></td>
  390. </tr>
  391. <tr>
  392. <th align="left">Available version</th>
  393. <td><?php echo updaterEscape($manifest["version"] ?? "Unavailable"); ?></td>
  394. </tr>
  395. <tr>
  396. <th align="left">Package URL</th>
  397. <td><?php echo updaterEscape($manifest["package_url"] ?? "Unavailable"); ?></td>
  398. </tr>
  399. <tr>
  400. <th align="left">SHA-256</th>
  401. <td><?php echo updaterEscape($manifest["sha256"] ?? "Unavailable"); ?></td>
  402. </tr>
  403. <tr>
  404. <th align="left">Published at</th>
  405. <td><?php echo updaterEscape($manifest["published_at"] ?? "Unavailable"); ?></td>
  406. </tr>
  407. <tr>
  408. <th align="left">Update available</th>
  409. <td><?php echo $updateAvailable ? "Yes" : "No"; ?></td>
  410. </tr>
  411. </tbody>
  412. </table>
  413. <h2>Manual deployment</h2>
  414. <form method="POST">
  415. <input type="hidden" name="csrf_token" value="<?php echo updaterEscape(updaterCsrfToken()); ?>">
  416. <p>
  417. <label>
  418. <input type="checkbox" name="force_redeploy" value="1">
  419. Force redeployment
  420. </label>
  421. </p>
  422. <button type="submit" name="deploy_update" value="1">Deploy update</button>
  423. </form>
  424. </body>
  425. </html>