# Backup, Restore & CMS-Update – Technische Dokumentation

## Übersicht

Das ITSWEBER CMS bietet ab Phase 6e drei eng verwandte Administrationsfunktionen:

1. **Site-Backup** – Vollständiges ZIP-Archiv aus Datenbank-Dump + Uploads
2. **Restore** – Wiederherstellung einer Installation aus einem Backup-ZIP
3. **CMS-Update** – Einspielen eines ITSWEBER-Update-Pakets mit optionalen Migrationen

Alle drei Operationen sind über den Admin-Bereich unter **Admin → Backup & Update** erreichbar und werden technisch durch `app/Services/BackupService.php` und `app/Http/Controllers/Admin/BackupController.php` implementiert.

---

## 1. Backup-Format

### Dateiname
```
backup_{site_name}_{YYYY-MM-DD_HH-MM-SS}.zip
```

### ZIP-Struktur
```
backup_*.zip
├── manifest.json          ← Backup-Metadaten
├── database.sql           ← SQL-Dump aller iw_*-Tabellen
└── uploads/               ← Alle hochgeladenen Dateien (Kopie von public/uploads/)
    ├── blog/
    ├── brand/
    ├── gallery/
    └── ...
```

### manifest.json – Pflichtfelder
```json
{
  "type":          "iw_backup",
  "backup_format": "1",
  "created_at":    "2026-03-15T14:30:00+01:00",
  "cms_version":   "1.0.0",
  "site_name":     "Meine Website",
  "php_version":   "8.2.0"
}
```

**Pflichtfelder:** `type`, `backup_format`, `cms_version`, `created_at`, `site_name`

### SQL-Dump-Format
- Enthält alle Tabellen mit Prefix `iw_`
- Pro Tabelle: `DROP TABLE IF EXISTS` + `CREATE TABLE` (aus `SHOW CREATE TABLE`)
- Daten als batched `INSERT INTO … VALUES (…), (…)` (100 Zeilen pro Statement)
- NULL-Werte werden korrekt als `NULL` ausgegeben
- String-Werte werden mit `PDO::quote()` escaped
- Erzeugt via `BackupService::generateSqlDump()` – kein `mysqldump` erforderlich

---

## 2. Backup erstellen

**Ablauf:**
1. Admin klickt „Backup erstellen" in `POST /admin/backup/create`
2. `BackupController::createBackup()` ruft `BackupService::createBackup($db, $basePath)` auf
3. BackupService legt ZIP in `storage/temp/backup_*.zip` an
4. `BackupService::streamDownload()` streamt das ZIP als Download an den Browser
5. Temp-Datei wird nach erfolgreichem Stream gelöscht

**Anforderungen:**
- PHP-Extension `zip` (`ZipArchive`) muss verfügbar sein
- `storage/temp/` muss schreibbar sein

---

## 3. Backup-Validierung (Phase 6e Hardening)

`BackupService::validateBackup($zipPath)` prüft in dieser Reihenfolge:

| Prüfung | Fehlertyp |
|---|---|
| PHP `zip`-Extension vorhanden | Hart |
| Datei existiert | Hart |
| Dateigröße ≤ MAX_ZIP_SIZE | Hart |
| ZIP öffenbar | Hart |
| `manifest.json` vorhanden | Hart |
| `manifest.json` ist valides JSON | Hart |
| `type === "iw_backup"` | Hart |
| `backup_format === "1"` (exakter Versionsvergleich) | Hart |
| Alle Pflichtfelder gesetzt und nicht leer | Hart |
| `database.sql` in ZIP vorhanden | Hart |

**Wichtig:** Format-Version und Typ werden exakt geprüft, nicht nur auf Vorhandensein. Unbekannte Format-Versionen werden hart abgelehnt, nicht toleriert.

---

## 4. Restore-Ablauf

