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"""\
-
-
-
-
-
-
-
-
-
-
-
- {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'| {_h.escape(c)} | ' for c in cells)
- lines_out.append(f"{row_html}
")
- continue
- elif line.startswith("- ") or line.startswith("* "):
- if in_table:
- lines_out.append("
")
- in_table = False
- lines_out.append(f'{line[2:]}')
- elif line.startswith("> "):
- if in_table:
- lines_out.append("")
- in_table = False
- lines_out.append(f'{line[2:]}
')
- elif line.strip() == "":
- if in_table:
- lines_out.append("")
- in_table = False
- lines_out.append("")
- else:
- if in_table:
- lines_out.append("")
- in_table = False
- styled = line.replace("**", "", 1).replace("**", "", 1)
- lines_out.append(f'{styled}
')
- if in_table:
- lines_out.append("")
- if in_code:
- lines_out.append("
")
- return "\n".join(lines_out)
-
- # Body aus allen Sections zusammensetzen
- body_parts = []
- plain_parts = [f"Ban Yaro Quartalsbericht Q{quarter} {now_str}\n", "=" * 50]
-
- for title, fn in sections:
- try:
- md = fn()
- body_parts.append(
- f''
- f'
{title}
'
- f'{md_to_html_simple(md)}'
- f''
- )
- plain_parts.append(f"\n=== {title.upper()} ===\n{md}\n")
- except Exception as e:
- body_parts.append(f'Fehler in Section {title}: {e}
')
- plain_parts.append(f"\n=== {title.upper()} ===\nFehler: {e}\n")
-
- full_body = "\n".join(body_parts)
- full_plain = "\n".join(plain_parts)
- subject = f"Ban Yaro Quartalsbericht Q{quarter}/{datetime.now(tz=_TZ).year} — {now_str}"
- html = email_html(full_body, footer_text=f"Ban Yaro · banyaro.app · Quartalsbericht Q{quarter}")
-
- await send_email(admin, subject, html, full_plain)
- logger.info(f"Quartalsbericht Q{quarter} gesendet an {admin}.")
- _log_job("quarterly_report", "ok", f"Q{quarter} → {admin}")
-
- except Exception as e:
- logger.error(f"Quartalsbericht: Fehler: {e}")
- _log_job("quarterly_report", "error", str(e))
-
-
def _compute_milestone(today: date, bday: date, dog_name: str):
"""
Gibt (titel, text) zurück wenn heute ein Meilenstein-Tag ist,
diff --git a/backend/scripts/generate_reports.py b/backend/scripts/generate_reports.py
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">
-
-
-
-
Bitte bestätige deine E-Mail-Adresse — wir haben dir eine Mail geschickt.
-
-
-
-
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 ? `
-
-

-
-
-
-
- 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
-
- ${UI.icon('plus')} Neue Vorlage
-
-
- ${templates.length === 0
- ? `
Noch keine Vorlagen.
`
- : `
- ${templates.map(t => `
-
-
-
- ${_esc(t.label)}
- ${accountBadge(t.from_account)}
-
-
- ${_esc(t.subject)}
-
-
-
-
- ${UI.icon('arrow-bend-up-left')}
-
-
- ${UI.icon('pencil-simple')}
-
-
- ${UI.icon('trash')}
-
-
-
`).join('')}
-
`}
-
-
-
-
-
-
-
-
Versand-Log
- ${log.length === 0
- ? `
Noch keine E-Mails gesendet.
`
- : `
-
-
- | Von |
- Empfänger |
- Betreff |
- Wer |
- Wann |
-
-
-
- ${log.map(l => `
-
- | ${accountBadge(l.from_account)} |
- ${_esc(l.recipient)} |
- ${_esc(l.subject)} |
- ${_esc(l.sent_by_name || '')} |
- ${(l.sent_at||'').slice(0,16).replace('T',' ')} |
-
`).join('')}
-
-
`}
-
-
-
- `;
-
- // 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: `
-
Abbrechen
-
Speichern`,
- });
-
- 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 = `
-
-
-

-
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.
-
-
-
- Link erneut senden
-
-
- Anderes Konto / 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 = (() => {
Anmelden
-
-
- Passwort vergessen?
-
-
`;
}
@@ -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: `
-
`,
- footer: `
-
Abbrechen
-
Link senden`,
- });
- 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 = `
-
-
-

-
Neues Passwort
-
- Wähle ein sicheres Passwort für deinen Account.
-
-
-
-
-
- `;
-
- _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