From de1677154f6018cf76f597c07f95667f761f3085 Mon Sep 17 00:00:00 2001
From: rene
Date: Fri, 1 May 2026 08:20:53 +0200
Subject: [PATCH] Security + E-Mail-HTML + Quartalsbericht +
Registrierungspflicht
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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"
---
Makefile | 28 +-
backend/content_filter.py | 63 +++
backend/mailer.py | 85 ++--
backend/main.py | 56 ++-
backend/ratelimit.py | 81 +++-
backend/routes/auth.py | 83 ++--
backend/routes/breeder.py | 67 +--
backend/routes/forum.py | 51 ++
backend/routes/litters.py | 21 +-
backend/routes/outreach.py | 30 +-
backend/scheduler.py | 138 +++++-
backend/scripts/generate_reports.py | 725 ++++++++++++++++++++++++++++
backend/static/js/app.js | 2 +-
backend/static/js/pages/admin.js | 2 +-
backend/static/js/pages/settings.js | 72 ++-
15 files changed, 1363 insertions(+), 141 deletions(-)
create mode 100644 backend/content_filter.py
create mode 100644 backend/scripts/generate_reports.py
diff --git a/Makefile b/Makefile
index 2427674..910c66d 100644
--- a/Makefile
+++ b/Makefile
@@ -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
diff --git a/backend/content_filter.py b/backend/content_filter.py
new file mode 100644
index 0000000..e094253
--- /dev/null
+++ b/backend/content_filter.py
@@ -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.")
diff --git a/backend/mailer.py b/backend/mailer.py
index e5cbdc0..344fe4f 100644
--- a/backend/mailer.py
+++ b/backend/mailer.py
@@ -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"""
+
+
+ {cta_label}
+
+
"""
+
+ footer = footer_text or "Ban Yaro · banyaro.app"
+
+ return f"""\
+
+
+
+
+
+
+
+
+
+
+
+ {body_html}{cta_block}
+
+
+
+ {footer}
+
+
+
+
+"""
+
+
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"""\
-
-
-
-
-
-
Ban Yaro 🐾
-
Hallo {name},
-
+ body = f"""
+
Hallo {name},
+
bitte bestätige deine E-Mail-Adresse, damit dein Konto vollständig eingerichtet ist.
-
-
- E-Mail bestätigen
-
-
-
- Der Link ist 48 Stunden gültig.
-
-
+
Der Link ist 48 Stunden gültig.
+
Falls du dich nicht bei Ban Yaro registriert hast, ignoriere diese E-Mail einfach.
-
-
-
-"""
+
"""
- 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)
diff --git a/backend/main.py b/backend/main.py
index e8720c9..83fa934 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -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):
diff --git a/backend/ratelimit.py b/backend/ratelimit.py
index 661eb26..7cb3a2f 100644
--- a/backend/ratelimit.py
+++ b/backend/ratelimit.py
@@ -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()
diff --git a/backend/routes/auth.py b/backend/routes/auth.py
index f810217..13d857d 100644
--- a/backend/routes/auth.py
+++ b/backend/routes/auth.py
@@ -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"""
+ Hallo {name},
+
+ willkommen bei Ban Yaro! Bitte bestätige deine E-Mail-Adresse, damit dein Konto aktiv wird.
+
+ Der Link ist 7 Tage gültig.
+
+ Falls du dich nicht bei Ban Yaro registriert hast, ignoriere diese E-Mail einfach.
+
"""
+ 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"""
+ Hallo {user['name']},
+
+ du hast eine Passwort-Zurücksetzen-Anfrage gestellt. Klicke auf den Button, um ein neues Passwort zu setzen.
+
+ Der Link ist 2 Stunden gültig.
+
+ Falls du keine Anfrage gestellt hast, ignoriere diese E-Mail einfach.
+
"""
+ 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}
diff --git a/backend/routes/breeder.py b/backend/routes/breeder.py
index bb5efc8..355a575 100644
--- a/backend/routes/breeder.py
+++ b/backend/routes/breeder.py
@@ -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"""
- Neuer Züchter-Antrag
- Von: {user['name']} ({user['email']})
- Zwingername: {zwingername}
- Rasse: {rasse_text}
- Verein: {verein}
- VDH: {'Ja' if vdh_mitglied else 'Nein'}
- Stadt: {stadt}
- Im Admin-Bereich prüfen
- """
+ admin_body = f"""
+ Neuer Züchter-Antrag eingegangen:
+
+ | Von | {user['name']} ({user['email']}) |
+ | Zwingername | {zwingername} |
+ | Rasse | {rasse_text} |
+ | Verein | {verein} |
+ | VDH | {'Ja' if vdh_mitglied else 'Nein'} |
+ | Stadt | {stadt} |
+
"""
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"""
- Willkommen als Züchter bei Banyaro!
- Hallo {user['name']},
- dein Züchter-Profil wurde erfolgreich verifiziert.
- Ab sofort hast du Zugang zu allen Züchter-Features.
- Zur App
- """
+ approve_body = f"""
+ Hallo {user['name']},
+
+ dein Züchter-Profil bei Ban Yaro wurde erfolgreich verifiziert. 🎉
+ Ab sofort hast du Zugang zu allen Züchter-Features.
+
"""
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"""
- Dein Züchter-Antrag bei Banyaro
- Hallo {user['name']},
- leider konnten wir deinen Antrag aktuell nicht bestätigen.
- Grund: {body.grund}
- Du kannst jederzeit einen neuen Antrag stellen.
- Bei Fragen: {ADMIN_EMAIL}
- """
+ import html as _h
+ reject_body = f"""
+ Hallo {user['name']},
+
+ leider konnten wir deinen Züchter-Antrag aktuell nicht bestätigen.
+
+
+ Grund: {_h.escape(body.grund)}
+
+
+ Du kannst jederzeit einen neuen Antrag stellen. Bei Fragen erreichst du uns unter
+ {ADMIN_EMAIL}.
+
"""
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:
diff --git a/backend/routes/forum.py b/backend/routes/forum.py
index 0cfe1df..fe730d5 100644
--- a/backend/routes/forum.py
+++ b/backend/routes/forum.py
@@ -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']:
diff --git a/backend/routes/litters.py b/backend/routes/litters.py
index 2bcf629..82ba96f 100644
--- a/backend/routes/litters.py
+++ b/backend/routes/litters.py
@@ -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"""
- Tierschutz-Hinweis bestätigt
- Züchter {zuechter} (Zwinger: {zwinger}) hat einen Wurf mit
- kritischen Tierschutz-Hinweisen trotzdem angelegt.
- Vater: {eltern['vater_name'] or '—'} · Mutter: {eltern['mutter_name'] or '—'}
- Wurf-ID: {litter_id}
- Im Admin-Bereich prüfen
- """
+ welfare_body = f"""
+ Kritischer Tierschutz-Hinweis bestätigt
+
+ | Züchter | {zuechter} |
+ | Zwinger | {zwinger} |
+ | Vater | {eltern['vater_name'] or '—'} |
+ | Mutter | {eltern['mutter_name'] or '—'} |
+ | Wurf-ID | #{litter_id} |
+
"""
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:
diff --git a/backend/routes/outreach.py b/backend/routes/outreach.py
index 6ec066c..85eb624 100644
--- a/backend/routes/outreach.py
+++ b/backend/routes/outreach.py
@@ -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", "
")
+ parts.append(f'{escaped}
')
+ 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)
# ------------------------------------------------------------------
diff --git a/backend/scheduler.py b/backend/scheduler.py
index c99600e..d87ef3f 100644
--- a/backend/scheduler.py
+++ b/backend/scheduler.py
@@ -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("")
+ in_code = False
+ else:
+ lines_out.append('')
+ in_code = True
+ continue
+ if in_code:
+ lines_out.append(_h.escape(line))
+ continue
+ if line.startswith("#### "):
+ lines_out.append(f'{line[5:]}
')
+ elif line.startswith("### "):
+ lines_out.append(f'{line[4:]}
')
+ elif line.startswith("## "):
+ lines_out.append(f'{line[3:]}
')
+ 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('')
+ 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'| {_h.escape(c)} | ' for c in cells)
+ lines_out.append(f"{row_html}
")
+ continue
+ elif line.startswith("- ") or line.startswith("* "):
+ if in_table:
+ lines_out.append("
")
+ in_table = False
+ lines_out.append(f'{line[2:]}')
+ elif line.startswith("> "):
+ if in_table:
+ lines_out.append("")
+ in_table = False
+ lines_out.append(f'{line[2:]}
')
+ elif line.strip() == "":
+ if in_table:
+ lines_out.append("")
+ in_table = False
+ lines_out.append("")
+ else:
+ if in_table:
+ lines_out.append("")
+ in_table = False
+ styled = line.replace("**", "", 1).replace("**", "", 1)
+ lines_out.append(f'{styled}
')
+ if in_table:
+ lines_out.append("")
+ if in_code:
+ lines_out.append("
")
+ 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''
+ f'
{title}
'
+ f'{md_to_html_simple(md)}'
+ f''
+ )
+ plain_parts.append(f"\n=== {title.upper()} ===\n{md}\n")
+ except Exception as e:
+ body_parts.append(f'Fehler in Section {title}: {e}
')
+ 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,
diff --git a/backend/scripts/generate_reports.py b/backend/scripts/generate_reports.py
new file mode 100644
index 0000000..6484c70
--- /dev/null
+++ b/backend/scripts/generate_reports.py
@@ -0,0 +1,725 @@
+#!/usr/bin/env python3
+"""
+BAN YARO — Quarterly Report Generator
+Aufruf: python3 scripts/generate_reports.py
+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 1–3)",
+ "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)
diff --git a/backend/static/js/app.js b/backend/static/js/app.js
index f1d577b..cdc5231 100644
--- a/backend/static/js/app.js
+++ b/backend/static/js/app.js
@@ -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 });
diff --git a/backend/static/js/pages/admin.js b/backend/static/js/pages/admin.js
index cd34154..1775ccd 100644
--- a/backend/static/js/pages/admin.js
+++ b/backend/static/js/pages/admin.js
@@ -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' },
diff --git a/backend/static/js/pages/settings.js b/backend/static/js/pages/settings.js
index 7c3679d..f8488b6 100644
--- a/backend/static/js/pages/settings.js
+++ b/backend/static/js/pages/settings.js
@@ -1238,6 +1238,49 @@ window.Page_settings = (() => {
// ----------------------------------------------------------
// NICHT EINGELOGGT — Login / Registrierung
// ----------------------------------------------------------
+ function _renderVerifyPending(email) {
+ _container.innerHTML = `
+
+
+

+
E-Mail bestätigen
+
+
+
+ Wir haben einen Bestätigungslink an
+ ${email}
+ gesendet.
+
+
+ Bitte prüfe dein Postfach und klicke auf den Link, um dein Konto zu aktivieren.
+ Danach kannst du dich hier anmelden.
+
+
+
+
+
+ `;
+ 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;
+ }
});
});
}