Sicherheits-/Korrektheits-Fixes und neue Funktionen

Bugfixes aus dem Code-Review:
- XSS: unescaptes $_SERVER['PHP_SELF'] in csvupload.php und
  letzteneintraege.php durch feste Seitennamen ersetzt.
- Stripe-Webhook: Event-Deduplizierung (neue Tabelle stripe_webhook_events)
  gegen doppelte Verarbeitung/Dolibarr-Rechnungen bei Retry-Zustellung.
- Stripe-Webhook: Tarifwechsel aus dem Kundenportal wird lokal nachgezogen
  (plan_code aus dem Preis-lookup_key bei subscription.updated).
- Post-Redirect-Get fuer jahresauswertung, mailversenden und die
  Selbst-Stricheintragung - verhindert Doppelbuchung/Doppelversand per
  Browser-Refresh.
- Jahresbonus: Mails erst nach erfolgreichem Commit; Restcent-Ausgleich
  beim letzten Empfaenger, damit die Summe exakt stimmt.
- Verschachtelte HTML-Dokumente in csvupload/einzahlung/stricheintragen
  entfernt (Layout kommt aus header.php).
- Rate-Limit fuer den Versand von E-Mail-Verifizierungslinks.

Neue Funktionen:
- Mitglieder koennen ihren zuletzt selbst eingetragenen Strich wieder
  stornieren (nur eigene Web-Eintraege, Kassenwart-Eintraege bleiben).
- Monatsuebersicht des eigenen Verbrauchs im Mitglieder-Dashboard.
- Automatische Zahlungserinnerung: opt-in pro Mandant ab der
  Warnschwelle, mit Intervall; Cron-Skript scripts/send-payment-reminders.php.
- CSV-Import fuer Mitgliederlisten inkl. herunterladbarer Vorlage
  (mitglieder-vorlage.php), Semikolon-/Komma- und BOM-Erkennung.
- Logo als PDF-Wasserzeichen pro Mandant (Upload in den Mandant-
  Einstellungen, geschuetzt unter var/tenant_logos/, ersetzt den
  Text-Wasserzeichen im Ausdruck).

