# CLAUDE.md This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. Kundenverwaltung für ein Ingenieurbüro: Kunden, Termine, Projekte, Angebote, Lieferscheine, Rechnungen, Zeiterfassung. Gewachsene PHP-Anwendung (seit ~2011) mit Smarty-Templates und MariaDB/MySQL. Domänenbegriffe, Bezeichner und UI-Texte sind durchgehend deutsch — das bitte beibehalten. ## Tooling Es gibt **kein** Composer, npm, Build, Linter oder Testsuite. Abhängigkeiten liegen als Kopie in `incs/` und werden dort direkt gepflegt. Ein Update einer dieser Bibliotheken ist immer ein manueller Eingriff, kein Paketmanager-Aufruf. Syntaxprüfung der eigenen Dateien: ```bash for f in $(git ls-files "*.php" | grep -vE "^(incs/(tcpdf|pChart|PHPMailer|WebAuthn|drawer|font)|smarty|spellcheck|js)/"); do php -l "$f"; done ``` Zum Ausführen braucht es einen Webserver mit PHP, eine MariaDB/MySQL-Instanz und die externe Konfigurationsdatei (siehe unten). Ohne die Konfiguration springt `index.php` in den Setup-Modus (`update/firststart.php`). **Nur** dann: Das Setup läuft ohne Login, schreibt die Config und kann die Datenbank leeren. Deshalb wird `action=setup` bei gültiger Config verworfen, und `firststart.php` und `rebuild-settings.php` brechen selbst ab. Werte, die in die Config geschrieben werden, immer über `var_export()` maskieren – die Config ist ausführbarer PHP-Code. **Lokal testen** (auch unter Windows): Die Umgebungsvariable `KV_CONFIG_PATH` ersetzt `CONFIG_PATH`. Die Config-Datei heißt nach dem Verzeichnisnamen des Checkouts, also z.B. `Kundenverwaltung_settings.conf`; `$lokale_netze` muss `127.0.0.1`/`::1` enthalten, sonst gibt es kein Passwortfeld. Start im Repo-Verzeichnis mit `php -S 127.0.0.1:8765`. Die PDF-Vorschau als Bild (`makeImageFromPDF()`) braucht ImageMagick unter `/opt/bin/convert` und funktioniert lokal nicht. ## Die zwei zentralen Mechanismen ### 1. Das Verzeichnis bestimmt die Instanz `incs/constants.php` leitet aus `getcwd()` den Namen des Installationsverzeichnisses ab. Der wählt aber nur noch die Konfigurationsdatei aus. Der eigentliche Instanzname steht in der Config als `$instanz` und wird zur Konstante `INSTANZ`: | Konstante | abgeleitet aus | Wert bei Installation in `…/kunden` | |---|---|---| | `CONFIG_FILE` | Verzeichnisname | `kunden_settings.conf` | | `INSTANZ` | `$instanz` in der Config | `kunden` | | `UPLOADS_PATH` | `INSTANZ` | `uploads_kunden/` | | `SESSION_NAME` | `INSTANZ` | `KUNDENSESS` | Fehlt `$instanz` in der Config (ältere Configs, Ersteinrichtung), fällt `INSTANZ` auf den Verzeichnisnamen zurück. Erlaubt sind nur Buchstaben und Ziffern, sonst bricht der Request ab. Die Config wird dafür schon in `constants.php` einmal eingelesen, also **vor** `session_start()`. Code in der Config darf deshalb nichts ausgeben und keine Verbindung voraussetzen. Aus **einer** Codebasis laufen dadurch mehrere getrennte Instanzen — die `uploads_dev`, `uploads_ingwa`, `uploads_kunden`, `uploads_lsg` im Baum sind die Spuren davon. Jede Instanz hat eigene Config, eigene Session, eigene Uploads. **Konsequenz für alles, was per CLI/Cron läuft:** Das Arbeitsverzeichnis muss das Installationsverzeichnis sein, sonst wird die falsche (oder keine) Konfiguration geladen. ```bash cd /home/intranet/kunden && php db-backup.php ``` `constants.php` kompensiert dabei nur die Aufrufe aus `ajax/` und `ajax/auto/`, indem es ein bzw. drei Ebenen hochgeht. ### 2. Zugangsdaten liegen außerhalb des Repos `CONFIG_PATH` zeigt auf `/volume1/homes/wagner/kundenconfig/` (Linux) bzw. `c:/kundenverwaltung/config/` (Windows). Geschrieben wird die Datei von `update/rebuild-settings.php` beim Ersteinrichten. Neben den DB-Zugangsdaten stehen dort auch `$instanz` (siehe oben), die SMTP-Zugänge je Firma (`$smtp_konten`, Schlüssel `"*"` als Rückfall, genutzt von `mail_attachment()`), der Zugang zur Statistik-Datenbank für `stats.php` (`$stats_mysql_*`) und die Wetter-API-Schlüssel (`$openweathermap_appid`, `$aeris_client_*`). Die Datei deklariert `$mysql_user`, `$mysql_pw`, `$mysql_server`, `$mysql_db`, `$firma` und weitere ausdrücklich als `global` und weist sie erst danach zu. Nur deshalb funktioniert `readsettings()` in `incs/settings.php`: Das `include` passiert im Funktionsrumpf, ohne die `global`-Zeilen blieben die Variablen dort lokal. Wer die Config-Struktur ändert, muss diese Eigenheit erhalten. Zugangsdaten gehören **nie** in den Code — `db-backup.php` zeigt das Muster für Skripte außerhalb des Request-Zyklus (Config einlesen, Passwort über eine temporäre Optionsdatei an `mysqldump`, nicht per Kommandozeile). ## Request-Ablauf `index.php` ist der einzige Einstieg für die Oberfläche: 1. Konstanten, Session, Konfiguration, DB-Verbindung 2. Schema-Migration (siehe unten) 3. `head.tpl` 4. bei gültiger Session: `checkRightsNInclude()` aus `incs/funcs.php` — ein `switch` über `$_GET["action"]`, das Rechte prüft und den Pfad in `sites/` zurückgibt, der dann inkludiert wird 5. `foot.tpl` **Eine neue Seite anzulegen heißt: Datei in `sites/` plus ein `case` in `checkRightsNInclude()`.** Ohne den `case` ist die Seite nicht erreichbar. Die Endpunkte in `ajax/` gehen **nicht** über `index.php`. Sie bootstrappen selbst über `incs/connectmysql.php` (Konstanten, Session, Config, DB, Escape-Wrapper) und rendern eigene Fragmente nach `ajax/templates_c/`. ## Rechtesystem `$_SESSION["rights"]` ist ein **String**, keine Zahl. Die Zeichenposition benennt den Bereich, der Zeichenwert ist eine Bitmaske: - Position: `RIGHTS_KUNDEN`=0, `RIGHTS_TERMINE`=1, `RIGHTS_ANGEBOTE`=2, `RIGHTS_LIEFER`=3, `RIGHTS_RECHNUNGEN`=4, `RIGHTS_SUPERADMIN`=5 - Wert: `RIGHTS_LOOK`=1, `RIGHTS_CHANGE`=2, `RIGHTS_ADD`=4 Geprüft wird immer nach diesem Muster: ```php if(substr($_SESSION["rights"], RIGHTS_KUNDEN, 1) & RIGHTS_LOOK) ``` ## Schema-Migrationen laufen bei jedem Seitenaufruf Es gibt keine SQL-Migrationsdateien. Der Stand steht in `update/thisversion/currentversion.txt` (dreistellig, aktuell `010`), die Zielversion ist der **erste** Eintrag in `$versions` in `update/versions.php`. `index.php` ruft bei jedem Request `checknewversion()` und `manageupdate()` aus `incs/update.php`; liegt die Zielversion höher, arbeitet `update_tables()` in `update/database-update.php` sich in einem `switch` von Version zu Version hoch und schreibt die neue Nummer zurück. Eine Schemaänderung besteht also aus: neuer Eintrag **vorn** in `update/versions.php` und passender `case` in `update_tables()`. `update/thisversion/` muss für den Webserver schreibbar sein. ## Datenbankzugriff **Umbau läuft (Branch `db-umbau`):** Verbindungen entstehen nur noch über `db_verbinden()` aus `incs/db.php`. Die Funktion setzt `utf8mb4`, den strikten `sql_mode` und schaltet die mysqli-Exceptions ab. Neue und umgebaute Abfragen nutzen `db_abfrage()`, `db_zeilen()`, `db_zeile()` und `db_wert()` mit `?`-Platzhaltern statt String-Verkettung. Ziel-Schema ist das der Instanz `kunden` in `utf8mb4`; die Altdaten sind teils doppelt kodiert und werden per Migration repariert. Dieser Stand darf nur zusammen mit der migrierten Datenbank live gehen. Regeln für den Umbau: - **Ergebnisse sind Strings** wie früher bei `mysqli_query()`: `db_zeilen()`, `db_zeile()`, `db_wert()` und `db_zuordnung()` wandeln die nativen int/float-Werte der Prepared Statements um. Wer `db_abfrage()` selbst ausliest, bekommt native Typen. - **Formularwerte** über `formularText()`, `formularZahl()` (Komma als Dezimaltrenner, leer wird 0) und `formularGanzzahl()` aus `incs/rechnung.php` holen – der strikte Modus lehnt `''` in Zahlenspalten ab. - **Änderungsprotokoll:** `saveLog()` bekommt den Text aus `db_sql_text()`. Die Tabelle `log` ist reine Dokumentation, nichts daraus wird ausgeführt. - **Mehrschrittiges Speichern** in eine Transaktion (`mysqli_begin_transaction` / `commit` / `rollback`), damit bei Fehlern keine halben Datensätze bleiben. - **Tabellen- und Spaltennamen** lassen sich nicht binden; nur feste Werte aus dem Code einsetzen, `LIMIT` als `(int)`. Umgebaut: Login/Passkeys, Benutzereinstellungen, Änderungsprotokoll, Rechnungsmodul (`incs/rechnung.php`, `sites/rechnungadd.php`, `sites/umschlagshow.php`, `ajax/autosave.php`, `ajax/preview.php`, Rechnungssuche). Offen: Kunden/Adressen (`incs/saveNchange.php`), Termine, Projekte, Zeiten, Benutzerverwaltung, übrige Suchen, `incs/makepdf.php`, `update/`. Prozedurales `mysqli`. `$dbconn` wird zusätzlich in `$_SESSION["dbconn"]` abgelegt, damit `incs/mysql_ecape_wrapper.php` eine globale Funktion `mysql_escape_string()` als Shim über diese Verbindung bereitstellen kann — ein Überbleibsel der `mysql_*`-Ära, das in altem Code noch aufgerufen wird. SQL wird durchgehend per String-Verkettung gebaut. Escaping ist Handarbeit über `mysqli_real_escape_string($dbconn, …)` und muss bei jeder Änderung mitgedacht werden. ## Fallstricke - **`smarty/` enthält Smarty 4.5.6** — der schlichte Verzeichnisname sagt nichts über die Version. - **PHPMailer liegt in zwei Versionen parallel und beide sind aktiv:** `index.php` lädt `incs/PHPMailer_v6` für den Rechnungsversand, `sites/projectadd.php` dagegen `incs/PHPMailer_v5.1`. Keine der beiden ist entfernbar, ohne den jeweiligen Aufrufer umzustellen. - **PDF-Schriften nur über `PDF_SCHRIFT_SERIF` / `PDF_SCHRIFT_SANS`** (oben in `incs/makepdf.php`, FreeSerif/FreeSans). Die eingebauten PDF-Schriften Times/Helvetica können nur westeuropäische Zeichen; aus ř, ł, č würde ein „?“. - **`incs/font/` gehört zu pChart, nicht zu TCPDF.** Die TTFs dort werden von `stats.php` für die Diagramme geladen; TCPDF bringt seine Schriften in `incs/tcpdf/fonts/` selbst mit. - **`index.php` setzt `error_reporting(E_ERROR | E_PARSE)`.** Warnungen und Notices sind unterdrückt, und der Code verlässt sich darauf — nicht ohne Not hochdrehen, sonst überflutet es die Ausgabe. - **`templates_c/`, `ajax/templates_c/` und `temp/` müssen für den Webserver schreibbar sein.** Sie sind git-ignoriert und nur über `.gitkeep` im Repo vorhanden. - **`uploads_*/{notes,signs}` enthalten personenbezogene Kundendaten** (Rechnungen, Scans, Unterschriften) und sind bewusst git-ignoriert. Nie committen. - Zeilenenden werden über `.gitattributes` auf LF normalisiert. ## Authentifizierung Klassischer Login plus WebAuthn/FIDO2. Server-Seite: `authServer.php` mit `incs/WebAuthn` (Bibliothek von Lukas Buchs) und `incs/authHelper.php`; Client-Seite `js/auth.js`. Schlüsselverwaltung über `addKey.php` und `sites/userkeys.php`. - **Passwort-Login nur aus dem lokalen Netz**, von außen nur per Passkey. „Lokal“ entscheidet `isLocal()` in `incs/authHelper.php` anhand von `$lokale_netze` (CIDR-Liste) aus der externen Config. Anfragen mit Proxy-Headern (`X-Forwarded-For`, `Forwarded` …) gelten nie als lokal. Webserver und MariaDB laufen in Docker auf dem NAS: **Das Docker-Netz gehört nicht in die Liste**, sonst gilt jede über den Docker- oder Reverse-Proxy eingehende Anfrage als lokal. - **Passkeys anlegen** geht nur über eine Einmal-Freigabe: Ein Superadmin erzeugt in `addKey.php` einen `addKey`, `sites/userkeys.php` löst ihn ein und legt `$_SESSION["passkey_freigabe"]` (Benutzer-id, 5 Minuten) ab. `authServer.php` akzeptiert `getCreateArgs`/`processCreate` nur mit dieser Freigabe und nimmt die Benutzer-id von dort, nie aus der URL. ## Historie Am 2026-08-28 wurden tote Pfade entfernt: `smarty3/` (ungenutztes Smarty 3.1.6), `incs/tcpdf_old/`, `incs/fpdf.php` samt FPDF-Schriftmetriken und `incs/font/makefont/`, `sites/makepdf_OLD.php`, `incs/users_old.php` und `oldindex.php`. Auf keines davon verwies noch Code. Das Repository wurde am 2026-08-28 neu initialisiert. Die mitgelieferte Historie von 2014 war irreparabel beschädigt und wurde verworfen — der erste Commit ist der Ausgangsstand, nicht der Projektbeginn.