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
+41 -23
View File
@@ -5,20 +5,6 @@ require_once __DIR__ . "/app/ledger.php";
require_once __DIR__ . "/app/saas-mail.php";
require_once __DIR__ . "/app/audit.php";
app_require_csrf();
include "header.php";
include "headerline.php";
include "nav.php";
?>
<!-- Banner -->
<section id="banner">
<div class="content">
<?php
$pdo = app_db_pdo();
$saasUser = saas_current_user($pdo);
@@ -38,16 +24,24 @@ if (!$hasAccess && $saasUser === null && checkKaffeelisteAdmin($conn, $mailadres
}
}
if (!$hasAccess) {
echo "<h2>Kein Zugriff</h2>";
include "footer.php";
exit;
}
$ergebnisse = null;
$dryRun = true;
$liveFlash = false;
if ($_SERVER["REQUEST_METHOD"] === "POST") {
// Ergebnis eines vorangegangenen Live-Versands (Post-Redirect-Get): nach dem
// echten Versand wird auf diese Seite zurueckgeleitet, damit ein Refresh nicht
// erneut an alle Mitglieder mailt.
if ($hasAccess && isset($_SESSION['flash_mailversand'])) {
$flash = $_SESSION['flash_mailversand'];
unset($_SESSION['flash_mailversand']);
if ((int)($flash['tenant_id'] ?? 0) === $tenantId) {
$ergebnisse = $flash['ergebnisse'] ?? null;
$dryRun = false;
$liveFlash = true;
}
}
if ($hasAccess && $_SERVER["REQUEST_METHOD"] === "POST") {
$dryRun = !empty($_POST['dry_run']);
$settings = saas_fetch_tenant_settings($pdo, $tenantId);
$dashboardUrl = saas_app_url('index.php');
@@ -89,10 +83,16 @@ if ($_SERVER["REQUEST_METHOD"] === "POST") {
if (!$dryRun) {
app_audit_log($pdo, $tenantId, $createdByUserId, 'mail_broadcast.sent', 'tenant', $tenantId, ['empfaenger' => count($ergebnisse)]);
// Post-Redirect-Get: Ergebnis in die Session legen und per GET erneut
// anzeigen, damit ein Browser-Refresh nicht erneut versendet.
$_SESSION['flash_mailversand'] = ['tenant_id' => $tenantId, 'ergebnisse' => $ergebnisse];
header('Location: mailversenden.php');
exit;
}
}
$log = saas_fetch_outbound_email_log($pdo, $tenantId, 20);
$log = $hasAccess ? saas_fetch_outbound_email_log($pdo, $tenantId, 20) : [];
function mailversenden_status_label(string $status): string
{
@@ -105,13 +105,29 @@ function mailversenden_status_label(string $status): string
};
}
include "header.php";
include "headerline.php";
include "nav.php";
?>
<!-- Banner -->
<section id="banner">
<div class="content">
<?php if (!$hasAccess): ?>
<h2>Kein Zugriff</h2>
<?php else: ?>
<h2>Info-Mail versenden</h2>
<p>Verschickt an alle aktiven Mitglieder eine E-Mail mit dem aktuellen
Kaffeelisten-Stand. Im Dry-Run wird nichts wirklich versendet, aber jeder
Empfänger wird im Versandlog unten protokolliert.</p>
<?php if ($liveFlash): ?>
<div class="hint-box success"><p>Die Info-Mails wurden versendet.</p></div>
<?php endif; ?>
<?php if ($ergebnisse !== null): ?>
<h3>Ergebnis dieses Laufs (<?php echo $dryRun ? 'Dry-Run' : 'Live-Versand'; ?>)</h3>
<table>
@@ -126,7 +142,7 @@ Empfänger wird im Versandlog unten protokolliert.</p>
</table>
<?php endif; ?>
<form method="post" action="<?php echo htmlspecialchars($_SERVER["PHP_SELF"]); ?>">
<form method="post" action="mailversenden.php">
<?php echo app_csrf_field(); ?>
<div class="form-check">
<input class="form-check-input" type="checkbox" name="dry_run" id="dry_run" checked>
@@ -152,6 +168,8 @@ Empfänger wird im Versandlog unten protokolliert.</p>
<?php endforeach; ?>
</table>
<?php endif; ?>
</div>
</section>