backup.php 16 KB

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