Compare commits

..

No commits in common. "de1677154f6018cf76f597c07f95667f761f3085" and "7fd71342dadcf495b5f2b7f2755a4651dcdb2719" have entirely different histories.

30 changed files with 147 additions and 3096 deletions

View file

@ -0,0 +1 @@
{"sessionId":"39ad9ffb-6cac-40b2-8c2d-b3974db3a4b8","pid":1946,"procStart":"Sat Apr 25 14:22:02 2026","acquiredAt":1777133270339}

View file

@ -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

View file

@ -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()

View file

@ -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.")

View file

@ -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:

View file

@ -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"""
<p style="margin:24px 0 0">
<a href="{cta_url}"
style="background:#C4843A;color:#fff;padding:14px 28px;border-radius:8px;
text-decoration:none;font-weight:700;font-size:15px;display:inline-block">
{cta_label}
</a>
</p>"""
footer = footer_text or "Ban Yaro · banyaro.app"
return f"""\
<!DOCTYPE html>
<html lang="de">
<head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
</head>
<body style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#f5f0ea;margin:0;padding:0">
<div style="max-width:600px;margin:24px auto;background:#fff;border-radius:14px;overflow:hidden;box-shadow:0 2px 16px rgba(0,0,0,.08)">
<div style="background:linear-gradient(135deg,#C4843A,#e8a857);padding:24px 28px;color:#fff">
<div style="font-size:22px;font-weight:800;margin-bottom:2px">🐾 Ban Yaro</div>
</div>
<div style="padding:28px;color:#333;font-size:15px;line-height:1.6">
{body_html}{cta_block}
</div>
<div style="padding:14px 28px;background:#fdf6ef;font-size:11px;color:#aaa;text-align:center">
{footer}
</div>
</div>
</body>
</html>"""
async def send_verify_email(to: str, name: str, token: str):
url = f"{APP_URL}/api/auth/verify/{token}"
subject = "Ban Yaro — E-Mail-Adresse bestätigen"
body = f"""
<p style="margin:0 0 16px">Hallo <b>{name}</b>,</p>
<p style="margin:0 0 16px">
html = f"""\
<!DOCTYPE html>
<html lang="de">
<head><meta charset="utf-8"></head>
<body style="font-family:sans-serif;background:#f9f9f9;margin:0;padding:0">
<div style="max-width:520px;margin:32px auto;background:#fff;border-radius:12px;
padding:40px 32px;box-shadow:0 2px 8px rgba(0,0,0,.08)">
<h1 style="color:#C4843A;margin:0 0 8px;font-size:24px">Ban Yaro 🐾</h1>
<p style="color:#444;margin:0 0 24px">Hallo {name},</p>
<p style="color:#444;margin:0 0 24px">
bitte bestätige deine E-Mail-Adresse, damit dein Konto vollständig eingerichtet ist.
</p>
<p style="margin:0;font-size:13px;color:#888">Der Link ist 48 Stunden gültig.</p>
<p style="margin:12px 0 0;font-size:12px;color:#bbb">
<p style="margin:0 0 32px">
<a href="{url}"
style="background:#C4843A;color:#fff;padding:14px 28px;border-radius:8px;
text-decoration:none;font-weight:700;font-size:15px;display:inline-block">
E-Mail bestätigen
</a>
</p>
<p style="color:#888;font-size:13px;margin:0 0 8px">
Der Link ist 48 Stunden gültig.
</p>
<p style="color:#bbb;font-size:12px;margin:0">
Falls du dich nicht bei Ban Yaro registriert hast, ignoriere diese E-Mail einfach.
</p>"""
</p>
</div>
</body>
</html>"""
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)

View file

@ -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):

View file

@ -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()

View file

