Big Sweep: Security + Race-Conditions + Tests + DSGVO + A11y, SW by-v1095

SECURITY (auth.py, routes/auth.py, database.py, main.py)
- JWT bekommt jti; Logout trägt in neue jwt_blacklist-Tabelle ein,
  decode_token() prüft → server-side Invalidierung
- JWT-Expiry default 30 → 7 Tage (ENV JWT_EXPIRY_DAYS überschreibt)
- Sliding-Refresh-Middleware: erneuert Cookie wenn >50% verbraucht
  (Schwelle via JWT_REFRESH_FRACTION, Default 2)
- Login-Lockout in DB-Tabelle login_attempts (5 Versuche / 15 Min,
  überlebt Container-Restart) — alte In-Memory-Lockouts ersetzt
- SMTP-Versand: alle 'except: pass' durch logger.exception ersetzt;
  Fehlversuche landen in failed_emails-Tabelle für späteres Retry
- Referral-Counter Race gefixt: UPDATE partner_codes SET uses=uses+1
  ... WHERE uses<max_uses RETURNING — atomar statt SELECT+UPDATE

RACE CONDITIONS (routes/invoices.py, database.py)
- Neue invoice_counters-Tabelle für atomare Nummernvergabe
- _next_invoice_number nutzt BEGIN IMMEDIATE + atomares UPDATE
- Funktioniert für RG- und ST-Prefixe (Stornorechnungen)
- Race-Test verifiziert (5 Threads × 20 Calls = 100 eindeutige Nummern)

VERSION + TESTS + ERROR-DIGEST (VERSION, Makefile, tests/, scheduler.py)
- Neue VERSION-Datei (Single Source of Truth) — main.py liest beim
  Startup
- Makefile-Target 'make bump' propagiert in sw.js, app.js, index.html
- Makefile-Target 'make test' setzt venv auf, läuft pytest
- 19 Smoke-Tests in tests/ (health, auth, diary, invoice) — alle grün
- Scheduler: täglicher _job_error_digest um 06:30 → schickt Error-
  Zusammenfassung an ADMIN_EMAIL (still wenn keine Errors)

DSGVO + A11Y + ERSTE-HILFE
- landing.html: 'HTML und ODS' → 'JSON' (tatsächlich implementiert)
- datenschutz.js: Sektion Account-Löschung erweitert (sofort gelöscht /
  anonymisiert / 10 Jahre für Rechnungen)
- erste-hilfe.js: prominentes Warning-Banner oben (ersetzt keine
  Tierarzt-Beratung); Notfallnummern gruppiert nach Land, TODO-Platz-
  halter für AT-Uni-Klinik, CH Tox Info Suisse, CH Tierspital Zürich
- ui.js Modal: ESC schließt, Focus-Trap, Auto-Focus erstes Element,
  Restore Focus auf vorigen Caller
- impressum.js Kontaktformular: Labels mit for=cf-name etc.

NEUE DB-TABELLEN (idempotent via CREATE TABLE IF NOT EXISTS)
- jwt_blacklist, login_attempts, failed_emails, invoice_counters

NEUE ENV-VARS
- JWT_REFRESH_FRACTION (Default 2)
- JWT_EXPIRY_DAYS Default geändert (30 → 7)
This commit is contained in:
rene 2026-05-26 20:12:01 +02:00
parent 6224044654
commit 9394bab1fb
23 changed files with 1208 additions and 78 deletions

View file

