backup.php 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520
  1. <?php
  2. declare(strict_types=1);
  3. // Client-side backup: collecting sources, writing the archive, local retention
  4. // and uploading to the manage server.
  5. //
  6. // Ported from the PSA order system (includes/backup.php). The one structural
  7. // change is that the source list is no longer hardcoded to data/*.json plus
  8. // data/uploads/**: it comes from MANAGE_BACKUP_SOURCES, plus an optional SQL
  9. // dump when MANAGE_BACKUP_DATABASE is configured.
  10. function manageBackupDir(): string
  11. {
  12. return rtrim((string) MANAGE_BACKUP_DIR, "/\\") . DIRECTORY_SEPARATOR;
  13. }
  14. function manageBackupIndexFile(): string
  15. {
  16. return manageBackupDir() . "backup-index.json";
  17. }
  18. function manageBackupLockFile(): string
  19. {
  20. return manageBackupDir() . ".backup.lock";
  21. }
  22. // ---------------------------------------------------------------------------
  23. // Source collection
  24. // ---------------------------------------------------------------------------
  25. // Recursively lists readable files below $dir, mapped to $entryPrefix.
  26. function manageBackupCollectDirectory(string $dir, string $entryPrefix, array &$files): void
  27. {
  28. if (!is_dir($dir)) {
  29. return;
  30. }
  31. $base = rtrim($dir, "/\\") . DIRECTORY_SEPARATOR;
  32. $items = new RecursiveIteratorIterator(
  33. new RecursiveDirectoryIterator($base, FilesystemIterator::SKIP_DOTS),
  34. RecursiveIteratorIterator::LEAVES_ONLY,
  35. );
  36. foreach ($items as $item) {
  37. if (!$item->isFile() || !$item->isReadable()) {
  38. continue;
  39. }
  40. $path = $item->getPathname();
  41. if (manageIsTemporaryFile($path)) {
  42. continue;
  43. }
  44. $relative = ltrim(manageClientNormalizePath(substr($path, strlen($base))), "/");
  45. if ($relative === "" || str_contains($relative, "\0")) {
  46. continue;
  47. }
  48. $files[] = [
  49. "path" => $path,
  50. "name" => trim($entryPrefix . "/" . $relative, "/"),
  51. ];
  52. }
  53. }
  54. /**
  55. * Resolves MANAGE_BACKUP_SOURCES into a flat list of archive entries.
  56. *
  57. * Each source entry supports one of:
  58. * "glob" => "data/*.json" non-recursive shell glob
  59. * "dir" => "data/uploads" recursive directory
  60. * "file" => "settings.ini" single file
  61. * plus an optional "as" prefix for the path inside the archive.
  62. */
  63. function manageBackupCollectSources(): array
  64. {
  65. $root = manageClientAppRoot();
  66. $sources = is_array(MANAGE_BACKUP_SOURCES) ? MANAGE_BACKUP_SOURCES : [];
  67. $files = [];
  68. foreach ($sources as $source) {
  69. if (!is_array($source)) {
  70. continue;
  71. }
  72. $prefix = trim((string) ($source["as"] ?? ""), "/");
  73. if (isset($source["glob"])) {
  74. $pattern = $root . DIRECTORY_SEPARATOR . ltrim((string) $source["glob"], "/\\");
  75. foreach (glob($pattern) ?: [] as $path) {
  76. if (!is_file($path) || !is_readable($path) || manageIsTemporaryFile($path)) {
  77. continue;
  78. }
  79. $files[] = [
  80. "path" => $path,
  81. "name" => trim($prefix . "/" . basename($path), "/"),
  82. ];
  83. }
  84. continue;
  85. }
  86. if (isset($source["dir"])) {
  87. $dir = $root . DIRECTORY_SEPARATOR . ltrim((string) $source["dir"], "/\\");
  88. manageBackupCollectDirectory($dir, $prefix !== "" ? $prefix : basename($dir), $files);
  89. continue;
  90. }
  91. if (isset($source["file"])) {
  92. $path = $root . DIRECTORY_SEPARATOR . ltrim((string) $source["file"], "/\\");
  93. if (is_file($path) && is_readable($path)) {
  94. $files[] = [
  95. "path" => $path,
  96. "name" => trim($prefix . "/" . basename($path), "/"),
  97. ];
  98. }
  99. }
  100. }
  101. // Two sources may resolve to the same archive entry; the first one wins so
  102. // the ZIP can never contain a duplicate name.
  103. $unique = [];
  104. foreach ($files as $file) {
  105. $unique[$file["name"]] = $file;
  106. }
  107. $files = array_values($unique);
  108. usort($files, static function (array $left, array $right): int {
  109. return strcmp($left["name"], $right["name"]);
  110. });
  111. return $files;
  112. }
  113. // ---------------------------------------------------------------------------
  114. // Index
  115. // ---------------------------------------------------------------------------
  116. function manageBackupReadIndex(): array
  117. {
  118. $index = manageReadJson(manageBackupIndexFile());
  119. $records = isset($index["backups"]) && is_array($index["backups"])
  120. ? $index["backups"]
  121. : [];
  122. return ["backups" => array_values($records)];
  123. }
  124. function manageBackupWriteIndex(array $records): void
  125. {
  126. manageWriteJson(manageBackupIndexFile(), ["backups" => array_values($records)]);
  127. }
  128. /**
  129. * Local backups, newest first. Self-healing: index records whose file is gone
  130. * are dropped and sizes are refreshed from disk.
  131. */
  132. function manageBackupList(): array
  133. {
  134. $dir = manageBackupDir();
  135. $existing = [];
  136. foreach (manageBackupReadIndex()["backups"] as $record) {
  137. if (!is_array($record)) {
  138. continue;
  139. }
  140. $filename = basename((string) ($record["filename"] ?? ""));
  141. if ($filename === "" || !is_file($dir . $filename)) {
  142. continue;
  143. }
  144. $record["filename"] = $filename;
  145. $record["size"] = (int) (filesize($dir . $filename) ?: ($record["size"] ?? 0));
  146. $existing[] = $record;
  147. }
  148. usort($existing, static function (array $left, array $right): int {
  149. return strcmp((string) ($right["created_at"] ?? ""), (string) ($left["created_at"] ?? ""));
  150. });
  151. return $existing;
  152. }
  153. function manageBackupRetentionLimit(): int
  154. {
  155. return max(1, (int) MANAGE_BACKUP_LOCAL_RETENTION);
  156. }
  157. function manageBackupApplyRetention(): void
  158. {
  159. $records = manageBackupList();
  160. $keep = manageBackupRetentionLimit();
  161. $dir = manageBackupDir();
  162. foreach (array_slice($records, $keep) as $record) {
  163. $filename = basename((string) ($record["filename"] ?? ""));
  164. if ($filename !== "" && is_file($dir . $filename)) {
  165. @unlink($dir . $filename);
  166. }
  167. }
  168. manageBackupWriteIndex(array_slice(manageBackupList(), 0, $keep));
  169. }
  170. function manageBackupPath(string $filename): string
  171. {
  172. $filename = basename($filename);
  173. if (preg_match('/^backup-\d{8}-\d{6}(?:-\d+)?\.zip$/', $filename) !== 1) {
  174. throw new RuntimeException("Ungültiger Backup-Dateiname: " . $filename);
  175. }
  176. $path = manageBackupDir() . $filename;
  177. if (!is_file($path)) {
  178. throw new RuntimeException("Backup wurde nicht gefunden: " . $filename);
  179. }
  180. return $path;
  181. }
  182. // ---------------------------------------------------------------------------
  183. // Upload to the manage server
  184. // ---------------------------------------------------------------------------
  185. function manageBackupBuildMultipartBody(
  186. array $fields,
  187. string $fileField,
  188. string $filePath,
  189. string $fileName,
  190. string $boundary,
  191. ): string {
  192. $body = "";
  193. foreach ($fields as $name => $value) {
  194. $body .= "--" . $boundary . "\r\n";
  195. $body .= 'Content-Disposition: form-data; name="' . addcslashes((string) $name, "\"\\") . "\"\r\n\r\n";
  196. $body .= (string) $value . "\r\n";
  197. }
  198. $payload = file_get_contents($filePath);
  199. if ($payload === false) {
  200. throw new RuntimeException("Backup-ZIP konnte für den Upload nicht gelesen werden.");
  201. }
  202. $body .= "--" . $boundary . "\r\n";
  203. $body .=
  204. 'Content-Disposition: form-data; name="' . addcslashes($fileField, "\"\\") .
  205. '"; filename="' . addcslashes($fileName, "\"\\") . "\"\r\n";
  206. $body .= "Content-Type: application/zip\r\n\r\n";
  207. $body .= $payload . "\r\n";
  208. $body .= "--" . $boundary . "--\r\n";
  209. return $body;
  210. }
  211. /**
  212. * Uploads one archive to the manage server.
  213. *
  214. * @param array $meta trigger, file_count, source_bytes, sha256, app_version
  215. */
  216. function manageBackupUpload(string $archivePath, array $meta = []): array
  217. {
  218. manageClientRequireConfigured();
  219. if (!is_file($archivePath)) {
  220. throw new RuntimeException("Backup-Datei existiert nicht: " . $archivePath);
  221. }
  222. $filename = basename($archivePath);
  223. $sha256 = strtolower(trim((string) ($meta["sha256"] ?? "")));
  224. if (preg_match('/^[a-f0-9]{64}$/', $sha256) !== 1) {
  225. $sha256 = strtolower(hash_file("sha256", $archivePath) ?: "");
  226. }
  227. if (preg_match('/^[a-f0-9]{64}$/', $sha256) !== 1) {
  228. throw new RuntimeException("Prüfsumme des Backups konnte nicht berechnet werden.");
  229. }
  230. $metaPayload = json_encode([
  231. "trigger" => (string) ($meta["trigger"] ?? "manual"),
  232. "file_count" => (int) ($meta["file_count"] ?? 0),
  233. "source_bytes" => (int) ($meta["source_bytes"] ?? 0),
  234. "app_version" => manageClientVersion(),
  235. ], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
  236. $boundary = "----manage-client-" . bin2hex(random_bytes(12));
  237. $body = manageBackupBuildMultipartBody(
  238. [
  239. "filename" => $filename,
  240. "sha256" => $sha256,
  241. "meta" => $metaPayload === false ? "{}" : $metaPayload,
  242. ],
  243. "backup",
  244. $archivePath,
  245. $filename,
  246. $boundary,
  247. );
  248. $response = manageClientRequest(
  249. "POST",
  250. "backup.php",
  251. $body,
  252. "multipart/form-data; boundary=" . $boundary,
  253. (int) MANAGE_HTTP_TIMEOUT_LONG,
  254. );
  255. if ($response["status"] < 200 || $response["status"] >= 300) {
  256. throw new ManageRemoteUploadException(
  257. manageClientErrorMessage($response["status"], $response["body"]),
  258. [
  259. "http_status" => $response["status"],
  260. "response_excerpt" => manageRemoteResponseExcerpt($response["body"]),
  261. "filename" => $filename,
  262. ],
  263. );
  264. }
  265. $decoded = json_decode($response["body"], true);
  266. if (!is_array($decoded) || empty($decoded["success"])) {
  267. $error = is_array($decoded) ? trim((string) ($decoded["error"] ?? "")) : "";
  268. throw new ManageRemoteUploadException(
  269. "Der Manage-Server hat das Backup abgelehnt" . ($error !== "" ? ": " . $error : "."),
  270. [
  271. "http_status" => $response["status"],
  272. "response_excerpt" => manageRemoteResponseExcerpt($response["body"]),
  273. "filename" => $filename,
  274. ],
  275. );
  276. }
  277. return [
  278. "target" => "Manage-Server",
  279. "type" => "manage",
  280. "success" => true,
  281. "uploaded_at" => date(DATE_ATOM),
  282. "server_filename" => (string) ($decoded["filename"] ?? ""),
  283. "remote_path" => manageClientEndpoint("backup.php"),
  284. ];
  285. }
  286. // ---------------------------------------------------------------------------
  287. // Creating a backup
  288. // ---------------------------------------------------------------------------
  289. /**
  290. * Creates a local archive and, unless disabled, uploads it.
  291. *
  292. * A failed upload never invalidates the local archive: the error is stored in
  293. * the index record and logged, exactly like the extra remote targets.
  294. *
  295. * @param string $trigger manual | automatic | cron | update
  296. */
  297. function manageBackupCreate(string $trigger = "manual"): array
  298. {
  299. $dir = manageBackupDir();
  300. manageEnsureDir($dir);
  301. $lockHandle = fopen(manageBackupLockFile(), "c+");
  302. if ($lockHandle === false) {
  303. throw new RuntimeException("Backup-Sperrdatei konnte nicht geöffnet werden.");
  304. }
  305. if (!flock($lockHandle, LOCK_EX | LOCK_NB)) {
  306. fclose($lockHandle);
  307. throw new RuntimeException("Es läuft bereits ein Backup.");
  308. }
  309. $dumpFile = null;
  310. try {
  311. $baseName = "backup-" . date("Ymd-His");
  312. $filename = $baseName . ".zip";
  313. $counter = 2;
  314. while (file_exists($dir . $filename)) {
  315. $filename = $baseName . "-" . $counter . ".zip";
  316. $counter++;
  317. }
  318. $tmpFile = $dir . "." . $filename . ".tmp";
  319. $archivePath = $dir . $filename;
  320. $createdAt = date(DATE_ATOM);
  321. $files = manageBackupCollectSources();
  322. $database = null;
  323. if (manageDatabaseConfigured()) {
  324. $dumpFile = rtrim((string) MANAGE_WORK_DIR, "/\\") . DIRECTORY_SEPARATOR .
  325. "dump-" . date("Ymd-His") . "-" . bin2hex(random_bytes(4)) . ".sql";
  326. $database = manageDatabaseDump($dumpFile);
  327. $files[] = [
  328. "path" => $dumpFile,
  329. "name" => "database/" . $database["database"] . ".sql",
  330. ];
  331. }
  332. $zipStats = manageZipWrite($tmpFile, $files);
  333. if (!rename($tmpFile, $archivePath)) {
  334. @unlink($tmpFile);
  335. throw new RuntimeException("Backup-ZIP konnte nicht finalisiert werden.");
  336. }
  337. @chmod($archivePath, 0660);
  338. $metadata = [
  339. "filename" => $filename,
  340. "created_at" => $createdAt,
  341. "trigger" => $trigger,
  342. "sha256" => $zipStats["sha256"],
  343. "file_count" => $zipStats["file_count"],
  344. "source_bytes" => $zipStats["source_bytes"],
  345. ];
  346. $uploads = [];
  347. if (MANAGE_BACKUP_UPLOAD === true && manageClientConfigured()) {
  348. try {
  349. $uploads[] = manageBackupUpload($archivePath, $metadata);
  350. } catch (Throwable $exception) {
  351. $debugContext = $exception instanceof ManageRemoteUploadException
  352. ? $exception->getDebugContext()
  353. : [];
  354. $uploads[] = [
  355. "target" => "Manage-Server",
  356. "type" => "manage",
  357. "success" => false,
  358. "error" => $exception->getMessage(),
  359. "debug" => $debugContext,
  360. ];
  361. manageClientLog("ERROR", "Backup upload to manage server failed", [
  362. "filename" => $filename,
  363. "error" => $exception->getMessage(),
  364. "debug" => $debugContext,
  365. ]);
  366. }
  367. }
  368. $uploads = array_merge($uploads, manageRemoteUploadAll($archivePath, $metadata));
  369. $record = [
  370. "filename" => $filename,
  371. "created_at" => $createdAt,
  372. "trigger" => $trigger,
  373. "size" => (int) (filesize($archivePath) ?: $zipStats["archive_bytes"]),
  374. "file_count" => $zipStats["file_count"],
  375. "source_bytes" => $zipStats["source_bytes"],
  376. "sha256" => $zipStats["sha256"],
  377. "app_version" => manageClientVersion(),
  378. "database" => $database,
  379. "remote_uploads" => $uploads,
  380. ];
  381. $records = manageBackupList();
  382. array_unshift($records, $record);
  383. manageBackupWriteIndex($records);
  384. manageBackupApplyRetention();
  385. manageClientLog("INFO", "Backup created", [
  386. "filename" => $filename,
  387. "trigger" => $trigger,
  388. "file_count" => $record["file_count"],
  389. "size" => $record["size"],
  390. ]);
  391. return $record;
  392. } catch (Throwable $exception) {
  393. manageClientLog("ERROR", "Backup failed", [
  394. "trigger" => $trigger,
  395. "error" => $exception->getMessage(),
  396. ]);
  397. throw $exception;
  398. } finally {
  399. if ($dumpFile !== null && is_file($dumpFile)) {
  400. @unlink($dumpFile);
  401. }
  402. flock($lockHandle, LOCK_UN);
  403. fclose($lockHandle);
  404. }
  405. }
  406. // ---------------------------------------------------------------------------
  407. // Automatic scheduling for hosts without cron
  408. // ---------------------------------------------------------------------------
  409. function manageBackupLastAutomaticAt(): int
  410. {
  411. foreach (manageBackupList() as $record) {
  412. $trigger = (string) ($record["trigger"] ?? "");
  413. if ($trigger !== "automatic" && $trigger !== "cron") {
  414. continue;
  415. }
  416. $timestamp = strtotime((string) ($record["created_at"] ?? ""));
  417. if ($timestamp !== false) {
  418. return $timestamp;
  419. }
  420. }
  421. return 0;
  422. }
  423. function manageBackupIsAutomaticDue(): bool
  424. {
  425. $interval = (int) MANAGE_BACKUP_AUTO_INTERVAL_SECONDS;
  426. if ($interval < 1) {
  427. return false;
  428. }
  429. return time() - manageBackupLastAutomaticAt() >= $interval;
  430. }
  431. /**
  432. * Creates an automatic backup when the interval has elapsed. Meant to be called
  433. * from an admin page the host application loads regularly. Returns null when
  434. * nothing was due.
  435. */
  436. function manageBackupCreateAutomaticIfDue(): ?array
  437. {
  438. if (!manageBackupIsAutomaticDue()) {
  439. return null;
  440. }
  441. return manageBackupCreate("automatic");
  442. }