@ -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"""
<p style="margin:0 0 16px">Hallo <b>{name}</b>,</p>
<p style="margin:0 0 16px">
willkommen bei Ban Yaro! Bitte bestätige deine E-Mail-Adresse, damit dein Konto aktiv wird.
</p>
<p style="margin:0;font-size:13px;color:#888">Der Link ist 7 Tage gültig.</p>
<p style="margin:12px 0 0;font-size:12px;color:#bbb">
Falls du dich nicht bei Ban Yaro registriert hast, ignoriere diese E-Mail einfach.
</p>"""
html = email_html(body_html, cta_url=url, cta_label="E-Mail bestätigen")
plain = f"Hallo {name},\n\nbitte bestätige deine E-Mail:\n{url}\n\nLink ist 7 Tage gültig.\n"
try:
_send_smtp(email, subject, 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"""
<p style="margin:0 0 16px">Hallo <b>{user['name']}</b>,</p>
<p style="margin:0 0 16px">
du hast eine Passwort-Zurücksetzen-Anfrage gestellt. Klicke auf den Button, um ein neues Passwort zu setzen.
</p>
<p style="margin:0;font-size:13px;color:#888">Der Link ist 2 Stunden gültig.</p>
<p style="margin:12px 0 0;font-size:12px;color:#bbb">
Falls du keine Anfrage gestellt hast, ignoriere diese E-Mail einfach.
</p>"""
html = email_html(body_html, cta_url=url, cta_label="Passwort zurücksetzen")
plain = f"Hallo {user['name']},\n\nPasswort zurücksetzen:\n{url}\n\nLink ist 2h gültig.\n"
try:
_send_smtp(data.email, subject, 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}

View file

@ -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"""
<p style="margin:0 0 12px"><b>Neuer Züchter-Antrag eingegangen:</b></p>
<table style="font-size:14px;border-collapse:collapse;width:100%">
<tr><td style="padding:5px 12px 5px 0;color:#888;white-space:nowrap">Von</td><td style="padding:5px 0"><b>{user['name']}</b> ({user['email']})</td></tr>
<tr><td style="padding:5px 12px 5px 0;color:#888">Zwingername</td><td style="padding:5px 0">{zwingername}</td></tr>
<tr><td style="padding:5px 12px 5px 0;color:#888">Rasse</td><td style="padding:5px 0">{rasse_text}</td></tr>
<tr><td style="padding:5px 12px 5px 0;color:#888">Verein</td><td style="padding:5px 0">{verein}</td></tr>
<tr><td style="padding:5px 12px 5px 0;color:#888">VDH</td><td style="padding:5px 0">{'Ja' if vdh_mitglied else 'Nein'}</td></tr>
<tr><td style="padding:5px 12px 5px 0;color:#888">Stadt</td><td style="padding:5px 0">{stadt}</td></tr>
</table>"""
admin_html = f"""
<h2>Neuer Züchter-Antrag</h2>
<p><b>Von:</b> {user['name']} ({user['email']})</p>
<p><b>Zwingername:</b> {zwingername}</p>
<p><b>Rasse:</b> {rasse_text}</p>
<p><b>Verein:</b> {verein}</p>
<p><b>VDH:</b> {'Ja' if vdh_mitglied else 'Nein'}</p>
<p><b>Stadt:</b> {stadt}</p>
<p><a href="{APP_URL}/admin">Im Admin-Bereich prüfen</a></p>
"""
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"""
<p style="margin:0 0 16px">Hallo <b>{user['name']}</b>,</p>
<p style="margin:0 0 16px">
dein Züchter-Profil bei Ban Yaro wurde erfolgreich verifiziert. 🎉<br>
Ab sofort hast du Zugang zu allen Züchter-Features.
</p>"""
html = f"""
<h2>Willkommen als Züchter bei Banyaro!</h2>
<p>Hallo {user['name']},</p>
<p>dein Züchter-Profil wurde erfolgreich verifiziert.</p>
<p>Ab sofort hast du Zugang zu allen Züchter-Features.</p>
<p><a href="{APP_URL}">Zur App</a></p>
"""
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"""
<p style="margin:0 0 16px">Hallo <b>{user['name']}</b>,</p>
<p style="margin:0 0 16px">
leider konnten wir deinen Züchter-Antrag aktuell nicht bestätigen.
</p>
<div style="background:#fdf6ef;border-left:3px solid #C4843A;padding:12px 16px;
border-radius:0 8px 8px 0;margin:0 0 16px;font-size:14px">
<b>Grund:</b> {_h.escape(body.grund)}
</div>
<p style="margin:0;color:#666;font-size:14px">
Du kannst jederzeit einen neuen Antrag stellen. Bei Fragen erreichst du uns unter
<a href="mailto:{ADMIN_EMAIL}" style="color:#C4843A">{ADMIN_EMAIL}</a>.
</p>"""
html = f"""
<h2>Dein Züchter-Antrag bei Banyaro</h2>
<p>Hallo {user['name']},</p>
<p>leider konnten wir deinen Antrag aktuell nicht bestätigen.</p>
<p><b>Grund:</b> {body.grund}</p>
<p>Du kannst jederzeit einen neuen Antrag stellen.</p>
<p>Bei Fragen: <a href="mailto:{ADMIN_EMAIL}">{ADMIN_EMAIL}</a></p>
"""
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:

View file

@ -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)

View file

@ -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']:

View file

@ -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"""
<p style="margin:0 0 12px"><b>Kritischer Tierschutz-Hinweis bestätigt</b></p>
<table style="font-size:14px;border-collapse:collapse;width:100%">
<tr><td style="padding:5px 12px 5px 0;color:#888;white-space:nowrap">Züchter</td><td style="padding:5px 0"><b>{zuechter}</b></td></tr>
<tr><td style="padding:5px 12px 5px 0;color:#888">Zwinger</td><td style="padding:5px 0">{zwinger}</td></tr>
<tr><td style="padding:5px 12px 5px 0;color:#888">Vater</td><td style="padding:5px 0">{eltern['vater_name'] or ''}</td></tr>
<tr><td style="padding:5px 12px 5px 0;color:#888">Mutter</td><td style="padding:5px 0">{eltern['mutter_name'] or ''}</td></tr>
<tr><td style="padding:5px 12px 5px 0;color:#888">Wurf-ID</td><td style="padding:5px 0">#{litter_id}</td></tr>
</table>"""
html = f"""
<h2>Tierschutz-Hinweis bestätigt</h2>
<p>Züchter <b>{zuechter}</b> (Zwinger: {zwinger}) hat einen Wurf mit
kritischen Tierschutz-Hinweisen trotzdem angelegt.</p>
<p>Vater: {eltern['vater_name'] or ''} &nbsp;·&nbsp; Mutter: {eltern['mutter_name'] or ''}</p>
<p>Wurf-ID: {litter_id}</p>
<p><a href="{app_url}/admin">Im Admin-Bereich prüfen</a></p>
"""
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:

View file

@ -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", "<br>")
parts.append(f'<p style="margin:0 0 14px;color:#444">{escaped}</p>')
return "".join(parts)
@router.post("/send")
def send_mail(data: SendRequest, user=Depends(require_admin)):
if not data.to:
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]

View file

