Registrierung & Login: - E-Mail-Verifikation jetzt Pflicht vor erstem Login - Register gibt keinen Token mehr zurück → "Postfach prüfen"-Screen - Login blockt mit EMAIL_NOT_VERIFIED (403) wenn unverifiziert - Resend-Verification ohne Auth (email-basiert) - Frontend: _renderVerifyPending() nach Register und Login-Fehler - Account-Lockout: 5 Fehlversuche → 15 Min gesperrt (ratelimit.py) - Login Rate-Limit zusätzlich per E-Mail-Adresse (5/5 Min) - Fehler-Tracking wird bei erfolgreichem Login zurückgesetzt E-Mail-Templates (alle Mails jetzt HTML): - email_html() Shared-Template in mailer.py (Gradient-Header, Warm-Beige) - Verifikations-Mail, Passwort-Reset → HTML mit CTA-Button - Admin-Outreach: plain text auto-wrapped in HTML - Züchter-Mails (Antrag/Genehmigung/Ablehnung) → Template - Tierschutz-Alert (litters.py) → Template - send_support_mail → HTML - outreach._build_message() + _send_smtp() unterstützen jetzt html= Parameter Forum-Schutz: - Post-Cooldown: 30 Sek zwischen beliebigen Posts (DB-Check) - Stunden-Limit: 5 Threads / 20 Antworten pro User/Stunde - Duplikat-Erkennung: gleicher Text in 5 Min blockiert (in-memory) - content_filter.py: Spam-Keywords, URL-Sperre für Accounts < 7 Tage, Sonderzeichen-Ratio-Check Security-Headers: - HSTS: max-age=31536000; includeSubDomains - Content-Security-Policy: frame-ancestors none, base-uri self, … - X-Frame-Options entfernt (CSP frame-ancestors ist moderner) Honeypot-Fallen (13 Scanner-Pfade → 24h IP-Sperre): - /api/admin/users, /api/v1/users, /api/.env, /api/config, /api/setup, /api/install, /api/phpinfo, /api/debug, /api/actuator, /api/swagger, /api/graphql u.a. Quartalsbericht-System: - backend/scripts/generate_reports.py: 6 Sections (Sicherheit, Funktionsumfang, Dateien, Nutzer, Partner, Server) - make reports: generiert alle Berichte aus dem Container, committed - Scheduler: quarterly_report Job (1. Feb/Mai/Aug/Nov 07:00) → vollständige HTML-Mail an ADMIN_EMAIL - quarterly_report erscheint im täglichen Status-Report Admin-Panel: - "Forum & Meldungen" → "Forum"
393 lines
16 KiB
Python
393 lines
16 KiB
Python
"""BAN YARO — Züchter-Verwaltung (Antrag, Admin-Prüfung)"""
|
|
import os
|
|
import logging
|
|
from datetime import datetime
|
|
from zoneinfo import ZoneInfo
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form
|
|
from fastapi.responses import FileResponse
|
|
from pydantic import BaseModel
|
|
from typing import Optional
|
|
|
|
from database import db
|
|
from auth import get_current_user, require_premium
|
|
from mailer import send_email, email_html
|
|
|
|
router = APIRouter()
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_TZ = ZoneInfo("Europe/Berlin")
|
|
|
|
BREEDER_DOCS_DIR = os.getenv("BREEDER_DOCS_DIR", "/data/breeder_docs")
|
|
os.makedirs(BREEDER_DOCS_DIR, exist_ok=True)
|
|
|
|
ADMIN_EMAIL = os.getenv("ADMIN_EMAIL", "admin@banyaro.app")
|
|
APP_URL = os.getenv("APP_URL", "https://banyaro.app")
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# Dependency: nur verifizierte Züchter + Admins
|
|
# ------------------------------------------------------------------
|
|
def require_breeder(user=Depends(get_current_user)):
|
|
if user["rolle"] not in ("breeder", "admin"):
|
|
raise HTTPException(403, "Nur für verifizierte Züchter.")
|
|
return user
|
|
|
|
def require_admin(user=Depends(get_current_user)):
|
|
if user["rolle"] != "admin":
|
|
raise HTTPException(403, "Nur für Admins.")
|
|
return user
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# GET /api/breeder/status — eigener Antragsstatus
|
|
# ------------------------------------------------------------------
|
|
@router.get("/breeder/status")
|
|
async def breeder_status(user=Depends(get_current_user)):
|
|
with db() as conn:
|
|
row = conn.execute(
|
|
"SELECT rolle, breeder_status FROM users WHERE id=?",
|
|
(user["id"],)
|
|
).fetchone()
|
|
if not row:
|
|
raise HTTPException(404, "User nicht gefunden.")
|
|
profile = None
|
|
if row["rolle"] in ("breeder", "admin"):
|
|
profile = conn.execute(
|
|
"SELECT zwingername, rasse_text, verein, vdh_mitglied, stadt, website, beschreibung, verified_at "
|
|
"FROM breeder_profiles WHERE user_id=?",
|
|
(user["id"],)
|
|
).fetchone()
|
|
return {
|
|
"rolle": row["rolle"],
|
|
"breeder_status": row["breeder_status"],
|
|
"profile": dict(profile) if profile else None,
|
|
}
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# POST /api/breeder/apply — Antrag stellen
|
|
# ------------------------------------------------------------------
|
|
@router.post("/breeder/apply")
|
|
async def breeder_apply(
|
|
zwingername: str = Form(...),
|
|
rasse_text: str = Form(...),
|
|
verein: str = Form(...),
|
|
vdh_mitglied: int = Form(0),
|
|
stadt: str = Form(...),
|
|
website: str = Form(""),
|
|
beschreibung: str = Form(""),
|
|
dokument: UploadFile = File(...),
|
|
user=Depends(get_current_user),
|
|
):
|
|
with db() as conn:
|
|
row = conn.execute(
|
|
"SELECT rolle, breeder_status FROM users WHERE id=?",
|
|
(user["id"],)
|
|
).fetchone()
|
|
|
|
if not row:
|
|
raise HTTPException(404, "User nicht gefunden.")
|
|
if row["rolle"] == "breeder":
|
|
raise HTTPException(400, "Du bist bereits verifizierter Züchter.")
|
|
if row["breeder_status"] == "pending":
|
|
raise HTTPException(400, "Du hast bereits einen offenen Antrag.")
|
|
|
|
# Dokument validieren und speichern
|
|
data = await dokument.read()
|
|
if len(data) > 10 * 1024 * 1024:
|
|
raise HTTPException(400, "Dokument zu groß (max. 10 MB).")
|
|
ext = os.path.splitext(dokument.filename or "")[1].lower()
|
|
if ext not in (".pdf", ".jpg", ".jpeg", ".png", ".webp"):
|
|
raise HTTPException(400, "Nur PDF, JPG, PNG oder WebP erlaubt.")
|
|
|
|
user_doc_dir = os.path.join(BREEDER_DOCS_DIR, str(user["id"]))
|
|
os.makedirs(user_doc_dir, exist_ok=True)
|
|
|
|
filename = f"antrag_{datetime.now(_TZ).strftime('%Y%m%d_%H%M%S')}{ext}"
|
|
filepath = os.path.join(user_doc_dir, filename)
|
|
with open(filepath, "wb") as f:
|
|
f.write(data)
|
|
|
|
with db() as conn:
|
|
conn.execute(
|
|
"UPDATE users SET breeder_status='pending' WHERE id=?",
|
|
(user["id"],)
|
|
)
|
|
# Profil-Entwurf anlegen (oder überschreiben wenn rejected)
|
|
conn.execute(
|
|
"INSERT INTO breeder_profiles (user_id, zwingername, rasse_text, verein, vdh_mitglied, stadt, website, beschreibung) "
|
|
"VALUES (?,?,?,?,?,?,?,?) "
|
|
"ON CONFLICT(user_id) DO UPDATE SET "
|
|
"zwingername=excluded.zwingername, rasse_text=excluded.rasse_text, "
|
|
"verein=excluded.verein, vdh_mitglied=excluded.vdh_mitglied, "
|
|
"stadt=excluded.stadt, website=excluded.website, beschreibung=excluded.beschreibung, "
|
|
"verified_at=NULL",
|
|
(user["id"], zwingername, rasse_text, verein, vdh_mitglied, stadt, website, beschreibung)
|
|
)
|
|
conn.execute(
|
|
"INSERT INTO breeder_documents (user_id, dokument_typ, file_path) VALUES (?,?,?)",
|
|
(user["id"], "antrag", filepath)
|
|
)
|
|
|
|
# Admin benachrichtigen
|
|
admin_body = f"""
|
|
<p style="margin:0 0 12px"><b>Neuer Züchter-Antrag eingegangen:</b></p>
|
|
<table style="font-size:14px;border-collapse:collapse;width:100%">
|
|
<tr><td style="padding:5px 12px 5px 0;color:#888;white-space:nowrap">Von</td><td style="padding:5px 0"><b>{user['name']}</b> ({user['email']})</td></tr>
|
|
<tr><td style="padding:5px 12px 5px 0;color:#888">Zwingername</td><td style="padding:5px 0">{zwingername}</td></tr>
|
|
<tr><td style="padding:5px 12px 5px 0;color:#888">Rasse</td><td style="padding:5px 0">{rasse_text}</td></tr>
|
|
<tr><td style="padding:5px 12px 5px 0;color:#888">Verein</td><td style="padding:5px 0">{verein}</td></tr>
|
|
<tr><td style="padding:5px 12px 5px 0;color:#888">VDH</td><td style="padding:5px 0">{'Ja' if vdh_mitglied else 'Nein'}</td></tr>
|
|
<tr><td style="padding:5px 12px 5px 0;color:#888">Stadt</td><td style="padding:5px 0">{stadt}</td></tr>
|
|
</table>"""
|
|
try:
|
|
await send_email(
|
|
ADMIN_EMAIL,
|
|
f"[Banyaro] Neuer Züchter-Antrag — {zwingername}",
|
|
email_html(admin_body, cta_url=f"{APP_URL}/#admin", cta_label="Im Admin-Bereich prüfen"),
|
|
f"Neuer Züchter-Antrag von {user['name']} ({user['email']}): {zwingername}, {rasse_text}, {verein}",
|
|
)
|
|
except Exception as e:
|
|
logger.warning(f"Admin-Mail nicht gesendet: {e}")
|
|
|
|
return {"message": "Antrag eingereicht. Du wirst per E-Mail benachrichtigt."}
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# GET /api/admin/breeders/pending — offene Anträge
|
|
# ------------------------------------------------------------------
|
|
@router.get("/admin/breeders/pending")
|
|
async def admin_pending_breeders(admin=Depends(require_admin)):
|
|
with db() as conn:
|
|
rows = conn.execute("""
|
|
SELECT u.id, u.name, u.email, u.created_at, u.breeder_status,
|
|
bp.zwingername, bp.rasse_text, bp.verein, bp.vdh_mitglied,
|
|
bp.stadt, bp.website, bp.beschreibung, bp.created_at AS antrag_at,
|
|
(SELECT COUNT(*) FROM breeder_documents WHERE user_id=u.id) AS dok_count
|
|
FROM users u
|
|
JOIN breeder_profiles bp ON bp.user_id = u.id
|
|
WHERE u.breeder_status = 'pending'
|
|
ORDER BY bp.created_at ASC
|
|
""").fetchall()
|
|
return [dict(r) for r in rows]
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# GET /api/admin/breeder/{user_id}/documents — Dokumente eines Antrags
|
|
# ------------------------------------------------------------------
|
|
@router.get("/admin/breeder/{user_id}/documents")
|
|
async def admin_breeder_documents(user_id: int, admin=Depends(require_admin)):
|
|
with db() as conn:
|
|
docs = conn.execute(
|
|
"SELECT id, dokument_typ, file_path, uploaded_at FROM breeder_documents WHERE user_id=?",
|
|
(user_id,)
|
|
).fetchall()
|
|
return [dict(d) for d in docs]
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# GET /api/admin/breeder/{user_id}/document/{doc_id} — Datei herunterladen
|
|
# ------------------------------------------------------------------
|
|
@router.get("/admin/breeder/{user_id}/document/{doc_id}")
|
|
async def admin_download_document(user_id: int, doc_id: int, admin=Depends(require_admin)):
|
|
with db() as conn:
|
|
row = conn.execute(
|
|
"SELECT file_path FROM breeder_documents WHERE id=? AND user_id=?",
|
|
(doc_id, user_id)
|
|
).fetchone()
|
|
if not row:
|
|
raise HTTPException(404, "Dokument nicht gefunden.")
|
|
path = row["file_path"]
|
|
if not os.path.exists(path):
|
|
raise HTTPException(404, "Datei nicht auf Datenträger.")
|
|
return FileResponse(path)
|
|
|
|
|
|
class RejectBody(BaseModel):
|
|
grund: str
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# POST /api/admin/breeder/{user_id}/approve — Freischalten
|
|
# ------------------------------------------------------------------
|
|
@router.post("/admin/breeder/{user_id}/approve")
|
|
async def admin_approve_breeder(user_id: int, admin=Depends(require_admin)):
|
|
with db() as conn:
|
|
user = conn.execute(
|
|
"SELECT id, name, email, breeder_status FROM users WHERE id=?",
|
|
(user_id,)
|
|
).fetchone()
|
|
if not user:
|
|
raise HTTPException(404, "User nicht gefunden.")
|
|
if user["breeder_status"] != "pending":
|
|
raise HTTPException(400, "Kein offener Antrag.")
|
|
|
|
conn.execute(
|
|
"UPDATE users SET rolle='breeder', breeder_status='approved' WHERE id=?",
|
|
(user_id,)
|
|
)
|
|
conn.execute(
|
|
"UPDATE breeder_profiles SET verified_at=datetime('now') WHERE user_id=?",
|
|
(user_id,)
|
|
)
|
|
|
|
# Bestätigungs-Mail
|
|
approve_body = f"""
|
|
<p style="margin:0 0 16px">Hallo <b>{user['name']}</b>,</p>
|
|
<p style="margin:0 0 16px">
|
|
dein Züchter-Profil bei Ban Yaro wurde erfolgreich verifiziert. 🎉<br>
|
|
Ab sofort hast du Zugang zu allen Züchter-Features.
|
|
</p>"""
|
|
try:
|
|
await send_email(
|
|
user["email"],
|
|
"Willkommen als Züchter bei Ban Yaro!",
|
|
email_html(approve_body, cta_url=APP_URL, cta_label="Zur App"),
|
|
f"Hallo {user['name']}, dein Züchter-Profil wurde verifiziert.",
|
|
)
|
|
except Exception as e:
|
|
logger.warning(f"Bestätigungs-Mail nicht gesendet: {e}")
|
|
|
|
return {"message": f"{user['name']} als Züchter freigeschaltet."}
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# POST /api/admin/breeder/{user_id}/reject — Ablehnen
|
|
# ------------------------------------------------------------------
|
|
@router.post("/admin/breeder/{user_id}/reject")
|
|
async def admin_reject_breeder(user_id: int, body: RejectBody, admin=Depends(require_admin)):
|
|
with db() as conn:
|
|
user = conn.execute(
|
|
"SELECT id, name, email, breeder_status FROM users WHERE id=?",
|
|
(user_id,)
|
|
).fetchone()
|
|
if not user:
|
|
raise HTTPException(404, "User nicht gefunden.")
|
|
if user["breeder_status"] != "pending":
|
|
raise HTTPException(400, "Kein offener Antrag.")
|
|
|
|
conn.execute(
|
|
"UPDATE users SET breeder_status='rejected' WHERE id=?",
|
|
(user_id,)
|
|
)
|
|
|
|
# Ablehnungs-Mail
|
|
import html as _h
|
|
reject_body = f"""
|
|
<p style="margin:0 0 16px">Hallo <b>{user['name']}</b>,</p>
|
|
<p style="margin:0 0 16px">
|
|
leider konnten wir deinen Züchter-Antrag aktuell nicht bestätigen.
|
|
</p>
|
|
<div style="background:#fdf6ef;border-left:3px solid #C4843A;padding:12px 16px;
|
|
border-radius:0 8px 8px 0;margin:0 0 16px;font-size:14px">
|
|
<b>Grund:</b> {_h.escape(body.grund)}
|
|
</div>
|
|
<p style="margin:0;color:#666;font-size:14px">
|
|
Du kannst jederzeit einen neuen Antrag stellen. Bei Fragen erreichst du uns unter
|
|
<a href="mailto:{ADMIN_EMAIL}" style="color:#C4843A">{ADMIN_EMAIL}</a>.
|
|
</p>"""
|
|
try:
|
|
await send_email(
|
|
user["email"],
|
|
"Dein Züchter-Antrag bei Ban Yaro",
|
|
email_html(reject_body),
|
|
f"Hallo {user['name']}, dein Antrag wurde abgelehnt. Grund: {body.grund}",
|
|
)
|
|
except Exception as e:
|
|
logger.warning(f"Ablehnungs-Mail nicht gesendet: {e}")
|
|
|
|
return {"message": f"Antrag von {user['name']} abgelehnt."}
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# GET /api/breeder/profil/{zwingername} — öffentliches Profil
|
|
# ------------------------------------------------------------------
|
|
@router.get("/breeder/profil/{zwingername}")
|
|
async def breeder_public_profile(zwingername: str):
|
|
with db() as conn:
|
|
row = conn.execute("""
|
|
SELECT bp.id, bp.zwingername, bp.rasse_text, bp.verein, bp.vdh_mitglied,
|
|
bp.stadt, bp.website, bp.beschreibung,
|
|
bp.location_lat, bp.location_lng, bp.verified_at, bp.created_at,
|
|
u.id AS zuechter_user_id,
|
|
u.name AS zuechter_name
|
|
FROM breeder_profiles bp
|
|
JOIN users u ON u.id = bp.user_id
|
|
WHERE LOWER(bp.zwingername) = LOWER(?)
|
|
AND u.rolle = 'breeder'
|
|
AND u.breeder_status = 'approved'
|
|
""", (zwingername,)).fetchone()
|
|
if not row:
|
|
raise HTTPException(404, "Züchter nicht gefunden.")
|
|
return dict(row)
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# POST /api/admin/breeder/create-profile — Admin-Schnellprofil
|
|
# ------------------------------------------------------------------
|
|
@router.post("/admin/breeder/create-profile")
|
|
async def admin_create_profile(admin=Depends(require_admin)):
|
|
with db() as conn:
|
|
existing = conn.execute(
|
|
"SELECT id FROM breeder_profiles WHERE user_id=?", (admin["id"],)
|
|
).fetchone()
|
|
if existing:
|
|
return {"message": "Profil existiert bereits.", "profile_id": existing["id"]}
|
|
cur = conn.execute(
|
|
"INSERT INTO breeder_profiles (user_id, zwingername, rasse_text, verein, stadt, verified_at) "
|
|
"VALUES (?, ?, ?, ?, ?, datetime('now'))",
|
|
(admin["id"], "Admin-Zwinger", "Alle Rassen", "Admin", "Überall")
|
|
)
|
|
conn.execute(
|
|
"UPDATE users SET breeder_status='approved' WHERE id=?", (admin["id"],)
|
|
)
|
|
return {"message": "Admin-Züchterprofil angelegt.", "profile_id": cur.lastrowid}
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# PUT /api/breeder/profile — eigenes Profil bearbeiten
|
|
# ------------------------------------------------------------------
|
|
class BreederProfileUpdate(BaseModel):
|
|
zwingername: Optional[str] = None
|
|
rasse_text: Optional[str] = None
|
|
verein: Optional[str] = None
|
|
vdh_mitglied: Optional[int] = None
|
|
stadt: Optional[str] = None
|
|
website: Optional[str] = None
|
|
beschreibung: Optional[str] = None
|
|
|
|
@router.put("/breeder/profile")
|
|
async def update_breeder_profile(body: BreederProfileUpdate, user=Depends(require_breeder)):
|
|
with db() as conn:
|
|
profile = conn.execute(
|
|
"SELECT id FROM breeder_profiles WHERE user_id=?", (user["id"],)
|
|
).fetchone()
|
|
if not profile:
|
|
raise HTTPException(404, "Kein Züchter-Profil vorhanden.")
|
|
fields = {k: v for k, v in body.model_dump().items() if v is not None}
|
|
if not fields:
|
|
return {"message": "Keine Änderungen."}
|
|
set_clause = ", ".join(f"{k}=?" for k in fields)
|
|
conn.execute(
|
|
f"UPDATE breeder_profiles SET {set_clause} WHERE id=?",
|
|
(*fields.values(), profile["id"])
|
|
)
|
|
return {"message": "Profil aktualisiert."}
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# GET /api/breeder/map — alle Züchter für Karte
|
|
# ------------------------------------------------------------------
|
|
@router.get("/breeder/map")
|
|
async def breeder_map_markers():
|
|
with db() as conn:
|
|
rows = conn.execute("""
|
|
SELECT bp.id, bp.zwingername, bp.rasse_text, bp.stadt,
|
|
bp.location_lat, bp.location_lng
|
|
FROM breeder_profiles bp
|
|
JOIN users u ON u.id = bp.user_id
|
|
WHERE bp.verified_at IS NOT NULL
|
|
AND u.rolle = 'breeder'
|
|
""").fetchall()
|
|
return [dict(r) for r in rows]
|