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"""\ + + + + + + +
+ +
+
🐾 Ban Yaro
+
+ +
+ {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'' for c in cells) + lines_out.append(f"{row_html}") + continue + elif line.startswith("- ") or line.startswith("* "): + if in_table: + lines_out.append("
{_h.escape(c)}
") + 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 = ` +
    +
    + Ban Yaro +

    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; + } }); }); }