backups.php 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616
  1. <?php
  2. declare(strict_types=1);
  3. // Server-side backup storage: receiving, indexing, S3 archiving and the two
  4. // retention tiers. Ported from the PSA order system (backup-server/lib.php),
  5. // with the instance allowlist replaced by the token-authenticated registry in
  6. // includes/instances.php.
  7. require_once __DIR__ . "/bootstrap.php";
  8. require_once __DIR__ . "/instances.php";
  9. require_once __DIR__ . "/s3.php";
  10. function manageBackupFilenamePattern(): string
  11. {
  12. return '/^backup-\d{8}-\d{6}(?:-\d+)?\.zip$/';
  13. }
  14. function manageBackupInstanceDir(string $instance): string
  15. {
  16. return manageBackupsDir() . $instance . DIRECTORY_SEPARATOR;
  17. }
  18. function manageBackupPath(string $instance, string $filename): string
  19. {
  20. return manageBackupInstanceDir($instance) . $filename;
  21. }
  22. function manageBackupReadIndex(): array
  23. {
  24. $index = manageReadJsonFile(manageBackupIndexFile());
  25. $backups = isset($index["backups"]) && is_array($index["backups"])
  26. ? $index["backups"]
  27. : [];
  28. return ["backups" => array_values($backups)];
  29. }
  30. function manageBackupWriteIndex(array $backups): void
  31. {
  32. manageWriteJsonFile(manageBackupIndexFile(), [
  33. "backups" => array_values($backups),
  34. ]);
  35. }
  36. function manageBackupUpdateIndexRecord(string $instance, string $filename, callable $update): void
  37. {
  38. $index = manageBackupReadIndex();
  39. foreach ($index["backups"] as $position => $backup) {
  40. if (
  41. is_array($backup) &&
  42. ($backup["instance"] ?? "") === $instance &&
  43. ($backup["filename"] ?? "") === $filename
  44. ) {
  45. $index["backups"][$position] = $update($backup);
  46. }
  47. }
  48. manageBackupWriteIndex($index["backups"]);
  49. }
  50. // Every instance that appears in the backup index, including instances that
  51. // were removed from the registry but still have stored history.
  52. function manageBackupIndexInstances(): array
  53. {
  54. $instances = [];
  55. foreach (manageBackupReadIndex()["backups"] as $backup) {
  56. if (is_array($backup)) {
  57. $instance = (string) ($backup["instance"] ?? "");
  58. if ($instance !== "") {
  59. $instances[$instance] = true;
  60. }
  61. }
  62. }
  63. return array_keys($instances);
  64. }
  65. function manageBackupListForInstance(string $instance): array
  66. {
  67. $backups = [];
  68. foreach (manageBackupReadIndex()["backups"] as $backup) {
  69. if (is_array($backup) && ($backup["instance"] ?? "") === $instance) {
  70. $backups[] = $backup;
  71. }
  72. }
  73. usort($backups, static function ($left, $right): int {
  74. return strcmp((string) ($right["uploaded_at"] ?? ""), (string) ($left["uploaded_at"] ?? ""));
  75. });
  76. return $backups;
  77. }
  78. function manageBackupGroupByInstance(): array
  79. {
  80. $grouped = [];
  81. foreach (manageBackupReadIndex()["backups"] as $backup) {
  82. if (!is_array($backup)) {
  83. continue;
  84. }
  85. $instance = (string) ($backup["instance"] ?? "");
  86. if ($instance === "") {
  87. continue;
  88. }
  89. $grouped[$instance][] = $backup;
  90. }
  91. foreach ($grouped as $instance => $backups) {
  92. usort($backups, static function ($left, $right): int {
  93. return strcmp((string) ($right["uploaded_at"] ?? ""), (string) ($left["uploaded_at"] ?? ""));
  94. });
  95. $grouped[$instance] = $backups;
  96. }
  97. ksort($grouped);
  98. return $grouped;
  99. }
  100. function manageBackupFind(string $instance, string $filename): ?array
  101. {
  102. foreach (manageBackupReadIndex()["backups"] as $backup) {
  103. if (
  104. is_array($backup) &&
  105. ($backup["instance"] ?? "") === $instance &&
  106. ($backup["filename"] ?? "") === $filename
  107. ) {
  108. return $backup;
  109. }
  110. }
  111. return null;
  112. }
  113. function manageBackupValidateFilename(string $filename): string
  114. {
  115. $filename = trim($filename);
  116. if (
  117. $filename === "" ||
  118. basename($filename) !== $filename ||
  119. preg_match(manageBackupFilenamePattern(), $filename) !== 1
  120. ) {
  121. throw new RuntimeException("Ungültiger Backup-Dateiname.");
  122. }
  123. return $filename;
  124. }
  125. // Never overwrites an existing file: a repeated filename gets a -2, -3, ... suffix.
  126. function manageBackupChooseFilename(string $clientFilename, string $instanceDir): string
  127. {
  128. $clientFilename = trim($clientFilename);
  129. if ($clientFilename === "") {
  130. $filename = "backup-" . gmdate("Ymd-His") . ".zip";
  131. } else {
  132. $filename = manageBackupValidateFilename($clientFilename);
  133. }
  134. $base = substr($filename, 0, -4);
  135. $counter = 2;
  136. while (is_file($instanceDir . $filename)) {
  137. $filename = $base . "-" . $counter . ".zip";
  138. $counter++;
  139. }
  140. return $filename;
  141. }
  142. // ---------------------------------------------------------------------------
  143. // Settings (UI values take precedence over the config constants)
  144. // ---------------------------------------------------------------------------
  145. function manageBackupSettings(): array
  146. {
  147. $settings = manageReadJsonFile(manageSettingsFile());
  148. return [
  149. "retention" => isset($settings["retention"])
  150. ? max(1, (int) $settings["retention"])
  151. : max(1, (int) MANAGE_BACKUP_RETENTION),
  152. "s3_retention" => isset($settings["s3_retention"])
  153. ? max(1, (int) $settings["s3_retention"])
  154. : max(1, (int) MANAGE_S3_RETENTION),
  155. ];
  156. }
  157. function manageBackupWriteSettings(array $settings): void
  158. {
  159. manageWriteJsonFile(manageSettingsFile(), [
  160. "retention" => max(1, (int) ($settings["retention"] ?? MANAGE_BACKUP_RETENTION)),
  161. "s3_retention" => max(1, (int) ($settings["s3_retention"] ?? MANAGE_S3_RETENTION)),
  162. ]);
  163. }
  164. // ---------------------------------------------------------------------------
  165. // S3 sync
  166. // ---------------------------------------------------------------------------
  167. // Uploads every local backup of the instance that is not yet confirmed in S3,
  168. // oldest first. Serves both the immediate upload after receiving a backup and
  169. // the opportunistic retry of earlier failures. Stops at the first failure
  170. // because the endpoint is then most likely unreachable.
  171. function manageBackupSyncInstanceS3(string $instance): array
  172. {
  173. $result = ["uploaded" => 0, "pending" => 0, "error" => null];
  174. if (!manageS3Enabled()) {
  175. return $result;
  176. }
  177. $pending = [];
  178. foreach (manageBackupReadIndex()["backups"] as $backup) {
  179. if (!is_array($backup) || ($backup["instance"] ?? "") !== $instance) {
  180. continue;
  181. }
  182. if (!empty($backup["s3_uploaded_at"])) {
  183. continue;
  184. }
  185. $filename = basename((string) ($backup["filename"] ?? ""));
  186. if ($filename === "" || !is_file(manageBackupPath($instance, $filename))) {
  187. continue;
  188. }
  189. $backup["filename"] = $filename;
  190. $pending[] = $backup;
  191. }
  192. usort($pending, static function ($left, $right): int {
  193. return strcmp((string) ($left["uploaded_at"] ?? ""), (string) ($right["uploaded_at"] ?? ""));
  194. });
  195. foreach ($pending as $position => $backup) {
  196. $filename = (string) $backup["filename"];
  197. $key = (string) ($backup["s3_key"] ?? "");
  198. if ($key === "") {
  199. $key = manageS3ObjectKey($instance, $filename);
  200. }
  201. try {
  202. manageS3PutFile(manageBackupPath($instance, $filename), $key);
  203. } catch (Throwable $exception) {
  204. $result["pending"] = count($pending) - $position;
  205. $result["error"] = $exception->getMessage();
  206. manageBackupUpdateIndexRecord($instance, $filename, static function (array $record) use ($key, $exception): array {
  207. $record["s3_key"] = $key;
  208. $record["s3_last_error"] = $exception->getMessage();
  209. $record["s3_last_attempt_at"] = date(DATE_ATOM);
  210. return $record;
  211. });
  212. manageLogS3("S3 upload failed", [
  213. "instance" => $instance,
  214. "filename" => $filename,
  215. "key" => $key,
  216. "error" => $exception->getMessage(),
  217. ]);
  218. return $result;
  219. }
  220. manageBackupUpdateIndexRecord($instance, $filename, static function (array $record) use ($key): array {
  221. $record["s3_key"] = $key;
  222. $record["s3_uploaded_at"] = date(DATE_ATOM);
  223. unset($record["s3_last_error"], $record["s3_last_attempt_at"], $record["s3_expired"]);
  224. return $record;
  225. });
  226. $result["uploaded"]++;
  227. }
  228. return $result;
  229. }
  230. function manageBackupSyncAllS3(): array
  231. {
  232. $total = ["uploaded" => 0, "pending" => 0, "error" => null];
  233. foreach (manageBackupIndexInstances() as $instance) {
  234. $result = manageBackupSyncInstanceS3($instance);
  235. $total["uploaded"] += $result["uploaded"];
  236. $total["pending"] += $result["pending"];
  237. if ($result["error"] !== null && $total["error"] === null) {
  238. $total["error"] = $result["error"];
  239. }
  240. }
  241. return $total;
  242. }
  243. // ---------------------------------------------------------------------------
  244. // Retention
  245. // ---------------------------------------------------------------------------
  246. // Applies both retention tiers for one instance. S3 keeps the newest
  247. // s3_retention archived backups; local keeps the newest retention copies but
  248. // never deletes a file whose S3 upload is still pending.
  249. function manageBackupApplyRetention(string $instance): void
  250. {
  251. $index = manageBackupReadIndex();
  252. $settings = manageBackupSettings();
  253. $s3Enabled = manageS3Enabled();
  254. $instanceBackups = [];
  255. $otherBackups = [];
  256. foreach ($index["backups"] as $backup) {
  257. if (!is_array($backup)) {
  258. continue;
  259. }
  260. if (($backup["instance"] ?? "") === $instance) {
  261. $instanceBackups[] = $backup;
  262. } else {
  263. $otherBackups[] = $backup;
  264. }
  265. }
  266. usort($instanceBackups, static function ($left, $right): int {
  267. return strcmp((string) ($right["uploaded_at"] ?? ""), (string) ($left["uploaded_at"] ?? ""));
  268. });
  269. if ($s3Enabled) {
  270. $archivedSeen = 0;
  271. foreach ($instanceBackups as $position => $backup) {
  272. if (empty($backup["s3_uploaded_at"])) {
  273. continue;
  274. }
  275. $archivedSeen++;
  276. if ($archivedSeen <= $settings["s3_retention"]) {
  277. continue;
  278. }
  279. $filename = basename((string) ($backup["filename"] ?? ""));
  280. $key = (string) ($backup["s3_key"] ?? "");
  281. if ($key === "" && $filename !== "") {
  282. $key = manageS3ObjectKey($instance, $filename);
  283. }
  284. try {
  285. if ($key !== "") {
  286. manageS3DeleteObject($key);
  287. }
  288. } catch (Throwable $exception) {
  289. manageLogS3("S3 retention delete failed", [
  290. "instance" => $instance,
  291. "filename" => $filename,
  292. "key" => $key,
  293. "error" => $exception->getMessage(),
  294. ]);
  295. continue;
  296. }
  297. unset($backup["s3_uploaded_at"], $backup["s3_key"]);
  298. $backup["s3_expired"] = true;
  299. $instanceBackups[$position] = $backup;
  300. }
  301. }
  302. $localSeen = 0;
  303. $kept = [];
  304. foreach ($instanceBackups as $backup) {
  305. $filename = basename((string) ($backup["filename"] ?? ""));
  306. $path = $filename !== "" ? manageBackupPath($instance, $filename) : "";
  307. $localExists = $path !== "" && is_file($path);
  308. $inS3 = !empty($backup["s3_uploaded_at"]);
  309. if (!$localExists) {
  310. if ($inS3) {
  311. $kept[] = $backup;
  312. }
  313. // Present in neither store: drop the orphaned record.
  314. continue;
  315. }
  316. $localSeen++;
  317. if ($localSeen <= $settings["retention"]) {
  318. $kept[] = $backup;
  319. continue;
  320. }
  321. if ($inS3) {
  322. @unlink($path);
  323. $backup["local_deleted_at"] = date(DATE_ATOM);
  324. $kept[] = $backup;
  325. continue;
  326. }
  327. if ($s3Enabled && empty($backup["s3_expired"])) {
  328. // The only copy lives locally until the S3 upload succeeds.
  329. $kept[] = $backup;
  330. continue;
  331. }
  332. // S3 disabled or the backup already aged out of the bucket.
  333. @unlink($path);
  334. }
  335. manageBackupWriteIndex(array_merge($otherBackups, $kept));
  336. }
  337. function manageBackupApplyRetentionAll(): void
  338. {
  339. foreach (manageBackupIndexInstances() as $instance) {
  340. manageBackupApplyRetention($instance);
  341. }
  342. }
  343. // ---------------------------------------------------------------------------
  344. // Store / delete / download
  345. // ---------------------------------------------------------------------------
  346. /**
  347. * Moves a validated upload into place, indexes it, archives it and applies
  348. * retention. $sourcePath must already have passed is_uploaded_file().
  349. */
  350. function manageBackupStoreUpload(
  351. string $instance,
  352. string $sourcePath,
  353. string $clientFilename,
  354. string $expectedSha256,
  355. array $meta = [],
  356. ): array {
  357. $instanceDir = manageBackupInstanceDir($instance);
  358. manageEnsureDirectory($instanceDir);
  359. $filename = manageBackupChooseFilename($clientFilename, $instanceDir);
  360. $targetPath = $instanceDir . $filename;
  361. if (!move_uploaded_file($sourcePath, $targetPath)) {
  362. throw new RuntimeException("Backup konnte nicht gespeichert werden.");
  363. }
  364. @chmod($targetPath, 0664);
  365. $size = filesize($targetPath);
  366. $sha256 = strtolower(hash_file("sha256", $targetPath) ?: "");
  367. if ($size === false || $size <= 0 || preg_match('/^[a-f0-9]{64}$/', $sha256) !== 1) {
  368. @unlink($targetPath);
  369. throw new RuntimeException("Gespeichertes Backup konnte nicht verifiziert werden.");
  370. }
  371. $expectedSha256 = strtolower(trim($expectedSha256));
  372. if ($expectedSha256 !== "" && $expectedSha256 !== $sha256) {
  373. @unlink($targetPath);
  374. throw new RuntimeException("Prüfsumme des Backups stimmt nicht überein.");
  375. }
  376. $index = manageBackupReadIndex();
  377. $index["backups"][] = [
  378. "instance" => $instance,
  379. "filename" => $filename,
  380. "client_filename" => basename($clientFilename !== "" ? $clientFilename : $filename),
  381. "size" => $size,
  382. "sha256" => $sha256,
  383. "uploaded_at" => date(DATE_ATOM),
  384. "source_ip" => $_SERVER["REMOTE_ADDR"] ?? "unknown",
  385. "trigger" => (string) ($meta["trigger"] ?? ""),
  386. "file_count" => (int) ($meta["file_count"] ?? 0),
  387. "source_bytes" => (int) ($meta["source_bytes"] ?? 0),
  388. "app_version" => (string) ($meta["app_version"] ?? ""),
  389. ];
  390. manageBackupWriteIndex($index["backups"]);
  391. // S3 problems must never fail the upload: the local copy exists and the
  392. // sync is retried on the next upload or from the management UI.
  393. $s3Enabled = manageS3Enabled();
  394. $s3Result = ["uploaded" => 0, "pending" => 0, "error" => null];
  395. if ($s3Enabled) {
  396. try {
  397. $s3Result = manageBackupSyncInstanceS3($instance);
  398. } catch (Throwable $exception) {
  399. $s3Result = ["uploaded" => 0, "pending" => 1, "error" => $exception->getMessage()];
  400. manageLogS3("S3 sync crashed", [
  401. "instance" => $instance,
  402. "error" => $exception->getMessage(),
  403. ]);
  404. }
  405. }
  406. manageBackupApplyRetention($instance);
  407. $stored = manageBackupListForInstance($instance);
  408. manageInstanceTouch($instance, []);
  409. try {
  410. manageInstanceUpdate($instance, [
  411. "last_backup_at" => date(DATE_ATOM),
  412. "backup_count" => count($stored),
  413. ]);
  414. } catch (Throwable $exception) {
  415. // Instance was deleted between authentication and storage; the backup
  416. // itself is safe and indexed, so this must not fail the request.
  417. manageLogError("Backup status update failed", [
  418. "instance" => $instance,
  419. "error" => $exception->getMessage(),
  420. ]);
  421. }
  422. manageLogAccess("Backup received", [
  423. "instance" => $instance,
  424. "filename" => $filename,
  425. "size" => $size,
  426. ]);
  427. return [
  428. "filename" => $filename,
  429. "size" => $size,
  430. "sha256" => $sha256,
  431. "retention" => manageBackupSettings()["retention"],
  432. "s3" => [
  433. "enabled" => $s3Enabled,
  434. "uploaded" => $s3Enabled && $s3Result["pending"] === 0,
  435. "pending" => $s3Result["pending"],
  436. ],
  437. ];
  438. }
  439. function manageBackupDelete(string $instance, string $filename): void
  440. {
  441. $instance = manageInstanceValidateId($instance);
  442. $filename = manageBackupValidateFilename($filename);
  443. $record = manageBackupFind($instance, $filename);
  444. if ($record === null) {
  445. throw new RuntimeException("Backup wurde nicht gefunden.");
  446. }
  447. $path = manageBackupPath($instance, $filename);
  448. if (is_file($path)) {
  449. @unlink($path);
  450. }
  451. $key = (string) ($record["s3_key"] ?? "");
  452. if ($key !== "" && !empty($record["s3_uploaded_at"]) && manageS3Enabled()) {
  453. try {
  454. manageS3DeleteObject($key);
  455. } catch (Throwable $exception) {
  456. manageLogS3("S3 delete failed", [
  457. "instance" => $instance,
  458. "filename" => $filename,
  459. "key" => $key,
  460. "error" => $exception->getMessage(),
  461. ]);
  462. throw new RuntimeException(
  463. "Lokale Kopie wurde gelöscht, die S3-Kopie jedoch nicht: " . $exception->getMessage(),
  464. );
  465. }
  466. }
  467. $remaining = [];
  468. foreach (manageBackupReadIndex()["backups"] as $backup) {
  469. if (
  470. is_array($backup) &&
  471. ($backup["instance"] ?? "") === $instance &&
  472. ($backup["filename"] ?? "") === $filename
  473. ) {
  474. continue;
  475. }
  476. $remaining[] = $backup;
  477. }
  478. manageBackupWriteIndex($remaining);
  479. manageLogAccess("Backup deleted", ["instance" => $instance, "filename" => $filename]);
  480. }
  481. // Streams a backup to the browser, from local disk when present and otherwise
  482. // from S3, so the bucket can stay private.
  483. function manageBackupSendDownload(string $instance, string $filename): void
  484. {
  485. $instance = manageInstanceValidateId($instance);
  486. $filename = manageBackupValidateFilename($filename);
  487. $record = manageBackupFind($instance, $filename);
  488. if ($record === null) {
  489. throw new RuntimeException("Backup wurde nicht gefunden.");
  490. }
  491. $path = manageBackupPath($instance, $filename);
  492. if (is_file($path)) {
  493. $size = filesize($path);
  494. $handle = fopen($path, "rb");
  495. if ($handle === false || $size === false) {
  496. throw new RuntimeException("Backup konnte nicht geöffnet werden.");
  497. }
  498. header("Content-Type: application/zip");
  499. header("Content-Disposition: attachment; filename=\"" . addcslashes($filename, "\"\\") . "\"");
  500. header("Content-Length: " . (string) $size);
  501. header("Cache-Control: private, no-store");
  502. header("X-Content-Type-Options: nosniff");
  503. fpassthru($handle);
  504. fclose($handle);
  505. exit;
  506. }
  507. $key = (string) ($record["s3_key"] ?? "");
  508. if ($key === "" || empty($record["s3_uploaded_at"]) || !manageS3Enabled()) {
  509. throw new RuntimeException("Backup-Datei ist weder lokal noch in S3 verfügbar.");
  510. }
  511. manageS3SendObjectToOutput($key, $filename, (int) ($record["size"] ?? 0));
  512. }
  513. // Short label describing where a backup currently lives.
  514. function manageBackupStorageLabel(array $backup): string
  515. {
  516. $instance = (string) ($backup["instance"] ?? "");
  517. $filename = basename((string) ($backup["filename"] ?? ""));
  518. $local = $instance !== "" && $filename !== "" && is_file(manageBackupPath($instance, $filename));
  519. $inS3 = !empty($backup["s3_uploaded_at"]);
  520. if ($local && $inS3) {
  521. return "Lokal + S3";
  522. }
  523. if ($local) {
  524. return !empty($backup["s3_last_error"]) ? "Nur lokal (S3-Fehler)" : "Nur lokal";
  525. }
  526. if ($inS3) {
  527. return "Nur S3";
  528. }
  529. return "Nicht verfügbar";
  530. }