Files
Kundenverwaltung/CLAUDE.md
T
adminandClaude Opus 5 490c4f12a4 Termine auf Prepared Statements umgebaut
Kalender, Terminliste, Terminformular und Terminzettel laufen ueber
db_abfrage() mit Platzhaltern; das Anlegen einer mehrtaegigen Terminreihe
liegt in einer Transaktion. Die Logik steckt jetzt in incs/termin.php,
sites/termin*.php rufen sie nur noch auf.

Dabei behoben:
- SQL-Injektion ueber die Suchbegriffe der Terminliste, ueber die
  Mehrfachauswahl (Ankreuzfelder del<N>/erl<N> gingen roh in DELETE bzw.
  UPDATE), ueber die Wiedervorlage-Tage (INTERVAL $folgedays DAY) und ueber
  ?action=terminshow&id=... in maketerminarray(), maketerminadresse(),
  maketelnrn() und makegooglestring()
- gespeichertes XSS: Beschreibung, Notiz, Termintyp, Ort, Kundenname und
  Mitarbeiterfarbe im Kalender, in der Terminliste und im Formular
- "or $err=error(...)" stand innerhalb des Abfrage-Strings, dadurch wurden
  die Nebenzeilen einer Terminreihe beim Loeschen nie entfernt
- die Abfrage zum Folgetermin las eine Spalte typ_id, die es in termin nicht
  gibt; Typ und Wiedervorlage fehlten deshalb beim Bearbeiten
- ein Folgetermin wurde mit termtime='' und termdauer='' angelegt, ein
  Termin ohne Folgetermin mit followed_by='' - im strikten Modus Fehler
- ein Termin ohne Datum landete auf dem 01.01.1970 und gab dabei
  "NICHTTERMINIERT!!" mitten in die Seite aus
- die Ueberschneidungspruefung verglich ab dem zweiten Tag gegen das Ende
  des ersten Tages
- eine unbekannte Registerkarte der Terminliste ergab ein leeres WHERE und
  damit einen SQL-Fehler; jetzt gilt die Standardansicht
- terminshow.php pruefte den Typ mit = statt ==
- der Kalender haengt nicht mehr an der Locale de_DE (strftime)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 00:04:38 +02:00

139 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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/saveNchange.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, damit bei Fehlern keine halben Datensätze bleiben — aber über `beginneSpeichern()` / `beendeSpeichern()` aus `incs/saveNchange.php`, nicht über `mysqli_begin_transaction()` direkt. Die Speicherfunktionen rufen sich gegenseitig auf (`savePart()``saveAdress()``updateKunde()``saveAdress()`, `loeschePartner()``delRechnung()`), MariaDB kennt aber keine geschachtelten Transaktionen; ein Zähler sorgt dafür, dass nur der äußerste Aufruf öffnet und schließt.
- **Tabellen- und Spaltennamen** lassen sich nicht binden; nur feste Werte aus dem Code einsetzen, `LIMIT` als `(int)`.
- **Suchabfragen** (`create*Qry()` in `incs/listfuncs.php`) liefern `array("all", "limited", "params")`. Aufrufer nutzen `db_abfrage($dbconn, $q["limited"], $q["params"])` und `zaehleTreffer()`. Die Suchbegriffe sind Platzhalterwerte, `%` und `_` darin bleiben absichtlich Jokerzeichen — so ist die Suche dokumentiert. In der Autovervollständigung (`ajax/auto/`) werden sie dagegen über `db_like_praefix()` entwertet.
- **Werte aus der Datenbank sind kein HTML.** `GROUP_CONCAT` trennt mit einem Zeilenumbruch; zusammengebaut und escaped wird erst in `zeilenHtml()` bzw. `makenameline()`/`makeadressline()`/`maketelline()`.
Umgebaut: Login/Passkeys, Benutzereinstellungen, Änderungsprotokoll, Rechnungsmodul (`incs/rechnung.php`, `sites/rechnungadd.php`, `sites/umschlagshow.php`, `ajax/autosave.php`, `ajax/preview.php`, Rechnungssuche), Kunden und Lieferanten (`incs/saveNchange.php` samt `sites/kunden*.php`, `sites/liefer*.php`, Kunden-, Lieferanten-, Adress- und Kommissionssuche, `ajax/auto/`), Termine (`incs/termin.php` samt `sites/termin*.php`, Terminliste, Terminzettel). Offen: Projekte, Zeiten, Benutzerverwaltung, der Rest von `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.
## Termine
Ein Termin über mehrere Tage ist eine **Reihe einzelner Zeilen** in `termin`, die alle dieselbe `main_id` tragen — die id der ersten Zeile. Der Kalender zeigt die Zeilen einzeln, die Terminliste fasst sie über `main_id` zusammen. Wird ein Termin auf mehrere Tage verlängert, wird die alte Reihe verworfen und neu angelegt; er bekommt dabei eine neue id.
Urlaub und Krankheit sind Termine ohne Kundenadresse. Ihre Typ-ids sind fest verdrahtet — `TERMTYP_URLAUB` = 0 und `TERMTYP_KRANKHEIT` = 5 in `incs/termin.php`; `incs/users.php` rechnet den Urlaubsanspruch über `typ = 0` aus und `templates/addtermin.tpl` blendet danach die Felder um. `termtyp` muss also eine Zeile mit der id 0 enthalten; beim Kopieren einer Datenbank braucht es dafür `NO_AUTO_VALUE_ON_ZERO`, sonst macht MariaDB eine 1 daraus.
Ganztägige Termine haben die Dauer `24:00:00` — die Terminliste unterscheidet die Registerkarten daran.
## 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/calendar_month.tpl` ist tot.** Kein Code bindet es ein, und unter PHP 8 läuft es in einen Fehler (`{$prevtermin = ""}` und danach `{$prevtermin.termtime}`).
- **`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.