Robusterer Teilnehmer-Lookup im Dashboard ueber user_id (Fallback E-Mail).
This commit is contained in:
2026-07-20 21:54:16 +02:00
parent c4cb12b26e
commit ef5d0e1822
23 changed files with 918 additions and 160 deletions
+65 -7
View File
@@ -63,6 +63,19 @@ $settings = $hasAccess ? saas_fetch_tenant_settings($pdo, $tenantId) : null;
$entryMessage = null;
$entryError = null;
// Ergebnis einer vorangegangenen Aktion (Post-Redirect-Get): nach dem Buchen
// bzw. Stornieren wird auf index.php zurueckgeleitet, damit ein Browser-Refresh
// die Aktion nicht ein zweites Mal ausloest.
if (isset($_SESSION['flash_dashboard'])) {
$flash = $_SESSION['flash_dashboard'];
unset($_SESSION['flash_dashboard']);
if (($flash['type'] ?? '') === 'success') {
$entryMessage = (string)$flash['text'];
} else {
$entryError = (string)$flash['text'];
}
}
function dashboard_money(int $cents): string
{
return saas_format_money_cents($cents) . ' €';
@@ -203,17 +216,29 @@ function dashboard_render_consumption(array $entries): void
}
if ($_SERVER["REQUEST_METHOD"] === "POST") {
$flash = ['type' => 'error', 'text' => 'Die Aktion konnte nicht ausgeführt werden.'];
if ($hasAccess && $participant !== null && $settings !== null) {
$result = dashboard_record_self_entry($pdo, $participant, $settings, $_POST["anzahlStriche"] ?? null, $saasUser['user_id'] ?? null);
if ($result['ok']) {
$entryMessage = $result['message'] ?? null;
$participant = ledger_fetch_participant_summary($pdo, $tenantId, (int)$participant['participant_id']);
$aktion = (string)($_POST['aktion'] ?? 'strich_eintragen');
if ($aktion === 'strich_stornieren') {
$result = ledger_void_own_self_entry($pdo, $tenantId, (int)$participant['participant_id']);
$flash = $result['ok']
? ['type' => 'success', 'text' => 'Dein zuletzt selbst eingetragener Strich wurde storniert.']
: ['type' => 'error', 'text' => $result['error'] ?? 'Der Strich konnte nicht storniert werden.'];
} else {
$entryError = $result['error'] ?? 'Die Stricheintragung konnte nicht gespeichert werden.';
$result = dashboard_record_self_entry($pdo, $participant, $settings, $_POST["anzahlStriche"] ?? null, $saasUser['user_id'] ?? null);
$flash = $result['ok']
? ['type' => 'success', 'text' => $result['message'] ?? 'Stricheintragung wurde erfolgreich eingetragen.']
: ['type' => 'error', 'text' => $result['error'] ?? 'Die Stricheintragung konnte nicht gespeichert werden.'];
}
} else {
$entryError = 'Die Stricheintragung konnte nicht gespeichert werden.';
}
// Post-Redirect-Get: Ergebnis in die Session legen und per GET erneut
// anzeigen, damit ein Refresh die Buchung/Stornierung nicht wiederholt.
$_SESSION['flash_dashboard'] = $flash;
header('Location: index.php');
exit;
}
$paymentEntries = $participant !== null
@@ -274,6 +299,29 @@ if ($hasAccess && $participant !== null && $settings !== null) {
Gesamtstriche im aktuellen Jahr: <?php echo number_format((int)$participant['year_marks'], 0, ',', '.'); ?><br>
<p>Gesamteinzahlung im aktuellen Jahr: <?php echo saas_html(dashboard_money((int)$participant['year_payments_cents'])); ?></p>
<?php
$aktuellesJahr = (int)date('Y');
$monatsdaten = ledger_fetch_monthly_consumption($pdo, $tenantId, (int)$participant['participant_id'], $aktuellesJahr);
$monatsnamen = [1 => 'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'];
$aktuellerMonat = (int)date('n');
if ((int)$participant['year_marks'] > 0):
?>
<h2>Monatsübersicht <?php echo $aktuellesJahr; ?></h2>
<table>
<tr><th style="width:160px">Monat</th><th>Striche</th><th>Verbrauch</th></tr>
<?php
for ($m = 1; $m <= $aktuellerMonat; $m++):
$md = $monatsdaten[$m];
?>
<tr>
<td><?php echo saas_html($monatsnamen[$m]); ?></td>
<td><?php echo number_format($md['marks'], 0, ',', '.'); ?></td>
<td><?php echo saas_html(dashboard_money($md['cost_cents'])); ?></td>
</tr>
<?php endfor; ?>
</table>
<?php endif; ?>
<?php if ($selfEntryEnabled): ?>
<h2>Eintrag in die Strichliste</h2>
<b>Hier kannst du einen oder zwei Striche für dich in der Kaffeeliste eintragen. <br>Dafür benötigst du keinen Eintrag auf der Liste durchführen.</b><br>
@@ -282,6 +330,7 @@ if ($hasAccess && $participant !== null && $settings !== null) {
<li>
<form method="post" action="index.php">
<button type="submit">Einen Strich eintragen</button>
<input type="hidden" name="aktion" value="strich_eintragen">
<input type="hidden" name="anzahlStriche" value="1">
<?php echo app_csrf_field(); ?>
</form>
@@ -289,11 +338,20 @@ if ($hasAccess && $participant !== null && $settings !== null) {
<li>
<form method="post" action="index.php">
<button type="submit">Zwei Striche eintragen</button>
<input type="hidden" name="aktion" value="strich_eintragen">
<input type="hidden" name="anzahlStriche" value="2">
<?php echo app_csrf_field(); ?>
</form>
</li>
<li>
<form method="post" action="index.php" onsubmit="return confirm('Deinen zuletzt selbst eingetragenen Strich stornieren?');">
<button type="submit" class="alt">Letzten Strich stornieren</button>
<input type="hidden" name="aktion" value="strich_stornieren">
<?php echo app_csrf_field(); ?>
</form>
</li>
</ul>
<small>Falsch getippt? Mit „Letzten Strich stornieren" nimmst du deinen jüngsten selbst eingetragenen Strich wieder zurück. Vom Kassenwart eingetragene Striche bleiben davon unberührt.</small>
<?php endif; ?>
<?php if ($paypalEnabled): ?>