# Changelog --- ## 2026-08-02 – Nummernkreise geben die zuletzt vergebene Nummer beim Löschen wieder frei - Der E2E-Finalisierungstest verbrauchte bei jedem Lauf eine Rechnungs- und eine Belegnummer: Die Testrechnung wurde zwar wieder gelöscht, die Nummern blieben aber vergeben und rissen dauerhaft Lücken in den laufenden Nummernkreis. - **`src/Models/Invoice.php`, `src/Models/Beleg.php`:** `delete()` gibt die Nummer des gelöschten Datensatzes jetzt wieder frei. Die zuletzt vergebene Nummer wird dafür aus dem Zählerstand rekonstruiert und nur bei exakter Übereinstimmung zurückgezählt – existiert bereits eine jüngere Rechnung bzw. ein jüngerer Beleg (oder stammt die Nummer aus einem früheren Monat/Jahr), bleibt der Zähler stehen. Damit kann keine Nummer doppelt vergeben werden. `Invoice::delete()` gibt zusätzlich die Belegnummer des mitgelöschten Rechnungsbelegs frei. - **`scripts/e2e-cleanup.php`:** Räumt liegengebliebene Testdaten absteigend ab (jüngste zuerst), damit die Freigabe für die ganze Kette greift und nicht nur für den letzten Datensatz. - **`tests/78_NummernkreisFreigabeTest.php`** (neu): Sichert Freigabe und Wiedervergabe für beide Nummernkreise ab – inklusive der Gegenprobe, dass das Löschen eines älteren Datensatzes den Zähler unverändert lässt. - **`tests/e2e/specs/rechnung-export.spec.js`:** Neuer Test `Löschen einer finalisierten Rechnung gibt die Nummer wieder frei` weist über zwei aufeinanderfolgende Finalisierungen nach, dass der Testlauf den Nummernkreis nicht mehr weiterschiebt. Der Hinweis auf die „nicht umkehrbare Nebenwirkung" in `tests/e2e/README.md` entfällt damit. ## 2026-08-02 – E2E-Tests: Browser-Ebene abgedeckt, zwei kaputte Seiten repariert - Die PHP-Suite prüft Models und Services, ruft aber nie einen Controller über HTTP auf. Routing, Views und das JavaScript in den Formularen waren damit vollständig ungetestet – genau die Ebene, auf der die Fehler der letzten Wochen auftraten. Steuerkram ist jetzt an den generischen [e2e-runner](../e2e-runner/) angebunden; auf dem Host wird dafür nichts installiert. - **`src/Controllers/AccountController.php`:** `/accounts/new` lieferte HTTP 500. `create()` las `$_GET['from']` ohne Existenzprüfung – beim Aufruf aus dem Menü (ohne Parameter) also immer. Jahrelang war das eine stille PHP-Warning; seit der Fehler-Härtung (#142) wandelt der `set_error_handler` Warnings in Exceptions um, seitdem war die Seite tot. Jetzt mit `?? ''` abgesichert. - **`src/Controllers/AssetController.php`, `src/Views/assets/form.php`:** `/assets/new` lieferte aus demselben Grund HTTP 500. Die View nutzt `$testMode` und `$defaultKontoId` unterhalb des Bearbeiten-Zweigs, gesetzt wurden beide aber nur innerhalb davon. `create()` übergibt jetzt `testMode`, die View initialisiert beide Werte am Kopf. - **`tests/e2e/`** (neu): 44 Playwright-Tests. `smoke.spec.js` prüft, dass alle Hauptseiten ohne Serverfehler rendern (dieser Lauf fand die beiden 500er), dazu 404-Verhalten, Login-Zwang und CSV-Export. `beleg-buchung.spec.js` deckt Upload → Buchungsmaske → gespeicherte Buchung ab, insbesondere den Netto/Brutto-Umschalter (#137), dessen Umrechnung erst im submit-Handler passiert und serverseitig nicht prüfbar ist. `rechnung-export.spec.js` prüft Entwurf → Finalisieren → PDF- und XML-Export. `bank-import.spec.js` sichert die Vorschau samt XSS-Regression (#133) ab, die vollständig im Browser aufgebaut wird. - **`scripts/e2e-user.php`, `scripts/e2e-cleanup.php`** (neu): Legen den Testnutzer idempotent an bzw. entfernen liegengebliebene, mit `E2E-TEST` markierte Testdaten. Die Tests laufen gegen eine Instanz mit echten Arbeitsdaten und rühren ausschließlich ihre eigenen Datensätze an. - **`tests/77_BelegLoeschsperreTest.php`** (neu): Sichert ab, dass `Beleg::delete()` einen Beleg mit Buchungen und einen festgeschriebenen Beleg verweigert und dabei die Buchungen unangetastet lässt. Bewusst als PHP-Test – reine Model-Logik gehört nicht in den Browser. - **`tests/67_BelegXmlXsltXssTest.php`:** Der Test legte ein Belegverzeichnis an und löschte am Ende nur die Datei, nicht das Verzeichnis. Die Transaktion rollt die Belegnummer zurück, das Dateisystem aber nicht – und weil die Suite über `docker compose exec` als *root* läuft, blieb ein root-eigenes Verzeichnis zurück. Bekam später ein echter Upload dieselbe Belegnummer, scheiterte `move_uploaded_file()` mit „Permission denied" und die Seite antwortete mit HTTP 500. Das traf nicht nur Tests, sondern den normalen Beleg-Upload nach jedem lokalen Testlauf. Der Test räumt das Verzeichnis jetzt mit ab. - Fehlt Docker, das Runner-Projekt oder das Playwright-Image, überspringt `tests/e2e/run.sh` mit Exit 0 und einer Warnung. Die E2E-Tests bleiben damit optional und blockieren keine Umgebung ohne Runner. ## 2026-08-01 – Belege blieben nach dem Umzug nach data/ unauffindbar - Beim Umzug der Nutzdaten nach `data/` (#150) wurden die Belege mit `mv uploads/2026 data/belege/` verschoben. Existierte im Ziel bereits ein Jahresverzeichnis – etwa weil vorher schon ein Container gelaufen war –, scheiterte `mv` (bzw. legte den Baum als `data/belege/2026/2026` ab). Weil alle Fehler mit `2>/dev/null || true` unterdrückt wurden, meldete die Migration trotzdem Erfolg: In der Oberfläche waren die Belege danach nicht mehr auffindbar, obwohl die Datenbankeinträge unverändert waren. - **`scripts/uploads-uebernahme.sh`** (neu): Übernimmt den Altbestand jetzt Datei für Datei und führt bestehende Verzeichnisse zusammen, statt ganze Verzeichnisse zu verschieben. Eine bereits vorhandene Zieldatei wird nie überschrieben – die Quelldatei bleibt in `uploads/` zur Prüfung liegen und die Kollision wird gemeldet. Verbleiben Reste, endet das Skript mit Exit-Code 1 statt mit einer Erfolgsmeldung. - **`docker-entrypoint.sh`, `scripts/migrate-datenverzeichnis.sh`:** Beide rufen dieselbe Übernahme-Logik auf, damit Container-Start und manueller Umzug nicht auseinanderdriften. Da `scripts/` als Volume gemountet ist, der Entrypoint dagegen aus dem Image stammt, wirkt eine Korrektur am Umzug ohne Rebuild. - **`tests/76_DatenverzeichnisUebernahmeTest.php`** (neu): Führt die Übernahme real gegen ein Zielverzeichnis aus, in dem das Jahresverzeichnis bereits existiert, und sichert Zusammenführung statt Verschachtelung, unangetastete Zieldateien, die Trennung von Logo und Belegen sowie die Exit-Codes ab. ## 2026-08-01 – Analyse-Skript für die Nextcloud-Verbindung - **`scripts/analyse_nextcloud.sh`** (neu): Grenzt Störungen des Belegimports ein, ohne dass dafür Einzeiler auf dem Server nötig sind. Das Skript liest die hinterlegte Konfiguration (Passwort nur als Länge und Verschlüsselungsstatus, nie im Klartext), zeigt Namensauflösung und IP-Pinning des SSRF-Schutzes, unternimmt einen PROPFIND auf den Import-Ordner, listet dessen Inhalt inklusive der Dateien, die wegen ihres Typs übersprungen würden, und prüft die Erreichbarkeit des Erledigte-Ordners. - Schlägt der Verbindungsversuch fehl, folgen automatisch Gegenproben – ohne IP-Pinning sowie je Adressfamilie einzeln – und ein Befund, der zwischen abgewiesener Anmeldung (401/403), fehlendem Ordner (404), einer nicht nutzbaren Adressfamilie und einem generell nicht erreichbaren Server unterscheidet. Die cURL-Optionen entsprechen bewusst denen von `NextcloudImportService::newCurl()`, damit die Analyse den echten Importlauf abbildet. - Rein lesend: keine schreibenden WebDAV-Methoden, kein Importlauf – bei der Fehlersuche werden also keine Belege verschoben oder gelöscht. Exit-Code 0 bedeutet, dass die Verbindung steht. Läuft das Skript im Container, ruft es `php` direkt auf, sonst über `docker compose exec`. - **`tests/75_AnalyseNextcloudSkriptTest.php`** (neu): Sichert bash-Syntax, Ausführbarkeit und vor allem zu, dass das Skript lesend bleibt und das Passwort nicht ausgibt. - **`docs/konfiguration.md`:** Abschnitt „Verbindung analysieren" unter „Nextcloud-Import einrichten". ## 2026-08-01 – Nextcloud-Belegimport wieder funktionsfähig (IP-Pinning bei Dual-Stack-Hosts) - Seit dem Deployment des SSRF-Schutzes (#130) schlug der Nextcloud-Import bei jedem Lauf mit „PROPFIND fehlgeschlagen. URL/Zugangsdaten prüfen." fehl. Ursache war nicht die Konfiguration, sondern das IP-Pinning: `SsrfGuard::resolvePinned()` gab pro aufgelöster IP-Adresse einen eigenen `CURLOPT_RESOLVE`-Eintrag zurück. libcurl behandelt mehrere Einträge für dieselbe `HOST:PORT`-Kombination aber nicht als Alternativen – der zuletzt gesetzte verdrängt die vorherigen. Bei einem Host mit A- **und** AAAA-Record blieb dadurch nur die IPv6-Adresse übrig, und da der Container keine IPv6-Konnektivität hat, scheiterte jede Verbindung sofort mit „Could not connect to server". - **`src/Services/SsrfGuard.php`:** `resolvePinned()` liefert jetzt genau einen Eintrag mit allen geprüften Adressen als Liste (`HOST:PORT:IP1,IP2,…`), IPv6 in eckigen Klammern. cURL probiert die Adressen damit wie bei eigener Auflösung nacheinander durch und fällt auf IPv4 zurück. Der DNS-Rebinding-Schutz bleibt unverändert – gepinnt wird weiterhin ausschließlich auf die zuvor validierten Adressen. - **`src/Services/NextcloudImportService.php`:** Fehlgeschlagene Requests landeten bisher ohne jedes Detail im Ereignis-Log, was die Eingrenzung ohne Server-Zugriff unmöglich machte. PROPFIND- und Download-Fehler nennen jetzt den cURL-Fehlercode bzw. den HTTP-Status samt tatsächlich kontaktierter Gegenstelle (neue Methode `describeTransfer()`). - **`tests/74_SsrfPinningDualStackTest.php`** (neu): Stellt sicher, dass `resolvePinned()` genau einen Eintrag liefert, beide Adressfamilien eines Dual-Stack-Hosts darin enthalten sind und IPv6-Adressen geklammert werden. ## 2026-07-31 – Rechnungsstatus folgt automatisch den erfassten Zahlungen - Bisher blieb eine Ausgangsrechnung auch dann auf „Gesendet", wenn dem zugehörigen Beleg bereits Zahlungsbuchungen zugeordnet waren – der Status wurde ausschließlich manuell über „Rechnung als bezahlt markieren" gesetzt. Neu leitet sich der Status aus der Summe der zugeordneten Buchungen ab: **Teilzahlung** (weniger als der Rechnungsbetrag), **Bezahlt** (exakt der Rechnungsbetrag) und **Überzahlt** (mehr als der Rechnungsbetrag). Werden alle Buchungen storniert, fällt die Rechnung auf „Gesendet" bzw. „Finalisiert" zurück. - **`migrations/090_invoice_zahlstatus.sql`** (neu): Erweitert das Status-ENUM um `Teilzahlung` und `Überzahlt`, ergänzt die Spalte `sent_at` (damit beim Zurücksetzen zwischen „Finalisiert" und „Gesendet" unterschieden werden kann) und rechnet den Status vorhandener Rechnungen einmalig aus deren Buchungen nach. Manuell auf „Bezahlt" gesetzte sowie stornierte Rechnungen bleiben unangetastet. - **`src/Models/Invoice.php`:** Neue Methoden `recalcPaymentStatus()` (Statusableitung inkl. Vorzeichennormalisierung für Stornorechnungen mit negativen Beträgen) und `recalcPaymentStatusForBeleg()`. Entwürfe und stornierte Rechnungen werden nie überschrieben. - **`src/Models/Booking.php`:** `create()` und `reverse()` stoßen die Neuberechnung nach dem Commit an, ebenso das Löschen einer Buchung im Testmodus (`BookingController::destroy()`). Damit greift die Statusberechnung unabhängig davon, ob über die Rechnung, den Beleg oder die Bankzuordnung gebucht wird. `sumBruttoForBeleg()` kann jetzt auf einen Buchungstyp eingeschränkt werden; `sumZahlungen()` summiert die aktiven Einnahme-Hauptbuchungen einer bereits geladenen Liste. - **`src/Views/invoices/show.php`, `book.php`:** Das Zahlungs-Modal und die Buchungsansicht zeigen unter den Buchungen jetzt Rechnungsbetrag, gezahlten Betrag und den offenen Rest (bzw. den überzahlten Betrag). Die Betragsvorbelegung im Modal entspricht dem offenen Restbetrag statt immer dem vollen Rechnungsnetto, der USt-Satz wird aus der Rechnung vorbelegt. - **Statusanzeige** in Rechnungsliste, Detailansicht, Buchungsansicht und Kundenakte um die neuen Status ergänzt (`badge-orange` neu für Teilzahlung, `badge-purple` für Überzahlt), Statusfilter der Rechnungsliste umfasst jetzt alle sieben Status. - **`src/Controllers/AccountController.php`:** Die Kennzahl „offene Rechnungen" auf dem Dashboard berücksichtigt Teilzahlungen und weist nur noch den tatsächlich offenen Restbetrag aus. - **`tests/73_InvoiceZahlungsstatusTest.php`** (neu): Deckt Teilzahlung, Vollzahlung, Überzahlung, Stornos und den Rückfall auf „Gesendet"/„Finalisiert" ab. ## 2026-07-31 – XML-Rechnung (EN 16931) als Download in der Rechnungs-Detailansicht - Neben dem PDF-Symbol steht in der Detailansicht einer Rechnung jetzt ein XML-Symbol, das die Rechnungsdaten als reine EN16931-XML-Datei (`Rechnung-.xml` bzw. `Stornorechnung-.xml`) zum Download anbietet. Bisher war das EN16931-XML nur in die PDF eingebettet und nicht einzeln zu bekommen. - **`src/Services/ZugferdService.php`:** Der Dokumentaufbau steckt jetzt in `buildDocument()`; `attachToPdf()` bettet das Ergebnis wie bisher in die PDF ein, neu liefert `createXml()` dasselbe Dokument als XML-String. Alle Feldzugriffe sind gegen fehlende Schlüssel und `null` abgesichert, sodass auch unvollständige Rechnungen (z.B. Entwürfe ohne Rechnungsnummer) ein wohlgeformtes XML ergeben statt eines Fehlers. - **Neue Methode `collectMissingData()`:** Prüft die für EN 16931 relevanten Angaben und liefert die fehlenden getrennt nach Stammdaten (BT-27, BT-35/37/38, BT-31/32, BT-84) und Rechnungsdaten (BT-1, BT-2, BT-9, BT-10, BT-44, BT-50/52/53/55, BT-72, BG-25) zurück. - **`src/Controllers/InvoiceController.php`, `public/index.php`:** Neue Action `xml()` und Route `GET /invoices/{id}/xml`; die Detailansicht bekommt zusätzlich die Liste der fehlenden Angaben übergeben. - **`src/Views/invoices/show.php`:** XML-Download-Symbol neben dem PDF-Symbol (auch bei Entwürfen sichtbar). Fehlen Angaben, erscheint über der Aktionsleiste ein Hinweisbanner mit den betroffenen Feldern samt BT-Nummer, und das Symbol wird in Warnfarbe dargestellt – der Download bleibt in jedem Fall möglich. - **Fix nebenbei:** `mapEinheit()` verglich Einheiten ohne den im UI üblichen Punkt („Std.", „Stk."), sodass Stunden im ZUGFeRD-XML bislang als Stück (`C62`) statt als `HUR` ausgewiesen wurden. Das betraf auch das in die PDF eingebettete XML. - **`tests/72_InvoiceXmlExportTest.php`** (neu): Prüft Profilkennung, Rechnungsnummer, Käufer- und Verkäuferdaten, Positionen, Bruttosumme und Einheiten-Mapping im erzeugten XML sowie die Meldung fehlender Stamm-/Rechnungsdaten und die Anbindung in Route und Detailansicht. ### Nachtrag: Konformität zur XRechnung-CIUS hergestellt Der E-Rechnungsvalidator (KoSIT-Prüfkonfiguration) meldete für die heruntergeladene Datei einen Fehler und eine Warnung. Die EN16931-Schematron-Prüfung war bereits fehlerfrei – beanstandet wurden ausschließlich die zusätzlichen deutschen Regeln der CIUS XRechnung: - **BR-DE-2 (Fehler):** Die Gruppe „SELLER CONTACT" (BG-6) fehlte. `ZugferdService` setzt jetzt Ansprechpartner (BT-41, aus `inhaber` mit Rückfall auf `firma`), Telefon (BT-42) und E-Mail (BT-43) aus den Firmeneinstellungen. - **BR-DE-21 (Warnung):** Die Spezifikationskennung (BT-24) soll der XRechnung entsprechen. Der XML-Download verwendet dafür jetzt das Profil **XRechnung 3.0** (`ZugferdProfiles::PROFILE_XRECHNUNG_3`) statt des reinen EN16931-Profils. XRechnung ist eine CIUS von EN 16931, die Datei bleibt damit normkonform und erfüllt zusätzlich die BR-DE-Regeln. - **PEPPOL-EN16931-R001/R010/R020 (Hinweise):** Geschäftsprozess (BT-23) sowie die elektronischen Adressen von Verkäufer (BT-34) und Käufer (BT-49, aus der Kunden-E-Mail) werden jetzt mit ausgegeben. - **Die PDF-Einbettung bleibt unverändert** beim Profil EN16931 (Factur-X, `factur-x.xml`) – dem üblichen Format für hybride B2B-Rechnungen. Die neuen Kontakt- und Adressangaben stehen aber auch dort, das PDF-XML wird dadurch nur vollständiger. - `collectMissingData()` prüft zusätzlich Ansprechpartner (BT-41), Telefon (BT-42), E-Mail (BT-43/BT-34) und die Kunden-E-Mail (BT-49); Test 72 deckt die neuen Elemente sowie eine XSD-Prüfung des erzeugten XML ab. - **Fix: USt-IdNr. des Kunden (BT-48) fehlte immer im XML.** `Invoice::find()` selektierte vom Kunden zwar Adresse, E-Mail und Zahlungsziel, aber nicht `c.ust_id` – und `invoices` hat keine eigene Spalte dafür. Der Zweig `addDocumentBuyerVATRegistrationNumber()` in `ZugferdService` lief deshalb nie, auch wenn beim Kunden eine USt-IdNr. gepflegt war (Bestandsfehler, betraf auch das in die PDF eingebettete XML). Bei inländischen Leistungen mit Steuerkategorie „S" ist BT-48 optional, weshalb es nicht auffiel; bei Reverse Charge (§ 13b UStG) und innergemeinschaftlichen Lieferungen ist die Angabe dagegen Pflicht. `Invoice::find()` liefert `c.ust_id` jetzt mit, Test 72 prüft BT-31 und BT-48 samt Schemakennung „VA". ## 2026-07-31 – Firmenlogo persistent in `data/logo`, Nutzdaten gebündelt unter `data/` - Fix #116 hatte die Beleg-Uploads aus dem Web-Root nach `data/uploads` verschoben – inklusive des Unterordners `logo/`. Angepasst wurden damals aber nur Beleg-, Bank- und Invoice-Code; `Setting`, `SettingsController` und `PdfInvoiceService` zeigten weiterhin auf `public/static/uploads/logo/`. Dadurch war das hochgeladene Firmenlogo ab diesem Zeitpunkt nicht mehr auffindbar (Datei vorhanden, Pfad falsch) und ein Neu-Hochladen legte es erneut im Web-Root ab, wo es bei jedem Update verloren ging. - **Neue Ablagestruktur:** Alle persistenten Nutzdaten liegen jetzt gebündelt und nach Art getrennt unter `data/` – `data/mariadb` (Datenbank), `data/belege` (Belegdateien), `data/logo` (Firmenlogo). Jedes Verzeichnis ist ein eigenes Volume; `data/mariadb` wird nicht mehr in den `web`-Container gemountet. - **`data/README.md`** (neu): Beschreibt für den Admin, was wo liegt, warum die Dateien außerhalb des Web-Roots stehen und wie eine vollständige Datensicherung aussieht. - **`src/Config.php`:** `upload_dir` zeigt auf `data/belege`, neuer Schlüssel `logo_dir` auf `data/logo`. - **`src/Models/Setting.php`:** Neue Methode `logoAbsolutePath()` löst den DB-Wert gegen `logo_dir` auf (toleriert Altpfade mit `static/uploads/`-Präfix); `deleteLogo()` nutzt sie statt des Web-Root-Pfads. `setLogo()` speichert nur noch den Dateinamen. - **`src/Controllers/SettingsController.php`:** `uploadLogo()` schreibt nach `data/logo` und räumt beim Formatwechsel (png ↔ jpg) die Altdatei ab; neue Methode `serveLogo()` liefert das Logo authentifiziert über `/settings/logo/file` aus (Pfad- und MIME-Prüfung). - **`src/Services/PdfInvoiceService.php`, `src/Views/invoices/show.php`, `src/Views/settings/form.php`:** Beziehen das Logo über den neuen Endpunkt bzw. den absoluten Dateipfad statt über einen Web-Root-Pfad. - **`migrations/089_pfade_ohne_webroot_praefix.sql`** (neu): Entfernt die historischen `static/uploads/`-Präfixe aus `belege.storage_path` und `company_settings.logo_path`; idempotent. - **`scripts/migrate-datenverzeichnis.sh`** (neu): Verschiebt vorhandene Daten aus `./uploads` nach `./data/belege` bzw. `./data/logo`. Der Umzug läuft in einem kurzlebigen Container als root, da die Dateien `www-data` gehören – kein sudo nötig, idempotent. - **`docker-compose.yml`, `Dockerfile`, `docker-entrypoint.sh`, `.gitignore`:** Volumes, Verzeichnisanlage und Rechte auf die neue Struktur umgestellt; `.gitignore` schließt `data/*` aus (Ausnahme: `data/README.md`), sodass `git clean` die Nutzdaten nicht mehr treffen kann. - **Kein Breaking Change bei bestehenden Installationen:** Der Entrypoint übernimmt vorhandene Dateien beim Container-Start automatisch nach `data/belege` bzw. `data/logo` – sowohl aus der gemeinsamen `uploads`-Ablage (dafür bleibt `./uploads` übergangsweise als `data/legacy-uploads` gemountet) als auch ein Logo aus dem alten Web-Root. Ein reines `scripts/update.sh` (git pull + Rebuild + Start) genügt damit; ohne diese Automatik hätte Migration 089 die DB-Pfade umgeschrieben, während die Dateien am alten Ort liegen geblieben wären. - **Fix: Datei-Backups liefen seit Fix #116 leer.** `scripts/backup.sh` sicherte weiterhin `public/static/uploads/` – seit dem Umzug aus dem Web-Root ein leeres Verzeichnis. Die Archive enthielten dadurch keine einzige Belegdatei mehr (117 Bytes statt zweistelliger MB-Beträge), ohne dass es auffiel; gesichert wurde nur noch die Datenbank. Das Skript sichert jetzt `data/belege` und `data/logo` und bricht nicht mehr stillschweigend ab, sondern warnt, wenn das Archiv keine Datei enthält. - **`scripts/restore.sh`:** Stellt Belege und Logo an den neuen Orten wieder her und erkennt zusätzlich das alte `uploads/`-Archivformat, sodass ältere Datei-Backups einspielbar bleiben. - **`scripts/update.sh`:** Neuer Schritt 2 aktualisiert `scripts/` per `git pull` und startet sich in der neuen Fassung neu, **bevor** das Backup läuft. Bislang sicherte ein Update sich noch mit den Skripten des Vorgängerstands ab – dadurch entstand das leere Datei-Backup. Der Ausgangs-Commit wird über `STEUERKRAM_BEFORE_COMMIT` an den Neustart weitergereicht, damit die Rebuild-Erkennung die Änderungen weiterhin sieht. Der frühere, engere Selbst-Neustart (nur bei Änderung an `update.sh` selbst, und erst nach dem Backup) entfällt. - **`scripts/backup.sh`:** Sichert defensiv jeden Ort, an dem Nutzdateien liegen können (`data/belege`, `data/logo`, `data/legacy-uploads`, `data/uploads` sowie das alte Web-Root-Verzeichnis), sofern er nicht leer ist. Damit ist das Backup auch im Übergangszustand eines laufenden Updates vollständig, in dem die Skripte schon neu sind, die Dateien aber erst beim nächsten Container-Start umziehen. - **`scripts/restore.sh`:** Entpackt in ein Staging-Verzeichnis und normalisiert anschließend alle je verwendeten Ablagen (`belege/`, `logo/`, `legacy-uploads/`, `uploads/`) auf die aktuelle Struktur. - **`tests/71_DatenverzeichnisPfadeTest.php`** (neu): Hält Backup-, Restore-, Compose-, Entrypoint- und Update-Pfade mit `Config::upload_dir`/`logo_dir` zusammen und prüft, dass die Skript-Vorabaktualisierung vor dem Backup steht – genau dieser Drift hatte das leere Backup verursacht. ## 2026-07-31 – Fix: Logo-Upload defekt (falsches Ziel-Formular + Warnings in Setting::update()) - Der „Hochladen"-Button in den Einstellungen postete an `/settings` statt an `/settings/logo`: Das Logo-Formular lag als verschachteltes `
` innerhalb von `#settings-form`. HTML erlaubt keine verschachtelten Formulare – der Browser verwirft das innere Start-Tag, wodurch das erste `
` bereits `#settings-form` schloss. Folge: Der Upload lief nie, stattdessen wurde `Setting::update()` mit unvollständigen POST-Daten aufgerufen. Aus demselben Grund war auch der Logo-Löschen-Button (`form="delete-logo-form"`) wirkungslos. - `Setting::update()` las `mail_subject_template`/`mail_body_template` weiterhin per `?:` aus `$data`, obwohl beide Felder seit „E-Mail-Betreff und -Text pro Kunde" (2026-05-30) nicht mehr im Einstellungsformular stehen. Das erzeugte „Undefined array key"-Warnings; deren Ausgabe verhinderte anschließend den `Location`-Header in `View::redirect()` („headers already sent"). - **`src/Views/settings/form.php`:** `#logo-upload-form` und `#delete-logo-form` stehen jetzt als eigenständige, leere Formulare außerhalb von `#settings-form`; Datei-Input und Buttons sind per `form="…"`-Attribut daran gebunden. - **`src/Models/Setting.php`:** Alle optionalen Felder werden konsistent über `($data['x'] ?? '') ?: default` gelesen, sodass fehlende POST-Keys keine Warnings mehr auslösen. - **`tests/70_SettingsLogoUploadTest.php`:** Neuer Test prüft, dass `Setting::update()` ohne optionale Keys warnungsfrei durchläuft, dass `setLogo()`/`deleteLogo()` den Pfad korrekt schreiben und dass in `#settings-form` kein verschachteltes `
` mehr steht. ## 2026-07-30 – Fix: SMTP-Header-/Kommando-Injection über mail_from_email (Issue #148) - `SmtpMailer::sendMessage()`/`buildMessage()` verwendeten `fromEmail` roh sowohl im `MAIL FROM:<...>`-SMTP-Kommando als auch im `From:`-Header, während alle anderen dynamischen Werte (Empfänger, `fromName`, `subject`, Anhangsname) bereits validiert bzw. CRLF-bereinigt wurden. `mail_from_email` (Firmeneinstellung, per POST setzbar) durchlief keine serverseitige Prüfung – ein eingeloggter Nutzer konnte per CRLF-Payload zusätzliche SMTP-Kommandos (z.B. eine verdeckte `RCPT TO`-Zeile) einschleusen oder den `From:`-Header manipulieren (CWE-93). - **`src/Models/Setting.php`:** `Setting::update()` validiert `mail_from_email` jetzt über `filter_var(..., FILTER_VALIDATE_EMAIL)` (neue private Methode `sanitizeFromEmail()`); ungültige Werte (inkl. CRLF-Payloads) werden verworfen statt gespeichert. - **`src/Services/SmtpMailer.php`:** Defense-in-Depth – neue Methode `sanitizeAddress()` entfernt CR/LF aus `fromEmail`, bevor dieser im `MAIL FROM:`-Kommando und im `From:`-Header verwendet wird. - **`tests/69_MailFromEmailInjectionTest.php`:** Neuer Test prüft, dass ungültige/CRLF-behaftete Absenderadressen beim Speichern verworfen werden und dass `SmtpMailer::sanitizeAddress()` CR/LF zuverlässig entfernt. ## 2026-07-27 – Fix: Produktions-Härtung der PHP-Fehlerausgabe (Issue #142) - Das Docker-Image (`php:8.3-apache`) übernahm die kompilierten PHP-Defaults, bei denen `display_errors` aktiviert ist – im offiziellen Image wird `php.ini-production` nicht automatisch aktiviert. In Kombination mit `PDO::ATTR_ERRMODE_EXCEPTION` (`src/Database.php`) und fehlendem globalem Error-Handler hätte jeder unerwartete Fehler (SQL-Fehler, DB-Verbindungsproblem, Typfehler) eine vollständige PHP-Fehlermeldung inkl. Stacktrace, absoluten Serverpfaden und ggf. SQL-Details direkt an den Client ausgegeben (CWE-209, Information Exposure Through an Error Message) – auch für nicht angemeldete Nutzer (`/login` ist vor `Auth::require()` erreichbar). - **`php/error-handling.ini`** (neu): `display_errors = Off`, `display_startup_errors = Off`, `log_errors = On`, `error_reporting = E_ALL & ~E_DEPRECATED`. - **`Dockerfile`:** Kopiert `php/error-handling.ini` zusätzlich zu `php/uploads.ini` nach `/usr/local/etc/php/conf.d/`. - **`public/index.php`:** Registriert ganz zu Beginn (vor Autoload/Routing) `set_error_handler` (wandelt Warnings/Notices in `ErrorException` um), `set_exception_handler` und `register_shutdown_function` (fängt Fatal Errors, die nicht als Exception auftreten). Unerwartete Fehler werden per `error_log` serverseitig geloggt; der Client erhält nur eine generische HTTP-500-Seite ohne Details. - **`tests/68_ProductionErrorHardeningTest.php`:** Neuer Test prüft die Ini-Direktiven, die Dockerfile-Einbindung, die Registrierung der Handler vor dem Autoload sowie dass die an den Client gesendete Fehlerseite keine Exception-Details (Message/Trace/File) enthält. ## 2026-07-27 – Fix: Stored XSS über XSLT in hochgeladenen XML-Belegen (Issue #135) - `BelegController::serveFile()` liefert Belege mit dem in der DB hinterlegten `mime_type` und `Content-Disposition: inline` unverändert an den Browser aus. Da für Belege `application/xml`/`text/xml` als MIME-Typ erlaubt ist, konnte eine hochgeladene XML-Datei mit eingebettetem ``/`xsl:stylesheet` beim Öffnen über den „Datei öffnen“-Link (`target="_blank"`, kein `download`-Attribut) direkt im Browser (v.a. Firefox) per XSLT-Transformation zu ausführbarem `