|
@@ -0,0 +1,823 @@
|
|
|
|
|
+<?php
|
|
|
|
|
+
|
|
|
|
|
+require_once __DIR__ . "/functions.php";
|
|
|
|
|
+
|
|
|
|
|
+if (!defined("BACKUP_DIR")) {
|
|
|
|
|
+ define("BACKUP_DIR", DATA_DIR . "backups/");
|
|
|
|
|
+}
|
|
|
|
|
+if (!defined("BACKUP_LOCAL_RETENTION")) {
|
|
|
|
|
+ define("BACKUP_LOCAL_RETENTION", 4);
|
|
|
|
|
+}
|
|
|
|
|
+if (!defined("BACKUP_AUTO_INTERVAL_SECONDS")) {
|
|
|
|
|
+ define("BACKUP_AUTO_INTERVAL_SECONDS", 604800);
|
|
|
|
|
+}
|
|
|
|
|
+if (!defined("BACKUP_REMOTE_TARGETS")) {
|
|
|
|
|
+ define("BACKUP_REMOTE_TARGETS", []);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function backupGetDirectory(): string
|
|
|
|
|
+{
|
|
|
|
|
+ return rtrim((string) BACKUP_DIR, "/\\") . DIRECTORY_SEPARATOR;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function backupGetIndexFile(): string
|
|
|
|
|
+{
|
|
|
|
|
+ return backupGetDirectory() . "backup-index.json";
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function backupGetLockFile(): string
|
|
|
|
|
+{
|
|
|
|
|
+ return backupGetDirectory() . ".backup.lock";
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function backupEnsureDirectory(string $dir): void
|
|
|
|
|
+{
|
|
|
|
|
+ if (!is_dir($dir) && !mkdir($dir, 02775, true) && !is_dir($dir)) {
|
|
|
|
|
+ throw new RuntimeException("Backup-Verzeichnis konnte nicht erstellt werden.");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @chmod($dir, 02775);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function backupNormalizePath(string $path): string
|
|
|
|
|
+{
|
|
|
|
|
+ return str_replace("\\", "/", $path);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function backupIsTemporaryFile(string $path): bool
|
|
|
|
|
+{
|
|
|
|
|
+ $name = basename($path);
|
|
|
|
|
+ return $name === "" ||
|
|
|
|
|
+ $name[0] === "." ||
|
|
|
|
|
+ str_ends_with($name, ".tmp") ||
|
|
|
|
|
+ str_ends_with($name, ".part");
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function backupGetSourceFiles(): array
|
|
|
|
|
+{
|
|
|
|
|
+ $dataDir = rtrim(DATA_DIR, "/\\") . DIRECTORY_SEPARATOR;
|
|
|
|
|
+ $files = [];
|
|
|
|
|
+
|
|
|
|
|
+ foreach (glob($dataDir . "*.json") ?: [] as $file) {
|
|
|
|
|
+ if (is_file($file) && is_readable($file) && !backupIsTemporaryFile($file)) {
|
|
|
|
|
+ $files[] = [
|
|
|
|
|
+ "path" => $file,
|
|
|
|
|
+ "name" => "data/" . basename($file),
|
|
|
|
|
+ ];
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $uploadsDir = rtrim(UPLOADS_DIR, "/\\") . DIRECTORY_SEPARATOR;
|
|
|
|
|
+ if (is_dir($uploadsDir)) {
|
|
|
|
|
+ $items = new RecursiveIteratorIterator(
|
|
|
|
|
+ new RecursiveDirectoryIterator($uploadsDir, FilesystemIterator::SKIP_DOTS),
|
|
|
|
|
+ RecursiveIteratorIterator::LEAVES_ONLY,
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ foreach ($items as $item) {
|
|
|
|
|
+ if (!$item->isFile() || !$item->isReadable()) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $path = $item->getPathname();
|
|
|
|
|
+ if (backupIsTemporaryFile($path)) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $relative = ltrim(
|
|
|
|
|
+ backupNormalizePath(substr($path, strlen($uploadsDir))),
|
|
|
|
|
+ "/",
|
|
|
|
|
+ );
|
|
|
|
|
+ if ($relative === "" || str_contains($relative, "\0")) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $files[] = [
|
|
|
|
|
+ "path" => $path,
|
|
|
|
|
+ "name" => "data/uploads/" . $relative,
|
|
|
|
|
+ ];
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ usort($files, function ($left, $right) {
|
|
|
|
|
+ return strcmp($left["name"], $right["name"]);
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ return $files;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function backupGetDosDateTime(int $timestamp): array
|
|
|
|
|
+{
|
|
|
|
|
+ $parts = getdate($timestamp);
|
|
|
|
|
+ $year = max(1980, (int) $parts["year"]);
|
|
|
|
|
+
|
|
|
|
|
+ return [
|
|
|
|
|
+ (($year - 1980) << 9) | ((int) $parts["mon"] << 5) | (int) $parts["mday"],
|
|
|
|
|
+ ((int) $parts["hours"] << 11) |
|
|
|
|
|
+ ((int) $parts["minutes"] << 5) |
|
|
|
|
|
+ ((int) floor(((int) $parts["seconds"]) / 2)),
|
|
|
|
|
+ ];
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function backupValidateZipEntryName(string $name): void
|
|
|
|
|
+{
|
|
|
|
|
+ $name = backupNormalizePath($name);
|
|
|
|
|
+
|
|
|
|
|
+ if (
|
|
|
|
|
+ $name === "" ||
|
|
|
|
|
+ str_contains($name, "\0") ||
|
|
|
|
|
+ str_starts_with($name, "/") ||
|
|
|
|
|
+ preg_match('/^[A-Za-z]:\//', $name) === 1
|
|
|
|
|
+ ) {
|
|
|
|
|
+ throw new RuntimeException("Ungültiger Backup-Pfad: " . $name);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ foreach (explode("/", $name) as $segment) {
|
|
|
|
|
+ if ($segment === "" || $segment === "." || $segment === "..") {
|
|
|
|
|
+ throw new RuntimeException("Ungültiger Backup-Pfad: " . $name);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (strlen($name) > 65535) {
|
|
|
|
|
+ throw new RuntimeException("Backup-Pfad ist zu lang: " . $name);
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function backupWriteBytes($handle, string $data): void
|
|
|
|
|
+{
|
|
|
|
|
+ $offset = 0;
|
|
|
|
|
+ $length = strlen($data);
|
|
|
|
|
+
|
|
|
|
|
+ while ($offset < $length) {
|
|
|
|
|
+ $written = fwrite($handle, substr($data, $offset));
|
|
|
|
|
+ if ($written === false || $written === 0) {
|
|
|
|
|
+ throw new RuntimeException("Backup-ZIP konnte nicht geschrieben werden.");
|
|
|
|
|
+ }
|
|
|
|
|
+ $offset += $written;
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function backupCopyFileToHandle(string $file, $handle): void
|
|
|
|
|
+{
|
|
|
|
|
+ $source = fopen($file, "rb");
|
|
|
|
|
+ if ($source === false) {
|
|
|
|
|
+ throw new RuntimeException("Backup-Datei konnte nicht gelesen werden: " . basename($file));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ while (!feof($source)) {
|
|
|
|
|
+ $chunk = fread($source, 1048576);
|
|
|
|
|
+ if ($chunk === false) {
|
|
|
|
|
+ fclose($source);
|
|
|
|
|
+ throw new RuntimeException("Backup-Datei konnte nicht gelesen werden: " . basename($file));
|
|
|
|
|
+ }
|
|
|
|
|
+ if ($chunk !== "") {
|
|
|
|
|
+ try {
|
|
|
|
|
+ backupWriteBytes($handle, $chunk);
|
|
|
|
|
+ } catch (Throwable $exception) {
|
|
|
|
|
+ fclose($source);
|
|
|
|
|
+ throw $exception;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ fclose($source);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function backupWriteZip(string $targetFile, array $files): array
|
|
|
|
|
+{
|
|
|
|
|
+ if (empty($files)) {
|
|
|
|
|
+ throw new RuntimeException("Keine Daten-Dateien für das Backup gefunden.");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $handle = fopen($targetFile, "wb");
|
|
|
|
|
+ if ($handle === false) {
|
|
|
|
|
+ throw new RuntimeException("Backup-ZIP konnte nicht erstellt werden.");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $centralDirectory = "";
|
|
|
|
|
+ $fileCount = 0;
|
|
|
|
|
+ $sourceBytes = 0;
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ foreach ($files as $file) {
|
|
|
|
|
+ $path = (string) ($file["path"] ?? "");
|
|
|
|
|
+ $name = backupNormalizePath((string) ($file["name"] ?? ""));
|
|
|
|
|
+ backupValidateZipEntryName($name);
|
|
|
|
|
+
|
|
|
|
|
+ if (!is_file($path) || !is_readable($path)) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $size = filesize($path);
|
|
|
|
|
+ if ($size === false) {
|
|
|
|
|
+ throw new RuntimeException("Backup-Dateigröße konnte nicht ermittelt werden: " . $name);
|
|
|
|
|
+ }
|
|
|
|
|
+ if ($size > 0xffffffff) {
|
|
|
|
|
+ throw new RuntimeException("Datei ist zu groß für dieses Backup-Format: " . $name);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $offset = ftell($handle);
|
|
|
|
|
+ if ($offset === false || $offset > 0xffffffff) {
|
|
|
|
|
+ throw new RuntimeException("Backup-ZIP ist zu groß für dieses Backup-Format.");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $crcHex = hash_file("crc32b", $path);
|
|
|
|
|
+ if (!is_string($crcHex) || !preg_match('/^[a-f0-9]{8}$/i', $crcHex)) {
|
|
|
|
|
+ throw new RuntimeException("Prüfsumme konnte nicht berechnet werden: " . $name);
|
|
|
|
|
+ }
|
|
|
|
|
+ $crc = (int) hexdec($crcHex);
|
|
|
|
|
+ [$dosDate, $dosTime] = backupGetDosDateTime((int) (filemtime($path) ?: time()));
|
|
|
|
|
+ $nameLength = strlen($name);
|
|
|
|
|
+
|
|
|
|
|
+ backupWriteBytes(
|
|
|
|
|
+ $handle,
|
|
|
|
|
+ pack(
|
|
|
|
|
+ "VvvvvvVVVvv",
|
|
|
|
|
+ 0x04034b50,
|
|
|
|
|
+ 10,
|
|
|
|
|
+ 0,
|
|
|
|
|
+ 0,
|
|
|
|
|
+ $dosTime,
|
|
|
|
|
+ $dosDate,
|
|
|
|
|
+ $crc,
|
|
|
|
|
+ $size,
|
|
|
|
|
+ $size,
|
|
|
|
|
+ $nameLength,
|
|
|
|
|
+ 0,
|
|
|
|
|
+ ) . $name,
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ backupCopyFileToHandle($path, $handle);
|
|
|
|
|
+
|
|
|
|
|
+ $centralDirectory .=
|
|
|
|
|
+ pack(
|
|
|
|
|
+ "VvvvvvvVVVvvvvvVV",
|
|
|
|
|
+ 0x02014b50,
|
|
|
|
|
+ 0x031e,
|
|
|
|
|
+ 10,
|
|
|
|
|
+ 0,
|
|
|
|
|
+ 0,
|
|
|
|
|
+ $dosTime,
|
|
|
|
|
+ $dosDate,
|
|
|
|
|
+ $crc,
|
|
|
|
|
+ $size,
|
|
|
|
|
+ $size,
|
|
|
|
|
+ $nameLength,
|
|
|
|
|
+ 0,
|
|
|
|
|
+ 0,
|
|
|
|
|
+ 0,
|
|
|
|
|
+ 0,
|
|
|
|
|
+ 0,
|
|
|
|
|
+ $offset,
|
|
|
|
|
+ ) .
|
|
|
|
|
+ $name;
|
|
|
|
|
+
|
|
|
|
|
+ $fileCount++;
|
|
|
|
|
+ $sourceBytes += $size;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if ($fileCount < 1) {
|
|
|
|
|
+ throw new RuntimeException("Keine lesbaren Daten-Dateien für das Backup gefunden.");
|
|
|
|
|
+ }
|
|
|
|
|
+ if ($fileCount > 65535) {
|
|
|
|
|
+ throw new RuntimeException("Zu viele Dateien für dieses Backup-Format.");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $centralOffset = ftell($handle);
|
|
|
|
|
+ $centralSize = strlen($centralDirectory);
|
|
|
|
|
+ if (
|
|
|
|
|
+ $centralOffset === false ||
|
|
|
|
|
+ $centralOffset > 0xffffffff ||
|
|
|
|
|
+ $centralSize > 0xffffffff
|
|
|
|
|
+ ) {
|
|
|
|
|
+ throw new RuntimeException("Backup-ZIP ist zu groß für dieses Backup-Format.");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ backupWriteBytes($handle, $centralDirectory);
|
|
|
|
|
+ backupWriteBytes(
|
|
|
|
|
+ $handle,
|
|
|
|
|
+ pack(
|
|
|
|
|
+ "VvvvvVVv",
|
|
|
|
|
+ 0x06054b50,
|
|
|
|
|
+ 0,
|
|
|
|
|
+ 0,
|
|
|
|
|
+ $fileCount,
|
|
|
|
|
+ $fileCount,
|
|
|
|
|
+ $centralSize,
|
|
|
|
|
+ $centralOffset,
|
|
|
|
|
+ 0,
|
|
|
|
|
+ ),
|
|
|
|
|
+ );
|
|
|
|
|
+ } catch (Throwable $exception) {
|
|
|
|
|
+ fclose($handle);
|
|
|
|
|
+ @unlink($targetFile);
|
|
|
|
|
+ throw $exception;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ fclose($handle);
|
|
|
|
|
+ @chmod($targetFile, 0660);
|
|
|
|
|
+
|
|
|
|
|
+ return [
|
|
|
|
|
+ "file_count" => $fileCount,
|
|
|
|
|
+ "source_bytes" => $sourceBytes,
|
|
|
|
|
+ "archive_bytes" => (int) (filesize($targetFile) ?: 0),
|
|
|
|
|
+ "sha256" => hash_file("sha256", $targetFile) ?: "",
|
|
|
|
|
+ ];
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function backupReadIndex(): array
|
|
|
|
|
+{
|
|
|
|
|
+ $index = readJsonFile(backupGetIndexFile());
|
|
|
|
|
+ $records =
|
|
|
|
|
+ isset($index["backups"]) && is_array($index["backups"])
|
|
|
|
|
+ ? $index["backups"]
|
|
|
|
|
+ : [];
|
|
|
|
|
+
|
|
|
|
|
+ return ["backups" => array_values($records)];
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function backupWriteIndex(array $records): bool
|
|
|
|
|
+{
|
|
|
|
|
+ return writeJsonFile(backupGetIndexFile(), [
|
|
|
|
|
+ "backups" => array_values($records),
|
|
|
|
|
+ ]);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function backupListBackups(): array
|
|
|
|
|
+{
|
|
|
|
|
+ $records = backupReadIndex()["backups"];
|
|
|
|
|
+ $dir = backupGetDirectory();
|
|
|
|
|
+ $existing = [];
|
|
|
|
|
+
|
|
|
|
|
+ foreach ($records as $record) {
|
|
|
|
|
+ if (!is_array($record)) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $filename = basename((string) ($record["filename"] ?? ""));
|
|
|
|
|
+ if ($filename === "" || !is_file($dir . $filename)) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $record["filename"] = $filename;
|
|
|
|
|
+ $record["size"] = (int) (filesize($dir . $filename) ?: ($record["size"] ?? 0));
|
|
|
|
|
+ $existing[] = $record;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ usort($existing, function ($left, $right) {
|
|
|
|
|
+ return strcmp((string) ($right["created_at"] ?? ""), (string) ($left["created_at"] ?? ""));
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ return $existing;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function backupFormatBytes(int $bytes): string
|
|
|
|
|
+{
|
|
|
|
|
+ if ($bytes >= 1073741824) {
|
|
|
|
|
+ return number_format($bytes / 1073741824, 2, ",", ".") . " GB";
|
|
|
|
|
+ }
|
|
|
|
|
+ if ($bytes >= 1048576) {
|
|
|
|
|
+ return number_format($bytes / 1048576, 2, ",", ".") . " MB";
|
|
|
|
|
+ }
|
|
|
|
|
+ if ($bytes >= 1024) {
|
|
|
|
|
+ return number_format($bytes / 1024, 1, ",", ".") . " KB";
|
|
|
|
|
+ }
|
|
|
|
|
+ return $bytes . " B";
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function backupGetRetentionLimit(): int
|
|
|
|
|
+{
|
|
|
|
|
+ return max(1, (int) BACKUP_LOCAL_RETENTION);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function backupApplyRetention(): void
|
|
|
|
|
+{
|
|
|
|
|
+ $records = backupListBackups();
|
|
|
|
|
+ $keep = backupGetRetentionLimit();
|
|
|
|
|
+ $dir = backupGetDirectory();
|
|
|
|
|
+
|
|
|
|
|
+ foreach (array_slice($records, $keep) as $record) {
|
|
|
|
|
+ $filename = basename((string) ($record["filename"] ?? ""));
|
|
|
|
|
+ if ($filename !== "" && is_file($dir . $filename)) {
|
|
|
|
|
+ @unlink($dir . $filename);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ backupWriteIndex(array_slice(backupListBackups(), 0, $keep));
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function backupGetRemoteTargets(): array
|
|
|
|
|
+{
|
|
|
|
|
+ return is_array(BACKUP_REMOTE_TARGETS) ? BACKUP_REMOTE_TARGETS : [];
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function backupGetTargetLabel(array $target, int $index): string
|
|
|
|
|
+{
|
|
|
|
|
+ $name = trim((string) ($target["name"] ?? ""));
|
|
|
|
|
+ if ($name !== "") {
|
|
|
|
|
+ return $name;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $type = trim((string) ($target["type"] ?? "target"));
|
|
|
|
|
+ return $type . "-" . ($index + 1);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function backupRemoteCapabilities(): array
|
|
|
|
|
+{
|
|
|
|
|
+ $targets = backupGetRemoteTargets();
|
|
|
|
|
+ $types = [];
|
|
|
|
|
+ foreach ($targets as $target) {
|
|
|
|
|
+ if (is_array($target)) {
|
|
|
|
|
+ $type = trim((string) ($target["type"] ?? ""));
|
|
|
|
|
+ if ($type !== "") {
|
|
|
|
|
+ $types[$type] = true;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return [
|
|
|
|
|
+ "s3" => [
|
|
|
|
|
+ "configured" => !empty($types["s3"]),
|
|
|
|
|
+ "available" => function_exists("hash_hmac"),
|
|
|
|
|
+ ],
|
|
|
|
|
+ "sftp" => [
|
|
|
|
|
+ "configured" => !empty($types["sftp"]),
|
|
|
|
|
+ "available" =>
|
|
|
|
|
+ function_exists("ssh2_connect") &&
|
|
|
|
|
+ function_exists("ssh2_sftp"),
|
|
|
|
|
+ ],
|
|
|
|
|
+ "custom" => [
|
|
|
|
|
+ "configured" => !empty($types["custom"]),
|
|
|
|
|
+ "available" => true,
|
|
|
|
|
+ ],
|
|
|
|
|
+ ];
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function backupUploadToS3(string $archivePath, array $metadata, array $target): array
|
|
|
|
|
+{
|
|
|
|
|
+ $bucket = trim((string) ($target["bucket"] ?? ""));
|
|
|
|
|
+ $region = trim((string) ($target["region"] ?? ""));
|
|
|
|
|
+ $accessKey = trim((string) ($target["access_key"] ?? ""));
|
|
|
|
|
+ $secretKey = (string) ($target["secret_key"] ?? "");
|
|
|
|
|
+ $prefix = trim((string) ($target["prefix"] ?? ""), "/");
|
|
|
|
|
+ $endpoint = rtrim(trim((string) ($target["endpoint"] ?? "")), "/");
|
|
|
|
|
+
|
|
|
|
|
+ if ($bucket === "" || $region === "" || $accessKey === "" || $secretKey === "") {
|
|
|
|
|
+ throw new RuntimeException("S3-Ziel ist unvollständig konfiguriert.");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $filename = basename($archivePath);
|
|
|
|
|
+ $key = ($prefix !== "" ? $prefix . "/" : "") . $filename;
|
|
|
|
|
+ $host = $endpoint !== ""
|
|
|
|
|
+ ? parse_url($endpoint, PHP_URL_HOST)
|
|
|
|
|
+ : $bucket . ".s3." . $region . ".amazonaws.com";
|
|
|
|
|
+ if (!is_string($host) || $host === "") {
|
|
|
|
|
+ throw new RuntimeException("S3-Endpunkt ist ungültig.");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $url = $endpoint !== ""
|
|
|
|
|
+ ? $endpoint . "/" . rawurlencode($bucket) . "/" . str_replace("%2F", "/", rawurlencode($key))
|
|
|
|
|
+ : "https://" . $host . "/" . str_replace("%2F", "/", rawurlencode($key));
|
|
|
|
|
+
|
|
|
|
|
+ $payload = file_get_contents($archivePath);
|
|
|
|
|
+ if ($payload === false) {
|
|
|
|
|
+ throw new RuntimeException("Backup-ZIP konnte für S3 nicht gelesen werden.");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $now = gmdate("Ymd\THis\Z");
|
|
|
|
|
+ $date = substr($now, 0, 8);
|
|
|
|
|
+ $payloadHash = hash("sha256", $payload);
|
|
|
|
|
+ $canonicalUri = parse_url($url, PHP_URL_PATH);
|
|
|
|
|
+ $canonicalUri = is_string($canonicalUri) && $canonicalUri !== "" ? $canonicalUri : "/";
|
|
|
|
|
+ $signedHeaders = "content-type;host;x-amz-content-sha256;x-amz-date";
|
|
|
|
|
+ $canonicalHeaders =
|
|
|
|
|
+ "content-type:application/zip\n" .
|
|
|
|
|
+ "host:" . $host . "\n" .
|
|
|
|
|
+ "x-amz-content-sha256:" . $payloadHash . "\n" .
|
|
|
|
|
+ "x-amz-date:" . $now . "\n";
|
|
|
|
|
+ $canonicalRequest =
|
|
|
|
|
+ "PUT\n" .
|
|
|
|
|
+ $canonicalUri .
|
|
|
|
|
+ "\n\n" .
|
|
|
|
|
+ $canonicalHeaders .
|
|
|
|
|
+ "\n" .
|
|
|
|
|
+ $signedHeaders .
|
|
|
|
|
+ "\n" .
|
|
|
|
|
+ $payloadHash;
|
|
|
|
|
+ $scope = $date . "/" . $region . "/s3/aws4_request";
|
|
|
|
|
+ $stringToSign =
|
|
|
|
|
+ "AWS4-HMAC-SHA256\n" .
|
|
|
|
|
+ $now .
|
|
|
|
|
+ "\n" .
|
|
|
|
|
+ $scope .
|
|
|
|
|
+ "\n" .
|
|
|
|
|
+ hash("sha256", $canonicalRequest);
|
|
|
|
|
+ $kDate = hash_hmac("sha256", $date, "AWS4" . $secretKey, true);
|
|
|
|
|
+ $kRegion = hash_hmac("sha256", $region, $kDate, true);
|
|
|
|
|
+ $kService = hash_hmac("sha256", "s3", $kRegion, true);
|
|
|
|
|
+ $kSigning = hash_hmac("sha256", "aws4_request", $kService, true);
|
|
|
|
|
+ $signature = hash_hmac("sha256", $stringToSign, $kSigning);
|
|
|
|
|
+ $authorization =
|
|
|
|
|
+ "AWS4-HMAC-SHA256 Credential=" .
|
|
|
|
|
+ $accessKey .
|
|
|
|
|
+ "/" .
|
|
|
|
|
+ $scope .
|
|
|
|
|
+ ", SignedHeaders=" .
|
|
|
|
|
+ $signedHeaders .
|
|
|
|
|
+ ", Signature=" .
|
|
|
|
|
+ $signature;
|
|
|
|
|
+
|
|
|
|
|
+ $context = stream_context_create([
|
|
|
|
|
+ "http" => [
|
|
|
|
|
+ "method" => "PUT",
|
|
|
|
|
+ "timeout" => (int) ($target["timeout"] ?? 120),
|
|
|
|
|
+ "ignore_errors" => false,
|
|
|
|
|
+ "header" =>
|
|
|
|
|
+ "Content-Type: application/zip\r\n" .
|
|
|
|
|
+ "Content-Length: " . strlen($payload) . "\r\n" .
|
|
|
|
|
+ "Host: " . $host . "\r\n" .
|
|
|
|
|
+ "X-Amz-Date: " . $now . "\r\n" .
|
|
|
|
|
+ "X-Amz-Content-Sha256: " . $payloadHash . "\r\n" .
|
|
|
|
|
+ "Authorization: " . $authorization . "\r\n",
|
|
|
|
|
+ "content" => $payload,
|
|
|
|
|
+ ],
|
|
|
|
|
+ ]);
|
|
|
|
|
+
|
|
|
|
|
+ $response = @file_get_contents($url, false, $context);
|
|
|
|
|
+ $status = 0;
|
|
|
|
|
+ if (function_exists("http_get_last_response_headers")) {
|
|
|
|
|
+ $headers = http_get_last_response_headers();
|
|
|
|
|
+ foreach (is_array($headers) ? $headers : [] as $header) {
|
|
|
|
|
+ if (preg_match('/^HTTP\/\S+\s+(\d+)/', $header, $matches) === 1) {
|
|
|
|
|
+ $status = (int) $matches[1];
|
|
|
|
|
+ break;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if ($response === false || $status < 200 || $status >= 300) {
|
|
|
|
|
+ throw new RuntimeException(
|
|
|
|
|
+ "S3-Upload fehlgeschlagen" . ($status > 0 ? " (HTTP " . $status . ")" : "") . ".",
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return ["remote_path" => "s3://" . $bucket . "/" . $key];
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function backupUploadToSftp(string $archivePath, array $metadata, array $target): array
|
|
|
|
|
+{
|
|
|
|
|
+ if (!function_exists("ssh2_connect") || !function_exists("ssh2_sftp")) {
|
|
|
|
|
+ throw new RuntimeException("PHP-SSH2-Erweiterung ist nicht verfügbar.");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $host = trim((string) ($target["host"] ?? ""));
|
|
|
|
|
+ $username = trim((string) ($target["username"] ?? ""));
|
|
|
|
|
+ $password = (string) ($target["password"] ?? "");
|
|
|
|
|
+ $remoteDir = rtrim((string) ($target["path"] ?? ""), "/");
|
|
|
|
|
+ $port = (int) ($target["port"] ?? 22);
|
|
|
|
|
+
|
|
|
|
|
+ if ($host === "" || $username === "" || $remoteDir === "") {
|
|
|
|
|
+ throw new RuntimeException("SFTP-Ziel ist unvollständig konfiguriert.");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $connection = @ssh2_connect($host, $port > 0 ? $port : 22);
|
|
|
|
|
+ if ($connection === false) {
|
|
|
|
|
+ throw new RuntimeException("SFTP-Verbindung konnte nicht hergestellt werden.");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $authenticated = false;
|
|
|
|
|
+ $privateKey = trim((string) ($target["private_key"] ?? ""));
|
|
|
|
|
+ $publicKey = trim((string) ($target["public_key"] ?? ""));
|
|
|
|
|
+ if (
|
|
|
|
|
+ $privateKey !== "" &&
|
|
|
|
|
+ $publicKey !== "" &&
|
|
|
|
|
+ function_exists("ssh2_auth_pubkey_file")
|
|
|
|
|
+ ) {
|
|
|
|
|
+ $authenticated = @ssh2_auth_pubkey_file(
|
|
|
|
|
+ $connection,
|
|
|
|
|
+ $username,
|
|
|
|
|
+ $publicKey,
|
|
|
|
|
+ $privateKey,
|
|
|
|
|
+ $password !== "" ? $password : null,
|
|
|
|
|
+ );
|
|
|
|
|
+ } elseif (function_exists("ssh2_auth_password")) {
|
|
|
|
|
+ $authenticated = @ssh2_auth_password($connection, $username, $password);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (!$authenticated) {
|
|
|
|
|
+ throw new RuntimeException("SFTP-Anmeldung fehlgeschlagen.");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $sftp = @ssh2_sftp($connection);
|
|
|
|
|
+ if ($sftp === false) {
|
|
|
|
|
+ throw new RuntimeException("SFTP-Subsystem konnte nicht gestartet werden.");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $remotePath = $remoteDir . "/" . basename($archivePath);
|
|
|
|
|
+ $targetStream = @fopen("ssh2.sftp://" . intval($sftp) . $remotePath, "wb");
|
|
|
|
|
+ if ($targetStream === false) {
|
|
|
|
|
+ throw new RuntimeException("SFTP-Zieldatei konnte nicht geöffnet werden.");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $source = fopen($archivePath, "rb");
|
|
|
|
|
+ if ($source === false) {
|
|
|
|
|
+ fclose($targetStream);
|
|
|
|
|
+ throw new RuntimeException("Backup-ZIP konnte für SFTP nicht gelesen werden.");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $copied = stream_copy_to_stream($source, $targetStream);
|
|
|
|
|
+ fclose($source);
|
|
|
|
|
+ fclose($targetStream);
|
|
|
|
|
+
|
|
|
|
|
+ if ($copied === false) {
|
|
|
|
|
+ throw new RuntimeException("SFTP-Upload fehlgeschlagen.");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return ["remote_path" => "sftp://" . $host . $remotePath];
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function backupUploadToCustom(string $archivePath, array $metadata, array $target): array
|
|
|
|
|
+{
|
|
|
|
|
+ $file = trim((string) ($target["file"] ?? ""));
|
|
|
|
|
+ $callback = $target["callback"] ?? null;
|
|
|
|
|
+
|
|
|
|
|
+ if ($file !== "") {
|
|
|
|
|
+ if (!is_file($file)) {
|
|
|
|
|
+ throw new RuntimeException("Custom-Uploader-Datei wurde nicht gefunden.");
|
|
|
|
|
+ }
|
|
|
|
|
+ require_once $file;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (!is_callable($callback)) {
|
|
|
|
|
+ throw new RuntimeException("Custom-Uploader ist nicht aufrufbar.");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $result = call_user_func($callback, $archivePath, $metadata, $target);
|
|
|
|
|
+ if ($result === true) {
|
|
|
|
|
+ return [];
|
|
|
|
|
+ }
|
|
|
|
|
+ if (is_array($result)) {
|
|
|
|
|
+ return $result;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ throw new RuntimeException("Custom-Uploader meldet einen Fehler.");
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function backupUploadRemotes(string $archivePath, array $metadata): array
|
|
|
|
|
+{
|
|
|
|
|
+ $results = [];
|
|
|
|
|
+
|
|
|
|
|
+ foreach (backupGetRemoteTargets() as $index => $target) {
|
|
|
|
|
+ if (!is_array($target)) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $type = trim((string) ($target["type"] ?? ""));
|
|
|
|
|
+ $label = backupGetTargetLabel($target, (int) $index);
|
|
|
|
|
+ $startedAt = date("c");
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ if ($type === "s3") {
|
|
|
|
|
+ $extra = backupUploadToS3($archivePath, $metadata, $target);
|
|
|
|
|
+ } elseif ($type === "sftp") {
|
|
|
|
|
+ $extra = backupUploadToSftp($archivePath, $metadata, $target);
|
|
|
|
|
+ } elseif ($type === "custom") {
|
|
|
|
|
+ $extra = backupUploadToCustom($archivePath, $metadata, $target);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ throw new RuntimeException("Unbekannter Backup-Zieltyp: " . $type);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $results[] = array_merge(
|
|
|
|
|
+ [
|
|
|
|
|
+ "target" => $label,
|
|
|
|
|
+ "type" => $type,
|
|
|
|
|
+ "success" => true,
|
|
|
|
|
+ "uploaded_at" => date("c"),
|
|
|
|
|
+ "started_at" => $startedAt,
|
|
|
|
|
+ ],
|
|
|
|
|
+ is_array($extra) ? $extra : [],
|
|
|
|
|
+ );
|
|
|
|
|
+ } catch (Throwable $exception) {
|
|
|
|
|
+ $results[] = [
|
|
|
|
|
+ "target" => $label,
|
|
|
|
|
+ "type" => $type !== "" ? $type : "unknown",
|
|
|
|
|
+ "success" => false,
|
|
|
|
|
+ "started_at" => $startedAt,
|
|
|
|
|
+ "error" => $exception->getMessage(),
|
|
|
|
|
+ ];
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return $results;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function backupGetLastAutomaticAt(): int
|
|
|
|
|
+{
|
|
|
|
|
+ foreach (backupListBackups() as $record) {
|
|
|
|
|
+ if (($record["trigger"] ?? "") !== "automatic") {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $timestamp = strtotime((string) ($record["created_at"] ?? ""));
|
|
|
|
|
+ if ($timestamp !== false) {
|
|
|
|
|
+ return $timestamp;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return 0;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function backupIsAutomaticDue(): bool
|
|
|
|
|
+{
|
|
|
|
|
+ $interval = (int) BACKUP_AUTO_INTERVAL_SECONDS;
|
|
|
|
|
+ if ($interval < 1) {
|
|
|
|
|
+ return false;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return time() - backupGetLastAutomaticAt() >= $interval;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function backupCreate(string $trigger = "manual"): array
|
|
|
|
|
+{
|
|
|
|
|
+ $trigger = $trigger === "automatic" ? "automatic" : "manual";
|
|
|
|
|
+ $dir = backupGetDirectory();
|
|
|
|
|
+ backupEnsureDirectory($dir);
|
|
|
|
|
+
|
|
|
|
|
+ $lockHandle = fopen(backupGetLockFile(), "c+");
|
|
|
|
|
+ if ($lockHandle === false) {
|
|
|
|
|
+ throw new RuntimeException("Backup-Sperrdatei konnte nicht geöffnet werden.");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (!flock($lockHandle, LOCK_EX | LOCK_NB)) {
|
|
|
|
|
+ fclose($lockHandle);
|
|
|
|
|
+ throw new RuntimeException("Es läuft bereits ein Backup.");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ $baseName = "backup-" . date("Ymd-His");
|
|
|
|
|
+ $filename = $baseName . ".zip";
|
|
|
|
|
+ $counter = 2;
|
|
|
|
|
+ while (file_exists($dir . $filename)) {
|
|
|
|
|
+ $filename = $baseName . "-" . $counter . ".zip";
|
|
|
|
|
+ $counter++;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $tmpFile = $dir . "." . $filename . ".tmp";
|
|
|
|
|
+ $archivePath = $dir . $filename;
|
|
|
|
|
+ $createdAt = date("c");
|
|
|
|
|
+ $zipStats = backupWriteZip($tmpFile, backupGetSourceFiles());
|
|
|
|
|
+
|
|
|
|
|
+ if (!rename($tmpFile, $archivePath)) {
|
|
|
|
|
+ @unlink($tmpFile);
|
|
|
|
|
+ throw new RuntimeException("Backup-ZIP konnte nicht finalisiert werden.");
|
|
|
|
|
+ }
|
|
|
|
|
+ @chmod($archivePath, 0660);
|
|
|
|
|
+
|
|
|
|
|
+ $record = [
|
|
|
|
|
+ "filename" => $filename,
|
|
|
|
|
+ "created_at" => $createdAt,
|
|
|
|
|
+ "trigger" => $trigger,
|
|
|
|
|
+ "size" => (int) (filesize($archivePath) ?: $zipStats["archive_bytes"]),
|
|
|
|
|
+ "file_count" => $zipStats["file_count"],
|
|
|
|
|
+ "source_bytes" => $zipStats["source_bytes"],
|
|
|
|
|
+ "sha256" => $zipStats["sha256"],
|
|
|
|
|
+ "remote_uploads" => backupUploadRemotes($archivePath, [
|
|
|
|
|
+ "filename" => $filename,
|
|
|
|
|
+ "created_at" => $createdAt,
|
|
|
|
|
+ "trigger" => $trigger,
|
|
|
|
|
+ "sha256" => $zipStats["sha256"],
|
|
|
|
|
+ ]),
|
|
|
|
|
+ ];
|
|
|
|
|
+
|
|
|
|
|
+ $records = backupListBackups();
|
|
|
|
|
+ array_unshift($records, $record);
|
|
|
|
|
+ backupWriteIndex($records);
|
|
|
|
|
+ backupApplyRetention();
|
|
|
|
|
+
|
|
|
|
|
+ logAccess("Backup created", [
|
|
|
|
|
+ "filename" => $filename,
|
|
|
|
|
+ "trigger" => $trigger,
|
|
|
|
|
+ "file_count" => $record["file_count"],
|
|
|
|
|
+ ]);
|
|
|
|
|
+
|
|
|
|
|
+ return $record;
|
|
|
|
|
+ } catch (Throwable $exception) {
|
|
|
|
|
+ logError("Backup failed", [
|
|
|
|
|
+ "trigger" => $trigger,
|
|
|
|
|
+ "error" => $exception->getMessage(),
|
|
|
|
|
+ ]);
|
|
|
|
|
+ throw $exception;
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ flock($lockHandle, LOCK_UN);
|
|
|
|
|
+ fclose($lockHandle);
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function backupCreateAutomaticIfDue(): ?array
|
|
|
|
|
+{
|
|
|
|
|
+ if (!backupIsAutomaticDue()) {
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return backupCreate("automatic");
|
|
|
|
|
+}
|