updater.php 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465
  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 updaterDeploy(array $manifest, string $appRoot): array
  266. {
  267. $runId = date("Ymd-His");
  268. $workDir = rtrim((string) UPDATE_WORK_DIR, "/\\") . DIRECTORY_SEPARATOR . $runId;
  269. $stageDir = $workDir . DIRECTORY_SEPARATOR . "stage";
  270. $zipFile = $workDir . DIRECTORY_SEPARATOR . "package.zip";
  271. $backupDir = rtrim((string) UPDATE_BACKUP_DIR, "/\\") .
  272. DIRECTORY_SEPARATOR .
  273. $runId .
  274. "-" .
  275. $manifest["version"];
  276. updaterEnsureDirectory($workDir);
  277. updaterDownloadPackage($manifest, $zipFile);
  278. updaterExtractPackage($zipFile, $stageDir);
  279. $result = updaterCopyWithBackup($stageDir, $appRoot, $backupDir);
  280. updaterRemoveDirectory($workDir);
  281. return [
  282. "backup_dir" => $backupDir,
  283. "copied" => $result["copied"],
  284. "backed_up" => $result["backed_up"],
  285. "skipped" => $result["skipped"],
  286. ];
  287. }
  288. $manifest = null;
  289. $updateAvailable = false;
  290. try {
  291. $manifest = updaterFetchManifest();
  292. $updateAvailable =
  293. version_compare(
  294. updaterVersionToCompare($manifest["version"]),
  295. updaterVersionToCompare(APP_VERSION),
  296. ">",
  297. );
  298. } catch (Throwable $exception) {
  299. $errors[] = $exception->getMessage();
  300. }
  301. if ($_SERVER["REQUEST_METHOD"] === "POST") {
  302. if (!updaterValidateCsrfToken((string) ($_POST["csrf_token"] ?? ""))) {
  303. $errors[] = "Invalid token. Please reload the page and try again.";
  304. } elseif ($appRoot === false) {
  305. $errors[] = "Application root cannot be resolved.";
  306. } else {
  307. try {
  308. $manifest = updaterFetchManifest();
  309. $force = !empty($_POST["force_redeploy"]);
  310. $updateAvailable =
  311. version_compare(
  312. updaterVersionToCompare($manifest["version"]),
  313. updaterVersionToCompare(APP_VERSION),
  314. ">",
  315. );
  316. if (!$updateAvailable && !$force) {
  317. throw new RuntimeException(
  318. "No newer update is available. Enable force redeployment to deploy this package anyway.",
  319. );
  320. }
  321. $result = updaterDeploy($manifest, $appRoot);
  322. $messages[] = "Deployment finished.";
  323. $messages[] = "Files copied: " . $result["copied"];
  324. $messages[] = "Files backed up: " . $result["backed_up"];
  325. $messages[] = "Skipped preserved paths: " . $result["skipped"];
  326. $messages[] = "Backup directory: " . $result["backup_dir"];
  327. } catch (Throwable $exception) {
  328. $errors[] = $exception->getMessage();
  329. }
  330. }
  331. }
  332. ?>
  333. <!DOCTYPE html>
  334. <html lang="de">
  335. <head>
  336. <meta charset="UTF-8">
  337. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  338. <title>Updater</title>
  339. </head>
  340. <body>
  341. <h1>Updater</h1>
  342. <p><a href="settings.php">Back to settings</a></p>
  343. <?php foreach ($messages as $message): ?>
  344. <p><strong><?php echo updaterEscape($message); ?></strong></p>
  345. <?php endforeach; ?>
  346. <?php foreach ($errors as $error): ?>
  347. <p><strong>Error:</strong> <?php echo updaterEscape($error); ?></p>
  348. <?php endforeach; ?>
  349. <table border="1" cellpadding="6" cellspacing="0">
  350. <tbody>
  351. <tr>
  352. <th align="left">Installed version</th>
  353. <td><?php echo updaterEscape(APP_VERSION); ?></td>
  354. </tr>
  355. <tr>
  356. <th align="left">Update target URL</th>
  357. <td><?php echo updaterEscape(UPDATE_MANIFEST_URL); ?></td>
  358. </tr>
  359. <tr>
  360. <th align="left">Available version</th>
  361. <td><?php echo updaterEscape($manifest["version"] ?? "Unavailable"); ?></td>
  362. </tr>
  363. <tr>
  364. <th align="left">Package URL</th>
  365. <td><?php echo updaterEscape($manifest["package_url"] ?? "Unavailable"); ?></td>
  366. </tr>
  367. <tr>
  368. <th align="left">SHA-256</th>
  369. <td><?php echo updaterEscape($manifest["sha256"] ?? "Unavailable"); ?></td>
  370. </tr>
  371. <tr>
  372. <th align="left">Published at</th>
  373. <td><?php echo updaterEscape($manifest["published_at"] ?? "Unavailable"); ?></td>
  374. </tr>
  375. <tr>
  376. <th align="left">Update available</th>
  377. <td><?php echo $updateAvailable ? "Yes" : "No"; ?></td>
  378. </tr>
  379. </tbody>
  380. </table>
  381. <h2>Manual deployment</h2>
  382. <form method="POST">
  383. <input type="hidden" name="csrf_token" value="<?php echo updaterEscape(updaterCsrfToken()); ?>">
  384. <p>
  385. <label>
  386. <input type="checkbox" name="force_redeploy" value="1">
  387. Force redeployment
  388. </label>
  389. </p>
  390. <button type="submit" name="deploy_update" value="1">Deploy update</button>
  391. </form>
  392. </body>
  393. </html>