Sprint 15: User-Profil (Bio, Wohnort, Erfahrung, Social, Avatar, Mitglied-seit)
This commit is contained in:
parent
3642995409
commit
596c11d207
6 changed files with 189 additions and 1 deletions
1
.claude/worktrees/agent-a1140340
Submodule
1
.claude/worktrees/agent-a1140340
Submodule
|
|
@ -0,0 +1 @@
|
||||||
|
Subproject commit 692e6f937856ace638d9773c17f7447ca439d881
|
||||||
1
.claude/worktrees/agent-a88ce9b7
Submodule
1
.claude/worktrees/agent-a88ce9b7
Submodule
|
|
@ -0,0 +1 @@
|
||||||
|
Subproject commit a60db217827213e019a15bca7c0ab05a5b183275
|
||||||
1
.claude/worktrees/agent-aa5d905d
Submodule
1
.claude/worktrees/agent-aa5d905d
Submodule
|
|
@ -0,0 +1 @@
|
||||||
|
Subproject commit 9bd8701a1d38e3fdb1823bb936b260d7ec0fe165
|
||||||
148
backend/mailer.py
Normal file
148
backend/mailer.py
Normal file
|
|
@ -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 <noreply@banyaro.app>")
|
||||||
|
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 <noreply@banyaro.app>" 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"""\
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head><meta charset="utf-8"></head>
|
||||||
|
<body style="font-family:sans-serif;background:#f9f9f9;margin:0;padding:0">
|
||||||
|
<div style="max-width:520px;margin:32px auto;background:#fff;border-radius:12px;
|
||||||
|
padding:40px 32px;box-shadow:0 2px 8px rgba(0,0,0,.08)">
|
||||||
|
<h1 style="color:#C4843A;margin:0 0 8px;font-size:24px">Ban Yaro 🐾</h1>
|
||||||
|
<p style="color:#444;margin:0 0 24px">Hallo {name},</p>
|
||||||
|
<p style="color:#444;margin:0 0 24px">
|
||||||
|
bitte bestätige deine E-Mail-Adresse, damit dein Konto vollständig eingerichtet ist.
|
||||||
|
</p>
|
||||||
|
<p style="margin:0 0 32px">
|
||||||
|
<a href="{url}"
|
||||||
|
style="background:#C4843A;color:#fff;padding:14px 28px;border-radius:8px;
|
||||||
|
text-decoration:none;font-weight:700;font-size:15px;display:inline-block">
|
||||||
|
E-Mail bestätigen
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<p style="color:#888;font-size:13px;margin:0 0 8px">
|
||||||
|
Der Link ist 48 Stunden gültig.
|
||||||
|
</p>
|
||||||
|
<p style="color:#bbb;font-size:12px;margin:0">
|
||||||
|
Falls du dich nicht bei Ban Yaro registriert hast, ignoriere diese E-Mail einfach.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>"""
|
||||||
|
|
||||||
|
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)
|
||||||
37
backend/ratelimit.py
Normal file
37
backend/ratelimit.py
Normal file
|
|
@ -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)
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
Router, State-Management, Navigation, Initialisierung.
|
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 = (() => {
|
const App = (() => {
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue