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:
parent
c1bb728153
commit
de1677154f
15 changed files with 1363 additions and 141 deletions
28
Makefile
28
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
|
||||
|
|
|
|||
63
backend/content_filter.py
Normal file
63
backend/content_filter.py
Normal 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.")
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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']:
|
||||
|
|
|
|||
|
|
@ -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 '—'} · 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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
725
backend/scripts/generate_reports.py
Normal file
725
backend/scripts/generate_reports.py
Normal 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 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)
|
||||
|
|
@ -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 });
|
||||
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue