Fix #150: Logo-Upload repariert, Nutzdaten nach data/ getrennt, Backups vollständig

This commit is contained in:
fds2610-bot 2026-07-31 16:22:09 +02:00
parent acd32a76a5
commit 54fa6dee58
25 changed files with 784 additions and 102 deletions

6
.gitignore vendored
View file

@ -1,6 +1,8 @@
vendor/ vendor/
data/mariadb/ # Persistente Nutzdaten (DB, Belege, Logo) – nie ins Repo, siehe data/README.md.
uploads/ # data/README.md selbst ist per Negation unten wieder eingeschlossen.
data/*
!data/README.md
backups/ backups/
.env .env
*.log *.log

View file

@ -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 `<form>` innerhalb von `#settings-form`. HTML erlaubt keine verschachtelten Formulare – der Browser verwirft das innere Start-Tag, wodurch das erste `</form>` 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 `<form>` mehr steht.
## 2026-07-30 – Fix: SMTP-Header-/Kommando-Injection über mail_from_email (Issue #148) ## 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). - `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).

View file

@ -37,7 +37,7 @@ COPY apache/steuerkram.conf /etc/apache2/sites-available/000-default.conf
RUN a2enmod rewrite headers RUN a2enmod rewrite headers
# Permissions # 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 \ && chown -R www-data:www-data /var/www/html \
&& chmod -R 755 /var/www/html/data && chmod -R 755 /var/www/html/data

View file

@ -188,12 +188,20 @@ Das Skript gibt beim Start eine Zusammenfassung aus (importiert / übersprungen
## Datenpersistenz ## 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 | | Pfad | Inhalt |
|---|---| |---|---|
| `./data/mariadb/` | MariaDB-Datenbankdateien | | `./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 ## Systemd-Service einrichten

41
data/README.md Normal file
View file

@ -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.

View file

@ -7,8 +7,14 @@ services:
- ./public:/var/www/html/public - ./public:/var/www/html/public
- ./src:/var/www/html/src - ./src:/var/www/html/src
- ./migrations:/var/www/html/migrations - ./migrations:/var/www/html/migrations
- ./uploads:/var/www/html/data/uploads # Persistente Nutzdaten (siehe data/README.md) – bewusst je ein eigenes Volume,
- ./data:/var/www/html/data # 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 - ./tests:/var/www/html/tests
- ./scripts:/var/www/html/scripts - ./scripts:/var/www/html/scripts
environment: environment:

View file

@ -1,6 +1,54 @@
#!/bin/sh #!/bin/sh
# Fix ownership of volume-mounted upload directory at container start DATA=/var/www/html/data
chown -R www-data:www-data /var/www/html/data/uploads 2>/dev/null || true 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 # Datenbank-Migrationen automatisch einspielen
php /var/www/html/scripts/migrate.php php /var/www/html/scripts/migrate.php

View file

@ -69,7 +69,7 @@ MariaDB 11, Engine InnoDB, Zeichensatz `utf8mb4_unicode_ci`. Das Schema wird dur
| `steuernummer` | VARCHAR(30) | Steuernummer beim Finanzamt | | `steuernummer` | VARCHAR(30) | Steuernummer beim Finanzamt |
| `finanzamt` | VARCHAR(255) | Zuständiges Finanzamt | | `finanzamt` | VARCHAR(255) | Zuständiges Finanzamt |
| `iban`, `bic`, `bank_name` | VARCHAR | Bankverbindung | | `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_steuernummer` | VARCHAR(20) | ELSTER-Format: `FFFBBBBBBUUUUP` |
| `ustva_bundesland` | VARCHAR(2) | Bundesland-Kürzel (z.B. `BY`) | | `ustva_bundesland` | VARCHAR(2) | Bundesland-Kürzel (z.B. `BY`) |
| `invoice_nr_prefix` | VARCHAR(10) | Präfix Rechnungsnummer (Standard: `AR`) | | `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 | | | `id` | INT UNSIGNED PK | |
| `beleg_nr` | VARCHAR(30) UNIQUE | Automatisch vergeben (z.B. `BLG-2026-0001`) | | `beleg_nr` | VARCHAR(30) UNIQUE | Automatisch vergeben (z.B. `BLG-2026-0001`) |
| `filename` | VARCHAR(500) | Originaldateiname | | `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 | | `mime_type` | VARCHAR(100) | MIME-Typ der Datei |
| `file_hash` | VARCHAR(64) | SHA-256 für GoBD-Integritätsprüfung | | `file_hash` | VARCHAR(64) | SHA-256 für GoBD-Integritätsprüfung |
| `beleg_datum` | DATE | Datum auf dem Dokument | | `beleg_datum` | DATE | Datum auf dem Dokument |

View file

@ -28,9 +28,20 @@ docker compose exec db mariadb-dump -u steuerkram -psteuerkram steuerkram \
| Pfad | Inhalt | | Pfad | Inhalt |
|---|---| |---|---|
| `./data/mariadb/` | MariaDB-Datenbankdateien | | `./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.
--- ---

View file

@ -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/%';

View file

@ -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->get('/settings', fn($p) => (new SettingsController())->show($p));
$router->post('/settings', fn($p) => (new SettingsController())->update($p)); $router->post('/settings', fn($p) => (new SettingsController())->update($p));
$router->post('/settings/mail-test', fn($p) => (new SettingsController())->mailTest($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', fn($p) => (new SettingsController())->uploadLogo($p));
$router->post('/settings/logo/delete', fn($p) => (new SettingsController())->deleteLogo($p)); $router->post('/settings/logo/delete', fn($p) => (new SettingsController())->deleteLogo($p));
$router->post('/settings/generate-app-key', fn($p) => (new SettingsController())->generateAppKey($p)); $router->post('/settings/generate-app-key', fn($p) => (new SettingsController())->generateAppKey($p));

View file

@ -41,12 +41,57 @@ docker compose -f "$PROJECT_DIR/docker-compose.yml" \
--triggers \ --triggers \
steuerkram > "$BACKUP_DIR/steuerkram_db_$TIMESTAMP.sql" 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" \ docker compose -f "$PROJECT_DIR/docker-compose.yml" \
--project-directory "$PROJECT_DIR" \ --project-directory "$PROJECT_DIR" \
exec -T web \ exec -T web sh -c '
tar -czf - -C /var/www/html/public/static uploads/ \ cd /var/www/html/data || exit 1
> "$BACKUP_DIR/steuerkram_uploads_$TIMESTAMP.tar.gz" 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 " DB: $BACKUP_DIR/steuerkram_db_$TIMESTAMP.sql"
echo " Uploads: $BACKUP_DIR/steuerkram_uploads_$TIMESTAMP.tar.gz" echo " Uploads: $BACKUP_DIR/steuerkram_uploads_$TIMESTAMP.tar.gz"

View file

@ -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."

View file

@ -1,4 +1,9 @@
#!/bin/bash #!/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) # 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. # Vor dem ersten "docker compose up" mit dem neuen docker-compose.yml ausführen.
# #

View file

@ -77,13 +77,59 @@ echo "[1/3] Datenbank importieren..."
$COMPOSE exec -T db mariadb -u root -prootpass steuerkram < "$DB_FILE" $COMPOSE exec -T db mariadb -u root -prootpass steuerkram < "$DB_FILE"
echo " OK" echo " OK"
# --- 2. Uploads einspielen --- # --- 2. Nutzdateien einspielen ---
echo "[2/3] Uploads entpacken..." # Das Archiv wird zunächst ausgepackt und dann normalisiert. Unterstützt werden
$COMPOSE exec -T web sh -c " # alle Ablagen, die im Lauf der Zeit im Einsatz waren:
cat > /tmp/uploads_restore.tar.gz # belege/, logo/ aktuelle Struktur
tar -xzf /tmp/uploads_restore.tar.gz -C /var/www/html/public/static/ # legacy-uploads/ gemeinsame Ablage vor der Trennung
rm /tmp/uploads_restore.tar.gz # uploads/ Web-Root-Ablage vor Fix #116
" < "$UPL_FILE" # 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" echo " OK"
# --- 3. Migrationen --- # --- 3. Migrationen ---

View file

@ -82,7 +82,7 @@ echo -e "${BOLD}═════════════════════
# ════════════════════════════════════════════════════════════════════════════ # ════════════════════════════════════════════════════════════════════════════
# SCHRITT 1: Voraussetzungen prüfen # SCHRITT 1: Voraussetzungen prüfen
# ════════════════════════════════════════════════════════════════════════════ # ════════════════════════════════════════════════════════════════════════════
step "1/8 Voraussetzungen prüfen" step "1/9 Voraussetzungen prüfen"
if ! command -v docker &>/dev/null; then if ! command -v docker &>/dev/null; then
fail "Docker ist nicht installiert oder nicht im PATH." fail "Docker ist nicht installiert oder nicht im PATH."
@ -109,9 +109,47 @@ fi
ok "Git-Repository gefunden" 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 if [[ "$OPT_NO_BACKUP" == "true" ]]; then
warn "Backup übersprungen (--no-backup)" warn "Backup übersprungen (--no-backup)"
@ -132,12 +170,14 @@ else
fi 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) # Wurde in Schritt 2 bereits gepullt, gilt der dort gesicherte Ausgangsstand –
BEFORE_SHORT=$(git rev-parse --short HEAD) # 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 # Lokale Änderungen prüfen
if ! git diff --quiet 2>/dev/null || ! git diff --cached --quiet 2>/dev/null; then 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) mapfile -t CHANGED_FILES < <(git diff --name-only "$BEFORE_COMMIT" "$AFTER_COMMIT" 2>/dev/null || true)
info "Geänderte Dateien (${#CHANGED_FILES[@]}):" info "Geänderte Dateien (${#CHANGED_FILES[@]}):"
for f in "${CHANGED_FILES[@]}"; do info " $f"; done for f in "${CHANGED_FILES[@]}"; do info " $f"; done
# Ein Neustart wegen geänderter Skripte ist hier nicht mehr nötig –
# Skript hat sich selbst geändert → neu starten damit die neue Logik läuft # Schritt 2 hat scripts/ bereits vor dem Backup aktualisiert.
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
fi fi
# Asset-Version aktualisieren (Cache-Buster für CSS/JS – immer, vor Docker-Restart) # Asset-Version aktualisieren (Cache-Buster für CSS/JS – immer, vor Docker-Restart)
@ -202,9 +238,9 @@ if [[ "$BEFORE_COMMIT" != "$AFTER_COMMIT" ]]; then
fi 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 if [[ "$NEEDS_REBUILD" == "true" ]]; then
info "Baue Docker-Image neu..." info "Baue Docker-Image neu..."
@ -219,9 +255,9 @@ else
fi 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..." info "Stoppe laufende Container..."
docker compose down --remove-orphans 2>&1 | while read -r line; do info "$line"; done 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" 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 TIMEOUT=120
ELAPSED=0 ELAPSED=0
@ -285,9 +321,9 @@ else
fi 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), # Autoloader neu generieren: vendor/ liegt im Image (nicht gemountet),
# neue Klassen in src/ wären sonst nach einem Pull ohne Rebuild unbekannt. # neue Klassen in src/ wären sonst nach einem Pull ohne Rebuild unbekannt.
@ -315,9 +351,9 @@ if [[ "$UPDATE_FAILED" == "false" ]]; then
fi 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 if [[ "$OPT_NO_TESTS" == "true" ]]; then
warn "Tests übersprungen (--no-tests)" warn "Tests übersprungen (--no-tests)"

View file

@ -17,7 +17,10 @@ class Config
'db_pass' => $_ENV['DB_PASS'] ?? getenv('DB_PASS') ?: 'steuerkram', 'db_pass' => $_ENV['DB_PASS'] ?? getenv('DB_PASS') ?: 'steuerkram',
'app_key' => $_ENV['APP_KEY'] ?? getenv('APP_KEY') ?: '', 'app_key' => $_ENV['APP_KEY'] ?? getenv('APP_KEY') ?: '',
'sidebar_bg_color' => $_ENV['SIDEBAR_BG_COLOR'] ?? getenv('SIDEBAR_BG_COLOR') ?: '#1e293b', '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' => '', 'base_url' => '',
]; ];
} }

View file

@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Steuerkram\Controllers; namespace Steuerkram\Controllers;
use Steuerkram\Config;
use Steuerkram\Models\BankSource; use Steuerkram\Models\BankSource;
use Steuerkram\Models\Setting; use Steuerkram\Models\Setting;
use Steuerkram\Services\EmailService; use Steuerkram\Services\EmailService;
@ -93,25 +94,64 @@ class SettingsController extends BaseController
} }
$ext = ['image/png' => 'png', 'image/jpeg' => 'jpg'][$mime]; $ext = ['image/png' => 'png', 'image/jpeg' => 'jpg'][$mime];
$relPath = 'static/uploads/logo/logo.' . $ext; $filename = 'logo.' . $ext;
$absDir = dirname(__DIR__, 2) . '/public/static/uploads/logo'; $absDir = Config::get('logo_dir');
if (!is_dir($absDir) && !mkdir($absDir, 0755, true)) { 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; return;
} }
$dest = dirname(__DIR__, 2) . '/public/' . $relPath; // Formatwechsel (png <-> jpg) darf keine verwaiste Altdatei hinterlassen
if (!move_uploaded_file($file['tmp_name'], $dest)) { $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'); View::redirect('/settings', 'Datei konnte nicht gespeichert werden. Bitte Schreibrechte prüfen.', 'error');
return; return;
} }
Setting::setLogo($relPath); if ($previous !== null && basename($previous) !== $filename && file_exists($previous)) {
unlink($previous);
}
Setting::setLogo($filename);
View::redirect('/settings', 'Logo hochgeladen.'); 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 public function mailTest(array $params): void
{ {
header('Content-Type: application/json'); header('Content-Type: application/json');

View file

@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Steuerkram\Models; namespace Steuerkram\Models;
use Steuerkram\Config;
use Steuerkram\Database; use Steuerkram\Database;
use Steuerkram\Services\Crypto; use Steuerkram\Services\Crypto;
@ -74,20 +75,20 @@ class Setting
(int)($data['payment_days_default'] ?? 14), (int)($data['payment_days_default'] ?? 14),
isset($data['test_mode']) ? 1 : 0, isset($data['test_mode']) ? 1 : 0,
isset($data['ki_vorschlag_aktiv']) ? 1 : 0, isset($data['ki_vorschlag_aktiv']) ? 1 : 0,
$data['nextcloud_url'] ?: null, ($data['nextcloud_url'] ?? '') ?: null,
$data['nextcloud_user'] ?: null, ($data['nextcloud_user'] ?? '') ?: null,
!empty($data['nextcloud_pass']) ? Crypto::encrypt($data['nextcloud_pass']) : null, !empty($data['nextcloud_pass']) ? Crypto::encrypt($data['nextcloud_pass']) : null,
$data['nextcloud_folder'] ?: '/Belege/Import', ($data['nextcloud_folder'] ?? '') ?: '/Belege/Import',
$data['nextcloud_done'] ?: null, ($data['nextcloud_done'] ?? '') ?: null,
$data['mail_smtp_host'] ?: '', ($data['mail_smtp_host'] ?? '') ?: '',
(int)($data['mail_smtp_port'] ?? 587), (int)($data['mail_smtp_port'] ?? 587),
in_array($data['mail_smtp_encryption'] ?? '', ['tls', 'ssl', 'none']) ? $data['mail_smtp_encryption'] : 'tls', 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']) : '', !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'] ?? ''), self::sanitizeFromEmail($data['mail_from_email'] ?? ''),
$data['mail_subject_template'] ?: 'Rechnung %RNR%', ($data['mail_subject_template'] ?? '') ?: 'Rechnung %RNR%',
$data['mail_body_template'] ?: null, ($data['mail_body_template'] ?? '') ?: null,
max(50, (int)($data['logo_max_width'] ?? 600)), max(50, (int)($data['logo_max_width'] ?? 600)),
max(20, (int)($data['logo_max_height'] ?? 100)), max(20, (int)($data['logo_max_height'] ?? 100)),
$logoPosition, $logoPosition,
@ -109,17 +110,33 @@ class Setting
return filter_var($value, FILTER_VALIDATE_EMAIL) !== false ? $value : ''; 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 public static function deleteLogo(): void
{ {
$settings = self::get(); $fullPath = self::logoAbsolutePath();
if (!empty($settings['logo_path'])) { if ($fullPath !== null && file_exists($fullPath)) {
$fullPath = dirname(__DIR__, 2) . '/public/' . $settings['logo_path']; unlink($fullPath);
if (file_exists($fullPath)) unlink($fullPath);
} }
Database::execute('UPDATE company_settings SET logo_path=NULL WHERE id=1'); Database::execute('UPDATE company_settings SET logo_path=NULL WHERE id=1');
} }

View file

@ -80,11 +80,12 @@ class PdfInvoiceService
// Logo // Logo
$logoHtml = ''; $logoHtml = '';
if (!empty($settings['logo_path'])) { if (!empty($settings['logo_path'])) {
$logoPath = dirname(__DIR__, 2) . '/public/' . $settings['logo_path']; // Logo liegt außerhalb des Web-Roots (data/logo) -> mPDF den absoluten Dateipfad geben
if (file_exists($logoPath)) { $logoPath = \Steuerkram\Models\Setting::logoAbsolutePath();
if ($logoPath !== null && file_exists($logoPath)) {
$maxW = max(50, (int)($settings['logo_max_width'] ?? 600)); $maxW = max(50, (int)($settings['logo_max_width'] ?? 600));
$maxH = max(20, (int)($settings['logo_max_height'] ?? 100)); $maxH = max(20, (int)($settings['logo_max_height'] ?? 100));
$logoHtml = '<img src="' . $h($settings['logo_path']) . '" alt="Logo"' $logoHtml = '<img src="' . $h($logoPath) . '" alt="Logo"'
. ' style="max-width:' . $maxW . 'px;max-height:' . $maxH . 'px">'; . ' style="max-width:' . $maxW . 'px;max-height:' . $maxH . 'px">';
} }
} }

View file

@ -102,7 +102,7 @@ $badgeClass = match($invoice['status']) {
} }
?> ?>
<div style="<?= $logoWrapStyle ?>"> <div style="<?= $logoWrapStyle ?>">
<img src="/<?= $h($settings['logo_path']) ?>" alt="Logo" <img src="/settings/logo/file" alt="Logo"
style="max-width:<?= $logoMaxW ?>px;max-height:<?= $logoMaxH ?>px"> style="max-width:<?= $logoMaxW ?>px;max-height:<?= $logoMaxH ?>px">
</div> </div>
<?php endif; ?> <?php endif; ?>

View file

@ -409,17 +409,16 @@
<div class="card-body"> <div class="card-body">
<?php if (!empty($settings['logo_path'])): ?> <?php if (!empty($settings['logo_path'])): ?>
<div style="text-align:center;margin-bottom:1rem"> <div style="text-align:center;margin-bottom:1rem">
<img src="/<?= $h($settings['logo_path']) ?>" alt="Logo" style="max-height:80px;max-width:400px"> <img src="/settings/logo/file" alt="Logo" style="max-height:80px;max-width:400px">
</div> </div>
<?php endif; ?> <?php endif; ?>
<form method="post" action="/settings/logo" enctype="multipart/form-data">
<div class="form-group"> <div class="form-group">
<label>Logo hochladen (PNG, JPG)</label> <label>Logo hochladen (PNG, JPG)</label>
<input type="file" name="logo" accept=".png,.jpg,.jpeg"> <input type="file" name="logo" accept=".png,.jpg,.jpeg" form="logo-upload-form">
<div class="form-hint">Empfohlen: transparentes PNG, mind. 300px breit</div> <div class="form-hint">Empfohlen: transparentes PNG, mind. 300px breit</div>
</div> </div>
<div style="display:flex;gap:.5rem;flex-wrap:wrap"> <div style="display:flex;gap:.5rem;flex-wrap:wrap">
<button type="submit" class="btn btn-secondary">Hochladen</button> <button type="submit" class="btn btn-secondary" form="logo-upload-form">Hochladen</button>
<?php if (!empty($settings['logo_path'])): ?> <?php if (!empty($settings['logo_path'])): ?>
<button type="submit" form="delete-logo-form" <button type="submit" form="delete-logo-form"
style="background:none;border:none;cursor:pointer;color:var(--color-danger);padding:.2rem;line-height:1;display:inline-flex;align-items:center" style="background:none;border:none;cursor:pointer;color:var(--color-danger);padding:.2rem;line-height:1;display:inline-flex;align-items:center"
@ -434,10 +433,6 @@
</button> </button>
<?php endif; ?> <?php endif; ?>
</div> </div>
</form>
<?php if (!empty($settings['logo_path'])): ?>
<form id="delete-logo-form" method="post" action="/settings/logo/delete"></form>
<?php endif; ?>
<div style="margin-top:1rem;border-top:1px solid var(--color-border,#e5e7eb);padding-top:1rem"> <div style="margin-top:1rem;border-top:1px solid var(--color-border,#e5e7eb);padding-top:1rem">
<div class="form-hint" style="margin-bottom:.75rem">Darstellung in der Rechnung</div> <div class="form-hint" style="margin-bottom:.75rem">Darstellung in der Rechnung</div>
@ -490,6 +485,13 @@
</div> </div>
</form> </form>
<!-- Logo-Formulare: müssen außerhalb von #settings-form stehen, HTML erlaubt keine
verschachtelten <form>-Elemente. Die Bedienelemente oben verweisen per form="…" hierher. -->
<form id="logo-upload-form" method="post" action="/settings/logo" enctype="multipart/form-data"></form>
<?php if (!empty($settings['logo_path'])): ?>
<form id="delete-logo-form" method="post" action="/settings/logo/delete"></form>
<?php endif; ?>
<!-- Beleg-Vorschau --> <!-- Beleg-Vorschau -->
<div class="card mb-2"> <div class="card mb-2">
<div class="card-header"><div class="card-title">Beleg-Vorschau (Tooltip)</div></div> <div class="card-header"><div class="card-title">Beleg-Vorschau (Tooltip)</div></div>

View file

@ -1 +1 @@
1-260721 1-260731

View file

@ -0,0 +1,117 @@
<?php
declare(strict_types=1);
use Steuerkram\Config;
use Steuerkram\Models\Setting;
/**
* Test 70: Logo-Upload – Formular-Struktur, warnungsfreies Speichern und
* Ablage in data/logo außerhalb des Web-Roots.
*/
class SettingsLogoUploadTest extends TestCase
{
protected string $testName = 'Test 70: Logo-Upload – keine Warnings, Formulare nicht verschachtelt, Ablage in data/logo';
public function run(): void
{
// 1) Setting::update() darf mit einem Datensatz ohne die optionalen Keys
// (z.B. mail_subject_template, seit "E-Mail-Texte pro Kunde" nicht mehr im
// Einstellungsformular) keine Warning erzeugen – sonst geht Output raus,
// bevor View::redirect() den Location-Header setzen kann.
$warnings = [];
set_error_handler(function (int $no, string $msg) use (&$warnings): bool {
$warnings[] = $msg;
return true;
}, E_ALL);
try {
Setting::update([
'firma' => '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 <form> 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, '<form id="settings-form"');
$settingsFormEnd = strpos($view, '</form>', (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, '<form '),
'Innerhalb von #settings-form steht kein weiteres <form>-Element'
);
$this->assert(
str_contains($view, '<form id="logo-upload-form"') && str_contains($view, 'action="/settings/logo"'),
'Eigenständiges #logo-upload-form mit action="/settings/logo" vorhanden'
);
$this->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'
);
}
}

View file

@ -0,0 +1,123 @@
<?php
declare(strict_types=1);
use Steuerkram\Config;
/**
* Test 71: Die Nutzdaten-Pfade in Backup, Restore, Compose-Datei und Entrypoint
* müssen zu Config::upload_dir/logo_dir passen.
*
* Hintergrund: Nach Fix #116 (Umzug aus dem Web-Root) zeigte scripts/backup.sh
* weiter auf public/static/uploads. Das Backup lief dadurch monatelang leer
* (117-Byte-Archive), ohne dass es auffiel. Dieser Test hält die Pfade zusammen.
*/
class DatenverzeichnisPfadeTest extends TestCase
{
protected string $testName = 'Test 71: Datenverzeichnis – Backup-, Restore- und Container-Pfade konsistent';
public function run(): void
{
$root = dirname(__DIR__);
$belegeRel = 'data/belege';
$logoRel = 'data/logo';
// Config muss auf die erwarteten Verzeichnisse zeigen
$this->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');
}
}