Merge pull request 'Fix #150: Logo-Upload repariert, Nutzdaten nach data/ getrennt, Backups vollständig' (#151) from fds2610-bot/Steuerkram:logo-datenverzeichnis into main
Reviewed-on: https://codeberg.org/fds2610/Steuerkram/pulls/151 zum einspielen bitte vor update.sh einmal folgendes ausführen: tar czf ~/steuerkram-dateien-vor-update.tar.gz uploads/ dann erst: ./scripts/update.sh
This commit is contained in:
commit
42f7aeb952
25 changed files with 784 additions and 102 deletions
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -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
|
||||||
|
|
|
||||||
28
Changes.md
28
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 `<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).
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
12
README.md
12
README.md
|
|
@ -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
41
data/README.md
Normal 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.
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 |
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
22
migrations/089_pfade_ohne_webroot_praefix.sql
Normal file
22
migrations/089_pfade_ohne_webroot_praefix.sql
Normal 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/%';
|
||||||
|
|
@ -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));
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
80
scripts/migrate-datenverzeichnis.sh
Executable file
80
scripts/migrate-datenverzeichnis.sh
Executable 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."
|
||||||
|
|
@ -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.
|
||||||
#
|
#
|
||||||
|
|
|
||||||
|
|
@ -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 ---
|
||||||
|
|
|
||||||
|
|
@ -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)"
|
||||||
|
|
|
||||||
|
|
@ -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' => '',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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">';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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; ?>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
1-260721
|
1-260731
|
||||||
|
|
|
||||||
117
tests/70_SettingsLogoUploadTest.php
Normal file
117
tests/70_SettingsLogoUploadTest.php
Normal 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'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
123
tests/71_DatenverzeichnisPfadeTest.php
Normal file
123
tests/71_DatenverzeichnisPfadeTest.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue