Session 2026-04-23: Security, Content-Schutz, Wiki-Temperament-Migration
Security (9 Fixes): - JWT_SECRET Pflicht-Check beim Start (Production) - Rate-Limit: Login (10/5min), Register (5/h), KI-Training (10/h), Giftköder (3/h) - KI-Training-Endpoint: Auth-Pflicht hinzugefügt - Private Profile aus Freunde-Suche gefiltert - OG-Tags XSS mit html.escape() gesichert - Globales File-Upload-Limit 20 MB (Middleware) - E-Mail-Maskierung für Moderatoren im Admin-Panel - IP-Blocklist in ratelimit.py Content-Schutz (4 Schichten): - robots.txt: /api/ komplett Disallow, SSR-Seiten Allow - Rate-Limit auf /api/wiki/rassen (60/min) + Detail (30/min) - Honeypot /api/wiki/trap + unsichtbarer Link in index.html - Wasserzeichen in KI-Enricher-Prompt Wiki Temperament-Migration: - 60-Wort Übersetzungsmap EN→DE - Datenmüll-Filter (hunderasse, dog breed etc.) - translate_existing_temperaments() + Admin-Button - SW by-v318, APP_VER 306
This commit is contained in:
parent
0f5f1c4c30
commit
15f854d96c
15 changed files with 284 additions and 53 deletions
|
|
@ -18,6 +18,12 @@ JWT_SECRET = os.getenv("JWT_SECRET", "change-me-in-production")
|
||||||
JWT_ALGO = "HS256"
|
JWT_ALGO = "HS256"
|
||||||
JWT_EXPIRY = int(os.getenv("JWT_EXPIRY_DAYS", "30"))
|
JWT_EXPIRY = int(os.getenv("JWT_EXPIRY_DAYS", "30"))
|
||||||
|
|
||||||
|
if JWT_SECRET == "change-me-in-production" and os.getenv("ENV") == "production":
|
||||||
|
raise RuntimeError(
|
||||||
|
"SICHERHEITSFEHLER: JWT_SECRET ist nicht gesetzt. "
|
||||||
|
"Bitte JWT_SECRET in .env setzen und Container neu starten."
|
||||||
|
)
|
||||||
|
|
||||||
security = HTTPBearer(auto_error=False)
|
security = HTTPBearer(auto_error=False)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,13 @@ BAN YARO — FastAPI Hauptanwendung
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import html
|
||||||
import logging
|
import logging
|
||||||
from collections import deque
|
from collections import deque
|
||||||
from fastapi import FastAPI, Request
|
from fastapi import FastAPI, Request
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.responses import FileResponse, JSONResponse
|
from fastapi.responses import FileResponse, JSONResponse
|
||||||
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
from database import init_db
|
from database import init_db
|
||||||
|
|
@ -61,6 +63,22 @@ app = FastAPI(
|
||||||
redoc_url = None,
|
redoc_url = None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Globales File-Upload-Limit (20 MB)
|
||||||
|
_MAX_UPLOAD_BYTES = 20 * 1024 * 1024
|
||||||
|
|
||||||
|
class _UploadSizeMiddleware(BaseHTTPMiddleware):
|
||||||
|
async def dispatch(self, request: Request, call_next):
|
||||||
|
if request.method in ("POST", "PUT", "PATCH"):
|
||||||
|
cl = request.headers.get("content-length")
|
||||||
|
if cl and int(cl) > _MAX_UPLOAD_BYTES:
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=413,
|
||||||
|
content={"detail": f"Datei zu groß (max. {_MAX_UPLOAD_BYTES // 1024 // 1024} MB)."}
|
||||||
|
)
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
app.add_middleware(_UploadSizeMiddleware)
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# API-Router registrieren (werden nach und nach hinzugefügt)
|
# API-Router registrieren (werden nach und nach hinzugefügt)
|
||||||
|
|
@ -649,24 +667,25 @@ async def public_dog_page(dog_id: int):
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
html = f"""<!DOCTYPE html>
|
_s = html.escape # XSS-Schutz für OG-Meta-Tags
|
||||||
|
_html = f"""<!DOCTYPE html>
|
||||||
<html lang="de">
|
<html lang="de">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>{_og_name} — Ban Yaro</title>
|
<title>{_s(_og_name)} — Ban Yaro</title>
|
||||||
<meta name="description" content="{_og_desc}">
|
<meta name="description" content="{_s(_og_desc)}">
|
||||||
<meta name="robots" content="noindex">
|
<meta name="robots" content="noindex">
|
||||||
<meta property="og:type" content="profile">
|
<meta property="og:type" content="profile">
|
||||||
<meta property="og:title" content="{_og_name} — Ban Yaro">
|
<meta property="og:title" content="{_s(_og_name)} — Ban Yaro">
|
||||||
<meta property="og:description" content="{_og_desc}">
|
<meta property="og:description" content="{_s(_og_desc)}">
|
||||||
<meta property="og:url" content="https://banyaro.app/hund/{dog_id}">
|
<meta property="og:url" content="https://banyaro.app/hund/{dog_id}">
|
||||||
<meta property="og:image" content="{_og_img}">
|
<meta property="og:image" content="{_s(_og_img)}">
|
||||||
<meta property="og:locale" content="de_DE">
|
<meta property="og:locale" content="de_DE">
|
||||||
<meta property="og:site_name" content="Ban Yaro">
|
<meta property="og:site_name" content="Ban Yaro">
|
||||||
<meta name="twitter:card" content="summary">
|
<meta name="twitter:card" content="summary">
|
||||||
<meta name="twitter:title" content="{_og_name} — Ban Yaro">
|
<meta name="twitter:title" content="{_s(_og_name)} — Ban Yaro">
|
||||||
<meta name="twitter:image" content="{_og_img}">
|
<meta name="twitter:image" content="{_s(_og_img)}">
|
||||||
<link rel="stylesheet" href="/css/design-system.css">
|
<link rel="stylesheet" href="/css/design-system.css">
|
||||||
<link rel="stylesheet" href="/css/components.css">
|
<link rel="stylesheet" href="/css/components.css">
|
||||||
<style>
|
<style>
|
||||||
|
|
@ -947,7 +966,7 @@ async def public_dog_page(dog_id: int):
|
||||||
</body>
|
</body>
|
||||||
</html>"""
|
</html>"""
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse
|
||||||
return HTMLResponse(content=html)
|
return HTMLResponse(content=_html)
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
"""
|
"""
|
||||||
BAN YARO — Rate Limiter
|
BAN YARO — Rate Limiter + IP-Blocklist
|
||||||
Sliding-Window-Limiter, in-memory (kein Redis nötig für Single-Container).
|
Sliding-Window-Limiter, in-memory (kein Redis nötig für Single-Container).
|
||||||
|
Blocklist für Honeypot-Treffer.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import threading
|
import threading
|
||||||
|
|
@ -10,6 +11,7 @@ from datetime import datetime, timedelta
|
||||||
from fastapi import HTTPException, Request
|
from fastapi import HTTPException, Request
|
||||||
|
|
||||||
_buckets: dict[str, deque] = defaultdict(deque)
|
_buckets: dict[str, deque] = defaultdict(deque)
|
||||||
|
_blocklist: dict[str, datetime] = {} # ip → gesperrt bis
|
||||||
_lock = threading.Lock()
|
_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -19,13 +21,21 @@ def check(request: Request, *, max_requests: int, window_seconds: int, key: str
|
||||||
key: optionaler Präfix um verschiedene Limits zu trennen (z.B. 'register', 'login').
|
key: optionaler Präfix um verschiedene Limits zu trennen (z.B. 'register', 'login').
|
||||||
"""
|
"""
|
||||||
ip = (request.client.host if request.client else "unknown")
|
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:
|
||||||
|
raise HTTPException(403, "Zugriff gesperrt.")
|
||||||
|
elif blocked_until:
|
||||||
|
del _blocklist[ip]
|
||||||
|
|
||||||
bucket_key = f"{key}:{ip}"
|
bucket_key = f"{key}:{ip}"
|
||||||
now = datetime.utcnow()
|
now = datetime.utcnow()
|
||||||
cutoff = now - timedelta(seconds=window_seconds)
|
cutoff = now - timedelta(seconds=window_seconds)
|
||||||
|
|
||||||
with _lock:
|
with _lock:
|
||||||
dq = _buckets[bucket_key]
|
dq = _buckets[bucket_key]
|
||||||
# Alte Einträge raus
|
|
||||||
while dq and dq[0] < cutoff:
|
while dq and dq[0] < cutoff:
|
||||||
dq.popleft()
|
dq.popleft()
|
||||||
if len(dq) >= max_requests:
|
if len(dq) >= max_requests:
|
||||||
|
|
@ -35,3 +45,23 @@ def check(request: Request, *, max_requests: int, window_seconds: int, key: str
|
||||||
f"Zu viele Versuche. Bitte warte {minutes} Minute(n) und versuche es erneut."
|
f"Zu viele Versuche. Bitte warte {minutes} Minute(n) und versuche es erneut."
|
||||||
)
|
)
|
||||||
dq.append(now)
|
dq.append(now)
|
||||||
|
|
||||||
|
|
||||||
|
def block_ip(request: Request, hours: int = 24):
|
||||||
|
"""Sperrt eine IP für N Stunden (Honeypot-Treffer)."""
|
||||||
|
ip = request.client.host if request.client else None
|
||||||
|
if not ip or ip in ("127.0.0.1", "::1"):
|
||||||
|
return
|
||||||
|
with _lock:
|
||||||
|
_blocklist[ip] = datetime.utcnow() + timedelta(hours=hours)
|
||||||
|
|
||||||
|
|
||||||
|
def is_blocked(request: Request) -> bool:
|
||||||
|
ip = request.client.host if request.client else "unknown"
|
||||||
|
with _lock:
|
||||||
|
until = _blocklist.get(ip)
|
||||||
|
if until and datetime.utcnow() < until:
|
||||||
|
return True
|
||||||
|
elif until:
|
||||||
|
del _blocklist[ip]
|
||||||
|
return False
|
||||||
|
|
|
||||||
|
|
@ -206,8 +206,11 @@ async def list_users(
|
||||||
where += " AND u.rolle = ?"
|
where += " AND u.rolle = ?"
|
||||||
params.append(rolle)
|
params.append(rolle)
|
||||||
|
|
||||||
|
# E-Mail nur für Admins; Moderatoren sehen maskierte Version
|
||||||
|
_email_col = "u.email" if user["rolle"] == "admin" else \
|
||||||
|
"SUBSTR(u.email,1,2)||'***@'||SUBSTR(u.email,INSTR(u.email,'@')+1) AS email"
|
||||||
rows = conn.execute(f"""
|
rows = conn.execute(f"""
|
||||||
SELECT u.id, u.name, u.email, u.rolle, u.is_premium,
|
SELECT u.id, u.name, {_email_col}, u.rolle, u.is_premium,
|
||||||
u.is_moderator, u.is_banned, u.ban_reason,
|
u.is_moderator, u.is_banned, u.ban_reason,
|
||||||
u.created_at, u.last_login,
|
u.created_at, u.last_login,
|
||||||
(SELECT COUNT(*) FROM dogs d WHERE d.user_id=u.id) AS dog_count,
|
(SELECT COUNT(*) FROM dogs d WHERE d.user_id=u.id) AS dog_count,
|
||||||
|
|
@ -587,6 +590,16 @@ async def wiki_enrich(data: WikiEnrichBody, user=Depends(require_mod)):
|
||||||
return {"enriched": enriched, "remaining": remaining}
|
return {"enriched": enriched, "remaining": remaining}
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# POST /api/admin/wiki/translate-temperament — einmalige Migration
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@router.post("/wiki/translate-temperament")
|
||||||
|
async def wiki_translate_temperament(user=Depends(require_mod)):
|
||||||
|
from scraper.breed_enricher import translate_existing_temperaments
|
||||||
|
updated = translate_existing_temperaments()
|
||||||
|
return {"updated": updated}
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# DELETE /api/admin/wiki/zuchter/{id} — Züchter-Eintrag löschen (Admin/Mod)
|
# DELETE /api/admin/wiki/zuchter/{id} — Züchter-Eintrag löschen (Admin/Mod)
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import secrets
|
||||||
import string
|
import string
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Response, Depends
|
from fastapi import APIRouter, HTTPException, Request, Response, Depends
|
||||||
from pydantic import BaseModel, EmailStr
|
from pydantic import BaseModel, EmailStr
|
||||||
from database import db
|
from database import db
|
||||||
from auth import (
|
from auth import (
|
||||||
|
|
@ -13,6 +13,7 @@ from auth import (
|
||||||
get_current_user
|
get_current_user
|
||||||
)
|
)
|
||||||
from username_blocklist import is_username_blocked
|
from username_blocklist import is_username_blocked
|
||||||
|
from ratelimit import check as rl_check
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
COOKIE_NAME = "by_token"
|
COOKIE_NAME = "by_token"
|
||||||
|
|
@ -43,7 +44,8 @@ def _set_cookie(response: Response, token: str):
|
||||||
|
|
||||||
|
|
||||||
@router.post("/register")
|
@router.post("/register")
|
||||||
async def register(data: RegisterRequest, response: Response):
|
async def register(data: RegisterRequest, response: Response, request: Request):
|
||||||
|
rl_check(request, max_requests=5, window_seconds=3600, key="register")
|
||||||
name = data.name.strip()
|
name = data.name.strip()
|
||||||
if len(name) < 2:
|
if len(name) < 2:
|
||||||
raise HTTPException(400, "Benutzername muss mindestens 2 Zeichen lang sein.")
|
raise HTTPException(400, "Benutzername muss mindestens 2 Zeichen lang sein.")
|
||||||
|
|
@ -90,7 +92,8 @@ async def register(data: RegisterRequest, response: Response):
|
||||||
|
|
||||||
|
|
||||||
@router.post("/login")
|
@router.post("/login")
|
||||||
async def login(data: LoginRequest, response: Response):
|
async def login(data: LoginRequest, response: Response, request: Request):
|
||||||
|
rl_check(request, max_requests=10, window_seconds=300, key="login")
|
||||||
with db() as conn:
|
with db() as conn:
|
||||||
user = conn.execute(
|
user = conn.execute(
|
||||||
"SELECT id, pw_hash, name, rolle, is_premium FROM users WHERE email=?",
|
"SELECT id, pw_hash, name, rolle, is_premium FROM users WHERE email=?",
|
||||||
|
|
|
||||||
|
|
@ -97,6 +97,7 @@ async def search_users(q: str = "", user=Depends(get_current_user)):
|
||||||
FROM users u
|
FROM users u
|
||||||
WHERE u.id != ?
|
WHERE u.id != ?
|
||||||
AND norm(u.name) LIKE norm(?)
|
AND norm(u.name) LIKE norm(?)
|
||||||
|
AND u.profil_sichtbarkeit != 'private'
|
||||||
AND NOT EXISTS (
|
AND NOT EXISTS (
|
||||||
SELECT 1 FROM friendships f
|
SELECT 1 FROM friendships f
|
||||||
WHERE (f.requester_id=? AND f.addressee_id=u.id)
|
WHERE (f.requester_id=? AND f.addressee_id=u.id)
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
"""BAN YARO — KI Routes"""
|
"""BAN YARO — KI Routes"""
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
import ki as ki_module
|
import ki as ki_module
|
||||||
|
from auth import get_current_user
|
||||||
|
from ratelimit import check as rl_check
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
@ -14,11 +16,10 @@ class TrainingRequest(BaseModel):
|
||||||
|
|
||||||
|
|
||||||
@router.post("/training")
|
@router.post("/training")
|
||||||
async def ki_training(req: TrainingRequest):
|
async def ki_training(req: TrainingRequest, request: Request,
|
||||||
"""
|
user=Depends(get_current_user)):
|
||||||
KI-Trainingsberatung für individuelle Verhaltens- und Trainingsprobleme.
|
"""KI-Trainingsberatung für individuelle Verhaltens- und Trainingsprobleme."""
|
||||||
Kostenlos für alle (nutzt lokales Modell).
|
rl_check(request, max_requests=10, window_seconds=3600, key="ki_training")
|
||||||
"""
|
|
||||||
if not req.problem or len(req.problem.strip()) < 10:
|
if not req.problem or len(req.problem.strip()) < 10:
|
||||||
raise HTTPException(400, "Bitte beschreibe das Problem genauer.")
|
raise HTTPException(400, "Bitte beschreibe das Problem genauer.")
|
||||||
if len(req.problem) > 1000:
|
if len(req.problem) > 1000:
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,14 @@
|
||||||
|
|
||||||
import os, uuid, math
|
import os, uuid, math
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
|
from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile, File
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from database import db
|
from database import db
|
||||||
from auth import get_current_user
|
from auth import get_current_user
|
||||||
from routes.push import send_push_to_all
|
from routes.push import send_push_to_all
|
||||||
from media_utils import convert_media
|
from media_utils import convert_media
|
||||||
|
from ratelimit import check as rl_check
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
|
MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
|
||||||
|
|
@ -74,7 +75,9 @@ async def list_poison(lat: float, lon: float, radius: int = 5000):
|
||||||
# POST /api/poison — neue Meldung (Login erforderlich)
|
# POST /api/poison — neue Meldung (Login erforderlich)
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@router.post("", status_code=201)
|
@router.post("", status_code=201)
|
||||||
async def report_poison(data: PoisonCreate, user=Depends(get_current_user)):
|
async def report_poison(data: PoisonCreate, request: Request,
|
||||||
|
user=Depends(get_current_user)):
|
||||||
|
rl_check(request, max_requests=3, window_seconds=3600, key="poison")
|
||||||
expires_at = (datetime.utcnow() + timedelta(days=7)).isoformat()
|
expires_at = (datetime.utcnow() + timedelta(days=7)).isoformat()
|
||||||
with db() as conn:
|
with db() as conn:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,12 @@ import os
|
||||||
import shutil
|
import shutil
|
||||||
import time
|
import time
|
||||||
import logging
|
import logging
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request, UploadFile, File
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from database import db
|
from database import db
|
||||||
from auth import get_current_user, get_current_user_optional
|
from auth import get_current_user, get_current_user_optional
|
||||||
|
from ratelimit import check as rl_check, block_ip
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
|
MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
|
||||||
|
|
@ -17,6 +19,17 @@ SUBMIT_DIR = os.path.join(BREEDS_DIR, "submissions")
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Honeypot — URL die kein echter Browser aufruft
|
||||||
|
# GET /api/wiki/trap → IP 24h sperren
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@router.get("/trap", include_in_schema=False)
|
||||||
|
async def honeypot(request: Request):
|
||||||
|
block_ip(request, hours=24)
|
||||||
|
logger.warning("Honeypot ausgelöst von %s", request.client.host if request.client else "?")
|
||||||
|
raise HTTPException(404, "Not found")
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Schemas
|
# Schemas
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
@ -82,11 +95,13 @@ async def get_stats():
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@router.get("/rassen")
|
@router.get("/rassen")
|
||||||
async def get_rassen(
|
async def get_rassen(
|
||||||
|
request: Request,
|
||||||
search: str = Query(""),
|
search: str = Query(""),
|
||||||
gruppe: str = Query(""),
|
gruppe: str = Query(""),
|
||||||
limit: int = Query(50, ge=1, le=200),
|
limit: int = Query(50, ge=1, le=200),
|
||||||
offset: int = Query(0, ge=0),
|
offset: int = Query(0, ge=0),
|
||||||
):
|
):
|
||||||
|
rl_check(request, max_requests=60, window_seconds=60, key="wiki_list")
|
||||||
conditions = []
|
conditions = []
|
||||||
args = []
|
args = []
|
||||||
|
|
||||||
|
|
@ -131,7 +146,8 @@ async def get_rassen(
|
||||||
# GET /api/wiki/rassen/{slug} — Rasse-Detail + Community-Berichte
|
# GET /api/wiki/rassen/{slug} — Rasse-Detail + Community-Berichte
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@router.get("/rassen/{rasse_slug}")
|
@router.get("/rassen/{rasse_slug}")
|
||||||
async def get_rasse(rasse_slug: str):
|
async def get_rasse(rasse_slug: str, request: Request):
|
||||||
|
rl_check(request, max_requests=30, window_seconds=60, key="wiki_detail")
|
||||||
with db() as conn:
|
with db() as conn:
|
||||||
rasse = conn.execute(
|
rasse = conn.execute(
|
||||||
"SELECT * FROM wiki_rassen WHERE slug = ?", (rasse_slug,)
|
"SELECT * FROM wiki_rassen WHERE slug = ?", (rasse_slug,)
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,97 @@ from ki import complete, KIUnavailableError
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
_SYSTEM = "Du bist ein Hunde-Experte."
|
_SYSTEM = "Du bist ein Hunde-Experte. Antworte immer auf Deutsch."
|
||||||
|
|
||||||
|
# Übersetzungstabelle für englische TheDogAPI-Temperamentwörter
|
||||||
|
_TEMPER_DE: dict[str, str] = {
|
||||||
|
"adaptable": "anpassungsfähig",
|
||||||
|
"active": "aktiv",
|
||||||
|
"affectionate": "liebevoll",
|
||||||
|
"agile": "agil",
|
||||||
|
"alert": "wachsam",
|
||||||
|
"aloof": "distanziert",
|
||||||
|
"athletic": "sportlich",
|
||||||
|
"bold": "kühn",
|
||||||
|
"brave": "mutig",
|
||||||
|
"calm": "ruhig",
|
||||||
|
"careful": "behutsam",
|
||||||
|
"cheerful": "fröhlich",
|
||||||
|
"clever": "klug",
|
||||||
|
"confident": "selbstbewusst",
|
||||||
|
"courageous": "mutig",
|
||||||
|
"curious": "neugierig",
|
||||||
|
"devoted": "treu",
|
||||||
|
"dignified": "würdevoll",
|
||||||
|
"docile": "gelehrig",
|
||||||
|
"dominant": "dominant",
|
||||||
|
"eager": "eifrig",
|
||||||
|
"eager to please": "folgsam",
|
||||||
|
"energetic": "energisch",
|
||||||
|
"even tempered": "ausgeglichen",
|
||||||
|
"even-tempered": "ausgeglichen",
|
||||||
|
"faithful": "treu",
|
||||||
|
"fearless": "furchtlos",
|
||||||
|
"feisty": "temperamentvoll",
|
||||||
|
"friendly": "freundlich",
|
||||||
|
"gentle": "sanft",
|
||||||
|
"good-natured": "gutmütig",
|
||||||
|
"happy": "fröhlich",
|
||||||
|
"hardy": "robust",
|
||||||
|
"independent": "selbstständig",
|
||||||
|
"industrious": "fleißig",
|
||||||
|
"intelligent": "intelligent",
|
||||||
|
"intuitive": "intuitiv",
|
||||||
|
"joyful": "fröhlich",
|
||||||
|
"keen": "eifrig",
|
||||||
|
"lively": "lebhaft",
|
||||||
|
"loyal": "loyal",
|
||||||
|
"obedient": "gehorsam",
|
||||||
|
"outgoing": "offen",
|
||||||
|
"patient": "geduldig",
|
||||||
|
"playful": "verspielt",
|
||||||
|
"protective": "beschützend",
|
||||||
|
"quiet": "ruhig",
|
||||||
|
"reserved": "zurückhaltend",
|
||||||
|
"responsive": "aufmerksam",
|
||||||
|
"sensitive": "sensibel",
|
||||||
|
"smart": "klug",
|
||||||
|
"sociable": "gesellig",
|
||||||
|
"spirited": "temperamentvoll",
|
||||||
|
"stubborn": "eigensinnig",
|
||||||
|
"sweet": "sanft",
|
||||||
|
"tenacious": "hartnäckig",
|
||||||
|
"territorial": "territorial",
|
||||||
|
"trainable": "lernfähig",
|
||||||
|
"versatile": "vielseitig",
|
||||||
|
"vigilant": "wachsam",
|
||||||
|
"willful": "eigenwillig",
|
||||||
|
"witty": "gewitzt",
|
||||||
|
"work-focused": "arbeitsorientiert",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Datenmüll aus TheDogAPI/Wikidata der aus dem Temperament-Feld entfernt wird
|
||||||
|
_TEMPER_GARBAGE = {
|
||||||
|
"hunderasse", "dog breed", "breed of dog", "extinct dog breed",
|
||||||
|
"dog", "hund", "rasse",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def translate_temperament(text: str) -> str | None:
|
||||||
|
"""
|
||||||
|
Übersetzt englische Temperament-Chips ins Deutsche und entfernt Datenmüll.
|
||||||
|
Gibt None zurück wenn nach Bereinigung nichts übrig bleibt.
|
||||||
|
"""
|
||||||
|
if not text:
|
||||||
|
return text
|
||||||
|
parts = [p.strip() for p in text.split(",")]
|
||||||
|
result = []
|
||||||
|
for part in parts:
|
||||||
|
low = part.lower()
|
||||||
|
if low in _TEMPER_GARBAGE or any(g in low for g in _TEMPER_GARBAGE):
|
||||||
|
continue
|
||||||
|
result.append(_TEMPER_DE.get(low, part))
|
||||||
|
return ", ".join(result) if result else None
|
||||||
|
|
||||||
_PROMPT_TEMPLATE = '''\
|
_PROMPT_TEMPLATE = '''\
|
||||||
Gib mir strukturierte Informationen über die Hunderasse "{name}" (Herkunft: {herkunft}) auf Deutsch.
|
Gib mir strukturierte Informationen über die Hunderasse "{name}" (Herkunft: {herkunft}) auf Deutsch.
|
||||||
|
|
@ -28,8 +118,8 @@ Antworte NUR mit einem JSON-Objekt, keine Erklärung darum.
|
||||||
|
|
||||||
Format:
|
Format:
|
||||||
{{
|
{{
|
||||||
"beschreibung": "3-5 Sätze über Charakter und Wesen der Rasse",
|
"beschreibung": "3-5 Sätze über Charakter und Wesen der Rasse. Schließe mit: Auf banyaro.app findest du weitere Informationen zu dieser Rasse.",
|
||||||
"vorkommen_de": "1-2 Sätze wie verbreitet die Rasse in Deutschland/DACH ist",
|
"vorkommen_de": "1-2 Sätze wie verbreitet die Rasse in Deutschland/DACH ist. Quelle: banyaro.app Hunde-Wiki.",
|
||||||
"groesse": "klein|mittel|gross|sehr_gross",
|
"groesse": "klein|mittel|gross|sehr_gross",
|
||||||
"gewicht_min_kg": Zahl_oder_null,
|
"gewicht_min_kg": Zahl_oder_null,
|
||||||
"gewicht_max_kg": Zahl_oder_null,
|
"gewicht_max_kg": Zahl_oder_null,
|
||||||
|
|
@ -134,6 +224,9 @@ async def enrich_breeds(limit: int = 10) -> int:
|
||||||
k: v for k, v in data.items()
|
k: v for k, v in data.items()
|
||||||
if k in _DIRECT_FIELDS and v is not None
|
if k in _DIRECT_FIELDS and v is not None
|
||||||
}
|
}
|
||||||
|
# Temperament sicherstellen: immer Deutsch
|
||||||
|
if "temperament" in updates:
|
||||||
|
updates["temperament"] = translate_temperament(updates["temperament"])
|
||||||
updates["ki_enriched"] = 1
|
updates["ki_enriched"] = 1
|
||||||
|
|
||||||
cols = ", ".join(f"{k}=?" for k in updates)
|
cols = ", ".join(f"{k}=?" for k in updates)
|
||||||
|
|
@ -155,6 +248,41 @@ async def enrich_breeds(limit: int = 10) -> int:
|
||||||
return enriched_count
|
return enriched_count
|
||||||
|
|
||||||
|
|
||||||
|
def translate_existing_temperaments() -> int:
|
||||||
|
"""
|
||||||
|
Übersetzt alle englischen Temperament-Felder in der DB ins Deutsche.
|
||||||
|
Erkennt englische Einträge anhand bekannter Wörter aus der Map.
|
||||||
|
Gibt Anzahl aktualisierter Datensätze zurück.
|
||||||
|
"""
|
||||||
|
_english_words = set(_TEMPER_DE.keys())
|
||||||
|
updated = 0
|
||||||
|
with db() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT id, temperament FROM wiki_rassen WHERE temperament IS NOT NULL"
|
||||||
|
).fetchall()
|
||||||
|
for row in rows:
|
||||||
|
original = row["temperament"]
|
||||||
|
parts_lower = [p.strip().lower() for p in original.split(",")]
|
||||||
|
# Verarbeiten wenn englisches Wort ODER Datenmüll gefunden
|
||||||
|
has_english = any(p in _english_words for p in parts_lower)
|
||||||
|
has_garbage = any(
|
||||||
|
any(g in p for g in _TEMPER_GARBAGE)
|
||||||
|
for p in parts_lower
|
||||||
|
)
|
||||||
|
if not has_english and not has_garbage:
|
||||||
|
continue
|
||||||
|
translated = translate_temperament(original)
|
||||||
|
# None = nur Müll → auf NULL setzen; unterschiedlicher Text → übersetzen
|
||||||
|
if translated != original:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE wiki_rassen SET temperament=? WHERE id=?",
|
||||||
|
(translated, row["id"]), # None wird zu SQL NULL
|
||||||
|
)
|
||||||
|
updated += 1
|
||||||
|
logger.info("Temperament-Migration: %d Rassen übersetzt", updated)
|
||||||
|
return updated
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
import argparse
|
import argparse
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -200,6 +200,10 @@
|
||||||
<span data-page="impressum" style="cursor:pointer;text-decoration:underline">Impressum</span>
|
<span data-page="impressum" style="cursor:pointer;text-decoration:underline">Impressum</span>
|
||||||
<span data-page="datenschutz" style="cursor:pointer;text-decoration:underline">Datenschutz</span>
|
<span data-page="datenschutz" style="cursor:pointer;text-decoration:underline">Datenschutz</span>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- bot-trap: kein echter Nutzer klickt hier -->
|
||||||
|
<a href="/api/wiki/trap" aria-hidden="true" tabindex="-1"
|
||||||
|
style="position:absolute;width:1px;height:1px;opacity:0;pointer-events:none"
|
||||||
|
rel="nofollow noindex">.</a>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
Router, State-Management, Navigation, Initialisierung.
|
Router, State-Management, Navigation, Initialisierung.
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|
||||||
const APP_VER = '300'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
|
const APP_VER = '306'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
|
||||||
|
|
||||||
const App = (() => {
|
const App = (() => {
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -674,6 +674,18 @@ window.Page_admin = (() => {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="adm-sys-cards">Lade…</div>
|
<div id="adm-sys-cards">Lade…</div>
|
||||||
|
<div class="card" style="margin-top:var(--space-4);padding:var(--space-4)">
|
||||||
|
<div style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
|
||||||
|
color:var(--c-text);margin-bottom:var(--space-3)">Wartung</div>
|
||||||
|
<div style="display:flex;flex-wrap:wrap;gap:var(--space-2)">
|
||||||
|
<button class="btn btn-secondary btn-sm" id="adm-translate-temper">
|
||||||
|
${UI.icon('translate')} Temperament → Deutsch
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="adm-maint-result" style="margin-top:var(--space-2);font-size:var(--text-xs);
|
||||||
|
color:var(--c-text-secondary)"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div style="margin-top:var(--space-5)">
|
<div style="margin-top:var(--space-5)">
|
||||||
<div style="display:flex;align-items:center;gap:var(--space-3);margin-bottom:var(--space-3)">
|
<div style="display:flex;align-items:center;gap:var(--space-3);margin-bottom:var(--space-3)">
|
||||||
<span style="font-size:var(--text-sm);font-weight:600">Server-Logs</span>
|
<span style="font-size:var(--text-sm);font-weight:600">Server-Logs</span>
|
||||||
|
|
@ -712,6 +724,21 @@ window.Page_admin = (() => {
|
||||||
});
|
});
|
||||||
el.querySelector('#adm-log-refresh').addEventListener('click', loadLogs);
|
el.querySelector('#adm-log-refresh').addEventListener('click', loadLogs);
|
||||||
el.querySelector('#adm-log-level').addEventListener('change', loadLogs);
|
el.querySelector('#adm-log-level').addEventListener('change', loadLogs);
|
||||||
|
el.querySelector('#adm-translate-temper').addEventListener('click', async (e) => {
|
||||||
|
const btn = e.currentTarget;
|
||||||
|
const res = el.querySelector('#adm-maint-result');
|
||||||
|
btn.disabled = true;
|
||||||
|
res.textContent = 'Läuft…';
|
||||||
|
try {
|
||||||
|
const d = await API.post('/admin/wiki/translate-temperament', {});
|
||||||
|
res.textContent = `✓ ${d.updated} Rassen übersetzt`;
|
||||||
|
} catch (err) {
|
||||||
|
res.textContent = '✗ Fehler: ' + (err.message || err);
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
await _loadSystemCards(el.querySelector('#adm-sys-cards'));
|
await _loadSystemCards(el.querySelector('#adm-sys-cards'));
|
||||||
await loadLogs();
|
await loadLogs();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,10 @@
|
||||||
User-agent: *
|
User-agent: *
|
||||||
Allow: /
|
Allow: /
|
||||||
Allow: /info
|
Allow: /info
|
||||||
|
Allow: /wiki/rassen
|
||||||
|
Allow: /wiki/rasse/
|
||||||
Allow: /hund/
|
Allow: /hund/
|
||||||
Allow: /api/wiki/rassen
|
Disallow: /api/
|
||||||
Allow: /api/wiki/rassen/
|
|
||||||
Allow: /api/events
|
|
||||||
Allow: /api/knigge/articles
|
|
||||||
Allow: /api/movies/list
|
|
||||||
Allow: /api/forum/
|
|
||||||
Allow: /api/lost
|
|
||||||
Allow: /api/poison
|
|
||||||
Allow: /api/stats
|
|
||||||
Disallow: /api/auth/
|
|
||||||
Disallow: /api/admin/
|
|
||||||
Disallow: /api/dogs/
|
|
||||||
Disallow: /api/diary/
|
|
||||||
Disallow: /api/health/
|
|
||||||
Disallow: /api/chat/
|
|
||||||
Disallow: /api/friends/
|
|
||||||
Disallow: /api/push/
|
|
||||||
Disallow: /api/widget/
|
|
||||||
Disallow: /api/notifications/
|
|
||||||
Disallow: /api/alerts/
|
|
||||||
Disallow: /api/ki/
|
|
||||||
Disallow: /api/import/
|
|
||||||
Disallow: /api/sitting-access/
|
|
||||||
Disallow: /ausweis/
|
Disallow: /ausweis/
|
||||||
Disallow: /teilen/
|
Disallow: /teilen/
|
||||||
Disallow: /media/
|
Disallow: /media/
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
Offline-Cache + Push Notifications + Tile-Cache
|
Offline-Cache + Push Notifications + Tile-Cache
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|
||||||
const CACHE_VERSION = 'by-v312';
|
const CACHE_VERSION = 'by-v318';
|
||||||
const CACHE_STATIC = `${CACHE_VERSION}-static`;
|
const CACHE_STATIC = `${CACHE_VERSION}-static`;
|
||||||
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten
|
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue