From 596c11d2074085864516b55ab66f257d30f56d8e Mon Sep 17 00:00:00 2001 From: rene Date: Fri, 17 Apr 2026 09:24:27 +0200 Subject: [PATCH] Sprint 15: User-Profil (Bio, Wohnort, Erfahrung, Social, Avatar, Mitglied-seit) --- .claude/worktrees/agent-a1140340 | 1 + .claude/worktrees/agent-a88ce9b7 | 1 + .claude/worktrees/agent-aa5d905d | 1 + backend/mailer.py | 148 +++++++++++++++++++++++++++++++ backend/ratelimit.py | 37 ++++++++ backend/static/js/app.js | 2 +- 6 files changed, 189 insertions(+), 1 deletion(-) create mode 160000 .claude/worktrees/agent-a1140340 create mode 160000 .claude/worktrees/agent-a88ce9b7 create mode 160000 .claude/worktrees/agent-aa5d905d create mode 100644 backend/mailer.py create mode 100644 backend/ratelimit.py diff --git a/.claude/worktrees/agent-a1140340 b/.claude/worktrees/agent-a1140340 new file mode 160000 index 0000000..692e6f9 --- /dev/null +++ b/.claude/worktrees/agent-a1140340 @@ -0,0 +1 @@ +Subproject commit 692e6f937856ace638d9773c17f7447ca439d881 diff --git a/.claude/worktrees/agent-a88ce9b7 b/.claude/worktrees/agent-a88ce9b7 new file mode 160000 index 0000000..a60db21 --- /dev/null +++ b/.claude/worktrees/agent-a88ce9b7 @@ -0,0 +1 @@ +Subproject commit a60db217827213e019a15bca7c0ab05a5b183275 diff --git a/.claude/worktrees/agent-aa5d905d b/.claude/worktrees/agent-aa5d905d new file mode 160000 index 0000000..9bd8701 --- /dev/null +++ b/.claude/worktrees/agent-aa5d905d @@ -0,0 +1 @@ +Subproject commit 9bd8701a1d38e3fdb1823bb936b260d7ec0fe165 diff --git a/backend/mailer.py b/backend/mailer.py new file mode 100644 index 0000000..b47fd5d --- /dev/null +++ b/backend/mailer.py @@ -0,0 +1,148 @@ +""" +BAN YARO — E-Mail-Versand +Unterstützt zwei Backends (wird automatisch gewählt): + 1. Brevo REST-API — wenn BREVO_API_KEY gesetzt (bevorzugt) + 2. SMTP — wenn SMTP_HOST gesetzt (Fallback) +""" + +import os +import smtplib +import asyncio +import logging +from email.mime.text import MIMEText +from email.mime.multipart import MIMEMultipart + +import httpx + +logger = logging.getLogger(__name__) + +# Brevo REST-API +BREVO_API_KEY = os.getenv("BREVO_API_KEY", "") +BREVO_API_URL = "https://api.brevo.com/v3/smtp/email" + +# SMTP Fallback +SMTP_HOST = os.getenv("SMTP_HOST", "") +SMTP_PORT = int(os.getenv("SMTP_PORT", "587")) +SMTP_USER = os.getenv("SMTP_USER", "") +SMTP_PASS = os.getenv("SMTP_PASS", "") + +SMTP_FROM = os.getenv("SMTP_FROM", "Ban Yaro ") +APP_URL = os.getenv("APP_URL", "https://banyaro.app") + + +# ------------------------------------------------------------------ +# Brevo REST-API +# ------------------------------------------------------------------ +async def _send_brevo(to: str, subject: str, html: str, plain: str): + # Absender-Name und -Adresse aus SMTP_FROM parsen + # Format: "Ban Yaro " oder "noreply@banyaro.app" + from_raw = SMTP_FROM + if "<" in from_raw: + from_name = from_raw[:from_raw.index("<")].strip() + from_email = from_raw[from_raw.index("<")+1:from_raw.index(">")].strip() + else: + from_name = "Ban Yaro" + from_email = from_raw.strip() + + payload = { + "sender": {"name": from_name, "email": from_email}, + "to": [{"email": to}], + "subject": subject, + "htmlContent": html, + "textContent": plain, + } + async with httpx.AsyncClient(timeout=15) as client: + resp = await client.post( + BREVO_API_URL, + json=payload, + headers={"api-key": BREVO_API_KEY, "Content-Type": "application/json"}, + ) + resp.raise_for_status() + + +# ------------------------------------------------------------------ +# SMTP Fallback +# ------------------------------------------------------------------ +def _send_smtp_sync(to: str, subject: str, html: str, plain: str): + msg = MIMEMultipart("alternative") + msg["Subject"] = subject + msg["From"] = SMTP_FROM + msg["To"] = to + msg.attach(MIMEText(plain, "plain", "utf-8")) + msg.attach(MIMEText(html, "html", "utf-8")) + + with smtplib.SMTP(SMTP_HOST, SMTP_PORT, timeout=10) as s: + s.ehlo() + s.starttls() + if SMTP_USER: + s.login(SMTP_USER, SMTP_PASS) + s.sendmail(SMTP_FROM, [to], msg.as_string()) + + +# ------------------------------------------------------------------ +# Öffentliche Funktion +# ------------------------------------------------------------------ +async def send_email(to: str, subject: str, html: str, plain: str = ""): + if BREVO_API_KEY: + try: + await _send_brevo(to, subject, html, plain) + logger.info(f"Mail via Brevo gesendet: «{subject}» → {to}") + return + except Exception as e: + logger.error(f"Brevo-Fehler: {e}") + raise + + if SMTP_HOST: + loop = asyncio.get_event_loop() + try: + await loop.run_in_executor(None, _send_smtp_sync, to, subject, html, plain) + logger.info(f"Mail via SMTP gesendet: «{subject}» → {to}") + return + except Exception as e: + logger.error(f"SMTP-Fehler: {e}") + raise + + logger.warning(f"Kein Mail-Backend konfiguriert — Mail NICHT gesendet: «{subject}» → {to}") + + +async def send_verify_email(to: str, name: str, token: str): + url = f"{APP_URL}/api/auth/verify/{token}" + subject = "Ban Yaro — E-Mail-Adresse bestätigen" + + html = f"""\ + + + + +
+

Ban Yaro 🐾

+

Hallo {name},

+

+ bitte bestätige deine E-Mail-Adresse, damit dein Konto vollständig eingerichtet ist. +

+

+ + E-Mail bestätigen + +

+

+ Der Link ist 48 Stunden gültig. +

+

+ Falls du dich nicht bei Ban Yaro registriert hast, ignoriere diese E-Mail einfach. +

+
+ +""" + + plain = ( + f"Ban Yaro — E-Mail-Adresse bestätigen\n\n" + f"Hallo {name},\n\n" + f"bitte bestätige deine E-Mail-Adresse:\n{url}\n\n" + f"Der Link ist 48 Stunden gültig.\n" + ) + + await send_email(to, subject, html, plain) diff --git a/backend/ratelimit.py b/backend/ratelimit.py new file mode 100644 index 0000000..b9623b0 --- /dev/null +++ b/backend/ratelimit.py @@ -0,0 +1,37 @@ +""" +BAN YARO — Rate Limiter +Sliding-Window-Limiter, in-memory (kein Redis nötig für Single-Container). +""" + +import threading +from collections import defaultdict, deque +from datetime import datetime, timedelta + +from fastapi import HTTPException, Request + +_buckets: dict[str, deque] = defaultdict(deque) +_lock = threading.Lock() + + +def check(request: Request, *, max_requests: int, window_seconds: int, key: str = ""): + """ + Wirft HTTP 429 wenn max_requests im Zeitfenster überschritten. + key: optionaler Präfix um verschiedene Limits zu trennen (z.B. 'register', 'login'). + """ + ip = (request.client.host if request.client else "unknown") + bucket_key = f"{key}:{ip}" + now = datetime.utcnow() + cutoff = now - timedelta(seconds=window_seconds) + + with _lock: + dq = _buckets[bucket_key] + # Alte Einträge raus + while dq and dq[0] < cutoff: + dq.popleft() + if len(dq) >= max_requests: + minutes = window_seconds // 60 + raise HTTPException( + 429, + f"Zu viele Versuche. Bitte warte {minutes} Minute(n) und versuche es erneut." + ) + dq.append(now) diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 6f0fff1..37b0a51 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '86'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '87'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const App = (() => {