diff --git a/.claude/scheduled_tasks.lock b/.claude/scheduled_tasks.lock new file mode 100644 index 0000000..f6fdb0d --- /dev/null +++ b/.claude/scheduled_tasks.lock @@ -0,0 +1 @@ +{"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 910c66d..2427674 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 reports + logs logs-f shell db dev clean-cache check-ssh # ---------------------------------------------------------- # SSH-Prüfung — Abhängigkeit aller DS-Befehle @@ -66,7 +66,6 @@ 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 "" # ---------------------------------------------------------- @@ -236,31 +235,6 @@ 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 b2736f5..942a3f1 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, email_verified 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 FROM users WHERE id=?", (user_id,) ).fetchone() diff --git a/backend/content_filter.py b/backend/content_filter.py deleted file mode 100644 index e094253..0000000 --- a/backend/content_filter.py +++ /dev/null @@ -1,63 +0,0 @@ -"""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 5ea9f4a..e428a56 100644 --- a/backend/database.py +++ b/backend/database.py @@ -488,8 +488,7 @@ def _migrate(conn_factory): # WebCal: Kalender-Abo-Token ("users", "calendar_token", "TEXT"), # User-Profil-Felder - ("users", "email_verified", "INTEGER NOT NULL DEFAULT 0"), - ("users", "verification_token", "TEXT"), + ("users", "email_verified", "INTEGER NOT NULL DEFAULT 0"), ("users", "bio", "TEXT"), ("users", "wohnort", "TEXT"), ("users", "erfahrung", "TEXT"), @@ -561,13 +560,9 @@ 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_pending", "INTEGER NOT NULL DEFAULT 0"), - # Passwort-Zurücksetzen - ("users", "password_reset_token", "TEXT"), - ("users", "password_reset_expires", "TEXT"), + ("users", "is_founder", "INTEGER NOT NULL DEFAULT 0"), + ("users", "is_partner", "INTEGER NOT NULL DEFAULT 0"), + ("users", "founder_number", "INTEGER"), ] with conn_factory() as conn: for table, column, col_type in migrations: @@ -1513,54 +1508,6 @@ 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 344fe4f..e5cbdc0 100644 --- a/backend/mailer.py +++ b/backend/mailer.py @@ -106,67 +106,44 @@ 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" - body = f""" -

Hallo {name},

-

+ html = f"""\ + + + + +

+

Ban Yaro 🐾

+

Hallo {name},

+

bitte bestätige deine E-Mail-Adresse, damit dein Konto vollständig eingerichtet ist.

-

Der Link ist 48 Stunden gültig.

-

+

+ + E-Mail bestätigen + +

+

+ Der Link ist 48 Stunden gültig. +

+

Falls du dich nicht bei Ban Yaro registriert hast, ignoriere diese E-Mail einfach. -

""" +

+
+ +""" - 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" + 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" + ) await send_email(to, subject, html, plain) diff --git a/backend/main.py b/backend/main.py index 83fa934..fb55815 100644 --- a/backend/main.py +++ b/backend/main.py @@ -64,28 +64,6 @@ 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 @@ -185,7 +163,6 @@ 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"]) @@ -218,7 +195,6 @@ 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"]) @@ -1626,43 +1602,6 @@ 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 7cb3a2f..661eb26 100644 --- a/backend/ratelimit.py +++ b/backend/ratelimit.py @@ -1,9 +1,9 @@ """ -BAN YARO — Rate Limiter + IP-Blocklist + Account-Lockout + Duplikat-Erkennung +BAN YARO — Rate Limiter + IP-Blocklist 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,23 +11,18 @@ from datetime import datetime, timedelta from fastapi import HTTPException, Request _buckets: dict[str, deque] = defaultdict(deque) -_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} +_blocklist: dict[str, datetime] = {} # ip → gesperrt bis _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.""" + """ + Wirft HTTP 429 wenn max_requests im Zeitfenster überschritten. + key: optionaler Präfix um verschiedene Limits zu trennen (z.B. 'register', 'login'). + """ 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: @@ -70,63 +65,3 @@ 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 13d857d..e46cda0 100644 --- a/backend/routes/auth.py +++ b/backend/routes/auth.py @@ -3,11 +3,9 @@ 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 ( @@ -15,36 +13,10 @@ from auth import ( get_current_user ) from username_blocklist import is_username_blocked -from ratelimit import check as rl_check, record_login_failure, is_account_locked, clear_login_failures +from ratelimit import check as rl_check router = APIRouter() -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 +COOKIE_NAME = "by_token" class LoginRequest(BaseModel): @@ -83,8 +55,6 @@ 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(): @@ -94,13 +64,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, verification_token) VALUES (?,?,?,?,?)", - (data.email, hash_password(data.password), name, code, verify_token) + "INSERT INTO users (email, pw_hash, name, referral_code) VALUES (?,?,?,?)", + (data.email, hash_password(data.password), name, code) ) 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,) @@ -127,8 +97,9 @@ async def register(data: RegisterRequest, response: Response, request: Request): "SELECT COUNT(*) FROM users WHERE is_founder=1" ).fetchone()[0] if total_founders < 100: - # Pending — wird nach erstem Hunde-Profil mit Plausibilitätsprüfung aktiviert - updates["is_founder_pending"] = 1 + founder_num = total_founders + 1 + updates["is_founder"] = 1 + updates["founder_number"] = founder_num set_clause = ", ".join(f"{k}=?" for k in updates) conn.execute( f"UPDATE users SET {set_clause} WHERE id=?", @@ -144,32 +115,23 @@ async def register(data: RegisterRequest, response: Response, request: Request): conn.execute("UPDATE users SET referred_by=? WHERE id=?", (referrer['id'], new_user_id)) - _send_verification_email(data.email, name, verify_token) - return {"pending_verification": True} + token = create_token(user["id"], user["rolle"]) + _set_cookie(response, token) + return {"token": token, "name": name} @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, email_verified FROM users WHERE email=?", + "SELECT id, pw_hash, name, rolle, is_premium 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) @@ -236,7 +198,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_pending + is_founder, is_partner, founder_number FROM users WHERE id=?""", (user["id"],) ).fetchone() @@ -245,106 +207,3 @@ 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 355a575..bb5efc8 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, email_html +from mailer import send_email router = APIRouter() logger = logging.getLogger(__name__) @@ -131,21 +131,21 @@ async def breeder_apply( ) # Admin benachrichtigen - 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}
""" + 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

+ """ try: await send_email( ADMIN_EMAIL, f"[Banyaro] Neuer Züchter-Antrag — {zwingername}", - email_html(admin_body, cta_url=f"{APP_URL}/#admin", cta_label="Im Admin-Bereich prüfen"), + admin_html, f"Neuer Züchter-Antrag von {user['name']} ({user['email']}): {zwingername}, {rasse_text}, {verein}", ) except Exception as e: @@ -233,17 +233,18 @@ async def admin_approve_breeder(user_id: int, admin=Depends(require_admin)): ) # Bestätigungs-Mail - 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. -

