- DB: upgrade_requests-Tabelle (user_id, tier, message, fulfilled_at)
- POST /api/upgrade-request: Anfrage speichern + Admin-Benachrichtigungsmail
- GET/POST /api/admin/upgrade-requests[/{id}/fulfill]: Admin-Endpunkte
— fulfill setzt subscription_tier + sendet Bestätigungsmail an User
- action-items: upgrades_pending zählt offene Anfragen → Badge im Admin
- Admin-Tab "Upgrades": Tabelle offener/erledigter Anfragen, Freischalten-Button
mit Confirm-Modal, automatischer Tier-Setzung und Bestätigungsmail
- Settings: Upgrade-Modal sendet echte API-Anfrage statt nur mailto
— doppelte Anfrage wird erkannt (already:true → Toast statt Fehler)
- api.js: API.auth.upgradeRequest(tier, message) hinzugefügt
- SW by-v920, APP_VER 920
396 lines
15 KiB
Python
396 lines
15 KiB
Python
"""BAN YARO — Auth Routes"""
|
|
|
|
import os
|
|
import secrets
|
|
import string
|
|
from datetime import datetime, timedelta
|
|
from typing import Optional
|
|
|
|
from fastapi import APIRouter, HTTPException, Request, Response, Depends
|
|
from fastapi.responses import RedirectResponse
|
|
from pydantic import BaseModel, EmailStr
|
|
from database import db
|
|
from auth import (
|
|
hash_password, verify_password, create_token,
|
|
get_current_user
|
|
)
|
|
from username_blocklist import is_username_blocked
|
|
from ratelimit import check as rl_check, record_login_failure, is_account_locked, clear_login_failures
|
|
|
|
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"))
|
|
|
|
|
|
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:
|
|
pass # Nicht blockieren wenn SMTP fehlschlägt
|
|
|
|
|
|
class LoginRequest(BaseModel):
|
|
email: EmailStr
|
|
password: str
|
|
|
|
class RegisterRequest(BaseModel):
|
|
email: EmailStr
|
|
password: str
|
|
name: str
|
|
ref_code: Optional[str] = None
|
|
|
|
|
|
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=30 * 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"]
|
|
|
|
if data.ref_code:
|
|
code_upper = data.ref_code.strip().upper()
|
|
# Zuerst prüfen ob es ein Partner-Code ist
|
|
partner = conn.execute(
|
|
"SELECT id, grants_founder, max_uses, uses FROM partner_codes WHERE code=?",
|
|
(code_upper,)
|
|
).fetchone()
|
|
if partner:
|
|
# Nur einlösen wenn max_uses nicht erreicht
|
|
if partner["max_uses"] is None or partner["uses"] < partner["max_uses"]:
|
|
conn.execute(
|
|
"UPDATE partner_codes SET uses=uses+1 WHERE id=?",
|
|
(partner["id"],)
|
|
)
|
|
updates = {"referred_by": -partner["id"]}
|
|
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()}")
|
|
|
|
if is_account_locked(data.email):
|
|
raise HTTPException(429, "Zu viele Fehlversuche. Bitte warte 15 Minuten und versuche es erneut.")
|
|
|
|
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"]):
|
|
record_login_failure(data.email)
|
|
raise HTTPException(401, "E-Mail oder Passwort falsch.")
|
|
|
|
if not user["email_verified"]:
|
|
raise HTTPException(403, "EMAIL_NOT_VERIFIED")
|
|
|
|
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(response: Response):
|
|
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,
|
|
preferred_theme, subscription_tier
|
|
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
|
|
|
|
|
|
@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"],)
|
|
)
|
|
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
|
|
password: str
|
|
|
|
@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:
|
|
pass
|
|
return {"ok": True}
|
|
|
|
|
|
class UpgradeRequestBody(BaseModel):
|
|
tier: str
|
|
message: Optional[str] = None
|
|
|
|
@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:
|
|
pass
|
|
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}
|