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_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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -3,11 +3,13 @@ BAN YARO — FastAPI Hauptanwendung
|
|||
"""
|
||||
|
||||
import os
|
||||
import html
|
||||
import logging
|
||||
from collections import deque
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from database import init_db
|
||||
|
|
@ -61,6 +63,22 @@ app = FastAPI(
|
|||
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)
|
||||
|
|
@ -649,24 +667,25 @@ async def public_dog_page(dog_id: int):
|
|||
except Exception:
|
||||
pass
|
||||
|
||||
html = f"""<!DOCTYPE html>
|
||||
_s = html.escape # XSS-Schutz für OG-Meta-Tags
|
||||
_html = f"""<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{_og_name} — Ban Yaro</title>
|
||||
<meta name="description" content="{_og_desc}">
|
||||
<title>{_s(_og_name)} — Ban Yaro</title>
|
||||
<meta name="description" content="{_s(_og_desc)}">
|
||||
<meta name="robots" content="noindex">
|
||||
<meta property="og:type" content="profile">
|
||||
<meta property="og:title" content="{_og_name} — Ban Yaro">
|
||||
<meta property="og:description" content="{_og_desc}">
|
||||
<meta property="og:title" content="{_s(_og_name)} — Ban Yaro">
|
||||
<meta property="og:description" content="{_s(_og_desc)}">
|
||||
<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:site_name" content="Ban Yaro">
|
||||
<meta name="twitter:card" content="summary">
|
||||
<meta name="twitter:title" content="{_og_name} — Ban Yaro">
|
||||
<meta name="twitter:image" content="{_og_img}">
|
||||
<meta name="twitter:title" content="{_s(_og_name)} — Ban Yaro">
|
||||
<meta name="twitter:image" content="{_s(_og_img)}">
|
||||
<link rel="stylesheet" href="/css/design-system.css">
|
||||
<link rel="stylesheet" href="/css/components.css">
|
||||
<style>
|
||||
|
|
@ -947,7 +966,7 @@ async def public_dog_page(dog_id: int):
|
|||
</body>
|
||||
</html>"""
|
||||
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).
|
||||
Blocklist für Honeypot-Treffer.
|
||||
"""
|
||||
|
||||
import threading
|
||||
|
|
@ -10,6 +11,7 @@ from datetime import datetime, timedelta
|
|||
from fastapi import HTTPException, Request
|
||||
|
||||
_buckets: dict[str, deque] = defaultdict(deque)
|
||||
_blocklist: dict[str, datetime] = {} # ip → gesperrt bis
|
||||
_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').
|
||||
"""
|
||||
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}"
|
||||
now = datetime.utcnow()
|
||||
cutoff = now - timedelta(seconds=window_seconds)
|
||||
|
||||
with _lock:
|
||||
dq = _buckets[bucket_key]
|
||||
# Alte Einträge raus
|
||||
while dq and dq[0] < cutoff:
|
||||
dq.popleft()
|
||||
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."
|
||||
)
|
||||
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 = ?"
|
||||
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"""
|
||||
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.created_at, u.last_login,
|
||||
(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}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 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)
|
||||
# ------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import secrets
|
|||
import string
|
||||
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 database import db
|
||||
from auth import (
|
||||
|
|
@ -13,6 +13,7 @@ from auth import (
|
|||
get_current_user
|
||||
)
|
||||
from username_blocklist import is_username_blocked
|
||||
from ratelimit import check as rl_check
|
||||
|
||||
router = APIRouter()
|
||||
COOKIE_NAME = "by_token"
|
||||
|
|
@ -43,7 +44,8 @@ def _set_cookie(response: Response, token: str):
|
|||
|
||||
|
||||
@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()
|
||||
if len(name) < 2:
|
||||
raise HTTPException(400, "Benutzername muss mindestens 2 Zeichen lang sein.")
|
||||
|
|
@ -90,7 +92,8 @@ async def register(data: RegisterRequest, response: Response):
|
|||
|
||||
|
||||
@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:
|
||||
user = conn.execute(
|
||||
"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
|
||||
WHERE u.id != ?
|
||||
AND norm(u.name) LIKE norm(?)
|
||||
AND u.profil_sichtbarkeit != 'private'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM friendships f
|
||||
WHERE (f.requester_id=? AND f.addressee_id=u.id)
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
"""BAN YARO — KI Routes"""
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
import ki as ki_module
|
||||
from auth import get_current_user
|
||||
from ratelimit import check as rl_check
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
|
@ -14,11 +16,10 @@ class TrainingRequest(BaseModel):
|
|||
|
||||
|
||||
@router.post("/training")
|
||||
async def ki_training(req: TrainingRequest):
|
||||
"""
|
||||
KI-Trainingsberatung für individuelle Verhaltens- und Trainingsprobleme.
|
||||
Kostenlos für alle (nutzt lokales Modell).
|
||||
"""
|
||||
async def ki_training(req: TrainingRequest, request: Request,
|
||||
user=Depends(get_current_user)):
|
||||
"""KI-Trainingsberatung für individuelle Verhaltens- und Trainingsprobleme."""
|
||||
rl_check(request, max_requests=10, window_seconds=3600, key="ki_training")
|
||||
if not req.problem or len(req.problem.strip()) < 10:
|
||||
raise HTTPException(400, "Bitte beschreibe das Problem genauer.")
|
||||
if len(req.problem) > 1000:
|
||||
|
|
|
|||
|
|
@ -2,13 +2,14 @@
|
|||
|
||||
import os, uuid, math
|
||||
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 typing import Optional
|
||||
from database import db
|
||||
from auth import get_current_user
|
||||
from routes.push import send_push_to_all
|
||||
from media_utils import convert_media
|
||||
from ratelimit import check as rl_check
|
||||
|
||||
router = APIRouter()
|
||||
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)
|
||||
# ------------------------------------------------------------------
|
||||
@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()
|
||||
with db() as conn:
|
||||
conn.execute(
|
||||
|
|
|
|||
|
|
@ -4,10 +4,12 @@ import os
|
|||
import shutil
|
||||
import time
|
||||
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 database import db
|
||||
from auth import get_current_user, get_current_user_optional
|
||||
from ratelimit import check as rl_check, block_ip
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
|
||||
|
|
@ -17,6 +19,17 @@ SUBMIT_DIR = os.path.join(BREEDS_DIR, "submissions")
|
|||
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
|
||||
# ------------------------------------------------------------------
|
||||
|
|
@ -82,11 +95,13 @@ async def get_stats():
|
|||
# ------------------------------------------------------------------
|
||||
@router.get("/rassen")
|
||||
async def get_rassen(
|
||||
request: Request,
|
||||
search: str = Query(""),
|
||||
gruppe: str = Query(""),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
):
|
||||
rl_check(request, max_requests=60, window_seconds=60, key="wiki_list")
|
||||
conditions = []
|
||||
args = []
|
||||
|
||||
|
|
@ -131,7 +146,8 @@ async def get_rassen(
|
|||
# GET /api/wiki/rassen/{slug} — Rasse-Detail + Community-Berichte
|
||||
# ------------------------------------------------------------------
|
||||
@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:
|
||||
rasse = conn.execute(
|
||||
"SELECT * FROM wiki_rassen WHERE slug = ?", (rasse_slug,)
|
||||
|
|
|
|||
|
|
@ -20,7 +20,97 @@ from ki import complete, KIUnavailableError
|
|||
|
||||
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 = '''\
|
||||
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:
|
||||
{{
|
||||
"beschreibung": "3-5 Sätze über Charakter und Wesen der Rasse",
|
||||
"vorkommen_de": "1-2 Sätze wie verbreitet die Rasse in Deutschland/DACH ist",
|
||||
"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. Quelle: banyaro.app Hunde-Wiki.",
|
||||
"groesse": "klein|mittel|gross|sehr_gross",
|
||||
"gewicht_min_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()
|
||||
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
|
||||
|
||||
cols = ", ".join(f"{k}=?" for k in updates)
|
||||
|
|
@ -155,6 +248,41 @@ async def enrich_breeds(limit: int = 10) -> int:
|
|||
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__":
|
||||
import argparse
|
||||
|
||||
|
|
|
|||
|
|
@ -200,6 +200,10 @@
|
|||
<span data-page="impressum" style="cursor:pointer;text-decoration:underline">Impressum</span>
|
||||
<span data-page="datenschutz" style="cursor:pointer;text-decoration:underline">Datenschutz</span>
|
||||
</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>
|
||||
</nav>
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
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 = (() => {
|
||||
|
||||
|
|
|
|||
|
|
@ -674,6 +674,18 @@ window.Page_admin = (() => {
|
|||
</button>
|
||||
</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="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>
|
||||
|
|
@ -712,6 +724,21 @@ window.Page_admin = (() => {
|
|||
});
|
||||
el.querySelector('#adm-log-refresh').addEventListener('click', 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 loadLogs();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,30 +1,10 @@
|
|||
User-agent: *
|
||||
Allow: /
|
||||
Allow: /info
|
||||
Allow: /wiki/rassen
|
||||
Allow: /wiki/rasse/
|
||||
Allow: /hund/
|
||||
Allow: /api/wiki/rassen
|
||||
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: /api/
|
||||
Disallow: /ausweis/
|
||||
Disallow: /teilen/
|
||||
Disallow: /media/
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
Offline-Cache + Push Notifications + Tile-Cache
|
||||
============================================================ */
|
||||
|
||||
const CACHE_VERSION = 'by-v312';
|
||||
const CACHE_VERSION = 'by-v318';
|
||||
const CACHE_STATIC = `${CACHE_VERSION}-static`;
|
||||
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue