10 Commits
Author SHA1 Message Date
clemensandClaude Opus 5 e7955e354c Rufnummernsuche auf den Altbestand user_old ausweiten
Die Suche lief nur gegen persons und fand deshalb den Anrufer von Anfrage
13037 nicht: dessen Mobilnummer steht in user_old, in persons ist zu
derselben Person nur eine aeltere siebenstellige Nummer gepflegt. Beide
Zeilen sind ueber tmp_user_person_map korrekt verknuepft (userid 3204 ->
person_id 2266), die Rufnummer ist bei der Migration schlicht nicht
nachgezogen worden.

Das ist kein Einzelfall. Von 2552 gemappten Paaren weicht bei 122 die
Nummer ab, 87 Nummern stehen ausschliesslich in user_old, und 176 alte
Zeilen haben gar kein Mapping. persons ist damit keine Obermenge, und die
Suche allein darueber verfehlt 87 der 2739 bekannten Rufnummern.

Der Index wird jetzt aus beiden Tabellen aufgebaut. Findet sich zu einer
alten Zeile ein aktueller Datensatz, werden dessen Daten gezeigt - die
Nummer ist nur der Schluessel, angezeigt gehoert der gepflegte Stand. Der
Vorschlag weist aus, wenn die Nummer aus dem Altbestand stammt, und
unterscheidet den Fall ohne aktuellen Datensatz.

Ein Patient darf je Nummer nur einmal erscheinen. Die Personen-ID reicht
dafuer nicht: nicht gemappte Altzeilen haben eine eigene userid und standen
dadurch neben ihrem persons-Gegenstueck, sichtbar als "2 Patienten:
Andreas Busche, Andreas Busche". Zweiter Schluessel ist deshalb Name plus
Geburtstag. Der Vergleich traegt hier, weil ohnehin nur Eintraege zu ein und
derselben Rufnummer verglichen werden - ueber den Gesamtbestand waere er
unbrauchbar, weil 0000-00-00 als Geburtstag massenhaft vorkommt.

Nachgeprueft ueber alle 2739 bekannten Rufnummern: jede liefert einen
Treffer, keine einzige eine doppelte Identitaet, groesste Trefferzahl 16 an
einer Nummer. 87 Nummern sind neu auffindbar, darunter die von Anfrage
13037, die jetzt korrekt auf person_id 2266 zeigt. Listenlogik ueber alle
12793 Anfragen unveraendert fehlerfrei; der Vorschlag steht weiterhin nie
bei einer Anfrage mit bereits zugeordnetem Patienten. Kosten bleiben zwei
Abfragen je Seitenaufruf, unabhaengig von der Zeilenzahl.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 21:09:53 +02:00
clemensandClaude Opus 5 53cff55ac2 Bei Telefonanfragen den Patienten zur Rufnummer vorschlagen
Die Telefonanlage fuellt anfragen.anrufernummer inzwischen - die erste
Anfrage damit ist #13037. Wenn die Anlage den Anrufer nicht selbst zuordnen
konnte, wird die Nummer jetzt gegen persons.tele gesucht und der Treffer
angezeigt.

NormalisiereRufnummer() vergleicht ziffernweise und fuehrt +49 und 0049 auf
die nationale Schreibweise mit fuehrender 0 zurueck. Nur so finden die
Anzeige der Anlage und die von Hand gepflegten Nummern zusammen: von 2972
gepflegten Nummern stehen 2941 als reine Ziffern da, 22 mit Bindestrich,
6 international.

Bewusst nur ein Vorschlag, keine Zuordnung. 521 der 2972 Nummern gehoeren zu
mehr als einer Person - Familien teilen sich einen Anschluss, zwei Nummern
haengen sogar an 16 bzw. 18 Personen. Bei mehreren Treffern werden deshalb
die Namen aufgezaehlt statt einer ausgewaehlt, und auch der eindeutige Fall
ist als "nicht bestaetigt - nur ueber die Rufnummer erkannt" ausgewiesen.
Eine falsche Zuordnung haette die Daten des falschen Patienten neben die
Anfrage gestellt.