### Schritt 1 – Upload & Validierung
- `POST /admin/backup/restore`
- Uploaded ZIP wird nach `storage/temp/` gespeichert
- `BackupService::validateBackup($zipPath)` prüft alle Pflichtfelder (siehe oben)
- Bei Fehler: Temp-Datei löschen + Fehlermeldung + Redirect
- Bei Erfolg: Temp-Pfad + Manifest in Session speichern → Bestätigungsseite rendern

### Schritt 2 – Bestätigungsseite
- Zeigt alle Manifest-Felder: erstellt am, Site-Name, CMS-Version, PHP-Version
- Zeigt Versionsvergleich (Backup vs. installiert) mit Warnung bei Abweichung
- Deutlicher Warnhinweis: alle aktuellen Daten werden überschrieben
- Zeigt, was NICHT überschrieben wird (app/Config/)
- Bestätigung via `POST /admin/backup/restore/execute` mit CSRF-Token

### Schritt 3 – Ausführung
- `BackupController::restoreExecute()` liest Temp-Pfad + Manifest aus Session
- Prüft auf valide Session-Daten (kein direkter Execute-Aufruf möglich)
- **Wartungsmodus wird aktiviert** (storage/.maintenance)
- `BackupService::restore($zipPath, $db, $basePath)`:
  1. `database.sql` aus ZIP importieren (alle `iw_*`-Tabellen gelöscht + neu importiert)
  2. Alle Einträge unter `uploads/` aus ZIP extrahieren → nach `public/uploads/` kopieren
  3. Pfad-Traversal-Schutz (siehe unten)
- **Wartungsmodus wird deaktiviert** (auch bei Fehler, via `finally`)
- Session-Keys löschen, Temp-Datei löschen
- Flash-Success + Redirect

### Restore-Pfad-Traversal-Schutz (Phase 6e Hardening)

Der Pfadschutz beim Restore ist jetzt vollständig realpath-basiert:

1. **Frühe Ablehnung** – Einträge mit `..` oder NUL-Bytes (`\0`) im Pfad werden sofort übersprungen, ohne weitere Verarbeitung
2. **Pfad-Normalisierung** – Backslashes werden zu Schrägstrichen normalisiert
3. **Verzeichnis anlegen** – Das Zielverzeichnis wird erstellt
4. **realpath-Prüfung** – Nach `mkdir()` wird `realpath($dir)` gegen `realpath($uploadsBase) . '/'` geprüft
5. **Schreiben** – Nur wenn `realPath` eindeutig innerhalb von `public/uploads/` liegt

Der Unterschied zur alten Implementierung: `realpath()` wurde früher zwar berechnet, aber nie tatsächlich im Vergleich verwendet. Jetzt ist die Prüfung zwingend und verhindert auch Symlink-basierte Traversal-Angriffe.

### Restore-Verhalten bei Upload-Dateien

Restore verhält sich **additiv/überschreibend**, nicht spiegelnd:
- Dateien aus dem Backup werden extrahiert und in `public/uploads/` geschrieben
- Bereits vorhandene Dateien mit gleichem Namen werden **überschrieben**
- Neue Dateien aus dem Backup werden **hinzugefügt**
- Dateien, die im Backup nicht vorhanden sind, **bleiben erhalten** (werden nicht gelöscht)

Dieses Verhalten ist bewusst konservativ gewählt:
- Verhindert versehentlichen Datenverlust bei unvollständigen Backups
- Entspricht dem Erwartungsverhalten bei einem Restore nach Neuinstallation (alles frisch) und bei einem selektiven Restore
- Eine echte „Spiegelung" (alte Dateien löschen) wäre destruktiver und erfordert einen separaten Bestätigungsschritt

---

## 5. Update-Paket-Format

### Dateiname-Konvention
```
itsweber-cms-update-{target_version}.zip
```

### ZIP-Struktur
```
update-*.zip
├── update_manifest.json   ← Update-Metadaten (Pflicht)
├── files/                 ← Dateien, die ins CMS-Root kopiert werden
│   ├── app/
│   ├── admin/
│   ├── public/
│   │   ├── admin/         ← Asset-Spiegel
│   │   └── themes/        ← Theme-Asset-Spiegel
│   └── themes/
└── migrations/            ← SQL-Migrations (optional)
    ├── 015_add_feature.sql
    └── 016_extend_table.sql
```

### update_manifest.json
```json
{
  "type":           "iw_update",
  "format_version": "1",
  "target_version": "1.1.0",
  "min_version":    "1.0.0",
  "description":    "Feature X hinzugefügt, Bug Y behoben",
  "released_at":    "2026-03-15"
}
```

**Pflichtfelder:** `type`, `format_version`, `target_version`
**Optionale Felder:** `min_version`, `description`, `released_at`

---

## 6. Update-Validierung (Phase 6e Hardening)

`BackupService::validateUpdate($zipPath, $currentVersion)` prüft:

| Prüfung | Fehlertyp |
|---|---|
| PHP `zip`-Extension vorhanden | Hart |
| Datei existiert | Hart |
| Dateigröße ≤ MAX_ZIP_SIZE | Hart |
| ZIP öffenbar | Hart |
| `update_manifest.json` vorhanden | Hart |
| `update_manifest.json` ist valides JSON | Hart |
| `type === "iw_update"` | Hart |
| `format_version === "1"` (exakter Versionsvergleich, neu in Hardening) | Hart |
| Alle Pflichtfelder gesetzt und nicht leer | Hart |
| `min_version` ≤ `$currentVersion` (falls gesetzt) | Hart |
| Kein Downgrade (`target_version` ≥ `$currentVersion`) | Hart |

---

## 7. Update-Preflight (Phase 6e Hardening)

`BackupService::preflightUpdate($zipPath, $basePath)` wird nach erfolgreicher Validierung gerufen und prüft:

| Prüfung | Kategorie |
|---|---|
| ZIP öffenbar | Harter Fehler |
| Path-Traversal-Sequenzen (`..`, NUL) in `files/`-Einträgen | Harter Fehler |
| CMS-Verzeichnisse (`app/`, `admin/`, `themes/`, `public/`) schreibbar | Harter Fehler |
| Geschützte Pfade im Paket vorhanden (werden beim Update übersprungen) | Warnung |

**Verhalten:**
- Harte Fehler blockieren das Update sofort; alle Fehler werden gesammelt und auf einmal angezeigt (nicht nur der erste)
- Warnungen werden auf der Bestätigungsseite angezeigt, blockieren aber nicht
- Der Admin sieht damit alle Probleme vor dem eigentlichen Update-Start

---

## 8. Update-Ablauf

### Schritt 1 – Upload, Validierung & Preflight
- `POST /admin/backup/update`
- Uploaded ZIP nach `storage/temp/` gespeichert
- `BackupService::validateUpdate()` prüft Manifest vollständig
- `BackupService::preflightUpdate()` prüft Pfade und Schreibbarkeit
- Bei Fehler: Temp-Datei löschen + alle Fehlermeldungen auf einmal + Redirect
- Bei Erfolg: Session + Bestätigungsseite mit etwaigen Warnungen

### Schritt 2 – Bestätigungsseite
- Zeigt: aktuellen Version → Zielversion
- Zeigt: Beschreibung, Veröffentlichungsdatum, Mindestversion, Pakettyp
- Zeigt Preflight-Warnungen (falls vorhanden)
- Listet geschützte Bereiche
- Hinweis: Website wird kurz in Wartungsmodus versetzt
- Bestätigung via `POST /admin/backup/update/execute`

