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:
rene 2026-04-23 18:34:05 +02:00
parent 0f5f1c4c30
commit 15f854d96c
15 changed files with 284 additions and 53 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -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=?",

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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