backup.php 33 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114
  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. class BackupRemoteUploadException extends RuntimeException
  16. {
  17. private array $debugContext;
  18. public function __construct(string $message, array $debugContext = [])
  19. {
  20. parent::__construct($message);
  21. $this->debugContext = $debugContext;
  22. }
  23. public function getDebugContext(): array
  24. {
  25. return $this->debugContext;
  26. }
  27. }
  28. function backupGetDirectory(): string
  29. {
  30. return rtrim((string) BACKUP_DIR, "/\\") . DIRECTORY_SEPARATOR;
  31. }
  32. function backupGetIndexFile(): string
  33. {
  34. return backupGetDirectory() . "backup-index.json";
  35. }
  36. function backupGetLockFile(): string
  37. {
  38. return backupGetDirectory() . ".backup.lock";
  39. }
  40. function backupEnsureDirectory(string $dir): void
  41. {
  42. if (!is_dir($dir) && !mkdir($dir, 02775, true) && !is_dir($dir)) {
  43. throw new RuntimeException("Backup-Verzeichnis konnte nicht erstellt werden.");
  44. }
  45. @chmod($dir, 02775);
  46. }
  47. function backupNormalizePath(string $path): string
  48. {
  49. return str_replace("\\", "/", $path);
  50. }
  51. function backupIsTemporaryFile(string $path): bool
  52. {
  53. $name = basename($path);
  54. return $name === "" ||
  55. $name[0] === "." ||
  56. str_ends_with($name, ".tmp") ||
  57. str_ends_with($name, ".part");
  58. }
  59. function backupGetSourceFiles(): array
  60. {
  61. $dataDir = rtrim(DATA_DIR, "/\\") . DIRECTORY_SEPARATOR;
  62. $files = [];
  63. foreach (glob($dataDir . "*.json") ?: [] as $file) {
  64. if (is_file($file) && is_readable($file) && !backupIsTemporaryFile($file)) {
  65. $files[] = [
  66. "path" => $file,
  67. "name" => "data/" . basename($file),
  68. ];
  69. }
  70. }
  71. $uploadsDir = rtrim(UPLOADS_DIR, "/\\") . DIRECTORY_SEPARATOR;
  72. if (is_dir($uploadsDir)) {
  73. $items = new RecursiveIteratorIterator(
  74. new RecursiveDirectoryIterator($uploadsDir, FilesystemIterator::SKIP_DOTS),
  75. RecursiveIteratorIterator::LEAVES_ONLY,
  76. );
  77. foreach ($items as $item) {
  78. if (!$item->isFile() || !$item->isReadable()) {
  79. continue;
  80. }
  81. $path = $item->getPathname();
  82. if (backupIsTemporaryFile($path)) {
  83. continue;
  84. }
  85. $relative = ltrim(
  86. backupNormalizePath(substr($path, strlen($uploadsDir))),
  87. "/",
  88. );
  89. if ($relative === "" || str_contains($relative, "\0")) {
  90. continue;
  91. }
  92. $files[] = [
  93. "path" => $path,
  94. "name" => "data/uploads/" . $relative,
  95. ];
  96. }
  97. }
  98. usort($files, function ($left, $right) {
  99. return strcmp($left["name"], $right["name"]);
  100. });
  101. return $files;
  102. }
  103. function backupGetDosDateTime(int $timestamp): array
  104. {
  105. $parts = getdate($timestamp);
  106. $year = max(1980, (int) $parts["year"]);
  107. return [
  108. (($year - 1980) << 9) | ((int) $parts["mon"] << 5) | (int) $parts["mday"],
  109. ((int) $parts["hours"] << 11) |
  110. ((int) $parts["minutes"] << 5) |
  111. ((int) floor(((int) $parts["seconds"]) / 2)),
  112. ];
  113. }
  114. function backupValidateZipEntryName(string $name): void
  115. {
  116. $name = backupNormalizePath($name);
  117. if (
  118. $name === "" ||
  119. str_contains($name, "\0") ||
  120. str_starts_with($name, "/") ||
  121. preg_match('/^[A-Za-z]:\//', $name) === 1
  122. ) {
  123. throw new RuntimeException("Ungültiger Backup-Pfad: " . $name);
  124. }
  125. foreach (explode("/", $name) as $segment) {
  126. if ($segment === "" || $segment === "." || $segment === "..") {
  127. throw new RuntimeException("Ungültiger Backup-Pfad: " . $name);
  128. }
  129. }
  130. if (strlen($name) > 65535) {
  131. throw new RuntimeException("Backup-Pfad ist zu lang: " . $name);
  132. }
  133. }
  134. function backupWriteBytes($handle, string $data): void
  135. {
  136. $offset = 0;
  137. $length = strlen($data);
  138. while ($offset < $length) {
  139. $written = fwrite($handle, substr($data, $offset));
  140. if ($written === false || $written === 0) {
  141. throw new RuntimeException("Backup-ZIP konnte nicht geschrieben werden.");
  142. }
  143. $offset += $written;
  144. }
  145. }
  146. function backupCopyFileToHandle(string $file, $handle): void
  147. {
  148. $source = fopen($file, "rb");
  149. if ($source === false) {
  150. throw new RuntimeException("Backup-Datei konnte nicht gelesen werden: " . basename($file));
  151. }
  152. while (!feof($source)) {
  153. $chunk = fread($source, 1048576);
  154. if ($chunk === false) {
  155. fclose($source);
  156. throw new RuntimeException("Backup-Datei konnte nicht gelesen werden: " . basename($file));
  157. }
  158. if ($chunk !== "") {
  159. try {
  160. backupWriteBytes($handle, $chunk);
  161. } catch (Throwable $exception) {
  162. fclose($source);
  163. throw $exception;
  164. }
  165. }
  166. }
  167. fclose($source);
  168. }
  169. function backupWriteZip(string $targetFile, array $files): array
  170. {
  171. if (empty($files)) {
  172. throw new RuntimeException("Keine Daten-Dateien für das Backup gefunden.");
  173. }
  174. $handle = fopen($targetFile, "wb");
  175. if ($handle === false) {
  176. throw new RuntimeException("Backup-ZIP konnte nicht erstellt werden.");
  177. }
  178. $centralDirectory = "";
  179. $fileCount = 0;
  180. $sourceBytes = 0;
  181. try {
  182. foreach ($files as $file) {
  183. $path = (string) ($file["path"] ?? "");
  184. $name = backupNormalizePath((string) ($file["name"] ?? ""));
  185. backupValidateZipEntryName($name);
  186. if (!is_file($path) || !is_readable($path)) {
  187. continue;
  188. }
  189. $size = filesize($path);
  190. if ($size === false) {
  191. throw new RuntimeException("Backup-Dateigröße konnte nicht ermittelt werden: " . $name);
  192. }
  193. if ($size > 0xffffffff) {
  194. throw new RuntimeException("Datei ist zu groß für dieses Backup-Format: " . $name);
  195. }
  196. $offset = ftell($handle);
  197. if ($offset === false || $offset > 0xffffffff) {
  198. throw new RuntimeException("Backup-ZIP ist zu groß für dieses Backup-Format.");
  199. }
  200. $crcHex = hash_file("crc32b", $path);
  201. if (!is_string($crcHex) || !preg_match('/^[a-f0-9]{8}$/i', $crcHex)) {
  202. throw new RuntimeException("Prüfsumme konnte nicht berechnet werden: " . $name);
  203. }
  204. $crc = (int) hexdec($crcHex);
  205. [$dosDate, $dosTime] = backupGetDosDateTime((int) (filemtime($path) ?: time()));
  206. $nameLength = strlen($name);
  207. backupWriteBytes(
  208. $handle,
  209. pack(
  210. "VvvvvvVVVvv",
  211. 0x04034b50,
  212. 10,
  213. 0,
  214. 0,
  215. $dosTime,
  216. $dosDate,
  217. $crc,
  218. $size,
  219. $size,
  220. $nameLength,
  221. 0,
  222. ) . $name,
  223. );
  224. backupCopyFileToHandle($path, $handle);
  225. $centralDirectory .=
  226. pack(
  227. "VvvvvvvVVVvvvvvVV",
  228. 0x02014b50,
  229. 0x031e,
  230. 10,
  231. 0,
  232. 0,
  233. $dosTime,
  234. $dosDate,
  235. $crc,
  236. $size,
  237. $size,
  238. $nameLength,
  239. 0,
  240. 0,
  241. 0,
  242. 0,
  243. 0,
  244. $offset,
  245. ) .
  246. $name;
  247. $fileCount++;
  248. $sourceBytes += $size;
  249. }
  250. if ($fileCount < 1) {
  251. throw new RuntimeException("Keine lesbaren Daten-Dateien für das Backup gefunden.");
  252. }
  253. if ($fileCount > 65535) {
  254. throw new RuntimeException("Zu viele Dateien für dieses Backup-Format.");
  255. }
  256. $centralOffset = ftell($handle);
  257. $centralSize = strlen($centralDirectory);
  258. if (
  259. $centralOffset === false ||
  260. $centralOffset > 0xffffffff ||
  261. $centralSize > 0xffffffff
  262. ) {
  263. throw new RuntimeException("Backup-ZIP ist zu groß für dieses Backup-Format.");
  264. }
  265. backupWriteBytes($handle, $centralDirectory);
  266. backupWriteBytes(
  267. $handle,
  268. pack(
  269. "VvvvvVVv",
  270. 0x06054b50,
  271. 0,
  272. 0,
  273. $fileCount,
  274. $fileCount,
  275. $centralSize,
  276. $centralOffset,
  277. 0,
  278. ),
  279. );
  280. } catch (Throwable $exception) {
  281. fclose($handle);
  282. @unlink($targetFile);
  283. throw $exception;
  284. }
  285. fclose($handle);
  286. @chmod($targetFile, 0660);
  287. return [
  288. "file_count" => $fileCount,
  289. "source_bytes" => $sourceBytes,
  290. "archive_bytes" => (int) (filesize($targetFile) ?: 0),
  291. "sha256" => hash_file("sha256", $targetFile) ?: "",
  292. ];
  293. }
  294. function backupReadIndex(): array
  295. {
  296. $index = readJsonFile(backupGetIndexFile());
  297. $records =
  298. isset($index["backups"]) && is_array($index["backups"])
  299. ? $index["backups"]
  300. : [];
  301. return ["backups" => array_values($records)];
  302. }
  303. function backupWriteIndex(array $records): bool
  304. {
  305. return writeJsonFile(backupGetIndexFile(), [
  306. "backups" => array_values($records),
  307. ]);
  308. }
  309. function backupListBackups(): array
  310. {
  311. $records = backupReadIndex()["backups"];
  312. $dir = backupGetDirectory();
  313. $existing = [];
  314. foreach ($records as $record) {
  315. if (!is_array($record)) {
  316. continue;
  317. }
  318. $filename = basename((string) ($record["filename"] ?? ""));
  319. if ($filename === "" || !is_file($dir . $filename)) {
  320. continue;
  321. }
  322. $record["filename"] = $filename;
  323. $record["size"] = (int) (filesize($dir . $filename) ?: ($record["size"] ?? 0));
  324. $existing[] = $record;
  325. }
  326. usort($existing, function ($left, $right) {
  327. return strcmp((string) ($right["created_at"] ?? ""), (string) ($left["created_at"] ?? ""));
  328. });
  329. return $existing;
  330. }
  331. function backupFormatBytes(int $bytes): string
  332. {
  333. if ($bytes >= 1073741824) {
  334. return number_format($bytes / 1073741824, 2, ",", ".") . " GB";
  335. }
  336. if ($bytes >= 1048576) {
  337. return number_format($bytes / 1048576, 2, ",", ".") . " MB";
  338. }
  339. if ($bytes >= 1024) {
  340. return number_format($bytes / 1024, 1, ",", ".") . " KB";
  341. }
  342. return $bytes . " B";
  343. }
  344. function backupGetRetentionLimit(): int
  345. {
  346. return max(1, (int) BACKUP_LOCAL_RETENTION);
  347. }
  348. function backupApplyRetention(): void
  349. {
  350. $records = backupListBackups();
  351. $keep = backupGetRetentionLimit();
  352. $dir = backupGetDirectory();
  353. foreach (array_slice($records, $keep) as $record) {
  354. $filename = basename((string) ($record["filename"] ?? ""));
  355. if ($filename !== "" && is_file($dir . $filename)) {
  356. @unlink($dir . $filename);
  357. }
  358. }
  359. backupWriteIndex(array_slice(backupListBackups(), 0, $keep));
  360. }
  361. function backupGetRemoteTargets(): array
  362. {
  363. return is_array(BACKUP_REMOTE_TARGETS) ? BACKUP_REMOTE_TARGETS : [];
  364. }
  365. function backupGetTargetLabel(array $target, int $index): string
  366. {
  367. $name = trim((string) ($target["name"] ?? ""));
  368. if ($name !== "") {
  369. return $name;
  370. }
  371. $type = trim((string) ($target["type"] ?? "target"));
  372. return $type . "-" . ($index + 1);
  373. }
  374. function backupGetSafeTargetContext(array $target): array
  375. {
  376. $safe = [];
  377. $allowedKeys = [
  378. "name",
  379. "type",
  380. "url",
  381. "instance",
  382. "bucket",
  383. "region",
  384. "prefix",
  385. "endpoint",
  386. "host",
  387. "port",
  388. "username",
  389. "path",
  390. "file",
  391. "callback",
  392. "timeout",
  393. ];
  394. foreach ($allowedKeys as $key) {
  395. if (array_key_exists($key, $target)) {
  396. $safe[$key] = is_scalar($target[$key]) ? (string) $target[$key] : gettype($target[$key]);
  397. }
  398. }
  399. return $safe;
  400. }
  401. function backupGetHttpUserAgent(): string
  402. {
  403. $version = defined("APP_VERSION") ? trim((string) APP_VERSION) : "";
  404. if ($version === "") {
  405. $version = "unknown";
  406. }
  407. return "PSA-Orderform-Backup/" . $version;
  408. }
  409. function backupFormatResponseExcerpt($response): string
  410. {
  411. if (!is_string($response) || $response === "") {
  412. return "";
  413. }
  414. $response = preg_replace('/\s+/', " ", trim($response));
  415. if (!is_string($response)) {
  416. return "";
  417. }
  418. return substr($response, 0, 500);
  419. }
  420. function backupFormatHeaderExcerpt(array $headers): array
  421. {
  422. $result = [];
  423. foreach ($headers as $header) {
  424. $header = trim((string) $header);
  425. if ($header === "") {
  426. continue;
  427. }
  428. $result[] = substr($header, 0, 500);
  429. if (count($result) >= 20) {
  430. break;
  431. }
  432. }
  433. return $result;
  434. }
  435. function backupGetLastPhpErrorMessage(): string
  436. {
  437. $error = error_get_last();
  438. if (!is_array($error)) {
  439. return "";
  440. }
  441. return substr(trim((string) ($error["message"] ?? "")), 0, 500);
  442. }
  443. function backupRemoteCapabilities(): array
  444. {
  445. $targets = backupGetRemoteTargets();
  446. $types = [];
  447. foreach ($targets as $target) {
  448. if (is_array($target)) {
  449. $type = trim((string) ($target["type"] ?? ""));
  450. if ($type !== "") {
  451. $types[$type] = true;
  452. }
  453. }
  454. }
  455. return [
  456. "s3" => [
  457. "configured" => !empty($types["s3"]),
  458. "available" => function_exists("hash_hmac"),
  459. ],
  460. "sftp" => [
  461. "configured" => !empty($types["sftp"]),
  462. "available" =>
  463. function_exists("ssh2_connect") &&
  464. function_exists("ssh2_sftp"),
  465. ],
  466. "custom" => [
  467. "configured" => !empty($types["custom"]),
  468. "available" => true,
  469. ],
  470. "managed" => [
  471. "configured" => !empty($types["managed"]),
  472. "available" => true,
  473. ],
  474. ];
  475. }
  476. function backupGetHttpStatusFromHeaders(array $headers): int
  477. {
  478. foreach ($headers as $header) {
  479. if (preg_match('/^HTTP\/\S+\s+(\d+)/', $header, $matches) === 1) {
  480. return (int) $matches[1];
  481. }
  482. }
  483. return 0;
  484. }
  485. function backupUploadToS3(string $archivePath, array $metadata, array $target): array
  486. {
  487. $bucket = trim((string) ($target["bucket"] ?? ""));
  488. $region = trim((string) ($target["region"] ?? ""));
  489. $accessKey = trim((string) ($target["access_key"] ?? ""));
  490. $secretKey = (string) ($target["secret_key"] ?? "");
  491. $prefix = trim((string) ($target["prefix"] ?? ""), "/");
  492. $endpoint = rtrim(trim((string) ($target["endpoint"] ?? "")), "/");
  493. if ($bucket === "" || $region === "" || $accessKey === "" || $secretKey === "") {
  494. throw new RuntimeException("S3-Ziel ist unvollständig konfiguriert.");
  495. }
  496. $filename = basename($archivePath);
  497. $key = ($prefix !== "" ? $prefix . "/" : "") . $filename;
  498. $host = $endpoint !== ""
  499. ? parse_url($endpoint, PHP_URL_HOST)
  500. : $bucket . ".s3." . $region . ".amazonaws.com";
  501. if (!is_string($host) || $host === "") {
  502. throw new RuntimeException("S3-Endpunkt ist ungültig.");
  503. }
  504. $url = $endpoint !== ""
  505. ? $endpoint . "/" . rawurlencode($bucket) . "/" . str_replace("%2F", "/", rawurlencode($key))
  506. : "https://" . $host . "/" . str_replace("%2F", "/", rawurlencode($key));
  507. $payload = file_get_contents($archivePath);
  508. if ($payload === false) {
  509. throw new RuntimeException("Backup-ZIP konnte für S3 nicht gelesen werden.");
  510. }
  511. $now = gmdate("Ymd\THis\Z");
  512. $date = substr($now, 0, 8);
  513. $payloadHash = hash("sha256", $payload);
  514. $canonicalUri = parse_url($url, PHP_URL_PATH);
  515. $canonicalUri = is_string($canonicalUri) && $canonicalUri !== "" ? $canonicalUri : "/";
  516. $signedHeaders = "content-type;host;x-amz-content-sha256;x-amz-date";
  517. $canonicalHeaders =
  518. "content-type:application/zip\n" .
  519. "host:" . $host . "\n" .
  520. "x-amz-content-sha256:" . $payloadHash . "\n" .
  521. "x-amz-date:" . $now . "\n";
  522. $canonicalRequest =
  523. "PUT\n" .
  524. $canonicalUri .
  525. "\n\n" .
  526. $canonicalHeaders .
  527. "\n" .
  528. $signedHeaders .
  529. "\n" .
  530. $payloadHash;
  531. $scope = $date . "/" . $region . "/s3/aws4_request";
  532. $stringToSign =
  533. "AWS4-HMAC-SHA256\n" .
  534. $now .
  535. "\n" .
  536. $scope .
  537. "\n" .
  538. hash("sha256", $canonicalRequest);
  539. $kDate = hash_hmac("sha256", $date, "AWS4" . $secretKey, true);
  540. $kRegion = hash_hmac("sha256", $region, $kDate, true);
  541. $kService = hash_hmac("sha256", "s3", $kRegion, true);
  542. $kSigning = hash_hmac("sha256", "aws4_request", $kService, true);
  543. $signature = hash_hmac("sha256", $stringToSign, $kSigning);
  544. $authorization =
  545. "AWS4-HMAC-SHA256 Credential=" .
  546. $accessKey .
  547. "/" .
  548. $scope .
  549. ", SignedHeaders=" .
  550. $signedHeaders .
  551. ", Signature=" .
  552. $signature;
  553. $context = stream_context_create([
  554. "http" => [
  555. "method" => "PUT",
  556. "timeout" => (int) ($target["timeout"] ?? 120),
  557. "ignore_errors" => true,
  558. "header" =>
  559. "Content-Type: application/zip\r\n" .
  560. "Content-Length: " . strlen($payload) . "\r\n" .
  561. "Host: " . $host . "\r\n" .
  562. "X-Amz-Date: " . $now . "\r\n" .
  563. "X-Amz-Content-Sha256: " . $payloadHash . "\r\n" .
  564. "Authorization: " . $authorization . "\r\n" .
  565. "User-Agent: " . backupGetHttpUserAgent() . "\r\n",
  566. "content" => $payload,
  567. ],
  568. ]);
  569. $response = @file_get_contents($url, false, $context);
  570. $phpError = $response === false ? backupGetLastPhpErrorMessage() : "";
  571. if (function_exists("http_get_last_response_headers")) {
  572. $lastHeaders = http_get_last_response_headers();
  573. $headers = is_array($lastHeaders) ? $lastHeaders : [];
  574. } else {
  575. $legacyHeaders = ${"http_response_header"} ?? [];
  576. $headers = is_array($legacyHeaders)
  577. ? $legacyHeaders
  578. : [];
  579. }
  580. $status = backupGetHttpStatusFromHeaders($headers);
  581. if ($response === false || $status < 200 || $status >= 300) {
  582. throw new BackupRemoteUploadException(
  583. "S3-Upload fehlgeschlagen" . ($status > 0 ? " (HTTP " . $status . ")" : "") . ".",
  584. [
  585. "http_status" => $status,
  586. "response_excerpt" => backupFormatResponseExcerpt($response),
  587. "response_headers" => backupFormatHeaderExcerpt($headers),
  588. "php_error" => $phpError,
  589. "bucket" => $bucket,
  590. "region" => $region,
  591. "key" => $key,
  592. "endpoint" => $endpoint,
  593. ],
  594. );
  595. }
  596. return ["remote_path" => "s3://" . $bucket . "/" . $key];
  597. }
  598. function backupUploadToSftp(string $archivePath, array $metadata, array $target): array
  599. {
  600. if (!function_exists("ssh2_connect") || !function_exists("ssh2_sftp")) {
  601. throw new RuntimeException("PHP-SSH2-Erweiterung ist nicht verfügbar.");
  602. }
  603. $host = trim((string) ($target["host"] ?? ""));
  604. $username = trim((string) ($target["username"] ?? ""));
  605. $password = (string) ($target["password"] ?? "");
  606. $remoteDir = rtrim((string) ($target["path"] ?? ""), "/");
  607. $port = (int) ($target["port"] ?? 22);
  608. if ($host === "" || $username === "" || $remoteDir === "") {
  609. throw new RuntimeException("SFTP-Ziel ist unvollständig konfiguriert.");
  610. }
  611. $connection = @ssh2_connect($host, $port > 0 ? $port : 22);
  612. if ($connection === false) {
  613. throw new RuntimeException("SFTP-Verbindung konnte nicht hergestellt werden.");
  614. }
  615. $authenticated = false;
  616. $privateKey = trim((string) ($target["private_key"] ?? ""));
  617. $publicKey = trim((string) ($target["public_key"] ?? ""));
  618. if (
  619. $privateKey !== "" &&
  620. $publicKey !== "" &&
  621. function_exists("ssh2_auth_pubkey_file")
  622. ) {
  623. $authenticated = @ssh2_auth_pubkey_file(
  624. $connection,
  625. $username,
  626. $publicKey,
  627. $privateKey,
  628. $password !== "" ? $password : null,
  629. );
  630. } elseif (function_exists("ssh2_auth_password")) {
  631. $authenticated = @ssh2_auth_password($connection, $username, $password);
  632. }
  633. if (!$authenticated) {
  634. throw new RuntimeException("SFTP-Anmeldung fehlgeschlagen.");
  635. }
  636. $sftp = @ssh2_sftp($connection);
  637. if ($sftp === false) {
  638. throw new RuntimeException("SFTP-Subsystem konnte nicht gestartet werden.");
  639. }
  640. $remotePath = $remoteDir . "/" . basename($archivePath);
  641. $targetStream = @fopen("ssh2.sftp://" . intval($sftp) . $remotePath, "wb");
  642. if ($targetStream === false) {
  643. throw new RuntimeException("SFTP-Zieldatei konnte nicht geöffnet werden.");
  644. }
  645. $source = fopen($archivePath, "rb");
  646. if ($source === false) {
  647. fclose($targetStream);
  648. throw new RuntimeException("Backup-ZIP konnte für SFTP nicht gelesen werden.");
  649. }
  650. $copied = stream_copy_to_stream($source, $targetStream);
  651. fclose($source);
  652. fclose($targetStream);
  653. if ($copied === false) {
  654. throw new RuntimeException("SFTP-Upload fehlgeschlagen.");
  655. }
  656. return ["remote_path" => "sftp://" . $host . $remotePath];
  657. }
  658. function backupValidateManagedInstance(string $instance): string
  659. {
  660. $instance = trim($instance);
  661. if (
  662. $instance === "" ||
  663. strlen($instance) > 120 ||
  664. preg_match('/^[A-Za-z0-9][A-Za-z0-9._-]*$/', $instance) !== 1
  665. ) {
  666. throw new RuntimeException("Managed-Backup-Instanz ist ungültig.");
  667. }
  668. return $instance;
  669. }
  670. function backupBuildMultipartBody(array $fields, string $fileField, string $filePath, string $fileName, string $boundary): string
  671. {
  672. $body = "";
  673. foreach ($fields as $name => $value) {
  674. $body .= "--" . $boundary . "\r\n";
  675. $body .= 'Content-Disposition: form-data; name="' . addcslashes((string) $name, "\"\\") . "\"\r\n\r\n";
  676. $body .= (string) $value . "\r\n";
  677. }
  678. $payload = file_get_contents($filePath);
  679. if ($payload === false) {
  680. throw new RuntimeException("Backup-ZIP konnte für Managed Upload nicht gelesen werden.");
  681. }
  682. $body .= "--" . $boundary . "\r\n";
  683. $body .=
  684. 'Content-Disposition: form-data; name="' .
  685. addcslashes($fileField, "\"\\") .
  686. '"; filename="' .
  687. addcslashes($fileName, "\"\\") .
  688. "\"\r\n";
  689. $body .= "Content-Type: application/zip\r\n\r\n";
  690. $body .= $payload . "\r\n";
  691. $body .= "--" . $boundary . "--\r\n";
  692. return $body;
  693. }
  694. function backupUploadToManaged(string $archivePath, array $metadata, array $target): array
  695. {
  696. $url = trim((string) ($target["url"] ?? ""));
  697. $instance = backupValidateManagedInstance((string) ($target["instance"] ?? ""));
  698. if (!filter_var($url, FILTER_VALIDATE_URL)) {
  699. throw new RuntimeException("Managed-Backup-URL ist ungültig.");
  700. }
  701. $filename = basename($archivePath);
  702. $sha256 = trim((string) ($metadata["sha256"] ?? ""));
  703. if (!preg_match('/^[a-f0-9]{64}$/', $sha256)) {
  704. $sha256 = strtolower(hash_file("sha256", $archivePath) ?: "");
  705. }
  706. if (!preg_match('/^[a-f0-9]{64}$/', $sha256)) {
  707. throw new RuntimeException("Managed-Backup-Prüfsumme konnte nicht berechnet werden.");
  708. }
  709. $boundary = "----psa-backup-" . bin2hex(random_bytes(12));
  710. $body = backupBuildMultipartBody(
  711. [
  712. "instance" => $instance,
  713. "filename" => $filename,
  714. "sha256" => $sha256,
  715. ],
  716. "backup",
  717. $archivePath,
  718. $filename,
  719. $boundary,
  720. );
  721. $context = stream_context_create([
  722. "http" => [
  723. "method" => "POST",
  724. "timeout" => (int) ($target["timeout"] ?? 120),
  725. "ignore_errors" => true,
  726. "header" =>
  727. "Content-Type: multipart/form-data; boundary=" .
  728. $boundary .
  729. "\r\nAccept: application/json\r\nUser-Agent: " .
  730. backupGetHttpUserAgent() .
  731. "\r\nContent-Length: " .
  732. strlen($body) .
  733. "\r\n",
  734. "content" => $body,
  735. ],
  736. ]);
  737. $response = @file_get_contents($url, false, $context);
  738. $phpError = $response === false ? backupGetLastPhpErrorMessage() : "";
  739. if (function_exists("http_get_last_response_headers")) {
  740. $lastHeaders = http_get_last_response_headers();
  741. $headers = is_array($lastHeaders) ? $lastHeaders : [];
  742. } else {
  743. $legacyHeaders = ${"http_response_header"} ?? [];
  744. $headers = is_array($legacyHeaders)
  745. ? $legacyHeaders
  746. : [];
  747. }
  748. $status = backupGetHttpStatusFromHeaders($headers);
  749. if ($response === false || $status < 200 || $status >= 300) {
  750. throw new BackupRemoteUploadException(
  751. "Managed-Backup-Upload fehlgeschlagen" . ($status > 0 ? " (HTTP " . $status . ")" : "") . ".",
  752. [
  753. "http_status" => $status,
  754. "response_excerpt" => backupFormatResponseExcerpt($response),
  755. "response_headers" => backupFormatHeaderExcerpt($headers),
  756. "php_error" => $phpError,
  757. "url" => $url,
  758. "instance" => $instance,
  759. ],
  760. );
  761. }
  762. $decoded = json_decode($response, true);
  763. if (!is_array($decoded) || empty($decoded["success"])) {
  764. $error = is_array($decoded) ? trim((string) ($decoded["error"] ?? "")) : "";
  765. throw new BackupRemoteUploadException(
  766. "Managed-Backup-Upload wurde abgelehnt" . ($error !== "" ? ": " . $error : "."),
  767. [
  768. "http_status" => $status,
  769. "response_excerpt" => backupFormatResponseExcerpt($response),
  770. "response_headers" => backupFormatHeaderExcerpt($headers),
  771. "url" => $url,
  772. "instance" => $instance,
  773. "server_error" => $error,
  774. ],
  775. );
  776. }
  777. return [
  778. "remote_path" => $url,
  779. "instance" => $instance,
  780. "server_filename" => (string) ($decoded["filename"] ?? ""),
  781. ];
  782. }
  783. function backupUploadToCustom(string $archivePath, array $metadata, array $target): array
  784. {
  785. $file = trim((string) ($target["file"] ?? ""));
  786. $callback = $target["callback"] ?? null;
  787. if ($file !== "") {
  788. if (!is_file($file)) {
  789. throw new RuntimeException("Custom-Uploader-Datei wurde nicht gefunden.");
  790. }
  791. require_once $file;
  792. }
  793. if (!is_callable($callback)) {
  794. throw new RuntimeException("Custom-Uploader ist nicht aufrufbar.");
  795. }
  796. $result = call_user_func($callback, $archivePath, $metadata, $target);
  797. if ($result === true) {
  798. return [];
  799. }
  800. if (is_array($result)) {
  801. return $result;
  802. }
  803. throw new RuntimeException("Custom-Uploader meldet einen Fehler.");
  804. }
  805. function backupUploadRemotes(string $archivePath, array $metadata): array
  806. {
  807. $results = [];
  808. foreach (backupGetRemoteTargets() as $index => $target) {
  809. if (!is_array($target)) {
  810. continue;
  811. }
  812. $type = trim((string) ($target["type"] ?? ""));
  813. $label = backupGetTargetLabel($target, (int) $index);
  814. $startedAt = date("c");
  815. $safeTargetContext = backupGetSafeTargetContext($target);
  816. try {
  817. if ($type === "s3") {
  818. $extra = backupUploadToS3($archivePath, $metadata, $target);
  819. } elseif ($type === "sftp") {
  820. $extra = backupUploadToSftp($archivePath, $metadata, $target);
  821. } elseif ($type === "custom") {
  822. $extra = backupUploadToCustom($archivePath, $metadata, $target);
  823. } elseif ($type === "managed") {
  824. $extra = backupUploadToManaged($archivePath, $metadata, $target);
  825. } else {
  826. throw new RuntimeException("Unbekannter Backup-Zieltyp: " . $type);
  827. }
  828. $result = array_merge(
  829. [
  830. "target" => $label,
  831. "type" => $type,
  832. "success" => true,
  833. "uploaded_at" => date("c"),
  834. "started_at" => $startedAt,
  835. ],
  836. is_array($extra) ? $extra : [],
  837. );
  838. $results[] = $result;
  839. logAccess("Backup remote upload succeeded", [
  840. "target" => $label,
  841. "type" => $type,
  842. "filename" => $metadata["filename"] ?? basename($archivePath),
  843. "remote_path" => (string) ($result["remote_path"] ?? ""),
  844. ]);
  845. } catch (Throwable $exception) {
  846. $debugContext = $exception instanceof BackupRemoteUploadException
  847. ? $exception->getDebugContext()
  848. : [];
  849. $result = [
  850. "target" => $label,
  851. "type" => $type !== "" ? $type : "unknown",
  852. "success" => false,
  853. "started_at" => $startedAt,
  854. "error" => $exception->getMessage(),
  855. ];
  856. if (!empty($debugContext)) {
  857. $result["debug"] = $debugContext;
  858. }
  859. $results[] = $result;
  860. logError("Backup remote upload failed", [
  861. "target" => $label,
  862. "type" => $type !== "" ? $type : "unknown",
  863. "target_config" => $safeTargetContext,
  864. "filename" => $metadata["filename"] ?? basename($archivePath),
  865. "sha256" => $metadata["sha256"] ?? "",
  866. "error" => $exception->getMessage(),
  867. "debug" => $debugContext,
  868. ]);
  869. }
  870. }
  871. return $results;
  872. }
  873. function backupGetLastAutomaticAt(): int
  874. {
  875. foreach (backupListBackups() as $record) {
  876. if (($record["trigger"] ?? "") !== "automatic") {
  877. continue;
  878. }
  879. $timestamp = strtotime((string) ($record["created_at"] ?? ""));
  880. if ($timestamp !== false) {
  881. return $timestamp;
  882. }
  883. }
  884. return 0;
  885. }
  886. function backupIsAutomaticDue(): bool
  887. {
  888. $interval = (int) BACKUP_AUTO_INTERVAL_SECONDS;
  889. if ($interval < 1) {
  890. return false;
  891. }
  892. return time() - backupGetLastAutomaticAt() >= $interval;
  893. }
  894. function backupCreate(string $trigger = "manual"): array
  895. {
  896. $trigger = $trigger === "automatic" ? "automatic" : "manual";
  897. $dir = backupGetDirectory();
  898. backupEnsureDirectory($dir);
  899. $lockHandle = fopen(backupGetLockFile(), "c+");
  900. if ($lockHandle === false) {
  901. throw new RuntimeException("Backup-Sperrdatei konnte nicht geöffnet werden.");
  902. }
  903. if (!flock($lockHandle, LOCK_EX | LOCK_NB)) {
  904. fclose($lockHandle);
  905. throw new RuntimeException("Es läuft bereits ein Backup.");
  906. }
  907. try {
  908. $baseName = "backup-" . date("Ymd-His");
  909. $filename = $baseName . ".zip";
  910. $counter = 2;
  911. while (file_exists($dir . $filename)) {
  912. $filename = $baseName . "-" . $counter . ".zip";
  913. $counter++;
  914. }
  915. $tmpFile = $dir . "." . $filename . ".tmp";
  916. $archivePath = $dir . $filename;
  917. $createdAt = date("c");
  918. $zipStats = backupWriteZip($tmpFile, backupGetSourceFiles());
  919. if (!rename($tmpFile, $archivePath)) {
  920. @unlink($tmpFile);
  921. throw new RuntimeException("Backup-ZIP konnte nicht finalisiert werden.");
  922. }
  923. @chmod($archivePath, 0660);
  924. $record = [
  925. "filename" => $filename,
  926. "created_at" => $createdAt,
  927. "trigger" => $trigger,
  928. "size" => (int) (filesize($archivePath) ?: $zipStats["archive_bytes"]),
  929. "file_count" => $zipStats["file_count"],
  930. "source_bytes" => $zipStats["source_bytes"],
  931. "sha256" => $zipStats["sha256"],
  932. "remote_uploads" => backupUploadRemotes($archivePath, [
  933. "filename" => $filename,
  934. "created_at" => $createdAt,
  935. "trigger" => $trigger,
  936. "sha256" => $zipStats["sha256"],
  937. ]),
  938. ];
  939. $records = backupListBackups();
  940. array_unshift($records, $record);
  941. backupWriteIndex($records);
  942. backupApplyRetention();
  943. logAccess("Backup created", [
  944. "filename" => $filename,
  945. "trigger" => $trigger,
  946. "file_count" => $record["file_count"],
  947. ]);
  948. return $record;
  949. } catch (Throwable $exception) {
  950. logError("Backup failed", [
  951. "trigger" => $trigger,
  952. "error" => $exception->getMessage(),
  953. ]);
  954. throw $exception;
  955. } finally {
  956. flock($lockHandle, LOCK_UN);
  957. fclose($lockHandle);
  958. }
  959. }
  960. function backupCreateAutomaticIfDue(): ?array
  961. {
  962. if (!backupIsAutomaticDue()) {
  963. return null;
  964. }
  965. return backupCreate("automatic");
  966. }