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.
735 lines
31 KiB
Python
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é & 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}
|