""" + 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

+ """ try: await send_email( user["email"], - "Willkommen als Züchter bei Ban Yaro!", - email_html(approve_body, cta_url=APP_URL, cta_label="Zur App"), + "Willkommen als Züchter bei Banyaro!", + html, f"Hallo {user['name']}, dein Züchter-Profil wurde verifiziert.", ) except Exception as e: @@ -273,25 +274,19 @@ async def admin_reject_breeder(user_id: int, body: RejectBody, admin=Depends(req ) # Ablehnungs-Mail - 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}. -

""" + 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}

+ """ try: await send_email( user["email"], - "Dein Züchter-Antrag bei Ban Yaro", - email_html(reject_body), + "Dein Züchter-Antrag bei Banyaro", + html, 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 74f1c95..8e176f8 100644 --- a/backend/routes/dogs.py +++ b/backend/routes/dogs.py @@ -78,41 +78,6 @@ 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: @@ -128,28 +93,6 @@ 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 fe730d5..b6d204f 100644 --- a/backend/routes/forum.py +++ b/backend/routes/forum.py @@ -7,8 +7,6 @@ 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 @@ -166,54 +164,8 @@ 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(): @@ -223,7 +175,6 @@ 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) @@ -241,7 +192,6 @@ 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 @@ -354,8 +304,6 @@ 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: @@ -370,8 +318,6 @@ 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 (?, ?, ?, ?)", @@ -397,7 +343,6 @@ 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 82ba96f..2bcf629 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, email_html + from mailer import send_email import os, logging as _log _logger = _log.getLogger(__name__) @@ -265,20 +265,19 @@ 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() - 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}
""" + 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

+ """ try: await send_email( admin_email, f"[Banyaro Tierschutz] Kritischer Hinweis bestätigt — {zwinger}", - email_html(welfare_body, cta_url=f"{app_url}/#admin", cta_label="Im Admin-Bereich prüfen"), + html, 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 deleted file mode 100644 index 85eb624..0000000 --- a/backend/routes/outreach.py +++ /dev/null @@ -1,264 +0,0 @@ -"""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 d87ef3f..c99600e 100644 --- a/backend/scheduler.py +++ b/backend/scheduler.py @@ -100,14 +100,6 @@ 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, @@ -117,7 +109,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, Quartalsbericht 1. Feb/Mai/Aug/Nov 07: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. OSM-Cache: on-demand (kein Prewarm).") def stop(): @@ -706,7 +698,6 @@ 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 = "" @@ -792,133 +783,6 @@ 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 deleted file mode 100644 index 6484c70..0000000 --- a/backend/scripts/generate_reports.py +++ /dev/null @@ -1,725 +0,0 @@ -#!/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 3fcf69f..9b4843c 100644 --- a/backend/static/icons/phosphor.svg +++ b/backend/static/icons/phosphor.svg @@ -189,5 +189,4 @@ - - \ 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 21afd73..5277ae8 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -108,26 +108,6 @@ 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 cdc5231..692526b 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 = '554'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen -const APP_VERSION = '1.2.1'; // ← semantische Version, wird bei make release gesetzt +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 IS_STAGING = location.hostname === 'staging.banyaro.app'; const App = (() => { @@ -76,15 +76,13 @@ const App = (() => { // AUTH GUARD — Login-Gate Texte pro Seite // ---------------------------------------------------------- const AUTH_GATE = { - 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 }, + 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.' }, }; // ---------------------------------------------------------- @@ -124,9 +122,10 @@ const App = (() => { async function _loadPage(pageId, params = {}) { const page = pages[pageId]; - // AUTH GUARD — geschützte Seiten für nicht-eingeloggte User → Welcome + // AUTH GUARD — geschützte Seiten für nicht-eingeloggte User if (page.requiresAuth && !state.user) { - navigate('welcome', false); + const container = document.querySelector(`#page-${pageId} .page-body`); + if (container) _renderLoginGate(container, pageId); return; } @@ -189,34 +188,16 @@ const App = (() => { container.innerHTML = `
    + min-height:60vh;padding:var(--space-8) var(--space-5);text-align:center;gap:var(--space-5)"> - - ${gate.preview ? ` -
    - ${UI.escape(title)} -
    -
    - - - Nur für Mitglieder - -
    -
    -
    ` : ` -
    -
    `} +
    @@ -232,13 +213,14 @@ const App = (() => {
    - + -
    @@ -473,7 +455,6 @@ const App = (() => { navigate('onboarding'); } - _showVerifyBanner(); _updateNotifBadge(); _updateChatBadge(); _checkNearbyAlerts(); @@ -548,30 +529,13 @@ const App = (() => { _updateHeaderUserBtn(false); - // 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; + // 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); } - 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) { @@ -823,29 +787,8 @@ 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'; - // Nicht eingeloggte User immer zur Welcome-Seite — auch bei direktem Link auf Forum, Map etc. - navigate(state.user ? startPage : 'welcome', false, hashParams); + navigate(startPage, false, hashParams); } async function _handleInvite(token) { diff --git a/backend/static/js/pages/admin.js b/backend/static/js/pages/admin.js index 1775ccd..69ed773 100644 --- a/backend/static/js/pages/admin.js +++ b/backend/static/js/pages/admin.js @@ -14,13 +14,12 @@ 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', icon: 'chat-circle-dots' }, + { id: 'forum', label: 'Forum & Meldungen', 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' }, ]; @@ -91,7 +90,6 @@ 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) { @@ -2018,256 +2016,6 @@ 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 ded9a7d..9ea9e0a 100644 --- a/backend/static/js/pages/map.js +++ b/backend/static/js/pages/map.js @@ -807,7 +807,6 @@ 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 f8488b6..9a6b596 100644 --- a/backend/static/js/pages/settings.js +++ b/backend/static/js/pages/settings.js @@ -138,15 +138,7 @@ window.Page_settings = (() => { style="display:none">
    ${_esc(u.name)}
    -
    - ${_esc(u.email)} - ${u.email_verified - ? `` - : `Nicht bestätigt`} -
    +
    ${_esc(u.email)}
    ${u.is_premium ? ` @@ -157,12 +149,6 @@ window.Page_settings = (() => { ? ` ${u.founder_number ? `Gründer #${u.founder_number}` : 'Gründer'} - ` - : u.is_founder_pending - ? ` - - Gründer-Platz reserviert ` : ''} ${u.is_partner ? ` @@ -488,12 +474,6 @@ 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) { @@ -1238,58 +1218,7 @@ 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 = `
    @@ -1364,13 +1293,6 @@ window.Page_settings = (() => { -

    - -

    `; } @@ -1472,54 +1394,13 @@ 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 () => { - 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; - } + const result = await API.auth.login(fd.email, fd.password); localStorage.setItem('by_token', result.token); // User-Daten laden @@ -1635,12 +1516,20 @@ 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'); - if (result.pending_verification) { - _renderVerifyPending(fd.email); - return; - } + _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(); }); }); } @@ -1699,93 +1588,6 @@ 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 d3afae4..d493e2a 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-v577'; +const CACHE_VERSION = 'by-v565'; 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 deleted file mode 100644 index e69de29..0000000 diff --git a/reports/2026-05-01-dateien.md b/reports/2026-05-01-dateien.md deleted file mode 100644 index 6ceb3c8..0000000 --- a/reports/2026-05-01-dateien.md +++ /dev/null @@ -1,180 +0,0 @@ -# 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 deleted file mode 100644 index 988821f..0000000 --- a/reports/2026-05-01-funktionsumfang.md +++ /dev/null @@ -1,151 +0,0 @@ -# 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 deleted file mode 100644 index 7422033..0000000 --- a/reports/2026-05-01-nutzer.md +++ /dev/null @@ -1,91 +0,0 @@ -# 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 deleted file mode 100644 index 31129b6..0000000 --- a/reports/2026-05-01-partner.md +++ /dev/null @@ -1,24 +0,0 @@ -# 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 deleted file mode 100644 index 8dc3572..0000000 --- a/reports/2026-05-01-server.md +++ /dev/null @@ -1,172 +0,0 @@ -# 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 deleted file mode 100644 index 49c50ea..0000000 --- a/reports/2026-05-01-sicherheit.md +++ /dev/null @@ -1,128 +0,0 @@ -# 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