@ -231,6 +231,15 @@ def start():
misfire_grace_time=3600,
coalesce=True,
)
# Täglich 06:30 — Error-Digest-Mail an ADMIN_EMAIL
_scheduler.add_job(
_job_error_digest,
CronTrigger(hour=6, minute=30),
id="error_digest",
replace_existing=True,
misfire_grace_time=1800,
coalesce=True,
)
_scheduler.start()
logger.info("Scheduler gestartet (gestaffelt) — Health-Reminder 08:00, Giftköder-Archiv 03:00, Wetter-Alert 07:30, Meilenstein-Check 00:05, Event-Import 1.+2./4./7./10. 02:00, Rassen-Seed 1. 03:00, Wikidata-Seed 1. 04:00, Status-Report 06:00, Moderation-Overdue 12:00, Quartalsbericht 1. Feb/Mai/Aug/Nov 07:10, KI-Gesundheitsbericht Mo 07:05, Streak-Reminder 19:00, Rückruf-Check 08:05, Goldene-Gassi-Stunde 07:00, Jahrestags-Erinnerungen 09:00, Monatlicher-Rückblick 1. 10:00, Foto-Challenge Mo 08:10, Weekly-Praise Mo 09:05, Abo-Check 03:15, Invoice-Reminder 08:30. OSM-Cache: on-demand (kein Prewarm).")
@ -2092,3 +2101,133 @@ def _find_best_gassi_window(hourly: list[dict]) -> tuple[int, int, float | None,
best_wind = sum(winds) / len(winds) if winds else None
return best_start, best_score, best_temp, best_wind
# ------------------------------------------------------------------
# JOB: Error-Digest (täglich 06:30 Uhr)
# ------------------------------------------------------------------
# Quellen fuer Fehler:
# 1. _job_log dieser Datei — Status der einzelnen Scheduler-Jobs
# 2. main.log_buffer — In-Memory-Buffer der letzten 500 Log-Zeilen
# Limitierung: log_buffer ist nicht persistent. Ein vollstaendiger 24h-Digest
# wuerde eine externe Log-Senke (Datei oder DB) brauchen. Bis dahin liefert
# der Job das was im Memory verfuegbar ist plus alle Scheduler-Errors.
# ------------------------------------------------------------------
async def _job_error_digest():
"""Schickt eine Zusammenfassung aller bekannten ERROR/EXCEPTION-Eintraege
der letzten 24h an ADMIN_EMAIL."""
import os
import html as _html
from collections import Counter
from mailer import send_email, email_html
admin = os.getenv("ADMIN_EMAIL", "")
if not admin:
logger.info("Error-Digest: ADMIN_EMAIL nicht gesetzt, uebersprungen.")
_log_job("error_digest", "ok", "ADMIN_EMAIL nicht gesetzt")
return
now = datetime.now(tz=_TZ)
cutoff = now - timedelta(hours=24)
# ── 1. Scheduler-Job-Errors einsammeln ───────────────────────
scheduler_errors = []
for jid, log in _job_log.items():
last_run = log.get("last_run")
if last_run and last_run >= cutoff and log.get("status") == "error":
scheduler_errors.append({
"job": jid,
"ts": last_run.strftime("%d.%m. %H:%M"),
"result": log.get("result", ""),
})
# ── 2. In-Memory-Log-Buffer einsammeln (best-effort) ─────────
log_errors = []
try:
from main import log_buffer # type: ignore
for entry in list(log_buffer):
lvl = entry.get("l", "")
if lvl in ("ERROR", "CRITICAL", "EXCEPTION"):
log_errors.append({
"ts": entry.get("t", ""),
"lvl": lvl,
"name": entry.get("n", ""),
"msg": entry.get("m", ""),
})
except Exception as e:
logger.debug(f"Error-Digest: log_buffer nicht verfuegbar: {e}")
# Gruppieren: Fehler-Meldungen mit gleichem msg-Prefix zusammenfassen
grouped: Counter = Counter()
for e in log_errors:
# Erste 80 Zeichen als Schluessel — schluckt Variablenwerte am Ende
key = (e["name"], e["msg"][:80])
grouped[key] += 1
# ── Wenn nichts zu melden ist: leise raus ────────────────────
if not scheduler_errors and not grouped:
logger.info("Error-Digest: Keine Errors in den letzten 24h.")
_log_job("error_digest", "ok", "0 Errors")
return
# ── HTML-Body bauen ──────────────────────────────────────────
parts = []
parts.append(f'<p style="margin:0 0 16px;color:#444">Fehler-Zusammenfassung der letzten 24h ({now.strftime("%d.%m.%Y %H:%M")}).</p>')
if scheduler_errors:
rows_html = "".join(
f'<tr>'
f'<td style="padding:6px 12px;color:#c45000;font-weight:600">{_html.escape(e["job"])}</td>'
f'<td style="padding:6px 12px;font-family:monospace;font-size:12px">{e["ts"]}</td>'
f'<td style="padding:6px 12px;color:#555">{_html.escape(e["result"])}</td>'
f'</tr>'
for e in scheduler_errors
)
parts.append(
'<h2 style="font-size:14px;color:#c45000;margin:20px 0 6px">Scheduler-Job-Fehler ({0})</h2>'.format(len(scheduler_errors))
+ f'<table style="width:100%;border-collapse:collapse;font-size:13px"><tbody>{rows_html}</tbody></table>'
)
if grouped:
sorted_groups = sorted(grouped.items(), key=lambda kv: -kv[1])
rows_html = "".join(
f'<tr>'
f'<td style="padding:6px 12px;text-align:right;font-weight:700;color:#dc2626">{count}x</td>'
f'<td style="padding:6px 12px;font-family:monospace;font-size:11px;color:#888">{_html.escape(name)}</td>'
f'<td style="padding:6px 12px;color:#444">{_html.escape(msg)}</td>'
f'</tr>'
for (name, msg), count in sorted_groups[:30]
)
parts.append(
'<h2 style="font-size:14px;color:#c45000;margin:20px 0 6px">In-Memory-Log-Errors ({0} Typen, {1} Eintraege)</h2>'.format(len(grouped), sum(grouped.values()))
+ f'<table style="width:100%;border-collapse:collapse;font-size:12px"><tbody>{rows_html}</tbody></table>'
)
parts.append('<p style="margin:24px 0 0;font-size:11px;color:#aaa">Quellen: scheduler._job_log (24h) + main.log_buffer (Memory, max. 500 Eintraege). Fuer vollstaendige 24h-Historie waere eine persistente Log-Senke noetig.</p>')
body = "\n".join(parts)
html = email_html(body, footer_text="Ban Yaro · Error-Digest")
plain_lines = [f"Ban Yaro Error-Digest — {now.strftime('%d.%m.%Y %H:%M')}", ""]
if scheduler_errors:
plain_lines.append("Scheduler-Job-Fehler:")
for e in scheduler_errors:
plain_lines.append(f" - {e['ts']} {e['job']}: {e['result']}")
plain_lines.append("")
if grouped:
plain_lines.append("Log-Errors (Top 30 Typen, gruppiert):")
for (name, msg), count in sorted(grouped.items(), key=lambda kv: -kv[1])[:30]:
plain_lines.append(f" {count}x [{name}] {msg}")
plain = "\n".join(plain_lines)
try:
await send_email(
admin,
f"Ban Yaro Error-Digest ({len(scheduler_errors)} Scheduler + {len(grouped)} Log-Errors)",
html,
plain,
)
logger.info(f"Error-Digest gesendet an {admin}{len(scheduler_errors)} scheduler, {len(grouped)} log-types.")
_log_job("error_digest", "ok", f"{len(scheduler_errors)} scheduler / {len(grouped)} log-types")
except Exception as e:
logger.error(f"Error-Digest: Mail-Fehler: {e}")
_log_job("error_digest", "error", str(e))