Nummern unter 7 Ziffern werden nicht gesucht: in persons stehen ein paar
Platzhalter mit 1 bis 5 Ziffern, die sonst wahllos zusammenfallen.

Der Index wird beim ersten Treffer einmal je Seitenaufruf aufgebaut und
danach wiederverwendet - die Liste zeigt bis zu einige tausend Zeilen, eine
Abfrage je Zeile waere zu teuer. Gemessen ueber 12800 Zeilen: genau eine
Abfrage gegen persons. Ohne Telefonanfrage mit brauchbarer Nummer wird der
Index gar nicht erst gebaut.

Angezeigt in der Anfrageliste (Kontaktspalte), auf der Hinweisseite fuer
Anfragen ohne zugeordneten Patienten und unter Antwort einsehen.

Geprueft: Normalisierung gegen internationale, geklammerte und mit
Leerzeichen geschriebene Nummern; Vorschlag bleibt aus bei Mail- und
Portalanfragen, bei fehlender, zu kurzer und unterdrueckter Nummer sowie
bei bereits zugeordnetem Patienten. Listenlogik ueber alle 12793 Anfragen
mit erzwungenen Treffern durchgerechnet - der Vorschlag steht nie bei einer
Anfrage, die schon einen Patienten hat. Fuer #13037 selbst gibt es keinen
Treffer, die Nummer ist in persons nicht gepflegt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 19:24:25 +02:00
clemensandClaude Opus 5 4dabad0b22 Anrufernummer verlinken, PraxisKI-Link auf /anfragen/
Der Hinweis aus dem letzten Commit zeigte auf die Startseite von PraxisKI.
Richtig ist https://ki-praxis.praxis.local/anfragen/.

Neue Spalte anfragen.anrufernummer VARCHAR(255) NULL wird jetzt bei
Telefonanfragen als anklickbarer tel:-Link ausgegeben. GetTelefonLink()
baut das Ziel aus Ziffern und einem fuehrenden Plus, zeigt die Nummer aber
unveraendert an - Leerzeichen, Klammern, Slashes und Durchwahl-Bindestriche
stoeren den Link damit nicht. Nummern ganz ohne Ziffer, etwa bei
unterdrueckter Rufnummer, bleiben Text ohne Link. Der Rohwert wird in der
Funktion maskiert.

GetAnruferZeile() liefert die beschriftete Zeile nur fuer source='telefon'
und nur, wenn eine Nummer da ist. In der Liste steht sie in der
Kontaktspalte, wo Telefonnummern hingehoeren; dort ersetzt sie bei Anfragen
ohne zugeordneten Patienten den Platzhalter "Kontaktdaten siehe Nachricht".
In Antwortformular, Hinweisseite, Antwort einsehen und Patientenhistorie
steht sie bei den uebrigen Anfrageinformationen. Die Abfragen der letzten
beiden lasen die Spalte nicht mit, sie ist dort ergaenzt.

Die Spalte ist derzeit in allen 309 Telefonanfragen NULL - die Anlage
schreibt sie noch nicht. Bis dahin aendert sich die Anzeige nicht, der
bisherige Rueckfall greift.

Geprueft: GetTelefonLink gegen internationale, geklammerte, mit Leerzeichen
und Durchwahl geschriebene Nummern sowie gegen Leerwert, NULL, reinen Text
und einen Injektionsversuch - das Script kommt maskiert heraus, nicht als
Markup. Listenlogik ueber alle 12789 Anfragen mit simulierter Nummer
durchgerechnet: der Link erscheint genau dann, wenn source='telefon' ist
und eine Nummer vorliegt, und Nicht-Telefonzeilen bekommen kein
zusaetzliches <br> in die Kontaktspalte.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 17:41:36 +02:00
clemensandClaude Opus 5 e39c00105e Bei Telefonanfragen auf die Sprachnachricht in PraxisKI hinweisen
anfragen.nachricht enthaelt bei source='telefon' nur das automatisch
erzeugte Transkript der Sprachnachricht, das Erkennungsfehler enthalten
kann. Bisher war aus der Oberflaeche nicht ersichtlich, dass es die
Aufnahme selbst noch gibt und wo sie liegt.

Neue Funktion GetSprachnachrichtHinweis() gibt fuer source='telefon' einen
Hinweis mit Link auf https://ki-praxis.praxis.local/ aus, fuer jede andere
Herkunft einen leeren String. Der Link ist nur aus dem Praxisnetz
erreichbar, das steht im Hinweis dabei.

Eingesetzt an allen fuenf Stellen, an denen eine Telefonanfrage sichtbar
wird: Anfrageliste, Antwortformular, Hinweisseite fuer Anfragen ohne
zugeordneten Patienten, Antwort einsehen und Patientenhistorie. Die
Abfragen fuer die letzten beiden lasen source bisher nicht mit, die Spalte
ist dort ergaenzt.

Nachgeprueft gegen die Datenbank: alle vier Abfragen liefern source, und
der Hinweis erscheint ueber alle 12785 Anfragen hinweg genau dann, wenn
source='telefon' ist - je Zeile geprueft, keine Ausnahme. Beide Dateien
bleiben UTF-8 ohne BOM.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 13:07:29 +02:00
clemensandClaude Opus 5 f19c490f44 Telefon-Anfragen ohne Personenbezug wieder anzeigen
Seit dem 02.09.2026 legt die Telefonanlage Anfragen mit source='telefon'
an. Von 309 solchen Zeilen haben 207 keine requester_person_id, weil der
Anrufer nicht zugeordnet werden konnte - darunter alle 33 offenen. Saemtliche
Anzeigepfade haengen per INNER JOIN an persons, wodurch genau diese Zeilen
kommentarlos aus dem Ergebnis fielen. Die Ansicht "Unbeantwortete Anfragen"
zeigte dadurch 2 statt 35 Eintraege.

Bei mail- und portal-Anfragen konnte das nicht auffallen: die Formulare
legen die Person vorher in persons an und brechen sonst ab (rezepte.php),
entsprechend hat dort keine einzige Zeile eine fehlende Person. personid
und userid stehen zwar auch bei mail auf 0, sind aber tote Legacy-Spalten
und tragen den Personenbezug nicht.

Die Anzeigepfade verwenden jetzt LEFT JOIN und zeigen ohne Treffer
"Kein Patient zugeordnet" statt einer leeren Zeile: Liste, Antworten,
Loesch-Dialog, Loeschen ohne Mail und Antwort einsehen. Die beiden Knoepfe,
die zwingend eine Mailadresse brauchen - Antworten und Loeschen mit Mail -
erscheinen nur noch mit zugeordneter Person; wer das Antwortformular direkt
aufruft, bekommt einen Hinweis samt Transkript statt eines Abbruchs.
"Telefonisch beantwortet" brauchte keine Aenderung, dieser Weg fragt nur
die anfrageid ab.

Der Mailversand in functions.inc.php und die patientenbezogene Historie
behalten ihren INNER JOIN - dort ist eine Person Voraussetzung.

Neu ist GetAnfrageHerkunft(). Die Liste leitete die Herkunft bisher aus
sicherenachricht ab, was Telefonanfragen mangels Wert als "Mailanfrage"
etikettierte. Nur dieser dritte Fall kommt aus source. Die Unterscheidung
intern/Mail bleibt bewusst an sicherenachricht haengen, weil insertAnfrage()
kein source setzt und 304 interne Anfragen deshalb als source='mail' in der
Tabelle stehen; ueber source vergeben haette das Label 9871 Bestandszeilen
umetikettiert, darunter die datenschutzrelevante Unterscheidung.

Nachgeprueft gegen die Datenbank: die Ansicht liefert statt 2 nun 35 Zeilen
mit allen 33 offenen Telefon-Anfragen. Der Renderblock ueber alle 12784
Zeilen simuliert, error_reporting=E_ALL als Exception, ohne Warnung; das
Herkunfts-Label bleibt fuer 12475 Bestandszeilen unveraendert und aendert
sich nur fuer die 309 Telefon-Zeilen. Alle fuenf von der Liste erreichbaren
Aktionen laden fuer eine Zeile ohne Person.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 12:56:45 +02:00
clemensandClaude Opus 5 864218c1b8 Zugriffsluecken und SQL-Injection in intern/ und admin/ beheben
Ergebnis der Durchsicht beider Bereiche (47 Dateien).

