# Changelog --- ## 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 `