diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..52fd351 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.env.local +.local/ +uploads/ diff --git a/config.php b/config.php index 8e86c6d..8fa1324 100644 --- a/config.php +++ b/config.php @@ -1,5 +1,9 @@ "", - "Uid" => "", - "PWD" => "", + "Database" => getenv('DB_NAME') ?: "", + "Uid" => getenv('DB_USER') ?: "", + "PWD" => getenv('DB_PASS') ?: "", "TrustServerCertificate"=>true ); diff --git a/database/mysql-dev-schema.sql b/database/mysql-dev-schema.sql new file mode 100644 index 0000000..136f50f --- /dev/null +++ b/database/mysql-dev-schema.sql @@ -0,0 +1,79 @@ +CREATE TABLE IF NOT EXISTS kl_Mitarbeiter ( + MitarbeiterID INT AUTO_INCREMENT PRIMARY KEY, + Name VARCHAR(255) NOT NULL, + Email VARCHAR(255) NOT NULL, + paypalname VARCHAR(255) NULL, + aktiv TINYINT(1) NOT NULL DEFAULT 1, + admin TINYINT(1) NOT NULL DEFAULT 0, + UNIQUE KEY uq_kl_mitarbeiter_email (Email) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS kl_config ( + id INT AUTO_INCREMENT PRIMARY KEY, + KostenproStrich DECIMAL(10,2) NOT NULL DEFAULT 0.20, + paypaluse TINYINT(1) NOT NULL DEFAULT 0, + paypallink VARCHAR(1000) NOT NULL DEFAULT '', + strichperweb TINYINT(1) NOT NULL DEFAULT 1 +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS kl_Einzahlungen ( + EinzahlungsID INT AUTO_INCREMENT PRIMARY KEY, + MitarbeiterID INT NOT NULL, + Betrag DECIMAL(10,2) NOT NULL, + Datum DATETIME NOT NULL, + KEY idx_kl_einzahlungen_mitarbeiter_datum (MitarbeiterID, Datum), + CONSTRAINT fk_kl_einzahlungen_mitarbeiter + FOREIGN KEY (MitarbeiterID) REFERENCES kl_Mitarbeiter(MitarbeiterID) + ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS kl_Kaffeeverbrauch ( + VerbrauchID INT AUTO_INCREMENT PRIMARY KEY, + MitarbeiterID INT NOT NULL, + AnzahlStriche INT NOT NULL, + Kosten DECIMAL(10,2) NOT NULL, + KostenproStrich DECIMAL(10,2) NOT NULL, + Datum DATETIME NOT NULL, + Eintragsart INT NULL, + KEY idx_kl_verbrauch_mitarbeiter_datum (MitarbeiterID, Datum), + CONSTRAINT fk_kl_verbrauch_mitarbeiter + FOREIGN KEY (MitarbeiterID) REFERENCES kl_Mitarbeiter(MitarbeiterID) + ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS kl_hinweise ( + id INT AUTO_INCREMENT PRIMARY KEY, + nachricht TEXT NOT NULL, + gueltig_bis DATETIME NOT NULL, + KEY idx_kl_hinweise_gueltig_bis (gueltig_bis) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS CoffeeSurveyVotedEmails ( + EmailNorm VARCHAR(255) PRIMARY KEY, + CreatedAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS CoffeeSurveyResponses ( + id INT AUTO_INCREMENT PRIMARY KEY, + Q1_ListEase INT NOT NULL, + Q2_ListSatisfaction INT NOT NULL, + Q3_CoffeeQuality INT NOT NULL, + Q4_WebsiteSatisfaction INT NOT NULL, + Q5_Drinks TEXT NOT NULL, + Q5_DrinksOther TEXT NULL, + Q6_NewVarieties TEXT NULL, + Q7_ListProblem VARCHAR(100) NOT NULL, + Q7_ListProblemOther TEXT NULL, + Q8_Improvements TEXT NOT NULL, + Q8_ImprovementsOther TEXT NULL, + Q9_BetterIdeas TEXT NULL, + CreatedAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +INSERT INTO kl_config (id, KostenproStrich, paypaluse, paypallink, strichperweb) +VALUES (1, 0.20, 0, '', 1) +ON DUPLICATE KEY UPDATE + KostenproStrich = VALUES(KostenproStrich), + paypaluse = VALUES(paypaluse), + strichperweb = VALUES(strichperweb); + diff --git a/docs/dev-mysql.md b/docs/dev-mysql.md new file mode 100644 index 0000000..8cf4b01 --- /dev/null +++ b/docs/dev-mysql.md @@ -0,0 +1,58 @@ +# Lokaler MySQL-Dev-Start + +Die Legacy-App kann fuer Entwicklung gegen MySQL laufen. Dafuer gibt es eine +Kompatibilitaetsschicht, die die verwendeten `sqlsrv_*`-Funktionen im Dev-Modus +auf PDO/MySQL abbildet. + +## Voraussetzungen + +- PHP CLI mit `pdo_mysql`. +- MySQL-Testdatenbank. +- Keine Produktivdaten und keine Produktivpasswoerter im Repository. + +In dieser VS-Code-Server-Umgebung wurde PHP lokal unter `.local/` entpackt, +weil `sudo` nicht passwordless verfuegbar war. `.local/` wird von Git ignoriert. + +## Umgebungsvariablen + +```bash +export APP_DB_DRIVER=mysql +export DB_HOST="example.mysql.host" +export DB_PORT="3306" +export DB_NAME="example_database" +export DB_USER="example_user" +export DB_PASS="example_password" +export DEV_AUTH_EMAIL="admin@test.local" +export DEV_AUTH_NAME="Test Admin" +``` + +## Testdatenbank initialisieren + +```bash +php scripts/init-mysql-dev.php +``` + +In der aktuellen lokalen Umgebung: + +```bash +LD_LIBRARY_PATH="$PWD/.local/php/usr/lib/x86_64-linux-gnu" \ + "$PWD/.local/php/usr/bin/php8.3" \ + -c "$PWD/.local/php-dev.ini" \ + scripts/init-mysql-dev.php +``` + +## Server starten + +```bash +HOST=127.0.0.1 PORT=8080 scripts/run-dev-server.sh +``` + +Danach ist die App unter `http://127.0.0.1:8080/` erreichbar. In VS Code kann +der Port ueber die Ports-Ansicht weitergeleitet werden. + +## Hinweise + +- Der Dev-Login umgeht AD/LDAP und nutzt `DEV_AUTH_EMAIL`. +- Die Tabellen werden aus `database/mysql-dev-schema.sql` angelegt. +- Der Dev-Modus ist nur fuer lokale Tests gedacht, nicht fuer Produktion. + diff --git a/docs/m0/README.md b/docs/m0/README.md new file mode 100644 index 0000000..b5b5c9a --- /dev/null +++ b/docs/m0/README.md @@ -0,0 +1,68 @@ +# M0 Baseline + +Stand: 2026-07-11 + +M0 dokumentiert den aktuellen Legacy-Bestand, bevor die SaaS-Umstrukturierung +beginnt. Ziel ist eine belastbare Ausgangsbasis fuer Migration, Sicherheit, +Design-Erhalt und fachliche Vergleichstests. + +## Status + +| Bereich | Status | Ergebnis | +| --- | --- | --- | +| Code-Inventar | begonnen | Siehe `code-inventory.md` | +| Prozesslandkarte | begonnen | In `code-inventory.md` enthalten | +| Sicherheitsbaseline | begonnen | Siehe `security-baseline.md` | +| DB-Schema-Export | vorbereitet | Siehe `schema-export.sql` | +| Golden-Master-Auswertungen | vorbereitet | Siehe `golden-master-queries.sql` | +| Design-/Screenshot-Referenz | vorbereitet | Siehe `screenshot-checklist.md` | + +## Lokal abgeschlossen + +- Legacy-Einstiegspunkte und Kernseiten identifiziert. +- Fachliche Tabellen aus SQL-Strings abgeleitet. +- Schreibende Aktionen und kritische Datenfluesse markiert. +- Sicherheitsrisiken fuer M0 inventarisiert. +- SQL-Vorlagen fuer Schema- und Golden-Master-Export angelegt. +- Screenshot-Checkliste fuer Design-Erhalt angelegt. + +## Lokale Einschraenkungen + +- `php` ist in dieser Umgebung nicht im PATH, daher kein lokaler Syntaxcheck und + kein lokaler App-Start. +- `sqlcmd` ist in dieser Umgebung nicht im PATH, daher kein direkter + SQL-Server-Export aus der Shell. +- Die aktuelle M0-Auswertung ist deshalb statisch aus dem Code abgeleitet. + +## Blockiert bis Input vorliegt + +Diese Punkte kann ich ohne externe Informationen nicht abschliessen: + +1. Live- oder Staging-DB-Schema exportieren. +2. Tabellen, Spalten, Indizes, Constraints und Rowcounts verifizieren. +3. Golden-Master-Ergebnisse gegen echte Daten berechnen. +4. Screenshots der echten App-Zustaende aufnehmen, falls die App lokal nicht + lauffaehig ist. +5. Produktivumgebung dokumentieren: PHP-Version, Webserver, SQL-Server-Version, + Auth-Setup, Cron/Job-Ausfuehrung. + +## Was ich von dir brauche + +Bitte stelle eines der folgenden Pakete bereit: + +- Idealerweise: einen anonymisierten SQL-Server-Schemaexport plus Rowcounts. +- Alternativ: Zugriffsdaten zu einer Test-/Staging-DB, nicht zu Produktion. +- Alternativ: die Ergebnisse aus `schema-export.sql` als CSV/Excel/Markdown. + +Zusaetzlich hilfreich: + +- Ein erreichbarer Test-Link zur bestehenden App oder Screenshots der Seiten aus + `screenshot-checklist.md`. +- Beispiel-Accounts oder Rollen fuer Admin und normales Mitglied, nur fuer eine + Testumgebung. +- Info, ob hart codierte Zugangsdaten aus Legacy-Skripten bereits rotiert wurden. +- Entscheidung, ob M1/M2 nativ in PHP oder mit Framework vorbereitet werden + sollen. + +Bitte keine Produktivpasswoerter oder echten personenbezogenen Exportdaten in +das Repository legen. diff --git a/docs/m0/code-inventory.md b/docs/m0/code-inventory.md new file mode 100644 index 0000000..60929bf --- /dev/null +++ b/docs/m0/code-inventory.md @@ -0,0 +1,192 @@ +# M0 Code- und Prozessinventar + +Stand: 2026-07-11 + +Dieses Inventar basiert auf statischer Sichtung des Legacy-Codes im Repository. +Das echte Datenbankschema muss noch mit `schema-export.sql` verifiziert werden. + +## App-Struktur + +| Datei | Rolle | Wichtige Beobachtung | +| --- | --- | --- | +| `config.php` | AD-/DB-Konfiguration | Erstellt globale SQL-Server-Verbindung `$conn` | +| `functions.php` | Auth-/User-Helper | Liest `$_SERVER['AUTH_USER']`, inkludiert LDAP, prueft `kl_Mitarbeiter` | +| `functionsLDAP.php` | LDAP-Mailauflösung | Ermittelt `$mailadress` aus AD | +| `header.php` | HTML-Head und Hinweisbanner | Laedt `assets/css/main.css`, liest `kl_hinweise` | +| `footer.php` | App-Sidebar und JS | Enthaelt die sichtbare Navigation und Admin-Menues | +| `nav.php` | Navigation | Aktuell praktisch leer | +| `headerline.php` | Headerfragment | Enthaelt NUL-Zeichen, fachlich unklar | +| `assets/css/main.css` | Hauptdesign | HTML5-UP Editorial, gruener Akzent, Tabellen, Buttons, Sidebar | +| `DataTables/` | Tabellen-Assets | Wird teilweise eingebunden, aber nicht konsequent initialisiert | +| `PHPMailer/` | Mailversand | Fuer Rundmails/Jahresauswertung genutzt | +| `TCPDF/` | PDF-Export | Fuer Kaffeelisten-Export genutzt | + +## Fachliche Kernseiten + +| Datei | Prozess | Tabellen | +| --- | --- | --- | +| `index.php` | Persoenliches Dashboard, Saldo, Jahreswerte, eigene Striche, PayPal-Links | `kl_Mitarbeiter`, `kl_Einzahlungen`, `kl_Kaffeeverbrauch`, `kl_config` | +| `kaffeeliste.php` | Admin-Gesamtuebersicht mit Salden | `kl_Mitarbeiter`, `kl_Einzahlungen`, `kl_Kaffeeverbrauch` | +| `stricheintragen.php` | Sammelerfassung von Strichen | `kl_Mitarbeiter`, `kl_Kaffeeverbrauch`, `kl_config` | +| `einzahlung.php` | Sammelerfassung von Einzahlungen | `kl_Mitarbeiter`, `kl_Einzahlungen`, `kl_Kaffeeverbrauch` | +| `mitarbeiterverwalten.php` | Mitglieder anlegen, bearbeiten, aktivieren, deaktivieren | `kl_Mitarbeiter` | +| `namenanpassen.php` | Anzeigenamen aendern | `kl_Mitarbeiter` | +| `letzteneintraege.php` | Letzte Buchungen anzeigen und loeschen | `kl_Einzahlungen`, `kl_Kaffeeverbrauch`, `kl_Mitarbeiter` | +| `csvupload.php` | Zahlungs-CSV importieren | `kl_Mitarbeiter`, `kl_Einzahlungen` | +| `exportKaffeeliste.php` | PDF-/Listenexport | `kl_Mitarbeiter`, `kl_Kaffeeverbrauch`, `kl_Einzahlungen`, `kl_config` | +| `mailausgebe.php` | Mailausgabe/Adressliste | `kl_Mitarbeiter` | +| `mailversenden.php` | Massenmail mit aktuellem Stand | `kl_Mitarbeiter`, `kl_Einzahlungen`, `kl_Kaffeeverbrauch` | +| `hinweise.php` | Hinweise pflegen | `kl_hinweise` | +| `faq.php` | FAQ | Keine fachlichen Schreibzugriffe | +| `jahresauswertung.php` | Jahresgutschriften und Mailversand | `kl_Mitarbeiter`, `kl_Kaffeeverbrauch`, `kl_Einzahlungen` | +| `umfrage.php` | Umfrage | `CoffeeSurveyVotedEmails`, `CoffeeSurveyResponses` | +| `umfrageergebnisse.php` | Umfrageauswertung | `CoffeeSurveyVotedEmails`, `CoffeeSurveyResponses` | + +## Abgeleitete Tabellen und Spalten + +Diese Spalten sind aus dem Code abgeleitet und muessen mit dem echten DB-Schema +abgeglichen werden. + +| Tabelle | Abgeleitete Spalten | +| --- | --- | +| `kl_Mitarbeiter` | `MitarbeiterID`, `Name`, `Email`, `aktiv`, `admin`, `paypalname` | +| `kl_Einzahlungen` | `EinzahlungsID`, `MitarbeiterID`, `Betrag`, `Datum` | +| `kl_Kaffeeverbrauch` | `VerbrauchID`, `MitarbeiterID`, `AnzahlStriche`, `Kosten`, `KostenproStrich`, `Datum`, `Eintragsart` | +| `kl_config` | `KostenproStrich`, `paypaluse`, `paypallink`, `strichperweb` | +| `kl_hinweise` | `id`, `nachricht`, `gueltig_bis` | +| `CoffeeSurveyVotedEmails` | `EmailNorm` | +| `CoffeeSurveyResponses` | diverse `Q*_`-Antwortspalten, siehe Umfragecode | + +## Prozesslandkarte + +### Authentifizierung + +1. Seite inkludiert `functions.php`. +2. `functions.php` liest `$_SERVER['AUTH_USER']`. +3. `functionsLDAP.php` ermittelt Mailadresse aus LDAP. +4. Zugriff wird ueber `kl_Mitarbeiter.Email`, `aktiv` und `admin` geprueft. + +M0-Feststellung: +Login-Konto, Kaffee-Teilnehmer und Rolle sind im Legacy-Modell gekoppelt. Fuer +SaaS muessen `users`, `participants` und `tenant_memberships` getrennt werden. + +### Persoenliches Dashboard + +Quelle: `index.php`. + +Berechnungen: + +- Mitarbeiter per E-Mail suchen. +- Gesamteinzahlung: Summe `kl_Einzahlungen.Betrag`. +- Gesamtausgabe: Summe `kl_Kaffeeverbrauch.Kosten`. +- Gesamtstriche: Summe `kl_Kaffeeverbrauch.AnzahlStriche`. +- Aktueller Stand: Einzahlungen minus Ausgaben. +- Jahreswerte: dieselben Summen fuer aktuelles Jahr. +- Bei aktivem `strichperweb`: eigener Strich-Eintrag. +- Bei aktivem `paypaluse`: PayPal-Links anzeigen. + +### Sammelerfassung Striche + +Quelle: `stricheintragen.php`. + +Berechnungen: + +- Preis pro Strich aus `kl_config.KostenproStrich`. +- Pro Mitarbeiter eine Anzahl erfassen. +- Kosten = Anzahl Striche mal Kosten pro Strich. +- Neue Zeile in `kl_Kaffeeverbrauch`. +- Filter `vorderseite`/`rueckseite` anhand der letzten 100 Tage und mindestens + 10 Strichen. + +### Sammelerfassung Einzahlungen + +Quelle: `einzahlung.php`. + +Berechnungen: + +- Pro Mitarbeiter Betrag erfassen. +- Neue Zeile in `kl_Einzahlungen`. +- Filter `vorderseite`/`rueckseite` anhand der letzten 100 Tage und mindestens + 10 Strichen. + +### Mitgliederverwaltung + +Quelle: `mitarbeiterverwalten.php`. + +Aktionen: + +- Mitglied anlegen. +- Name/E-Mail/aktiv/admin bearbeiten. +- Mitglied aktivieren/deaktivieren. + +M0-Feststellung: +Das `admin`-Flag ist fachlich eine Rolleninformation und sollte in der SaaS-App +in `tenant_memberships.role` wandern. + +### Korrektur letzter Eintraege + +Quelle: `letzteneintraege.php`. + +Aktionen: + +- Letzte 100 Einzahlungen anzeigen. +- Einzahlung hart loeschen. +- Letzte 100 Strich-Eintraege anzeigen. +- Strich-Eintrag hart loeschen. + +M0-Feststellung: +Finanznahe Korrekturen sollten im Zielmodell nicht hart loeschen, sondern als +Storno/Reversal mit Audit-Trail gespeichert werden. + +### CSV-Import + +Quelle: `csvupload.php`. + +Ablauf: + +- Datei hochladen. +- CSV mit Komma-Trennung lesen. +- Name aus Spalte 3, Betrag aus Spalte 7, Datum aus Spalte 0. +- Mitarbeiter per Name oder `paypalname` finden. +- Dublette anhand Mitarbeiter, Betrag und Datumstag pruefen. +- Einzahlung einfuegen. +- Ergebnis in Erfolg/Fehler klassifizieren. + +### Jahresauswertung + +Quelle: `jahresauswertung.php`. + +Ablauf: + +- Gesamtstriche des aktuellen Jahres je aktivem Mitarbeiter berechnen. +- Konstante Anzahl neuer Striche/Gutschrift proportional verteilen. +- Einzahlung je Mitarbeiter einfuegen. +- Mail an Mitarbeiter senden. + +M0-Feststellung: +Das ist ein Job/Skript mit direktem Schreibzugriff und Mailversand. Fuer SaaS +braucht es Dry-Run, Audit, Tenant-Scope und expliziten Operator. + +## Offene Schemafragen + +- Gibt es Primary Keys, Foreign Keys und Indizes fuer die `kl_`-Tabellen? +- Sind Datentypen fuer Geldwerte `decimal`, `money`, `float` oder etwas anderes? +- Gibt es historische Spalten, die im Code nicht sichtbar sind? +- Gibt es Trigger oder Stored Procedures? +- Gibt es weitere Jobs ausser `jahresauswertung.php`? +- Sind Umfragetabellen produktiv relevant oder Archiv/Randmodul? + +## Technische Legacy-Inkonsistenzen + +- `csvupload.php` sucht in `getMitarbeiterID` nach `Name = ? or paypalname = ?`, + uebergibt aber nur einen Parameter. Das sollte vor der Uebernahme des + CSV-Flows verifiziert werden. +- `einzahlung.php` prueft auf `$_GET["action"]`, die Buttons erzeugen aber + URLs mit `aktion=...`. Der Vorderseite-/Rueckseite-Filter kann dadurch + abweichend vom erwarteten Verhalten laufen. +- `exportKaffeeliste.php` referenziert `tcpdf/tcpdf.php`, waehrend der Ordner im + Repository `TCPDF/` heisst. Auf case-sensitiven Dateisystemen kann das + brechen. +- `headerline.php` ist fachlich leer beziehungsweise mit NUL-Zeichen gefuellt. + Vor der neuen App-Shell sollte geklaert werden, ob die Datei entfernt werden + kann. diff --git a/docs/m0/golden-master-queries.sql b/docs/m0/golden-master-queries.sql new file mode 100644 index 0000000..849bcaa --- /dev/null +++ b/docs/m0/golden-master-queries.sql @@ -0,0 +1,185 @@ +/* +M0 Golden-Master Queries + +Ziel: +- Fachliche Legacy-Ergebnisse exportieren, damit Migration und neue SaaS-App + gegen denselben Stand verglichen werden koennen. + +Ausfuehrung: +- In der Legacy-Datenbank ausfuehren. +- Ergebnisse je Query als CSV speichern. +- Keine personenbezogenen Exporte ins Repo committen. +*/ + +DECLARE @CurrentYear int = YEAR(GETDATE()); + +-- 1. Teilnehmer-Basisdaten +SELECT + m.MitarbeiterID, + m.Name, + m.Email, + m.aktiv, + m.admin +FROM kl_Mitarbeiter m +ORDER BY m.Name; + +-- 2. Saldo je Teilnehmer +WITH payments AS ( + SELECT + MitarbeiterID, + SUM(Betrag) AS total_payments + FROM kl_Einzahlungen + GROUP BY MitarbeiterID +), +consumption AS ( + SELECT + MitarbeiterID, + SUM(Kosten) AS total_costs, + SUM(AnzahlStriche) AS total_marks + FROM kl_Kaffeeverbrauch + GROUP BY MitarbeiterID +) +SELECT + m.MitarbeiterID, + m.Name, + m.Email, + COALESCE(p.total_payments, 0) AS total_payments, + COALESCE(c.total_costs, 0) AS total_costs, + COALESCE(c.total_marks, 0) AS total_marks, + COALESCE(p.total_payments, 0) - COALESCE(c.total_costs, 0) AS balance +FROM kl_Mitarbeiter m +LEFT JOIN payments p ON p.MitarbeiterID = m.MitarbeiterID +LEFT JOIN consumption c ON c.MitarbeiterID = m.MitarbeiterID +ORDER BY m.Name; + +-- 3. Jahreswerte je Teilnehmer +WITH year_payments AS ( + SELECT + MitarbeiterID, + SUM(Betrag) AS year_payments + FROM kl_Einzahlungen + WHERE YEAR(Datum) = @CurrentYear + GROUP BY MitarbeiterID +), +year_consumption AS ( + SELECT + MitarbeiterID, + SUM(Kosten) AS year_costs, + SUM(AnzahlStriche) AS year_marks + FROM kl_Kaffeeverbrauch + WHERE YEAR(Datum) = @CurrentYear + GROUP BY MitarbeiterID +) +SELECT + m.MitarbeiterID, + m.Name, + m.Email, + COALESCE(yp.year_payments, 0) AS year_payments, + COALESCE(yc.year_costs, 0) AS year_costs, + COALESCE(yc.year_marks, 0) AS year_marks +FROM kl_Mitarbeiter m +LEFT JOIN year_payments yp ON yp.MitarbeiterID = m.MitarbeiterID +LEFT JOIN year_consumption yc ON yc.MitarbeiterID = m.MitarbeiterID +ORDER BY m.Name; + +-- 4. Letzte Einzahlungen je Teilnehmer +SELECT + e.EinzahlungsID, + e.MitarbeiterID, + m.Name, + m.Email, + e.Betrag, + e.Datum +FROM kl_Einzahlungen e +JOIN kl_Mitarbeiter m ON m.MitarbeiterID = e.MitarbeiterID +ORDER BY e.Datum DESC, e.EinzahlungsID DESC; + +-- 5. Letzte Strich-/Verbrauchseintraege je Teilnehmer +SELECT + v.VerbrauchID, + v.MitarbeiterID, + m.Name, + m.Email, + v.AnzahlStriche, + v.Kosten, + v.KostenproStrich, + v.Datum, + v.Eintragsart +FROM kl_Kaffeeverbrauch v +JOIN kl_Mitarbeiter m ON m.MitarbeiterID = v.MitarbeiterID +ORDER BY v.Datum DESC, v.VerbrauchID DESC; + +-- 6. Aktuelle Konfiguration +SELECT + * +FROM kl_config; + +-- 7. Aktuelle und historische Hinweise +SELECT + id, + nachricht, + gueltig_bis +FROM kl_hinweise +ORDER BY gueltig_bis DESC; + +-- 8. 100-Tage-Vorderseite analog Legacy-Stricherfassung +DECLARE @ReferenceDate date = ( + SELECT MAX(CAST(Datum AS date)) + FROM kl_Kaffeeverbrauch + WHERE Datum < CAST(GETDATE() AS date) +); + +SELECT + m.MitarbeiterID, + m.Name, + m.Email, + SUM(v.AnzahlStriche) AS marks_in_window +FROM kl_Mitarbeiter m +JOIN kl_Kaffeeverbrauch v ON m.MitarbeiterID = v.MitarbeiterID +WHERE v.Datum >= DATEADD(DAY, -100, @ReferenceDate) + AND m.aktiv = 1 +GROUP BY m.MitarbeiterID, m.Name, m.Email +HAVING SUM(v.AnzahlStriche) >= 10 +ORDER BY m.Name; + +-- 9. 100-Tage-Rueckseite analog Legacy-Stricherfassung +SELECT + m.MitarbeiterID, + m.Name, + m.Email, + COALESCE(SUM(v.AnzahlStriche), 0) AS marks_in_window +FROM kl_Mitarbeiter m +LEFT JOIN kl_Kaffeeverbrauch v + ON m.MitarbeiterID = v.MitarbeiterID + AND v.Datum >= DATEADD(DAY, -100, @ReferenceDate) +WHERE m.aktiv = 1 +GROUP BY m.MitarbeiterID, m.Name, m.Email +HAVING COALESCE(SUM(v.AnzahlStriche), 0) < 10 +ORDER BY m.Name; + +-- 10. CSV-Import-Dublettenbasis +SELECT + e.MitarbeiterID, + m.Name, + e.Betrag, + CONVERT(varchar, e.Datum, 23) AS booking_day, + COUNT(*) AS duplicates_per_day +FROM kl_Einzahlungen e +JOIN kl_Mitarbeiter m ON m.MitarbeiterID = e.MitarbeiterID +GROUP BY e.MitarbeiterID, m.Name, e.Betrag, CONVERT(varchar, e.Datum, 23) +HAVING COUNT(*) > 1 +ORDER BY booking_day DESC, m.Name; + +-- 11. Jahresauswertung: Gesamtstriche aktuelles Jahr +SELECT + m.MitarbeiterID, + m.Name, + m.Email, + SUM(v.AnzahlStriche) AS GesamtStriche +FROM kl_Kaffeeverbrauch v +JOIN kl_Mitarbeiter m ON v.MitarbeiterID = m.MitarbeiterID +WHERE YEAR(v.Datum) = @CurrentYear + AND m.aktiv = 1 +GROUP BY m.MitarbeiterID, m.Name, m.Email +ORDER BY GesamtStriche DESC, m.Name; + diff --git a/docs/m0/schema-export.sql b/docs/m0/schema-export.sql new file mode 100644 index 0000000..dd5acbf --- /dev/null +++ b/docs/m0/schema-export.sql @@ -0,0 +1,152 @@ +/* +M0 SQL Server Schema Export + +Ziel: +- Tabellen, Spalten, Defaults, Primary Keys, Foreign Keys, Indizes und Rowcounts + aus der Legacy-Datenbank exportieren. + +Hinweise: +- In der Ziel-Datenbank ausfuehren. +- Ergebnisse als CSV/Excel speichern und nicht mit Produktiv-Secrets ins Repo + legen. +- Falls nur Kaffeelisten-Tabellen exportiert werden sollen, den WHERE-Block bei + Bedarf auf kl_% und CoffeeSurvey% begrenzen. +*/ + +-- 1. Tabellen und Spalten +SELECT + s.name AS schema_name, + t.name AS table_name, + c.column_id, + c.name AS column_name, + ty.name AS data_type, + c.max_length, + c.precision, + c.scale, + c.is_nullable, + c.is_identity, + dc.definition AS default_definition, + ep.value AS column_description +FROM sys.tables t +JOIN sys.schemas s ON s.schema_id = t.schema_id +JOIN sys.columns c ON c.object_id = t.object_id +JOIN sys.types ty ON ty.user_type_id = c.user_type_id +LEFT JOIN sys.default_constraints dc + ON dc.parent_object_id = t.object_id + AND dc.parent_column_id = c.column_id +LEFT JOIN sys.extended_properties ep + ON ep.major_id = t.object_id + AND ep.minor_id = c.column_id + AND ep.name = 'MS_Description' +WHERE t.is_ms_shipped = 0 +ORDER BY s.name, t.name, c.column_id; + +-- 2. Primary Keys und Unique Constraints +SELECT + s.name AS schema_name, + t.name AS table_name, + kc.name AS constraint_name, + kc.type_desc, + ic.key_ordinal, + c.name AS column_name +FROM sys.key_constraints kc +JOIN sys.tables t ON t.object_id = kc.parent_object_id +JOIN sys.schemas s ON s.schema_id = t.schema_id +JOIN sys.index_columns ic + ON ic.object_id = kc.parent_object_id + AND ic.index_id = kc.unique_index_id +JOIN sys.columns c + ON c.object_id = ic.object_id + AND c.column_id = ic.column_id +ORDER BY s.name, t.name, kc.name, ic.key_ordinal; + +-- 3. Foreign Keys +SELECT + sch_parent.name AS parent_schema, + parent_t.name AS parent_table, + parent_c.name AS parent_column, + fk.name AS foreign_key_name, + sch_ref.name AS referenced_schema, + ref_t.name AS referenced_table, + ref_c.name AS referenced_column, + fk.delete_referential_action_desc, + fk.update_referential_action_desc +FROM sys.foreign_keys fk +JOIN sys.foreign_key_columns fkc ON fkc.constraint_object_id = fk.object_id +JOIN sys.tables parent_t ON parent_t.object_id = fkc.parent_object_id +JOIN sys.schemas sch_parent ON sch_parent.schema_id = parent_t.schema_id +JOIN sys.columns parent_c + ON parent_c.object_id = fkc.parent_object_id + AND parent_c.column_id = fkc.parent_column_id +JOIN sys.tables ref_t ON ref_t.object_id = fkc.referenced_object_id +JOIN sys.schemas sch_ref ON sch_ref.schema_id = ref_t.schema_id +JOIN sys.columns ref_c + ON ref_c.object_id = fkc.referenced_object_id + AND ref_c.column_id = fkc.referenced_column_id +ORDER BY parent_schema, parent_table, foreign_key_name; + +-- 4. Indizes +SELECT + s.name AS schema_name, + t.name AS table_name, + i.name AS index_name, + i.type_desc, + i.is_unique, + i.is_primary_key, + i.is_unique_constraint, + ic.key_ordinal, + ic.is_included_column, + c.name AS column_name +FROM sys.indexes i +JOIN sys.tables t ON t.object_id = i.object_id +JOIN sys.schemas s ON s.schema_id = t.schema_id +JOIN sys.index_columns ic + ON ic.object_id = i.object_id + AND ic.index_id = i.index_id +JOIN sys.columns c + ON c.object_id = ic.object_id + AND c.column_id = ic.column_id +WHERE t.is_ms_shipped = 0 + AND i.name IS NOT NULL +ORDER BY s.name, t.name, i.name, ic.key_ordinal, ic.index_column_id; + +-- 5. Rowcounts +SELECT + s.name AS schema_name, + t.name AS table_name, + SUM(p.row_count) AS row_count +FROM sys.tables t +JOIN sys.schemas s ON s.schema_id = t.schema_id +JOIN sys.dm_db_partition_stats p ON p.object_id = t.object_id +WHERE t.is_ms_shipped = 0 + AND p.index_id IN (0, 1) +GROUP BY s.name, t.name +ORDER BY s.name, t.name; + +-- 6. Trigger +SELECT + s.name AS schema_name, + t.name AS table_name, + tr.name AS trigger_name, + tr.is_disabled, + OBJECT_DEFINITION(tr.object_id) AS trigger_definition +FROM sys.triggers tr +JOIN sys.tables t ON t.object_id = tr.parent_id +JOIN sys.schemas s ON s.schema_id = t.schema_id +ORDER BY s.name, t.name, tr.name; + +-- 7. Stored Procedures und Funktionen mit Kaffeelisten-Bezug +SELECT + s.name AS schema_name, + o.name AS object_name, + o.type_desc, + OBJECT_DEFINITION(o.object_id) AS object_definition +FROM sys.objects o +JOIN sys.schemas s ON s.schema_id = o.schema_id +WHERE o.type IN ('P', 'FN', 'IF', 'TF') + AND ( + OBJECT_DEFINITION(o.object_id) LIKE '%kl_%' + OR OBJECT_DEFINITION(o.object_id) LIKE '%CoffeeSurvey%' + ) +ORDER BY s.name, o.name; + diff --git a/docs/m0/screenshot-checklist.md b/docs/m0/screenshot-checklist.md new file mode 100644 index 0000000..0cdc878 --- /dev/null +++ b/docs/m0/screenshot-checklist.md @@ -0,0 +1,74 @@ +# M0 Screenshot- und Designreferenz + +Stand: 2026-07-11 + +Diese Checkliste friert die bestehende Optik ein, damit die SaaS-App vertraut +bleibt. Wenn die Legacy-App lokal nicht laeuft, koennen Screenshots aus der +aktuellen Umgebung geliefert werden. + +## Allgemeine Vorgaben + +- Je Seite Desktop mit ca. 1440 px Breite aufnehmen. +- Je Kernseite mobile Ansicht mit ca. 390 px Breite aufnehmen. +- Je relevanter Rolle aufnehmen: normales Mitglied und Admin. +- Keine echten personenbezogenen Daten veroeffentlichen. +- Dateinamen nach Muster `m0---.png`. + +## Kernseiten + +| Prioritaet | Seite | Rolle | Zweck | +| --- | --- | --- | --- | +| Hoch | `index.php` | Mitglied | Dashboard, Saldo, Jahreswerte, eigene Striche, PayPal | +| Hoch | `index.php` | Admin | Dashboard mit Admin-Sidebar | +| Hoch | `kaffeeliste.php` | Admin | Gesamtuebersicht und Aktionsleiste | +| Hoch | `stricheintragen.php` | Admin | Sammelerfassung Striche | +| Hoch | `einzahlung.php` | Admin | Sammelerfassung Einzahlungen | +| Hoch | `mitarbeiterverwalten.php` | Admin | Mitgliederliste und Formular | +| Hoch | `letzteneintraege.php` | Admin | Korrekturansicht mit Loeschaktionen | +| Mittel | `csvupload.php` | Admin | CSV-Upload-Formular und Ergebniszustand | +| Mittel | `hinweise.php` | Admin | Hinweise anlegen und Liste | +| Mittel | `faq.php` | Mitglied | FAQ-Inhalte und Typografie | +| Mittel | `namenanpassen.php` | Mitglied | Formular fuer Anzeigenamen | +| Niedrig | `teilnehmerauswertung.php` | Admin | Detailauswertung pro Teilnehmer | +| Niedrig | `mailausgebe.php` | Admin | Mail-/Adressausgabe | +| Niedrig | `umfrage.php` | Mitglied | Randmodul, falls SaaS-relevant | +| Niedrig | `umfrageergebnisse.php` | Admin | Randmodul, falls SaaS-relevant | + +## Zustaende + +Diese UI-Zustaende sollten, wenn moeglich, separat gesichert werden: + +- Positiver Kontostand. +- Negativer Kontostand. +- Nullsaldo. +- Nutzer ohne Zugriff. +- Erfolgreiche Stricherfassung. +- Erfolgreiche Einzahlung. +- CSV-Import mit Erfolg, Dublette und unbekanntem Nutzer. +- Hinweisbanner aktiv. +- Leere Tabellen oder keine Daten. +- Mobile Sidebar geoeffnet und geschlossen. + +## Designmerkmale, die erhalten bleiben sollen + +- Gruener Akzent fuer Links, Buttons und Linien. +- Roboto-Slab-Ueberschriften. +- Open-Sans-Fliesstext. +- Weisse Hauptflaeche. +- Graue Sidebar in der App. +- Tabellen als zentrale Datenansicht. +- Schlichte Formularfelder und Buttons. +- Ruhige, arbeitsorientierte Darstellung. + +## Ablage + +Empfohlene lokale Ablage, falls Screenshots ins Repo sollen: + +```text +docs/m0/screenshots/ +``` + +Vor dem Commit sollten Screenshots anonymisiert sein. Wenn echte Namen, +E-Mails, PayPal-Daten oder Salden sichtbar sind, besser ausserhalb des Repos +ablegen und nur als Referenz verwenden. + diff --git a/docs/m0/security-baseline.md b/docs/m0/security-baseline.md new file mode 100644 index 0000000..938f4f8 --- /dev/null +++ b/docs/m0/security-baseline.md @@ -0,0 +1,67 @@ +# M0 Sicherheitsbaseline + +Stand: 2026-07-11 + +Diese Baseline ist eine statische Erstbewertung. Sie ersetzt kein Penetration +Testing, markiert aber die wichtigsten Risiken fuer die SaaS-Umstrukturierung. + +## Kritische Sofortthemen + +| Risiko | Quelle | Auswirkung | Empfehlung | +| --- | --- | --- | --- | +| Hart codierte DB-Zugangsdaten | `jahresauswertung.php` Zeilen 4-8 | Secret-Leak, direkte Produktiv-DB-Gefahr | Zugangsdaten rotieren, Skript deaktivieren oder auf Env-Konfiguration umstellen | +| Schreibende Seiten ohne eigene Rollenpruefung | `stricheintragen.php`, `einzahlung.php`, `mailversenden.php`, `exportKaffeeliste.php` | Direkter URL-Aufruf kann Aktionen erlauben | Jede Seite serverseitig mit `requireRole` absichern | +| Keine CSRF-Token | nahezu alle POST-/Delete-Formulare | Ungewollte Buchungen, Loeschungen, Imports | CSRF fuer alle schreibenden Aktionen | +| Harte Deletes fuer Buchungen | `letzteneintraege.php` | Audit-Historie und Revisionsfaehigkeit gehen verloren | Storno-/Reversal-Modell statt Delete | +| Upload in Webroot | `csvupload.php` Zeilen 131-138 | Dateiablage kann missbraucht werden | Upload ausserhalb Webroot, Dateityp/MIME/Name pruefen | +| Kein Tenant-Scope | alle fachlichen Queries | Zentrales SaaS-Leak-Risiko | `tenant_id` verpflichtend und Query-Schicht testen | + +## Weitere Befunde + +| Befund | Quelle | Empfehlung | +| --- | --- | --- | +| Direkte SQL-String-Verkettung mit Mailadresse | `functions.php` Zeilen 14, 25, 39, 55 | Parameterisierte Queries | +| LDAP-Filter ohne Escaping | `functionsLDAP.php` LDAP-Suchen nach `samaccountname` | LDAP-Filter escapen | +| Datumsformat `Y-d-m H:i:s` | `index.php`, `stricheintragen.php`, `einzahlung.php`, `hinweise.php` | ISO/DB-kompatibel `Y-m-d H:i:s` oder DB-Zeit verwenden | +| Ausgabe von Namen/E-Mails teils unescaped | mehrere Tabellen, z.B. Mitgliederverwaltung | `htmlspecialchars` zentral erzwingen | +| Fehlerausgabe mit `sqlsrv_errors()` an Nutzer | mehrere Dateien | Logging intern, neutrale Fehlermeldung extern | +| CSV-Mitarbeitersuche mit mutmasslich falscher Parameteranzahl | `csvupload.php` `getMitarbeiterID` | Query pruefen und mit Tests abdecken | +| Basis-Auth-Beispiel mit Platzhalter-Passwort | `umfrageergebnisse.php` Kommentarblock | Entfernen oder echte Auth-Middleware nutzen | +| App-Navigation ist in `footer.php` | Layoutstruktur | Trennung in App-Shell und Public-Shell | +| `headerline.php` enthaelt NUL-Zeichen | `headerline.php` | Datei pruefen/entfernen, wenn ungenutzt | +| Massenmail ohne Versandlog | `mailversenden.php`, `jahresauswertung.php` | Job-Modell mit Dry-Run, Audit, Rate-Limit | + +## Rollen- und Zugriffsrisiken + +Aktuell gilt: + +- Sichtbare Admin-Navigation wird in `footer.php` per `checkKaffeelisteAdmin` + gesteuert. +- Einige Admin-Zielseiten haben eigene Checks. +- Einige schreibende oder sensible Dateien verlassen sich nicht durchgehend auf + eine eigene Rollenpruefung. + +Fuer SaaS gilt: + +- Menue-Ausblendung ist keine Berechtigungspruefung. +- Jede Route braucht serverseitige Auth- und Rollenpruefung. +- Jede fachliche Route braucht Tenant-Kontext. +- IDs aus Requests muessen zum aktuellen Tenant gehoeren. + +## Empfohlene Reihenfolge fuer Sicherheitsarbeit + +1. Secrets rotieren und aus dem Code entfernen. +2. Legacy-Schreibseiten bis zum Umbau hinter explizite Admin-Pruefung setzen. +3. CSRF-Schutz in der neuen App-Basis einplanen. +4. Finanzdaten im Zielmodell nur noch stornieren, nicht loeschen. +5. Uploads ausserhalb des Webroots modellieren. +6. Einheitliche Escape-/View-Helfer einfuehren. +7. Tenant-Isolation mit Tests gegen zwei Tenants absichern. + +## Nicht im Repo speichern + +- Produktivpasswoerter. +- Echte Nutzerlisten. +- Bank-/PayPal-Exportdaten mit Personenbezug. +- AD-/LDAP-Service-Account-Daten. +- Vollstaendige Produktiv-Dumps. diff --git a/docs/saas-umstrukturierungsplan.md b/docs/saas-umstrukturierungsplan.md index 1ee396c..588e50f 100644 --- a/docs/saas-umstrukturierungsplan.md +++ b/docs/saas-umstrukturierungsplan.md @@ -296,6 +296,15 @@ Uebersicht: Ziel: Den aktuellen Zustand verlaesslich dokumentieren, bevor umgebaut wird. +Arbeitsartefakte: + +- `docs/m0/README.md` +- `docs/m0/code-inventory.md` +- `docs/m0/security-baseline.md` +- `docs/m0/schema-export.sql` +- `docs/m0/golden-master-queries.sql` +- `docs/m0/screenshot-checklist.md` + Schritte: - Live-DB-Schema exportieren. diff --git a/einzahlung.php b/einzahlung.php index d0f282e..5d58603 100644 --- a/einzahlung.php +++ b/einzahlung.php @@ -28,7 +28,7 @@ if ($_SERVER["REQUEST_METHOD"] == "POST" ) { $anzahlBetrag = floatval($anzahlBetrag); if($anzahlBetrag != 0){ - $datum = date("Y-d-m H:i:s"); // Das aktuelle Datum verwenden + $datum = date("Y-m-d H:i:s"); // Das aktuelle Datum verwenden // SQL-Abfrage zum Einfügen der Daten $sql = "INSERT INTO kl_Einzahlungen (MitarbeiterID, Betrag, Datum) VALUES (?, ?, ?)"; @@ -149,4 +149,4 @@ $stmtMitarbeiter = sqlsrv_query($conn, $sqlMitarbeiter); - \ No newline at end of file + diff --git a/functions.php b/functions.php index 98d97ff..7a54ad0 100644 --- a/functions.php +++ b/functions.php @@ -2,12 +2,18 @@ include_once "config.php"; -$kennungtemp = $_SERVER['AUTH_USER']; -$teile = explode("\\", $kennungtemp); -$kennung = $teile[1]; - +$devAuthEmail = getenv('DEV_AUTH_EMAIL') ?: ''; +if ($devAuthEmail !== '') { + $mailadress = strtolower(trim($devAuthEmail)); + $kennung = $mailadress; +} else { + $kennungtemp = $_SERVER['AUTH_USER']; + $teile = explode("\\", $kennungtemp); + $kennung = $teile[1]; + -include_once "functionsLDAP.php"; + include_once "functionsLDAP.php"; +} function getUserName($conn, $mail){ @@ -66,4 +72,4 @@ function checkKaffeelisteAdmin($conn, $mail) } -?> \ No newline at end of file +?> diff --git a/headerline.php b/headerline.php index 06d7405..95dea3c 100644 Binary files a/headerline.php and b/headerline.php differ diff --git a/hinweise.php b/hinweise.php index 847f1e4..7944bdb 100644 --- a/hinweise.php +++ b/hinweise.php @@ -35,7 +35,7 @@ if(checkKaffeelisteAdmin($conn, $mailadress)){ $dt = DateTime::createFromFormat('Y-m-d\TH:i', $gueltig_bis); if ($dt) { - $gueltig_bis_sql = $dt->format('Y-d-m H:i:s'); // z.B. "2025-03-09 14:00:00" + $gueltig_bis_sql = $dt->format('Y-m-d H:i:s'); // z.B. "2025-09-03 14:00:00" } else { die("Ungültiges Datumsformat"); } @@ -92,4 +92,4 @@ if(checkKaffeelisteAdmin($conn, $mailadress)){ \ No newline at end of file +?> diff --git a/index.php b/index.php index 95810f7..0dc04ec 100644 --- a/index.php +++ b/index.php @@ -166,7 +166,7 @@ if(checkKaffeelisteAccess($conn, $mailadress)){ $anzahlStriche = (int)$anzahlStriche; $kostenproStrich = floatval($KostenproStrichtemp); $kosten = floatval($anzahlStriche * $KostenproStrichtemp); - $datum = date("Y-d-m H:i:s"); // Das aktuelle Datum verwenden + $datum = date("Y-m-d H:i:s"); // Das aktuelle Datum verwenden if($anzahlStriche != 0){ // SQL-Abfrage zum Einfügen der Daten $sql = "INSERT INTO kl_Kaffeeverbrauch (MitarbeiterID, AnzahlStriche, Kosten, KostenproStrich, Datum, Eintragsart) VALUES (?, ?, ?, ?, ?, 2)"; @@ -238,4 +238,4 @@ if(checkKaffeelisteAccess($conn, $mailadress)){ \ No newline at end of file +?> diff --git a/jahresauswertung.php b/jahresauswertung.php index b21c218..ef2f317 100644 --- a/jahresauswertung.php +++ b/jahresauswertung.php @@ -1,11 +1,11 @@ "Automatisierungsclient", - "Uid" => "ac_admin", - "PWD" => "allacc3ssPW", + "Database" => "database", + "Uid" => "user", + "PWD" => "password", "TrustServerCertificate"=>true ); diff --git a/lib/sqlsrv_mysql_compat.php b/lib/sqlsrv_mysql_compat.php new file mode 100644 index 0000000..361a34b --- /dev/null +++ b/lib/sqlsrv_mysql_compat.php @@ -0,0 +1,245 @@ +pdo = $pdo; + } +} + +final class MysqlSqlsrvStatement +{ + /** @var array> */ + public array $rows; + public int $index = 0; + public int $affectedRows = 0; + + /** + * @param array> $rows + */ + public function __construct(array $rows = [], int $affectedRows = 0) + { + $this->rows = $rows; + $this->affectedRows = $affectedRows; + } +} + +function sqlsrv_connect($serverName, array $connectionOptions = []) +{ + $database = getenv('DB_NAME') ?: ($connectionOptions['Database'] ?? ''); + $user = getenv('DB_USER') ?: ($connectionOptions['Uid'] ?? ''); + $password = getenv('DB_PASS') ?: ($connectionOptions['PWD'] ?? ''); + $host = getenv('DB_HOST') ?: (string)$serverName; + $port = getenv('DB_PORT') ?: '3306'; + + try { + $dsn = sprintf('mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4', $host, $port, $database); + $pdo = new PDO($dsn, $user, $password, [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => true, + ]); + + return new MysqlSqlsrvConnection($pdo); + } catch (Throwable $e) { + $GLOBALS['__sqlsrv_mysql_last_error'] = [[ + 'SQLSTATE' => $e->getCode(), + 'message' => $e->getMessage(), + ]]; + return false; + } +} + +function sqlsrv_query($conn, string $sql, array $params = [], array $options = []) +{ + if (!$conn instanceof MysqlSqlsrvConnection) { + $GLOBALS['__sqlsrv_mysql_last_error'] = [[ + 'SQLSTATE' => 'HY000', + 'message' => 'Invalid MySQL compatibility connection.', + ]]; + return false; + } + + $translatedSql = sqlsrv_mysql_translate_query($sql); + + try { + $stmt = $conn->pdo->prepare($translatedSql); + $stmt->execute(sqlsrv_mysql_normalize_params($translatedSql, $params)); + + if ($stmt->columnCount() > 0) { + $rows = $stmt->fetchAll(PDO::FETCH_ASSOC); + foreach ($rows as &$row) { + $row = sqlsrv_mysql_normalize_row($row); + } + unset($row); + + return new MysqlSqlsrvStatement($rows, $stmt->rowCount()); + } + + return new MysqlSqlsrvStatement([], $stmt->rowCount()); + } catch (Throwable $e) { + $conn->lastError = [[ + 'SQLSTATE' => $e->getCode(), + 'message' => $e->getMessage(), + 'sql' => $translatedSql, + ]]; + $GLOBALS['__sqlsrv_mysql_last_error'] = $conn->lastError; + return false; + } +} + +function sqlsrv_fetch_array($stmt, int $fetchType = SQLSRV_FETCH_ASSOC) +{ + if (!$stmt instanceof MysqlSqlsrvStatement) { + return null; + } + + if (!array_key_exists($stmt->index, $stmt->rows)) { + return null; + } + + $row = $stmt->rows[$stmt->index++]; + + if ($fetchType === SQLSRV_FETCH_NUMERIC) { + return array_values($row); + } + + return $row; +} + +function sqlsrv_num_rows($stmt): int +{ + if (!$stmt instanceof MysqlSqlsrvStatement) { + return 0; + } + + return count($stmt->rows); +} + +function sqlsrv_errors() +{ + return $GLOBALS['__sqlsrv_mysql_last_error'] ?? null; +} + +function sqlsrv_close($conn): bool +{ + return true; +} + +function sqlsrv_begin_transaction($conn): bool +{ + return $conn instanceof MysqlSqlsrvConnection && $conn->pdo->beginTransaction(); +} + +function sqlsrv_commit($conn): bool +{ + return $conn instanceof MysqlSqlsrvConnection && $conn->pdo->commit(); +} + +function sqlsrv_rollback($conn): bool +{ + return $conn instanceof MysqlSqlsrvConnection && $conn->pdo->rollBack(); +} + +function sqlsrv_mysql_translate_query(string $sql): string +{ + $query = str_replace(["\r\n", "\r"], "\n", $sql); + $query = preg_replace('/\bdbo\./i', '', $query); + $query = preg_replace('/\[([A-Za-z0-9_]+)\]/', '`$1`', $query); + + $query = preg_replace('/FORMAT\(([^,]+),\s*\'yyyy\'\)\s*=\s*FORMAT\(GETDATE\(\),\s*\'yyyy\'\)/i', 'YEAR($1) = YEAR(NOW())', $query); + $query = preg_replace('/CAST\(GETDATE\(\)\s+AS\s+DATE\)/i', 'CURDATE()', $query); + $query = preg_replace('/GETDATE\(\)/i', 'NOW()', $query); + $query = preg_replace('/SYSDATETIME\(\)/i', 'NOW()', $query); + $query = preg_replace('/CONVERT\(VARCHAR,\s*([^)]+),\s*23\)/i', 'DATE($1)', $query); + + $query = preg_replace( + '/DATEADD\(DAY,\s*(-?\d+),\s*\(SELECT\s+MAX\(Datum\)\s+FROM\s+kl_Kaffeeverbrauch\s+WHERE\s+Datum\s+<\s+CURDATE\(\)\)\)/i', + 'DATE_ADD((SELECT MAX(Datum) FROM kl_Kaffeeverbrauch WHERE Datum < CURDATE()), INTERVAL $1 DAY)', + $query + ); + $query = preg_replace( + '/DATEADD\(DAY,\s*(-?\d+),\s*\(SELECT\s+MAX\(Datum\)\s+FROM\s+kl_Kaffeeverbrauch\s*\)\)/i', + 'DATE_ADD((SELECT MAX(Datum) FROM kl_Kaffeeverbrauch), INTERVAL $1 DAY)', + $query + ); + $query = preg_replace('/DATEADD\(DAY,\s*(-?\d+),\s*NOW\(\)\)/i', 'DATE_ADD(NOW(), INTERVAL $1 DAY)', $query); + + $limit = null; + $query = preg_replace_callback('/^\s*SELECT\s+TOP\s+\(?\s*(\d+)\s*\)?\s+/i', function (array $matches) use (&$limit): string { + $limit = (int)$matches[1]; + return 'SELECT '; + }, $query); + + if ($limit !== null && !preg_match('/\bLIMIT\s+\d+\s*$/i', trim($query))) { + $query = rtrim($query, " \t\n\r\0\x0B;") . ' LIMIT ' . $limit; + } + + return $query; +} + +/** + * PDO is stricter than the SQL Server driver used by the legacy app. Some + * legacy calls pass unused parameters, and one CSV lookup passes one value for + * two equivalent placeholders. Normalize this only in the dev adapter. + * + * @param array $params + * @return array + */ +function sqlsrv_mysql_normalize_params(string $sql, array $params): array +{ + $normalized = array_values($params); + $placeholderCount = substr_count($sql, '?'); + + if ($placeholderCount === 0) { + return []; + } + + if (count($normalized) > $placeholderCount) { + return array_slice($normalized, 0, $placeholderCount); + } + + if (count($normalized) < $placeholderCount && count($normalized) > 0) { + $last = $normalized[count($normalized) - 1]; + while (count($normalized) < $placeholderCount) { + $normalized[] = $last; + } + } + + return $normalized; +} + +/** + * @param array $row + * @return array + */ +function sqlsrv_mysql_normalize_row(array $row): array +{ + foreach ($row as $key => $value) { + if ($value === null || $value instanceof DateTimeInterface) { + continue; + } + + if (is_string($value) && preg_match('/(datum|date|gueltig_bis|valid_)/i', (string)$key)) { + try { + $row[$key] = new DateTime($value); + } catch (Throwable $e) { + $row[$key] = $value; + } + } + } + + return $row; +} diff --git a/scripts/init-mysql-dev.php b/scripts/init-mysql-dev.php new file mode 100644 index 0000000..8aea62a --- /dev/null +++ b/scripts/init-mysql-dev.php @@ -0,0 +1,67 @@ + PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, +]); + +$schema = file_get_contents(__DIR__ . '/../database/mysql-dev-schema.sql'); +if ($schema === false) { + fwrite(STDERR, "Could not read schema file.\n"); + exit(1); +} + +$pdo->exec($schema); + +$email = strtolower(trim((string)getenv('DEV_AUTH_EMAIL'))); +$name = trim((string)(getenv('DEV_AUTH_NAME') ?: 'Test Admin')); + +$stmt = $pdo->prepare( + 'INSERT INTO kl_Mitarbeiter (Name, Email, aktiv, admin) + VALUES (?, ?, 1, 1) + ON DUPLICATE KEY UPDATE Name = VALUES(Name), aktiv = 1, admin = 1' +); +$stmt->execute([$name, $email]); + +$memberId = (int)$pdo->query('SELECT MitarbeiterID FROM kl_Mitarbeiter WHERE Email = ' . $pdo->quote($email))->fetchColumn(); + +$paymentCount = (int)$pdo->query('SELECT COUNT(*) FROM kl_Einzahlungen WHERE MitarbeiterID = ' . $memberId)->fetchColumn(); +if ($paymentCount === 0) { + $stmt = $pdo->prepare('INSERT INTO kl_Einzahlungen (MitarbeiterID, Betrag, Datum) VALUES (?, ?, NOW())'); + $stmt->execute([$memberId, 10.00]); +} + +$consumptionCount = (int)$pdo->query('SELECT COUNT(*) FROM kl_Kaffeeverbrauch WHERE MitarbeiterID = ' . $memberId)->fetchColumn(); +if ($consumptionCount === 0) { + $stmt = $pdo->prepare( + 'INSERT INTO kl_Kaffeeverbrauch (MitarbeiterID, AnzahlStriche, Kosten, KostenproStrich, Datum, Eintragsart) + VALUES (?, ?, ?, ?, NOW(), ?)' + ); + $stmt->execute([$memberId, 3, 0.60, 0.20, 2]); +} + +$noticeCount = (int)$pdo->query('SELECT COUNT(*) FROM kl_hinweise')->fetchColumn(); +if ($noticeCount === 0) { + $stmt = $pdo->prepare('INSERT INTO kl_hinweise (nachricht, gueltig_bis) VALUES (?, DATE_ADD(NOW(), INTERVAL 30 DAY))'); + $stmt->execute(['Dev-Hinweis: MySQL-Testumgebung aktiv.']); +} + +echo "MySQL dev schema is ready for {$email}.\n"; + diff --git a/scripts/run-dev-server.sh b/scripts/run-dev-server.sh new file mode 100755 index 0000000..09f06dc --- /dev/null +++ b/scripts/run-dev-server.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +PHP_BIN="${PHP_BIN:-$ROOT_DIR/.local/php/usr/bin/php8.3}" +PHP_INI="${PHP_INI:-$ROOT_DIR/.local/php-dev.ini}" +PHP_LIB_DIR="$ROOT_DIR/.local/php/usr/lib/x86_64-linux-gnu" + +export LD_LIBRARY_PATH="${LD_LIBRARY_PATH:-$PHP_LIB_DIR}" +export APP_DB_DRIVER="${APP_DB_DRIVER:-mysql}" +export APP_ENV="${APP_ENV:-dev}" +export DEV_AUTH_EMAIL="${DEV_AUTH_EMAIL:-admin@test.local}" +export DEV_AUTH_NAME="${DEV_AUTH_NAME:-Test Admin}" + +exec "$PHP_BIN" -c "$PHP_INI" -S "${HOST:-127.0.0.1}:${PORT:-8080}" -t "$ROOT_DIR" diff --git a/stricheintragen.php b/stricheintragen.php index cdcd4e4..94c8fa1 100644 --- a/stricheintragen.php +++ b/stricheintragen.php @@ -31,7 +31,7 @@ if ($_SERVER["REQUEST_METHOD"] == "POST") { $anzahlStriche = (int)$anzahlStriche; $kostenproStrich = floatval($kostenproStrich); $kosten = floatval($anzahlStriche * $kostenproStrich); - $datum = date("Y-d-m H:i:s"); // Das aktuelle Datum verwenden + $datum = date("Y-m-d H:i:s"); // Das aktuelle Datum verwenden if($anzahlStriche != 0){ // SQL-Abfrage zum Einfügen der Daten $sql = "INSERT INTO kl_Kaffeeverbrauch (MitarbeiterID, AnzahlStriche, Kosten, KostenproStrich, Datum) VALUES (?, ?, ?, ?, ?)"; @@ -158,4 +158,4 @@ $KostenproStrich = number_format($KostenproStrichtemp, 2, '.', ''); - \ No newline at end of file +