diff --git a/.claude/scheduled_tasks.lock b/.claude/scheduled_tasks.lock deleted file mode 100644 index f6fdb0d..0000000 --- a/.claude/scheduled_tasks.lock +++ /dev/null @@ -1 +0,0 @@ -{"sessionId":"39ad9ffb-6cac-40b2-8c2d-b3974db3a4b8","pid":1946,"procStart":"Sat Apr 25 14:22:02 2026","acquiredAt":1777133270339} \ No newline at end of file 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/auth.py b/backend/auth.py index 942a3f1..b2736f5 100644 --- a/backend/auth.py +++ b/backend/auth.py @@ -87,7 +87,7 @@ def get_current_user( user_id = int(payload["sub"]) with db() as conn: row = conn.execute( - "SELECT id, email, name, rolle, is_premium, is_moderator, is_banned, ban_reason, is_social_media, notes_ki_enabled, breeder_status, is_founder, is_partner, founder_number FROM users WHERE id=?", + "SELECT id, email, name, rolle, is_premium, is_moderator, is_banned, ban_reason, is_social_media, notes_ki_enabled, breeder_status, is_founder, is_partner, founder_number, email_verified FROM users WHERE id=?", (user_id,) ).fetchone() 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/database.py b/backend/database.py index e428a56..5ea9f4a 100644 --- a/backend/database.py +++ b/backend/database.py @@ -488,7 +488,8 @@ def _migrate(conn_factory): # WebCal: Kalender-Abo-Token ("users", "calendar_token", "TEXT"), # User-Profil-Felder - ("users", "email_verified", "INTEGER NOT NULL DEFAULT 0"), + ("users", "email_verified", "INTEGER NOT NULL DEFAULT 0"), + ("users", "verification_token", "TEXT"), ("users", "bio", "TEXT"), ("users", "wohnort", "TEXT"), ("users", "erfahrung", "TEXT"), @@ -560,9 +561,13 @@ def _migrate(conn_factory): ("users", "ki_zucht_beschreibung", "INTEGER NOT NULL DEFAULT 1"), ("users", "ki_zucht_jahresbericht", "INTEGER NOT NULL DEFAULT 1"), # Partner-Code + Gründer-Lizenz - ("users", "is_founder", "INTEGER NOT NULL DEFAULT 0"), - ("users", "is_partner", "INTEGER NOT NULL DEFAULT 0"), - ("users", "founder_number", "INTEGER"), + ("users", "is_founder", "INTEGER NOT NULL DEFAULT 0"), + ("users", "is_partner", "INTEGER NOT NULL DEFAULT 0"), + ("users", "founder_number", "INTEGER"), + ("users", "is_founder_pending", "INTEGER NOT NULL DEFAULT 0"), + # Passwort-Zurücksetzen + ("users", "password_reset_token", "TEXT"), + ("users", "password_reset_expires", "TEXT"), ] with conn_factory() as conn: for table, column, col_type in migrations: @@ -1508,6 +1513,54 @@ def _migrate(conn_factory): except Exception as e: logger.warning(f"Migration partner_codes: {e}") + # Outreach-Log (Admin-E-Mail-Versand) + try: + conn.executescript(""" + CREATE TABLE IF NOT EXISTS outreach_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + sent_by INTEGER REFERENCES users(id), + recipient TEXT NOT NULL, + subject TEXT NOT NULL, + body TEXT NOT NULL, + sent_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + """) + except Exception as e: + logger.warning(f"Migration outreach_log: {e}") + + # E-Mail-Vorlagen (DB-gespeichert, CRUD über Admin) + try: + conn.execute(""" + CREATE TABLE IF NOT EXISTS email_templates ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + key TEXT UNIQUE NOT NULL, + label TEXT NOT NULL, + subject TEXT NOT NULL, + body TEXT NOT NULL, + from_account TEXT NOT NULL DEFAULT 'partner', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT + ) + """) + # Startwert-Vorlage einspielen wenn Tabelle noch leer + count = conn.execute("SELECT COUNT(*) FROM email_templates").fetchone()[0] + if count == 0: + conn.execute(""" + INSERT INTO email_templates (key, label, subject, body, from_account) VALUES + ('influencer_de', + 'Influencer-Ansprache (DE)', + 'Ban Yaro — 100 Gründer-Plätze, einer davon für deine Community', + 'Hallo {name},\n\nich bin René und habe Ban Yaro gebaut — eine Hunde-App für Tagebuch, Gesundheit, Giftköder-Alarm und Community. Kostenlos, ohne App Store, direkt als PWA.\n\nIch kontaktiere gerade einige Influencer aus der deutschen Hunde-Community mit einem konkreten Angebot:\n\nWas deine Follower bekommen: Wer sich mit deinem persönlichen Code registriert, sichert sich einen der 100 Gründer-Plätze — eine nummerierte Badge ("Gründer #42") die dauerhaft im Profil sichtbar ist. Diese 100 Plätze gibt es genau einmal.\n\nWas du bekommst: Partner-Badge in der App, eigener Code, öffentliches Ranking wer die meisten Gründer bringt.\n\nKein Geld, kein verpflichtender Post — aber eine echte Exklusivität die du deiner Community geben kannst.\n\nAlle Infos: https://banyaro.app/partner\n\nWenn dich das interessiert, antworte einfach kurz — ich richte deinen Code binnen 24h ein.\n\nViele Grüße,\nRené\nbanyaro.app', + 'partner') + """) + except Exception as e: + logger.warning(f"Migration email_templates: {e}") + + # from_account-Spalte in outreach_log nachträglich hinzufügen + existing_ol = [row[1] for row in conn.execute("PRAGMA table_info(outreach_log)").fetchall()] + if 'from_account' not in existing_ol: + conn.execute("ALTER TABLE outreach_log ADD COLUMN from_account TEXT DEFAULT 'partner'") + # js_exercise_id zu training_exercises — verbindet training_exercises mit exercise_progress existing_te = [row[1] for row in conn.execute("PRAGMA table_info(training_exercises)").fetchall()] if 'js_exercise_id' not in existing_te: 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 fb55815..83fa934 100644 --- a/backend/main.py +++ b/backend/main.py @@ -64,6 +64,28 @@ app = FastAPI( redoc_url = None, ) +class SecurityHeadersMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request: Request, call_next): + response = await call_next(request) + 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) + + # Globales File-Upload-Limit (20 MB) _MAX_UPLOAD_BYTES = 20 * 1024 * 1024 @@ -163,6 +185,7 @@ from routes.zucht_hunde import router as zucht_hunde_router from routes.breeder_export import router as breeder_export_router from routes.zucht_ki import router as zucht_ki_router from routes.partner import router as partner_router +from routes.outreach import router as outreach_router app.include_router(auth_router, prefix="/api/auth", tags=["Auth"]) app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"]) @@ -195,6 +218,7 @@ app.include_router(zucht_hunde_router, prefix="/api", tags=["Zuchtkart app.include_router(breeder_export_router, prefix="/api", tags=["Export"]) app.include_router(zucht_ki_router, prefix="/api", tags=["Züchter-KI"]) app.include_router(partner_router, prefix="/api", tags=["Partner"]) +app.include_router(outreach_router, prefix="/api/outreach", tags=["Outreach"]) app.include_router(webcal_router, prefix="/api/webcal", tags=["WebCal"]) app.include_router(profile_router, prefix="/api/profile", tags=["Profil"]) app.include_router(import_router, prefix="/api/import", tags=["Import"]) @@ -1602,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 e46cda0..13d857d 100644 --- a/backend/routes/auth.py +++ b/backend/routes/auth.py @@ -3,9 +3,11 @@ import os import secrets import string +from datetime import datetime, timedelta from typing import Optional from fastapi import APIRouter, HTTPException, Request, Response, Depends +from fastapi.responses import RedirectResponse from pydantic import BaseModel, EmailStr from database import db from auth import ( @@ -13,10 +15,36 @@ 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" +COOKIE_NAME = "by_token" +_APP_URL = os.getenv("APP_URL", "https://banyaro.app") +_SMTP_READY = bool(os.getenv("SMTP_SUPPORT_USER") and os.getenv("SMTP_SUPPORT_PASS")) + + +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_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, plain, "support", html=html) + except Exception: + pass # Nicht blockieren wenn SMTP fehlschlägt class LoginRequest(BaseModel): @@ -55,6 +83,8 @@ async def register(data: RegisterRequest, response: Response, request: Request): raise HTTPException(400, "Benutzername darf keine Leerzeichen enthalten.") if is_username_blocked(name): raise HTTPException(400, "Dieser Benutzername ist nicht erlaubt.") + if len(data.password) < 8: + raise HTTPException(400, "Passwort muss mindestens 8 Zeichen lang sein.") with db() as conn: if conn.execute("SELECT 1 FROM users WHERE email=?", (data.email,)).fetchone(): @@ -64,13 +94,13 @@ async def register(data: RegisterRequest, response: Response, request: Request): ).fetchone(): raise HTTPException(400, "Dieser Name ist bereits vergeben. Bitte wähle einen anderen.") code = _gen_referral_code() + verify_token = secrets.token_urlsafe(32) try: conn.execute( - "INSERT INTO users (email, pw_hash, name, referral_code) VALUES (?,?,?,?)", - (data.email, hash_password(data.password), name, code) + "INSERT INTO users (email, pw_hash, name, referral_code, verification_token) VALUES (?,?,?,?,?)", + (data.email, hash_password(data.password), name, code, verify_token) ) except Exception: - # Fallback falls UNIQUE-Index greift (Race Condition) raise HTTPException(400, "Dieser Name ist bereits vergeben. Bitte wähle einen anderen.") user = conn.execute( "SELECT id, rolle FROM users WHERE email=?", (data.email,) @@ -97,9 +127,8 @@ async def register(data: RegisterRequest, response: Response, request: Request): "SELECT COUNT(*) FROM users WHERE is_founder=1" ).fetchone()[0] if total_founders < 100: - founder_num = total_founders + 1 - updates["is_founder"] = 1 - updates["founder_number"] = founder_num + # Pending — wird nach erstem Hunde-Profil mit Plausibilitätsprüfung aktiviert + updates["is_founder_pending"] = 1 set_clause = ", ".join(f"{k}=?" for k in updates) conn.execute( f"UPDATE users SET {set_clause} WHERE id=?", @@ -115,23 +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) - return {"token": token, "name": name} + _send_verification_email(data.email, name, verify_token) + 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) @@ -198,7 +236,7 @@ async def me(user=Depends(get_current_user)): """SELECT id, name, real_name, email, rolle, is_premium, email_verified, bio, wohnort, erfahrung, social_link, profil_sichtbarkeit, avatar_url, created_at, - is_founder, is_partner, founder_number + is_founder, is_partner, founder_number, is_founder_pending FROM users WHERE id=?""", (user["id"],) ).fetchone() @@ -207,3 +245,106 @@ async def me(user=Depends(get_current_user)): data = dict(row) data["is_premium"] = bool(data["is_premium"]) return data + + +@router.get("/verify-email/{token}") +async def verify_email(token: str): + with db() as conn: + row = conn.execute( + "SELECT id, email_verified FROM users WHERE verification_token=?", (token,) + ).fetchone() + if not row: + return RedirectResponse(f"{_APP_URL}/#settings?verified=error", status_code=302) + conn.execute( + "UPDATE users SET email_verified=1, verification_token=NULL WHERE id=?", + (row["id"],) + ) + return RedirectResponse(f"{_APP_URL}/#settings?verified=1", status_code=302) + + +class ResendVerificationRequest(BaseModel): + email: EmailStr + +@router.post("/resend-verification") +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 id, name, email_verified FROM users WHERE email=?", (data.email,) + ).fetchone() + 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, row["id"]) + ) + _send_verification_email(data.email, row["name"], token) + return {"ok": True} + + +class ForgotPasswordRequest(BaseModel): + email: EmailStr + +class ResetPasswordRequest(BaseModel): + token: str + password: str + +@router.post("/forgot-password") +async def forgot_password(data: ForgotPasswordRequest, request: Request): + rl_check(request, max_requests=3, window_seconds=3600, key="forgot_pw") + with db() as conn: + user = conn.execute( + "SELECT id, name FROM users WHERE email=?", (data.email,) + ).fetchone() + # Immer OK zurückgeben — kein User-Enumeration + if user: + token = secrets.token_urlsafe(32) + expires = (datetime.utcnow() + timedelta(hours=2)).isoformat() + with db() as conn: + conn.execute( + "UPDATE users SET password_reset_token=?, password_reset_expires=? WHERE id=?", + (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" + 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, plain, "support", html=html) + except Exception: + pass + return {"ok": True} + + +@router.post("/reset-password") +async def reset_password(data: ResetPasswordRequest, request: Request): + rl_check(request, max_requests=5, window_seconds=3600, key="reset_pw") + if len(data.password) < 8: + raise HTTPException(400, "Passwort muss mindestens 8 Zeichen lang sein.") + with db() as conn: + user = conn.execute( + "SELECT id, password_reset_expires FROM users WHERE password_reset_token=?", + (data.token,) + ).fetchone() + if not user: + raise HTTPException(400, "Ungültiger oder abgelaufener Link.") + if user["password_reset_expires"] < datetime.utcnow().isoformat(): + raise HTTPException(400, "Dieser Link ist abgelaufen. Bitte fordere einen neuen an.") + conn.execute( + "UPDATE users SET pw_hash=?, password_reset_token=NULL, password_reset_expires=NULL WHERE id=?", + (hash_password(data.password), user["id"]) + ) + 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/dogs.py b/backend/routes/dogs.py index 8e176f8..74f1c95 100644 --- a/backend/routes/dogs.py +++ b/backend/routes/dogs.py @@ -78,6 +78,41 @@ async def list_dogs(user=Depends(get_current_user)): return result +def _is_plausible_dog(name: str, rasse: str, geburtstag) -> tuple[bool, str]: + """Einfache Plausibilitätsprüfung für Hunde-Profile.""" + import re, datetime + name = (name or "").strip() + rasse = (rasse or "").strip() + + if len(name) < 2: + return False, "Der Name muss mindestens 2 Zeichen haben." + if not re.search(r'[a-zA-ZäöüÄÖÜß]', name): + return False, "Der Name muss mindestens einen Buchstaben enthalten." + if len(set(name.lower())) < 2: + return False, "Bitte einen echten Namen eingeben." + + if rasse and len(rasse) < 2: + return False, "Bitte eine gültige Rasse eingeben." + if rasse and not re.search(r'[a-zA-ZäöüÄÖÜß]', rasse): + return False, "Die Rasse muss Buchstaben enthalten." + + if geburtstag: + try: + if isinstance(geburtstag, str): + year = int(geburtstag[:4]) + else: + year = geburtstag.year + now = datetime.date.today().year + if year > now: + return False, "Das Geburtsdatum liegt in der Zukunft." + if year < now - 30: + return False, "Das Geburtsdatum ist unrealistisch." + except Exception: + pass + + return True, "" + + @router.post("") async def create_dog(data: DogCreate, user=Depends(get_current_user)): with db() as conn: @@ -93,6 +128,28 @@ async def create_dog(data: DogCreate, user=Depends(get_current_user)): "SELECT * FROM dogs WHERE user_id=? ORDER BY id DESC LIMIT 1", (user["id"],) ).fetchone() + + # Gründer-Aktivierung: erstes Hunde-Profil + is_founder_pending + user_row = conn.execute( + "SELECT is_founder_pending, is_founder FROM users WHERE id=?", + (user["id"],) + ).fetchone() + if user_row and user_row["is_founder_pending"] and not user_row["is_founder"]: + dog_count = conn.execute( + "SELECT COUNT(*) FROM dogs WHERE user_id=?", (user["id"],) + ).fetchone()[0] + if dog_count == 1: # genau dieser erste Hund + plausible, reason = _is_plausible_dog(data.name, data.rasse, data.geburtstag) + if plausible: + total = conn.execute( + "SELECT COUNT(*) FROM users WHERE is_founder=1" + ).fetchone()[0] + if total < 100: + conn.execute( + "UPDATE users SET is_founder=1, founder_number=?, is_founder_pending=0 WHERE id=?", + (total + 1, user["id"]) + ) + return dict(dog) diff --git a/backend/routes/forum.py b/backend/routes/forum.py index b6d204f..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,8 +166,54 @@ 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"): + raise HTTPException(403, "Bitte bestätige zuerst deine E-Mail-Adresse um im Forum zu schreiben.") if not data.titel.strip(): raise HTTPException(400, "Titel darf nicht leer sein.") if not data.text.strip(): @@ -175,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) @@ -192,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 @@ -304,6 +354,8 @@ async def patch_thread(thread_id: int, data: ThreadPatch, user=Depends(get_curre # ------------------------------------------------------------------ @router.post("/threads/{thread_id}/posts", status_code=201) async def create_post(thread_id: int, data: PostCreate, user=Depends(get_current_user)): + if not user.get("email_verified"): + raise HTTPException(403, "Bitte bestätige zuerst deine E-Mail-Adresse um im Forum zu schreiben.") if not data.text.strip(): raise HTTPException(400, "Text darf nicht leer sein.") with db() as conn: @@ -318,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 (?, ?, ?, ?)", @@ -343,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 new file mode 100644 index 0000000..85eb624 --- /dev/null +++ b/backend/routes/outreach.py @@ -0,0 +1,264 @@ +"""BAN YARO — Mailing (Admin)""" + +import imaplib +import os +import smtplib +import ssl +from email.mime.text import MIMEText +from email.mime.multipart import MIMEMultipart +from email.utils import formataddr +from datetime import datetime +from typing import List, Optional + +import logging + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel + +from auth import require_admin +from database import db + +router = APIRouter() +_log = logging.getLogger(__name__) + +_SMTP_HOST = os.getenv("SMTP_HOST", "mail.your-server.de") +_SMTP_PORT = int(os.getenv("SMTP_PORT", "587")) +_IMAP_HOST = os.getenv("IMAP_HOST", "mail.your-server.de") +_IMAP_PORT = int(os.getenv("IMAP_PORT", "993")) + +_ACCOUNTS = { + "partner": { + "user": os.getenv("SMTP_USER", ""), + "pass": os.getenv("SMTP_PASS", ""), + "from": "partner@banyaro.app", + "name": "Ban Yaro Partner", + }, + "support": { + "user": os.getenv("SMTP_SUPPORT_USER", "support@banyaro.de"), + "pass": os.getenv("SMTP_SUPPORT_PASS", ""), + "from": "support@banyaro.app", + "name": "Ban Yaro Support", + }, +} + +# Mögliche Namen für den Sent-Ordner (Hetzner/Dovecot) +_SENT_CANDIDATES = ["Sent", "Sent Messages", "Sent Items", "INBOX.Sent", "Gesendete Objekte"] + + +def _imap_save_sent(msg_bytes: bytes, account: str): + acc = _ACCOUNTS.get(account) or _ACCOUNTS["partner"] + if not acc["user"] or not acc["pass"]: + _log.warning("IMAP: Account '%s' nicht konfiguriert, überspringe.", account) + return + try: + ctx = ssl.create_default_context() + with imaplib.IMAP4_SSL(_IMAP_HOST, _IMAP_PORT, ssl_context=ctx) as imap: + imap.login(acc["user"], acc["pass"]) + _, raw_folders = imap.list() + available = [f.decode(errors="replace") for f in (raw_folders or [])] + _log.info("IMAP Ordner (%s): %s", account, available) + + # Echten Ordnernamen aus LIST-Antwort extrahieren + # Format: '(\Flags) "." INBOX.Sent' → letztes Token + folder = None + for line in available: + name = line.rsplit('"." ', 1)[-1].strip().strip('"') + for candidate in _SENT_CANDIDATES: + if candidate.lower() in name.lower(): + folder = name + break + if folder: + break + if not folder: + folder = "INBOX.Sent" + _log.info("IMAP: speichere in Ordner '%s' (%s)", folder, account) + + typ, data = imap.append( + folder, + r"\Seen", + imaplib.Time2Internaldate(datetime.now().timestamp()), + msg_bytes, + ) + _log.info("IMAP append: %s %s", typ, data) + except Exception as e: + _log.error("IMAP Sent-Speicherung fehlgeschlagen (%s): %s", account, e) + + +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 + msg["From"] = formataddr((acc["name"], acc["from"])) + 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", 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, html=html) + msg_bytes = msg.as_bytes() + ctx = ssl.create_default_context() + with smtplib.SMTP(_SMTP_HOST, _SMTP_PORT, timeout=15) as s: + s.ehlo() + s.starttls(context=ctx) + s.login(acc["user"], acc["pass"]) + s.sendmail(acc["from"], [to], msg_bytes) + _imap_save_sent(msg_bytes, account) + + +# ------------------------------------------------------------------ +# Schemas +# ------------------------------------------------------------------ + +class TemplateIn(BaseModel): + key: str + label: str + subject: str + body: str + from_account: str = "partner" + + +class TemplateUpdate(BaseModel): + label: str + subject: str + body: str + from_account: str = "partner" + + +class SendRequest(BaseModel): + to: List[str] + subject: str + body: str + from_account: str = "partner" + template_id: Optional[int] = None + + +# ------------------------------------------------------------------ +# Templates CRUD +# ------------------------------------------------------------------ + +@router.get("/templates") +def list_templates(user=Depends(require_admin)): + with db() as conn: + rows = conn.execute( + "SELECT id, key, label, subject, body, from_account FROM email_templates ORDER BY id" + ).fetchall() + return [dict(r) for r in rows] + + +@router.post("/templates", status_code=201) +def create_template(data: TemplateIn, user=Depends(require_admin)): + try: + with db() as conn: + row = conn.execute( + """INSERT INTO email_templates (key, label, subject, body, from_account, created_at) + VALUES (?, ?, ?, ?, ?, ?) RETURNING id""", + (data.key, data.label, data.subject, data.body, data.from_account, + datetime.utcnow().isoformat()) + ).fetchone() + return {"id": row["id"]} + except Exception as e: + raise HTTPException(400, f"Vorlage konnte nicht angelegt werden: {e}") + + +@router.put("/templates/{tpl_id}") +def update_template(tpl_id: int, data: TemplateUpdate, user=Depends(require_admin)): + with db() as conn: + conn.execute( + """UPDATE email_templates + SET label=?, subject=?, body=?, from_account=?, updated_at=? + WHERE id=?""", + (data.label, data.subject, data.body, data.from_account, + datetime.utcnow().isoformat(), tpl_id) + ) + return {"ok": True} + + +@router.delete("/templates/{tpl_id}") +def delete_template(tpl_id: int, user=Depends(require_admin)): + with db() as conn: + conn.execute("DELETE FROM email_templates WHERE id=?", (tpl_id,)) + return {"ok": True} + + +# ------------------------------------------------------------------ +# 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: + raise HTTPException(400, "Mindestens eine Empfänger-Adresse angeben.") + 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, html=html) + sent.append(addr) + with db() as conn: + conn.execute( + """INSERT INTO outreach_log + (sent_by, recipient, subject, body, from_account, sent_at) + VALUES (?, ?, ?, ?, ?, ?)""", + (user["id"], addr, data.subject, data.body, data.from_account, + datetime.utcnow().isoformat()) + ) + except Exception as e: + failed.append({"addr": addr, "error": str(e)}) + + return {"sent": sent, "failed": failed} + + +# ------------------------------------------------------------------ +# Support-Versand (intern, ohne Admin-Auth — für Moderatoren-Trigger) +# ------------------------------------------------------------------ + +def send_support_mail(to: str, subject: str, body: str): + """Intern aufrufbar ohne HTTP-Request — z.B. aus Moderations-Logik.""" + from mailer import email_html + html = email_html(_plain_to_html_body(body)) + _send_smtp(to, subject, body, "support", html=html) + + +# ------------------------------------------------------------------ +# Log +# ------------------------------------------------------------------ + +@router.get("/log") +def outreach_log_endpoint(user=Depends(require_admin)): + with db() as conn: + rows = conn.execute( + """SELECT ol.id, ol.recipient, ol.subject, ol.sent_at, + ol.from_account, u.name AS sent_by_name + FROM outreach_log ol + JOIN users u ON u.id = ol.sent_by + ORDER BY ol.sent_at DESC LIMIT 200""" + ).fetchall() + return [dict(r) for r in rows] 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/icons/phosphor.svg b/backend/static/icons/phosphor.svg index 9b4843c..3fcf69f 100644 --- a/backend/static/icons/phosphor.svg +++ b/backend/static/icons/phosphor.svg @@ -189,4 +189,5 @@ - \ No newline at end of file + + \ No newline at end of file diff --git a/backend/static/index.html b/backend/static/index.html index 5277ae8..21afd73 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -108,6 +108,26 @@ border-radius:999px;padding:1px 7px;font-size:11px;font-weight:700"> + + + diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 692526b..cdc5231 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,8 +3,8 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '542'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen -const APP_VERSION = '1.1.4'; // ← semantische Version, wird bei make release gesetzt +const APP_VER = '554'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VERSION = '1.2.1'; // ← semantische Version, wird bei make release gesetzt const IS_STAGING = location.hostname === 'staging.banyaro.app'; const App = (() => { @@ -76,13 +76,15 @@ const App = (() => { // AUTH GUARD — Login-Gate Texte pro Seite // ---------------------------------------------------------- const AUTH_GATE = { - diary: { icon: 'book-open', text: 'Halte besondere Momente mit deinem Hund fest — Spaziergänge, Meilensteine, Fotos.' }, - health: { icon: 'syringe', text: 'Impfungen, Tierarztbesuche, Medikamente und Allergien deines Hundes immer im Blick.' }, - 'dog-profile':{ icon: 'paw-print', text: 'Erstelle ein Profil für deinen Hund — mit Foto, Bio und NFC-Tag.' }, - friends: { icon: 'users', text: 'Finde Hundebesitzer in deiner Nähe und baue ein Netzwerk auf.' }, - chat: { icon: 'chat-circle-dots', text: 'Schreibe direkt mit anderen Hundebesitzern.' }, - walks: { icon: 'paw-print', text: 'Organisiere Gassi-Treffen und triff andere Hunde in deiner Gegend.' }, - sitting: { icon: 'house-line', text: 'Finde einen Dogsitter oder biete selbst Sitting an.' }, + diary: { icon: 'book-open', text: 'Dein persönliches Hunde-Tagebuch — Fotos, Notizen, Stimmungen. Nur für dich, privat und sicher.', preview: '/img/screenshots/screen-1.jpg' }, + health: { icon: 'syringe', text: 'Impfungen, Tierarztbesuche, Gewicht und Medikamente — alles an einem Ort, immer abrufbar.', preview: '/img/screenshots/screen-3.jpg' }, + 'dog-profile':{ icon: 'paw-print', text: 'Erstelle ein Profil für deinen Hund mit Foto, Bio, Chip-Nr. und NFC-Tag.', preview: null }, + friends: { icon: 'users', text: 'Finde Hundebesitzer in deiner Nähe und tausche dich aus.', preview: null }, + chat: { icon: 'chat-circle-dots', text: 'Schreibe direkt mit anderen Hundebesitzern.', preview: null }, + walks: { icon: 'paw-print', text: 'Organisiere Gassi-Treffen und triff andere Hunde.', preview: '/img/screenshots/screen-5.jpg' }, + sitting: { icon: 'house-line', text: 'Finde einen Dogsitter oder biete selbst Sitting an.', preview: null }, + uebungen: { icon: 'target', text: 'Über 100 Übungen mit Anleitungen — tracke deinen Trainingsfortschritt.', preview: '/img/screenshots/screen-4.jpg' }, + notes: { icon: 'note-pencil', text: 'Dein persönlicher Notizblock — für alles was du nicht vergessen willst.', preview: null }, }; // ---------------------------------------------------------- @@ -122,10 +124,9 @@ const App = (() => { async function _loadPage(pageId, params = {}) { const page = pages[pageId]; - // AUTH GUARD — geschützte Seiten für nicht-eingeloggte User + // AUTH GUARD — geschützte Seiten für nicht-eingeloggte User → Welcome if (page.requiresAuth && !state.user) { - const container = document.querySelector(`#page-${pageId} .page-body`); - if (container) _renderLoginGate(container, pageId); + navigate('welcome', false); return; } @@ -188,16 +189,34 @@ const App = (() => { container.innerHTML = `
    + min-height:60vh;padding:var(--space-6) var(--space-5);text-align:center;gap:var(--space-4)"> - -
    + ${UI.escape(title)} +
    +
    + + + Nur für Mitglieder + +
    +
    +
    ` : ` +
    -
    +
    `}
    @@ -213,14 +232,13 @@ const App = (() => {
    - - +
    @@ -455,6 +473,7 @@ const App = (() => { navigate('onboarding'); } + _showVerifyBanner(); _updateNotifBadge(); _updateChatBadge(); _checkNearbyAlerts(); @@ -529,13 +548,30 @@ const App = (() => { _updateHeaderUserBtn(false); - // Wenn aktuelle Seite geschützt ist → zu freier Seite wechseln - if (pages[state.page]?.requiresAuth) { - navigate('map', false); - } else { - // Bleib auf der Seite, zeige aber den Gate-Screen - _loadPage(state.page); + // Nicht eingeloggte User immer zur Welcome-Seite + navigate('welcome', false); + } + + function _showVerifyBanner() { + const banner = document.getElementById('verify-banner'); + if (!banner) return; + if (!state.user || state.user.email_verified) { + banner.style.display = 'none'; + return; } + const dismissed = sessionStorage.getItem('by_verify_dismissed'); + if (dismissed) return; + banner.style.display = 'flex'; + + document.getElementById('verify-resend-btn')?.addEventListener('click', async () => { + await API.post('/auth/resend-verification', { email: state.user.email }); + UI.toast.success('Bestätigungs-Mail erneut gesendet.'); + }, { once: true }); + + document.getElementById('verify-banner-close')?.addEventListener('click', () => { + banner.style.display = 'none'; + sessionStorage.setItem('by_verify_dismissed', '1'); + }, { once: true }); } function _updateHeaderUserBtn(loggedIn) { @@ -787,8 +823,29 @@ const App = (() => { hashParams[k] = isNaN(v) ? v : Number(v); }); } + + // Passwort-Reset: #reset-password?token=xxx + if (hashPage === 'reset-password' && hashParams.token) { + sessionStorage.setItem('by_reset_token', hashParams.token); + history.replaceState(null, '', '/'); + navigate('settings', false); + return; + } + + // E-Mail-Verifikation: Redirect von /api/auth/verify-email/{token} + if (hashParams.verified === '1' || hashParams.verified === 1) { + if (state.user) state.user.email_verified = 1; + document.getElementById('verify-banner')?.style?.setProperty('display', 'none'); + UI.toast.success('E-Mail-Adresse erfolgreich bestätigt!'); + history.replaceState(null, '', '/'); + } else if (hashParams.verified === 'error') { + UI.toast.error('Ungültiger oder abgelaufener Bestätigungs-Link.'); + history.replaceState(null, '', '/'); + } + const startPage = (hashPage && pages[hashPage]) ? hashPage : 'welcome'; - navigate(startPage, false, hashParams); + // Nicht eingeloggte User immer zur Welcome-Seite — auch bei direktem Link auf Forum, Map etc. + navigate(state.user ? startPage : 'welcome', false, hashParams); } async function _handleInvite(token) { diff --git a/backend/static/js/pages/admin.js b/backend/static/js/pages/admin.js index 69ed773..1775ccd 100644 --- a/backend/static/js/pages/admin.js +++ b/backend/static/js/pages/admin.js @@ -14,12 +14,13 @@ 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' }, { id: 'jobs', label: 'Jobs', icon: 'clock' }, { id: 'partner', label: 'Partner', icon: 'handshake' }, + { id: 'outreach', label: 'Outreach', icon: 'envelope-simple' }, { id: 'audit', label: 'Audit-Log', icon: 'clipboard-text' }, ]; @@ -90,6 +91,7 @@ window.Page_admin = (() => { case 'system': await _renderSystem(el); break; case 'jobs': await _renderJobs(el); break; case 'partner': await _renderPartner(el); break; + case 'outreach': await _renderOutreach(el); break; case 'audit': await _renderAudit(el); break; } } catch (e) { @@ -2016,6 +2018,256 @@ window.Page_admin = (() => { }); } + async function _renderOutreach(el) { + const [templates, log] = await Promise.all([ + API.get('/outreach/templates').catch(() => []), + API.get('/outreach/log').catch(() => []), + ]); + + const accountBadge = a => a === 'support' + ? `support@` + : `partner@`; + + el.innerHTML = ` +
    + + +
    +
    +

    Vorlagen

    + +
    + ${templates.length === 0 + ? `

    Noch keine Vorlagen.

    ` + : `
    + ${templates.map(t => ` +
    +
    +
    + ${_esc(t.label)} + ${accountBadge(t.from_account)} +
    +
    + ${_esc(t.subject)} +
    +
    +
    + + + +
    +
    `).join('')} +
    `} +
    + + +
    +

    E-Mail senden

    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    + + +
    + + +
    + + +
    + +
    + + + {name} wird nicht automatisch ersetzt — bitte manuell anpassen. + +
    +
    +
    + + +
    +

    Versand-Log

    + ${log.length === 0 + ? `

    Noch keine E-Mails gesendet.

    ` + : ` + + + + + + + + + + + ${log.map(l => ` + + + + + + + `).join('')} + +
    VonEmpfängerBetreffWerWann
    ${accountBadge(l.from_account)}${_esc(l.recipient)}${_esc(l.subject)}${_esc(l.sent_by_name || '')}${(l.sent_at||'').slice(0,16).replace('T',' ')}
    `} +
    + +
    + `; + + // Vorlage in Compose laden + function _loadTplIntoCompose(id) { + const tpl = templates.find(t => t.id === id); + if (!tpl) return; + el.querySelector('#adm-outreach-from').value = tpl.from_account || 'partner'; + el.querySelector('#adm-outreach-subject').value = tpl.subject; + el.querySelector('#adm-outreach-body').value = tpl.body; + } + + el.querySelectorAll('.adm-tpl-load').forEach(btn => { + btn.addEventListener('click', () => _loadTplIntoCompose(Number(btn.dataset.id))); + }); + + // Vorlage löschen + el.querySelectorAll('.adm-tpl-del').forEach(btn => { + btn.addEventListener('click', async () => { + if (!window.confirm('Vorlage löschen?')) return; + await API.del(`/outreach/templates/${btn.dataset.id}`); + await _renderOutreach(el); + }); + }); + + // Vorlage bearbeiten + el.querySelectorAll('.adm-tpl-edit').forEach(btn => { + btn.addEventListener('click', () => { + const tpl = templates.find(t => t.id === Number(btn.dataset.id)); + if (tpl) _openTplModal(el, tpl); + }); + }); + + // Neue Vorlage + el.querySelector('#adm-tpl-new')?.addEventListener('click', () => _openTplModal(el, null)); + + // Senden + el.querySelector('#adm-outreach-form')?.addEventListener('submit', async e => { + e.preventDefault(); + const btn = e.target.querySelector('[type="submit"]'); + const from_account = el.querySelector('#adm-outreach-from').value; + const to = (el.querySelector('#adm-outreach-to').value || '') + .split(',').map(s => s.trim()).filter(Boolean); + const subject = el.querySelector('#adm-outreach-subject').value.trim(); + const body = el.querySelector('#adm-outreach-body').value.trim(); + + if (!to.length) { UI.toast.warning('Bitte mindestens einen Empfänger eingeben.'); return; } + if (!subject) { UI.toast.warning('Betreff fehlt.'); return; } + if (!body) { UI.toast.warning('Text fehlt.'); return; } + + await UI.asyncButton(btn, async () => { + const res = await API.post('/outreach/send', { to, subject, body, from_account }); + if (res.sent?.length) UI.toast.success(`${res.sent.length} E-Mail(s) gesendet.`); + if (res.failed?.length) UI.toast.error(`${res.failed.length} Fehler: ${res.failed.map(f => f.error).join(', ')}`); + await _renderOutreach(el); + }); + }); + } + + function _openTplModal(el, tpl) { + const isNew = !tpl; + const id = `adm-tpl-modal-${Date.now()}`; + UI.modal.open({ + title: isNew ? 'Neue Vorlage' : 'Vorlage bearbeiten', + body: ` +
    +
    +
    + + +
    +
    + + +
    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    `, + footer: ` + + `, + }); + + document.getElementById(id)?.addEventListener('submit', async e => { + e.preventDefault(); + const payload = { + label: document.getElementById(`${id}-label`).value.trim(), + subject: document.getElementById(`${id}-subject`).value.trim(), + body: document.getElementById(`${id}-body`).value.trim(), + from_account: document.getElementById(`${id}-from`).value, + }; + if (!payload.label || !payload.subject || !payload.body) { + UI.toast.warning('Alle Felder ausfüllen.'); return; + } + if (isNew) { + const key = document.getElementById(`${id}-key`).value.trim(); + if (!key) { UI.toast.warning('Interner Name fehlt.'); return; } + await API.post('/outreach/templates', { ...payload, key }); + } else { + await API.put(`/outreach/templates/${tpl.id}`, payload); + } + UI.modal.close(); + await _renderOutreach(el); + }); + } + async function _renderAudit(el) { el.innerHTML = `
    diff --git a/backend/static/js/pages/map.js b/backend/static/js/pages/map.js index 9ea9e0a..ded9a7d 100644 --- a/backend/static/js/pages/map.js +++ b/backend/static/js/pages/map.js @@ -807,6 +807,7 @@ window.Page_map = (() => { // Marker setzen (Placement-Mode) // ---------------------------------------------------------- function _togglePlacementMode() { + if (!_appState?.user) { App.navigate('welcome'); return; } _placingMarker = !_placingMarker; const btn = document.getElementById('map-pin-btn'); if (_placingMarker) { diff --git a/backend/static/js/pages/settings.js b/backend/static/js/pages/settings.js index 9a6b596..f8488b6 100644 --- a/backend/static/js/pages/settings.js +++ b/backend/static/js/pages/settings.js @@ -138,7 +138,15 @@ window.Page_settings = (() => { style="display:none">
    ${_esc(u.name)}
    -
    ${_esc(u.email)}
    +
    + ${_esc(u.email)} + ${u.email_verified + ? `` + : `Nicht bestätigt`} +
    ${u.is_premium ? ` @@ -149,6 +157,12 @@ window.Page_settings = (() => { ? ` ${u.founder_number ? `Gründer #${u.founder_number}` : 'Gründer'} + ` + : u.is_founder_pending + ? ` + + Gründer-Platz reserviert ` : ''} ${u.is_partner ? ` @@ -474,6 +488,12 @@ window.Page_settings = (() => { }); // Avatar-Hover-Overlay + // E-Mail-Verifikation: Chip → erneut senden + document.getElementById('settings-verify-chip')?.addEventListener('click', async () => { + await API.post('/auth/resend-verification', {}); + UI.toast.success('Bestätigungs-Mail gesendet — bitte prüfe dein Postfach.'); + }); + const avatarBtn = document.getElementById('settings-avatar-btn'); const avatarOverlay = avatarBtn?.querySelector('.avatar-overlay'); if (avatarBtn && avatarOverlay) { @@ -1218,7 +1238,58 @@ 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'); + if (resetToken) { + sessionStorage.removeItem('by_reset_token'); + _renderResetPassword(resetToken); + return; + } + _mode = mode; _container.innerHTML = `
    @@ -1293,6 +1364,13 @@ window.Page_settings = (() => { +

    + +

    `; } @@ -1394,13 +1472,54 @@ window.Page_settings = (() => { function _bindLoginForm() { _bindPwToggle('login-pw', 'login-pw-toggle'); + + document.getElementById('forgot-pw-link')?.addEventListener('click', () => { + const id = 'forgot-pw-modal'; + UI.modal.open({ + title: 'Passwort zurücksetzen', + body: ` +
    +

    + Gib deine E-Mail-Adresse ein. Du erhältst einen Link zum Zurücksetzen deines Passworts. +

    +
    + + +
    +
    `, + footer: ` + + `, + }); + document.getElementById(id)?.addEventListener('submit', async e => { + e.preventDefault(); + const btn = document.querySelector(`[form="${id}"]`); + const email = document.getElementById('forgot-pw-email').value.trim(); + await UI.asyncButton(btn, async () => { + await API.post('/auth/forgot-password', { email }); + UI.modal.close(); + UI.toast.success('Falls ein Account existiert, haben wir dir einen Link geschickt.'); + }); + }); + }); + document.getElementById('auth-form')?.addEventListener('submit', async e => { e.preventDefault(); const btn = e.target.querySelector('[type="submit"]'); 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 @@ -1516,20 +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 - ? `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; + } }); }); } @@ -1588,6 +1699,93 @@ window.Page_settings = (() => { setTimeout(remove, 12000); } + // ---------------------------------------------------------- + // PASSWORT ZURÜCKSETZEN + // ---------------------------------------------------------- + function _renderResetPassword(token) { + _container.innerHTML = ` +
    +
    + Ban Yaro +

    Neues Passwort

    +

    + Wähle ein sicheres Passwort für deinen Account. +

    +
    + +
    +
    + +
    + + +
    + +
    +
    + 🐾 Passwort-Vorschlag + +
    +
    + + +
    +
    +
    + + +
    +
    + `; + + _bindPwToggle('reset-pw-input', 'reset-pw-toggle'); + + const phraseEl = document.getElementById('reset-gen-phrase'); + const pwInput = document.getElementById('reset-pw-input'); + const _refresh = () => { phraseEl.textContent = _genPassphrase(); }; + _refresh(); + document.getElementById('reset-gen-new')?.addEventListener('click', _refresh); + document.getElementById('reset-gen-use')?.addEventListener('click', () => { + pwInput.value = phraseEl.textContent; + pwInput.type = 'text'; + }); + + document.getElementById('reset-pw-form')?.addEventListener('submit', async e => { + e.preventDefault(); + const btn = e.target.querySelector('[type="submit"]'); + const password = document.getElementById('reset-pw-input').value; + await UI.asyncButton(btn, async () => { + const res = await API.post('/auth/reset-password', { token, password }); + if (res?.ok) { + UI.toast.success('Passwort geändert! Du kannst dich jetzt anmelden.'); + _renderAuth('login'); + } + }); + }); + } + // ---------------------------------------------------------- // HELPER // ---------------------------------------------------------- diff --git a/backend/static/sw.js b/backend/static/sw.js index d493e2a..d3afae4 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v565'; +const CACHE_VERSION = 'by-v577'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache diff --git a/reports/.gitkeep b/reports/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/reports/2026-05-01-dateien.md b/reports/2026-05-01-dateien.md new file mode 100644 index 0000000..6ceb3c8 --- /dev/null +++ b/reports/2026-05-01-dateien.md @@ -0,0 +1,180 @@ +# Dateiliste — Ban Yaro + +_Erstellt: 01.05.2026 06:07_ + + +--- + + +## Backend — Python-Dateien + +| Datei | Größe | +| ---------------------------- | -------- | +| ._auth.py | 163.0 B | +| ._database.py | 163.0 B | +| ._ki.py | 163.0 B | +| ._main.py | 163.0 B | +| auth.py | 4.5 KB | +| content_filter.py | 2.3 KB | +| database.py | 76.6 KB | +| generate_thumbs.py | 1.0 KB | +| ki.py | 15.7 KB | +| mailer.py | 5.9 KB | +| main.py | 76.9 KB | +| media_utils.py | 7.7 KB | +| migrate_media.py | 3.3 KB | +| ratelimit.py | 4.5 KB | +| routes/.___init__.py | 163.0 B | +| routes/._auth.py | 163.0 B | +| routes/._diary.py | 163.0 B | +| routes/._dogs.py | 163.0 B | +| routes/._health.py | 163.0 B | +| routes/._ki.py | 163.0 B | +| routes/._poison.py | 163.0 B | +| routes/._push.py | 163.0 B | +| routes/__init__.py | 0.0 B | +| routes/achievements.py | 10.9 KB | +| routes/admin.py | 41.0 KB | +| routes/alerts.py | 1.5 KB | +| routes/auth.py | 13.5 KB | +| routes/breeder.py | 16.2 KB | +| routes/breeder_export.py | 22.0 KB | +| routes/breeder_photos.py | 13.4 KB | +| routes/chat.py | 10.4 KB | +| routes/diary.py | 35.8 KB | +| routes/dogs.py | 22.2 KB | +| routes/events.py | 8.9 KB | +| routes/forum.py | 27.1 KB | +| routes/friends.py | 11.8 KB | +| routes/health.py | 21.1 KB | +| routes/import_data.py | 10.0 KB | +| routes/ki.py | 2.2 KB | +| routes/knigge.py | 3.9 KB | +| routes/litters.py | 25.0 KB | +| routes/lost.py | 6.3 KB | +| routes/moderation.py | 10.0 KB | +| routes/movies.py | 10.2 KB | +| routes/notes.py | 9.5 KB | +| routes/notifications.py | 4.2 KB | +| routes/osm.py | 16.8 KB | +| routes/outreach.py | 8.9 KB | +| routes/partner.py | 7.3 KB | +| routes/places.py | 6.4 KB | +| routes/poison.py | 7.0 KB | +| routes/praise.py | 1.2 KB | +| routes/profile.py | 3.7 KB | +| routes/push.py | 5.9 KB | +| routes/ratings.py | 4.8 KB | +| routes/routen.py | 22.2 KB | +| routes/services.py | 5.1 KB | +| routes/sharing.py | 5.2 KB | +| routes/sitting.py | 10.0 KB | +| routes/sitting_access.py | 2.8 KB | +| routes/social.py | 117.2 KB | +| routes/stats.py | 1.5 KB | +| routes/tieraerzte.py | 6.1 KB | +| routes/training.py | 33.8 KB | +| routes/walks.py | 20.5 KB | +| routes/weather.py | 537.0 B | +| routes/webcal.py | 14.9 KB | +| routes/widget.py | 1.8 KB | +| routes/wiki.py | 26.6 KB | +| routes/zucht_hunde.py | 31.2 KB | +| routes/zucht_ki.py | 18.8 KB | +| scheduler.py | 32.8 KB | +| scraper/__init__.py | 0.0 B | +| scraper/breed_enricher.py | 21.5 KB | +| scraper/breed_evaluator.py | 4.9 KB | +| scraper/breeds.py | 5.9 KB | +| scraper/events_vdh.py | 10.6 KB | +| scraper/fetch_wiki_images.py | 9.0 KB | +| scraper/wikidata_breeds.py | 7.8 KB | +| scraper/wikipedia_photos.py | 6.7 KB | +| scripts/generate_reports.py | 29.4 KB | +| timeutils.py | 3.3 KB | +| username_blocklist.py | 1.2 KB | +| weather.py | 5.9 KB | +| welfare_check.py | 10.0 KB | + +**Gesamt**: 85 Dateien, 1.0 MB + + +## Frontend — JavaScript + +| Datei | Größe | +| ------------------------ | -------- | +| ._api.js | 163.0 B | +| ._app.js | 163.0 B | +| ._ui.js | 163.0 B | +| api.js | 31.2 KB | +| app.js | 38.2 KB | +| leaflet.js | 143.7 KB | +| leaflet.markercluster.js | 33.3 KB | +| pages/admin.js | 119.1 KB | +| pages/breeder.js | 8.3 KB | +| pages/chat.js | 19.0 KB | +| pages/datenschutz.js | 11.2 KB | +| pages/diary.js | 92.7 KB | +| pages/dog-profile.js | 51.5 KB | +| pages/erste-hilfe.js | 31.7 KB | +| pages/events.js | 29.8 KB | +| pages/forum.js | 52.8 KB | +| pages/friends.js | 38.6 KB | +| pages/gruender.js | 7.1 KB | +| pages/health.js | 107.5 KB | +| pages/impressum.js | 3.9 KB | +| pages/knigge.js | 16.9 KB | +| pages/litters.js | 51.6 KB | +| pages/lost.js | 30.3 KB | +| pages/map.js | 70.7 KB | +| pages/moderation.js | 23.0 KB | +| pages/movies.js | 18.6 KB | +| pages/notes.js | 38.1 KB | +| pages/notifications.js | 12.0 KB | +| pages/onboarding.js | 17.2 KB | +| pages/places.js | 19.7 KB | +| pages/poison.js | 26.9 KB | +| pages/routes.js | 132.6 KB | +| pages/settings.js | 84.2 KB | +| pages/sitting.js | 33.9 KB | +| pages/social.js | 74.3 KB | +| pages/trainingsplaene.js | 40.0 KB | +| pages/uebungen.js | 98.8 KB | +| pages/walks.js | 42.4 KB | +| pages/welcome.js | 51.1 KB | +| pages/widget.js | 5.6 KB | +| pages/wiki.js | 55.9 KB | +| pages/wurfboerse.js | 9.7 KB | +| pages/zucht-profil.js | 23.6 KB | +| pages/zuchthunde.js | 67.0 KB | +| qrcode.min.js | 19.5 KB | +| ui.js | 34.8 KB | + +**Gesamt**: 46 Dateien, 1.9 MB + + +## Frontend — CSS + +| Datei | Größe | +| ------------------------- | -------- | +| ._components.css | 163.0 B | +| ._design-system.css | 163.0 B | +| ._layout.css | 163.0 B | +| MarkerCluster.Default.css | 1.3 KB | +| MarkerCluster.css | 872.0 B | +| components.css | 178.5 KB | +| design-system.css | 10.0 KB | +| layout.css | 20.7 KB | +| leaflet.css | 14.2 KB | + +**Gesamt**: 9 Dateien, 226.1 KB + + +## Frontend — HTML + +| Datei | Größe | +| ------------ | ------- | +| ._index.html | 163.0 B | +| index.html | 25.3 KB | +| landing.html | 35.2 KB | + diff --git a/reports/2026-05-01-funktionsumfang.md b/reports/2026-05-01-funktionsumfang.md new file mode 100644 index 0000000..988821f --- /dev/null +++ b/reports/2026-05-01-funktionsumfang.md @@ -0,0 +1,151 @@ +# Funktionsumfang — Ban Yaro + +_Erstellt: 01.05.2026 06:07_ + + +--- + + +## 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 + + +--- + + +## Backend-Routers + +| 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 | diff --git a/reports/2026-05-01-nutzer.md b/reports/2026-05-01-nutzer.md new file mode 100644 index 0000000..7422033 --- /dev/null +++ b/reports/2026-05-01-nutzer.md @@ -0,0 +1,91 @@ +# Nutzerübersicht — Ban Yaro + +_Erstellt: 01.05.2026 06:07_ + + +--- + + +## Nutzer nach Rolle + +| Gruppe | Anzahl | +| -------------------- | ------ | +| Gesamt Nutzer | 5 | +| Admin | 1 | +| Moderatoren | 2 | +| Züchter | 0 | +| Gründer (aktiv) | 0 | +| Partner | 1 | +| Premium | 0 | +| Gesperrt (banned) | 0 | +| E-Mail unverifiziert | 4 | + +## Registrierungen (letzte 6 Monate) + +| Monat | Neue Nutzer | +| ------- | ----------- | +| 2026-04 | 5 | + + +## Hunde + +| Metrik | Anzahl | +| ---------------------------- | ------ | +| Hunde gesamt | 4 | +| Hunde mit Tagebuch-Einträgen | 3 | + + +## Forum + +| Metrik | Anzahl | +| ---------------- | ------ | +| Threads | 10 | +| Antworten | 7 | +| Offene Meldungen | 0 | + +**Threads nach Kategorie:** + +| Kategorie | Threads | +| ----------- | ------- | +| rasse | 3 | +| spaziergang | 3 | +| allgemein | 2 | +| ausflug | 2 | + + +## Tagebuch + +| Metrik | Anzahl | +| ------------------- | ------ | +| Einträge gesamt | 117 | +| Mit Foto | 0 | +| Mit GPS-Koordinaten | 0 | + + +## Medien auf dem Server + +| Verzeichnis | Dateien | Größe | +| ----------- | ------- | -------- | +| avatars | 4 | 7.1 MB | +| breeds | 820 | 212.5 MB | +| diary | 311 | 215.6 MB | +| dogs | 10 | 39.8 MB | +| forum | 44 | 112.1 MB | +| poison | 0 | 0.0 B | +| routes | 1 | 6.6 MB | +| **GESAMT** | 1190 | 593.6 MB | + + +## Gesendete E-Mails + +| Absender | Anzahl | Erste Mail | Letzte Mail | +| -------- | ------ | ---------- | ----------- | +| partner | 9 | 2026-04-30 | 2026-04-30 | + +**Gesamt**: 9 Mails gesendet + + +## 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. + diff --git a/reports/2026-05-01-partner.md b/reports/2026-05-01-partner.md new file mode 100644 index 0000000..31129b6 --- /dev/null +++ b/reports/2026-05-01-partner.md @@ -0,0 +1,24 @@ +# Partnerliste — Ban Yaro + +_Erstellt: 01.05.2026 06:07_ + + +--- + + +## Partner-Accounts + +| Name | E-Mail | Partner seit | Gründer-Nr. | +| ---- | ---------------- | ------------ | ----------- | +| René | mail@motocamp.de | 2026-04-12 | — | + + +## Partner-Codes + +_Keine Partner-Codes_ + + +## Gründer + +_Noch keine Gründer_ + diff --git a/reports/2026-05-01-server.md b/reports/2026-05-01-server.md new file mode 100644 index 0000000..8dc3572 --- /dev/null +++ b/reports/2026-05-01-server.md @@ -0,0 +1,172 @@ +# Server & Speicherbelegung — Ban Yaro + +_Erstellt: 01.05.2026 06:07_ + + +--- + + +## Festplattenbelegung + +``` +Filesystem Size Used Avail Use% Mounted on +/dev/mapper/cachedev_0 25T 14T 11T 58% /data +``` + + +## Media-Verzeichnisse + +``` +217M /data/media/diary +215M /data/media/breeds +113M /data/media/forum +40M /data/media/dogs +7.1M /data/media/avatars +6.6M /data/media/routes +0 /data/media/poison + +Gesamt: 596M /data/media +``` + + +## Datenbank + +**DB-Größe:** 62M /data/banyaro.db + +| Tabelle | Zeilen | +| ---------------------- | ------- | +| osm_pois | 440,865 | +| osm_tiles | 7,613 | +| wiki_rassen | 1,003 | +| diary_dogs | 118 | +| diary | 117 | +| training_exercises | 110 | +| diary_media | 101 | +| pflege_tipps | 45 | +| sqlite_sequence | 42 | +| push_subscriptions | 26 | +| user_badges | 22 | +| route_walks | 19 | +| notifications | 17 | +| exercise_progress | 15 | +| routes | 13 | +| user_map_pois | 13 | +| knigge_votes | 12 | +| forum_threads | 11 | +| health | 11 | +| direct_messages | 10 | +| outreach_log | 9 | +| forum_posts | 8 | +| forum_likes | 7 | +| poison | 6 | +| events | 5 | +| ki_daily_calls | 5 | +| training_sessions | 5 | +| users | 5 | +| dogs | 4 | +| ki_health_reports | 4 | +| social_content | 4 | +| weekly_praise | 4 | +| ors_daily_total | 3 | +| walks | 3 | +| friendships | 2 | +| zucht_hunde | 2 | +| admin_audit | 1 | +| breeder_jahresberichte | 1 | +| breeder_profiles | 1 | +| conversations | 1 | +| dog_shares | 1 | +| email_templates | 1 | +| hund_des_monats_votes | 1 | +| notes | 1 | +| ratings | 1 | +| tieraerzte | 1 | +| training_ki_cache | 1 | +| wiki_breed_interest | 1 | +| wiki_foto_submissions | 1 | +| breeder_documents | 0 | +| breeder_photos | 0 | +| dog_genetic_tests | 0 | +| dog_health_tests | 0 | +| dog_titles | 0 | +| event_rsvp | 0 | +| forum_reports | 0 | +| health_media | 0 | +| litters | 0 | +| lost_dogs | 0 | +| movie_votes | 0 | +| osm_poi_edits | 0 | +| osm_reports | 0 | +| partner_codes | 0 | +| places | 0 | +| premium_orders | 0 | +| puppies | 0 | +| puppy_weights | 0 | +| route_suggest_usage | 0 | +| service_offers | 0 | +| sitters | 0 | +| sitting_requests | 0 | +| sitting_subscriptions | 0 | +| training_plan_progress | 0 | +| walk_invitations | 0 | +| walk_participant_dogs | 0 | +| walk_participants | 0 | +| wiki_berichte | 0 | +| wiki_zuchter | 0 | + + +## App-Code + +**App-Verzeichnis (/app):** 8.9M /app + + +## Kapazitäts-Warnung + +> ✅ 58 % Festplatte belegt — ausreichend Kapazität. + + +## Installierte Python-Pakete + +``` +Package Version +------------------ ------------ +aiohappyeyeballs 2.6.1 +aiohttp 3.13.5 +aiosignal 1.4.0 +annotated-types 0.7.0 +anthropic 0.49.0 +anyio 4.13.0 +APScheduler 3.10.4 +attrs 26.1.0 +bcrypt 4.3.0 +certifi 2026.4.22 +cffi 2.0.0 +charset-normalizer 3.4.7 +click 8.3.3 +cryptography 47.0.0 +defusedxml 0.7.1 +distro 1.9.0 +dnspython 2.8.0 +email-validator 2.3.0 +fastapi 0.115.0 +frozenlist 1.8.0 +h11 0.16.0 +http_ece 1.2.1 +httpcore 1.0.9 +httptools 0.7.1 +httpx 0.28.1 +idna 3.13 +jiter 0.14.0 +multidict 6.7.1 +odfpy 1.4.1 +openai 1.59.2 +pillow 11.2.1 +pillow_heif 0.22.0 +pip 25.0.1 +polyline 2.0.2 +propcache 0.4.1 +py-vapid 1.9.4 +pycparser 3.0 +pydantic 2.10.6 +``` + diff --git a/reports/2026-05-01-sicherheit.md b/reports/2026-05-01-sicherheit.md new file mode 100644 index 0000000..49c50ea --- /dev/null +++ b/reports/2026-05-01-sicherheit.md @@ -0,0 +1,128 @@ +# Sicherheitsbericht — Ban Yaro + +_Erstellt: 01.05.2026 06:07_ + + +--- + + +## Übersicht implementierter Schutzmaßnahmen + + +### 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 + + +### 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) + + +### 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) + + +### 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 + + +### 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) | + + +### 6. Rate Limiting (alle Endpunkte) + +| 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 | + + +### 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 +``` + + +### 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 + + +### 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 + + +## Aktuelle Kennzahlen + +| Metrik | Wert | +| ------------------------ | ---- | +| Gesperrte Accounts | 0 | +| Unverifizierte Accounts | 4 | +| Gesendete Outreach-Mails | 9 | + + +## 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 + + +## 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