backup.php 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823
  1. <?php
  2. require_once __DIR__ . "/functions.php";
  3. if (!defined("BACKUP_DIR")) {
  4. define("BACKUP_DIR", DATA_DIR . "backups/");
  5. }
  6. if (!defined("BACKUP_LOCAL_RETENTION")) {
  7. define("BACKUP_LOCAL_RETENTION", 4);
  8. }
  9. if (!defined("BACKUP_AUTO_INTERVAL_SECONDS")) {
  10. define("BACKUP_AUTO_INTERVAL_SECONDS", 604800);
  11. }
  12. if (!defined("BACKUP_REMOTE_TARGETS")) {
  13. define("BACKUP_REMOTE_TARGETS", []);
  14. }
  15. function backupGetDirectory(): string
  16. {
  17. return rtrim((string) BACKUP_DIR, "/\\") . DIRECTORY_SEPARATOR;
  18. }
  19. function backupGetIndexFile(): string
  20. {
  21. return backupGetDirectory() . "backup-index.json";
  22. }
  23. function backupGetLockFile(): string
  24. {
  25. return backupGetDirectory() . ".backup.lock";
  26. }
  27. function backupEnsureDirectory(string $dir): void
  28. {
  29. if (!is_dir($dir) && !mkdir($dir, 02775, true) && !is_dir($dir)) {
  30. throw new RuntimeException("Backup-Verzeichnis konnte nicht erstellt werden.");
  31. }
  32. @chmod($dir, 02775);
  33. }
  34. function backupNormalizePath(string $path): string
  35. {
  36. return str_replace("\\", "/", $path);
  37. }
  38. function backupIsTemporaryFile(string $path): bool
  39. {
  40. $name = basename($path);
  41. return $name === "" ||
  42. $name[0] === "." ||
  43. str_ends_with($name, ".tmp") ||
  44. str_ends_with($name, ".part");
  45. }
  46. function backupGetSourceFiles(): array
  47. {
  48. $dataDir = rtrim(DATA_DIR, "/\\") . DIRECTORY_SEPARATOR;
  49. $files = [];
  50. foreach (glob($dataDir . "*.json") ?: [] as $file) {
  51. if (is_file($file) && is_readable($file) && !backupIsTemporaryFile($file)) {
  52. $files[] = [
  53. "path" => $file,
  54. "name" => "data/" . basename($file),
  55. ];
  56. }
  57. }
  58. $uploadsDir = rtrim(UPLOADS_DIR, "/\\") . DIRECTORY_SEPARATOR;
  59. if (is_dir($uploadsDir)) {
  60. $items = new RecursiveIteratorIterator(
  61. new RecursiveDirectoryIterator($uploadsDir, FilesystemIterator::SKIP_DOTS),
  62. RecursiveIteratorIterator::LEAVES_ONLY,
  63. );
  64. foreach ($items as $item) {
  65. if (!$item->isFile() || !$item->isReadable()) {
  66. continue;
  67. }
  68. $path = $item->getPathname();
  69. if (backupIsTemporaryFile($path)) {
  70. continue;
  71. }
  72. $relative = ltrim(
  73. backupNormalizePath(substr($path, strlen($uploadsDir))),
  74. "/",
  75. );
  76. if ($relative === "" || str_contains($relative, "\0")) {
  77. continue;
  78. }
  79. $files[] = [
  80. "path" => $path,
  81. "name" => "data/uploads/" . $relative,
  82. ];
  83. }
  84. }
  85. usort($files, function ($left, $right) {
  86. return strcmp($left["name"], $right["name"]);
  87. });
  88. return $files;
  89. }
  90. function backupGetDosDateTime(int $timestamp): array
  91. {
  92. $parts = getdate($timestamp);
  93. $year = max(1980, (int) $parts["year"]);
  94. return [
  95. (($year - 1980) << 9) | ((int) $parts["mon"] << 5) | (int) $parts["mday"],
  96. ((int) $parts["hours"] << 11) |
  97. ((int) $parts["minutes"] << 5) |
  98. ((int) floor(((int) $parts["seconds"]) / 2)),
  99. ];
  100. }
  101. function backupValidateZipEntryName(string $name): void
  102. {
  103. $name = backupNormalizePath($name);
  104. if (
  105. $name === "" ||
  106. str_contains($name, "\0") ||
  107. str_starts_with($name, "/") ||
  108. preg_match('/^[A-Za-z]:\//', $name) === 1
  109. ) {
  110. throw new RuntimeException("Ungültiger Backup-Pfad: " . $name);
  111. }
  112. foreach (explode("/", $name) as $segment) {
  113. if ($segment === "" || $segment === "." || $segment === "..") {
  114. throw new RuntimeException("Ungültiger Backup-Pfad: " . $name);
  115. }
  116. }
  117. if (strlen($name) > 65535) {
  118. throw new RuntimeException("Backup-Pfad ist zu lang: " . $name);
  119. }
  120. }
  121. function backupWriteBytes($handle, string $data): void
  122. {
  123. $offset = 0;
  124. $length = strlen($data);
  125. while ($offset < $length) {
  126. $written = fwrite($handle, substr($data, $offset));
  127. if ($written === false || $written === 0) {
  128. throw new RuntimeException("Backup-ZIP konnte nicht geschrieben werden.");
  129. }
  130. $offset += $written;
  131. }
  132. }
  133. function backupCopyFileToHandle(string $file, $handle): void
  134. {
  135. $source = fopen($file, "rb");
  136. if ($source === false) {
  137. throw new RuntimeException("Backup-Datei konnte nicht gelesen werden: " . basename($file));
  138. }
  139. while (!feof($source)) {
  140. $chunk = fread($source, 1048576);
  141. if ($chunk === false) {
  142. fclose($source);
  143. throw new RuntimeException("Backup-Datei konnte nicht gelesen werden: " . basename($file));
  144. }
  145. if ($chunk !== "") {
  146. try {
  147. backupWriteBytes($handle, $chunk);
  148. } catch (Throwable $exception) {
  149. fclose($source);
  150. throw $exception;
  151. }
  152. }
  153. }
  154. fclose($source);
  155. }
  156. function backupWriteZip(string $targetFile, array $files): array
  157. {
  158. if (empty($files)) {
  159. throw new RuntimeException("Keine Daten-Dateien für das Backup gefunden.");
  160. }
  161. $handle = fopen($targetFile, "wb");
  162. if ($handle === false) {
  163. throw new RuntimeException("Backup-ZIP konnte nicht erstellt werden.");
  164. }
  165. $centralDirectory = "";
  166. $fileCount = 0;
  167. $sourceBytes = 0;
  168. try {
  169. foreach ($files as $file) {
  170. $path = (string) ($file["path"] ?? "");
  171. $name = backupNormalizePath((string) ($file["name"] ?? ""));
  172. backupValidateZipEntryName($name);
  173. if (!is_file($path) || !is_readable($path)) {
  174. continue;
  175. }
  176. $size = filesize($path);
  177. if ($size === false) {
  178. throw new RuntimeException("Backup-Dateigröße konnte nicht ermittelt werden: " . $name);
  179. }
  180. if ($size > 0xffffffff) {
  181. throw new RuntimeException("Datei ist zu groß für dieses Backup-Format: " . $name);
  182. }
  183. $offset = ftell($handle);
  184. if ($offset === false || $offset > 0xffffffff) {
  185. throw new RuntimeException("Backup-ZIP ist zu groß für dieses Backup-Format.");
  186. }
  187. $crcHex = hash_file("crc32b", $path);
  188. if (!is_string($crcHex) || !preg_match('/^[a-f0-9]{8}$/i', $crcHex)) {
  189. throw new RuntimeException("Prüfsumme konnte nicht berechnet werden: " . $name);
  190. }
  191. $crc = (int) hexdec($crcHex);
  192. [$dosDate, $dosTime] = backupGetDosDateTime((int) (filemtime($path) ?: time()));
  193. $nameLength = strlen($name);
  194. backupWriteBytes(
  195. $handle,
  196. pack(
  197. "VvvvvvVVVvv",
  198. 0x04034b50,
  199. 10,
  200. 0,
  201. 0,
  202. $dosTime,
  203. $dosDate,
  204. $crc,
  205. $size,
  206. $size,
  207. $nameLength,
  208. 0,
  209. ) . $name,
  210. );
  211. backupCopyFileToHandle($path, $handle);
  212. $centralDirectory .=
  213. pack(
  214. "VvvvvvvVVVvvvvvVV",
  215. 0x02014b50,
  216. 0x031e,
  217. 10,
  218. 0,
  219. 0,
  220. $dosTime,
  221. $dosDate,
  222. $crc,
  223. $size,
  224. $size,
  225. $nameLength,
  226. 0,
  227. 0,
  228. 0,
  229. 0,
  230. 0,
  231. $offset,
  232. ) .
  233. $name;
  234. $fileCount++;
  235. $sourceBytes += $size;
  236. }
  237. if ($fileCount < 1) {
  238. throw new RuntimeException("Keine lesbaren Daten-Dateien für das Backup gefunden.");
  239. }
  240. if ($fileCount > 65535) {
  241. throw new RuntimeException("Zu viele Dateien für dieses Backup-Format.");
  242. }
  243. $centralOffset = ftell($handle);
  244. $centralSize = strlen($centralDirectory);
  245. if (
  246. $centralOffset === false ||
  247. $centralOffset > 0xffffffff ||
  248. $centralSize > 0xffffffff
  249. ) {
  250. throw new RuntimeException("Backup-ZIP ist zu groß für dieses Backup-Format.");
  251. }
  252. backupWriteBytes($handle, $centralDirectory);
  253. backupWriteBytes(
  254. $handle,
  255. pack(
  256. "VvvvvVVv",
  257. 0x06054b50,
  258. 0,
  259. 0,
  260. $fileCount,
  261. $fileCount,
  262. $centralSize,
  263. $centralOffset,
  264. 0,
  265. ),
  266. );
  267. } catch (Throwable $exception) {
  268. fclose($handle);
  269. @unlink($targetFile);
  270. throw $exception;
  271. }
  272. fclose($handle);
  273. @chmod($targetFile, 0660);
  274. return [
  275. "file_count" => $fileCount,
  276. "source_bytes" => $sourceBytes,
  277. "archive_bytes" => (int) (filesize($targetFile) ?: 0),
  278. "sha256" => hash_file("sha256", $targetFile) ?: "",
  279. ];
  280. }
  281. function backupReadIndex(): array
  282. {
  283. $index = readJsonFile(backupGetIndexFile());
  284. $records =
  285. isset($index["backups"]) && is_array($index["backups"])
  286. ? $index["backups"]
  287. : [];
  288. return ["backups" => array_values($records)];
  289. }
  290. function backupWriteIndex(array $records): bool
  291. {
  292. return writeJsonFile(backupGetIndexFile(), [
  293. "backups" => array_values($records),
  294. ]);
  295. }
  296. function backupListBackups(): array
  297. {
  298. $records = backupReadIndex()["backups"];
  299. $dir = backupGetDirectory();
  300. $existing = [];
  301. foreach ($records as $record) {
  302. if (!is_array($record)) {
  303. continue;
  304. }
  305. $filename = basename((string) ($record["filename"] ?? ""));
  306. if ($filename === "" || !is_file($dir . $filename)) {
  307. continue;
  308. }
  309. $record["filename"] = $filename;
  310. $record["size"] = (int) (filesize($dir . $filename) ?: ($record["size"] ?? 0));
  311. $existing[] = $record;
  312. }
  313. usort($existing, function ($left, $right) {
  314. return strcmp((string) ($right["created_at"] ?? ""), (string) ($left["created_at"] ?? ""));
  315. });
  316. return $existing;
  317. }
  318. function backupFormatBytes(int $bytes): string
  319. {
  320. if ($bytes >= 1073741824) {
  321. return number_format($bytes / 1073741824, 2, ",", ".") . " GB";
  322. }
  323. if ($bytes >= 1048576) {
  324. return number_format($bytes / 1048576, 2, ",", ".") . " MB";
  325. }
  326. if ($bytes >= 1024) {
  327. return number_format($bytes / 1024, 1, ",", ".") . " KB";
  328. }
  329. return $bytes . " B";
  330. }
  331. function backupGetRetentionLimit(): int
  332. {
  333. return max(1, (int) BACKUP_LOCAL_RETENTION);
  334. }
  335. function backupApplyRetention(): void
  336. {
  337. $records = backupListBackups();
  338. $keep = backupGetRetentionLimit();
  339. $dir = backupGetDirectory();
  340. foreach (array_slice($records, $keep) as $record) {
  341. $filename = basename((string) ($record["filename"] ?? ""));
  342. if ($filename !== "" && is_file($dir . $filename)) {
  343. @unlink($dir . $filename);
  344. }
  345. }
  346. backupWriteIndex(array_slice(backupListBackups(), 0, $keep));
  347. }
  348. function backupGetRemoteTargets(): array
  349. {
  350. return is_array(BACKUP_REMOTE_TARGETS) ? BACKUP_REMOTE_TARGETS : [];
  351. }
  352. function backupGetTargetLabel(array $target, int $index): string
  353. {
  354. $name = trim((string) ($target["name"] ?? ""));
  355. if ($name !== "") {
  356. return $name;
  357. }
  358. $type = trim((string) ($target["type"] ?? "target"));
  359. return $type . "-" . ($index + 1);
  360. }
  361. function backupRemoteCapabilities(): array
  362. {
  363. $targets = backupGetRemoteTargets();
  364. $types = [];
  365. foreach ($targets as $target) {
  366. if (is_array($target)) {
  367. $type = trim((string) ($target["type"] ?? ""));
  368. if ($type !== "") {
  369. $types[$type] = true;
  370. }
  371. }
  372. }
  373. return [
  374. "s3" => [
  375. "configured" => !empty($types["s3"]),
  376. "available" => function_exists("hash_hmac"),
  377. ],
  378. "sftp" => [
  379. "configured" => !empty($types["sftp"]),
  380. "available" =>
  381. function_exists("ssh2_connect") &&
  382. function_exists("ssh2_sftp"),
  383. ],
  384. "custom" => [
  385. "configured" => !empty($types["custom"]),
  386. "available" => true,
  387. ],
  388. ];
  389. }
  390. function backupUploadToS3(string $archivePath, array $metadata, array $target): array
  391. {
  392. $bucket = trim((string) ($target["bucket"] ?? ""));
  393. $region = trim((string) ($target["region"] ?? ""));
  394. $accessKey = trim((string) ($target["access_key"] ?? ""));
  395. $secretKey = (string) ($target["secret_key"] ?? "");
  396. $prefix = trim((string) ($target["prefix"] ?? ""), "/");
  397. $endpoint = rtrim(trim((string) ($target["endpoint"] ?? "")), "/");
  398. if ($bucket === "" || $region === "" || $accessKey === "" || $secretKey === "") {
  399. throw new RuntimeException("S3-Ziel ist unvollständig konfiguriert.");
  400. }
  401. $filename = basename($archivePath);
  402. $key = ($prefix !== "" ? $prefix . "/" : "") . $filename;
  403. $host = $endpoint !== ""
  404. ? parse_url($endpoint, PHP_URL_HOST)
  405. : $bucket . ".s3." . $region . ".amazonaws.com";
  406. if (!is_string($host) || $host === "") {
  407. throw new RuntimeException("S3-Endpunkt ist ungültig.");
  408. }
  409. $url = $endpoint !== ""
  410. ? $endpoint . "/" . rawurlencode($bucket) . "/" . str_replace("%2F", "/", rawurlencode($key))
  411. : "https://" . $host . "/" . str_replace("%2F", "/", rawurlencode($key));
  412. $payload = file_get_contents($archivePath);
  413. if ($payload === false) {
  414. throw new RuntimeException("Backup-ZIP konnte für S3 nicht gelesen werden.");
  415. }
  416. $now = gmdate("Ymd\THis\Z");
  417. $date = substr($now, 0, 8);
  418. $payloadHash = hash("sha256", $payload);
  419. $canonicalUri = parse_url($url, PHP_URL_PATH);
  420. $canonicalUri = is_string($canonicalUri) && $canonicalUri !== "" ? $canonicalUri : "/";
  421. $signedHeaders = "content-type;host;x-amz-content-sha256;x-amz-date";
  422. $canonicalHeaders =
  423. "content-type:application/zip\n" .
  424. "host:" . $host . "\n" .
  425. "x-amz-content-sha256:" . $payloadHash . "\n" .
  426. "x-amz-date:" . $now . "\n";
  427. $canonicalRequest =
  428. "PUT\n" .
  429. $canonicalUri .
  430. "\n\n" .
  431. $canonicalHeaders .
  432. "\n" .
  433. $signedHeaders .
  434. "\n" .
  435. $payloadHash;
  436. $scope = $date . "/" . $region . "/s3/aws4_request";
  437. $stringToSign =
  438. "AWS4-HMAC-SHA256\n" .
  439. $now .
  440. "\n" .
  441. $scope .
  442. "\n" .
  443. hash("sha256", $canonicalRequest);
  444. $kDate = hash_hmac("sha256", $date, "AWS4" . $secretKey, true);
  445. $kRegion = hash_hmac("sha256", $region, $kDate, true);
  446. $kService = hash_hmac("sha256", "s3", $kRegion, true);
  447. $kSigning = hash_hmac("sha256", "aws4_request", $kService, true);
  448. $signature = hash_hmac("sha256", $stringToSign, $kSigning);
  449. $authorization =
  450. "AWS4-HMAC-SHA256 Credential=" .
  451. $accessKey .
  452. "/" .
  453. $scope .
  454. ", SignedHeaders=" .
  455. $signedHeaders .
  456. ", Signature=" .
  457. $signature;
  458. $context = stream_context_create([
  459. "http" => [
  460. "method" => "PUT",
  461. "timeout" => (int) ($target["timeout"] ?? 120),
  462. "ignore_errors" => false,
  463. "header" =>
  464. "Content-Type: application/zip\r\n" .
  465. "Content-Length: " . strlen($payload) . "\r\n" .
  466. "Host: " . $host . "\r\n" .
  467. "X-Amz-Date: " . $now . "\r\n" .
  468. "X-Amz-Content-Sha256: " . $payloadHash . "\r\n" .
  469. "Authorization: " . $authorization . "\r\n",
  470. "content" => $payload,
  471. ],
  472. ]);
  473. $response = @file_get_contents($url, false, $context);
  474. $status = 0;
  475. if (function_exists("http_get_last_response_headers")) {
  476. $headers = http_get_last_response_headers();
  477. foreach (is_array($headers) ? $headers : [] as $header) {
  478. if (preg_match('/^HTTP\/\S+\s+(\d+)/', $header, $matches) === 1) {
  479. $status = (int) $matches[1];
  480. break;
  481. }
  482. }
  483. }
  484. if ($response === false || $status < 200 || $status >= 300) {
  485. throw new RuntimeException(
  486. "S3-Upload fehlgeschlagen" . ($status > 0 ? " (HTTP " . $status . ")" : "") . ".",
  487. );
  488. }
  489. return ["remote_path" => "s3://" . $bucket . "/" . $key];
  490. }
  491. function backupUploadToSftp(string $archivePath, array $metadata, array $target): array
  492. {
  493. if (!function_exists("ssh2_connect") || !function_exists("ssh2_sftp")) {
  494. throw new RuntimeException("PHP-SSH2-Erweiterung ist nicht verfügbar.");
  495. }
  496. $host = trim((string) ($target["host"] ?? ""));
  497. $username = trim((string) ($target["username"] ?? ""));
  498. $password = (string) ($target["password"] ?? "");
  499. $remoteDir = rtrim((string) ($target["path"] ?? ""), "/");
  500. $port = (int) ($target["port"] ?? 22);
  501. if ($host === "" || $username === "" || $remoteDir === "") {
  502. throw new RuntimeException("SFTP-Ziel ist unvollständig konfiguriert.");
  503. }
  504. $connection = @ssh2_connect($host, $port > 0 ? $port : 22);
  505. if ($connection === false) {
  506. throw new RuntimeException("SFTP-Verbindung konnte nicht hergestellt werden.");
  507. }
  508. $authenticated = false;
  509. $privateKey = trim((string) ($target["private_key"] ?? ""));
  510. $publicKey = trim((string) ($target["public_key"] ?? ""));
  511. if (
  512. $privateKey !== "" &&
  513. $publicKey !== "" &&
  514. function_exists("ssh2_auth_pubkey_file")
  515. ) {
  516. $authenticated = @ssh2_auth_pubkey_file(
  517. $connection,
  518. $username,
  519. $publicKey,
  520. $privateKey,
  521. $password !== "" ? $password : null,
  522. );
  523. } elseif (function_exists("ssh2_auth_password")) {
  524. $authenticated = @ssh2_auth_password($connection, $username, $password);
  525. }
  526. if (!$authenticated) {
  527. throw new RuntimeException("SFTP-Anmeldung fehlgeschlagen.");
  528. }
  529. $sftp = @ssh2_sftp($connection);
  530. if ($sftp === false) {
  531. throw new RuntimeException("SFTP-Subsystem konnte nicht gestartet werden.");
  532. }
  533. $remotePath = $remoteDir . "/" . basename($archivePath);
  534. $targetStream = @fopen("ssh2.sftp://" . intval($sftp) . $remotePath, "wb");
  535. if ($targetStream === false) {
  536. throw new RuntimeException("SFTP-Zieldatei konnte nicht geöffnet werden.");
  537. }
  538. $source = fopen($archivePath, "rb");
  539. if ($source === false) {
  540. fclose($targetStream);
  541. throw new RuntimeException("Backup-ZIP konnte für SFTP nicht gelesen werden.");
  542. }
  543. $copied = stream_copy_to_stream($source, $targetStream);
  544. fclose($source);
  545. fclose($targetStream);
  546. if ($copied === false) {
  547. throw new RuntimeException("SFTP-Upload fehlgeschlagen.");
  548. }
  549. return ["remote_path" => "sftp://" . $host . $remotePath];
  550. }
  551. function backupUploadToCustom(string $archivePath, array $metadata, array $target): array
  552. {
  553. $file = trim((string) ($target["file"] ?? ""));
  554. $callback = $target["callback"] ?? null;
  555. if ($file !== "") {
  556. if (!is_file($file)) {
  557. throw new RuntimeException("Custom-Uploader-Datei wurde nicht gefunden.");
  558. }
  559. require_once $file;
  560. }
  561. if (!is_callable($callback)) {
  562. throw new RuntimeException("Custom-Uploader ist nicht aufrufbar.");
  563. }
  564. $result = call_user_func($callback, $archivePath, $metadata, $target);
  565. if ($result === true) {
  566. return [];
  567. }
  568. if (is_array($result)) {
  569. return $result;
  570. }
  571. throw new RuntimeException("Custom-Uploader meldet einen Fehler.");
  572. }
  573. function backupUploadRemotes(string $archivePath, array $metadata): array
  574. {
  575. $results = [];
  576. foreach (backupGetRemoteTargets() as $index => $target) {
  577. if (!is_array($target)) {
  578. continue;
  579. }
  580. $type = trim((string) ($target["type"] ?? ""));
  581. $label = backupGetTargetLabel($target, (int) $index);
  582. $startedAt = date("c");
  583. try {
  584. if ($type === "s3") {
  585. $extra = backupUploadToS3($archivePath, $metadata, $target);
  586. } elseif ($type === "sftp") {
  587. $extra = backupUploadToSftp($archivePath, $metadata, $target);
  588. } elseif ($type === "custom") {
  589. $extra = backupUploadToCustom($archivePath, $metadata, $target);
  590. } else {
  591. throw new RuntimeException("Unbekannter Backup-Zieltyp: " . $type);
  592. }
  593. $results[] = array_merge(
  594. [
  595. "target" => $label,
  596. "type" => $type,
  597. "success" => true,
  598. "uploaded_at" => date("c"),
  599. "started_at" => $startedAt,
  600. ],
  601. is_array($extra) ? $extra : [],
  602. );
  603. } catch (Throwable $exception) {
  604. $results[] = [
  605. "target" => $label,
  606. "type" => $type !== "" ? $type : "unknown",
  607. "success" => false,
  608. "started_at" => $startedAt,
  609. "error" => $exception->getMessage(),
  610. ];
  611. }
  612. }
  613. return $results;
  614. }
  615. function backupGetLastAutomaticAt(): int
  616. {
  617. foreach (backupListBackups() as $record) {
  618. if (($record["trigger"] ?? "") !== "automatic") {
  619. continue;
  620. }
  621. $timestamp = strtotime((string) ($record["created_at"] ?? ""));
  622. if ($timestamp !== false) {
  623. return $timestamp;
  624. }
  625. }
  626. return 0;
  627. }
  628. function backupIsAutomaticDue(): bool
  629. {
  630. $interval = (int) BACKUP_AUTO_INTERVAL_SECONDS;
  631. if ($interval < 1) {
  632. return false;
  633. }
  634. return time() - backupGetLastAutomaticAt() >= $interval;
  635. }
  636. function backupCreate(string $trigger = "manual"): array
  637. {
  638. $trigger = $trigger === "automatic" ? "automatic" : "manual";
  639. $dir = backupGetDirectory();
  640. backupEnsureDirectory($dir);
  641. $lockHandle = fopen(backupGetLockFile(), "c+");
  642. if ($lockHandle === false) {
  643. throw new RuntimeException("Backup-Sperrdatei konnte nicht geöffnet werden.");
  644. }
  645. if (!flock($lockHandle, LOCK_EX | LOCK_NB)) {
  646. fclose($lockHandle);
  647. throw new RuntimeException("Es läuft bereits ein Backup.");
  648. }
  649. try {
  650. $baseName = "backup-" . date("Ymd-His");
  651. $filename = $baseName . ".zip";
  652. $counter = 2;
  653. while (file_exists($dir . $filename)) {
  654. $filename = $baseName . "-" . $counter . ".zip";
  655. $counter++;
  656. }
  657. $tmpFile = $dir . "." . $filename . ".tmp";
  658. $archivePath = $dir . $filename;
  659. $createdAt = date("c");
  660. $zipStats = backupWriteZip($tmpFile, backupGetSourceFiles());
  661. if (!rename($tmpFile, $archivePath)) {
  662. @unlink($tmpFile);
  663. throw new RuntimeException("Backup-ZIP konnte nicht finalisiert werden.");
  664. }
  665. @chmod($archivePath, 0660);
  666. $record = [
  667. "filename" => $filename,
  668. "created_at" => $createdAt,
  669. "trigger" => $trigger,
  670. "size" => (int) (filesize($archivePath) ?: $zipStats["archive_bytes"]),
  671. "file_count" => $zipStats["file_count"],
  672. "source_bytes" => $zipStats["source_bytes"],
  673. "sha256" => $zipStats["sha256"],
  674. "remote_uploads" => backupUploadRemotes($archivePath, [
  675. "filename" => $filename,
  676. "created_at" => $createdAt,
  677. "trigger" => $trigger,
  678. "sha256" => $zipStats["sha256"],
  679. ]),
  680. ];
  681. $records = backupListBackups();
  682. array_unshift($records, $record);
  683. backupWriteIndex($records);
  684. backupApplyRetention();
  685. logAccess("Backup created", [
  686. "filename" => $filename,
  687. "trigger" => $trigger,
  688. "file_count" => $record["file_count"],
  689. ]);
  690. return $record;
  691. } catch (Throwable $exception) {
  692. logError("Backup failed", [
  693. "trigger" => $trigger,
  694. "error" => $exception->getMessage(),
  695. ]);
  696. throw $exception;
  697. } finally {
  698. flock($lockHandle, LOCK_UN);
  699. fclose($lockHandle);
  700. }
  701. }
  702. function backupCreateAutomaticIfDue(): ?array
  703. {
  704. if (!backupIsAutomaticDue()) {
  705. return null;
  706. }
  707. return backupCreate("automatic");
  708. }