backups.php 20 KB

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