backup.php 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960
  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. "managed" => [
  389. "configured" => !empty($types["managed"]),
  390. "available" => true,
  391. ],
  392. ];
  393. }
  394. function backupGetHttpStatusFromHeaders(array $headers): int
  395. {
  396. foreach ($headers as $header) {
  397. if (preg_match('/^HTTP\/\S+\s+(\d+)/', $header, $matches) === 1) {
  398. return (int) $matches[1];
  399. }
  400. }
  401. return 0;
  402. }
  403. function backupUploadToS3(string $archivePath, array $metadata, array $target): array
  404. {
  405. $bucket = trim((string) ($target["bucket"] ?? ""));
  406. $region = trim((string) ($target["region"] ?? ""));
  407. $accessKey = trim((string) ($target["access_key"] ?? ""));
  408. $secretKey = (string) ($target["secret_key"] ?? "");
  409. $prefix = trim((string) ($target["prefix"] ?? ""), "/");
  410. $endpoint = rtrim(trim((string) ($target["endpoint"] ?? "")), "/");
  411. if ($bucket === "" || $region === "" || $accessKey === "" || $secretKey === "") {
  412. throw new RuntimeException("S3-Ziel ist unvollständig konfiguriert.");
  413. }
  414. $filename = basename($archivePath);
  415. $key = ($prefix !== "" ? $prefix . "/" : "") . $filename;
  416. $host = $endpoint !== ""
  417. ? parse_url($endpoint, PHP_URL_HOST)
  418. : $bucket . ".s3." . $region . ".amazonaws.com";
  419. if (!is_string($host) || $host === "") {
  420. throw new RuntimeException("S3-Endpunkt ist ungültig.");
  421. }
  422. $url = $endpoint !== ""
  423. ? $endpoint . "/" . rawurlencode($bucket) . "/" . str_replace("%2F", "/", rawurlencode($key))
  424. : "https://" . $host . "/" . str_replace("%2F", "/", rawurlencode($key));
  425. $payload = file_get_contents($archivePath);
  426. if ($payload === false) {
  427. throw new RuntimeException("Backup-ZIP konnte für S3 nicht gelesen werden.");
  428. }
  429. $now = gmdate("Ymd\THis\Z");
  430. $date = substr($now, 0, 8);
  431. $payloadHash = hash("sha256", $payload);
  432. $canonicalUri = parse_url($url, PHP_URL_PATH);
  433. $canonicalUri = is_string($canonicalUri) && $canonicalUri !== "" ? $canonicalUri : "/";
  434. $signedHeaders = "content-type;host;x-amz-content-sha256;x-amz-date";
  435. $canonicalHeaders =
  436. "content-type:application/zip\n" .
  437. "host:" . $host . "\n" .
  438. "x-amz-content-sha256:" . $payloadHash . "\n" .
  439. "x-amz-date:" . $now . "\n";
  440. $canonicalRequest =
  441. "PUT\n" .
  442. $canonicalUri .
  443. "\n\n" .
  444. $canonicalHeaders .
  445. "\n" .
  446. $signedHeaders .
  447. "\n" .
  448. $payloadHash;
  449. $scope = $date . "/" . $region . "/s3/aws4_request";
  450. $stringToSign =
  451. "AWS4-HMAC-SHA256\n" .
  452. $now .
  453. "\n" .
  454. $scope .
  455. "\n" .
  456. hash("sha256", $canonicalRequest);
  457. $kDate = hash_hmac("sha256", $date, "AWS4" . $secretKey, true);
  458. $kRegion = hash_hmac("sha256", $region, $kDate, true);
  459. $kService = hash_hmac("sha256", "s3", $kRegion, true);
  460. $kSigning = hash_hmac("sha256", "aws4_request", $kService, true);
  461. $signature = hash_hmac("sha256", $stringToSign, $kSigning);
  462. $authorization =
  463. "AWS4-HMAC-SHA256 Credential=" .
  464. $accessKey .
  465. "/" .
  466. $scope .
  467. ", SignedHeaders=" .
  468. $signedHeaders .
  469. ", Signature=" .
  470. $signature;
  471. $context = stream_context_create([
  472. "http" => [
  473. "method" => "PUT",
  474. "timeout" => (int) ($target["timeout"] ?? 120),
  475. "ignore_errors" => false,
  476. "header" =>
  477. "Content-Type: application/zip\r\n" .
  478. "Content-Length: " . strlen($payload) . "\r\n" .
  479. "Host: " . $host . "\r\n" .
  480. "X-Amz-Date: " . $now . "\r\n" .
  481. "X-Amz-Content-Sha256: " . $payloadHash . "\r\n" .
  482. "Authorization: " . $authorization . "\r\n",
  483. "content" => $payload,
  484. ],
  485. ]);
  486. $response = @file_get_contents($url, false, $context);
  487. if (function_exists("http_get_last_response_headers")) {
  488. $lastHeaders = http_get_last_response_headers();
  489. $headers = is_array($lastHeaders) ? $lastHeaders : [];
  490. } else {
  491. $legacyHeaders = ${"http_response_header"} ?? [];
  492. $headers = is_array($legacyHeaders)
  493. ? $legacyHeaders
  494. : [];
  495. }
  496. $status = backupGetHttpStatusFromHeaders($headers);
  497. if ($response === false || $status < 200 || $status >= 300) {
  498. throw new RuntimeException(
  499. "S3-Upload fehlgeschlagen" . ($status > 0 ? " (HTTP " . $status . ")" : "") . ".",
  500. );
  501. }
  502. return ["remote_path" => "s3://" . $bucket . "/" . $key];
  503. }
  504. function backupUploadToSftp(string $archivePath, array $metadata, array $target): array
  505. {
  506. if (!function_exists("ssh2_connect") || !function_exists("ssh2_sftp")) {
  507. throw new RuntimeException("PHP-SSH2-Erweiterung ist nicht verfügbar.");
  508. }
  509. $host = trim((string) ($target["host"] ?? ""));
  510. $username = trim((string) ($target["username"] ?? ""));
  511. $password = (string) ($target["password"] ?? "");
  512. $remoteDir = rtrim((string) ($target["path"] ?? ""), "/");
  513. $port = (int) ($target["port"] ?? 22);
  514. if ($host === "" || $username === "" || $remoteDir === "") {
  515. throw new RuntimeException("SFTP-Ziel ist unvollständig konfiguriert.");
  516. }
  517. $connection = @ssh2_connect($host, $port > 0 ? $port : 22);
  518. if ($connection === false) {
  519. throw new RuntimeException("SFTP-Verbindung konnte nicht hergestellt werden.");
  520. }
  521. $authenticated = false;
  522. $privateKey = trim((string) ($target["private_key"] ?? ""));
  523. $publicKey = trim((string) ($target["public_key"] ?? ""));
  524. if (
  525. $privateKey !== "" &&
  526. $publicKey !== "" &&
  527. function_exists("ssh2_auth_pubkey_file")
  528. ) {
  529. $authenticated = @ssh2_auth_pubkey_file(
  530. $connection,
  531. $username,
  532. $publicKey,
  533. $privateKey,
  534. $password !== "" ? $password : null,
  535. );
  536. } elseif (function_exists("ssh2_auth_password")) {
  537. $authenticated = @ssh2_auth_password($connection, $username, $password);
  538. }
  539. if (!$authenticated) {
  540. throw new RuntimeException("SFTP-Anmeldung fehlgeschlagen.");
  541. }
  542. $sftp = @ssh2_sftp($connection);
  543. if ($sftp === false) {
  544. throw new RuntimeException("SFTP-Subsystem konnte nicht gestartet werden.");
  545. }
  546. $remotePath = $remoteDir . "/" . basename($archivePath);
  547. $targetStream = @fopen("ssh2.sftp://" . intval($sftp) . $remotePath, "wb");
  548. if ($targetStream === false) {
  549. throw new RuntimeException("SFTP-Zieldatei konnte nicht geöffnet werden.");
  550. }
  551. $source = fopen($archivePath, "rb");
  552. if ($source === false) {
  553. fclose($targetStream);
  554. throw new RuntimeException("Backup-ZIP konnte für SFTP nicht gelesen werden.");
  555. }
  556. $copied = stream_copy_to_stream($source, $targetStream);
  557. fclose($source);
  558. fclose($targetStream);
  559. if ($copied === false) {
  560. throw new RuntimeException("SFTP-Upload fehlgeschlagen.");
  561. }
  562. return ["remote_path" => "sftp://" . $host . $remotePath];
  563. }
  564. function backupValidateManagedInstance(string $instance): string
  565. {
  566. $instance = trim($instance);
  567. if (
  568. $instance === "" ||
  569. strlen($instance) > 120 ||
  570. preg_match('/^[A-Za-z0-9][A-Za-z0-9._-]*$/', $instance) !== 1
  571. ) {
  572. throw new RuntimeException("Managed-Backup-Instanz ist ungültig.");
  573. }
  574. return $instance;
  575. }
  576. function backupBuildMultipartBody(array $fields, string $fileField, string $filePath, string $fileName, string $boundary): string
  577. {
  578. $body = "";
  579. foreach ($fields as $name => $value) {
  580. $body .= "--" . $boundary . "\r\n";
  581. $body .= 'Content-Disposition: form-data; name="' . addcslashes((string) $name, "\"\\") . "\"\r\n\r\n";
  582. $body .= (string) $value . "\r\n";
  583. }
  584. $payload = file_get_contents($filePath);
  585. if ($payload === false) {
  586. throw new RuntimeException("Backup-ZIP konnte für Managed Upload nicht gelesen werden.");
  587. }
  588. $body .= "--" . $boundary . "\r\n";
  589. $body .=
  590. 'Content-Disposition: form-data; name="' .
  591. addcslashes($fileField, "\"\\") .
  592. '"; filename="' .
  593. addcslashes($fileName, "\"\\") .
  594. "\"\r\n";
  595. $body .= "Content-Type: application/zip\r\n\r\n";
  596. $body .= $payload . "\r\n";
  597. $body .= "--" . $boundary . "--\r\n";
  598. return $body;
  599. }
  600. function backupUploadToManaged(string $archivePath, array $metadata, array $target): array
  601. {
  602. $url = trim((string) ($target["url"] ?? ""));
  603. $instance = backupValidateManagedInstance((string) ($target["instance"] ?? ""));
  604. if (!filter_var($url, FILTER_VALIDATE_URL)) {
  605. throw new RuntimeException("Managed-Backup-URL ist ungültig.");
  606. }
  607. $filename = basename($archivePath);
  608. $sha256 = trim((string) ($metadata["sha256"] ?? ""));
  609. if (!preg_match('/^[a-f0-9]{64}$/', $sha256)) {
  610. $sha256 = strtolower(hash_file("sha256", $archivePath) ?: "");
  611. }
  612. if (!preg_match('/^[a-f0-9]{64}$/', $sha256)) {
  613. throw new RuntimeException("Managed-Backup-Prüfsumme konnte nicht berechnet werden.");
  614. }
  615. $boundary = "----psa-backup-" . bin2hex(random_bytes(12));
  616. $body = backupBuildMultipartBody(
  617. [
  618. "instance" => $instance,
  619. "filename" => $filename,
  620. "sha256" => $sha256,
  621. ],
  622. "backup",
  623. $archivePath,
  624. $filename,
  625. $boundary,
  626. );
  627. $context = stream_context_create([
  628. "http" => [
  629. "method" => "POST",
  630. "timeout" => (int) ($target["timeout"] ?? 120),
  631. "ignore_errors" => true,
  632. "header" =>
  633. "Content-Type: multipart/form-data; boundary=" .
  634. $boundary .
  635. "\r\nContent-Length: " .
  636. strlen($body) .
  637. "\r\n",
  638. "content" => $body,
  639. ],
  640. ]);
  641. $response = @file_get_contents($url, false, $context);
  642. if (function_exists("http_get_last_response_headers")) {
  643. $lastHeaders = http_get_last_response_headers();
  644. $headers = is_array($lastHeaders) ? $lastHeaders : [];
  645. } else {
  646. $legacyHeaders = ${"http_response_header"} ?? [];
  647. $headers = is_array($legacyHeaders)
  648. ? $legacyHeaders
  649. : [];
  650. }
  651. $status = backupGetHttpStatusFromHeaders($headers);
  652. if ($response === false || $status < 200 || $status >= 300) {
  653. throw new RuntimeException(
  654. "Managed-Backup-Upload fehlgeschlagen" . ($status > 0 ? " (HTTP " . $status . ")" : "") . ".",
  655. );
  656. }
  657. $decoded = json_decode($response, true);
  658. if (!is_array($decoded) || empty($decoded["success"])) {
  659. $error = is_array($decoded) ? trim((string) ($decoded["error"] ?? "")) : "";
  660. throw new RuntimeException(
  661. "Managed-Backup-Upload wurde abgelehnt" . ($error !== "" ? ": " . $error : "."),
  662. );
  663. }
  664. return [
  665. "remote_path" => $url,
  666. "instance" => $instance,
  667. "server_filename" => (string) ($decoded["filename"] ?? ""),
  668. ];
  669. }
  670. function backupUploadToCustom(string $archivePath, array $metadata, array $target): array
  671. {
  672. $file = trim((string) ($target["file"] ?? ""));
  673. $callback = $target["callback"] ?? null;
  674. if ($file !== "") {
  675. if (!is_file($file)) {
  676. throw new RuntimeException("Custom-Uploader-Datei wurde nicht gefunden.");
  677. }
  678. require_once $file;
  679. }
  680. if (!is_callable($callback)) {
  681. throw new RuntimeException("Custom-Uploader ist nicht aufrufbar.");
  682. }
  683. $result = call_user_func($callback, $archivePath, $metadata, $target);
  684. if ($result === true) {
  685. return [];
  686. }
  687. if (is_array($result)) {
  688. return $result;
  689. }
  690. throw new RuntimeException("Custom-Uploader meldet einen Fehler.");
  691. }
  692. function backupUploadRemotes(string $archivePath, array $metadata): array
  693. {
  694. $results = [];
  695. foreach (backupGetRemoteTargets() as $index => $target) {
  696. if (!is_array($target)) {
  697. continue;
  698. }
  699. $type = trim((string) ($target["type"] ?? ""));
  700. $label = backupGetTargetLabel($target, (int) $index);
  701. $startedAt = date("c");
  702. try {
  703. if ($type === "s3") {
  704. $extra = backupUploadToS3($archivePath, $metadata, $target);
  705. } elseif ($type === "sftp") {
  706. $extra = backupUploadToSftp($archivePath, $metadata, $target);
  707. } elseif ($type === "custom") {
  708. $extra = backupUploadToCustom($archivePath, $metadata, $target);
  709. } elseif ($type === "managed") {
  710. $extra = backupUploadToManaged($archivePath, $metadata, $target);
  711. } else {
  712. throw new RuntimeException("Unbekannter Backup-Zieltyp: " . $type);
  713. }
  714. $results[] = array_merge(
  715. [
  716. "target" => $label,
  717. "type" => $type,
  718. "success" => true,
  719. "uploaded_at" => date("c"),
  720. "started_at" => $startedAt,
  721. ],
  722. is_array($extra) ? $extra : [],
  723. );
  724. } catch (Throwable $exception) {
  725. $results[] = [
  726. "target" => $label,
  727. "type" => $type !== "" ? $type : "unknown",
  728. "success" => false,
  729. "started_at" => $startedAt,
  730. "error" => $exception->getMessage(),
  731. ];
  732. }
  733. }
  734. return $results;
  735. }
  736. function backupGetLastAutomaticAt(): int
  737. {
  738. foreach (backupListBackups() as $record) {
  739. if (($record["trigger"] ?? "") !== "automatic") {
  740. continue;
  741. }
  742. $timestamp = strtotime((string) ($record["created_at"] ?? ""));
  743. if ($timestamp !== false) {
  744. return $timestamp;
  745. }
  746. }
  747. return 0;
  748. }
  749. function backupIsAutomaticDue(): bool
  750. {
  751. $interval = (int) BACKUP_AUTO_INTERVAL_SECONDS;
  752. if ($interval < 1) {
  753. return false;
  754. }
  755. return time() - backupGetLastAutomaticAt() >= $interval;
  756. }
  757. function backupCreate(string $trigger = "manual"): array
  758. {
  759. $trigger = $trigger === "automatic" ? "automatic" : "manual";
  760. $dir = backupGetDirectory();
  761. backupEnsureDirectory($dir);
  762. $lockHandle = fopen(backupGetLockFile(), "c+");
  763. if ($lockHandle === false) {
  764. throw new RuntimeException("Backup-Sperrdatei konnte nicht geöffnet werden.");
  765. }
  766. if (!flock($lockHandle, LOCK_EX | LOCK_NB)) {
  767. fclose($lockHandle);
  768. throw new RuntimeException("Es läuft bereits ein Backup.");
  769. }
  770. try {
  771. $baseName = "backup-" . date("Ymd-His");
  772. $filename = $baseName . ".zip";
  773. $counter = 2;
  774. while (file_exists($dir . $filename)) {
  775. $filename = $baseName . "-" . $counter . ".zip";
  776. $counter++;
  777. }
  778. $tmpFile = $dir . "." . $filename . ".tmp";
  779. $archivePath = $dir . $filename;
  780. $createdAt = date("c");
  781. $zipStats = backupWriteZip($tmpFile, backupGetSourceFiles());
  782. if (!rename($tmpFile, $archivePath)) {
  783. @unlink($tmpFile);
  784. throw new RuntimeException("Backup-ZIP konnte nicht finalisiert werden.");
  785. }
  786. @chmod($archivePath, 0660);
  787. $record = [
  788. "filename" => $filename,
  789. "created_at" => $createdAt,
  790. "trigger" => $trigger,
  791. "size" => (int) (filesize($archivePath) ?: $zipStats["archive_bytes"]),
  792. "file_count" => $zipStats["file_count"],
  793. "source_bytes" => $zipStats["source_bytes"],
  794. "sha256" => $zipStats["sha256"],
  795. "remote_uploads" => backupUploadRemotes($archivePath, [
  796. "filename" => $filename,
  797. "created_at" => $createdAt,
  798. "trigger" => $trigger,
  799. "sha256" => $zipStats["sha256"],
  800. ]),
  801. ];
  802. $records = backupListBackups();
  803. array_unshift($records, $record);
  804. backupWriteIndex($records);
  805. backupApplyRetention();
  806. logAccess("Backup created", [
  807. "filename" => $filename,
  808. "trigger" => $trigger,
  809. "file_count" => $record["file_count"],
  810. ]);
  811. return $record;
  812. } catch (Throwable $exception) {
  813. logError("Backup failed", [
  814. "trigger" => $trigger,
  815. "error" => $exception->getMessage(),
  816. ]);
  817. throw $exception;
  818. } finally {
  819. flock($lockHandle, LOCK_UN);
  820. fclose($lockHandle);
  821. }
  822. }
  823. function backupCreateAutomaticIfDue(): ?array
  824. {
  825. if (!backupIsAutomaticDue()) {
  826. return null;
  827. }
  828. return backupCreate("automatic");
  829. }