Security + E-Mail-HTML + Quartalsbericht + Registrierungspflicht

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"
This commit is contained in:
rene 2026-05-01 08:20:53 +02:00
parent c1bb728153
commit de1677154f
15 changed files with 1363 additions and 141 deletions

View file

@ -27,7 +27,7 @@ TAR_EXCLUDE := --exclude='.git' \
--exclude='./.DS_Store'
.PHONY: help deploy deploy-clean staging release sync push restart build stop status \
logs logs-f shell db dev clean-cache check-ssh
logs logs-f shell db dev clean-cache check-ssh reports
# ----------------------------------------------------------
# SSH-Prüfung — Abhängigkeit aller DS-Befehle
@ -66,6 +66,7 @@ help:
@echo ""
@echo " make dev Lokaler Dev-Server auf Mac (Port 8001)"
@echo " make clean-cache SW-Cache-Version erhöhen + restart"
@echo " make reports Quartalsberichte generieren + committen"
@echo ""
# ----------------------------------------------------------
@ -235,6 +236,31 @@ dev:
DB_PATH=./dev.db \
uvicorn main:app --reload --port 8001
# ----------------------------------------------------------
# REPORTS — Quartalsberichte generieren und committen
# Berichte laufen im Container (DB-Zugriff), werden lokal gespeichert
# ----------------------------------------------------------
REPORT_DATE := $(shell date +%Y-%m-%d)
REPORT_SECTIONS := sicherheit funktionsumfang dateien nutzer partner server
reports: check-ssh
@mkdir -p reports
@echo "→ Berichte generieren ($(REPORT_DATE))..."
@for section in $(REPORT_SECTIONS); do \
echo "$$section..."; \
ssh $(DS_HOST) "$(DOCKER) exec $(CONTAINER) python3 scripts/generate_reports.py $$section" \
> reports/$(REPORT_DATE)-$$section.md; \
done
@echo "→ Berichte committen..."
@git add reports/
@git diff --cached --quiet || git commit -m "Reports $(REPORT_DATE) — Quartalsbericht"
@echo ""
@echo " ✓ Alle Berichte erstellt und committed:"
@for section in $(REPORT_SECTIONS); do \
echo " reports/$(REPORT_DATE)-$$section.md"; \
done
# ----------------------------------------------------------
# CACHE leeren — SW-Version erhöhen, dann restart
# Nach größeren CSS/JS-Änderungen wenn SW gecacht hat

63
backend/content_filter.py Normal file
View file

@ -0,0 +1,63 @@
"""BAN YARO — Content-Filter für Spam und Missbrauch im Forum."""
import re
from datetime import datetime, timedelta, timezone
from fastapi import HTTPException
# Offensichtliche Spam-Signale
_SPAM_KEYWORDS = [
"casino", "poker", "slots", "jackpot", "sportwetten",
"viagra", "cialis", "levitra", "pharmacy", "apotheke online",
"kreditkarte sofort", "kredit ohne schufa", "schnell geld verdienen",
"passive income", "work from home", "earn money fast",
"click here", "klick hier", "free followers", "buy followers",
"whatsapp +", "telegram +", "call now", "jetzt anrufen",
"seo service", "backlinks kaufen", "website traffic",
"crypto invest", "bitcoin verdienen", "nft mint",
"lose weight fast", "abnehmen schnell", "diät pille",
]
# URL-Muster (http/https oder nackte Domains)
_URL_RE = re.compile(
r"(https?://|www\.|\b[a-zA-Z0-9-]+\.(de|com|net|org|io|app|shop|info|biz|ru|cn)\b)",
re.IGNORECASE,
)
# Mindest-Account-Alter für URL-Posts (Tage)
_MIN_DAYS_FOR_URLS = 7
def check_forum_content(text: str, user_created_at: str | None = None) -> None:
"""
Prüft Forum-Text auf Spam.
Wirft HTTPException(400) bei Fund.
"""
lower = text.lower()
# Spam-Keywords
for kw in _SPAM_KEYWORDS:
if kw in lower:
raise HTTPException(400, "Dein Beitrag wurde als Spam erkannt und nicht gespeichert.")
# URLs in neuen Accounts sperren
if _URL_RE.search(text):
if user_created_at:
try:
created = datetime.fromisoformat(user_created_at)
if created.tzinfo is None:
created = created.replace(tzinfo=timezone.utc)
age = datetime.now(timezone.utc) - created
if age < timedelta(days=_MIN_DAYS_FOR_URLS):
raise HTTPException(
400,
"Links können erst nach 7 Tagen Mitgliedschaft gepostet werden."
)
except (ValueError, TypeError):
pass
# Zu viele Sonderzeichen / Zeichensalat
if len(text) > 20:
alnum = sum(c.isalnum() or c.isspace() for c in text)
ratio = alnum / len(text)
if ratio < 0.5:
raise HTTPException(400, "Dein Beitrag enthält zu viele Sonderzeichen.")

View file

@ -106,44 +106,67 @@ async def send_email(to: str, subject: str, html: str, plain: str = ""):
logger.warning(f"Kein Mail-Backend konfiguriert — Mail NICHT gesendet: «{subject}» → {to}")
def email_html(
body_html: str,
cta_url: str = None,
cta_label: str = None,
footer_text: str = None,
) -> str:
"""Shared branded HTML email template (matches Status-Report design)."""
cta_block = ""
if cta_url and cta_label:
cta_block = f"""
<p style="margin:24px 0 0">
<a href="{cta_url}"
style="background:#C4843A;color:#fff;padding:14px 28px;border-radius:8px;
text-decoration:none;font-weight:700;font-size:15px;display:inline-block">
{cta_label}
</a>
</p>"""
footer = footer_text or "Ban Yaro · banyaro.app"
return f"""\
<!DOCTYPE html>
<html lang="de">
<head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
</head>
<body style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#f5f0ea;margin:0;padding:0">
<div style="max-width:600px;margin:24px auto;background:#fff;border-radius:14px;overflow:hidden;box-shadow:0 2px 16px rgba(0,0,0,.08)">
<div style="background:linear-gradient(135deg,#C4843A,#e8a857);padding:24px 28px;color:#fff">
<div style="font-size:22px;font-weight:800;margin-bottom:2px">🐾 Ban Yaro</div>
</div>
<div style="padding:28px;color:#333;font-size:15px;line-height:1.6">
{body_html}{cta_block}
</div>
<div style="padding:14px 28px;background:#fdf6ef;font-size:11px;color:#aaa;text-align:center">
{footer}
</div>
</div>
</body>
</html>"""
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">
body = f"""
<p style="margin:0 0 16px">Hallo <b>{name}</b>,</p>
<p style="margin:0 0 16px">
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">
<p style="margin:0;font-size:13px;color:#888">Der Link ist 48 Stunden 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>
</div>
</body>
</html>"""
</p>"""
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"
)
html = email_html(body, cta_url=url, cta_label="E-Mail bestätigen")
plain = f"Ban Yaro — E-Mail bestätigen\n\nHallo {name},\n\nbitte bestätige:\n{url}\n\nLink ist 48h gültig.\n"
await send_email(to, subject, html, plain)

View file

@ -67,11 +67,20 @@ app = FastAPI(
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
response = await call_next(request)
response.headers["X-Frame-Options"] = "SAMEORIGIN"
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
response.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=(self)"
response.headers["X-XSS-Protection"] = "1; mode=block"
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
response.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=(self)"
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
response.headers["Content-Security-Policy"] = (
"default-src 'self'; "
"script-src 'self' 'unsafe-inline' 'unsafe-eval'; "
"style-src 'self' 'unsafe-inline'; "
"img-src 'self' data: blob: https:; "
"connect-src 'self' https:; "
"frame-ancestors 'none'; "
"base-uri 'self'; "
"form-action 'self';"
)
return response
app.add_middleware(SecurityHeadersMiddleware)
@ -1617,6 +1626,43 @@ async def partner_landing():
return HTMLResponse(content=html, headers={"Cache-Control": "no-cache"})
# ------------------------------------------------------------------
# Honeypot-Fallen für Scanner und Bots
# Jeder Aufruf → 24h IP-Sperre
# ------------------------------------------------------------------
from ratelimit import block_ip as _block_ip
_HONEYPOT_PATHS = [
"/api/admin/users",
"/api/v1/users",
"/api/users",
"/api/.env",
"/api/config",
"/api/setup",
"/api/install",
"/api/phpinfo",
"/api/debug",
"/api/actuator",
"/api/actuator/health",
"/api/swagger",
"/api/graphql",
]
async def _honeypot_handler(request: Request):
import logging as _log
_log.getLogger("banyaro.security").warning(
"Honeypot getroffen: %s %s — IP %s",
request.method, request.url.path,
request.client.host if request.client else "?"
)
_block_ip(request, hours=24)
from fastapi.responses import JSONResponse
return JSONResponse(status_code=404, content={"detail": "Not Found"})
for _hp in _HONEYPOT_PATHS:
app.add_api_route(_hp, _honeypot_handler, methods=["GET", "POST", "PUT", "DELETE"], include_in_schema=False)
# SPA Fallback — ALLE nicht-API-Routen gehen zur index.html
@app.get("/{full_path:path}")
async def spa_fallback(full_path: str):

View file

@ -1,9 +1,9 @@
"""
BAN YARO Rate Limiter + IP-Blocklist
BAN YARO Rate Limiter + IP-Blocklist + Account-Lockout + Duplikat-Erkennung
Sliding-Window-Limiter, in-memory (kein Redis nötig für Single-Container).
Blocklist für Honeypot-Treffer.
"""
import hashlib
import threading
from collections import defaultdict, deque
from datetime import datetime, timedelta
@ -11,18 +11,23 @@ from datetime import datetime, timedelta
from fastapi import HTTPException, Request
_buckets: dict[str, deque] = defaultdict(deque)
_blocklist: dict[str, datetime] = {} # ip → gesperrt bis
_blocklist: dict[str, datetime] = {} # ip → gesperrt bis
_login_failures: dict[str, list] = defaultdict(list) # email → [datetime, ...]
_post_hashes: dict[int, dict] = defaultdict(dict) # user_id → {hash: datetime}
_lock = threading.Lock()
_LOCKOUT_WINDOW = 15 # Minuten
_LOCKOUT_ATTEMPTS = 5 # Fehlversuche bis Sperre
_DUPLICATE_WINDOW = 300 # Sekunden (5 Minuten)
# ------------------------------------------------------------------
# IP-basiertes Rate Limiting
# ------------------------------------------------------------------
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').
"""
"""Wirft HTTP 429 wenn max_requests im Zeitfenster überschritten."""
ip = (request.client.host if request.client else "unknown")
# Blocklist prüfen
with _lock:
blocked_until = _blocklist.get(ip)
if blocked_until and datetime.utcnow() < blocked_until:
@ -65,3 +70,63 @@ def is_blocked(request: Request) -> bool:
elif until:
del _blocklist[ip]
return False
# ------------------------------------------------------------------
# Account-Lockout (per E-Mail)
# ------------------------------------------------------------------
def record_login_failure(email: str) -> int:
"""Failure aufzeichnen. Gibt Anzahl Fehlversuche im Fenster zurück."""
email = email.lower()
now = datetime.utcnow()
cutoff = now - timedelta(minutes=_LOCKOUT_WINDOW)
with _lock:
recent = [t for t in _login_failures[email] if t > cutoff]
recent.append(now)
_login_failures[email] = recent
return len(recent)
def is_account_locked(email: str) -> bool:
"""True wenn ≥5 Fehlversuche in den letzten 15 Minuten."""
email = email.lower()
now = datetime.utcnow()
cutoff = now - timedelta(minutes=_LOCKOUT_WINDOW)
with _lock:
recent = [t for t in _login_failures.get(email, []) if t > cutoff]
return len(recent) >= _LOCKOUT_ATTEMPTS
def clear_login_failures(email: str):
"""Bei erfolgreichem Login zurücksetzen."""
with _lock:
_login_failures.pop(email.lower(), None)
# ------------------------------------------------------------------
# Duplikat-Post-Erkennung (per User, in-memory)
# ------------------------------------------------------------------
def content_hash(text: str) -> str:
normalized = " ".join(text.lower().split())
return hashlib.sha256(normalized.encode()).hexdigest()[:20]
def is_duplicate_post(user_id: int, text: str) -> bool:
"""True wenn derselbe User denselben Text in den letzten 5 Minuten gepostet hat."""
h = content_hash(text)
now = datetime.utcnow()
cutoff = now - timedelta(seconds=_DUPLICATE_WINDOW)
with _lock:
hashes = _post_hashes[user_id]
# Alte Einträge bereinigen
expired = [k for k, ts in hashes.items() if ts < cutoff]
for k in expired:
del hashes[k]
return h in hashes
def record_post(user_id: int, text: str):
"""Post-Hash speichern nach erfolgreichem Erstellen."""
h = content_hash(text)
with _lock:
_post_hashes[user_id][h] = datetime.utcnow()

View file

@ -15,7 +15,7 @@ from auth import (
get_current_user
)
from username_blocklist import is_username_blocked
from ratelimit import check as rl_check
from ratelimit import check as rl_check, record_login_failure, is_account_locked, clear_login_failures
router = APIRouter()
COOKIE_NAME = "by_token"
@ -27,17 +27,22 @@ def _send_verification_email(email: str, name: str, token: str):
if not _SMTP_READY:
return
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"
body = (
f"Hallo {name},\n\n"
"willkommen bei Ban Yaro! Bitte bestätige deine E-Mail-Adresse mit diesem Link:\n\n"
f"{_APP_URL}/api/auth/verify-email/{token}\n\n"
"Der Link ist 7 Tage gültig.\n\n"
"Falls du dich nicht bei Ban Yaro registriert hast, kannst du diese Mail ignorieren.\n\n"
"Viele Grüße,\nDas Ban Yaro Team\nbanyaro.app"
)
body_html = f"""
<p style="margin:0 0 16px">Hallo <b>{name}</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, body, "support")
_send_smtp(email, subject, plain, "support", html=html)
except Exception:
pass # Nicht blockieren wenn SMTP fehlschlägt
@ -139,24 +144,32 @@ async def register(data: RegisterRequest, response: Response, request: Request):
conn.execute("UPDATE users SET referred_by=? WHERE id=?",
(referrer['id'], new_user_id))
token = create_token(user["id"], user["rolle"])
_set_cookie(response, token)
_send_verification_email(data.email, name, verify_token)
return {"token": token, "name": name, "email_verified": 0}
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 FROM users WHERE email=?",
"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)
@ -249,23 +262,24 @@ async def verify_email(token: str):
return RedirectResponse(f"{_APP_URL}/#settings?verified=1", status_code=302)
class ResendVerificationRequest(BaseModel):
email: EmailStr
@router.post("/resend-verification")
async def resend_verification(request: Request, user=Depends(get_current_user)):
rl_check(request, max_requests=3, window_seconds=3600, key="resend_verify")
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 email, name, email_verified FROM users WHERE id=?", (user["id"],)
"SELECT id, name, email_verified FROM users WHERE email=?", (data.email,)
).fetchone()
if not row:
raise HTTPException(404)
if row["email_verified"]:
return {"ok": True, "already_verified": True}
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, user["id"])
"UPDATE users SET verification_token=? WHERE id=?", (token, row["id"])
)
_send_verification_email(row["email"], row["name"], token)
_send_verification_email(data.email, row["name"], token)
return {"ok": True}
@ -293,18 +307,23 @@ async def forgot_password(data: ForgotPasswordRequest, request: Request):
(token, expires, user["id"])
)
app_url = os.getenv("APP_URL", "https://banyaro.app")
url = f"{app_url}/#reset-password?token={token}"
subject = "Ban Yaro — Passwort zurücksetzen"
body = (
f"Hallo {user['name']},\n\n"
"du hast eine Passwort-Zurücksetzen-Anfrage gestellt.\n\n"
f"Klicke hier um ein neues Passwort zu setzen:\n"
f"{app_url}/#reset-password?token={token}\n\n"
"Der Link ist 2 Stunden gültig. Falls du keine Anfrage gestellt hast, ignoriere diese Mail.\n\n"
"Viele Grüße,\nDas Ban Yaro Team"
)
from routes.outreach import _send_smtp
from mailer import email_html
body_html = f"""
<p style="margin:0 0 16px">Hallo <b>{user['name']}</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, body, "support")
_send_smtp(data.email, subject, plain, "support", html=html)
except Exception:
pass
return {"ok": True}

View file

