Bladeren bron

adding backup functionality

Josef Straßl 1 maand geleden
bovenliggende
commit
82372334e3
7 gewijzigde bestanden met toevoegingen van 1014 en 2 verwijderingen
  1. 2 0
      .gitignore
  2. 2 1
      README.md
  3. 141 0
      admin/settings.php
  4. 33 0
      config.sample.php
  5. 12 0
      docs/CONFIG_REFERENCE.md
  6. 823 0
      includes/backup.php
  7. 1 1
      includes/version.php

+ 2 - 0
.gitignore

@@ -3,4 +3,6 @@ config.php
 data/reservations.php
 data/logs/
 data/updates/
+data/backups/
 data/orders.json
+build/

+ 2 - 1
README.md

@@ -29,6 +29,7 @@ Dieses Projekt ist ein internes Bestellsystem für persönliche Schutzausrüstun
 - Systemeinstellungen: `data/settings.json`
 - Produkte: `data/products.json`
 - Produktbilder: `data/uploads/`
+- Backups: `data/backups/` (wird automatisch erstellt, nicht öffentlich)
 
 ## Einrichtung
 
@@ -36,7 +37,7 @@ Dieses Projekt ist ein internes Bestellsystem für persönliche Schutzausrüstun
 2. Schreibrechte auf `data/` und `data/ratelimit/` (für Rate-Limits) sicherstellen.
 3. Statische Dateien bereitstellen: `favicon.png` (Document Root), `assets/branding/`, `assets/fonts/`, `assets/no-image.jpg`.
 4. Adminzugänge in `data/admins.json` auf dem Server pflegen (nicht aus dem Repo übernehmen).
-5. Empfängeradresse und PDF-Anhang im Admin unter `Einstellungen` prüfen.
+5. Empfängeradresse, PDF-Anhang und Backups im Admin unter `Einstellungen` prüfen.
 6. Organisationen im Admin unter `Organisationen verwalten` pflegen.
 7. Apache: `.htaccess` aktiv (schützt `config.php` und JSON unter `data/`).
 

+ 141 - 0
admin/settings.php

@@ -2,6 +2,7 @@
 require_once __DIR__ . "/../config.php";
 require_once __DIR__ . "/../includes/functions.php";
 require_once __DIR__ . "/../includes/version.php";
+require_once __DIR__ . "/../includes/backup.php";
 
 if (!defined("UPDATE_MANIFEST_URL")) {
     define("UPDATE_MANIFEST_URL", "");
@@ -75,6 +76,53 @@ function settingsGetUpdaterStatus(): array
     ];
 }
 
+function settingsFormatBackupDate(string $date): string
+{
+    $timestamp = strtotime($date);
+    if ($timestamp === false) {
+        return $date;
+    }
+
+    return date("d.m.Y H:i", $timestamp);
+}
+
+function settingsGetBackupUploadLabel(array $backup): string
+{
+    $uploads =
+        isset($backup["remote_uploads"]) && is_array($backup["remote_uploads"])
+            ? $backup["remote_uploads"]
+            : [];
+
+    if (empty($uploads)) {
+        return "Nur lokal";
+    }
+
+    $successful = 0;
+    foreach ($uploads as $upload) {
+        if (is_array($upload) && !empty($upload["success"])) {
+            $successful++;
+        }
+    }
+
+    if ($successful === count($uploads)) {
+        return "Remote erfolgreich (" . $successful . ")";
+    }
+    if ($successful > 0) {
+        return "Teilweise erfolgreich (" . $successful . "/" . count($uploads) . ")";
+    }
+
+    return "Remote fehlgeschlagen";
+}
+
+function settingsGetBackupCapabilityLabel(array $capability): string
+{
+    if (empty($capability["configured"])) {
+        return "nicht konfiguriert";
+    }
+
+    return !empty($capability["available"]) ? "bereit" : "nicht verfügbar";
+}
+
 if (empty($_SESSION['admin_logged_in'])) {
     header("Location: login.php");
     exit();
@@ -83,6 +131,8 @@ if (empty($_SESSION['admin_logged_in'])) {
 $pageTitle = "Einstellungen";
 $message = "";
 $messageType = "";
+$backupAutoMessage = "";
+$backupAutoMessageType = "";
 
 if ($_SERVER['REQUEST_METHOD'] === "POST" && isset($_POST['save_settings'])) {
     // Validate CSRF token
@@ -106,10 +156,47 @@ if ($_SERVER['REQUEST_METHOD'] === "POST" && isset($_POST['save_settings'])) {
             $messageType = "error";
         }
     }
+} elseif ($_SERVER['REQUEST_METHOD'] === "POST" && isset($_POST['create_backup'])) {
+    if (!validateCsrfToken($_POST['csrf_token'] ?? "")) {
+        $message = "Ungültiges Token. Bitte versuchen Sie es erneut.";
+        $messageType = "error";
+    } else {
+        try {
+            $backup = backupCreate("manual");
+            $message =
+                "Backup wurde erstellt: " .
+                $backup["filename"] .
+                " (" .
+                backupFormatBytes((int) $backup["size"]) .
+                ").";
+            $messageType = "success";
+        } catch (Throwable $exception) {
+            $message = "Backup konnte nicht erstellt werden: " . $exception->getMessage();
+            $messageType = "error";
+        }
+    }
+} elseif ($_SERVER['REQUEST_METHOD'] === "GET") {
+    try {
+        $backup = backupCreateAutomaticIfDue();
+        if ($backup !== null) {
+            $backupAutoMessage =
+                "Automatisches Backup wurde erstellt: " .
+                $backup["filename"] .
+                ".";
+            $backupAutoMessageType = "success";
+        }
+    } catch (Throwable $exception) {
+        $backupAutoMessage =
+            "Automatisches Backup konnte nicht erstellt werden: " .
+            $exception->getMessage();
+        $backupAutoMessageType = "warning";
+    }
 }
 
 $settings = getSystemSettings();
 $updaterStatus = settingsGetUpdaterStatus();
+$backupCapabilities = backupRemoteCapabilities();
+$backups = backupListBackups();
 
 $bodyClass = "admin-page";
 include __DIR__ . "/../includes/header.php";
@@ -128,6 +215,12 @@ include __DIR__ . "/../includes/header.php";
     </div>
 <?php endif; ?>
 
+<?php if ($backupAutoMessage !== ""): ?>
+    <div class="alert alert-<?php echo escape($backupAutoMessageType); ?>">
+        <?php echo escape($backupAutoMessage); ?>
+    </div>
+<?php endif; ?>
+
 <div class="panel panel-lg">
     <form method="POST">
         <?php echo csrfField(); ?>
@@ -153,6 +246,54 @@ include __DIR__ . "/../includes/header.php";
     </form>
 </div>
 
+<div class="panel panel-lg mt-4">
+    <h3>Backups</h3>
+    <p>Lokale Aufbewahrung: <?php echo (int) backupGetRetentionLimit(); ?> Backups</p>
+    <p>Automatisches Intervall: <?php echo (int) floor(((int) BACKUP_AUTO_INTERVAL_SECONDS) / 86400); ?> Tage</p>
+    <p>
+        S3: <?php echo escape(settingsGetBackupCapabilityLabel($backupCapabilities["s3"])); ?> ·
+        SFTP: <?php echo escape(settingsGetBackupCapabilityLabel($backupCapabilities["sftp"])); ?> ·
+        Custom: <?php echo escape(settingsGetBackupCapabilityLabel($backupCapabilities["custom"])); ?>
+    </p>
+
+    <form method="POST" class="inline-form">
+        <?php echo csrfField(); ?>
+        <button type="submit" name="create_backup" class="btn">Backup erstellen</button>
+    </form>
+
+    <h4 class="mt-4">Letzte Backups</h4>
+    <?php if (empty($backups)): ?>
+        <p>Es wurden noch keine Backups erstellt.</p>
+    <?php else: ?>
+        <div class="table-responsive">
+            <table class="responsive-table">
+                <thead>
+                    <tr>
+                        <th>Datei</th>
+                        <th>Erstellt</th>
+                        <th>Auslöser</th>
+                        <th>Größe</th>
+                        <th>Dateien</th>
+                        <th>Remote</th>
+                    </tr>
+                </thead>
+                <tbody>
+                    <?php foreach ($backups as $backup): ?>
+                        <tr>
+                            <td data-label="Datei"><?php echo escape($backup["filename"] ?? ""); ?></td>
+                            <td data-label="Erstellt"><?php echo escape(settingsFormatBackupDate((string) ($backup["created_at"] ?? ""))); ?></td>
+                            <td data-label="Auslöser"><?php echo (($backup["trigger"] ?? "") === "automatic") ? "Automatisch" : "Manuell"; ?></td>
+                            <td data-label="Größe"><?php echo escape(backupFormatBytes((int) ($backup["size"] ?? 0))); ?></td>
+                            <td data-label="Dateien"><?php echo (int) ($backup["file_count"] ?? 0); ?></td>
+                            <td data-label="Remote"><?php echo escape(settingsGetBackupUploadLabel($backup)); ?></td>
+                        </tr>
+                    <?php endforeach; ?>
+                </tbody>
+            </table>
+        </div>
+    <?php endif; ?>
+</div>
+
 <div class="panel panel-lg mt-4">
     <h3>Updater</h3>
     <p>Installierte Version: <?php echo escape(APP_VERSION); ?></p>

+ 33 - 0
config.sample.php

@@ -61,6 +61,39 @@ define('UPDATE_MANIFEST_URL', 'https://dev.med0.de/psa/updater/manifest.php');
 define('UPDATE_WORK_DIR', DATA_DIR . 'updates/work/');
 define('UPDATE_BACKUP_DIR', DATA_DIR . 'updates/backups/');
 
+// Data backup settings
+define('BACKUP_DIR', DATA_DIR . 'backups/');
+define('BACKUP_LOCAL_RETENTION', 4);
+define('BACKUP_AUTO_INTERVAL_SECONDS', 604800);
+define('BACKUP_REMOTE_TARGETS', [
+    // [
+    //     'name' => 'S3 Backup',
+    //     'type' => 's3',
+    //     'bucket' => 'example-bucket',
+    //     'region' => 'eu-central-1',
+    //     'prefix' => 'psa-orderform',
+    //     'access_key' => 'AKIA...',
+    //     'secret_key' => '...',
+    //     // Optional for S3-compatible storage:
+    //     // 'endpoint' => 'https://s3.example.org',
+    // ],
+    // [
+    //     'name' => 'SFTP Backup',
+    //     'type' => 'sftp',
+    //     'host' => 'backup.example.org',
+    //     'port' => 22,
+    //     'username' => 'backup-user',
+    //     'password' => '...',
+    //     'path' => '/backups/psa-orderform',
+    // ],
+    // [
+    //     'name' => 'Custom Backup',
+    //     'type' => 'custom',
+    //     'file' => __DIR__ . '/custom-backup-uploader.php',
+    //     'callback' => 'uploadPsaOrderformBackup',
+    // ],
+]);
+
 // Session settings
 if (session_status() === PHP_SESSION_NONE) {
     $isHttps =

+ 12 - 0
docs/CONFIG_REFERENCE.md

@@ -38,6 +38,10 @@
 | `CATEGORIES_FILE` | JSON-Datei für Kategorien |
 | `FAQ_FILE` | JSON-Datei für FAQ-Inhalte |
 | `MANUAL_BACKORDERS_FILE` | JSON-Datei für manuelle Nachbestell-Einträge (ohne Bestellbezug) |
+| `BACKUP_DIR` | Lokales Verzeichnis für Daten-Backups (Standard: `DATA_DIR . 'backups/'`) |
+| `BACKUP_LOCAL_RETENTION` | Anzahl lokal aufzubewahrender Backup-ZIPs (Standard: 4) |
+| `BACKUP_AUTO_INTERVAL_SECONDS` | Intervall für Backups durch Admin-Aktivität (Standard: 604800 = wöchentlich; 0 deaktiviert) |
+| `BACKUP_REMOTE_TARGETS` | Optionale Remote-Ziele für Backup-Uploads (`s3`, `sftp`, `custom`) |
 
 ## Runtime (`data/settings.json`)
 
@@ -54,6 +58,14 @@ Der Startseiten-Introtext wird unter **FAQ** gepflegt (`startpage_intro_text` in
 - Wenn das Verzeichnis nicht beschreibbar ist, gelten Limits als **nicht aktiv** (Anfragen werden zugelassen — Verfügbarkeit auf Shared Hosting).
 - Zugriffs- und Fehlerprotokolle: `data/logs/` (siehe `logAccess` / `logError` in `includes/functions.php`).
 
+## Backups
+
+- Manuelle Backups werden im Admin unter **Einstellungen** erstellt.
+- Automatische Backups werden nur durch Admin-Aktivität auf der Einstellungsseite ausgelöst, wenn das konfigurierte Intervall abgelaufen ist.
+- Backups enthalten `data/*.json` und `data/uploads/**`; App-Dateien, Logs, Updates, Rate-Limits und bestehende Backups werden ausgeschlossen.
+- SFTP benötigt die optionale PHP-SSH2-Erweiterung. Ohne Erweiterung bleibt das lokale Backup gültig, der Remote-Upload wird als fehlgeschlagen protokolliert.
+- Zugangsdaten für Remote-Ziele gehören in `config.php`, nicht in `data/settings.json`.
+
 ## Hinweis
 
 Die Konstanten definieren die Startwerte. Änderbare Betriebsparameter wie interne Empfängeradresse können zusätzlich im Adminbereich unter `Einstellungen` angepasst werden.

+ 823 - 0
includes/backup.php

@@ -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");
+}

+ 1 - 1
includes/version.php

@@ -1,3 +1,3 @@
 <?php
 
-define("APP_VERSION", "v1.3.2");
+define("APP_VERSION", "v1.3.3");