@ -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("</code></pre>")
in_code = False
else:
lines_out.append('<pre style="background:#f5f0ea;padding:10px;border-radius:6px;font-size:12px;overflow-x:auto"><code>')
in_code = True
continue
if in_code:
lines_out.append(_h.escape(line))
continue
if line.startswith("#### "):
lines_out.append(f'<h4 style="margin:12px 0 4px;color:#333">{line[5:]}</h4>')
elif line.startswith("### "):
lines_out.append(f'<h3 style="margin:16px 0 6px;color:#555;font-size:14px;text-transform:uppercase;letter-spacing:.04em">{line[4:]}</h3>')
elif line.startswith("## "):
lines_out.append(f'<h2 style="margin:20px 0 8px;color:#C4843A;font-size:16px;border-bottom:1px solid #f0e8dc;padding-bottom:4px">{line[3:]}</h2>')
elif line.startswith("# "):
pass # Haupttitel kommt vom äußeren Template
elif line.startswith("---"):
pass # Trennlinie überspringen
elif line.startswith("| "):
if not in_table:
lines_out.append('<table style="width:100%;border-collapse:collapse;font-size:13px;margin:8px 0">')
in_table = True
if set(line.replace("|","").replace("-","").replace(" ","")) == set():
continue # Trenn-Zeile
cells = [c.strip() for c in line.split("|")[1:-1]]
row_html = "".join(f'<td style="padding:4px 8px;border-bottom:1px solid #f0e8dc">{_h.escape(c)}</td>' for c in cells)
lines_out.append(f"<tr>{row_html}</tr>")
continue
elif line.startswith("- ") or line.startswith("* "):
if in_table:
lines_out.append("</table>")
in_table = False
lines_out.append(f'<li style="margin:2px 0;color:#444">{line[2:]}</li>')
elif line.startswith("> "):
if in_table:
lines_out.append("</table>")
in_table = False
lines_out.append(f'<blockquote style="border-left:3px solid #C4843A;margin:8px 0;padding:6px 12px;background:#fdf6ef;color:#555;font-size:13px">{line[2:]}</blockquote>')
elif line.strip() == "":
if in_table:
lines_out.append("</table>")
in_table = False
lines_out.append("")
else:
if in_table:
lines_out.append("</table>")
in_table = False
styled = line.replace("**", "<b>", 1).replace("**", "</b>", 1)
lines_out.append(f'<p style="margin:4px 0;color:#444;font-size:14px">{styled}</p>')
if in_table:
lines_out.append("</table>")
if in_code:
lines_out.append("</code></pre>")
return "\n".join(lines_out)
# Body aus allen Sections zusammensetzen
body_parts = []
plain_parts = [f"Ban Yaro Quartalsbericht Q{quarter} {now_str}\n", "=" * 50]
for title, fn in sections:
try:
md = fn()
body_parts.append(
f'<div style="margin-bottom:32px">'
f'<h1 style="font-size:18px;font-weight:800;color:#C4843A;margin:0 0 12px;'
f'border-bottom:2px solid #f0e8dc;padding-bottom:6px">{title}</h1>'
f'{md_to_html_simple(md)}'
f'</div>'
)
plain_parts.append(f"\n=== {title.upper()} ===\n{md}\n")
except Exception as e:
body_parts.append(f'<p style="color:#dc2626">Fehler in Section {title}: {e}</p>')
plain_parts.append(f"\n=== {title.upper()} ===\nFehler: {e}\n")
full_body = "\n".join(body_parts)
full_plain = "\n".join(plain_parts)
subject = f"Ban Yaro Quartalsbericht Q{quarter}/{datetime.now(tz=_TZ).year}{now_str}"
html = email_html(full_body, footer_text=f"Ban Yaro · banyaro.app · Quartalsbericht Q{quarter}")
await send_email(admin, subject, html, full_plain)
logger.info(f"Quartalsbericht Q{quarter} gesendet an {admin}.")
_log_job("quarterly_report", "ok", f"Q{quarter}{admin}")
except Exception as e:
logger.error(f"Quartalsbericht: Fehler: {e}")
_log_job("quarterly_report", "error", str(e))
def _compute_milestone(today: date, bday: date, dog_name: str):
"""
Gibt (titel, text) zurück wenn heute ein Meilenstein-Tag ist,

View file

@ -1,725 +0,0 @@
#!/usr/bin/env python3
"""
BAN YARO Quarterly Report Generator
Aufruf: python3 scripts/generate_reports.py <section>
Sections: sicherheit | funktionsumfang | dateien | nutzer | partner | server | all
"""
import os
import sys
import sqlite3
import subprocess
from datetime import datetime
from pathlib import Path
DB_PATH = os.getenv("DB_PATH", "/data/banyaro.db")
MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
APP_DIR = "/app"
NOW = datetime.now()
DATE_STR = NOW.strftime("%d.%m.%Y %H:%M")
ISO_DATE = NOW.strftime("%Y-%m-%d")
# ──────────────────────────────────────────────────────────────────────────────
# Hilfsfunktionen
# ──────────────────────────────────────────────────────────────────────────────
def db():
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
return conn
def q(sql, params=()):
try:
with db() as conn:
return conn.execute(sql, params).fetchall()
except Exception as e:
return []
def q1(sql, params=()):
rows = q(sql, params)
return rows[0] if rows else None
def val(sql, params=(), default=0):
row = q1(sql, params)
if row is None:
return default
return row[0]
def sh(cmd):
try:
r = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=10)
return r.stdout.strip()
except Exception:
return "(nicht verfügbar)"
def hr():
return "\n---\n"
def h(level, text):
return f"\n{'#' * level} {text}\n"
def table(headers, rows):
col_widths = [len(h) for h in headers]
for row in rows:
for i, cell in enumerate(row):
if i < len(col_widths):
col_widths[i] = max(col_widths[i], len(str(cell)))
sep = "| " + " | ".join("-" * w for w in col_widths) + " |"
hdr = "| " + " | ".join(str(h).ljust(col_widths[i]) for i, h in enumerate(headers)) + " |"
lines = [hdr, sep]
for row in rows:
line = "| " + " | ".join(str(row[i] if i < len(row) else "").ljust(col_widths[i]) for i in range(len(headers))) + " |"
lines.append(line)
return "\n".join(lines)
def bytes_human(b):
for unit in ("B", "KB", "MB", "GB"):
if b < 1024:
return f"{b:.1f} {unit}"
b /= 1024
return f"{b:.1f} TB"
# ──────────────────────────────────────────────────────────────────────────────
# 1 SICHERHEITSBERICHT
# ──────────────────────────────────────────────────────────────────────────────
def report_sicherheit():
# Aktive Bans aus DB
banned = val("SELECT COUNT(*) FROM users WHERE is_banned=1")
unverifiziert = val("SELECT COUNT(*) FROM users WHERE email_verified=0")
outreach_rows = q("SELECT COUNT(*) as n, from_account FROM outreach_log GROUP BY from_account")
lines = [
f"# Sicherheitsbericht — Ban Yaro",
f"\n_Erstellt: {DATE_STR}_\n",
hr(),
h(2, "Übersicht implementierter Schutzmaßnahmen"),
h(3, "1. Authentifizierung & Passwörter"),
"- **JWT** (HS256) mit 30-Tage-Ablauf, HttpOnly + Secure + SameSite=lax Cookie",
"- **Bcrypt**-Passwort-Hashing mit automatischem Salt",
"- Mindestlänge 8 Zeichen, serverseitig erzwungen",
"- Passwort-Reset: kryptographisches Token, 2 Stunden Ablauf",
"",
h(3, "2. Registrierung"),
"- **E-Mail-Verifikation** zwingend vor dem ersten Login",
"- Verifikationslink läuft nach 7 Tagen ab",
"- Rate Limit: 5 Registrierungen / Stunde / IP",
"- Username-Blocklist: >200 reservierte und unangemessene Begriffe",
"- Keine Doppelanmeldung (E-Mail und Username unique)",
"",
h(3, "3. Login-Schutz"),
"- **IP-Rate-Limit**: 10 Versuche / 5 Minuten",
"- **Email-Rate-Limit**: 5 Versuche / 5 Minuten pro E-Mail-Adresse",
"- **Account-Lockout**: 5 Fehlversuche → 15 Minuten gesperrt (in-memory)",
"- Fehlerzähler wird bei erfolgreichem Login zurückgesetzt",
"- Gleiche Fehlermeldung bei falschem Passwort UND unbekannter E-Mail (kein User-Enumeration)",
"",
h(3, "4. Forum-Schutz"),
"- E-Mail-Verifikation Pflicht zum Posten",
"- **Post-Cooldown**: 30 Sekunden zwischen beliebigen Beiträgen",
"- **Stunden-Limit Threads**: max. 5 neue Threads / Stunde / User",
"- **Stunden-Limit Antworten**: max. 20 Antworten / Stunde / User",
"- **Duplikat-Erkennung**: gleicher Text in 5 Minuten → blockiert",
"- **Content-Filter**: Spam-Keywords, URL-Sperre für Accounts < 7 Tage, Sonderzeichen-Ratio",
"- Moderatoren können Threads sperren, Beiträge löschen (Soft-Delete)",
"- Report-System: User können Beiträge melden",
"",
h(3, "5. HTTP-Security-Headers"),
"| Header | Wert |",
"|--------|------|",
"| `Strict-Transport-Security` | `max-age=31536000; includeSubDomains` |",
"| `Content-Security-Policy` | default-src 'self'; frame-ancestors 'none'; … |",
"| `X-Content-Type-Options` | `nosniff` |",
"| `Referrer-Policy` | `strict-origin-when-cross-origin` |",
"| `Permissions-Policy` | camera=(), microphone=(), geolocation=(self) |",
"",
h(3, "6. Rate Limiting (alle Endpunkte)"),
table(
["Endpunkt", "Limit", "Fenster"],
[
["/auth/register", "5 Req", "60 Min"],
["/auth/login (IP)", "10 Req", "5 Min"],
["/auth/login (Email)", "5 Req", "5 Min"],
["/auth/forgot-password", "3 Req", "60 Min"],
["/auth/resend-verification", "3 Req", "60 Min / Email"],
["/auth/reset-password", "5 Req", "60 Min"],
["KI-Features", "10 Req", "60 Min"],
["Poison-Reports", "3 Req", "60 Min"],
["Wiki-Liste", "60 Req", "60 Sek"],
["Wiki-Detail", "30 Req", "60 Sek"],
]
),
"",
h(3, "7. Honeypot-Fallen"),
"Folgende Pfade blockieren Scanner-IPs sofort für 24 Stunden:",
"",
"```",
"/api/admin/users /api/v1/users /api/users /api/.env",
"/api/config /api/setup /api/install /api/phpinfo",
"/api/debug /api/actuator /api/swagger /api/graphql",
"/api/wiki/trap",
"```",
"",
h(3, "8. Datei-Upload-Sicherheit"),
"- **Magic-Byte-Prüfung**: JPEG, PNG, GIF, WebP, MP4, WebM",
"- **Path-Traversal-Schutz**: alle Pfade bleiben innerhalb `MEDIA_DIR`",
"- **Größenbeschränkung**: 20 MB globales Limit (Middleware)",
"- Automatische Konvertierung: HEIC→JPEG, MOV/AVI→MP4",
"- Max. 5 Fotos pro Forum-Thread",
"",
h(3, "9. Admin & Moderation"),
"- Admin-Endpoints per `require_admin` Dependency geschützt",
"- Moderatoren-Rolle mit eingeschränkten Rechten",
"- User-Banning mit Sperrgrund, geprüft bei jedem Request",
"- Outreach-Mailing nur über Admin-Panel, vollständiges Log",
"",
h(2, "Aktuelle Kennzahlen"),
table(
["Metrik", "Wert"],
[
["Gesperrte Accounts", str(banned)],
["Unverifizierte Accounts", str(unverifiziert)],
["Gesendete Outreach-Mails", str(sum(r[0] for r in outreach_rows))],
]
),
"",
h(2, "Bekannte Einschränkungen"),
"- Rate-Limit-Daten und IP-Blocklist sind **in-memory** → Reset bei Container-Neustart",
"- Kein CAPTCHA (bewusst: Nutzerfreundlichkeit vs. Bot-Schutz)",
"- Keine Refresh-Token-Rotation (JWT ist 30 Tage gültig)",
"- Analytics (Besucher) extern über Umami — kein Zugriff aus dem Container",
"",
h(2, "Empfehlungen für nächste Überprüfung"),
"- [ ] Prüfen ob IP-Blocklist-Persistenz via DB sinnvoll wäre",
"- [ ] CSP weiter verschärfen (nonce-basiert statt unsafe-inline)",
"- [ ] Login-Logs in DB schreiben (für Audit-Trail)",
"- [ ] Zwei-Faktor-Authentifizierung für Admin-Accounts evaluieren",
]
return "\n".join(lines)
# ──────────────────────────────────────────────────────────────────────────────
# 2 FUNKTIONSUMFANG
# ──────────────────────────────────────────────────────────────────────────────
def report_funktionsumfang():
BEREICHE = [
("Authentifizierung", [
"Registrierung mit E-Mail-Verifikation",
"Login / Logout (JWT + HttpOnly-Cookie)",
"Passwort vergessen / zurücksetzen",
"Verifikations-Mail erneut senden",
"Referral-System (3 Stufen: 10/20/50 Refs → 20/30/50 % Rabatt)",
"Partner-Codes (Gründer-Slot, eigene Einladungen)",
]),
("Hunde-Profile", [
"Anlegen / Bearbeiten von Hunde-Profilen (Rasse, Geburtsdatum, Gewicht, …)",
"Avatar-Upload (JPEG/WebP-Konvertierung, Vorschau)",
"Öffentliches Profil mit QR-Code und Teilen-Link",
"Hunde-Ausweis (druckbares HTML-Dokument)",
"Mehrere Hunde pro Account",
]),
("Forum", [
"Thread erstellen mit Kategorien (allgemein, rasse, region, …)",
"Antworten, Likes, Foto-Anhänge (max. 5 pro Thread)",
"Moderatoren: Thread pinnen, sperren, löschen",
"Report-System: Beiträge melden",
"Push-Benachrichtigungen bei neuer Antwort",
"Öffentlich lesbar, Schreiben nur für verifizierte User",
]),
("Tagebuch", [
"Tageseinträge mit Freitext, Fotos, GPS-Koordinaten",
"EXIF-GPS-Extraktion aus Foto-Uploads",
"Kartenansicht aller Tagebuch-Pins",
"Kalenderansicht nach Datum",
"Medienansicht (Galerie aller Fotos)",
"Day-One-kompatibles Format",
]),
("Gesundheit & Training", [
"Gewichtsverlauf mit Diagramm",
"Gesundheits-Erinnerungen (Push, täglich 08:00)",
"104 Übungen (DB-basiert, KI-Trainingspläne)",
"Training-Logging mit Fortschrittsverfolgung",
"KI-Gesundheitsberichte (wöchentlich, cloud/lokal)",
]),
("Karte & POIs", [
"Leaflet-Karte mit Cluster-Markern",
"Nearby-Alerts: Giftköder, Vermisste Hunde in der Nähe",
"Overpass-API-Integration (Tierärzte, Hundewiesen, Parks, …)",
"90-Tage-Cache für Overpass-Abfragen",
"ORS-Routenvorschläge zu Hundeparks",
]),
("Wiki & Rassen", [
"Rassen-Datenbank (TheDogAPI + Wikidata-Enrichment)",
"Züchter-Verzeichnis mit Verifikation",
"Breed-Interest-Tracking ('So einen hab ich' / 'Interessiert mich')",
"KI-gestützte Rassen-Anreicherung",
"Wikipedia-basierte Beschreibungen",
]),
("Züchter-Features", [
"Züchter-Antrag mit Dokument-Upload",
"Admin-Prüfung und Freischaltung",
"Züchter-Profil (Zwingername, Rassen, VDH, Stadt)",
"Wurfverwaltung mit Elterntieren, Welpen, Fotos",
"Tierschutz-Check vor Wurf-Anlage",
"Stammbaum-Ansicht",
"Genetik-Tracking (Farbgene, Erbkrankheiten)",
"Kaufvertrags-Generator",
"Jahresbericht-Export",
]),
("Social Features", [
"Freundschaften (anfragen, annehmen, ablehnen)",
"Social-Media-Posts (Luna — KI-Social-Manager)",
"Lober: wöchentlicher KI-Lob-Push (Mo 09:00)",
"Benachrichtigungen (in-app + Push-Notifications)",
]),
("Admin & Moderation", [
"Admin-Dashboard: User-Verwaltung, Ban/Unban",
"Moderation-Queue: gemeldete Beiträge",
"Outreach-Mailing: Templates, Versand, Log",
"Statistiken: User-Wachstum, Aktivität",
"Züchter-Anträge prüfen",
"Partner-Codes verwalten",
"KI-Konfiguration (cloud/lokal, Limits)",
]),
("Infrastruktur", [
"Service Worker (Offline-Stufen 13)",
"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)

View file

@ -189,5 +189,4 @@
<symbol id="certificate" viewBox="0 0 256 256">
<path d="M232,86.53V56a16,16,0,0,0-16-16H40A16,16,0,0,0,24,56V184a16,16,0,0,0,16,16H160v24A8,8,0,0,0,172,231l24-13.74L220,231A8,8,0,0,0,232,224V161.47a51.88,51.88,0,0,0,0-74.94ZM128,144H72a8,8,0,0,1,0-16h56a8,8,0,0,1,0,16Zm0-32H72a8,8,0,0,1,0-16h56a8,8,0,0,1,0,16Zm88,98.21-16-9.16a8,8,0,0,0-7.94,0l-16,9.16V172a51.88,51.88,0,0,0,40,0ZM196,160a36,36,0,1,1,36-36A36,36,0,0,1,196,160Z"/>
</symbol>
<symbol id="envelope-simple" viewBox="0 0 256 256"><path d="M224,48H32a8,8,0,0,0-8,8V192a16,16,0,0,0,16,16H216a16,16,0,0,0,16-16V56A8,8,0,0,0,224,48Zm-8,144H40V74.19l82.59,75.71a8,8,0,0,0,10.82,0L216,74.19V192Z"/></symbol>
<symbol id="arrow-bend-up-left" viewBox="0 0 256 256"><path d="M232,200a8,8,0,0,1-16,0,88.1,88.1,0,0,0-88-88H88v40a8,8,0,0,1-13.66,5.66l-48-48a8,8,0,0,1,0-11.32l48-48A8,8,0,0,1,88,56V96h40A104.11,104.11,0,0,1,232,200Z"/></symbol></svg>
</svg>

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Before After
Before After

View file

@ -108,26 +108,6 @@
border-radius:999px;padding:1px 7px;font-size:11px;font-weight:700"></span>
</div>
<!-- E-Mail-Verifikations-Banner -->
<div id="verify-banner" aria-live="polite"
style="display:none;position:fixed;top:0;left:0;right:0;z-index:9998;
background:#d97706;color:#fff;font-size:0.8rem;font-weight:500;
padding:8px 16px;align-items:center;justify-content:center;gap:10px;
box-shadow:0 2px 8px rgba(0,0,0,.2)">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 256 256">
<path d="M224,48H32a8,8,0,0,0-8,8V192a16,16,0,0,0,16,16H216a16,16,0,0,0,16-16V56A8,8,0,0,0,224,48ZM98.71,128,40,181.81V74.19Zm11.84,10.85,12,11.05a8,8,0,0,0,10.82,0l12-11.05,58,53.15H52.57ZM157.29,128,216,74.19V181.81ZM40,61.62l88,80.15,88-80.15Z"/>
</svg>
<span>Bitte bestätige deine E-Mail-Adresse — wir haben dir eine Mail geschickt.</span>
<button id="verify-resend-btn"
style="background:rgba(255,255,255,.2);border:none;color:#fff;padding:3px 10px;
border-radius:999px;font-size:0.75rem;cursor:pointer;font-weight:600">
Erneut senden
</button>
<button id="verify-banner-close"
style="background:none;border:none;color:#fff;opacity:.7;cursor:pointer;
font-size:1rem;line-height:1;padding:0 4px" aria-label="Schließen">✕</button>
</div>
<!-- Backdrop + Sidebar direkt im body (kein Ancestor-Stacking-Context) -->
<div id="sidebar-backdrop" class="sidebar-backdrop"></div>

View file

@ -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 = `
<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;
min-height:60vh;padding:var(--space-6) var(--space-5);text-align:center;gap:var(--space-4)">
min-height:60vh;padding:var(--space-8) var(--space-5);text-align:center;gap:var(--space-5)">
<!-- Preview-Screenshot (wenn vorhanden) -->
${gate.preview ? `
<div style="position:relative;width:100%;max-width:280px;border-radius:var(--radius-lg);
overflow:hidden;box-shadow:0 8px 32px rgba(0,0,0,0.15)">
<img src="${gate.preview}" alt="${UI.escape(title)}"
style="width:100%;display:block;filter:blur(3px) brightness(0.7);transform:scale(1.05)">
<div style="position:absolute;inset:0;display:flex;align-items:center;justify-content:center">
<div style="background:rgba(255,255,255,0.15);backdrop-filter:blur(4px);
border-radius:var(--radius-md);padding:var(--space-3) var(--space-4);
border:1px solid rgba(255,255,255,0.3)">
<svg style="width:28px;height:28px;color:#fff;display:block;margin:0 auto" aria-hidden="true">
<use href="/icons/phosphor.svg#lock-simple"></use>
</svg>
<span style="font-size:var(--text-xs);color:#fff;font-weight:700;display:block;margin-top:4px">
Nur für Mitglieder
</span>
</div>
</div>
</div>` : `
<div style="width:64px;height:64px;border-radius:50%;
<!-- Icon -->
<div style="width:72px;height:72px;border-radius:50%;
background:var(--c-primary-subtle);
display:flex;align-items:center;justify-content:center">
<svg style="width:32px;height:32px;color:var(--c-primary)" aria-hidden="true">
<svg style="width:36px;height:36px;color:var(--c-primary)" aria-hidden="true">
<use href="/icons/phosphor.svg#${UI.escape(gate.icon)}"></use>
</svg>
</div>`}
</div>
<!-- Text -->
<div style="max-width:300px">
@ -232,13 +213,14 @@ const App = (() => {
<!-- CTAs -->
<div style="display:flex;flex-direction:column;gap:var(--space-3);width:100%;max-width:280px">
<button class="btn btn-primary" id="gate-register-btn">
<button class="btn btn-primary" id="gate-login-btn">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#sign-out"></use></svg>
Anmelden
</button>
<button class="btn btn-secondary" id="gate-register-btn">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#user-plus"></use></svg>
Kostenlos registrieren
</button>
<button class="btn btn-ghost" id="gate-login-btn" style="font-size:var(--text-sm)">
Schon dabei? Anmelden
</button>
</div>
<!-- Hinweis was sonst frei ist -->
@ -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) {

View file

@ -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'
? `<span style="font-size:10px;background:var(--c-warning-bg,#FEF3C7);color:var(--c-warning,#D97706);padding:1px 6px;border-radius:999px">support@</span>`
: `<span style="font-size:10px;background:var(--c-primary-bg,#EFF6FF);color:var(--c-primary,#2563EB);padding:1px 6px;border-radius:999px">partner@</span>`;
el.innerHTML = `
<div style="display:flex;flex-direction:column;gap:var(--space-5)">
<!-- Vorlagen-Manager -->
<div class="by-card" style="padding:var(--space-4)">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:var(--space-3)">
<h3 style="margin:0;font-size:var(--text-base)">Vorlagen</h3>
<button class="btn btn-sm btn-secondary" id="adm-tpl-new">
${UI.icon('plus')} Neue Vorlage
</button>
</div>
${templates.length === 0
? `<p style="color:var(--c-text-muted);font-size:var(--text-sm)">Noch keine Vorlagen.</p>`
: `<div style="display:flex;flex-direction:column;gap:var(--space-2)">
${templates.map(t => `
<div style="display:flex;align-items:center;gap:var(--space-3);padding:var(--space-2) var(--space-3);
background:var(--c-bg-elevated);border-radius:var(--radius-md);border:1px solid var(--c-border)">
<div style="flex:1;min-width:0">
<div style="display:flex;align-items:center;gap:var(--space-2)">
<span style="font-size:var(--text-sm);font-weight:600">${_esc(t.label)}</span>
${accountBadge(t.from_account)}
</div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis">
${_esc(t.subject)}
</div>
</div>
<div style="display:flex;gap:var(--space-2);flex-shrink:0">
<button class="btn btn-xs btn-secondary adm-tpl-load" data-id="${t.id}" title="In Compose laden">
${UI.icon('arrow-bend-up-left')}
</button>
<button class="btn btn-xs btn-secondary adm-tpl-edit" data-id="${t.id}" title="Bearbeiten">
${UI.icon('pencil-simple')}
</button>
<button class="btn btn-xs btn-danger adm-tpl-del" data-id="${t.id}" title="Löschen">
${UI.icon('trash')}
</button>
</div>
</div>`).join('')}
</div>`}
</div>
<!-- Compose -->
<div class="by-card" style="padding:var(--space-4)">
<h3 style="margin:0 0 var(--space-3);font-size:var(--text-base)">E-Mail senden</h3>
<form id="adm-outreach-form" style="display:flex;flex-direction:column;gap:var(--space-3)">
<!-- Absender -->
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div>
<label class="form-label" style="font-size:var(--text-xs)">Absender</label>
<select id="adm-outreach-from" class="form-control">
<option value="partner">partner@banyaro.app (Influencer/Partner)</option>
<option value="support">support@banyaro.app (Support/Moderation)</option>
</select>
</div>
<div>
<label class="form-label" style="font-size:var(--text-xs)">
Empfänger <span style="color:var(--c-text-muted)">(Komma-getrennt)</span>
</label>
<input class="form-control" id="adm-outreach-to" type="text"
placeholder="name@example.com, andere@example.com">
</div>
</div>
<!-- Betreff -->
<div>
<label class="form-label" style="font-size:var(--text-xs)">Betreff</label>
<input class="form-control" id="adm-outreach-subject" type="text">
</div>
<!-- Text -->
<div>
<label class="form-label" style="font-size:var(--text-xs)">Text</label>
<textarea id="adm-outreach-body" class="form-control" rows="14"
style="font-family:monospace;font-size:var(--text-sm);resize:vertical"></textarea>
</div>
<div style="display:flex;gap:var(--space-3);align-items:center">
<button type="submit" class="btn btn-primary">
${UI.icon('paper-plane-tilt')} Senden
</button>
<span style="font-size:var(--text-xs);color:var(--c-text-muted)">
{name} wird nicht automatisch ersetzt bitte manuell anpassen.
</span>
</div>
</form>
</div>
<!-- Versand-Log -->
<div class="by-card" style="padding:var(--space-4)">
<h3 style="margin:0 0 var(--space-3);font-size:var(--text-base)">Versand-Log</h3>
${log.length === 0
? `<p style="color:var(--c-text-muted);font-size:var(--text-sm)">Noch keine E-Mails gesendet.</p>`
: `<table style="width:100%;border-collapse:collapse;font-size:var(--text-xs)">
<thead>
<tr style="border-bottom:1px solid var(--c-border)">
<th style="text-align:left;padding:var(--space-2);color:var(--c-text-muted)">Von</th>
<th style="text-align:left;padding:var(--space-2);color:var(--c-text-muted)">Empfänger</th>
<th style="text-align:left;padding:var(--space-2);color:var(--c-text-muted)">Betreff</th>
<th style="text-align:left;padding:var(--space-2);color:var(--c-text-muted)">Wer</th>
<th style="text-align:left;padding:var(--space-2);color:var(--c-text-muted)">Wann</th>
</tr>
</thead>
<tbody>
${log.map(l => `
<tr style="border-bottom:1px solid var(--c-border)">
<td style="padding:var(--space-2)">${accountBadge(l.from_account)}</td>
<td style="padding:var(--space-2)">${_esc(l.recipient)}</td>
<td style="padding:var(--space-2);color:var(--c-text-secondary)">${_esc(l.subject)}</td>
<td style="padding:var(--space-2);color:var(--c-text-muted)">${_esc(l.sent_by_name || '')}</td>
<td style="padding:var(--space-2);color:var(--c-text-muted)">${(l.sent_at||'').slice(0,16).replace('T',' ')}</td>
</tr>`).join('')}
</tbody>
</table>`}
</div>
</div>
`;
// 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: `
<form id="${id}" style="display:flex;flex-direction:column;gap:var(--space-3)">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div>
<label class="form-label" style="font-size:var(--text-xs)">Name (intern)</label>
<input class="form-control" id="${id}-key" type="text" placeholder="z.B. willkommen_neu"
value="${_esc(tpl?.key || '')}" ${isNew ? '' : 'readonly'}>
</div>
<div>
<label class="form-label" style="font-size:var(--text-xs)">Absender</label>
<select id="${id}-from" class="form-control">
<option value="partner" ${(tpl?.from_account||'partner')==='partner'?'selected':''}>partner@banyaro.app</option>
<option value="support" ${tpl?.from_account==='support'?'selected':''}>support@banyaro.app</option>
</select>
</div>
</div>
<div>
<label class="form-label" style="font-size:var(--text-xs)">Bezeichnung (sichtbar)</label>
<input class="form-control" id="${id}-label" type="text" placeholder="z.B. Willkommensnachricht"
value="${_esc(tpl?.label || '')}">
</div>
<div>
<label class="form-label" style="font-size:var(--text-xs)">Betreff</label>
<input class="form-control" id="${id}-subject" type="text"
value="${_esc(tpl?.subject || '')}">
</div>
<div>
<label class="form-label" style="font-size:var(--text-xs)">Text</label>
<textarea id="${id}-body" class="form-control" rows="12"
style="font-family:monospace;font-size:var(--text-sm);resize:vertical">${_esc(tpl?.body || '')}</textarea>
</div>
</form>`,
footer: `
<button class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
<button class="btn btn-primary" form="${id}" type="submit">Speichern</button>`,
});
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 = `
<div style="display:flex;justify-content:flex-end;margin-bottom:var(--space-3)">

View file

@ -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) {

View file

@ -138,15 +138,7 @@ window.Page_settings = (() => {
style="display:none">
<div>
<div style="font-weight:700;font-size:var(--text-lg)">${_esc(u.name)}</div>
<div style="display:flex;align-items:center;gap:var(--space-2);color:var(--c-text-secondary);font-size:var(--text-sm)">
${_esc(u.email)}
${u.email_verified
? `<svg class="ph-icon" aria-hidden="true" style="width:14px;height:14px;color:#22c55e" title="Bestätigt"><use href="/icons/phosphor.svg#check-circle"></use></svg>`
: `<span id="settings-verify-chip"
style="font-size:10px;background:#fef3c7;color:#d97706;padding:1px 7px;
border-radius:999px;cursor:pointer;white-space:nowrap"
title="E-Mail noch nicht bestätigt">Nicht bestätigt</span>`}
</div>
<div style="color:var(--c-text-secondary);font-size:var(--text-sm)">${_esc(u.email)}</div>
<div style="display:flex;flex-wrap:wrap;gap:var(--space-1);margin-top:var(--space-1)">
${u.is_premium
? `<span class="badge badge-primary">
@ -157,12 +149,6 @@ window.Page_settings = (() => {
? `<span class="badge" style="background:#7c3aed;color:#fff;cursor:pointer" data-page="gruender">
<svg class="ph-icon" aria-hidden="true" style="width:12px;height:12px"><use href="/icons/phosphor.svg#key"></use></svg>
${u.founder_number ? `Gründer #${u.founder_number}` : 'Gründer'}
</span>`
: u.is_founder_pending
? `<span class="badge" style="background:#f59e0b;color:#fff;cursor:pointer" data-page="dog-profile"
title="Hunde-Profil anlegen um Gründer-Platz zu sichern">
<svg class="ph-icon" aria-hidden="true" style="width:12px;height:12px"><use href="/icons/phosphor.svg#hourglass"></use></svg>
Gründer-Platz reserviert
</span>` : ''}
${u.is_partner
? `<span class="badge" style="background:#0ea5e9;color:#fff">
@ -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 = `
<div style="max-width:380px;margin:0 auto;padding:var(--space-6) 0;text-align:center">
<div style="margin-bottom:var(--space-5)">
<img src="/icons/icon-180.png" alt="Ban Yaro"
style="width:72px;height:72px;border-radius:var(--radius-lg);margin-bottom:var(--space-3)">
<h1 style="font-size:var(--text-2xl);font-weight:700;margin:0">E-Mail bestätigen</h1>
</div>
<div style="background:var(--c-surface-2);border-radius:var(--radius-lg);
padding:var(--space-5);margin-bottom:var(--space-4);text-align:left">
<p style="margin:0 0 var(--space-2)">
Wir haben einen Bestätigungslink an<br>
<strong>${email}</strong><br>
gesendet.
</p>
<p style="margin:0;color:var(--c-text-secondary);font-size:var(--text-sm)">
Bitte prüfe dein Postfach und klicke auf den Link, um dein Konto zu aktivieren.
Danach kannst du dich hier anmelden.
</p>
</div>
<button id="verify-resend-btn2" class="btn btn-ghost w-full"
style="margin-bottom:var(--space-3)">
Link erneut senden
</button>
<button id="verify-back-btn" class="btn btn-ghost w-full"
style="color:var(--c-text-muted);font-size:var(--text-sm)">
Anderes Konto / Anmelden
</button>
</div>
`;
document.getElementById('verify-resend-btn2')?.addEventListener('click', async function() {
this.disabled = true;
this.textContent = 'Gesendet …';
try {
await API.post('/auth/resend-verification', { email });
UI.toast.success('Bestätigungs-Mail erneut gesendet.');
} catch {
UI.toast.error('Fehler beim Senden — bitte versuche es später erneut.');
}
});
document.getElementById('verify-back-btn')?.addEventListener('click', () => _renderAuth('login'));
}
function _renderAuth(mode) {
// Passwort-Reset über Link aus E-Mail
const resetToken = sessionStorage.getItem('by_reset_token');
if (resetToken) {
sessionStorage.removeItem('by_reset_token');
_renderResetPassword(resetToken);
return;
}
_mode = mode;
_container.innerHTML = `
<div style="max-width:380px;margin:0 auto;padding:var(--space-6) 0">
@ -1364,13 +1293,6 @@ window.Page_settings = (() => {
<button type="submit" class="btn btn-primary w-full" style="margin-top:var(--space-2)">
Anmelden
</button>
<p style="text-align:center;margin-top:var(--space-3);font-size:var(--text-xs)">
<button type="button" id="forgot-pw-link"
class="btn btn-ghost"
style="font-size:var(--text-xs);color:var(--c-text-muted);padding:0">
Passwort vergessen?
</button>
</p>
</form>
`;
}
@ -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: `
<form id="${id}" style="display:flex;flex-direction:column;gap:var(--space-3)">
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin:0">
Gib deine E-Mail-Adresse ein. Du erhältst einen Link zum Zurücksetzen deines Passworts.
</p>
<div>
<label class="form-label">E-Mail</label>
<input class="form-control" id="forgot-pw-email" type="email"
placeholder="deine@email.de" autocomplete="email" required>
</div>
</form>`,
footer: `
<button class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
<button class="btn btn-primary" form="${id}" type="submit">Link senden</button>`,
});
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 = `
<div style="max-width:380px;margin:0 auto;padding:var(--space-6) 0">
<div style="text-align:center;margin-bottom:var(--space-6)">
<img src="/icons/icon-180.png" alt="Ban Yaro"
style="width:72px;height:72px;border-radius:var(--radius-lg);margin-bottom:var(--space-3)">
<h1 style="font-size:var(--text-2xl);font-weight:700;margin:0">Neues Passwort</h1>
<p style="color:var(--c-text-secondary);margin:var(--space-1) 0 0">
Wähle ein sicheres Passwort für deinen Account.
</p>
</div>
<form id="reset-pw-form" style="display:flex;flex-direction:column;gap:var(--space-4)">
<div>
<label class="form-label">Neues Passwort</label>
<div style="position:relative">
<input class="form-control" type="password" id="reset-pw-input"
placeholder="Mindestens 8 Zeichen" autocomplete="new-password"
minlength="8" required style="padding-right:var(--space-10)">
<button type="button" id="reset-pw-toggle"
class="btn btn-ghost btn-icon"
style="position:absolute;right:var(--space-1);top:50%;transform:translateY(-50%);
color:var(--c-text-muted);padding:var(--space-2)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#eye"></use></svg>
</button>
</div>
<!-- Hundepassphrase-Generator -->
<div style="margin-top:var(--space-2);padding:var(--space-3);
background:var(--c-surface-2);border-radius:var(--radius-md);
border-left:3px solid var(--c-primary)">
<div style="display:flex;align-items:center;gap:var(--space-2);margin-bottom:var(--space-2)">
<span style="font-size:var(--text-xs);font-weight:700;color:var(--c-text-secondary);
text-transform:uppercase;letter-spacing:.05em">🐾 Passwort-Vorschlag</span>
<button type="button" id="reset-gen-new"
style="margin-left:auto;font-size:var(--text-xs);color:var(--c-primary);
background:none;border:none;cursor:pointer;padding:0;font-weight:600">
neu
</button>
</div>
<div style="display:flex;align-items:center;gap:var(--space-2)">
<code id="reset-gen-phrase"
style="flex:1;font-size:var(--text-sm);font-weight:700;
color:var(--c-text);letter-spacing:.02em;word-break:break-all"></code>
<button type="button" id="reset-gen-use"
class="btn btn-sm btn-secondary" style="flex-shrink:0">
Übernehmen
</button>
</div>
</div>
</div>
<button type="submit" class="btn btn-primary w-full">
Passwort speichern
</button>
</form>
</div>
`;
_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
// ----------------------------------------------------------

View file

@ -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

View file

View file

@ -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 |

View file

@ -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 13)
- 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 |

View file

@ -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.

View file

@ -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_

View file

@ -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
```

View file

@ -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