Patientendaten ohne Anmeldung abrufbar
--------------------------------------
admin/mailtemplate.php hatte keine Zugriffspruefung. Der Endpunkt
liefert gerenderte Mailvorlagen und damit Vorname, Nachname,
Geburtstag, Adresse, Medikamente, Anfragetext und den Anfragen-Link
mit dem hash. Ein anonymer POST wurde bis in die Datenbank verarbeitet
(geprueft mit ungueltiger templetid, es sind keine Daten geflossen).
Jetzt check_admin_user() mit HTTP 401. Ausserdem ging die
Exception-Meldung an den Aufrufer zurueck - sie geht jetzt ins Log.

intern/meineanfragen.php filterte beim Detailaufruf ausschliesslich
auf die anfrageid aus $_POST, ohne Bezug zum angemeldeten Benutzer -
und setzte sie unmaskiert ins SQL. Jeder registrierte Patient konnte
damit fremde Anfragen samt Geburtstag, Adresse und Telefonnummer
lesen. Jetzt Prepared Statement und zusaetzlich an die E-Mail des
angemeldeten Benutzers gebunden, wie in der Listenansicht derselben
Datei.

SQL-Injection
-------------
admin/togoadmin.php: 14 Abfragen bauten $_GET/$_POST direkt in das
SQL. Ganzzahlige Spalten bekommen einen (int)-Cast, damit die
umgebenden mysqli-Schleifen unveraendert bleiben; alle INSERT- und
UPDATE-Anweisungen mit Textwerten sind auf Prepared Statements
umgestellt. create_time dort jetzt per NOW() statt PHP-date().
Ein abschliessender Scan ueber intern/, admin/ und zeiterfassung/
findet keine verkettete Nutzereingabe in SQL mehr.

Zugriffspruefung ohne Wirkung
-----------------------------
admin/anrufbeantworter.php und admin/kalender.php riefen
check_admin_user() auf, werteten den Rueckgabewert aber nie aus. Die
Funktion liefert bei fehlender Anmeldung nur null, sie bricht nicht
ab - beide Seiten rendered fuer anonyme Besucher weiter. Daten flossen
nicht ab, aber die Oberflaeche war sichtbar. Jetzt gleiches Muster wie
admin/index.php.

Tokens ohne Ablauf
------------------
Die Tabelle securitytokens hatte keine Ablaufspalte: ein erbeutetes
Admin-Cookie galt unbegrenzt. Der Patientenbereich setzt 30 Tage.
securitytokensHatAblaufspalte() prueft die Spalte zur Laufzeit, damit
der Code vor und nach der Migration laeuft; admin/login.php setzt den
Ablauf beim Anlegen, check_admin_user() beruecksichtigt ihn beim
Lesen. Migration in admin/sql/. Die Cookie-Laufzeit war auf 365 Tage
gesetzt und ist jetzt deckungsgleich mit dem Token.

Entfernt
--------
admin/phpinfo.php lieferte ohne Anmeldung 102 KB Serverkonfiguration.
intern/admin.php, admin/admin.php sowie mailtemplatebody.php und
mailtemplatebetreff.php in beiden Verzeichnissen waren tot (falscher
relativer require-Pfad, HTTP 500) - die mailtemplate-Vorgaenger
enthielten zudem rohe SQL-Injection mit Patientendaten. Keine der
sechs Dateien wird irgendwo aufgerufen.
admin/sql war als leere Datei statt als Verzeichnis angelegt.

Kleinere Korrekturen
--------------------
intern/authentifizierung.php und admin/passwortzuruecksetzen.php
verglichen das Ergebnis von fetch() mit null statt false und pruefen
den Ablauf des Codes jetzt in SQL statt mit strtotime()/time() -
dieselbe Zeitzonenfalle wie bei den 2FA-Codes, hier in die harmlose
Richtung. Ein toter password_hash()-Aufruf auf einer nie gesetzten
Variablen ist entfallen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 23:48:45 +02:00
clemensandClaude Opus 5 e5f6482811 Fehlerausgabe, stiller Mailversand und fehlende Registrierungs-Mail
Drei zusammenhaengende Punkte aus der Analyse des Login-Problems.

1. PHP-Fehler wurden an Besucher ausgegeben

display_errors war in impfwarteliste.php (oeffentlich), intern/
impfwarteliste.php, intern/neueanfrage.php, den beiden neueanfrage-old
Sicherungen und zeiterfassung/api/vacations.php fest eingeschaltet.
Die oeffentliche Impfwarteliste zeigte Patienten bei einem Fatal Error
sogar Meldung, Dateipfad und Zeilennummer. Ueberall auf log_errors
umgestellt: E_ALL wird weiterhin vollstaendig erfasst, landet aber im
Server-Log statt auf der Seite. Der Shutdown-Handler der Impfwarteliste
protokolliert die Details und zeigt nur noch einen neutralen Hinweis.
Auch register.php gab die rohe Exception-Meldung aus.

2. SendMailMessageSilent() verschluckte jeden Fehler

Der catch-Block war leer. Ein SMTP-Ausfall war dadurch von aussen nicht
von einem falschen Code zu unterscheiden - der Benutzer landete auf
verify_2fa.php und wartete auf eine Mail, die nie kam. Die Funktion
protokolliert jetzt und liefert einen bool zurueck. login.php wertet
das aus, nimmt bei Fehlschlag den 2FA-Datensatz und die Session-Vormerkung
zurueck und sagt es auf der Login-Seite, statt weiterzuleiten.
Nebenbei entfernt: ein uebrig gebliebenes echo, das den Mailserver-Namen
mitten in die Seite schrieb, sowie ein zweites mysqli_fetch_assoc() auf
demselben Result, das nur NULL liefern konnte.

3. register.php verschickte keine Bestaetigungsmail

mailreg blieb 0, jeder neue Benutzer landete nach dem Login auf der
Aufforderung, die Authentifizierung selbst anzustossen. Die Mail geht
jetzt direkt nach der Registrierung raus. Erzeugung und Text liegen in
der neuen Funktion sendeAuthentifizierungsMail(), die authmeldung.php
ebenfalls benutzt - dort wurde der Rueckgabewert des Versands bisher
einer Variablen zugewiesen und nie ausgewertet, die Seite meldete
Erfolg auch bei fehlgeschlagenem Versand.

Der Versand laeuft nach dem commit(), deshalb prueft der catch-Block in
register.php jetzt inTransaction(), bevor er rollBack() aufruft.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 20:53:10 +02:00
clemensandClaude Opus 5 52712aeff0 Anmeldung im internen Bereich repariert
index.php und neueanfrage.php gaben das Header-Template aus, bevor
check_intern_user() aufgerufen wurde. Dadurch schlugen setcookie(),
session_regenerate_id() und header('Location: login.php') mit
"headers already sent" fehl: die Session wurde geloescht ohne neues
Cookie zu setzen, der rotierte Remember-Token landete nur in der DB,
und die Weiterleitung blieb wirkungslos. Ergebnis war eine halb
gerenderte Seite statt des Logins. Beide Seiten puffern jetzt mit
ob_start(), wie login.php und verify_2fa.php es bereits tun.

Weitere Fehler im selben Ablauf:

- passwortvergessen.php uebergab $con (mysqli) an SendMailMessage(),
  das PDO erwartet -> fataler TypeError, "Passwort vergessen" brach
  immer mit HTTP 500 ab und verschickte nie eine Mail.
- logout.php war aus dem Admin-Bereich kopiert und loeschte weder
  intern_securitytokens noch die Cookies remember_device /
  remember_device_token. Der Logout war damit wirkungslos, weil
  check_intern_user() sofort wieder ueber das Cookie anmeldete.
- is_checked_in_index() pruefte das Admin-Cookie 'identifier' statt
  'remember_device'.
- index.php gab das undefinierte $email_value im Login-Formular aus.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 19:35:04 +02:00
clemens 4b4c1f74df impfwarteliste angepasst 2026-03-23 17:14:09 +01:00
clemens c043ee9a52 Inital 2026-03-20 17:13:38 +01:00