@ -11,7 +11,7 @@ from typing import Optional
from database import db
from auth import get_current_user, require_premium
from mailer import send_email
from mailer import send_email, email_html
router = APIRouter()
logger = logging.getLogger(__name__)
@ -131,21 +131,21 @@ async def breeder_apply(
)
# Admin benachrichtigen
admin_html = f"""
<h2>Neuer Züchter-Antrag</h2>
<p><b>Von:</b> {user['name']} ({user['email']})</p>
<p><b>Zwingername:</b> {zwingername}</p>
<p><b>Rasse:</b> {rasse_text}</p>
<p><b>Verein:</b> {verein}</p>
<p><b>VDH:</b> {'Ja' if vdh_mitglied else 'Nein'}</p>
<p><b>Stadt:</b> {stadt}</p>
<p><a href="{APP_URL}/admin">Im Admin-Bereich prüfen</a></p>
"""
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}",
admin_html,
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:
@ -233,18 +233,17 @@ async def admin_approve_breeder(user_id: int, admin=Depends(require_admin)):
)
# Bestätigungs-Mail
html = f"""
<h2>Willkommen als Züchter bei Banyaro!</h2>
<p>Hallo {user['name']},</p>
<p>dein Züchter-Profil wurde erfolgreich verifiziert.</p>
<p>Ab sofort hast du Zugang zu allen Züchter-Features.</p>
<p><a href="{APP_URL}">Zur App</a></p>
"""
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 Banyaro!",
html,
"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:
@ -274,19 +273,25 @@ async def admin_reject_breeder(user_id: int, body: RejectBody, admin=Depends(req
)
# Ablehnungs-Mail
html = f"""
<h2>Dein Züchter-Antrag bei Banyaro</h2>
<p>Hallo {user['name']},</p>
<p>leider konnten wir deinen Antrag aktuell nicht bestätigen.</p>
<p><b>Grund:</b> {body.grund}</p>
<p>Du kannst jederzeit einen neuen Antrag stellen.</p>
<p>Bei Fragen: <a href="mailto:{ADMIN_EMAIL}">{ADMIN_EMAIL}</a></p>
"""
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 Banyaro",
html,
"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:

View file

@ -7,6 +7,8 @@ from typing import Optional
from database import db
from auth import get_current_user, get_current_user_optional
from timeutils import safe_client_time
from ratelimit import is_duplicate_post, record_post
from content_filter import check_forum_content
from routes.push import send_push_to_user
from media_utils import convert_media, extract_video_thumb, generate_preview, preview_url_from
@ -164,6 +166,50 @@ async def list_threads(
# ------------------------------------------------------------------
# POST /api/forum/threads
# ------------------------------------------------------------------
def _check_post_limits(user_id: int, conn, text: str, user_created_at: str | None = None, is_thread: bool = False):
"""Cooldown, Stunden-Limit und Duplikat-Check für Forum-Posts."""
# 30-Sekunden-Cooldown zwischen beliebigen Posts
last = conn.execute(
"""SELECT MAX(created_at) AS last FROM (
SELECT created_at FROM forum_threads WHERE user_id=?
UNION ALL
SELECT created_at FROM forum_posts WHERE user_id=?
)""",
(user_id, user_id),
).fetchone()["last"]
if last:
try:
from datetime import datetime as _dt
diff = (_dt.utcnow() - _dt.fromisoformat(last)).total_seconds()
if diff < 30:
raise HTTPException(429, "Bitte warte einen Moment bevor du erneut postest.")
except (ValueError, TypeError):
pass
# Stunden-Limit
if is_thread:
count = conn.execute(
"SELECT COUNT(*) FROM forum_threads WHERE user_id=? AND created_at > datetime('now','-1 hour')",
(user_id,),
).fetchone()[0]
if count >= 5:
raise HTTPException(429, "Du hast in dieser Stunde bereits 5 Threads erstellt. Bitte warte etwas.")
else:
count = conn.execute(
"SELECT COUNT(*) FROM forum_posts WHERE user_id=? AND created_at > datetime('now','-1 hour')",
(user_id,),
).fetchone()[0]
if count >= 20:
raise HTTPException(429, "Du hast in dieser Stunde bereits 20 Antworten geschrieben. Bitte warte etwas.")
# Duplikat-Check
if is_duplicate_post(user_id, text):
raise HTTPException(400, "Dieser Beitrag wurde bereits kürzlich gepostet.")
# Content-Filter
check_forum_content(text, user_created_at)
@router.post("/threads", status_code=201)
async def create_thread(data: ThreadCreate, user=Depends(get_current_user)):
if not user.get("email_verified"):
@ -177,6 +223,7 @@ async def create_thread(data: ThreadCreate, user=Depends(get_current_user)):
if data.kategorie not in KATEGORIEN:
raise HTTPException(400, "Ungültige Kategorie.")
with db() as conn:
_check_post_limits(user["id"], conn, data.text.strip(), user.get("created_at"), is_thread=True)
ct = safe_client_time(data.client_time)
cur = conn.execute(
"""INSERT INTO forum_threads (user_id, kategorie, titel, text, thread_lat, thread_lon, thread_ort, created_at)
@ -194,6 +241,7 @@ async def create_thread(data: ThreadCreate, user=Depends(get_current_user)):
t = dict(row)
t['foto_urls'] = _parse_foto_urls(t.get('foto_urls'))
t['user_liked'] = False
record_post(user["id"], data.text.strip())
return t
@ -322,6 +370,8 @@ async def create_post(thread_id: int, data: PostCreate, user=Depends(get_current
if thread['is_deleted']:
raise HTTPException(404, "Thread nicht gefunden.")
_check_post_limits(user["id"], conn, data.text.strip(), user.get("created_at"), is_thread=False)
ct = safe_client_time(data.client_time)
cur = conn.execute(
"INSERT INTO forum_posts (thread_id, user_id, text, created_at) VALUES (?, ?, ?, ?)",
@ -347,6 +397,7 @@ async def create_post(thread_id: int, data: PostCreate, user=Depends(get_current
pd = dict(row)
pd['foto_urls'] = []
pd['user_liked'] = False
record_post(user["id"], data.text.strip())
# Push-Notification an Thread-Owner (nicht an sich selbst)
if owner_id and owner_id != user['id']:

View file

@ -240,7 +240,7 @@ async def create_litter(body: LitterCreate, user=Depends(_require_breeder)):
# ------------------------------------------------------------------
@router.post("/litters/{litter_id}/welfare-confirm")
async def welfare_confirm(litter_id: int, user=Depends(_require_breeder)):
from mailer import send_email
from mailer import send_email, email_html
import os, logging as _log
_logger = _log.getLogger(__name__)
@ -265,19 +265,20 @@ async def welfare_confirm(litter_id: int, user=Depends(_require_breeder)):
eltern = conn.execute(
"SELECT vater_name, mutter_name FROM litters WHERE id=?", (litter_id,)
).fetchone()
html = f"""
<h2>Tierschutz-Hinweis bestätigt</h2>
<p>Züchter <b>{zuechter}</b> (Zwinger: {zwinger}) hat einen Wurf mit
kritischen Tierschutz-Hinweisen trotzdem angelegt.</p>
<p>Vater: {eltern['vater_name'] or ''} &nbsp;·&nbsp; Mutter: {eltern['mutter_name'] or ''}</p>
<p>Wurf-ID: {litter_id}</p>
<p><a href="{app_url}/admin">Im Admin-Bereich prüfen</a></p>
"""
welfare_body = f"""
<p style="margin:0 0 12px"><b>Kritischer Tierschutz-Hinweis bestätigt</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">Züchter</td><td style="padding:5px 0"><b>{zuechter}</b></td></tr>
<tr><td style="padding:5px 12px 5px 0;color:#888">Zwinger</td><td style="padding:5px 0">{zwinger}</td></tr>
<tr><td style="padding:5px 12px 5px 0;color:#888">Vater</td><td style="padding:5px 0">{eltern['vater_name'] or ''}</td></tr>
<tr><td style="padding:5px 12px 5px 0;color:#888">Mutter</td><td style="padding:5px 0">{eltern['mutter_name'] or ''}</td></tr>
<tr><td style="padding:5px 12px 5px 0;color:#888">Wurf-ID</td><td style="padding:5px 0">#{litter_id}</td></tr>
</table>"""
try:
await send_email(
admin_email,
f"[Banyaro Tierschutz] Kritischer Hinweis bestätigt — {zwinger}",
html,
email_html(welfare_body, cta_url=f"{app_url}/#admin", cta_label="Im Admin-Bereich prüfen"),
f"Züchter {zuechter} hat Wurf #{litter_id} trotz kritischer Tierschutz-Hinweise angelegt.",
)
except Exception as e:

View file

@ -84,7 +84,7 @@ def _imap_save_sent(msg_bytes: bytes, account: str):
_log.error("IMAP Sent-Speicherung fehlgeschlagen (%s): %s", account, e)
def _build_message(to: str, subject: str, body: str, account: str) -> MIMEMultipart:
def _build_message(to: str, subject: str, body: str, account: str, html: str = None) -> MIMEMultipart:
acc = _ACCOUNTS.get(account) or _ACCOUNTS["partner"]
msg = MIMEMultipart("alternative")
msg["Subject"] = subject
@ -92,14 +92,16 @@ def _build_message(to: str, subject: str, body: str, account: str) -> MIMEMultip
msg["To"] = to
msg["Reply-To"] = acc["from"]
msg.attach(MIMEText(body, "plain", "utf-8"))
if html:
msg.attach(MIMEText(html, "html", "utf-8"))
return msg
def _send_smtp(to: str, subject: str, body: str, account: str = "partner"):
def _send_smtp(to: str, subject: str, body: str, account: str = "partner", html: str = None):
acc = _ACCOUNTS.get(account) or _ACCOUNTS["partner"]
if not acc["user"] or not acc["pass"]:
raise RuntimeError(f"SMTP-Account '{account}' nicht konfiguriert.")
msg = _build_message(to, subject, body, account)
msg = _build_message(to, subject, body, account, html=html)
msg_bytes = msg.as_bytes()
ctx = ssl.create_default_context()
with smtplib.SMTP(_SMTP_HOST, _SMTP_PORT, timeout=15) as s:
@ -189,6 +191,16 @@ def delete_template(tpl_id: int, user=Depends(require_admin)):
# Senden
# ------------------------------------------------------------------
def _plain_to_html_body(text: str) -> str:
import html as h
paragraphs = text.strip().split("\n\n")
parts = []
for p in paragraphs:
escaped = h.escape(p).replace("\n", "<br>")
parts.append(f'<p style="margin:0 0 14px;color:#444">{escaped}</p>')
return "".join(parts)
@router.post("/send")
def send_mail(data: SendRequest, user=Depends(require_admin)):
if not data.to:
@ -196,13 +208,19 @@ def send_mail(data: SendRequest, user=Depends(require_admin)):
if not data.subject.strip() or not data.body.strip():
raise HTTPException(400, "Betreff und Text dürfen nicht leer sein.")
from mailer import email_html
html = email_html(
_plain_to_html_body(data.body),
footer_text=f"Ban Yaro · banyaro.app · {data.subject}",
)
sent, failed = [], []
for addr in data.to:
addr = addr.strip()
if not addr:
continue
try:
_send_smtp(addr, data.subject, data.body, data.from_account)
_send_smtp(addr, data.subject, data.body, data.from_account, html=html)
sent.append(addr)
with db() as conn:
conn.execute(
@ -224,7 +242,9 @@ def send_mail(data: SendRequest, user=Depends(require_admin)):
def send_support_mail(to: str, subject: str, body: str):
"""Intern aufrufbar ohne HTTP-Request — z.B. aus Moderations-Logik."""
_send_smtp(to, subject, body, "support")
from mailer import email_html
html = email_html(_plain_to_html_body(body))
_send_smtp(to, subject, body, "support", html=html)
# ------------------------------------------------------------------

View file

@ -100,6 +100,14 @@ def start():
replace_existing=True,
misfire_grace_time=1800,
)
# 1. Feb / Mai / Aug / Nov 07:00 — Quartalsbericht
_scheduler.add_job(
_job_quarterly_report,
CronTrigger(month="2,5,8,11", day=1, hour=7, minute=0),
id="quarterly_report",
replace_existing=True,
misfire_grace_time=7200,
)
# Jeden Montag 07:00 — KI-Gesundheitsberichte (alle 2 Wochen)
_scheduler.add_job(
_job_ki_health_report,
@ -109,7 +117,7 @@ def start():
misfire_grace_time=3600,
)
_scheduler.start()
logger.info("Scheduler gestartet — Health-Reminder 08:00, Giftköder-Archiv 03:00, Wetter-Alert 07:30, Meilenstein-Check 00:05, Event-Import So 02:00, Rassen-Seed monatlich 1. des Monats, Status-Report täglich 06:00. OSM-Cache: on-demand (kein Prewarm).")
logger.info("Scheduler gestartet — Health-Reminder 08:00, Giftköder-Archiv 03:00, Wetter-Alert 07:30, Meilenstein-Check 00:05, Event-Import So 02:00, Rassen-Seed monatlich 1. des Monats, Status-Report täglich 06:00, Quartalsbericht 1. Feb/Mai/Aug/Nov 07:00. OSM-Cache: on-demand (kein Prewarm).")
def stop():
@ -698,6 +706,7 @@ async def _job_status_report():
"seed_wikidata": "Rassen-Seed (Wikidata, monatlich)",
"weekly_praise": "Wöchentlicher Lober (Mo 09:00)",
"ki_health_report": "KI-Gesundheitsberichte",
"quarterly_report": "Quartalsbericht (1. Feb/Mai/Aug/Nov)",
}
job_rows_html = ""
job_rows_txt = ""
@ -783,6 +792,133 @@ Züchter (pending): {metrics['zuchter_pending']}
logger.error(f"Status-Report: Mail-Fehler: {e}")
async def _job_quarterly_report():
"""Quartalsbericht: alle Report-Sections per Mail an ADMIN_EMAIL."""
import os, sys
from mailer import send_email, email_html
admin = os.getenv("ADMIN_EMAIL", "")
if not admin:
logger.info("Quartalsbericht: ADMIN_EMAIL nicht gesetzt, übersprungen.")
_log_job("quarterly_report", "ok", "ADMIN_EMAIL nicht gesetzt")
return
now_str = datetime.now(tz=_TZ).strftime("%d.%m.%Y")
quarter = (datetime.now(tz=_TZ).month - 1) // 3 + 1
try:
# Report-Script importieren und alle Sections aufrufen
sys.path.insert(0, "/app/scripts")
import importlib, generate_reports as gr
importlib.reload(gr) # sicherstellen dass aktuelle Version
sections = [
("Sicherheit", gr.report_sicherheit),
("Funktionsumfang", gr.report_funktionsumfang),
("Dateien", gr.report_dateien),
("Nutzerübersicht", gr.report_nutzer),
("Partnerliste", gr.report_partner),
("Server & Speicher", gr.report_server),
]
def md_to_html_simple(text: str) -> str:
"""Minimale Markdown→HTML-Konvertierung für E-Mail."""
import html as _h
lines_out = []
in_code = False
in_table = False
for line in text.split("\n"):
if line.startswith("```"):
if in_code:
lines_out.append("</code></pre>")
in_code = False
else:
lines_out.append('<pre style="background:#f5f0ea;padding:10px;border-radius:6px;font-size:12px;overflow-x:auto"><code>')
in_code = True
continue
if in_code:
lines_out.append(_h.escape(line))
continue
if line.startswith("#### "):
lines_out.append(f'<h4 style="margin:12px 0 4px;color:#333">{line[5:]}</h4>')
elif line.startswith("### "):
lines_out.append(f'<h3 style="margin:16px 0 6px;color:#555;font-size:14px;text-transform:uppercase;letter-spacing:.04em">{line[4:]}</h3>')
elif line.startswith("## "):
lines_out.append(f'<h2 style="margin:20px 0 8px;color:#C4843A;font-size:16px;border-bottom:1px solid #f0e8dc;padding-bottom:4px">{line[3:]}</h2>')
elif line.startswith("# "):
pass # Haupttitel kommt vom äußeren Template
elif line.startswith("---"):
pass # Trennlinie überspringen
elif line.startswith("| "):
if not in_table:
lines_out.append('<table style="width:100%;border-collapse:collapse;font-size:13px;margin:8px 0">')
in_table = True
if set(line.replace("|","").replace("-","").replace(" ","")) == set():
continue # Trenn-Zeile
cells = [c.strip() for c in line.split("|")[1:-1]]
row_html = "".join(f'<td style="padding:4px 8px;border-bottom:1px solid #f0e8dc">{_h.escape(c)}</td>' for c in cells)
lines_out.append(f"<tr>{row_html}</tr>")
continue
elif line.startswith("- ") or line.startswith("* "):
if in_table:
lines_out.append("</table>")
in_table = False
lines_out.append(f'<li style="margin:2px 0;color:#444">{line[2:]}</li>')
elif line.startswith("> "):
if in_table:
lines_out.append("</table>")
in_table = False
lines_out.append(f'<blockquote style="border-left:3px solid #C4843A;margin:8px 0;padding:6px 12px;background:#fdf6ef;color:#555;font-size:13px">{line[2:]}</blockquote>')
elif line.strip() == "":
if in_table:
lines_out.append("</table>")
in_table = False
lines_out.append("")
else:
if in_table:
lines_out.append("</table>")
in_table = False
styled = line.replace("**", "<b>", 1).replace("**", "</b>", 1)
lines_out.append(f'<p style="margin:4px 0;color:#444;font-size:14px">{styled}</p>')
if in_table:
lines_out.append("</table>")
if in_code:
lines_out.append("</code></pre>")
return "\n".join(lines_out)
# Body aus allen Sections zusammensetzen
body_parts = []
plain_parts = [f"Ban Yaro Quartalsbericht Q{quarter} {now_str}\n", "=" * 50]
for title, fn in sections:
try:
md = fn()
body_parts.append(
f'<div style="margin-bottom:32px">'
f'<h1 style="font-size:18px;font-weight:800;color:#C4843A;margin:0 0 12px;'
f'border-bottom:2px solid #f0e8dc;padding-bottom:6px">{title}</h1>'
f'{md_to_html_simple(md)}'
f'</div>'
)
plain_parts.append(f"\n=== {title.upper()} ===\n{md}\n")
except Exception as e:
body_parts.append(f'<p style="color:#dc2626">Fehler in Section {title}: {e}</p>')
plain_parts.append(f"\n=== {title.upper()} ===\nFehler: {e}\n")
full_body = "\n".join(body_parts)
full_plain = "\n".join(plain_parts)
subject = f"Ban Yaro Quartalsbericht Q{quarter}/{datetime.now(tz=_TZ).year}{now_str}"
html = email_html(full_body, footer_text=f"Ban Yaro · banyaro.app · Quartalsbericht Q{quarter}")
await send_email(admin, subject, html, full_plain)
logger.info(f"Quartalsbericht Q{quarter} gesendet an {admin}.")
_log_job("quarterly_report", "ok", f"Q{quarter}{admin}")
except Exception as e:
logger.error(f"Quartalsbericht: Fehler: {e}")
_log_job("quarterly_report", "error", str(e))
def _compute_milestone(today: date, bday: date, dog_name: str):
"""
Gibt (titel, text) zurück wenn heute ein Meilenstein-Tag ist,

View file

@ -0,0 +1,725 @@
#!/usr/bin/env python3
"""
BAN YARO Quarterly Report Generator
Aufruf: python3 scripts/generate_reports.py <section>
Sections: sicherheit | funktionsumfang | dateien | nutzer | partner | server | all
"""
import os
import sys
import sqlite3
import subprocess
from datetime import datetime
from pathlib import Path
DB_PATH = os.getenv("DB_PATH", "/data/banyaro.db")
MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
APP_DIR = "/app"
NOW = datetime.now()
DATE_STR = NOW.strftime("%d.%m.%Y %H:%M")
ISO_DATE = NOW.strftime("%Y-%m-%d")
# ──────────────────────────────────────────────────────────────────────────────
# Hilfsfunktionen
# ──────────────────────────────────────────────────────────────────────────────
def db():
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
return conn
def q(sql, params=()):
try:
with db() as conn:
return conn.execute(sql, params).fetchall()
except Exception as e:
return []
def q1(sql, params=()):
rows = q(sql, params)
return rows[0] if rows else None
def val(sql, params=(), default=0):
row = q1(sql, params)
if row is None:
return default
return row[0]
def sh(cmd):
try:
r = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=10)
return r.stdout.strip()
except Exception:
return "(nicht verfügbar)"
def hr():
return "\n---\n"
def h(level, text):
return f"\n{'#' * level} {text}\n"
def table(headers, rows):
col_widths = [len(h) for h in headers]
for row in rows:
for i, cell in enumerate(row):
if i < len(col_widths):
col_widths[i] = max(col_widths[i], len(str(cell)))
sep = "| " + " | ".join("-" * w for w in col_widths) + " |"
hdr = "| " + " | ".join(str(h).ljust(col_widths[i]) for i, h in enumerate(headers)) + " |"
lines = [hdr, sep]
for row in rows:
line = "| " + " | ".join(str(row[i] if i < len(row) else "").ljust(col_widths[i]) for i in range(len(headers))) + " |"
lines.append(line)
return "\n".join(lines)
def bytes_human(b):
for unit in ("B", "KB", "MB", "GB"):
if b < 1024:
return f"{b:.1f} {unit}"
b /= 1024
return f"{b:.1f} TB"
# ──────────────────────────────────────────────────────────────────────────────
# 1 SICHERHEITSBERICHT
# ──────────────────────────────────────────────────────────────────────────────
def report_sicherheit():
# Aktive Bans aus DB
banned = val("SELECT COUNT(*) FROM users WHERE is_banned=1")
unverifiziert = val("SELECT COUNT(*) FROM users WHERE email_verified=0")
outreach_rows = q("SELECT COUNT(*) as n, from_account FROM outreach_log GROUP BY from_account")
lines = [
f"# Sicherheitsbericht — Ban Yaro",
f"\n_Erstellt: {DATE_STR}_\n",
hr(),
h(2, "Übersicht implementierter Schutzmaßnahmen"),
h(3, "1. Authentifizierung & Passwörter"),
"- **JWT** (HS256) mit 30-Tage-Ablauf, HttpOnly + Secure + SameSite=lax Cookie",
"- **Bcrypt**-Passwort-Hashing mit automatischem Salt",
"- Mindestlänge 8 Zeichen, serverseitig erzwungen",
"- Passwort-Reset: kryptographisches Token, 2 Stunden Ablauf",
"",
h(3, "2. Registrierung"),
"- **E-Mail-Verifikation** zwingend vor dem ersten Login",
"- Verifikationslink läuft nach 7 Tagen ab",
"- Rate Limit: 5 Registrierungen / Stunde / IP",
"- Username-Blocklist: >200 reservierte und unangemessene Begriffe",
"- Keine Doppelanmeldung (E-Mail und Username unique)",
"",
h(3, "3. Login-Schutz"),
"- **IP-Rate-Limit**: 10 Versuche / 5 Minuten",
"- **Email-Rate-Limit**: 5 Versuche / 5 Minuten pro E-Mail-Adresse",
"- **Account-Lockout**: 5 Fehlversuche → 15 Minuten gesperrt (in-memory)",
"- Fehlerzähler wird bei erfolgreichem Login zurückgesetzt",
"- Gleiche Fehlermeldung bei falschem Passwort UND unbekannter E-Mail (kein User-Enumeration)",
"",
h(3, "4. Forum-Schutz"),
"- E-Mail-Verifikation Pflicht zum Posten",
"- **Post-Cooldown**: 30 Sekunden zwischen beliebigen Beiträgen",
"- **Stunden-Limit Threads**: max. 5 neue Threads / Stunde / User",
"- **Stunden-Limit Antworten**: max. 20 Antworten / Stunde / User",
"- **Duplikat-Erkennung**: gleicher Text in 5 Minuten → blockiert",
"- **Content-Filter**: Spam-Keywords, URL-Sperre für Accounts < 7 Tage, Sonderzeichen-Ratio",
"- Moderatoren können Threads sperren, Beiträge löschen (Soft-Delete)",
"- Report-System: User können Beiträge melden",
"",
h(3, "5. HTTP-Security-Headers"),
"| Header | Wert |",
"|--------|------|",
"| `Strict-Transport-Security` | `max-age=31536000; includeSubDomains` |",
"| `Content-Security-Policy` | default-src 'self'; frame-ancestors 'none'; … |",
"| `X-Content-Type-Options` | `nosniff` |",
"| `Referrer-Policy` | `strict-origin-when-cross-origin` |",
"| `Permissions-Policy` | camera=(), microphone=(), geolocation=(self) |",
"",
h(3, "6. Rate Limiting (alle Endpunkte)"),
table(
["Endpunkt", "Limit", "Fenster"],
[
["/auth/register", "5 Req", "60 Min"],
["/auth/login (IP)", "10 Req", "5 Min"],
["/auth/login (Email)", "5 Req", "5 Min"],
["/auth/forgot-password", "3 Req", "60 Min"],
["/auth/resend-verification", "3 Req", "60 Min / Email"],
["/auth/reset-password", "5 Req", "60 Min"],
["KI-Features", "10 Req", "60 Min"],
["Poison-Reports", "3 Req", "60 Min"],
["Wiki-Liste", "60 Req", "60 Sek"],
["Wiki-Detail", "30 Req", "60 Sek"],
]
),
"",
h(3, "7. Honeypot-Fallen"),
"Folgende Pfade blockieren Scanner-IPs sofort für 24 Stunden:",
"",
"```",
"/api/admin/users /api/v1/users /api/users /api/.env",
"/api/config /api/setup /api/install /api/phpinfo",
"/api/debug /api/actuator /api/swagger /api/graphql",
"/api/wiki/trap",
"```",
"",
h(3, "8. Datei-Upload-Sicherheit"),
"- **Magic-Byte-Prüfung**: JPEG, PNG, GIF, WebP, MP4, WebM",
"- **Path-Traversal-Schutz**: alle Pfade bleiben innerhalb `MEDIA_DIR`",
"- **Größenbeschränkung**: 20 MB globales Limit (Middleware)",
"- Automatische Konvertierung: HEIC→JPEG, MOV/AVI→MP4",
"- Max. 5 Fotos pro Forum-Thread",
"",
h(3, "9. Admin & Moderation"),
"- Admin-Endpoints per `require_admin` Dependency geschützt",
"- Moderatoren-Rolle mit eingeschränkten Rechten",
"- User-Banning mit Sperrgrund, geprüft bei jedem Request",
"- Outreach-Mailing nur über Admin-Panel, vollständiges Log",
"",
h(2, "Aktuelle Kennzahlen"),
table(
["Metrik", "Wert"],
[
["Gesperrte Accounts", str(banned)],
["Unverifizierte Accounts", str(unverifiziert)],
["Gesendete Outreach-Mails", str(sum(r[0] for r in outreach_rows))],
]
),
"",
h(2, "Bekannte Einschränkungen"),
"- Rate-Limit-Daten und IP-Blocklist sind **in-memory** → Reset bei Container-Neustart",
"- Kein CAPTCHA (bewusst: Nutzerfreundlichkeit vs. Bot-Schutz)",
"- Keine Refresh-Token-Rotation (JWT ist 30 Tage gültig)",
"- Analytics (Besucher) extern über Umami — kein Zugriff aus dem Container",
"",
h(2, "Empfehlungen für nächste Überprüfung"),
"- [ ] Prüfen ob IP-Blocklist-Persistenz via DB sinnvoll wäre",
"- [ ] CSP weiter verschärfen (nonce-basiert statt unsafe-inline)",
"- [ ] Login-Logs in DB schreiben (für Audit-Trail)",
"- [ ] Zwei-Faktor-Authentifizierung für Admin-Accounts evaluieren",
]
return "\n".join(lines)
# ──────────────────────────────────────────────────────────────────────────────
# 2 FUNKTIONSUMFANG
# ──────────────────────────────────────────────────────────────────────────────
def report_funktionsumfang():
BEREICHE = [
("Authentifizierung", [
"Registrierung mit E-Mail-Verifikation",
"Login / Logout (JWT + HttpOnly-Cookie)",
"Passwort vergessen / zurücksetzen",
"Verifikations-Mail erneut senden",
"Referral-System (3 Stufen: 10/20/50 Refs → 20/30/50 % Rabatt)",
"Partner-Codes (Gründer-Slot, eigene Einladungen)",
]),
("Hunde-Profile", [
"Anlegen / Bearbeiten von Hunde-Profilen (Rasse, Geburtsdatum, Gewicht, …)",
"Avatar-Upload (JPEG/WebP-Konvertierung, Vorschau)",
"Öffentliches Profil mit QR-Code und Teilen-Link",
"Hunde-Ausweis (druckbares HTML-Dokument)",
"Mehrere Hunde pro Account",
]),
("Forum", [
"Thread erstellen mit Kategorien (allgemein, rasse, region, …)",
"Antworten, Likes, Foto-Anhänge (max. 5 pro Thread)",
"Moderatoren: Thread pinnen, sperren, löschen",
"Report-System: Beiträge melden",
"Push-Benachrichtigungen bei neuer Antwort",
"Öffentlich lesbar, Schreiben nur für verifizierte User",
]),
("Tagebuch", [
"Tageseinträge mit Freitext, Fotos, GPS-Koordinaten",
"EXIF-GPS-Extraktion aus Foto-Uploads",
"Kartenansicht aller Tagebuch-Pins",
"Kalenderansicht nach Datum",
"Medienansicht (Galerie aller Fotos)",
"Day-One-kompatibles Format",
]),
("Gesundheit & Training", [
"Gewichtsverlauf mit Diagramm",
"Gesundheits-Erinnerungen (Push, täglich 08:00)",
"104 Übungen (DB-basiert, KI-Trainingspläne)",
"Training-Logging mit Fortschrittsverfolgung",
"KI-Gesundheitsberichte (wöchentlich, cloud/lokal)",
]),
("Karte & POIs", [
"Leaflet-Karte mit Cluster-Markern",
"Nearby-Alerts: Giftköder, Vermisste Hunde in der Nähe",
"Overpass-API-Integration (Tierärzte, Hundewiesen, Parks, …)",
"90-Tage-Cache für Overpass-Abfragen",
"ORS-Routenvorschläge zu Hundeparks",
]),
("Wiki & Rassen", [
"Rassen-Datenbank (TheDogAPI + Wikidata-Enrichment)",
"Züchter-Verzeichnis mit Verifikation",
"Breed-Interest-Tracking ('So einen hab ich' / 'Interessiert mich')",
"KI-gestützte Rassen-Anreicherung",
"Wikipedia-basierte Beschreibungen",
]),
("Züchter-Features", [
"Züchter-Antrag mit Dokument-Upload",
"Admin-Prüfung und Freischaltung",
"Züchter-Profil (Zwingername, Rassen, VDH, Stadt)",
"Wurfverwaltung mit Elterntieren, Welpen, Fotos",
"Tierschutz-Check vor Wurf-Anlage",
"Stammbaum-Ansicht",
"Genetik-Tracking (Farbgene, Erbkrankheiten)",
"Kaufvertrags-Generator",
"Jahresbericht-Export",
]),
("Social Features", [
"Freundschaften (anfragen, annehmen, ablehnen)",
"Social-Media-Posts (Luna — KI-Social-Manager)",
"Lober: wöchentlicher KI-Lob-Push (Mo 09:00)",
"Benachrichtigungen (in-app + Push-Notifications)",
]),
("Admin & Moderation", [
"Admin-Dashboard: User-Verwaltung, Ban/Unban",
"Moderation-Queue: gemeldete Beiträge",
"Outreach-Mailing: Templates, Versand, Log",
"Statistiken: User-Wachstum, Aktivität",
"Züchter-Anträge prüfen",
"Partner-Codes verwalten",
"KI-Konfiguration (cloud/lokal, Limits)",
]),
("Infrastruktur", [
"Service Worker (Offline-Stufen 13)",
"Push-Notifications (VAPID)",
"APScheduler: 9 Hintergrund-Jobs (Gesundheit, Wetter, Events, …)",
"Brevo E-Mail-API + SMTP-Fallback",
"Analytics: Umami v2 (extern)",
"SEO: robots.txt, sitemap.xml, llms.txt",
"Landing Page + Widget",
]),
]
lines = [
"# Funktionsumfang — Ban Yaro",
f"\n_Erstellt: {DATE_STR}_\n",
hr(),
]
for bereich, features in BEREICHE:
lines.append(h(2, bereich))
for f in features:
lines.append(f"- {f}")
lines.append("")
# Anzahl Routes aus DB-Query-Kontext (statisch)
lines += [
hr(),
h(2, "Backend-Routers"),
table(
["Router", "Präfix"],
[
["auth", "/api/auth"],
["dogs", "/api/dogs"],
["diary", "/api/diary"],
["health", "/api/health"],
["forum", "/api/forum"],
["wiki", "/api/wiki"],
["map", "/api/map"],
["poison", "/api/poison"],
["lost", "/api/lost"],
["breeder", "/api/breeder"],
["litters", "/api/litters"],
["training", "/api/training"],
["outreach", "/api/outreach"],
["moderation", "/api/moderation"],
["notes", "/api/notes"],
["notifications", "/api/notifications"],
["push", "/api/push"],
["friends", "/api/friends"],
["profile", "/api/profile"],
["social", "/api/social"],
["sitting", "/api/sitting"],
["achievements", "/api/achievements"],
["stats", "/api/stats"],
["walks", "/api/walks"],
["events", "/api/events"],
["alerts", "/api/alerts"],
["ratings", "/api/ratings"],
]
),
]
return "\n".join(lines)
# ──────────────────────────────────────────────────────────────────────────────
# 3 DATEILISTE
# ──────────────────────────────────────────────────────────────────────────────
def report_dateien():
lines = [
"# Dateiliste — Ban Yaro",
f"\n_Erstellt: {DATE_STR}_\n",
hr(),
]
def scan_dir(title, path, ext):
lines.append(h(2, title))
files = sorted(Path(path).rglob(f"*.{ext}")) if Path(path).exists() else []
rows = []
total = 0
for f in files:
try:
size = f.stat().st_size
total += size
rows.append([str(f.relative_to(path)), bytes_human(size)])
except Exception:
pass
if rows:
lines.append(table(["Datei", "Größe"], rows))
lines.append(f"\n**Gesamt**: {len(rows)} Dateien, {bytes_human(total)}\n")
scan_dir("Backend — Python-Dateien", APP_DIR, "py")
scan_dir("Frontend — JavaScript", f"{APP_DIR}/static/js", "js")
scan_dir("Frontend — CSS", f"{APP_DIR}/static/css", "css")
# HTML-Templates
html_files = list(Path(f"{APP_DIR}/static").glob("*.html")) if Path(f"{APP_DIR}/static").exists() else []
if html_files:
lines.append(h(2, "Frontend — HTML"))
rows = [[f.name, bytes_human(f.stat().st_size)] for f in sorted(html_files)]
lines.append(table(["Datei", "Größe"], rows))
lines.append("")
return "\n".join(lines)
# ──────────────────────────────────────────────────────────────────────────────
# 4 NUTZERÜBERSICHT
# ──────────────────────────────────────────────────────────────────────────────
def report_nutzer():
lines = [
"# Nutzerübersicht — Ban Yaro",
f"\n_Erstellt: {DATE_STR}_\n",
hr(),
]
# Nutzer nach Rolle
lines.append(h(2, "Nutzer nach Rolle"))
total_users = val("SELECT COUNT(*) FROM users")
admins = val("SELECT COUNT(*) FROM users WHERE rolle='admin'")
mods = val("SELECT COUNT(*) FROM users WHERE rolle='moderator' OR is_moderator=1")
breeders = val("SELECT COUNT(*) FROM users WHERE rolle='breeder'")
founders = val("SELECT COUNT(*) FROM users WHERE is_founder=1")
partners = val("SELECT COUNT(*) FROM users WHERE is_partner=1")
banned = val("SELECT COUNT(*) FROM users WHERE is_banned=1")
unverifiziert = val("SELECT COUNT(*) FROM users WHERE email_verified=0")
premium = val("SELECT COUNT(*) FROM users WHERE is_premium=1")
lines.append(table(
["Gruppe", "Anzahl"],
[
["Gesamt Nutzer", str(total_users)],
["Admin", str(admins)],
["Moderatoren", str(mods)],
["Züchter", str(breeders)],
["Gründer (aktiv)", str(founders)],
["Partner", str(partners)],
["Premium", str(premium)],
["Gesperrt (banned)", str(banned)],
["E-Mail unverifiziert", str(unverifiziert)],
]
))
# Registrierungen pro Monat (letzte 6 Monate)
lines.append(h(2, "Registrierungen (letzte 6 Monate)"))
reg_rows = q("""
SELECT strftime('%Y-%m', created_at) as monat, COUNT(*) as n
FROM users
WHERE created_at >= date('now', '-6 months')
GROUP BY monat ORDER BY monat
""")
if reg_rows:
lines.append(table(["Monat", "Neue Nutzer"], [(r[0], r[1]) for r in reg_rows]))
else:
lines.append("_Keine Daten_")
lines.append("")
# Hunde
lines.append(h(2, "Hunde"))
dogs = val("SELECT COUNT(*) FROM dogs")
dogs_with_diary = val("SELECT COUNT(DISTINCT dog_id) FROM diary")
lines.append(table(
["Metrik", "Anzahl"],
[
["Hunde gesamt", str(dogs)],
["Hunde mit Tagebuch-Einträgen", str(dogs_with_diary)],
]
))
lines.append("")
# Forum
lines.append(h(2, "Forum"))
threads = val("SELECT COUNT(*) FROM forum_threads WHERE is_deleted=0")
posts = val("SELECT COUNT(*) FROM forum_posts WHERE is_deleted=0")
reports_open = val("SELECT COUNT(*) FROM forum_reports WHERE resolved=0", default=0)
lines.append(table(
["Metrik", "Anzahl"],
[
["Threads", str(threads)],
["Antworten", str(posts)],
["Offene Meldungen", str(reports_open)],
]
))
# Kategorie-Verteilung
kat_rows = q("""
SELECT kategorie, COUNT(*) as n
FROM forum_threads WHERE is_deleted=0
GROUP BY kategorie ORDER BY n DESC
""")
if kat_rows:
lines.append("\n**Threads nach Kategorie:**\n")
lines.append(table(["Kategorie", "Threads"], [(r[0], r[1]) for r in kat_rows]))
lines.append("")
# Tagebuch
lines.append(h(2, "Tagebuch"))
diary_total = val("SELECT COUNT(*) FROM diary")
diary_mit_foto = val("SELECT COUNT(*) FROM diary WHERE foto_url IS NOT NULL AND foto_url != ''")
diary_mit_gps = val("SELECT COUNT(*) FROM diary WHERE lat IS NOT NULL")
lines.append(table(
["Metrik", "Anzahl"],
[
["Einträge gesamt", str(diary_total)],
["Mit Foto", str(diary_mit_foto)],
["Mit GPS-Koordinaten", str(diary_mit_gps)],
]
))
lines.append("")
# Medien (Dateisystem)
lines.append(h(2, "Medien auf dem Server"))
media_root = Path(MEDIA_DIR)
if media_root.exists():
rows = []
total_size = 0
total_count = 0
for subdir in sorted(media_root.iterdir()):
if subdir.is_dir():
files = list(subdir.rglob("*"))
files = [f for f in files if f.is_file()]
size = sum(f.stat().st_size for f in files if f.is_file())
total_size += size
total_count += len(files)
rows.append([subdir.name, str(len(files)), bytes_human(size)])
rows.append(["**GESAMT**", str(total_count), bytes_human(total_size)])
lines.append(table(["Verzeichnis", "Dateien", "Größe"], rows))
else:
lines.append(f"_Media-Verzeichnis nicht gefunden: {MEDIA_DIR}_")
lines.append("")
# Outreach-Mails
lines.append(h(2, "Gesendete E-Mails"))
mail_rows = q("""
SELECT from_account, COUNT(*) as n,
MIN(sent_at) as erste, MAX(sent_at) as letzte
FROM outreach_log
GROUP BY from_account ORDER BY n DESC
""")
if mail_rows:
lines.append(table(
["Absender", "Anzahl", "Erste Mail", "Letzte Mail"],
[(r[0], r[1], r[2][:10] if r[2] else "", r[3][:10] if r[3] else "") for r in mail_rows]
))
total_mails = sum(r[1] for r in mail_rows)
lines.append(f"\n**Gesamt**: {total_mails} Mails gesendet\n")
else:
lines.append("_Noch keine Mails versendet_\n")
# Analytics-Hinweis
lines += [
h(2, "Besuche (Analytics)"),
"> **Hinweis:** Besucher-Statistiken (Besuche/Tag und Monat) werden extern "
"über **Umami** erfasst und sind nicht im Container verfügbar. "
"Bitte Umami-Dashboard direkt aufrufen.",
"",
]
return "\n".join(lines)
# ──────────────────────────────────────────────────────────────────────────────
# 5 PARTNERLISTE
# ──────────────────────────────────────────────────────────────────────────────
def report_partner():
lines = [
"# Partnerliste — Ban Yaro",
f"\n_Erstellt: {DATE_STR}_\n",
hr(),
]
# Partner-User
lines.append(h(2, "Partner-Accounts"))
partner_users = q("""
SELECT name, email, created_at, founder_number
FROM users WHERE is_partner=1
ORDER BY created_at
""")
if partner_users:
lines.append(table(
["Name", "E-Mail", "Partner seit", "Gründer-Nr."],
[(r[0], r[1], r[2][:10] if r[2] else "", str(r[3]) if r[3] else "") for r in partner_users]
))
else:
lines.append("_Keine Partner-Accounts_")
lines.append("")
# Partner-Codes
lines.append(h(2, "Partner-Codes"))
codes = q("""
SELECT code, grants_founder, max_uses, uses, created_at
FROM partner_codes ORDER BY created_at
""")
if codes:
lines.append(table(
["Code", "Gründer-Slot", "Max. Nutzungen", "Verwendet", "Erstellt"],
[(
r[0],
"Ja" if r[1] else "Nein",
str(r[2]) if r[2] else "",
str(r[3]),
r[4][:10] if r[4] else ""
) for r in codes]
))
else:
lines.append("_Keine Partner-Codes_")
lines.append("")
# Gründer
lines.append(h(2, "Gründer"))
gruender = q("""
SELECT founder_number, name, email, created_at
FROM users WHERE is_founder=1
ORDER BY founder_number
""")
if gruender:
lines.append(table(
["Nr.", "Name", "E-Mail", "Registriert"],
[(r[0], r[1], r[2], r[3][:10] if r[3] else "") for r in gruender]
))
lines.append(f"\n**{len(gruender)} von 100 Gründer-Plätzen belegt.**\n")
else:
lines.append("_Noch keine Gründer_")
lines.append("")
return "\n".join(lines)
# ──────────────────────────────────────────────────────────────────────────────
# 6 SERVER & SPEICHER
# ──────────────────────────────────────────────────────────────────────────────
def report_server():
lines = [
"# Server & Speicherbelegung — Ban Yaro",
f"\n_Erstellt: {DATE_STR}_\n",
hr(),
]
# Disk Usage
lines.append(h(2, "Festplattenbelegung"))
df_out = sh("df -h /data 2>/dev/null || df -h /")
lines.append(f"```\n{df_out}\n```\n")
# Media-Verzeichnisse
lines.append(h(2, "Media-Verzeichnisse"))
du_media = sh(f"du -sh {MEDIA_DIR}/* 2>/dev/null | sort -rh")
du_total = sh(f"du -sh {MEDIA_DIR} 2>/dev/null")
if du_media:
lines.append(f"```\n{du_media}\n\nGesamt: {du_total}\n```\n")
else:
lines.append("_Keine Media-Daten_\n")
# DB-Größe
lines.append(h(2, "Datenbank"))
db_size = sh(f"du -sh {DB_PATH} 2>/dev/null")
db_rows = {}
try:
with db() as conn:
tables = conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"
).fetchall()
for t in tables:
name = t[0]
count = conn.execute(f"SELECT COUNT(*) FROM {name}").fetchone()[0]
db_rows[name] = count
except Exception:
pass
lines.append(f"**DB-Größe:** {db_size}\n")
if db_rows:
rows_sorted = sorted(db_rows.items(), key=lambda x: x[1], reverse=True)
lines.append(table(["Tabelle", "Zeilen"], [(k, f"{v:,}") for k, v in rows_sorted]))
lines.append("")
# App-Code Größe
lines.append(h(2, "App-Code"))
du_app = sh(f"du -sh {APP_DIR} 2>/dev/null")
lines.append(f"**App-Verzeichnis ({APP_DIR}):** {du_app}\n")
# Speicher-Kapazität (Warnung wenn >80 %)
lines.append(h(2, "Kapazitäts-Warnung"))
df_pct = sh("df /data 2>/dev/null | awk 'NR==2{print $5}' | tr -d '%' || df / | awk 'NR==2{print $5}' | tr -d '%'")
try:
pct = int(df_pct.strip())
if pct >= 90:
lines.append(f"> ⚠️ **KRITISCH: {pct} % Festplatte belegt!** Sofortige Maßnahmen nötig.")
elif pct >= 80:
lines.append(f"> ⚠️ **Warnung: {pct} % Festplatte belegt.** Bald aufrüsten.")
elif pct >= 70:
lines.append(f"> {pct} % Festplatte belegt — im Blick behalten.")
else:
lines.append(f"> ✅ {pct} % Festplatte belegt — ausreichend Kapazität.")
except (ValueError, TypeError):
lines.append(f"> Belegung: {df_pct}")
lines.append("")
# Python-Pakete
lines.append(h(2, "Installierte Python-Pakete"))
pip_list = sh("pip list --format=columns 2>/dev/null | head -40")
lines.append(f"```\n{pip_list}\n```\n")
return "\n".join(lines)
# ──────────────────────────────────────────────────────────────────────────────
# Main
# ──────────────────────────────────────────────────────────────────────────────
REPORTS = {
"sicherheit": report_sicherheit,
"funktionsumfang": report_funktionsumfang,
"dateien": report_dateien,
"nutzer": report_nutzer,
"partner": report_partner,
"server": report_server,
}
if __name__ == "__main__":
section = sys.argv[1] if len(sys.argv) > 1 else "all"
if section == "all":
for name, fn in REPORTS.items():
print(f"=== REPORT:{name} ===")
print(fn())
print()
elif section in REPORTS:
print(REPORTS[section]())
else:
print(f"Unbekannte Section: {section}", file=sys.stderr)
print(f"Verfügbar: {', '.join(REPORTS.keys())}", file=sys.stderr)
sys.exit(1)

View file

@ -564,7 +564,7 @@ const App = (() => {
banner.style.display = 'flex';
document.getElementById('verify-resend-btn')?.addEventListener('click', async () => {
await API.post('/auth/resend-verification', {});
await API.post('/auth/resend-verification', { email: state.user.email });
UI.toast.success('Bestätigungs-Mail erneut gesendet.');
}, { once: true });

View file

@ -14,7 +14,7 @@ window.Page_admin = (() => {
{ id: 'nutzer', label: 'Nutzer', icon: 'users' },
{ id: 'moderation', label: 'Moderation', icon: 'shield-check' },
{ id: 'zuchter', label: 'Züchter', icon: 'certificate' },
{ id: 'forum', label: 'Forum & Meldungen', icon: 'chat-circle-dots' },
{ id: 'forum', label: 'Forum', icon: 'chat-circle-dots' },
{ id: 'social', label: 'Social Media', icon: 'camera' },
{ id: 'analytics', label: 'Analytics', icon: 'target' },
{ id: 'system', label: 'System', icon: 'gear' },

View file

@ -1238,6 +1238,49 @@ window.Page_settings = (() => {
// ----------------------------------------------------------
// NICHT EINGELOGGT — Login / Registrierung
// ----------------------------------------------------------
function _renderVerifyPending(email) {
_container.innerHTML = `
<div style="max-width:380px;margin:0 auto;padding:var(--space-6) 0;text-align:center">
<div style="margin-bottom:var(--space-5)">
<img src="/icons/icon-180.png" alt="Ban Yaro"
style="width:72px;height:72px;border-radius:var(--radius-lg);margin-bottom:var(--space-3)">
<h1 style="font-size:var(--text-2xl);font-weight:700;margin:0">E-Mail bestätigen</h1>
</div>
<div style="background:var(--c-surface-2);border-radius:var(--radius-lg);
padding:var(--space-5);margin-bottom:var(--space-4);text-align:left">
<p style="margin:0 0 var(--space-2)">
Wir haben einen Bestätigungslink an<br>
<strong>${email}</strong><br>
gesendet.
</p>
<p style="margin:0;color:var(--c-text-secondary);font-size:var(--text-sm)">
Bitte prüfe dein Postfach und klicke auf den Link, um dein Konto zu aktivieren.
Danach kannst du dich hier anmelden.
</p>
</div>
<button id="verify-resend-btn2" class="btn btn-ghost w-full"
style="margin-bottom:var(--space-3)">
Link erneut senden
</button>
<button id="verify-back-btn" class="btn btn-ghost w-full"
style="color:var(--c-text-muted);font-size:var(--text-sm)">
Anderes Konto / Anmelden
</button>
</div>
`;
document.getElementById('verify-resend-btn2')?.addEventListener('click', async function() {
this.disabled = true;
this.textContent = 'Gesendet …';
try {
await API.post('/auth/resend-verification', { email });
UI.toast.success('Bestätigungs-Mail erneut gesendet.');
} catch {
UI.toast.error('Fehler beim Senden — bitte versuche es später erneut.');
}
});
document.getElementById('verify-back-btn')?.addEventListener('click', () => _renderAuth('login'));
}
function _renderAuth(mode) {
// Passwort-Reset über Link aus E-Mail
const resetToken = sessionStorage.getItem('by_reset_token');
@ -1467,7 +1510,16 @@ window.Page_settings = (() => {
const fd = UI.formData(e.target);
await UI.asyncButton(btn, async () => {
const result = await API.auth.login(fd.email, fd.password);
let result;
try {
result = await API.auth.login(fd.email, fd.password);
} catch (err) {
if (err.message === 'EMAIL_NOT_VERIFIED') {
_renderVerifyPending(fd.email);
return;
}
throw err;
}
localStorage.setItem('by_token', result.token);
// User-Daten laden
@ -1583,22 +1635,12 @@ window.Page_settings = (() => {
const refCode = sessionStorage.getItem('by_ref_code') || '';
const finalCode = partnerCode || refCode || undefined;
const result = await API.auth.register(fd.email, fd.password, fd.name.trim(), finalCode);
localStorage.setItem('by_token', result.token);
if (refCode) sessionStorage.removeItem('by_ref_code');
_appState.user = await API.auth.me();
document.getElementById('sidebar-username').textContent = _appState.user.name;
_appState.dogs = [];
_appState.activeDog = null;
document.getElementById('header-login-btn')?.remove();
const greeting = _appState.user.is_founder_pending
? `Willkommen, ${_appState.user.name}! 🎉 Dein Gründer-Platz ist reserviert — leg jetzt dein Hunde-Profil an um ihn zu sichern!`
: _appState.user.is_founder
? `Willkommen, Gründer ${_appState.user.name}! 🎉`
: `Willkommen bei Ban Yaro, ${_appState.user.name}!`;
UI.toast.success(greeting);
App.showOnboarding();
if (result.pending_verification) {
_renderVerifyPending(fd.email);
return;
}
});
});
}