### Schritt 3 – Ausführung
- **Wartungsmodus wird aktiviert** (storage/.maintenance)
- `BackupService::applyUpdate($zipPath, $basePath, $db)`:
  1. Alle Einträge unter `files/` aus ZIP extrahieren
  2. Für jede Datei:
     - Pfad-Traversal-Check (`..`, NUL) → harter Fehler, kein stilles Überspringen
     - `isProtectedPath()` → überspringen mit Logging (nicht still)
     - realpath-basierte Verifikation gegen basePath
     - Verzeichnis anlegen + Datei schreiben
  3. Alle `*.sql`-Dateien aus `migrations/` gesammelt und **alphabetisch sortiert** (nach Basename) ausgeführt
- **Wartungsmodus wird deaktiviert** (auch bei Fehler, via `finally`)

---

## 9. Migrations-/SQL-Reihenfolge (Phase 6e Hardening)

**Altes Verhalten:** Migrations wurden inline während der ZIP-Iteration ausgeführt – in der Reihenfolge, wie sie im ZIP gespeichert waren (nicht spezifiziert, nicht portabel).

**Neues Verhalten (deterministisch):**
1. Alle `*.sql`-Dateien aus `migrations/` werden in einer Map gesammelt
2. Die Map wird per `ksort(..., SORT_NATURAL)` nach Basename alphabetisch sortiert
3. Erst danach werden die SQL-Dateien in sortierter Reihenfolge ausgeführt

Namenskonvention für Migrations-Dateien: `{NNN}_{beschreibung}.sql` (z. B. `015_add_feature.sql`, `016_extend_table.sql`). Mit numerischem Präfix wird durch `SORT_NATURAL` korrekt nach Nummer sortiert.

---

## 10. Geschützte Pfade beim Update

Folgende Pfade werden beim Update **immer übersprungen** (unabhängig vom Paketinhalt):

| Pfad | Grund |
|---|---|
| `public/uploads/` | Nutzer-Medien (Bilder, Logos, Dokumente) |
| `storage/` | Cache, Logs, Sessions, Temp-Dateien |
| `app/Config/database.php` | Datenbankverbindung der Installation |
| `app/Config/app.php` | App-Konfiguration (Mail, Schlüssel, etc.) |

**Verhalten bei geschützten Pfaden (Phase 6e Hardening):**
- Geschützte Pfade im Paket werden nicht mehr still ignoriert
- Sie werden beim `error_log()` protokolliert
- Auf der Bestätigungsseite werden sie als Preflight-Warnung angezeigt
- Das Update wird trotzdem fortgesetzt – die Einträge werden sicher übersprungen

Die Liste ist in `BackupService::PROTECTED_PATHS` definiert und über `BackupService::getProtectedPaths()` abrufbar.

---

## 11. Wartungsmodus (Phase 6e Hardening)

### Mechanismus
- `BackupService::enableMaintenanceMode($basePath)` schreibt `storage/.maintenance` (JSON mit Zeitstempel und Grund)
- `BackupService::disableMaintenanceMode($basePath)` löscht die Datei
- `BackupService::isMaintenanceMode($basePath)` prüft das Vorhandensein

### Integration
- Beide `restore()` und `applyUpdate()` verwenden `try { ... } finally { disableMaintenanceMode() }`
- Der Wartungsmodus wird damit auch bei Fehlern und Exceptions zuverlässig deaktiviert

### Frontend-Verhalten (`public/index.php`)
- Beim Request-Start wird `storage/.maintenance` geprüft
- Nicht-Admin-Requests (URL beginnt nicht mit `/admin`) erhalten eine HTTP 503-Seite mit `Retry-After: 60`
- Admin-Requests werden immer durchgelassen (damit der Admin den Zustand überwachen kann)
- Die Maintenance-Seite ist ein inline-HTML ohne Framework-Abhängigkeiten (robust auch wenn CMS-Autoloader fehlschlägt)

### Grenzen
- Kein automatischer Recovery wenn der Server zwischen `enable` und `finally` hart abstürzt (Datei bleibt dann stehen)
- In diesem Fall muss `storage/.maintenance` manuell gelöscht werden
- Kein Countdown-Timer, keine geplante Wartungszeit – nur On/Off während der Operation

---

## 12. ZIP-Größen-Limit

- `BackupService::MAX_ZIP_SIZE_DEFAULT_MB` = 50 MB (Standardwert)
- Konfigurierbar über `app/Config/app.php` Schlüssel `'backup_max_zip_mb'` (Integer, in MB)
- `BackupService::getMaxZipSize()` liest den Konfig-Wert und fällt auf den Default zurück
- Die Admin-UI zeigt den aktuell konfigurierten Wert an (nicht hartcodiert in der View)

**Wichtig:** PHP-seitige Limits (`upload_max_filesize`, `post_max_size`) müssen ausreichend groß konfiguriert sein. Das CMS-interne Limit greift zusätzlich.

---

## 13. Fehlermeldungen

Alle Fehlermeldungen werden in verständlichem Deutsch in der Admin-UI angezeigt. Folgende Kategorien:

| Situation | Meldung |
|---|---|
| ZIP zu groß | Größe und Limit werden genannt |
| Falscher Dateityp | Tatsächliche Endung wird genannt |
| Falscher Pakettyp (Backup statt Update etc.) | Erwarteter vs. gefundener Typ |
| Falsche Format-Version | Gefundene vs. unterstützte Version |
| Fehlende Manifest-Felder | Konkreter Feldname genannt |
| Preflight-Fehler | Alle Fehler auf einmal, nicht nur der erste |
| PHP-Upload-Fehler | Aufgelistet nach Upload-Error-Code mit verständlicher Erklärung |
| Schreibrechte fehlen | Betroffenes Verzeichnis genannt |

---

## 14. Sicherheitsannahmen und Grenzen

### Backup-Integrität
- Backups werden nicht kryptografisch signiert
- Ein modifiziertes Backup-ZIP kann eingespielt werden, sofern es das korrekte Format hat
- Backups sollten ausschließlich von vertrauenswürdiger Quelle (eigene Exporte) verwendet werden
- Der CMS-Admin hat volle Datenhoheit

### Update-Paket-Integrität
- Update-Pakete werden durch Manifest-Felder validiert (Typ, Format-Version, Min-Version)
- Eine kryptografische Signaturprüfung ist in Phase 6e **nicht** implementiert (vorbehalten für spätere Phase)
- Update-Pakete sollten ausschließlich von vertrauenswürdiger Quelle (offizielle ITSWEBER-Distribution) eingespielt werden

### Pfad-Traversal-Schutz
- **Restore**: Einträge mit `..`/NUL werden früh abgelehnt; realpath-Verifikation nach mkdir
- **Update**: Einträge mit `..`/NUL sind harte Preflight-Fehler (blockieren das gesamte Update); realpath-Verifikation nach mkdir

### Keine automatischen Rollbacks
- Weder Restore noch Update implementieren automatische Rollbacks
- Empfehlung: vor jedem Update ein aktuelles Backup erstellen (Hinweis in der UI)

---

## 15. Abhängigkeiten und Anforderungen

| Anforderung | Pflicht | Anmerkung |
|---|---|---|
| PHP `zip`-Extension (`ZipArchive`) | Ja | Ohne Extension ist Backup/Restore/Update nicht möglich; Admin-UI zeigt Warnung |
| Schreibrechte `storage/temp/` | Ja | Für temporäre ZIP-Ablage |
| Schreibrechte `storage/` | Ja | Für Wartungsmodus-Flag |
| Schreibrechte CMS-Verzeichnisse | Ja (für Update) | app/, admin/, themes/, public/ – Preflight prüft das |
| PHP PDO | Ja | Für SQL-Dump und Restore; bereits CMS-Grundvoraussetzung |
| `mysqldump` auf Server | Nein | Bewusst vermieden (ADR-051) |
| Shell-Zugriff | Nein | Keine `exec()`/`shell_exec()`-Aufrufe |
