# Changelog --- ## 2026-08-12 – SSRF-Schutz für SMTP-Einstellungen (#183) - `mail_smtp_host`/`mail_smtp_port` wurden ungeprüft aus `Setting::get()` an `SmtpMailer::connect()` durchgereicht, das per `stream_socket_client()` eine TCP-/TLS-Verbindung zu genau diesem Ziel aufbaut – anders als `fints_url` (#125) und `nextcloud_url` (#127) ohne jede Prüfung auf private/interne Adressen. Über `POST /settings/mail-test` konnte ein authentifizierter Nutzer damit interne Netzwerk-/Portscans auslösen (Blind-SSRF-Oracle, CWE-918), da unterschiedliche `RuntimeException`-Nachrichten offene/geschlossene/gefilterte Ports bzw. Fremddienste erkennbar machten. - **`src/Services/SsrfGuard.php`:** neue Methode `assertPublicHost(string $host)` – wie `assertPublicHttpsUrl()`, aber ohne vorausgesetztes `https://`-Schema (SMTP kennt kein URL-Schema). Prüft IP-Literale direkt, blockt `localhost` explizit, löst Hostnamen auf und prüft alle zurückgegebenen Adressen gegen `FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE`. - **`src/Services/SmtpMailer.php`:** `connect()` ruft `SsrfGuard::assertPublicHost($this->host)` vor dem `stream_socket_client()`-Aufruf auf. - **`src/Controllers/SettingsController.php`:** `update()` prüft `mail_smtp_host` defensiv bereits beim Speichern (analog zu `nextcloud_url`), damit ein ungültiger Host erst gar nicht in die Einstellungen gelangt. `mailTest()` gibt bei einem Fehler nicht mehr `$e->getMessage()` an den Client zurück, sondern eine generische Meldung; die tatsächliche Ursache landet per `error_log()` im Server-Log – entschärft die Oracle-Eigenschaft für den Fall, dass trotz Speicher-Prüfung (z.B. DNS-Rebinding zwischen Speichern und Testversand) noch unterscheidbare Fehler entstehen könnten. - **`tests/90_SsrfGuardAssertPublicHostTest.php`** (neu): Deckt `assertPublicHost()` gegen Loopback, `localhost`, RFC1918-Adressen, die Link-Local/Metadata-IP `169.254.169.254`, IPv6-Loopback, leeren Host sowie eine akzeptierte öffentliche IP ab. ## 2026-08-11 – Überlange Methoden zerlegt: FinTsFetchService::run(), Booking::create()/reverse(), Bank-/InvoiceController (#159) - Rein strukturelle Änderung, keine Verhaltensänderung: bestehende Codeblöcke wurden in benannte Methoden verschoben, Bedingungen, Fehlermeldungen und Ablaufreihenfolge blieben unverändert. - **`src/Services/FinTsFetchService.php`:** `run()` war mit 274 Zeilen der größte Ausreißer im Projekt und dabei von außen nicht testbar, weil sie eine echte Bankverbindung voraussetzt. Aufgeteilt in `validateConfig()`, `assertUrlSafe()`, `connect()`, `selectTan()` (mit `selectTanMode()`/`selectTanMedium()`), `login()`, `resolveAccounts()` (mit `fetchAccounts()`/`applyAccountFilter()`), `computeFetchRange()` sowie `fetchAllStatements()`/`fetchStatementsForAccount()`/`fetchAccountBalance()`/`importTransactions()`. `run()` liegt jetzt bei 50 Zeilen. Die doppelte SSRF-Prüfung aus Issue #130 (DNS-Rebinding-Fenster zwischen Validierung und Verbindungsaufbau) ist unverändert erhalten, der erklärende Kommentar sitzt weiterhin direkt am zweiten `assertUrlSafe()`-Aufruf in `run()`. - **`src/Models/Booking.php`:** `create()` (144 Zeilen) und `reverse()` (95 Zeilen) enthielten mehrere Schreibpfade im selben Transaktionsblock. Die tatsächlichen Pfade in `create()` sind Hauptbuchung, eine VSt-/USt-Korrekturzeile für normale Buchungen mit 7/19 % (nicht „Eigenanteil“, wie im Issue-Text vermutet – dieser Begriff bezeichnet im Projekt die 30-%-Bewirtungskürzung aus `upsertEigenanteil30()`, ein unabhängiger Pfad außerhalb von `create()`) sowie die ig.-Erwerb-19-%-ReverseCharge-Kindzeilen (kein „Split“ im Sinne von `bucheBewirtungssplit()`). Extrahiert nach `insertHauptbuchung()`, `insertVorsteuerUmsatzsteuerZeile()`, `insertIgErwerbReverseCharge()`; `reverse()` entsprechend nach `insertHauptstorno()`/`insertKindstornos()`. Die Transaktionsklammer bleibt jeweils in `create()`/`reverse()`. - **`src/Controllers/BankController.php`:** `direktStore()` (111 Zeilen) aufgeteilt in `storeCsvFormat()`, `resolveProviderId()`, `storeBankSource()`; `direktUpdate()` (70 Zeilen) in `buildUpdateData()`, `buildApiUpdateData()`, `buildFintsUpdateData()`. - **`src/Controllers/InvoiceController.php`:** `storeBooking()` (84 Zeilen, drei identische Render-Blöcke für Validierungs-/PDO-/allgemeine Fehler) aufgeteilt in `validateBookingInput()`, `renderBookForm()` (bündelt die vormals dreifach kopierte View-Rendering-Logik), `persistBooking()`. - **`tests/88_FinTsHilfsmethodenTest.php`** (neu): `validateConfig()` gegen fehlende BLZ/Benutzerkennung/PIN/URL, `applyAccountFilter()` mit leerer/passender/nicht passender Filterliste, `accountId()` für Konten mit/ohne IBAN und mit/ohne Unterkonto – alles ohne echte Bankverbindung über Reflection auf die privaten Methoden. - **`tests/89_BookingCreatePfadeTest.php`** (neu): Deckt die drei Schreibpfade von `Booking::create()` ab – reine Hauptbuchung, Hauptbuchung mit VSt-Korrekturzeile (19 %), ig.-Erwerb-19-%-Buchung mit zwei Kindzeilen. ## 2026-08-11 – Duplizierte View-Logik gebündelt: Kontengruppierung, Kontrastfarbe, Helper-Boilerplate (#158) - **Kontengruppierung** (`invoices/book.php`, `invoices/show.php`, `belege/_buchungsform.php`) war dreimal kopiert und bereits divergiert: Nur `belege/_buchungsform.php` (per `include` in `detail.php`) filterte Zahlungskonten auf den Nummernbereich 1200–1299, die beiden Rechnungs-Views nicht. Neue Methode `View::gruppiereKonten(array $accounts, bool $nurZahlungskonten1200 = false)` bündelt beide Varianten; alle drei Views wenden jetzt den Filter an (Parameter `true`). - Entscheidung für „Filter überall anwenden“ statt „nirgends“ oder „Unterschied beibehalten“: `BuchungsvorlageController::modal()` filtert unabhängig davon bereits identisch auf 1200–1299 – das ist die etablierte Konvention für „welches Bankkonto hat die Zahlung erhalten“-Auswahlen im Projekt, nicht nur ein Sonderfall der Belegerfassung. Produktiv gab es zum Zeitpunkt der Änderung noch keine einzige Rechnungs-Zahlungsbuchung (0 Zeilen in `bookings` mit verknüpfter `invoices`), das Risiko eines bestehenden Workflows, der auf Kasse/Verrechnungskonto/USt-Konten als Zahlungskonto einer Rechnung angewiesen ist, bestand also nicht. - Sichtbare Auswirkung: Im Zahlungs-Modal der Rechnungsdetailansicht und in `invoices/book.php` stehen jetzt nur noch die fünf Bankkonten (1200–1240) zur Auswahl, nicht mehr Kasse, Verrechnungskonto oder die Umsatzsteuerkonten. Siehe `docs/funktionen.md`. - **Kontrastfarbe**: Die Formel zur Wahl einer lesbaren Textfarbe (`0.299*hexdec(...)+0.587*hexdec(...)+0.114*hexdec(...)`) stand identisch in fünf Views (`belege/detail.php`, `bank/index.php`, `bank/show.php`, `bank/fints.php`, `settings/bankimport.php`). Neue Methode `View::kontrastfarbe(string $hex, string $dunkel = '#1e293b', string $hell = '#ffffff')` validiert die Eingabe (fehlt `#`, ist der Wert zu kurz oder kein gültiges Hex, liefert sie die Standardfarbe statt der bisherigen stillen Rechnung auf 0, die bei leerem/kurzem Wert immer weiße Schrift ergab); dreistellige Kurzform (`#abc`) wird expandiert. Alle fünf Views delegieren jetzt an `View::kontrastfarbe()`. - **Helper-Boilerplate**: `$h = fn($v) => View::h($v);` (36 Views) und `$fmt = fn($v) => View::formatMoney($v);` (8 exakte Duplikate; abweichende `$fmt`-Definitionen mit `abs()` bzw. ohne geschütztes Leerzeichen blieben unverändert) sind entfernt. `BaseController::renderView()` und `View::render()` stellen `$h`/`$fmt` jetzt per `$data['h'] ??= …` zentral bereit, bevor `extract()` läuft – Views, die die Variablen weiterhin selbst definieren, bleiben dadurch unverändert lauffähig. - **`tests/87_ViewHelperTest.php`** (neu): Deckt `View::kontrastfarbe()` (bekannte Farben, ungültige/leere Werte → Standardfarbe, Kurzform-Expansion, eigene Standardfarben) und `View::gruppiereKonten()` (mit/ohne Filter) ab. ## 2026-08-11 – belege/detail.php in Teil-Views und externe JS-Dateien aufgeteilt (#157) - `src/Views/belege/detail.php` war mit 1.548 Zeilen die mit Abstand größte Datei der Anwendung, davon 827 Zeilen JavaScript in neun `