diff --git a/.gitignore b/.gitignore index 882b235..e539a92 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ vendor/ -data/mariadb/ -uploads/ +# Persistente Nutzdaten (DB, Belege, Logo) – nie ins Repo, siehe data/README.md. +# data/README.md selbst ist per Negation unten wieder eingeschlossen. +data/* +!data/README.md backups/ .env *.log diff --git a/Changes.md b/Changes.md index 5bd7a48..1ff70eb 100644 --- a/Changes.md +++ b/Changes.md @@ -2,6 +2,34 @@ --- +## 2026-07-31 – Firmenlogo persistent in `data/logo`, Nutzdaten gebündelt unter `data/` + +- Fix #116 hatte die Beleg-Uploads aus dem Web-Root nach `data/uploads` verschoben – inklusive des Unterordners `logo/`. Angepasst wurden damals aber nur Beleg-, Bank- und Invoice-Code; `Setting`, `SettingsController` und `PdfInvoiceService` zeigten weiterhin auf `public/static/uploads/logo/`. Dadurch war das hochgeladene Firmenlogo ab diesem Zeitpunkt nicht mehr auffindbar (Datei vorhanden, Pfad falsch) und ein Neu-Hochladen legte es erneut im Web-Root ab, wo es bei jedem Update verloren ging. +- **Neue Ablagestruktur:** Alle persistenten Nutzdaten liegen jetzt gebündelt und nach Art getrennt unter `data/` – `data/mariadb` (Datenbank), `data/belege` (Belegdateien), `data/logo` (Firmenlogo). Jedes Verzeichnis ist ein eigenes Volume; `data/mariadb` wird nicht mehr in den `web`-Container gemountet. +- **`data/README.md`** (neu): Beschreibt für den Admin, was wo liegt, warum die Dateien außerhalb des Web-Roots stehen und wie eine vollständige Datensicherung aussieht. +- **`src/Config.php`:** `upload_dir` zeigt auf `data/belege`, neuer Schlüssel `logo_dir` auf `data/logo`. +- **`src/Models/Setting.php`:** Neue Methode `logoAbsolutePath()` löst den DB-Wert gegen `logo_dir` auf (toleriert Altpfade mit `static/uploads/`-Präfix); `deleteLogo()` nutzt sie statt des Web-Root-Pfads. `setLogo()` speichert nur noch den Dateinamen. +- **`src/Controllers/SettingsController.php`:** `uploadLogo()` schreibt nach `data/logo` und räumt beim Formatwechsel (png ↔ jpg) die Altdatei ab; neue Methode `serveLogo()` liefert das Logo authentifiziert über `/settings/logo/file` aus (Pfad- und MIME-Prüfung). +- **`src/Services/PdfInvoiceService.php`, `src/Views/invoices/show.php`, `src/Views/settings/form.php`:** Beziehen das Logo über den neuen Endpunkt bzw. den absoluten Dateipfad statt über einen Web-Root-Pfad. +- **`migrations/089_pfade_ohne_webroot_praefix.sql`** (neu): Entfernt die historischen `static/uploads/`-Präfixe aus `belege.storage_path` und `company_settings.logo_path`; idempotent. +- **`scripts/migrate-datenverzeichnis.sh`** (neu): Verschiebt vorhandene Daten aus `./uploads` nach `./data/belege` bzw. `./data/logo`. Der Umzug läuft in einem kurzlebigen Container als root, da die Dateien `www-data` gehören – kein sudo nötig, idempotent. +- **`docker-compose.yml`, `Dockerfile`, `docker-entrypoint.sh`, `.gitignore`:** Volumes, Verzeichnisanlage und Rechte auf die neue Struktur umgestellt; `.gitignore` schließt `data/*` aus (Ausnahme: `data/README.md`), sodass `git clean` die Nutzdaten nicht mehr treffen kann. +- **Kein Breaking Change bei bestehenden Installationen:** Der Entrypoint übernimmt vorhandene Dateien beim Container-Start automatisch nach `data/belege` bzw. `data/logo` – sowohl aus der gemeinsamen `uploads`-Ablage (dafür bleibt `./uploads` übergangsweise als `data/legacy-uploads` gemountet) als auch ein Logo aus dem alten Web-Root. Ein reines `scripts/update.sh` (git pull + Rebuild + Start) genügt damit; ohne diese Automatik hätte Migration 089 die DB-Pfade umgeschrieben, während die Dateien am alten Ort liegen geblieben wären. +- **Fix: Datei-Backups liefen seit Fix #116 leer.** `scripts/backup.sh` sicherte weiterhin `public/static/uploads/` – seit dem Umzug aus dem Web-Root ein leeres Verzeichnis. Die Archive enthielten dadurch keine einzige Belegdatei mehr (117 Bytes statt zweistelliger MB-Beträge), ohne dass es auffiel; gesichert wurde nur noch die Datenbank. Das Skript sichert jetzt `data/belege` und `data/logo` und bricht nicht mehr stillschweigend ab, sondern warnt, wenn das Archiv keine Datei enthält. +- **`scripts/restore.sh`:** Stellt Belege und Logo an den neuen Orten wieder her und erkennt zusätzlich das alte `uploads/`-Archivformat, sodass ältere Datei-Backups einspielbar bleiben. +- **`scripts/update.sh`:** Neuer Schritt 2 aktualisiert `scripts/` per `git pull` und startet sich in der neuen Fassung neu, **bevor** das Backup läuft. Bislang sicherte ein Update sich noch mit den Skripten des Vorgängerstands ab – dadurch entstand das leere Datei-Backup. Der Ausgangs-Commit wird über `STEUERKRAM_BEFORE_COMMIT` an den Neustart weitergereicht, damit die Rebuild-Erkennung die Änderungen weiterhin sieht. Der frühere, engere Selbst-Neustart (nur bei Änderung an `update.sh` selbst, und erst nach dem Backup) entfällt. +- **`scripts/backup.sh`:** Sichert defensiv jeden Ort, an dem Nutzdateien liegen können (`data/belege`, `data/logo`, `data/legacy-uploads`, `data/uploads` sowie das alte Web-Root-Verzeichnis), sofern er nicht leer ist. Damit ist das Backup auch im Übergangszustand eines laufenden Updates vollständig, in dem die Skripte schon neu sind, die Dateien aber erst beim nächsten Container-Start umziehen. +- **`scripts/restore.sh`:** Entpackt in ein Staging-Verzeichnis und normalisiert anschließend alle je verwendeten Ablagen (`belege/`, `logo/`, `legacy-uploads/`, `uploads/`) auf die aktuelle Struktur. +- **`tests/71_DatenverzeichnisPfadeTest.php`** (neu): Hält Backup-, Restore-, Compose-, Entrypoint- und Update-Pfade mit `Config::upload_dir`/`logo_dir` zusammen und prüft, dass die Skript-Vorabaktualisierung vor dem Backup steht – genau dieser Drift hatte das leere Backup verursacht. + +## 2026-07-31 – Fix: Logo-Upload defekt (falsches Ziel-Formular + Warnings in Setting::update()) + +- Der „Hochladen"-Button in den Einstellungen postete an `/settings` statt an `/settings/logo`: Das Logo-Formular lag als verschachteltes `
` innerhalb von `#settings-form`. HTML erlaubt keine verschachtelten Formulare – der Browser verwirft das innere Start-Tag, wodurch das erste `
` bereits `#settings-form` schloss. Folge: Der Upload lief nie, stattdessen wurde `Setting::update()` mit unvollständigen POST-Daten aufgerufen. Aus demselben Grund war auch der Logo-Löschen-Button (`form="delete-logo-form"`) wirkungslos. +- `Setting::update()` las `mail_subject_template`/`mail_body_template` weiterhin per `?:` aus `$data`, obwohl beide Felder seit „E-Mail-Betreff und -Text pro Kunde" (2026-05-30) nicht mehr im Einstellungsformular stehen. Das erzeugte „Undefined array key"-Warnings; deren Ausgabe verhinderte anschließend den `Location`-Header in `View::redirect()` („headers already sent"). +- **`src/Views/settings/form.php`:** `#logo-upload-form` und `#delete-logo-form` stehen jetzt als eigenständige, leere Formulare außerhalb von `#settings-form`; Datei-Input und Buttons sind per `form="…"`-Attribut daran gebunden. +- **`src/Models/Setting.php`:** Alle optionalen Felder werden konsistent über `($data['x'] ?? '') ?: default` gelesen, sodass fehlende POST-Keys keine Warnings mehr auslösen. +- **`tests/70_SettingsLogoUploadTest.php`:** Neuer Test prüft, dass `Setting::update()` ohne optionale Keys warnungsfrei durchläuft, dass `setLogo()`/`deleteLogo()` den Pfad korrekt schreiben und dass in `#settings-form` kein verschachteltes `
` mehr steht. + ## 2026-07-30 – Fix: SMTP-Header-/Kommando-Injection über mail_from_email (Issue #148) - `SmtpMailer::sendMessage()`/`buildMessage()` verwendeten `fromEmail` roh sowohl im `MAIL FROM:<...>`-SMTP-Kommando als auch im `From:`-Header, während alle anderen dynamischen Werte (Empfänger, `fromName`, `subject`, Anhangsname) bereits validiert bzw. CRLF-bereinigt wurden. `mail_from_email` (Firmeneinstellung, per POST setzbar) durchlief keine serverseitige Prüfung – ein eingeloggter Nutzer konnte per CRLF-Payload zusätzliche SMTP-Kommandos (z.B. eine verdeckte `RCPT TO`-Zeile) einschleusen oder den `From:`-Header manipulieren (CWE-93). diff --git a/Dockerfile b/Dockerfile index 8bc7749..2816f27 100644 --- a/Dockerfile +++ b/Dockerfile @@ -37,7 +37,7 @@ COPY apache/steuerkram.conf /etc/apache2/sites-available/000-default.conf RUN a2enmod rewrite headers # Permissions -RUN mkdir -p /var/www/html/data/uploads \ +RUN mkdir -p /var/www/html/data/belege /var/www/html/data/logo \ && chown -R www-data:www-data /var/www/html \ && chmod -R 755 /var/www/html/data diff --git a/README.md b/README.md index 66fbe92..6f8ab8a 100644 --- a/README.md +++ b/README.md @@ -188,12 +188,20 @@ Das Skript gibt beim Start eine Zusammenfassung aus (importiert / übersprungen ## Datenpersistenz +Alle persistenten Nutzdaten liegen gebündelt unter `./data/` – getrennt nach Art, +damit erkennbar bleibt, was wo liegt (Details: [`data/README.md`](data/README.md)): + | Pfad | Inhalt | |---|---| | `./data/mariadb/` | MariaDB-Datenbankdateien | -| Docker Volume `uploads` | Hochgeladene Belege | +| `./data/belege/` | Hochgeladene Belege | +| `./data/logo/` | Firmenlogo | -Beide Pfade liegen außerhalb des Containers und bleiben bei `docker compose down` erhalten. +Diese Pfade liegen außerhalb des Containers und bleiben bei `docker compose down` +erhalten. `data/belege` und `data/logo` liegen bewusst außerhalb des Web-Roots und +werden nur über die Anwendung ausgeliefert (`/belege/{id}/file`, `/settings/logo/file`). +`./data/` ist per `.gitignore` vom Repo ausgenommen und wird von `git clean` nicht +angefasst. ## Systemd-Service einrichten diff --git a/data/README.md b/data/README.md new file mode 100644 index 0000000..5be4e93 --- /dev/null +++ b/data/README.md @@ -0,0 +1,41 @@ +# data/ – persistente Nutzdaten + +Alles in diesem Verzeichnis ist **Nutzdatenbestand**, nicht Code. Es überlebt +Updates, `git pull`, `git clean` und Container-Neubauten und gehört in die +Datensicherung. Der Inhalt ist per `.gitignore` (`data/*`) vom Repo ausgenommen – +nur diese README ist eingecheckt. + +| Verzeichnis | Inhalt | Gemountet in | +|---|---|---| +| `data/mariadb/` | Datenbank (MariaDB-Datendateien) | nur `db`-Container: `/var/lib/mysql` | +| `data/belege/` | Hochgeladene Belegdateien, nach Jahr/Belegnummer | `web`: `/var/www/html/data/belege` | +| `data/logo/` | Firmenlogo (`logo.png` oder `logo.jpg`) | `web`: `/var/www/html/data/logo` | + +## Warum außerhalb des Web-Roots + +`data/belege` und `data/logo` liegen bewusst **nicht** unter `public/`. Beide +werden ausschließlich über die Anwendung ausgeliefert, die vorher Anmeldung und +Dateityp prüft: + +- Belege über `/belege/{id}/file` (`BelegController::serveFile`) +- Logo über `/settings/logo/file` (`SettingsController::serveLogo`) + +Ein direkter Abruf per URL ist damit nicht möglich. In der Datenbank steht jeweils +nur der Pfad **relativ** zum zugehörigen Verzeichnis (`belege.storage_path`, +`company_settings.logo_path`) – kein Web-Pfad und kein absoluter Serverpfad. + +Der `web`-Container sieht `data/mariadb` nicht; die Datenbankdateien sind +ausschließlich in den `db`-Container gemountet. + +## Datensicherung + +```bash +# Datenbank +docker compose exec -T db mariadb-dump -usteuerkram -psteuerkram steuerkram > backup.sql + +# Dateien +tar czf backup-dateien.tar.gz data/belege data/logo +``` + +Ein Backup ist nur zusammen vollständig: Ohne `data/belege` verweisen die +Beleg-Datensätze der Datenbank ins Leere. diff --git a/docker-compose.yml b/docker-compose.yml index 2acba01..d689f91 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,8 +7,14 @@ services: - ./public:/var/www/html/public - ./src:/var/www/html/src - ./migrations:/var/www/html/migrations - - ./uploads:/var/www/html/data/uploads - - ./data:/var/www/html/data + # Persistente Nutzdaten (siehe data/README.md) – bewusst je ein eigenes Volume, + # damit erkennbar bleibt, was wo liegt. data/mariadb wird hier NICHT gemountet. + - ./data/belege:/var/www/html/data/belege + - ./data/logo:/var/www/html/data/logo + # Übergangs-Mount: alte Ablage vor der Trennung in belege/logo. Der Entrypoint + # übernimmt vorhandene Dateien beim Start automatisch nach data/belege bzw. + # data/logo. Kann entfernt werden, sobald alle Installationen umgezogen sind. + - ./uploads:/var/www/html/data/legacy-uploads - ./tests:/var/www/html/tests - ./scripts:/var/www/html/scripts environment: diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index aed7b43..1a4d00e 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -1,6 +1,54 @@ #!/bin/sh -# Fix ownership of volume-mounted upload directory at container start -chown -R www-data:www-data /var/www/html/data/uploads 2>/dev/null || true +DATA=/var/www/html/data +BELEGE="$DATA/belege" +LOGO="$DATA/logo" +LEGACY="$DATA/legacy-uploads" +WEBROOT_UPLOADS=/var/www/html/public/static/uploads + +mkdir -p "$BELEGE" "$LOGO" 2>/dev/null || true + +# ── Einmalige Übernahme von Altbeständen ──────────────────────────────────── +# Belege und Logo lagen früher gemeinsam in einem uploads-Verzeichnis (bzw. davor +# im Web-Root). Beide Fälle werden hier automatisch übernommen, damit ein reines +# "git pull + docker compose up" (z.B. über scripts/update.sh) nicht in einer +# Installation endet, deren Dateien nicht mehr gefunden werden. +# Idempotent: Nach dem Umzug sind die Quellverzeichnisse leer. + +# Logo aus dem alten Web-Root (Stand vor Fix #116) +if [ -d "$WEBROOT_UPLOADS/logo" ]; then + for f in "$WEBROOT_UPLOADS"/logo/*; do + [ -e "$f" ] || continue + echo "Migration: $(basename "$f") aus dem Web-Root -> data/logo/" + mv "$f" "$LOGO/" 2>/dev/null || true + done + rmdir "$WEBROOT_UPLOADS/logo" 2>/dev/null || true +fi + +# Gemeinsame uploads-Ablage (Stand nach #116, vor der Trennung in belege/logo) +if [ -d "$LEGACY" ] && [ -n "$(ls -A "$LEGACY" 2>/dev/null)" ]; then + echo "Migration: Altbestand aus uploads/ wird übernommen..." + + # Logo zuerst, damit es nicht als Beleg mitwandert + if [ -d "$LEGACY/logo" ]; then + for f in "$LEGACY"/logo/*; do + [ -e "$f" ] || continue + echo " Logo: $(basename "$f") -> data/logo/" + mv "$f" "$LOGO/" 2>/dev/null || true + done + rmdir "$LEGACY/logo" 2>/dev/null || true + fi + + for entry in "$LEGACY"/*; do + [ -e "$entry" ] || continue + echo " Beleg: $(basename "$entry") -> data/belege/" + mv "$entry" "$BELEGE/" 2>/dev/null || true + done + + echo "Migration: Altbestand übernommen." +fi + +# Fix ownership of volume-mounted data directories at container start +chown -R www-data:www-data "$BELEGE" "$LOGO" 2>/dev/null || true # Datenbank-Migrationen automatisch einspielen php /var/www/html/scripts/migrate.php diff --git a/docs/datenbankstruktur.md b/docs/datenbankstruktur.md index e80bfb9..2631656 100644 --- a/docs/datenbankstruktur.md +++ b/docs/datenbankstruktur.md @@ -69,7 +69,7 @@ MariaDB 11, Engine InnoDB, Zeichensatz `utf8mb4_unicode_ci`. Das Schema wird dur | `steuernummer` | VARCHAR(30) | Steuernummer beim Finanzamt | | `finanzamt` | VARCHAR(255) | Zuständiges Finanzamt | | `iban`, `bic`, `bank_name` | VARCHAR | Bankverbindung | -| `logo_path` | VARCHAR(500) | Pfad zum Logo-PNG | +| `logo_path` | VARCHAR(500) | Dateiname des Logos in `data/logo/` (z.B. `logo.png`); Auslieferung nur über `/settings/logo/file` | | `ustva_steuernummer` | VARCHAR(20) | ELSTER-Format: `FFFBBBBBBUUUUP` | | `ustva_bundesland` | VARCHAR(2) | Bundesland-Kürzel (z.B. `BY`) | | `invoice_nr_prefix` | VARCHAR(10) | Präfix Rechnungsnummer (Standard: `AR`) | @@ -116,7 +116,7 @@ MariaDB 11, Engine InnoDB, Zeichensatz `utf8mb4_unicode_ci`. Das Schema wird dur | `id` | INT UNSIGNED PK | | | `beleg_nr` | VARCHAR(30) UNIQUE | Automatisch vergeben (z.B. `BLG-2026-0001`) | | `filename` | VARCHAR(500) | Originaldateiname | -| `storage_path` | VARCHAR(500) | Relativer Pfad unter `data/uploads/` (außerhalb des Web-Roots, Bestandsdaten mit altem Präfix `static/uploads/` werden von `Beleg::absolutePath()` weiter unterstützt) | +| `storage_path` | VARCHAR(500) | Relativer Pfad unter `data/belege/` (außerhalb des Web-Roots, Auslieferung nur über `/belege/{id}/file`). Migration 089 hat das historische Präfix `static/uploads/` entfernt; `Beleg::absolutePath()` toleriert es weiterhin | | `mime_type` | VARCHAR(100) | MIME-Typ der Datei | | `file_hash` | VARCHAR(64) | SHA-256 für GoBD-Integritätsprüfung | | `beleg_datum` | DATE | Datum auf dem Dokument | diff --git a/docs/update-backup.md b/docs/update-backup.md index 3e45610..684d0f7 100644 --- a/docs/update-backup.md +++ b/docs/update-backup.md @@ -28,9 +28,20 @@ docker compose exec db mariadb-dump -u steuerkram -psteuerkram steuerkram \ | Pfad | Inhalt | |---|---| | `./data/mariadb/` | MariaDB-Datenbankdateien | -| Docker Volume `uploads` | Hochgeladene Belege | +| `./data/belege/` | Hochgeladene Belege | +| `./data/logo/` | Firmenlogo | -Beide Pfade liegen außerhalb des Containers und bleiben bei `docker compose down` erhalten. +Alle drei Pfade liegen außerhalb des Containers und bleiben bei `docker compose down` +erhalten. Details in [`data/README.md`](../data/README.md). + +Ein vollständiges Backup umfasst **Datenbank und Dateien gemeinsam** – ohne +`data/belege` verweisen die Beleg-Datensätze der Datenbank ins Leere. `scripts/backup.sh` +sichert beides und warnt, wenn das Datei-Archiv leer bleibt. + +> **Alte Datei-Backups:** Archive aus der Zeit vor dem Umzug enthalten ein +> `uploads/`-Verzeichnis. `scripts/restore.sh` erkennt dieses Format und verteilt den +> Inhalt automatisch auf `data/belege` und `data/logo` – solche Backups bleiben also +> einspielbar. --- diff --git a/migrations/089_pfade_ohne_webroot_praefix.sql b/migrations/089_pfade_ohne_webroot_praefix.sql new file mode 100644 index 0000000..ae459ba --- /dev/null +++ b/migrations/089_pfade_ohne_webroot_praefix.sql @@ -0,0 +1,22 @@ +-- Bereinigt die noch aus der Zeit vor Fix #116 stammenden, webroot-relativen +-- Pfad-Präfixe "static/uploads/". Belegdateien und Logo liegen seither außerhalb +-- des Web-Roots (data/belege bzw. data/logo); die DB speichert nur noch den +-- Pfad relativ zum jeweiligen Datenverzeichnis. +-- +-- Idempotent: Der WHERE-Filter greift nach dem ersten Lauf nicht mehr. +-- LENGTH('static/uploads/') = 15, SUBSTRING ist 1-basiert -> ab Position 16. + +UPDATE belege + SET storage_path = SUBSTRING(storage_path, 16) + WHERE storage_path LIKE 'static/uploads/%'; + +-- Das Logo lag zusätzlich in einem eigenen Unterordner "logo/", der beim Umzug +-- zum eigenen Datenverzeichnis data/logo wurde -> auch dieses Präfix entfernen. +-- LENGTH('logo/') = 5 -> ab Position 6. +UPDATE company_settings + SET logo_path = SUBSTRING(logo_path, 16) + WHERE logo_path LIKE 'static/uploads/%'; + +UPDATE company_settings + SET logo_path = SUBSTRING(logo_path, 6) + WHERE logo_path LIKE 'logo/%'; diff --git a/public/index.php b/public/index.php index 9c42643..9a26742 100644 --- a/public/index.php +++ b/public/index.php @@ -210,6 +210,7 @@ $router->get('/tax/euer/{year}/anlageliste/pdf', fn($p) => (new EuerController() $router->get('/settings', fn($p) => (new SettingsController())->show($p)); $router->post('/settings', fn($p) => (new SettingsController())->update($p)); $router->post('/settings/mail-test', fn($p) => (new SettingsController())->mailTest($p)); +$router->get('/settings/logo/file', fn($p) => (new SettingsController())->serveLogo($p)); $router->post('/settings/logo', fn($p) => (new SettingsController())->uploadLogo($p)); $router->post('/settings/logo/delete', fn($p) => (new SettingsController())->deleteLogo($p)); $router->post('/settings/generate-app-key', fn($p) => (new SettingsController())->generateAppKey($p)); diff --git a/scripts/backup.sh b/scripts/backup.sh index 518a467..ca13101 100755 --- a/scripts/backup.sh +++ b/scripts/backup.sh @@ -41,12 +41,57 @@ docker compose -f "$PROJECT_DIR/docker-compose.yml" \ --triggers \ steuerkram > "$BACKUP_DIR/steuerkram_db_$TIMESTAMP.sql" -# Uploads sichern (im Container ausführen – umgeht Host-Berechtigungen auf Bind-Mount-Dateien) +# Nutzdateien sichern: Belege UND Logo (im Container ausführen – umgeht +# Host-Berechtigungen auf Bind-Mount-Dateien). +# +# Bewusst defensiv: gesichert wird jeder Ort, an dem Nutzdateien liegen KÖNNEN, +# sofern er nicht leer ist. Das deckt auch den Übergangszustand während eines +# Updates ab, in dem die Skripte schon neu sind, die Dateien aber erst beim +# nächsten Container-Start an ihren neuen Platz wandern. Ein fest verdrahteter +# Einzelpfad war die Ursache dafür, dass die Archive nach Fix #116 unbemerkt +# leer blieben. docker compose -f "$PROJECT_DIR/docker-compose.yml" \ --project-directory "$PROJECT_DIR" \ - exec -T web \ - tar -czf - -C /var/www/html/public/static uploads/ \ - > "$BACKUP_DIR/steuerkram_uploads_$TIMESTAMP.tar.gz" + exec -T web sh -c ' + cd /var/www/html/data || exit 1 + SRC="" + # belege/logo = aktuelle Struktur + # legacy-uploads = Übergangs-Mount der alten Sammelablage + # uploads = Sammelablage im noch laufenden Container des + # Vorgängerstands (dort ist data/uploads gemountet) + for d in belege logo legacy-uploads uploads; do + if [ -d "$d" ] && [ -n "$(ls -A "$d" 2>/dev/null)" ]; then + SRC="$SRC $d" + fi + done + + # Logo aus dem alten Web-Root (Stand vor Fix #116). Nur heranziehen, + # wenn data/uploads nicht schon dieselbe Ablage abdeckt – sonst gäbe es + # zwei Einträge namens uploads/ im Archiv. + WR=/var/www/html/public/static/uploads + if [ -d "$WR" ] && [ -n "$(ls -A "$WR" 2>/dev/null)" ] \ + && [ ! -d uploads ]; then + # shellcheck disable=SC2086 + exec tar -czf - $SRC -C /var/www/html/public/static uploads + fi + + if [ -z "$SRC" ]; then + # Nichts vorhanden -> leeres, aber gültiges Archiv + exec tar -czf - -T /dev/null + fi + + # shellcheck disable=SC2086 + exec tar -czf - $SRC + ' > "$BACKUP_DIR/steuerkram_uploads_$TIMESTAMP.tar.gz" + +# Plausibilitätsprüfung: ein Archiv ohne Dateien deutet auf einen falschen +# Quellpfad hin und darf nicht unbemerkt als gültiges Backup durchgehen. +UPL_ARCHIVE="$BACKUP_DIR/steuerkram_uploads_$TIMESTAMP.tar.gz" +FILE_COUNT=$(tar -tzf "$UPL_ARCHIVE" 2>/dev/null | grep -vc '/$' || true) +if [ "${FILE_COUNT:-0}" -eq 0 ]; then + echo " WARNUNG: Das Datei-Backup enthält keine einzige Datei!" >&2 + echo " Quellpfad prüfen: data/belege und data/logo im web-Container." >&2 +fi echo " DB: $BACKUP_DIR/steuerkram_db_$TIMESTAMP.sql" echo " Uploads: $BACKUP_DIR/steuerkram_uploads_$TIMESTAMP.tar.gz" diff --git a/scripts/migrate-datenverzeichnis.sh b/scripts/migrate-datenverzeichnis.sh new file mode 100755 index 0000000..ab0e64c --- /dev/null +++ b/scripts/migrate-datenverzeichnis.sh @@ -0,0 +1,80 @@ +#!/bin/sh +# Verschiebt die Nutzdaten aus der alten Struktur (./uploads mit Unterordner logo/) +# in die getrennten Verzeichnisse ./data/belege und ./data/logo. +# +# Idempotent: Ein zweiter Lauf findet nichts mehr zu tun und meldet das. +# Vor dem Lauf die Container stoppen: docker compose down +# +# ./scripts/migrate-datenverzeichnis.sh +# +# Die Belegdateien gehören www-data (uid 33), nicht dem angemeldeten Benutzer. +# Der eigentliche Umzug läuft deshalb in einem kurzlebigen Container als root – +# das vermeidet sudo und funktioniert auf jedem Host gleich. + +set -eu + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" + +echo "Projektverzeichnis: $ROOT" + +mkdir -p "$ROOT/data/belege" "$ROOT/data/logo" + +# Historischer Sonderfall: Vor dem Umzug aus dem Web-Root landete das Logo in +# public/static/uploads/logo. Falls dort noch etwas liegt, zuerst herüberholen. +ALT_WEBROOT="$ROOT/public/static/uploads" +if [ -d "$ALT_WEBROOT/logo" ]; then + for f in "$ALT_WEBROOT"/logo/*; do + [ -e "$f" ] || continue + echo "Logo aus Web-Root: $(basename "$f") -> data/logo/" + cp -p "$f" "$ROOT/data/logo/" && rm -f "$f" + done + rmdir "$ALT_WEBROOT/logo" 2>/dev/null || true + rmdir "$ALT_WEBROOT" 2>/dev/null || true +fi + +if [ ! -d "$ROOT/uploads" ]; then + echo "Kein altes Verzeichnis $ROOT/uploads vorhanden – nichts weiter zu verschieben." + exit 0 +fi + +if ! command -v docker >/dev/null 2>&1; then + echo "Fehler: docker nicht gefunden. Bitte den Umzug manuell als root durchführen:" >&2 + echo " mv uploads/logo/* data/logo/ && mv uploads/* data/belege/" >&2 + exit 1 +fi + +docker run --rm \ + -v "$ROOT/uploads:/alt" \ + -v "$ROOT/data:/neu" \ + alpine:3 sh -eu -c ' + mkdir -p /neu/belege /neu/logo + + # Logo zuerst, damit es nicht als Beleg mitwandert + if [ -d /alt/logo ]; then + for f in /alt/logo/*; do + [ -e "$f" ] || continue + echo "Logo: $(basename "$f") -> data/logo/" + mv "$f" /neu/logo/ + done + rmdir /alt/logo 2>/dev/null || true + fi + + for entry in /alt/*; do + [ -e "$entry" ] || continue + echo "Beleg: $(basename "$entry") -> data/belege/" + mv "$entry" /neu/belege/ + done + + chown -R 33:33 /neu/belege /neu/logo + + echo + echo "data/belege: $(find /neu/belege -type f | wc -l) Datei(en)" + echo "data/logo: $(find /neu/logo -type f | wc -l) Datei(en)" + ' + +rmdir "$ROOT/uploads" 2>/dev/null || echo "Hinweis: $ROOT/uploads ist nicht leer und bleibt bestehen." +rmdir "$ROOT/data/uploads" 2>/dev/null || true + +echo +echo "Weiter mit: docker compose up -d --build" +echo "Die Pfad-Migration in der Datenbank (089) läuft beim Container-Start automatisch." diff --git a/scripts/migrate-uploads-to-bindmount.sh b/scripts/migrate-uploads-to-bindmount.sh index 5bfe931..5ad4151 100755 --- a/scripts/migrate-uploads-to-bindmount.sh +++ b/scripts/migrate-uploads-to-bindmount.sh @@ -1,4 +1,9 @@ #!/bin/bash +# VERALTET – nur noch für historische Stände relevant. +# Das damalige Ziel ./uploads/ gibt es nicht mehr; Belege liegen inzwischen in +# ./data/belege/, das Logo in ./data/logo/. Für den Umzug aus ./uploads/ in die +# heutige Struktur ist scripts/migrate-datenverzeichnis.sh zuständig. +# # Einmalige Migration: Uploads vom named Docker Volume in ./uploads/ (Bind Mount) # Vor dem ersten "docker compose up" mit dem neuen docker-compose.yml ausführen. # diff --git a/scripts/restore.sh b/scripts/restore.sh index 3ba9e6e..4f12082 100755 --- a/scripts/restore.sh +++ b/scripts/restore.sh @@ -77,13 +77,59 @@ echo "[1/3] Datenbank importieren..." $COMPOSE exec -T db mariadb -u root -prootpass steuerkram < "$DB_FILE" echo " OK" -# --- 2. Uploads einspielen --- -echo "[2/3] Uploads entpacken..." -$COMPOSE exec -T web sh -c " - cat > /tmp/uploads_restore.tar.gz - tar -xzf /tmp/uploads_restore.tar.gz -C /var/www/html/public/static/ - rm /tmp/uploads_restore.tar.gz -" < "$UPL_FILE" +# --- 2. Nutzdateien einspielen --- +# Das Archiv wird zunächst ausgepackt und dann normalisiert. Unterstützt werden +# alle Ablagen, die im Lauf der Zeit im Einsatz waren: +# belege/, logo/ aktuelle Struktur +# legacy-uploads/ gemeinsame Ablage vor der Trennung +# uploads/ Web-Root-Ablage vor Fix #116 +# In allen Fällen landet ein Unterordner logo/ in data/logo, alles Übrige in +# data/belege. +echo "[2/3] Belege und Logo entpacken..." +$COMPOSE exec -T web sh -c ' + set -e + STAGE=/tmp/restore-stage + rm -rf "$STAGE" && mkdir -p "$STAGE" + cat > /tmp/restore.tar.gz + tar -xzf /tmp/restore.tar.gz -C "$STAGE" + rm -f /tmp/restore.tar.gz + + BELEGE=/var/www/html/data/belege + LOGO=/var/www/html/data/logo + mkdir -p "$BELEGE" "$LOGO" + + RESTORED_B=0 + RESTORED_L=0 + + # Aktuelle Struktur + if [ -d "$STAGE/belege" ]; then + cp -a "$STAGE/belege/." "$BELEGE/" && RESTORED_B=1 + fi + if [ -d "$STAGE/logo" ]; then + cp -a "$STAGE/logo/." "$LOGO/" && RESTORED_L=1 + fi + + # Ältere Sammelablagen auf die neue Struktur verteilen + for old in legacy-uploads uploads; do + [ -d "$STAGE/$old" ] || continue + echo " Älteres Archivformat \"$old/\" erkannt – wird verteilt." + if [ -d "$STAGE/$old/logo" ]; then + cp -a "$STAGE/$old/logo/." "$LOGO/" && RESTORED_L=1 + rm -rf "$STAGE/$old/logo" + fi + if [ -n "$(ls -A "$STAGE/$old" 2>/dev/null)" ]; then + cp -a "$STAGE/$old/." "$BELEGE/" && RESTORED_B=1 + fi + done + + rm -rf "$STAGE" + chown -R www-data:www-data "$BELEGE" "$LOGO" + + echo " Belege: $(find "$BELEGE" -type f | wc -l) Datei(en), Logo: $(find "$LOGO" -type f | wc -l) Datei(en)" + if [ "$RESTORED_B" = "0" ] && [ "$RESTORED_L" = "0" ]; then + echo " WARNUNG: Das Archiv enthielt keine bekannten Verzeichnisse!" >&2 + fi +' < "$UPL_FILE" echo " OK" # --- 3. Migrationen --- diff --git a/scripts/update.sh b/scripts/update.sh index 5f45a0f..48ed885 100755 --- a/scripts/update.sh +++ b/scripts/update.sh @@ -82,7 +82,7 @@ echo -e "${BOLD}═════════════════════ # ════════════════════════════════════════════════════════════════════════════ # SCHRITT 1: Voraussetzungen prüfen # ════════════════════════════════════════════════════════════════════════════ -step "1/8 Voraussetzungen prüfen" +step "1/9 Voraussetzungen prüfen" if ! command -v docker &>/dev/null; then fail "Docker ist nicht installiert oder nicht im PATH." @@ -109,9 +109,47 @@ fi ok "Git-Repository gefunden" # ════════════════════════════════════════════════════════════════════════════ -# SCHRITT 2: Backup erstellen +# SCHRITT 2: Update-Skripte vorab aktualisieren # ════════════════════════════════════════════════════════════════════════════ -step "2/8 Backup erstellen" +# Alle folgenden Schritte werden von Skripten in scripts/ erledigt (allen voran +# backup.sh). Liefe hier noch die alte Fassung, würde ein Update mit den Mitteln +# des Vorgänger-Stands abgesichert – genau so entstand das leere Datei-Backup +# nach dem Umzug der Belege aus dem Web-Root. +# Deshalb: Ändert sich irgendetwas unter scripts/, wird zuerst gepullt und das +# Skript in der neuen Fassung neu gestartet – noch vor dem Backup. +step "2/9 Update-Skripte prüfen" + +if [[ "${STEUERKRAM_SELF_UPDATED:-0}" == "1" ]]; then + ok "Skripte sind auf dem neuesten Stand (Neustart bereits erfolgt)" +else + if git fetch --quiet 2>/dev/null && git rev-parse --abbrev-ref '@{u}' &>/dev/null; then + if git diff --quiet HEAD '@{u}' -- scripts/ 2>/dev/null; then + ok "Keine Änderungen an den Update-Skripten" + else + warn "Update-Skripte haben sich geändert – ziehe sie vorab und starte neu" + git diff --name-only HEAD '@{u}' -- scripts/ | while read -r line; do info " $line"; done + + # Ausgangs-Commit für die spätere Rebuild-Erkennung sichern, sonst + # sähe der Neustart keine Änderungen mehr und würde den Rebuild + # überspringen. + export STEUERKRAM_BEFORE_COMMIT="$(git rev-parse HEAD)" + + git pull --ff-only 2>&1 | while read -r line; do info "$line"; done + + export STEUERKRAM_SELF_UPDATED=1 + info "Starte update.sh in der aktualisierten Fassung neu..." + echo "" + exec "$SCRIPT_DIR/update.sh" "$@" + fi + else + warn "Kein Upstream-Branch erreichbar – überspringe Skript-Vorabprüfung" + fi +fi + +# ════════════════════════════════════════════════════════════════════════════ +# SCHRITT 3: Backup erstellen +# ════════════════════════════════════════════════════════════════════════════ +step "3/9 Backup erstellen" if [[ "$OPT_NO_BACKUP" == "true" ]]; then warn "Backup übersprungen (--no-backup)" @@ -132,12 +170,14 @@ else fi # ════════════════════════════════════════════════════════════════════════════ -# SCHRITT 3: git pull + Änderungen erkennen +# SCHRITT 4: git pull + Änderungen erkennen # ════════════════════════════════════════════════════════════════════════════ -step "3/8 Code aktualisieren (git pull)" +step "4/9 Code aktualisieren (git pull)" -BEFORE_COMMIT=$(git rev-parse HEAD) -BEFORE_SHORT=$(git rev-parse --short HEAD) +# Wurde in Schritt 2 bereits gepullt, gilt der dort gesicherte Ausgangsstand – +# sonst bliebe die Rebuild-Erkennung unten blind. +BEFORE_COMMIT="${STEUERKRAM_BEFORE_COMMIT:-$(git rev-parse HEAD)}" +BEFORE_SHORT=$(git rev-parse --short "$BEFORE_COMMIT") # Lokale Änderungen prüfen if ! git diff --quiet 2>/dev/null || ! git diff --cached --quiet 2>/dev/null; then @@ -159,12 +199,8 @@ else mapfile -t CHANGED_FILES < <(git diff --name-only "$BEFORE_COMMIT" "$AFTER_COMMIT" 2>/dev/null || true) info "Geänderte Dateien (${#CHANGED_FILES[@]}):" for f in "${CHANGED_FILES[@]}"; do info " $f"; done - - # Skript hat sich selbst geändert → neu starten damit die neue Logik läuft - if printf '%s\n' "${CHANGED_FILES[@]}" | grep -q "^scripts/update\.sh$"; then - warn "update.sh wurde aktualisiert – starte Skript neu..." - exec "$SCRIPT_DIR/update.sh" --no-backup "$@" - fi + # Ein Neustart wegen geänderter Skripte ist hier nicht mehr nötig – + # Schritt 2 hat scripts/ bereits vor dem Backup aktualisiert. fi # Asset-Version aktualisieren (Cache-Buster für CSS/JS – immer, vor Docker-Restart) @@ -202,9 +238,9 @@ if [[ "$BEFORE_COMMIT" != "$AFTER_COMMIT" ]]; then fi # ════════════════════════════════════════════════════════════════════════════ -# SCHRITT 4: Docker-Image bauen (wenn nötig) +# SCHRITT 5: Docker-Image bauen (wenn nötig) # ════════════════════════════════════════════════════════════════════════════ -step "4/8 Docker-Image" +step "5/9 Docker-Image" if [[ "$NEEDS_REBUILD" == "true" ]]; then info "Baue Docker-Image neu..." @@ -219,9 +255,9 @@ else fi # ════════════════════════════════════════════════════════════════════════════ -# SCHRITT 5: Container neu starten +# SCHRITT 6: Container neu starten # ════════════════════════════════════════════════════════════════════════════ -step "5/8 Container neu starten" +step "6/9 Container neu starten" info "Stoppe laufende Container..." docker compose down --remove-orphans 2>&1 | while read -r line; do info "$line"; done @@ -232,9 +268,9 @@ docker compose up -d 2>&1 | while read -r line; do info "$line"; done ok "Container gestartet" # ════════════════════════════════════════════════════════════════════════════ -# SCHRITT 6: Auf Betriebsbereitschaft warten +# SCHRITT 7: Auf Betriebsbereitschaft warten # ════════════════════════════════════════════════════════════════════════════ -step "6/8 Auf Betriebsbereitschaft warten" +step "7/9 Auf Betriebsbereitschaft warten" TIMEOUT=120 ELAPSED=0 @@ -285,9 +321,9 @@ else fi # ════════════════════════════════════════════════════════════════════════════ -# SCHRITT 7: Migrationen explizit ausführen +# SCHRITT 8: Migrationen explizit ausführen # ════════════════════════════════════════════════════════════════════════════ -step "7/8 Autoloader + Datenbank-Migrationen" +step "8/9 Autoloader + Datenbank-Migrationen" # Autoloader neu generieren: vendor/ liegt im Image (nicht gemountet), # neue Klassen in src/ wären sonst nach einem Pull ohne Rebuild unbekannt. @@ -315,9 +351,9 @@ if [[ "$UPDATE_FAILED" == "false" ]]; then fi # ════════════════════════════════════════════════════════════════════════════ -# SCHRITT 8: Tests ausführen +# SCHRITT 9: Tests ausführen # ════════════════════════════════════════════════════════════════════════════ -step "8/8 Tests" +step "9/9 Tests" if [[ "$OPT_NO_TESTS" == "true" ]]; then warn "Tests übersprungen (--no-tests)" diff --git a/src/Config.php b/src/Config.php index c43a6c6..37e57bb 100644 --- a/src/Config.php +++ b/src/Config.php @@ -17,7 +17,10 @@ class Config 'db_pass' => $_ENV['DB_PASS'] ?? getenv('DB_PASS') ?: 'steuerkram', 'app_key' => $_ENV['APP_KEY'] ?? getenv('APP_KEY') ?: '', 'sidebar_bg_color' => $_ENV['SIDEBAR_BG_COLOR'] ?? getenv('SIDEBAR_BG_COLOR') ?: '#1e293b', - 'upload_dir' => dirname(__DIR__) . '/data/uploads', + // Persistente Nutzdaten, jeweils eigenes Volume außerhalb des Web-Roots: + // data/belege → Belegdateien, data/logo → Firmenlogo, data/mariadb → Datenbank + 'upload_dir' => dirname(__DIR__) . '/data/belege', + 'logo_dir' => dirname(__DIR__) . '/data/logo', 'base_url' => '', ]; } diff --git a/src/Controllers/SettingsController.php b/src/Controllers/SettingsController.php index d9105b5..fc7f3ff 100644 --- a/src/Controllers/SettingsController.php +++ b/src/Controllers/SettingsController.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Steuerkram\Controllers; +use Steuerkram\Config; use Steuerkram\Models\BankSource; use Steuerkram\Models\Setting; use Steuerkram\Services\EmailService; @@ -92,26 +93,65 @@ class SettingsController extends BaseController return; } - $ext = ['image/png' => 'png', 'image/jpeg' => 'jpg'][$mime]; - $relPath = 'static/uploads/logo/logo.' . $ext; - $absDir = dirname(__DIR__, 2) . '/public/static/uploads/logo'; + $ext = ['image/png' => 'png', 'image/jpeg' => 'jpg'][$mime]; + $filename = 'logo.' . $ext; + $absDir = Config::get('logo_dir'); if (!is_dir($absDir) && !mkdir($absDir, 0755, true)) { - View::redirect('/settings', 'Upload-Verzeichnis konnte nicht erstellt werden.', 'error'); + View::redirect('/settings', 'Logo-Verzeichnis konnte nicht erstellt werden.', 'error'); return; } - $dest = dirname(__DIR__, 2) . '/public/' . $relPath; - if (!move_uploaded_file($file['tmp_name'], $dest)) { + // Formatwechsel (png <-> jpg) darf keine verwaiste Altdatei hinterlassen + $previous = Setting::logoAbsolutePath(); + + if (!move_uploaded_file($file['tmp_name'], $absDir . '/' . $filename)) { View::redirect('/settings', 'Datei konnte nicht gespeichert werden. Bitte Schreibrechte prüfen.', 'error'); return; } - Setting::setLogo($relPath); + if ($previous !== null && basename($previous) !== $filename && file_exists($previous)) { + unlink($previous); + } + + Setting::setLogo($filename); View::redirect('/settings', 'Logo hochgeladen.'); } + /** + * Liefert das Firmenlogo aus data/logo aus – die Datei liegt bewusst + * außerhalb des Web-Roots und ist daher nicht statisch abrufbar. + */ + public function serveLogo(array $params): void + { + $absPath = Setting::logoAbsolutePath(); + if ($absPath === null) { http_response_code(404); return; } + + $baseDir = realpath(Config::get('logo_dir')); + $realPath = realpath($absPath); + + if ($baseDir === false || $realPath === false || !str_starts_with($realPath, $baseDir)) { + http_response_code(404); + return; + } + + $finfo = finfo_open(FILEINFO_MIME_TYPE); + $mime = (string)finfo_file($finfo, $realPath); + finfo_close($finfo); + + if (!in_array($mime, ['image/png', 'image/jpeg'], true)) { + http_response_code(404); + return; + } + + header('Content-Type: ' . $mime); + header('Content-Length: ' . filesize($realPath)); + header('Cache-Control: private, max-age=300'); + readfile($realPath); + exit; + } + public function mailTest(array $params): void { header('Content-Type: application/json'); diff --git a/src/Models/Setting.php b/src/Models/Setting.php index 7f65e95..1d24851 100644 --- a/src/Models/Setting.php +++ b/src/Models/Setting.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Steuerkram\Models; +use Steuerkram\Config; use Steuerkram\Database; use Steuerkram\Services\Crypto; @@ -74,20 +75,20 @@ class Setting (int)($data['payment_days_default'] ?? 14), isset($data['test_mode']) ? 1 : 0, isset($data['ki_vorschlag_aktiv']) ? 1 : 0, - $data['nextcloud_url'] ?: null, - $data['nextcloud_user'] ?: null, + ($data['nextcloud_url'] ?? '') ?: null, + ($data['nextcloud_user'] ?? '') ?: null, !empty($data['nextcloud_pass']) ? Crypto::encrypt($data['nextcloud_pass']) : null, - $data['nextcloud_folder'] ?: '/Belege/Import', - $data['nextcloud_done'] ?: null, - $data['mail_smtp_host'] ?: '', + ($data['nextcloud_folder'] ?? '') ?: '/Belege/Import', + ($data['nextcloud_done'] ?? '') ?: null, + ($data['mail_smtp_host'] ?? '') ?: '', (int)($data['mail_smtp_port'] ?? 587), in_array($data['mail_smtp_encryption'] ?? '', ['tls', 'ssl', 'none']) ? $data['mail_smtp_encryption'] : 'tls', - $data['mail_smtp_user'] ?: '', + ($data['mail_smtp_user'] ?? '') ?: '', !empty($data['mail_smtp_pass']) ? Crypto::encrypt($data['mail_smtp_pass']) : '', - $data['mail_from_name'] ?: '', + ($data['mail_from_name'] ?? '') ?: '', self::sanitizeFromEmail($data['mail_from_email'] ?? ''), - $data['mail_subject_template'] ?: 'Rechnung %RNR%', - $data['mail_body_template'] ?: null, + ($data['mail_subject_template'] ?? '') ?: 'Rechnung %RNR%', + ($data['mail_body_template'] ?? '') ?: null, max(50, (int)($data['logo_max_width'] ?? 600)), max(20, (int)($data['logo_max_height'] ?? 100)), $logoPosition, @@ -109,17 +110,33 @@ class Setting return filter_var($value, FILTER_VALIDATE_EMAIL) !== false ? $value : ''; } - public static function setLogo(string $path): void + public static function setLogo(string $filename): void { - Database::execute('UPDATE company_settings SET logo_path=? WHERE id=1', [$path]); + Database::execute('UPDATE company_settings SET logo_path=? WHERE id=1', [$filename]); + } + + /** + * Absoluter Pfad zur Logodatei in data/logo, oder null wenn kein Logo gesetzt ist. + * + * Migration 089 bereinigt die historischen, webroot-relativen Präfixe + * ("static/uploads/logo/", "logo/"). Der Basename-Fallback hier greift nur, + * falls die Migration (noch) nicht gelaufen ist – ohne ihn wäre das Logo + * bis zum nächsten Container-Start unauffindbar. + */ + public static function logoAbsolutePath(): ?string + { + $stored = (string)(self::get()['logo_path'] ?? ''); + if ($stored === '') { + return null; + } + return Config::get('logo_dir') . '/' . basename($stored); } public static function deleteLogo(): void { - $settings = self::get(); - if (!empty($settings['logo_path'])) { - $fullPath = dirname(__DIR__, 2) . '/public/' . $settings['logo_path']; - if (file_exists($fullPath)) unlink($fullPath); + $fullPath = self::logoAbsolutePath(); + if ($fullPath !== null && file_exists($fullPath)) { + unlink($fullPath); } Database::execute('UPDATE company_settings SET logo_path=NULL WHERE id=1'); } diff --git a/src/Services/PdfInvoiceService.php b/src/Services/PdfInvoiceService.php index fc16e8a..f7fdc19 100644 --- a/src/Services/PdfInvoiceService.php +++ b/src/Services/PdfInvoiceService.php @@ -80,11 +80,12 @@ class PdfInvoiceService // Logo $logoHtml = ''; if (!empty($settings['logo_path'])) { - $logoPath = dirname(__DIR__, 2) . '/public/' . $settings['logo_path']; - if (file_exists($logoPath)) { + // Logo liegt außerhalb des Web-Roots (data/logo) -> mPDF den absoluten Dateipfad geben + $logoPath = \Steuerkram\Models\Setting::logoAbsolutePath(); + if ($logoPath !== null && file_exists($logoPath)) { $maxW = max(50, (int)($settings['logo_max_width'] ?? 600)); $maxH = max(20, (int)($settings['logo_max_height'] ?? 100)); - $logoHtml = 'Logo'; } } diff --git a/src/Views/invoices/show.php b/src/Views/invoices/show.php index d56d065..b6850be 100644 --- a/src/Views/invoices/show.php +++ b/src/Views/invoices/show.php @@ -102,7 +102,7 @@ $badgeClass = match($invoice['status']) { } ?>
- Logo
diff --git a/src/Views/settings/form.php b/src/Views/settings/form.php index 61ecfc0..50b8364 100644 --- a/src/Views/settings/form.php +++ b/src/Views/settings/form.php @@ -409,35 +409,30 @@
- Logo + Logo
- -
- - -
Empfohlen: transparentes PNG, mind. 300px breit
-
-
- - - - -
- - -
- +
+ + +
Empfohlen: transparentes PNG, mind. 300px breit
+
+
+ + + + +
Darstellung in der Rechnung
@@ -490,6 +485,13 @@
+ +
+ +
+ +
Beleg-Vorschau (Tooltip)
diff --git a/src/version.txt b/src/version.txt index 67d3a53..f994931 100644 --- a/src/version.txt +++ b/src/version.txt @@ -1 +1 @@ -1-260721 +1-260731 diff --git a/tests/70_SettingsLogoUploadTest.php b/tests/70_SettingsLogoUploadTest.php new file mode 100644 index 0000000..e968a7e --- /dev/null +++ b/tests/70_SettingsLogoUploadTest.php @@ -0,0 +1,117 @@ + 'Testfirma', + 'strasse' => 'Teststr. 1', + 'plz' => '12345', + 'ort' => 'Testort', + ]); + } finally { + restore_error_handler(); + } + + $this->assert( + $warnings === [], + 'Setting::update() ohne optionale Keys erzeugt keine Warnings (erhalten: ' . implode(' | ', $warnings) . ')' + ); + + $stored = Setting::get(); + $this->assertEquals('Rechnung %RNR%', $stored['mail_subject_template'], 'Fehlender Betreff fällt auf den Default zurück'); + $this->assertEquals('Testfirma', $stored['firma'], 'Firma wurde gespeichert'); + + // 2) Logo liegt in data/logo, die DB speichert nur den Dateinamen. + // Bewusst ein eigener Testdateiname – deleteLogo() löscht die Datei + // physisch, ein echtes logo.png darf davon nie getroffen werden. + $testFile = 'logo-test-' . getmypid() . '.png'; + $testPath = Config::get('logo_dir') . '/' . $testFile; + + Setting::setLogo($testFile); + $this->assertEquals($testFile, Setting::get()['logo_path'], 'setLogo() speichert nur den Dateinamen'); + + $abs = Setting::logoAbsolutePath(); + $this->assertEquals($testPath, $abs, 'logoAbsolutePath() zeigt in das Logo-Datenverzeichnis'); + $this->assert( + !str_contains((string)$abs, '/public/'), + 'Logo liegt außerhalb des Web-Roots' + ); + + // Altbestände mit webroot-relativem Präfix bleiben auffindbar, falls + // Migration 089 noch nicht gelaufen ist + Setting::setLogo('static/uploads/logo/' . $testFile); + $this->assertEquals( + $testPath, + Setting::logoAbsolutePath(), + 'Legacy-Pfad wird auf das Logo-Verzeichnis abgebildet' + ); + + // deleteLogo() entfernt Datei und DB-Eintrag + if (!is_dir((string)Config::get('logo_dir'))) { + mkdir((string)Config::get('logo_dir'), 0755, true); + } + file_put_contents($testPath, 'dummy'); + Setting::setLogo($testFile); + Setting::deleteLogo(); + + $this->assert(!file_exists($testPath), 'deleteLogo() entfernt die Datei aus data/logo'); + $this->assert(empty(Setting::get()['logo_path']), 'deleteLogo() entfernt den Pfad'); + $this->assert(Setting::logoAbsolutePath() === null, 'Ohne Logo liefert logoAbsolutePath() null'); + + // 3) Belege und Logo liegen getrennt und beide außerhalb von public/ + $uploadDir = (string)Config::get('upload_dir'); + $logoDir = (string)Config::get('logo_dir'); + $this->assert(!str_contains($uploadDir, '/public/'), 'Belegverzeichnis liegt außerhalb des Web-Roots'); + $this->assert($uploadDir !== $logoDir, 'Belege und Logo haben getrennte Verzeichnisse'); + $this->assert(str_ends_with($uploadDir, '/data/belege'), 'Belege liegen in data/belege'); + $this->assert(str_ends_with($logoDir, '/data/logo'), 'Logo liegt in data/logo'); + + // 3) Das Logo-Upload-Formular darf nicht in #settings-form verschachtelt sein – + // sonst verwirft der Browser das innere
und der Hochladen-Button + // postet an /settings statt an /settings/logo. + $view = file_get_contents(dirname(__DIR__) . '/src/Views/settings/form.php'); + $this->assertNotEmpty($view, 'settings/form.php ist lesbar'); + + $settingsFormStart = strpos($view, '', (int)$settingsFormStart); + $this->assert($settingsFormStart !== false && $settingsFormEnd !== false, '#settings-form ist im View vorhanden'); + + $inner = substr($view, (int)$settingsFormStart + 1, (int)$settingsFormEnd - (int)$settingsFormStart); + $this->assert( + !str_contains($inner, '-Element' + ); + $this->assert( + str_contains($view, 'assert( + str_contains($view, 'name="logo" accept=".png,.jpg,.jpeg" form="logo-upload-form"'), + 'Datei-Input ist per form-Attribut an #logo-upload-form gebunden' + ); + } +} diff --git a/tests/71_DatenverzeichnisPfadeTest.php b/tests/71_DatenverzeichnisPfadeTest.php new file mode 100644 index 0000000..96ac417 --- /dev/null +++ b/tests/71_DatenverzeichnisPfadeTest.php @@ -0,0 +1,123 @@ +assert( + str_ends_with((string)Config::get('upload_dir'), '/' . $belegeRel), + 'Config::upload_dir zeigt auf ' . $belegeRel + ); + $this->assert( + str_ends_with((string)Config::get('logo_dir'), '/' . $logoRel), + 'Config::logo_dir zeigt auf ' . $logoRel + ); + + // backup.sh muss beide Verzeichnisse sichern und darf nicht mehr den + // alten Web-Root-Pfad verwenden + $backup = (string)file_get_contents($root . '/scripts/backup.sh'); + $this->assertNotEmpty($backup, 'scripts/backup.sh ist lesbar'); + $this->assert( + str_contains($backup, 'cd /var/www/html/data') + && str_contains($backup, 'for d in belege logo legacy-uploads uploads'), + 'backup.sh sichert data/belege und data/logo samt Altablagen' + ); + $this->assert( + !str_contains($backup, '-C /var/www/html/public/static uploads/'), + 'backup.sh sichert nicht mehr fest aus dem Web-Root' + ); + $this->assert( + str_contains($backup, 'WARNUNG: Das Datei-Backup enthält keine einzige Datei'), + 'backup.sh warnt bei einem leeren Archiv' + ); + + // restore.sh muss beide Archivformate bedienen können + $restore = (string)file_get_contents($root . '/scripts/restore.sh'); + $this->assertNotEmpty($restore, 'scripts/restore.sh ist lesbar'); + $this->assert( + str_contains($restore, '/var/www/html/data/belege') && str_contains($restore, '/var/www/html/data/logo'), + 'restore.sh stellt Belege und Logo wieder her' + ); + $this->assert( + str_contains($restore, 'for old in legacy-uploads uploads'), + 'restore.sh verteilt alte Archive im uploads/-Format' + ); + $this->assert( + !str_contains($restore, '-C /var/www/html/public/static/'), + 'restore.sh entpackt nicht mehr in den Web-Root' + ); + + // docker-compose.yml muss die Volumes bereitstellen + $compose = (string)file_get_contents($root . '/docker-compose.yml'); + $this->assert( + str_contains($compose, './data/belege:/var/www/html/data/belege'), + 'docker-compose.yml mountet data/belege' + ); + $this->assert( + str_contains($compose, './data/logo:/var/www/html/data/logo'), + 'docker-compose.yml mountet data/logo' + ); + $this->assert( + !str_contains($compose, './data:/var/www/html/data'), + 'Das gesamte data/ (inkl. mariadb) wird nicht mehr in den web-Container gemountet' + ); + + // Der Entrypoint muss Altbestände selbsttätig übernehmen, damit ein + // reines "git pull + docker compose up" keine Installation zerlegt + $entrypoint = (string)file_get_contents($root . '/docker-entrypoint.sh'); + $this->assert( + str_contains($entrypoint, 'legacy-uploads'), + 'Entrypoint übernimmt Altbestände aus der gemeinsamen uploads-Ablage' + ); + $this->assert( + str_contains($entrypoint, 'public/static/uploads'), + 'Entrypoint übernimmt auch ein Logo aus dem alten Web-Root' + ); + $this->assert( + str_contains($compose, './uploads:/var/www/html/data/legacy-uploads'), + 'docker-compose.yml stellt dem Entrypoint die alte Ablage bereit' + ); + + // update.sh muss scripts/ aktualisieren, BEVOR backup.sh benutzt wird – + // sonst sichert ein Update mit den Mitteln des Vorgängerstands. + $update = (string)file_get_contents($root . '/scripts/update.sh'); + $this->assertNotEmpty($update, 'scripts/update.sh ist lesbar'); + + $posSelfUpdate = strpos($update, 'STEUERKRAM_SELF_UPDATED'); + $posBackupCall = strpos($update, 'scripts/backup.sh'); + $this->assert($posSelfUpdate !== false, 'update.sh aktualisiert die Skripte vorab'); + $this->assert( + $posSelfUpdate !== false && $posBackupCall !== false && $posSelfUpdate < $posBackupCall, + 'Die Skript-Vorabaktualisierung steht vor dem Backup-Aufruf' + ); + $this->assert( + str_contains($update, 'STEUERKRAM_BEFORE_COMMIT'), + 'update.sh reicht den Ausgangs-Commit weiter, damit die Rebuild-Erkennung greift' + ); + + // .gitignore darf die Nutzdaten nicht ins Repo lassen, aber die README behalten + $gitignore = (string)file_get_contents($root . '/.gitignore'); + $this->assert(str_contains($gitignore, 'data/*'), '.gitignore schließt data/* aus'); + $this->assert(str_contains($gitignore, '!data/README.md'), '.gitignore behält data/README.md'); + } +}