banyaro/backend/routes/auth.py
rene d0a76e1b54 Ban Yaro Blues — Hymne in der WELT-Welt
Eigener Song (KI-Demo via Suno) als Marken-Hymne. Dezente Player-Karte unter
dem Tageszitat; preload=none → 6 MB MP3 lädt erst bei Play, der SW cacht sie
danach für offline. Der Banner ist einmalige Einladung und verschwindet nach
erstem Hören (durchgehört oder >30s + Pause); danach dezenter runder Play-Button
unten links als Gegenspieler zum FAB, nur in WELT. Audio-Element zentral in
index.html → übersteht Welt-Wechsel & Re-Renders.

„Gehört" wird hybrid gemerkt: localStorage (sofort/offline) + DB-Flag
anthem_heard am User (neue Spalte, über /auth/me, gesetzt via
POST /api/profile/anthem-heard) — geräte- und deploy-übergreifend, damit der
Banner nicht erneut nervt.
2026-06-14 21:33:23 +02:00

735 lines
31 KiB
Python

"""BAN YARO — Auth Routes"""
import os
import secrets
import string
import logging
from datetime import datetime, timedelta, timezone
from typing import Optional
import jwt as _pyjwt
from fastapi import APIRouter, HTTPException, Request, Response, Depends
from fastapi.responses import RedirectResponse
from pydantic import BaseModel, EmailStr, Field
from database import db
from auth import (
hash_password, verify_password, create_token,
get_current_user, decode_token, blacklist_jti, JWT_EXPIRY
)
from username_blocklist import is_username_blocked
from ratelimit import check as rl_check
logger = logging.getLogger(__name__)
router = APIRouter()
COOKIE_NAME = "by_token"
_APP_URL = os.getenv("APP_URL", "https://banyaro.app")
_SMTP_READY = bool(os.getenv("SMTP_SUPPORT_USER") and os.getenv("SMTP_SUPPORT_PASS"))
# Login-Brute-Force-Lockout: 5 Fehlversuche in 15 Minuten → 15 Min. gesperrt.
_LOCKOUT_WINDOW_MIN = 15
_LOCKOUT_ATTEMPTS_MAX = 5
# ------------------------------------------------------------------
# Login-Lockout (DB-basiert, überlebt Container-Restart)
# ------------------------------------------------------------------
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def _db_is_account_locked(email: str) -> Optional[int]:
"""Gibt verbleibende Sperrzeit in Sekunden zurück (oder None falls nicht gesperrt)."""
with db() as conn:
row = conn.execute(
"SELECT locked_until FROM login_attempts WHERE email=? COLLATE NOCASE",
(email,)
).fetchone()
if not row or not row["locked_until"]:
return None
try:
locked_until = datetime.fromisoformat(row["locked_until"])
except Exception:
return None
now = datetime.now(timezone.utc)
# Falls ohne TZ gespeichert (Legacy) → as-UTC interpretieren
if locked_until.tzinfo is None:
locked_until = locked_until.replace(tzinfo=timezone.utc)
if locked_until <= now:
return None
return int((locked_until - now).total_seconds())
def _db_record_login_failure(email: str):
"""Inkrementiert Fehlversuche; setzt locked_until wenn Schwelle erreicht."""
now = datetime.now(timezone.utc)
window_start = now - timedelta(minutes=_LOCKOUT_WINDOW_MIN)
with db() as conn:
row = conn.execute(
"SELECT attempts, last_attempt FROM login_attempts WHERE email=? COLLATE NOCASE",
(email,)
).fetchone()
if row:
try:
last = datetime.fromisoformat(row["last_attempt"])
if last.tzinfo is None:
last = last.replace(tzinfo=timezone.utc)
except Exception:
last = now
# Zähler resetten wenn letzter Versuch außerhalb des Fensters lag
attempts = (row["attempts"] + 1) if last >= window_start else 1
else:
attempts = 1
locked_until = None
if attempts >= _LOCKOUT_ATTEMPTS_MAX:
locked_until = (now + timedelta(minutes=_LOCKOUT_WINDOW_MIN)).isoformat()
conn.execute(
"""INSERT INTO login_attempts (email, attempts, last_attempt, locked_until)
VALUES (?,?,?,?)
ON CONFLICT(email) DO UPDATE SET
attempts=excluded.attempts,
last_attempt=excluded.last_attempt,
locked_until=excluded.locked_until""",
(email.lower(), attempts, now.isoformat(), locked_until)
)
def _db_clear_login_failures(email: str):
with db() as conn:
conn.execute("DELETE FROM login_attempts WHERE email=? COLLATE NOCASE", (email,))
# ------------------------------------------------------------------
# SMTP-Fehler-Logging
# ------------------------------------------------------------------
def _log_smtp_failure(to_email: str, subject: str, body: str, error: Exception, context: str = ""):
"""Loggt SMTP-Fehler und speichert in failed_emails für Admin-Retry."""
logger.exception("SMTP failed for %s | context=%s | subject=%s", to_email, context, subject)
try:
with db() as conn:
conn.execute(
"INSERT INTO failed_emails (to_email, subject, body, error, context) VALUES (?,?,?,?,?)",
(to_email, subject, body, repr(error), context or None)
)
except Exception:
logger.exception("failed_emails-Insert fehlgeschlagen")
def _send_verification_email(email: str, name: str, token: str):
if not _SMTP_READY:
return
import html as _html
from routes.outreach import _send_smtp
from mailer import email_html
url = f"{_APP_URL}/api/auth/verify-email/{token}"
subject = "Ban Yaro — bitte bestätige deine E-Mail-Adresse"
_ename = _html.escape(name)
body_html = f"""
<p style="margin:0 0 16px">Hallo <b>{_ename}</b>,</p>
<p style="margin:0 0 16px">
willkommen bei Ban Yaro! Bitte bestätige deine E-Mail-Adresse, damit dein Konto aktiv wird.
</p>
<p style="margin:0;font-size:13px;color:#888">Der Link ist 7 Tage gültig.</p>
<p style="margin:12px 0 0;font-size:12px;color:#bbb">
Falls du dich nicht bei Ban Yaro registriert hast, ignoriere diese E-Mail einfach.
</p>"""
html = email_html(body_html, cta_url=url, cta_label="E-Mail bestätigen")
plain = f"Hallo {name},\n\nbitte bestätige deine E-Mail:\n{url}\n\nLink ist 7 Tage gültig.\n"
try:
_send_smtp(email, subject, plain, "support", html=html)
except Exception as exc:
# Nicht blockieren wenn SMTP fehlschlägt — aber Fehler protokollieren + persistieren.
_log_smtp_failure(email, subject, plain, exc, context="verification_email")
class LoginRequest(BaseModel):
email: EmailStr
password: str = Field(..., min_length=1, max_length=200)
class RegisterRequest(BaseModel):
email: EmailStr
password: str = Field(..., min_length=8, max_length=200)
name: str = Field(..., min_length=2, max_length=40)
ref_code: Optional[str] = Field(None, max_length=50)
qr_token: Optional[str] = Field(None, max_length=20) # physischer Partner-QR (Sticker/Flyer)
def _gen_referral_code() -> str:
alphabet = string.ascii_uppercase + string.digits
return ''.join(secrets.choice(alphabet) for _ in range(8))
def _set_cookie(response: Response, token: str):
response.set_cookie(
key=COOKIE_NAME, value=token,
httponly=True, secure=True, samesite="lax",
max_age=JWT_EXPIRY * 24 * 3600
)
@router.post("/register")
async def register(data: RegisterRequest, response: Response, request: Request):
rl_check(request, max_requests=5, window_seconds=3600, key="register")
name = data.name.strip()
if len(name) < 2:
raise HTTPException(400, "Benutzername muss mindestens 2 Zeichen lang sein.")
if len(name) > 40:
raise HTTPException(400, "Benutzername darf maximal 40 Zeichen lang sein.")
if ' ' in name:
raise HTTPException(400, "Benutzername darf keine Leerzeichen enthalten.")
if is_username_blocked(name):
raise HTTPException(400, "Dieser Benutzername ist nicht erlaubt.")
if len(data.password) < 8:
raise HTTPException(400, "Passwort muss mindestens 8 Zeichen lang sein.")
with db() as conn:
if conn.execute("SELECT 1 FROM users WHERE email=?", (data.email,)).fetchone():
raise HTTPException(400, "E-Mail bereits registriert.")
if conn.execute(
"SELECT 1 FROM users WHERE name=? COLLATE NOCASE", (name,)
).fetchone():
raise HTTPException(400, "Dieser Name ist bereits vergeben. Bitte wähle einen anderen.")
code = _gen_referral_code()
verify_token = secrets.token_urlsafe(32)
try:
conn.execute(
"INSERT INTO users (email, pw_hash, name, referral_code, verification_token) VALUES (?,?,?,?,?)",
(data.email, hash_password(data.password), name, code, verify_token)
)
except Exception:
raise HTTPException(400, "Dieser Name ist bereits vergeben. Bitte wähle einen anderen.")
user = conn.execute(
"SELECT id, rolle FROM users WHERE email=?", (data.email,)
).fetchone()
new_user_id = user["id"]
# QR-only-Flow: Die Scan-URL trägt bewusst KEINEN Klartext-Code mehr —
# der Partner-Code wird hier server-seitig aus dem QR-Token aufgelöst.
ref_code_in = data.ref_code
if not ref_code_in and data.qr_token:
qr_row = conn.execute(
"""SELECT pc.code FROM partner_qr_codes q
JOIN partner_qr_batches b ON b.id = q.batch_id
JOIN partner_codes pc ON pc.id = b.partner_code_id
WHERE q.token=?""",
(data.qr_token.strip(),)
).fetchone()
if qr_row:
ref_code_in = qr_row["code"]
if ref_code_in:
code_upper = ref_code_in.strip().upper()
# Zuerst prüfen ob es ein Partner-Code ist (active=0 = Notbremse bei
# geleakten Codes: wird wie nicht existent behandelt, Historie bleibt)
partner = conn.execute(
"SELECT id, grants_founder, max_uses FROM partner_codes WHERE code=? AND active=1",
(code_upper,)
).fetchone()
if partner:
# ATOMARE Inkrementierung (SQLite ≥3.35 unterstützt RETURNING).
# Schließt Race-Condition wenn zwei User gleichzeitig den gleichen Code einlösen.
if partner["max_uses"] is None:
redeemed = conn.execute(
"UPDATE partner_codes SET uses=uses+1 WHERE id=? RETURNING uses",
(partner["id"],)
).fetchone()
else:
redeemed = conn.execute(
"UPDATE partner_codes SET uses=uses+1 WHERE id=? AND uses<? RETURNING uses",
(partner["id"], partner["max_uses"])
).fetchone()
if redeemed:
updates = {"referred_by": -partner["id"]}
# QR-Rückverfolgung: Token muss zu einem Kontingent DIESES Codes gehören
if data.qr_token:
qr = conn.execute(
"""SELECT q.token FROM partner_qr_codes q
JOIN partner_qr_batches b ON b.id = q.batch_id
WHERE q.token=? AND b.partner_code_id=?""",
(data.qr_token.strip(), partner["id"])
).fetchone()
if qr:
updates["referred_qr"] = qr["token"]
if partner["grants_founder"]:
total_founders = conn.execute(
"SELECT COUNT(*) FROM users WHERE is_founder=1"
).fetchone()[0]
if total_founders < 100:
# Pending — wird nach erstem Hunde-Profil mit Plausibilitätsprüfung aktiviert
updates["is_founder_pending"] = 1
set_clause = ", ".join(f"{k}=?" for k in updates)
conn.execute(
f"UPDATE users SET {set_clause} WHERE id=?",
(*updates.values(), new_user_id)
)
else:
# Fallback: Referral-Code eines anderen Users
referrer = conn.execute(
"SELECT id FROM users WHERE referral_code=? AND id != ?",
(code_upper, new_user_id)
).fetchone()
if referrer:
conn.execute("UPDATE users SET referred_by=? WHERE id=?",
(referrer['id'], new_user_id))
_send_verification_email(data.email, name, verify_token)
return {"pending_verification": True}
@router.post("/login")
async def login(data: LoginRequest, response: Response, request: Request):
rl_check(request, max_requests=10, window_seconds=300, key="login")
rl_check(request, max_requests=5, window_seconds=300, key=f"login_email:{data.email.lower()}")
# DB-basierter Account-Lockout (überlebt Container-Restart)
remaining = _db_is_account_locked(data.email)
if remaining is not None:
minutes = max(1, remaining // 60)
raise HTTPException(
429,
f"Zu viele Fehlversuche. Bitte warte {minutes} Minute(n) und versuche es erneut.",
headers={"Retry-After": str(remaining)}
)
with db() as conn:
user = conn.execute(
"SELECT id, pw_hash, name, rolle, is_premium, email_verified FROM users WHERE email=?",
(data.email,)
).fetchone()
if not user or not verify_password(data.password, user["pw_hash"]):
_db_record_login_failure(data.email)
raise HTTPException(401, "E-Mail oder Passwort falsch.")
if not user["email_verified"]:
raise HTTPException(403, "EMAIL_NOT_VERIFIED")
_db_clear_login_failures(data.email)
token = create_token(user["id"], user["rolle"])
_set_cookie(response, token)
with db() as conn:
conn.execute(
"UPDATE users SET last_login=datetime('now') WHERE id=?", (user["id"],)
)
return {"token": token, "name": user["name"], "is_premium": bool(user["is_premium"])}
@router.post("/logout")
async def logout(request: Request, response: Response):
# Token aus Cookie ODER Bearer-Header extrahieren und auf die Blacklist setzen,
# damit es serverseitig wirklich ungültig wird (nicht nur Cookie löschen).
raw_token = request.cookies.get(COOKIE_NAME)
if not raw_token:
auth_header = request.headers.get("Authorization", "")
if auth_header.lower().startswith("bearer "):
raw_token = auth_header.split(None, 1)[1].strip()
if raw_token:
try:
# options={'verify_exp': False}: auch bei abgelaufenem Token wollen wir jti+exp lesen,
# falls jemand sich vor Ablauf "ordentlich" abmelden möchte — und exp brauchen wir für TTL.
payload = _pyjwt.decode(
raw_token, options={"verify_signature": False}
)
jti = payload.get("jti")
exp = payload.get("exp")
if jti and exp:
expires_at = datetime.fromtimestamp(int(exp), tz=timezone.utc).isoformat()
blacklist_jti(jti, expires_at)
except Exception:
logger.exception("Logout: Token konnte nicht für Blacklist gelesen werden")
response.delete_cookie(COOKIE_NAME)
return {"ok": True}
_REFERRAL_TIERS = [
(50, 50),
(20, 30),
(10, 20),
]
def _referral_tier(count: int):
for threshold, discount in _REFERRAL_TIERS:
if count >= threshold:
return discount
return 0
def _referral_next(count: int):
for threshold, discount in reversed(_REFERRAL_TIERS):
if count < threshold:
return {"count": threshold, "discount": discount}
return None # Maximalstufe erreicht
@router.get("/referral")
async def get_referral_info(user=Depends(get_current_user)):
with db() as conn:
row = conn.execute(
"""SELECT referral_code,
COALESCE((SELECT COUNT(*) FROM users WHERE referred_by=?), 0) AS count
FROM users WHERE id=?""",
(user['id'], user['id'])
).fetchone()
code = row["referral_code"] if row else None
if not code:
code = _gen_referral_code()
conn.execute("UPDATE users SET referral_code=? WHERE id=?", (code, user['id']))
count = row["count"] if row else 0
base = os.getenv("APP_URL", "https://banyaro.app")
return {
"code": code,
"count": count,
"link": f"{base}/?ref={code}",
"discount_pct": _referral_tier(count),
"next_tier": _referral_next(count),
}
@router.get("/me")
async def me(user=Depends(get_current_user)):
with db() as conn:
row = conn.execute(
"""SELECT id, name, real_name, email, rolle, is_premium, email_verified,
bio, wohnort, erfahrung, social_link,
profil_sichtbarkeit, avatar_url, created_at,
is_founder, is_partner, founder_number, is_founder_pending,
notes_ki_enabled, gassi_stunde_push, anthem_heard,
preferred_theme, subscription_tier,
subscription_expires_at, subscription_cancelled_at, needs_dog_selection,
billing_address, geburtstag
FROM users WHERE id=?""",
(user["id"],)
).fetchone()
if not row:
raise HTTPException(404, "User nicht gefunden.")
data = dict(row)
data["is_premium"] = bool(data["is_premium"])
return data
def _notify_partner_registration(user_id: int):
"""Dank-Mail an den Partner (Code-Besitzer), wenn ein Geworbener seine
E-Mail bestätigt hat — inkl. kleiner Statistik. Best effort."""
import html as _html
with db() as conn:
u = conn.execute(
"SELECT referred_by, referred_qr FROM users WHERE id=?", (user_id,)
).fetchone()
if not u or (u["referred_by"] or 0) >= 0:
return # kein Partner-Code im Spiel
code_id = -u["referred_by"]
pc = conn.execute(
"""SELECT pc.code, pc.label, pc.grants_founder, pc.owner_user_id,
o.name AS owner_name, o.email AS owner_email
FROM partner_codes pc
LEFT JOIN users o ON o.id = pc.owner_user_id
WHERE pc.id=?""",
(code_id,)
).fetchone()
if not pc or not pc["owner_email"]:
return # Code ohne Besitzer → niemand zu benachrichtigen
total = conn.execute(
"SELECT COUNT(*) FROM users WHERE referred_by=? AND email_verified=1",
(-code_id,)
).fetchone()[0]
month = conn.execute(
"""SELECT COUNT(*) FROM users
WHERE referred_by=? AND email_verified=1
AND strftime('%Y-%m', created_at) = strftime('%Y-%m', 'now')""",
(-code_id,)
).fetchone()[0]
qr_line = ""
if u["referred_qr"]:
qr = conn.execute(
"""SELECT q.seq, b.label FROM partner_qr_codes q
JOIN partner_qr_batches b ON b.id = q.batch_id
WHERE q.token=?""",
(u["referred_qr"],)
).fetchone()
if qr:
qr_line = f"Gekommen über deinen gedruckten QR-Code #{qr['seq']} (Kontingent „{qr['label']}“)."
founder_line = ""
if pc["grants_founder"]:
founders = conn.execute(
"SELECT COUNT(*) FROM users WHERE is_founder=1"
).fetchone()[0]
founder_line = f"Noch {max(0, 100 - founders)} von 100 Gründer-Plätzen frei."
subject = "🐾 Danke! Neue Registrierung über deinen Partner-Code"
_oname = _html.escape(pc["owner_name"] or "Partner")
stats_html = (
f"<p style='margin:0 0 16px'>Deine Bilanz mit dem Code <b>{pc['code']}</b>:<br>"
f"<b>{total}</b> bestätigte Registrierung{'en' if total != 1 else ''} insgesamt · "
f"<b>{month}</b> in diesem Monat.</p>"
)
body_html = f"""
<p style="margin:0 0 16px">Hallo <b>{_oname}</b>,</p>
<p style="margin:0 0 16px">
gerade hat ein neuer Hundefreund seine Registrierung über deinen
Partner-Code bestätigt — danke, dass du Ban Yaro weiterträgst! 🎉
</p>
{f'<p style="margin:0 0 16px">{_html.escape(qr_line)}</p>' if qr_line else ''}
{stats_html}
{f'<p style="margin:0 0 16px;color:#888">{_html.escape(founder_line)}</p>' if founder_line else ''}"""
plain = (f"Hallo {pc['owner_name'] or 'Partner'},\n\n"
f"gerade hat ein neuer Hundefreund seine Registrierung über deinen Partner-Code bestätigt — danke!\n"
+ (f"\n{qr_line}\n" if qr_line else "")
+ f"\nDeine Bilanz mit dem Code {pc['code']}: {total} bestätigte Registrierungen insgesamt, {month} in diesem Monat.\n"
+ (f"{founder_line}\n" if founder_line else "")
+ f"\nDein Partner-Bereich: {_APP_URL}/#partner-dashboard\n")
try:
from routes.outreach import _send_smtp
from mailer import email_html
html = email_html(body_html, cta_url=f"{_APP_URL}/#partner-dashboard", cta_label="Mein Partner-Bereich")
_send_smtp(pc["owner_email"], subject, plain, "partner", html=html)
except Exception as exc:
_log_smtp_failure(pc["owner_email"], subject, plain, exc, context="partner_thank_you")
@router.get("/verify-email/{token}")
async def verify_email(token: str):
with db() as conn:
row = conn.execute(
"SELECT id, email_verified FROM users WHERE verification_token=?", (token,)
).fetchone()
if not row:
return RedirectResponse(f"{_APP_URL}/#settings?verified=error", status_code=302)
conn.execute(
"UPDATE users SET email_verified=1, verification_token=NULL WHERE id=?",
(row["id"],)
)
# Dank-Mail an den Partner — nur beim ERSTEN Bestätigen (Link doppelt geklickt = kein Spam)
if not row["email_verified"]:
_notify_partner_registration(row["id"])
return RedirectResponse(f"{_APP_URL}/#settings?verified=1", status_code=302)
class ResendVerificationRequest(BaseModel):
email: EmailStr
@router.post("/resend-verification")
async def resend_verification(data: ResendVerificationRequest, request: Request):
rl_check(request, max_requests=3, window_seconds=3600, key=f"resend_verify_{data.email}")
with db() as conn:
row = conn.execute(
"SELECT id, name, email_verified FROM users WHERE email=?", (data.email,)
).fetchone()
if not row or row["email_verified"]:
return {"ok": True}
token = secrets.token_urlsafe(32)
with db() as conn:
conn.execute(
"UPDATE users SET verification_token=? WHERE id=?", (token, row["id"])
)
_send_verification_email(data.email, row["name"], token)
return {"ok": True}
class ForgotPasswordRequest(BaseModel):
email: EmailStr
class ResetPasswordRequest(BaseModel):
token: str = Field(..., min_length=10, max_length=200)
password: str = Field(..., min_length=8, max_length=200)
@router.post("/forgot-password")
async def forgot_password(data: ForgotPasswordRequest, request: Request):
rl_check(request, max_requests=3, window_seconds=3600, key="forgot_pw")
with db() as conn:
user = conn.execute(
"SELECT id, name FROM users WHERE email=?", (data.email,)
).fetchone()
# Immer OK zurückgeben — kein User-Enumeration
if user:
token = secrets.token_urlsafe(32)
expires = (datetime.utcnow() + timedelta(hours=2)).isoformat()
with db() as conn:
conn.execute(
"UPDATE users SET password_reset_token=?, password_reset_expires=? WHERE id=?",
(token, expires, user["id"])
)
import html as _html
app_url = os.getenv("APP_URL", "https://banyaro.app")
url = f"{app_url}/#reset-password?token={token}"
subject = "Ban Yaro — Passwort zurücksetzen"
from routes.outreach import _send_smtp
from mailer import email_html
_ename = _html.escape(user['name'])
body_html = f"""
<p style="margin:0 0 16px">Hallo <b>{_ename}</b>,</p>
<p style="margin:0 0 16px">
du hast eine Passwort-Zurücksetzen-Anfrage gestellt. Klicke auf den Button, um ein neues Passwort zu setzen.
</p>
<p style="margin:0;font-size:13px;color:#888">Der Link ist 2 Stunden gültig.</p>
<p style="margin:12px 0 0;font-size:12px;color:#bbb">
Falls du keine Anfrage gestellt hast, ignoriere diese E-Mail einfach.
</p>"""
html = email_html(body_html, cta_url=url, cta_label="Passwort zurücksetzen")
plain = f"Hallo {user['name']},\n\nPasswort zurücksetzen:\n{url}\n\nLink ist 2h gültig.\n"
try:
_send_smtp(data.email, subject, plain, "support", html=html)
except Exception as exc:
_log_smtp_failure(data.email, subject, plain, exc, context="forgot_password")
return {"ok": True}
class UpgradeRequestBody(BaseModel):
tier: str = Field(..., max_length=50)
message: Optional[str] = Field(None, max_length=2000)
@router.post("/upgrade-request")
async def create_upgrade_request(data: UpgradeRequestBody, user=Depends(get_current_user)):
_VALID = {"pro", "breeder"}
if data.tier not in _VALID:
raise HTTPException(400, "Ungültiger Tarif.")
with db() as conn:
existing = conn.execute(
"SELECT id FROM upgrade_requests WHERE user_id=? AND tier=? AND fulfilled_at IS NULL",
(user["id"], data.tier)
).fetchone()
if existing:
return {"ok": True, "already": True}
conn.execute(
"INSERT INTO upgrade_requests (user_id, tier, message) VALUES (?,?,?)",
(user["id"], data.tier, data.message or None)
)
email = conn.execute("SELECT email FROM users WHERE id=?", (user["id"],)).fetchone()["email"]
tier_labels = {"pro": "Ban Yaro Pro", "breeder": "Züchter"}
tier_label = tier_labels[data.tier]
admin_email = os.getenv("ADMIN_EMAIL", "")
if admin_email:
try:
from routes.outreach import _send_smtp
subject = f"[Ban Yaro] Upgrade-Anfrage: {tier_label}{user['name']}"
body = (f"Neue Upgrade-Anfrage:\n\n"
f"Nutzer: {user['name']} ({email})\n"
f"Tarif: {tier_label}\n"
f"Nachricht: {data.message or ''}\n\n"
f"Admin-Panel: https://banyaro.app/#admin")
_send_smtp(admin_email, subject, body, "support")
except Exception as exc:
_log_smtp_failure(admin_email, subject, body, exc, context="upgrade_request_admin_notify")
return {"ok": True}
@router.post("/reset-password")
async def reset_password(data: ResetPasswordRequest, request: Request):
rl_check(request, max_requests=5, window_seconds=3600, key="reset_pw")
if len(data.password) < 8:
raise HTTPException(400, "Passwort muss mindestens 8 Zeichen lang sein.")
with db() as conn:
user = conn.execute(
"SELECT id, password_reset_expires FROM users WHERE password_reset_token=?",
(data.token,)
).fetchone()
if not user:
raise HTTPException(400, "Ungültiger oder abgelaufener Link.")
if user["password_reset_expires"] < datetime.utcnow().isoformat():
raise HTTPException(400, "Dieser Link ist abgelaufen. Bitte fordere einen neuen an.")
conn.execute(
"UPDATE users SET pw_hash=?, password_reset_token=NULL, password_reset_expires=NULL WHERE id=?",
(hash_password(data.password), user["id"])
)
return {"ok": True}
@router.post("/subscription/cancel")
async def cancel_subscription(user=Depends(get_current_user)):
with db() as conn:
row = conn.execute(
"SELECT subscription_tier, subscription_expires_at, subscription_cancelled_at FROM users WHERE id=?",
(user["id"],)
).fetchone()
if not row or row["subscription_tier"] in ("standard", "standard_test"):
raise HTTPException(400, "Kein aktives Abo vorhanden.")
if row["subscription_cancelled_at"]:
raise HTTPException(400, "Abo ist bereits gekündigt.")
conn.execute(
"UPDATE users SET subscription_cancelled_at=strftime('%Y-%m-%dT%H:%M:%SZ','now') WHERE id=?",
(user["id"],)
)
expires = row["subscription_expires_at"]
# Bestätigungsmail
try:
from mailer import send_email, email_html
import html as _html
tier_label = {"pro": "Ban Yaro Pro", "breeder": "Züchter"}.get(row["subscription_tier"], row["subscription_tier"])
expires_de = None
if expires:
from datetime import date as _date
try:
d = _date.fromisoformat(expires[:10])
monate = ["Januar","Februar","März","April","Mai","Juni",
"Juli","August","September","Oktober","November","Dezember"]
expires_de = f"{d.day}. {monate[d.month-1]} {d.year}"
except Exception:
expires_de = expires[:10]
expiry_line = (
f"<p>Dein Abo ist weiterhin aktiv bis zum <strong>{expires_de}</strong>. "
f"Ab diesem Datum wirst du automatisch auf den kostenlosen Tarif gesetzt.</p>"
if expires_de else
"<p>Dein Abo bleibt bis zum Ende des bezahlten Zeitraums aktiv.</p>"
)
body_html = f"""
<p>Hallo {_html.escape(user['name'])},</p>
<p>deine Kündigung für <strong>{tier_label}</strong> wurde bestätigt.</p>
{expiry_line}
<p>Deine Daten (Tagebuch, Gesundheit, Notizen) bleiben vollständig erhalten.
Wenn du mehrere Hunde hast, kannst du vor dem Ablauf einen als Haupthund festlegen.</p>
<p>Wir hoffen, dich bald wieder begrüßen zu dürfen!</p>
<p>Viele Grüße<br>René &amp; das Ban Yaro Team</p>"""
html = email_html(body_html, cta_url="https://banyaro.app", cta_label="Ban Yaro öffnen")
plain = (f"Hallo {user['name']},\n\nKündigung bestätigt für {tier_label}.\n"
+ (f"Aktiv bis: {expires_de}\n" if expires_de else "")
+ "\nAlle Daten bleiben erhalten.\n\nViele Grüße\nRené")
await send_email(user["email"], f"Kündigung bestätigt — {tier_label}", html, plain)
except Exception as exc:
_log_smtp_failure(
user.get("email") or "",
f"Kündigung bestätigt — {tier_label if 'tier_label' in locals() else '?'}",
plain if 'plain' in locals() else "",
exc,
context="subscription_cancel_confirmation"
)
return {"ok": True, "expires_at": expires}
@router.post("/subscription/select-dog")
async def select_primary_dog(body: dict, user=Depends(get_current_user)):
"""Nach Downgrade: Haupthund auswählen, Rest bleibt erhalten aber inaktiv."""
dog_id = body.get("dog_id")
if not dog_id:
raise HTTPException(400, "dog_id fehlt.")
with db() as conn:
dog = conn.execute(
"SELECT id FROM dogs WHERE id=? AND user_id=?", (dog_id, user["id"])
).fetchone()
if not dog:
raise HTTPException(404, "Hund nicht gefunden.")
# Alle anderen Hunde deaktivieren
conn.execute(
"UPDATE dogs SET is_active=0 WHERE user_id=? AND id!=?", (user["id"], dog_id)
)
conn.execute(
"UPDATE dogs SET is_active=1 WHERE id=?", (dog_id,)
)
conn.execute(
"UPDATE users SET needs_dog_selection=0 WHERE id=?", (user["id"],)
)
return {"ok": True}
@router.post("/heartbeat")
async def heartbeat(user=Depends(get_current_user)):
with db() as conn:
conn.execute("UPDATE users SET last_seen=datetime('now') WHERE id=?", (user["id"],))
return {"ok": True}