2521 lines
115 KiB
Python
2521 lines
115 KiB
Python
"""
|
||
BAN YARO — FastAPI Hauptanwendung
|
||
"""
|
||
|
||
import os
|
||
import html
|
||
import logging
|
||
from collections import deque
|
||
import httpx
|
||
from fastapi import FastAPI, Request
|
||
from fastapi.staticfiles import StaticFiles
|
||
from fastapi.responses import FileResponse, JSONResponse, Response
|
||
from starlette.middleware.base import BaseHTTPMiddleware
|
||
from fastapi.middleware.gzip import GZipMiddleware
|
||
from brotli_asgi import BrotliMiddleware
|
||
from contextlib import asynccontextmanager
|
||
|
||
from database import init_db
|
||
import ki
|
||
import scheduler as sched
|
||
|
||
# In-Memory Log-Buffer (letzte 500 Zeilen)
|
||
log_buffer: deque = deque(maxlen=500)
|
||
|
||
class _BufferHandler(logging.Handler):
|
||
_fmt = logging.Formatter()
|
||
|
||
def emit(self, record):
|
||
log_buffer.append({
|
||
't': self._fmt.formatTime(record, '%H:%M:%S'),
|
||
'l': record.levelname,
|
||
'm': record.getMessage(),
|
||
'n': record.name,
|
||
})
|
||
|
||
logging.basicConfig(
|
||
level = logging.INFO,
|
||
format = "%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||
)
|
||
logging.getLogger().addHandler(_BufferHandler())
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
# ------------------------------------------------------------------
|
||
# Startup / Shutdown
|
||
# ------------------------------------------------------------------
|
||
def _backfill_image_sizes():
|
||
"""Füllt img_width/img_height für alle diary_media-Bilder ohne Maße nach."""
|
||
import io
|
||
from database import db
|
||
from media_utils import get_image_size
|
||
MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
|
||
with db() as conn:
|
||
rows = conn.execute(
|
||
"SELECT id, url FROM diary_media WHERE media_type='image' AND img_width IS NULL"
|
||
).fetchall()
|
||
if not rows:
|
||
return
|
||
logger.info("Backfill Bildmaße: %d Einträge...", len(rows))
|
||
updated = 0
|
||
for row in rows:
|
||
# url ist z.B. /media/diary/xxx.jpg → Pfad: MEDIA_DIR/diary/xxx.jpg
|
||
rel = row["url"].removeprefix("/media/")
|
||
path = os.path.join(MEDIA_DIR, rel)
|
||
try:
|
||
with open(path, "rb") as f:
|
||
data = f.read()
|
||
size = get_image_size(data)
|
||
if size:
|
||
with db() as conn:
|
||
conn.execute(
|
||
"UPDATE diary_media SET img_width=?, img_height=? WHERE id=?",
|
||
(size[0], size[1], row["id"])
|
||
)
|
||
updated += 1
|
||
except Exception:
|
||
pass
|
||
logger.info("Backfill Bildmaße abgeschlossen: %d/%d aktualisiert.", updated, len(rows))
|
||
@asynccontextmanager
|
||
async def lifespan(app: FastAPI):
|
||
logger.info("Ban Yaro startet...")
|
||
init_db()
|
||
_backfill_image_sizes()
|
||
from routes.movies import seed_movies
|
||
seed_movies()
|
||
logger.info(f"KI-Modus: {ki.KI_MODE}")
|
||
sched.start()
|
||
yield
|
||
sched.stop()
|
||
logger.info("Ban Yaro beendet.")
|
||
|
||
|
||
# ------------------------------------------------------------------
|
||
# App
|
||
# ------------------------------------------------------------------
|
||
app = FastAPI(
|
||
title = "Ban Yaro API",
|
||
version = "0.1.0",
|
||
lifespan = lifespan,
|
||
docs_url = "/api/docs" if os.getenv("ENV") != "production" else None,
|
||
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' https://umami.motocamp.de; " # ohne unsafe-inline/eval — alle Inline-Scripts extrahiert
|
||
"style-src 'self' 'unsafe-inline'; " # Inline-Styles bleiben (zu viele Fundstellen für jetzt)
|
||
"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)
|
||
|
||
|
||
class _SlidingTokenRefreshMiddleware(BaseHTTPMiddleware):
|
||
"""Sliding-Session: wenn get_current_user ein neues Token vorbereitet hat
|
||
(request.state.refresh_token), schreiben wir es als HttpOnly-Cookie zurück.
|
||
So bleibt der User eingeloggt solange er aktiv ist, ohne langlaufendes Token."""
|
||
async def dispatch(self, request: Request, call_next):
|
||
response = await call_next(request)
|
||
new_token = getattr(request.state, "refresh_token", None)
|
||
if new_token:
|
||
from auth import JWT_EXPIRY as _JWT_EXPIRY
|
||
response.set_cookie(
|
||
key="by_token",
|
||
value=new_token,
|
||
httponly=True,
|
||
secure=True,
|
||
samesite="lax",
|
||
max_age=_JWT_EXPIRY * 24 * 3600,
|
||
)
|
||
return response
|
||
|
||
app.add_middleware(_SlidingTokenRefreshMiddleware)
|
||
|
||
|
||
# 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)
|
||
|
||
|
||
class _AppVersionMiddleware(BaseHTTPMiddleware):
|
||
"""Fügt X-App-Version zu allen /api/-Antworten hinzu.
|
||
api.js erkennt damit sofort wenn eine neue Version deployed wurde
|
||
und lädt beim nächsten Seitenwechsel automatisch neu — kein Banner nötig.
|
||
"""
|
||
async def dispatch(self, request: Request, call_next):
|
||
response = await call_next(request)
|
||
if request.url.path.startswith('/api/'):
|
||
response.headers['X-App-Version'] = APP_VER
|
||
return response
|
||
|
||
app.add_middleware(_AppVersionMiddleware)
|
||
|
||
|
||
class _CacheControlMiddleware(BaseHTTPMiddleware):
|
||
"""Setzt Cache-Control-Header für statische Assets.
|
||
JS/CSS: immer no-cache — SW übernimmt Caching. Immutable wäre gefährlich,
|
||
weil Browser-HTTP-Cache nach force-update nicht geleert wird und veraltete
|
||
app.js mit falschem APP_VER eine Update-Dauerschleife verursacht.
|
||
"""
|
||
async def dispatch(self, request: Request, call_next):
|
||
response = await call_next(request)
|
||
path = request.url.path
|
||
if path.startswith(("/css/", "/js/", "/icons/phosphor.svg")):
|
||
response.headers["Cache-Control"] = "no-cache, must-revalidate"
|
||
return response
|
||
|
||
app.add_middleware(_CacheControlMiddleware)
|
||
|
||
|
||
class MediaCacheMiddleware(BaseHTTPMiddleware):
|
||
"""Setzt aggressive Cache-Header für /media/-Requests.
|
||
UUID-basierte Dateinamen ändern sich nie → immutable caching.
|
||
"""
|
||
async def dispatch(self, request: Request, call_next):
|
||
response = await call_next(request)
|
||
if request.url.path.startswith('/media/'):
|
||
if os.getenv('STAGING') == 'true':
|
||
response.headers['Cache-Control'] = 'no-cache'
|
||
else:
|
||
response.headers['Cache-Control'] = 'public, max-age=31536000, immutable'
|
||
return response
|
||
|
||
app.add_middleware(MediaCacheMiddleware)
|
||
app.add_middleware(BrotliMiddleware, minimum_size=1000, quality=4)
|
||
app.add_middleware(GZipMiddleware, minimum_size=1000)
|
||
|
||
|
||
# ------------------------------------------------------------------
|
||
# API-Router registrieren (werden nach und nach hinzugefügt)
|
||
# ------------------------------------------------------------------
|
||
from routes.auth import router as auth_router
|
||
from routes.dogs import router as dogs_router
|
||
from routes.diary import router as diary_router
|
||
from routes.health import router as health_router
|
||
from routes.poison import router as poison_router
|
||
from routes.push import router as push_router
|
||
from routes.ki import router as ki_router
|
||
from routes.tieraerzte import router as tieraerzte_router
|
||
from routes.places import router as places_router
|
||
from routes.routen import router as routen_router
|
||
from routes.walks import router as walks_router
|
||
from routes.events import router as events_router
|
||
from routes.sitting import router as sitting_router
|
||
from routes.osm import router as osm_router
|
||
from routes.forum import router as forum_router
|
||
from routes.lost import router as lost_router
|
||
from routes.knigge import router as knigge_router
|
||
from routes.wiki import router as wiki_router
|
||
from routes.movies import router as movies_router
|
||
from routes.friends import router as friends_router
|
||
from routes.chat import router as chat_router
|
||
from routes.admin import router as admin_router
|
||
from routes.webcal import router as webcal_router
|
||
from routes.profile import router as profile_router
|
||
from routes.import_data import router as import_router
|
||
from routes.sharing import dog_router as sharing_dog_router, share_router as sharing_share_router
|
||
from routes.widget import router as widget_router
|
||
from routes.notifications import router as notifications_router
|
||
from routes.alerts import router as alerts_router
|
||
from routes.services import router as services_router
|
||
from routes.ratings import router as ratings_router
|
||
from routes.sitting_access import router as sitting_access_router
|
||
from routes.stats import router as stats_router
|
||
from routes.achievements import router as achievements_router
|
||
from routes.training import router as training_router
|
||
from routes.praise import router as praise_router
|
||
from routes.weather import router as weather_router
|
||
from routes.social import router as social_router
|
||
from routes.moderation import router as moderation_router
|
||
from routes.notes import router as notes_router
|
||
from routes.breeder import router as breeder_router
|
||
from routes.litters import router as litters_router
|
||
from routes.laeufi import router as laeufi_router
|
||
from routes.breeder_photos import router as breeder_photos_router
|
||
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
|
||
from routes.jobs import router as jobs_router
|
||
from routes.streak import router as streak_router
|
||
from routes.expenses import router as expenses_router
|
||
from routes.recalls import router as recalls_router
|
||
from routes.adoption import router as adoption_router
|
||
from routes.health_docs import router as health_docs_router
|
||
from routes.passport import router as passport_router
|
||
from routes.playdate import router as playdate_router
|
||
from routes.ernaehrung import router as ernaehrung_router
|
||
from routes.challenges import router as challenges_router
|
||
from routes.gassi_zeiten import router as gassi_zeiten_router
|
||
from routes.help import router as help_router
|
||
from routes.feedback import router as feedback_router
|
||
from routes.contact import router as contact_router
|
||
from routes.invoices import router as invoices_router
|
||
|
||
app.include_router(auth_router, prefix="/api/auth", tags=["Auth"])
|
||
app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"])
|
||
app.include_router(diary_router, prefix="/api/dogs", tags=["Tagebuch"])
|
||
app.include_router(health_router, prefix="/api/dogs", tags=["Gesundheit"])
|
||
app.include_router(poison_router, prefix="/api/poison", tags=["Giftköder"])
|
||
app.include_router(push_router, prefix="/api/push", tags=["Push"])
|
||
app.include_router(ki_router, prefix="/api/ki", tags=["KI"])
|
||
app.include_router(tieraerzte_router, prefix="/api/tieraerzte", tags=["Tierärzte"])
|
||
app.include_router(places_router, prefix="/api/places", tags=["Orte"])
|
||
app.include_router(routen_router, prefix="/api/routes", tags=["Routen"])
|
||
app.include_router(walks_router, prefix="/api/walks", tags=["Gassi-Treffen"])
|
||
app.include_router(events_router, prefix="/api/events", tags=["Events"])
|
||
app.include_router(sitting_router, prefix="/api/sitting", tags=["Sitting"])
|
||
app.include_router(osm_router, prefix="/api/osm", tags=["OSM"])
|
||
app.include_router(weather_router, prefix="/api/weather", tags=["Wetter"])
|
||
app.include_router(social_router, prefix="/api/social", tags=["Social"])
|
||
app.include_router(forum_router, prefix="/api/forum", tags=["Forum"])
|
||
app.include_router(lost_router, prefix="/api/lost", tags=["Verlorener Hund"])
|
||
app.include_router(knigge_router, prefix="/api/knigge", tags=["Knigge"])
|
||
app.include_router(wiki_router, prefix="/api/wiki", tags=["Wiki"])
|
||
app.include_router(movies_router, prefix="/api/movies", tags=["Filme"])
|
||
app.include_router(friends_router, prefix="/api/friends", tags=["Freunde"])
|
||
app.include_router(chat_router, prefix="/api/chat", tags=["Chat"])
|
||
app.include_router(admin_router, prefix="/api/admin", tags=["Admin"])
|
||
app.include_router(breeder_router, prefix="/api", tags=["Züchter"])
|
||
app.include_router(litters_router, prefix="/api", tags=["Würfe"])
|
||
app.include_router(laeufi_router, prefix="/api", tags=["Läufigkeit"])
|
||
app.include_router(breeder_photos_router, prefix="/api", tags=["Züchter-Fotos"])
|
||
app.include_router(zucht_hunde_router, prefix="/api", tags=["Zuchtkartei"])
|
||
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(jobs_router, prefix="/api/jobs", tags=["Jobs"])
|
||
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"])
|
||
app.include_router(sharing_dog_router, prefix="/api/dogs", tags=["Teilen"])
|
||
app.include_router(sharing_share_router, prefix="/api/share", tags=["Teilen"])
|
||
app.include_router(widget_router, prefix="/api/widget", tags=["Widget"])
|
||
app.include_router(notifications_router, prefix="/api/notifications", tags=["Notifications"])
|
||
app.include_router(alerts_router, prefix="/api/alerts", tags=["Alerts"])
|
||
app.include_router(services_router, prefix="/api/services", tags=["Services"])
|
||
app.include_router(ratings_router, prefix="/api/ratings", tags=["Ratings"])
|
||
app.include_router(sitting_access_router, prefix="/api/sitting-access", tags=["SittingAccess"])
|
||
app.include_router(stats_router, prefix="/api/stats", tags=["Stats"])
|
||
app.include_router(achievements_router, prefix="/api/achievements", tags=["Achievements"])
|
||
app.include_router(training_router, prefix="/api/training", tags=["Training"])
|
||
app.include_router(praise_router, prefix="/api/praise", tags=["Praise"])
|
||
app.include_router(moderation_router, prefix="/api/moderation", tags=["Moderation"])
|
||
app.include_router(notes_router, prefix="/api/notes", tags=["Notes"])
|
||
app.include_router(streak_router, prefix="/api", tags=["Streak"])
|
||
app.include_router(expenses_router, prefix="/api/expenses", tags=["Ausgaben"])
|
||
app.include_router(recalls_router, prefix="/api/recalls", tags=["Rückrufe"])
|
||
app.include_router(adoption_router, prefix="/api/adoption", tags=["Adoption"])
|
||
app.include_router(health_docs_router, prefix="/api/health-docs", tags=["Gesundheitsdokumente"])
|
||
app.include_router(passport_router, prefix="/api/passport", tags=["Hundepass"])
|
||
app.include_router(playdate_router, prefix="/api/playdate", tags=["Playdate"])
|
||
app.include_router(ernaehrung_router, prefix="/api/dogs", tags=["Ernährung"])
|
||
app.include_router(challenges_router, prefix="/api/challenges", tags=["Foto-Challenge"])
|
||
app.include_router(gassi_zeiten_router, prefix="/api/gassi-zeiten", tags=["Gassi-Zeiten"])
|
||
app.include_router(help_router, prefix="/api/help", tags=["Hilfe/FAQ"])
|
||
app.include_router(feedback_router, prefix="/api/feedback", tags=["Feedback"])
|
||
app.include_router(contact_router, prefix="/api/contact", tags=["Kontakt"])
|
||
app.include_router(invoices_router)
|
||
|
||
|
||
# ------------------------------------------------------------------
|
||
# Fehlerbehandlung — einheitliches JSON-Format
|
||
# ------------------------------------------------------------------
|
||
@app.exception_handler(Exception)
|
||
async def global_exception_handler(request: Request, exc: Exception):
|
||
logger.error(f"Unbehandelter Fehler: {exc}", exc_info=True)
|
||
return JSONResponse(
|
||
status_code=500,
|
||
content={"detail": "Interner Serverfehler."}
|
||
)
|
||
|
||
|
||
# ------------------------------------------------------------------
|
||
# Statische Dateien + SPA-Fallback
|
||
# ------------------------------------------------------------------
|
||
STATIC_DIR = os.path.join(os.path.dirname(__file__), "static")
|
||
|
||
app.mount("/css", StaticFiles(directory=f"{STATIC_DIR}/css"), name="css")
|
||
app.mount("/js", StaticFiles(directory=f"{STATIC_DIR}/js"), name="js")
|
||
app.mount("/icons", StaticFiles(directory=f"{STATIC_DIR}/icons"), name="icons")
|
||
app.mount("/img", StaticFiles(directory=f"{STATIC_DIR}/img"), name="img")
|
||
|
||
# User-generierte Medien (Fotos aus Tagebuch, Giftköder-Alarm, etc.)
|
||
MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
|
||
os.makedirs(MEDIA_DIR, exist_ok=True)
|
||
|
||
STAGING = os.getenv("STAGING", "false").lower() == "true"
|
||
PROD_MEDIA_DIR = "/prod-media"
|
||
|
||
_MIME_MAP = {
|
||
".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png",
|
||
".webp": "image/webp", ".gif": "image/gif", ".mp4": "video/mp4",
|
||
".webm": "video/webm", ".pdf": "application/pdf",
|
||
}
|
||
|
||
from fastapi import Request as _Request
|
||
from fastapi.responses import FileResponse as _FileResponse
|
||
from auth import decode_token as _decode_token
|
||
|
||
# Pfade die Login erfordern (kein DB-Lookup — UUID in Dateiname schützt ausreichend)
|
||
_AUTH_REQUIRED = ("diary/", "health/", "walks/")
|
||
|
||
|
||
def _is_logged_in(request: _Request) -> bool:
|
||
token = request.cookies.get("by_token")
|
||
if not token:
|
||
return False
|
||
try:
|
||
_decode_token(token)
|
||
return True
|
||
except Exception:
|
||
return False
|
||
|
||
|
||
def _media_response(filepath: str):
|
||
ext = os.path.splitext(filepath)[1].lower()
|
||
mt = _MIME_MAP.get(ext, "application/octet-stream")
|
||
return _FileResponse(filepath, media_type=mt,
|
||
headers={"Cache-Control": "private, max-age=3600"})
|
||
|
||
|
||
def _resolve_media_path(path: str) -> str | None:
|
||
primary = os.path.join(MEDIA_DIR, path)
|
||
if os.path.isfile(primary):
|
||
return primary
|
||
if STAGING and os.path.isdir(PROD_MEDIA_DIR):
|
||
fallback = os.path.join(PROD_MEDIA_DIR, path)
|
||
if os.path.isfile(fallback):
|
||
return fallback
|
||
return None
|
||
|
||
|
||
@app.api_route("/media/{path:path}", methods=["GET", "HEAD"])
|
||
async def serve_media(path: str, request: _Request):
|
||
from fastapi import HTTPException as _HE
|
||
|
||
prefix = path.split("/")[0] + "/"
|
||
|
||
# Sensible Pfade: Login erforderlich — UUID-basierte Dateinamen verhindern Raten
|
||
if prefix in _AUTH_REQUIRED and not _is_logged_in(request):
|
||
raise _HE(401, "Anmeldung erforderlich.")
|
||
|
||
filepath = _resolve_media_path(path)
|
||
if not filepath:
|
||
raise _HE(404, "Nicht gefunden.")
|
||
return _media_response(filepath)
|
||
|
||
# APP_VER wird zentral aus der VERSION-Datei im Projekt-Root gelesen.
|
||
# Bumpe ausschliesslich via `make bump` — bumpt VERSION + sw.js + app.js + index.html atomar.
|
||
def _read_app_ver() -> str:
|
||
from pathlib import Path
|
||
candidates = [
|
||
Path(__file__).resolve().parent.parent / "VERSION", # Projekt-Root (lokal/dev)
|
||
Path("/app/VERSION"), # Container-Layout
|
||
Path("/data/VERSION"), # falls als Volume gemountet
|
||
]
|
||
for p in candidates:
|
||
try:
|
||
if p.is_file():
|
||
txt = p.read_text(encoding="utf-8").strip()
|
||
if txt:
|
||
return txt
|
||
except Exception:
|
||
pass
|
||
return "0"
|
||
|
||
APP_VER = _read_app_ver() # muss mit APP_VER in app.js übereinstimmen (siehe VERSION + `make bump`)
|
||
|
||
@app.get("/.well-known/assetlinks.json")
|
||
async def assetlinks():
|
||
"""TWA-Verifikation für Google Play Store (app.banyaro.twa)."""
|
||
return Response(
|
||
content='[{"relation":["delegate_permission/common.handle_all_urls"],"target":{"namespace":"android_app","package_name":"app.banyaro.twa","sha256_cert_fingerprints":["49:02:DC:5B:63:C0:D7:42:7F:A4:DC:2F:EB:78:73:11:CC:B9:36:22:00:01:A0:03:1C:0A:F9:41:35:9F:D4:B7"]}}]',
|
||
media_type="application/json",
|
||
headers={"Cache-Control": "no-cache"},
|
||
)
|
||
|
||
@app.get("/api/version")
|
||
async def app_version():
|
||
"""Aktuelle Frontend-Version — wird beim App-Start gecheckt."""
|
||
return Response(
|
||
content=f'{{"version":"{APP_VER}"}}',
|
||
media_type="application/json",
|
||
headers={"Cache-Control": "no-store"},
|
||
)
|
||
|
||
|
||
@app.get("/stats/script.js")
|
||
async def umami_script_proxy():
|
||
async with httpx.AsyncClient(timeout=10) as client:
|
||
r = await client.get("https://umami.motocamp.de/script.js")
|
||
return Response(content=r.content, media_type="application/javascript",
|
||
headers={"Cache-Control": "public, max-age=86400"})
|
||
|
||
@app.post("/stats/api/send")
|
||
async def umami_send_proxy(request: Request):
|
||
body = await request.body()
|
||
async with httpx.AsyncClient(timeout=10) as client:
|
||
r = await client.post(
|
||
"https://umami.motocamp.de/api/send",
|
||
content=body,
|
||
headers={"Content-Type": "application/json",
|
||
"User-Agent": request.headers.get("user-agent", "")},
|
||
)
|
||
return Response(content=r.content, status_code=r.status_code,
|
||
media_type="application/json")
|
||
|
||
|
||
@app.get("/robots.txt")
|
||
async def robots():
|
||
return FileResponse(f"{STATIC_DIR}/robots.txt", media_type="text/plain")
|
||
|
||
@app.get("/llms.txt")
|
||
async def llms():
|
||
return FileResponse(f"{STATIC_DIR}/llms.txt", media_type="text/plain")
|
||
|
||
@app.get("/sitemap.xml")
|
||
async def sitemap():
|
||
from fastapi.responses import Response
|
||
from database import db as _db
|
||
from datetime import date
|
||
|
||
today = date.today().isoformat()
|
||
urls = [
|
||
("https://banyaro.app/", "weekly", "1.0"),
|
||
("https://banyaro.app/zuechter", "weekly", "0.9"),
|
||
("https://banyaro.app/wurfboerse", "daily", "0.8"),
|
||
("https://banyaro.app/wiki/rassen", "weekly", "0.8"),
|
||
("https://banyaro.app/help", "monthly", "0.7"),
|
||
("https://banyaro.app/knigge", "monthly", "0.7"),
|
||
("https://banyaro.app/partner", "monthly", "0.6"),
|
||
]
|
||
|
||
try:
|
||
with _db() as conn:
|
||
rassen = conn.execute(
|
||
"SELECT slug FROM wiki_rassen WHERE slug IS NOT NULL AND slug != '' LIMIT 500"
|
||
).fetchall()
|
||
for r in rassen:
|
||
urls.append((f"https://banyaro.app/wiki/rasse/{r['slug']}", "monthly", "0.7"))
|
||
|
||
# Öffentliche Züchter-Profile
|
||
breeders = conn.execute(
|
||
"SELECT bp.zwingername FROM breeder_profiles bp "
|
||
"JOIN users u ON u.id = bp.user_id "
|
||
"WHERE bp.verified_at IS NOT NULL AND u.rolle = 'breeder'"
|
||
).fetchall()
|
||
for b in breeders:
|
||
if b["zwingername"]:
|
||
from urllib.parse import quote
|
||
urls.append((
|
||
f"https://banyaro.app/breeder/{quote(b['zwingername'])}",
|
||
"weekly", "0.7"
|
||
))
|
||
except Exception:
|
||
pass
|
||
|
||
entries = "\n".join(
|
||
f""" <url>
|
||
<loc>{loc}</loc>
|
||
<lastmod>{today}</lastmod>
|
||
<changefreq>{freq}</changefreq>
|
||
<priority>{prio}</priority>
|
||
</url>"""
|
||
for loc, freq, prio in urls
|
||
)
|
||
|
||
xml = f"""<?xml version="1.0" encoding="UTF-8"?>
|
||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||
{entries}
|
||
</urlset>"""
|
||
return Response(content=xml, media_type="application/xml")
|
||
|
||
@app.get("/info")
|
||
async def info_page():
|
||
return FileResponse(f"{STATIC_DIR}/landing.html", headers={"Cache-Control": "max-age=3600"})
|
||
|
||
|
||
@app.get("/zuechter")
|
||
async def zuechter_landing(request: _Request):
|
||
from fastapi.responses import RedirectResponse as _RR
|
||
if os.getenv("STAGING") == "true":
|
||
return _RR("https://banyaro.app/zuechter", status_code=301)
|
||
return FileResponse(f"{STATIC_DIR}/zuechter.html", headers={"Cache-Control": "max-age=3600"})
|
||
|
||
|
||
# ------------------------------------------------------------------
|
||
# SEO: Server-gerenderete Wiki-Rassen-Übersicht /wiki/rassen
|
||
# ------------------------------------------------------------------
|
||
@app.get("/wiki/rassen")
|
||
async def wiki_rassen_page():
|
||
from fastapi.responses import HTMLResponse
|
||
from database import db as _db
|
||
|
||
with _db() as conn:
|
||
rows = conn.execute(
|
||
"""SELECT name, slug, gruppe, groesse, aktivitaet, foto_url, kinder_geeignet, wohnung_geeignet
|
||
FROM wiki_rassen WHERE slug IS NOT NULL
|
||
ORDER BY name ASC"""
|
||
).fetchall()
|
||
|
||
rassen = [dict(r) for r in rows]
|
||
total = len(rassen)
|
||
|
||
groessen_map = {"klein": "Klein", "mittel": "Mittel", "gross": "Groß", "sehr_gross": "Sehr groß"}
|
||
aktivitaet_map = {"niedrig": "Niedrig", "mittel": "Mittel", "hoch": "Hoch", "sehr_hoch": "Sehr hoch"}
|
||
|
||
cards = ""
|
||
for r in rassen:
|
||
foto = r["foto_url"] or ""
|
||
img = f'<img src="{foto}" alt="{r["name"]}" loading="lazy">' if foto else '<div class="breed-placeholder">🐕</div>'
|
||
groesse = groessen_map.get(r.get("groesse") or "", r.get("groesse") or "")
|
||
kinder = "✓ Kinder" if r.get("kinder_geeignet") else ""
|
||
wohnung = "✓ Wohnung" if r.get("wohnung_geeignet") else ""
|
||
tags = " ".join(f'<span class="tag">{t}</span>' for t in [groesse, kinder, wohnung] if t)
|
||
cards += f"""<a href="/wiki/rasse/{r['slug']}" class="breed-card">
|
||
{img}
|
||
<div class="breed-info">
|
||
<h2>{r['name']}</h2>
|
||
<p class="gruppe">{r.get('gruppe') or ''}</p>
|
||
<div class="tags">{tags}</div>
|
||
</div>
|
||
</a>\n"""
|
||
|
||
html = f"""<!DOCTYPE html>
|
||
<html lang="de">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Hunderassen-Wiki — {total} Rassen im Überblick | Ban Yaro</title>
|
||
<meta name="description" content="Alle {total} Hunderassen im Überblick: Charakter, Größe, Aktivität, Eignung für Familien und Wohnungen. Das Hunderassen-Wiki von Ban Yaro.">
|
||
<meta name="robots" content="index, follow">
|
||
<link rel="canonical" href="https://banyaro.app/wiki/rassen">
|
||
<meta property="og:title" content="Hunderassen-Wiki — {total} Rassen | Ban Yaro">
|
||
<meta property="og:description" content="Alle {total} Hunderassen im Überblick: Charakter, Größe, Aktivität, Eignung für Familien und Wohnungen.">
|
||
<meta property="og:url" content="https://banyaro.app/wiki/rassen">
|
||
<meta property="og:image" content="https://banyaro.app/icons/icon-512.png">
|
||
<meta property="og:locale" content="de_DE">
|
||
<script type="application/ld+json">
|
||
{{"@context":"https://schema.org","@type":"CollectionPage","name":"Hunderassen-Wiki","description":"Übersicht über {total} Hunderassen mit Charakter, Größe, Aktivität und Eignungsprofil.","url":"https://banyaro.app/wiki/rassen","publisher":{{"@type":"Organization","name":"Ban Yaro","url":"https://banyaro.app"}}}}
|
||
</script>
|
||
<style>
|
||
*,*::before,*::after{{box-sizing:border-box;margin:0;padding:0}}
|
||
body{{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#FAF7F2;color:#1a1a1a}}
|
||
header{{background:linear-gradient(135deg,#C4843A,#e8a857);color:#fff;padding:2rem 1.5rem;text-align:center}}
|
||
header h1{{font-size:clamp(1.4rem,4vw,2rem);font-weight:700;margin-bottom:.4rem}}
|
||
header p{{opacity:.9;font-size:.95rem}}
|
||
nav{{background:#fff;border-bottom:1px solid #e8ddd0;padding:.6rem 1.5rem;display:flex;gap:1rem;align-items:center;flex-wrap:wrap}}
|
||
nav a{{color:#555;text-decoration:none;font-size:.9rem;font-weight:500}}
|
||
nav a:hover{{color:#C4843A}}
|
||
nav .brand{{font-weight:800;margin-right:auto;color:#C4843A}}
|
||
.container{{max-width:1100px;margin:0 auto;padding:2rem 1.5rem}}
|
||
.grid{{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:1.25rem}}
|
||
.breed-card{{background:#fff;border:1px solid #e8ddd0;border-radius:12px;overflow:hidden;text-decoration:none;color:#1a1a1a;transition:box-shadow .15s,transform .15s;display:flex;flex-direction:column}}
|
||
.breed-card:hover{{box-shadow:0 4px 20px rgba(0,0,0,.1);transform:translateY(-2px)}}
|
||
.breed-card img{{width:100%;height:140px;object-fit:cover}}
|
||
.breed-placeholder{{width:100%;height:140px;background:#f0e8dc;display:flex;align-items:center;justify-content:center;font-size:3rem}}
|
||
.breed-info{{padding:.85rem 1rem;flex:1}}
|
||
.breed-info h2{{font-size:.95rem;font-weight:700;margin-bottom:.2rem}}
|
||
.gruppe{{font-size:.78rem;color:#888;margin-bottom:.4rem}}
|
||
.tags{{display:flex;flex-wrap:wrap;gap:.3rem}}
|
||
.tag{{background:#f5e6d3;color:#a86e2e;font-size:.7rem;font-weight:600;padding:.15rem .5rem;border-radius:999px}}
|
||
footer{{background:#1a1a1a;color:#aaa;text-align:center;padding:1.5rem;font-size:.82rem;margin-top:3rem}}
|
||
footer a{{color:#C4843A}}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<header>
|
||
<h1>Hunderassen-Wiki</h1>
|
||
<p>{total} Rassen — Charakter, Eignung, Pflege auf einen Blick</p>
|
||
</header>
|
||
<nav>
|
||
<span class="brand">Ban Yaro</span>
|
||
<a href="/info">Über die App</a>
|
||
<a href="/knigge">Knigge</a>
|
||
<a href="/" style="background:#C4843A;color:#fff;padding:.35rem 1rem;border-radius:999px;font-weight:700">App öffnen</a>
|
||
</nav>
|
||
<div class="container">
|
||
<div class="grid">
|
||
{cards}
|
||
</div>
|
||
</div>
|
||
<footer>
|
||
<strong style="color:#fff">Ban Yaro</strong> — Die Hunde-Plattform · <a href="https://banyaro.app">banyaro.app</a>
|
||
</footer>
|
||
</body>
|
||
</html>"""
|
||
return HTMLResponse(content=html, headers={"Cache-Control": "max-age=3600"})
|
||
|
||
|
||
# ------------------------------------------------------------------
|
||
# SEO: Server-gerenderete Wiki-Rassen-Detailseite /wiki/rasse/{slug}
|
||
# ------------------------------------------------------------------
|
||
@app.get("/wiki/rasse/{slug}")
|
||
async def wiki_rasse_page(slug: str):
|
||
from fastapi.responses import HTMLResponse
|
||
from database import db as _db
|
||
|
||
def esc(s):
|
||
if not s: return ""
|
||
return str(s).replace("&","&").replace("<","<").replace(">",">").replace('"',""")
|
||
|
||
with _db() as conn:
|
||
rasse = conn.execute("SELECT * FROM wiki_rassen WHERE slug=?", (slug,)).fetchone()
|
||
if not rasse:
|
||
return HTMLResponse("<html><body><h1>Rasse nicht gefunden</h1><a href='/wiki/rassen'>Alle Rassen</a></body></html>", status_code=404)
|
||
|
||
berichte = conn.execute(
|
||
"""SELECT wb.titel, wb.text, wb.created_at, u.name AS autor
|
||
FROM wiki_berichte wb JOIN users u ON u.id=wb.user_id
|
||
WHERE wb.rasse=? ORDER BY wb.created_at DESC LIMIT 20""",
|
||
(slug,)
|
||
).fetchall()
|
||
|
||
r = dict(rasse)
|
||
|
||
try:
|
||
dogs_count = conn.execute(
|
||
"SELECT COUNT(DISTINCT d.user_id) FROM dogs d WHERE LOWER(d.rasse) LIKE ?",
|
||
(f"%{r.get('name','').lower()}%",)
|
||
).fetchone()[0]
|
||
except Exception:
|
||
dogs_count = 0
|
||
|
||
try:
|
||
zuchter_count = conn.execute(
|
||
"SELECT COUNT(*) FROM wiki_zuchter WHERE rasse_slug=? AND verified=1",
|
||
(slug,)
|
||
).fetchone()[0]
|
||
except Exception:
|
||
zuchter_count = 0
|
||
|
||
berichte = [dict(b) for b in berichte]
|
||
berichte_count = len(berichte)
|
||
|
||
groessen_map = {"klein":"Klein","mittel":"Mittel","gross":"Groß","sehr_gross":"Sehr groß"}
|
||
aktivitaet_map = {"niedrig":"Niedrig","mittel":"Mittel","hoch":"Hoch","sehr_hoch":"Sehr hoch"}
|
||
erfahrung_map = {"anfaenger":"Anfänger geeignet","fortgeschritten":"Für Erfahrene","experte":"Nur für Experten"}
|
||
|
||
groesse = groessen_map.get(r.get("groesse") or "", r.get("groesse") or "–")
|
||
aktivitaet = aktivitaet_map.get(r.get("aktivitaet") or "", r.get("aktivitaet") or "–")
|
||
erfahrung = erfahrung_map.get(r.get("erfahrung") or "", r.get("erfahrung") or "–")
|
||
kinder = "Ja" if r.get("kinder_geeignet") else "Bedingt"
|
||
wohnung = "Ja" if r.get("wohnung_geeignet") else "Besser Haus mit Garten"
|
||
|
||
gewicht = ""
|
||
if r.get("gewicht_min_kg") and r.get("gewicht_max_kg"):
|
||
gewicht = f"{r['gewicht_min_kg']}–{r['gewicht_max_kg']} kg"
|
||
elif r.get("gewicht_max_kg"):
|
||
gewicht = f"bis {r['gewicht_max_kg']} kg"
|
||
|
||
foto_html = f'<img src="{esc(r["foto_url"])}" alt="{esc(r["name"])}" class="breed-photo">' if r.get("foto_url") else '<div class="breed-photo-placeholder">🐕</div>'
|
||
|
||
facts = [
|
||
("Gruppe / FCI", esc(r.get("gruppe") or "")),
|
||
("Herkunft", esc(r.get("herkunft") or "")),
|
||
("Größe", groesse),
|
||
("Gewicht", gewicht),
|
||
("Lebensdauer", esc(r.get("lebensdauer") or "")),
|
||
("Aktivitätslevel", aktivitaet),
|
||
("Für Anfänger", erfahrung),
|
||
("Kinder geeignet", kinder),
|
||
("Wohnungsgeeignet", wohnung),
|
||
("Ursprüngliche Aufgabe", esc(r.get("bred_for") or "")),
|
||
]
|
||
facts_html = "".join(
|
||
f'<div class="fact-row"><span class="fact-label">{label}</span><span class="fact-value">{val}</span></div>'
|
||
for label, val in facts if val
|
||
)
|
||
|
||
temperament_html = ""
|
||
if r.get("temperament"):
|
||
tags = [t.strip() for t in str(r["temperament"]).split(",") if t.strip()]
|
||
temperament_html = (
|
||
'<div style="display:flex;flex-wrap:wrap;gap:.4rem;margin-bottom:1rem">'
|
||
+ "".join(
|
||
f'<span style="background:#f5e6d3;color:#a86e2e;padding:.2rem .7rem;border-radius:999px;font-size:.8rem">{esc(t)}</span>'
|
||
for t in tags
|
||
)
|
||
+ '</div>'
|
||
)
|
||
|
||
berichte_html = ""
|
||
if berichte:
|
||
for b in berichte:
|
||
datum = b.get("created_at","")[:10] if b.get("created_at") else ""
|
||
berichte_html += f"""<div class="bericht">
|
||
<div class="bericht-meta">{esc(b.get('autor',''))} · {datum}</div>
|
||
<h3 class="bericht-titel">{esc(b.get('titel',''))}</h3>
|
||
<p class="bericht-text">{esc(b.get('text',''))}</p>
|
||
</div>"""
|
||
|
||
beschreibung_html = ""
|
||
if r.get("beschreibung"):
|
||
beschreibung_html = (
|
||
'<section>'
|
||
'<h2>Charakter & Wesen</h2>'
|
||
f'<p style="font-size:.95rem;color:#444;line-height:1.7">{esc(r["beschreibung"])}</p>'
|
||
'</section>'
|
||
)
|
||
|
||
vorkommen_html = ""
|
||
if r.get("vorkommen_de"):
|
||
vorkommen_html = (
|
||
'<section>'
|
||
'<h2>Vorkommen in Deutschland</h2>'
|
||
f'<p style="font-size:.9rem;color:#555;line-height:1.65">{esc(r["vorkommen_de"])}</p>'
|
||
'</section>'
|
||
)
|
||
|
||
stats_html = (
|
||
'<div style="display:flex;gap:1.5rem;flex-wrap:wrap;margin-bottom:1.5rem;font-size:.85rem;color:#888">'
|
||
f'<span>🐕 {dogs_count} Nutzer haben diesen Hund</span>'
|
||
f'<span>🏆 {zuchter_count} Züchter eingetragen</span>'
|
||
f'<span>💬 {berichte_count} Community-Berichte</span>'
|
||
'</div>'
|
||
)
|
||
|
||
name = esc(r.get("name",""))
|
||
gruppe = esc(r.get("gruppe") or "")
|
||
herkunft = esc(r.get("herkunft") or "")
|
||
temp_str = esc(r.get("temperament") or "")
|
||
beschr_str = esc(r.get("beschreibung") or "")
|
||
if beschr_str:
|
||
desc = f"{name} — {beschr_str[:160]}".strip().rstrip(".")
|
||
else:
|
||
desc = f"{name} — Hunderasse aus {herkunft}. Größe: {groesse}. Aktivität: {aktivitaet}. {temp_str[:120] if temp_str else ''}".strip().rstrip(".")
|
||
# Optionale Dog-Schema-Felder
|
||
dog_schema_extras = []
|
||
if r.get("lebensdauer"):
|
||
dog_schema_extras.append(f'"typicalAgeAtDeath":"{esc(r["lebensdauer"])}"')
|
||
if herkunft:
|
||
dog_schema_extras.append(f'"countryOfOrigin":"{herkunft}"')
|
||
if r.get("gruppe"):
|
||
dog_schema_extras.append(f'"breedGroup":"{gruppe}"')
|
||
if gewicht:
|
||
dog_schema_extras.append(f'"weight":"{gewicht}"')
|
||
dog_extras_str = (", " + ", ".join(dog_schema_extras)) if dog_schema_extras else ""
|
||
|
||
json_ld = f"""{{
|
||
"@context":"https://schema.org",
|
||
"@type":"ItemPage",
|
||
"headline":"{name} — Hunderasse Profil",
|
||
"description":"{desc}",
|
||
"url":"https://banyaro.app/wiki/rasse/{slug}",
|
||
"inLanguage":"de",
|
||
"publisher":{{"@type":"Organization","name":"Ban Yaro","url":"https://banyaro.app"}},
|
||
"mainEntityOfPage":{{"@type":"WebPage","@id":"https://banyaro.app/wiki/rasse/{slug}"}},
|
||
"about":{{
|
||
"@type":"Dog",
|
||
"name":"{name}",
|
||
"description":"{desc}"{dog_extras_str}
|
||
}}
|
||
}}"""
|
||
|
||
html = f"""<!DOCTYPE html>
|
||
<html lang="de">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>{name} — Hunderasse Profil | Ban Yaro Wiki</title>
|
||
<meta name="description" content="{desc[:160]}">
|
||
<meta name="robots" content="index, follow">
|
||
<link rel="canonical" href="https://banyaro.app/wiki/rasse/{slug}">
|
||
<meta property="og:type" content="article">
|
||
<meta property="og:title" content="{name} — Hunderasse | Ban Yaro">
|
||
<meta property="og:description" content="{desc[:200]}">
|
||
<meta property="og:url" content="https://banyaro.app/wiki/rasse/{slug}">
|
||
<meta property="og:image" content="{esc(r.get('foto_url') or 'https://banyaro.app/icons/icon-512.png')}">
|
||
<meta property="og:locale" content="de_DE">
|
||
<meta property="og:site_name" content="Ban Yaro">
|
||
<meta name="twitter:card" content="summary_large_image">
|
||
<meta name="twitter:title" content="{name} — Hunderasse | Ban Yaro">
|
||
<meta name="twitter:description" content="{desc[:200]}">
|
||
<meta name="twitter:image" content="{esc(r.get('foto_url') or 'https://banyaro.app/icons/icon-512.png')}">
|
||
<script type="application/ld+json">{json_ld}</script>
|
||
<script type="application/ld+json">
|
||
{{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[
|
||
{{"@type":"ListItem","position":1,"name":"Ban Yaro","item":"https://banyaro.app"}},
|
||
{{"@type":"ListItem","position":2,"name":"Hunderassen-Wiki","item":"https://banyaro.app/wiki/rassen"}},
|
||
{{"@type":"ListItem","position":3,"name":"{name}","item":"https://banyaro.app/wiki/rasse/{slug}"}}
|
||
]}}
|
||
</script>
|
||
<style>
|
||
*,*::before,*::after{{box-sizing:border-box;margin:0;padding:0}}
|
||
body{{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#FAF7F2;color:#1a1a1a;line-height:1.6}}
|
||
a{{color:#C4843A;text-decoration:none}}
|
||
a:hover{{text-decoration:underline}}
|
||
nav{{background:#fff;border-bottom:1px solid #e8ddd0;padding:.7rem 1.5rem;display:flex;gap:1rem;align-items:center;flex-wrap:wrap;position:sticky;top:0;z-index:10}}
|
||
nav .brand{{font-weight:800;color:#C4843A;margin-right:auto}}
|
||
nav a{{font-size:.9rem;font-weight:500;color:#555}}
|
||
nav a:hover{{color:#C4843A;text-decoration:none}}
|
||
.container{{max-width:860px;margin:0 auto;padding:2rem 1.5rem}}
|
||
.hero{{display:flex;gap:2rem;align-items:flex-start;margin-bottom:2.5rem;flex-wrap:wrap}}
|
||
.breed-photo{{width:200px;height:200px;border-radius:16px;object-fit:cover;border:3px solid #e8ddd0;flex-shrink:0}}
|
||
.breed-photo-placeholder{{width:200px;height:200px;border-radius:16px;background:#f0e8dc;display:flex;align-items:center;justify-content:center;font-size:5rem;flex-shrink:0}}
|
||
.hero-info h1{{font-size:clamp(1.5rem,4vw,2.2rem);font-weight:800;margin-bottom:.3rem}}
|
||
.hero-info .gruppe{{color:#888;font-size:.95rem;margin-bottom:1rem}}
|
||
.temp-tags{{display:flex;flex-wrap:wrap;gap:.4rem;margin-bottom:1rem}}
|
||
.temp-tag{{background:#f5e6d3;color:#a86e2e;font-size:.8rem;font-weight:600;padding:.25rem .7rem;border-radius:999px}}
|
||
.cta{{display:inline-block;background:#C4843A;color:#fff;font-weight:700;padding:.65rem 1.5rem;border-radius:999px;font-size:.9rem;margin-top:.75rem}}
|
||
.cta:hover{{background:#a86e2e;text-decoration:none}}
|
||
section{{margin-bottom:2.5rem}}
|
||
h2{{font-size:1.15rem;font-weight:700;color:#C4843A;margin-bottom:1rem;padding-bottom:.4rem;border-bottom:2px solid #f0e8dc;text-transform:uppercase;letter-spacing:.04em;font-size:.85rem}}
|
||
.facts{{background:#fff;border:1px solid #e8ddd0;border-radius:12px;overflow:hidden}}
|
||
.fact-row{{display:flex;padding:.7rem 1.25rem;border-bottom:1px solid #f5f0eb}}
|
||
.fact-row:last-child{{border-bottom:none}}
|
||
.fact-label{{width:180px;font-size:.85rem;color:#888;font-weight:500;flex-shrink:0}}
|
||
.fact-value{{font-size:.9rem;font-weight:600;color:#1a1a1a}}
|
||
.bericht{{background:#fff;border:1px solid #e8ddd0;border-radius:12px;padding:1.25rem;margin-bottom:1rem}}
|
||
.bericht-meta{{font-size:.78rem;color:#aaa;margin-bottom:.35rem}}
|
||
.bericht-titel{{font-size:1rem;font-weight:700;margin-bottom:.4rem}}
|
||
.bericht-text{{font-size:.9rem;color:#444;line-height:1.6}}
|
||
.breadcrumb{{font-size:.82rem;color:#aaa;margin-bottom:1.5rem}}
|
||
.breadcrumb a{{color:#C4843A}}
|
||
footer{{background:#1a1a1a;color:#aaa;text-align:center;padding:1.5rem;font-size:.82rem;margin-top:3rem}}
|
||
footer a{{color:#C4843A}}
|
||
@media(max-width:500px){{.breed-photo,.breed-photo-placeholder{{width:140px;height:140px;font-size:3.5rem}}.fact-label{{width:140px}}}}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<nav>
|
||
<span class="brand">Ban Yaro</span>
|
||
<a href="/wiki/rassen">Alle Rassen</a>
|
||
<a href="/knigge">Knigge</a>
|
||
<a href="/info">Über die App</a>
|
||
<a href="/" class="cta" style="padding:.35rem 1rem;font-size:.82rem">App öffnen</a>
|
||
</nav>
|
||
<div class="container">
|
||
<div class="breadcrumb">
|
||
<a href="/wiki/rassen">Hunderassen-Wiki</a> › {name}
|
||
</div>
|
||
|
||
<div class="hero">
|
||
{foto_html}
|
||
<div class="hero-info">
|
||
<h1>{name}</h1>
|
||
{'<p class="gruppe">' + gruppe + '</p>' if gruppe else ''}
|
||
{temperament_html}
|
||
<a href="/" class="cta">In der App öffnen</a>
|
||
</div>
|
||
</div>
|
||
|
||
{beschreibung_html}
|
||
|
||
{vorkommen_html}
|
||
|
||
<section>
|
||
<h2>Steckbrief</h2>
|
||
<div class="facts">{facts_html}</div>
|
||
</section>
|
||
|
||
{stats_html}
|
||
|
||
{'<section><h2>Erfahrungsberichte der Community (' + str(berichte_count) + ')</h2>' + berichte_html + '</section>' if berichte else ''}
|
||
|
||
<section>
|
||
<h2>In der App</h2>
|
||
<p style="font-size:.9rem;color:#555;margin-bottom:.75rem">
|
||
Ban Yaro ist die kostenlose Hunde-App für Deutschland, Österreich und die Schweiz.
|
||
Tagebuch, Impfpass, Giftköder-Alarm, Gassi-Community und mehr — DSGVO-konform, ohne App Store.
|
||
</p>
|
||
<div style="display:flex;flex-wrap:wrap;gap:.6rem;margin-bottom:1rem">
|
||
<a href="/" class="cta">Kostenlos starten</a>
|
||
{f'<a href="/wurfboerse?rasse={slug}" class="cta" style="background:#f5e6d3;color:#a86e2e;border:1px solid #e8cba8">{name}-Welpen auf Ban Yaro</a>' if zuchter_count > 0 else ''}
|
||
</div>
|
||
<p style="font-size:.8rem;color:#888">
|
||
{f'{zuchter_count} verifizierte {name}-Züchter · ' if zuchter_count > 0 else ''}{dogs_count} Nutzer haben diesen Hund · <a href="/wiki/rassen">Alle 1003 Rassen</a>
|
||
</p>
|
||
</section>
|
||
</div>
|
||
<footer>
|
||
<strong style="color:#fff">Ban Yaro</strong> — Die Hunde-App für DACH ·
|
||
<a href="/wiki/rassen">Hunderassen-Wiki</a> ·
|
||
<a href="https://banyaro.app">banyaro.app</a> ·
|
||
<a href="https://banyaro.app/#impressum">Impressum</a>
|
||
</footer>
|
||
</body>
|
||
</html>"""
|
||
return HTMLResponse(content=html, headers={"Cache-Control": "max-age=3600"})
|
||
|
||
|
||
@app.get("/favicon.ico")
|
||
async def favicon():
|
||
return FileResponse(f"{STATIC_DIR}/icons/favicon.ico")
|
||
|
||
@app.get("/manifest.json")
|
||
async def manifest():
|
||
import json as _json
|
||
IS_STAGING = os.getenv("STAGING", "false").lower() == "true"
|
||
with open(f"{STATIC_DIR}/manifest.json", encoding="utf-8") as f:
|
||
data = _json.load(f)
|
||
if IS_STAGING:
|
||
data["name"] = "Ban Yaro Staging"
|
||
data["short_name"] = "BY ⚗️"
|
||
data["theme_color"] = "#7c3aed"
|
||
data["background_color"] = "#2d1b69"
|
||
data["id"] = "/staging"
|
||
# Icons mit Staging-Variante überschreiben falls vorhanden
|
||
staging_icon = "/icons/icon-192-staging.png"
|
||
import os as _os
|
||
if _os.path.exists(f"{STATIC_DIR}/icons/icon-192-staging.png"):
|
||
data["icons"] = [
|
||
{"src": "/icons/icon-192-staging.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable"},
|
||
{"src": "/icons/icon-512-staging.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable"},
|
||
]
|
||
from fastapi.responses import JSONResponse
|
||
return JSONResponse(content=data, media_type="application/manifest+json")
|
||
|
||
@app.get("/sw.js")
|
||
async def service_worker():
|
||
return FileResponse(
|
||
f"{STATIC_DIR}/sw.js",
|
||
headers={"Cache-Control": "no-cache, no-store, must-revalidate"}
|
||
)
|
||
|
||
# Web Share Target
|
||
@app.post("/share")
|
||
async def share_target(request: Request):
|
||
# Empfängt geteilte Inhalte vom Handy (Fotos, Links, Text)
|
||
# Weiterleitung zur App mit den Daten
|
||
return FileResponse(
|
||
f"{STATIC_DIR}/index.html",
|
||
headers={"Cache-Control": "no-store, no-cache"}
|
||
)
|
||
|
||
# Öffentliche Hunde-Profilseite (für NFC-Tags, kein Login nötig)
|
||
@app.get("/hund/{dog_id}")
|
||
async def public_dog_page(dog_id: int):
|
||
from database import db as _db
|
||
_og_name = "Hunde-Profil"
|
||
_og_desc = "Hunde-Profil auf Ban Yaro — der deutschen Hunde-Plattform"
|
||
_og_img = "https://banyaro.app/icons/icon-512.png"
|
||
try:
|
||
with _db() as conn:
|
||
_dog = conn.execute(
|
||
"SELECT name, rasse, foto_url, bio FROM dogs WHERE id=? AND is_public=1",
|
||
(dog_id,)
|
||
).fetchone()
|
||
if _dog:
|
||
_og_name = _dog["name"]
|
||
_rasse = f" · {_dog['rasse']}" if _dog.get("rasse") else ""
|
||
_og_desc = f"{_dog['name']}{_rasse} — Profil auf Ban Yaro"
|
||
if _dog.get("bio"):
|
||
_og_desc = f"{_dog['name']}{_rasse}: {str(_dog['bio'])[:120]}"
|
||
if _dog.get("foto_url"):
|
||
_og_img = f"https://banyaro.app{_dog['foto_url']}"
|
||
except Exception:
|
||
pass
|
||
|
||
_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>{_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="{_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="{_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="{_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>
|
||
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
|
||
body {{
|
||
font-family: var(--font-sans);
|
||
background: var(--c-bg);
|
||
color: var(--c-text);
|
||
min-height: 100dvh;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
padding: var(--space-6) var(--space-4);
|
||
}}
|
||
.profile-card {{
|
||
background: var(--c-surface);
|
||
border-radius: var(--radius-xl);
|
||
box-shadow: var(--shadow-lg);
|
||
max-width: 440px;
|
||
width: 100%;
|
||
padding: var(--space-8) var(--space-6);
|
||
text-align: center;
|
||
}}
|
||
.dog-photo {{
|
||
width: 140px;
|
||
height: 140px;
|
||
border-radius: 50%;
|
||
object-fit: cover;
|
||
border: 4px solid var(--c-primary);
|
||
margin-bottom: var(--space-4);
|
||
}}
|
||
.dog-photo-placeholder {{
|
||
width: 140px;
|
||
height: 140px;
|
||
border-radius: 50%;
|
||
background: var(--c-surface-2);
|
||
border: 4px solid var(--c-border);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 4rem;
|
||
margin: 0 auto var(--space-4);
|
||
}}
|
||
.dog-name {{
|
||
font-size: var(--text-2xl);
|
||
font-weight: var(--weight-bold);
|
||
color: var(--c-text);
|
||
margin-bottom: var(--space-1);
|
||
}}
|
||
.dog-rasse {{
|
||
font-size: var(--text-base);
|
||
color: var(--c-text-secondary);
|
||
margin-bottom: var(--space-5);
|
||
}}
|
||
.info-row {{
|
||
display: flex;
|
||
justify-content: center;
|
||
gap: var(--space-4);
|
||
flex-wrap: wrap;
|
||
margin-bottom: var(--space-5);
|
||
}}
|
||
.info-pill {{
|
||
background: var(--c-primary-subtle);
|
||
border-radius: var(--radius-full);
|
||
padding: var(--space-2) var(--space-4);
|
||
font-size: var(--text-sm);
|
||
color: var(--c-primary-dark);
|
||
font-weight: var(--weight-medium);
|
||
}}
|
||
.dog-bio {{
|
||
background: var(--c-surface-2);
|
||
border-radius: var(--radius-md);
|
||
padding: var(--space-4);
|
||
font-style: italic;
|
||
color: var(--c-text-secondary);
|
||
line-height: var(--leading-relaxed);
|
||
margin-bottom: var(--space-5);
|
||
text-align: left;
|
||
}}
|
||
.besitzer {{
|
||
font-size: var(--text-sm);
|
||
color: var(--c-text-muted);
|
||
margin-bottom: var(--space-6);
|
||
}}
|
||
.found-section {{
|
||
border-top: 1px solid var(--c-border-light);
|
||
padding-top: var(--space-6);
|
||
}}
|
||
.found-hint {{
|
||
font-size: var(--text-sm);
|
||
color: var(--c-text-secondary);
|
||
margin-bottom: var(--space-4);
|
||
}}
|
||
.found-fields {{
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: var(--space-3);
|
||
margin-bottom: var(--space-4);
|
||
}}
|
||
.found-input {{
|
||
width: 100%;
|
||
padding: var(--space-3) var(--space-4);
|
||
border: 1.5px solid var(--c-border);
|
||
border-radius: var(--radius-md);
|
||
font-size: var(--text-base);
|
||
font-family: var(--font-sans);
|
||
background: var(--c-surface);
|
||
color: var(--c-text);
|
||
outline: none;
|
||
transition: border-color var(--transition-fast);
|
||
}}
|
||
.found-input:focus {{
|
||
border-color: var(--c-primary);
|
||
}}
|
||
.found-btn {{
|
||
width: 100%;
|
||
}}
|
||
.success-msg {{
|
||
background: var(--c-success-subtle);
|
||
color: var(--c-success);
|
||
border-radius: var(--radius-md);
|
||
padding: var(--space-4);
|
||
font-weight: var(--weight-medium);
|
||
display: none;
|
||
}}
|
||
.error-msg {{
|
||
background: var(--c-danger-subtle);
|
||
color: var(--c-danger);
|
||
border-radius: var(--radius-md);
|
||
padding: var(--space-3);
|
||
font-size: var(--text-sm);
|
||
display: none;
|
||
}}
|
||
.loading {{
|
||
color: var(--c-text-muted);
|
||
padding: var(--space-12) 0;
|
||
font-size: var(--text-lg);
|
||
}}
|
||
.not-found {{
|
||
text-align: center;
|
||
color: var(--c-text-secondary);
|
||
padding: var(--space-12) var(--space-4);
|
||
}}
|
||
.not-found .icon {{ font-size: 4rem; margin-bottom: var(--space-4); display: block; }}
|
||
.app-logo {{
|
||
font-size: var(--text-sm);
|
||
color: var(--c-text-muted);
|
||
margin-top: var(--space-8);
|
||
}}
|
||
.app-logo a {{
|
||
color: var(--c-primary);
|
||
text-decoration: none;
|
||
font-weight: var(--weight-medium);
|
||
}}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="profile-card" id="profile-card">
|
||
<div class="loading">Lade Profil…</div>
|
||
</div>
|
||
<p class="app-logo">powered by <a href="/">BAN YARO</a></p>
|
||
|
||
<script>
|
||
const DOG_ID = {dog_id};
|
||
|
||
function esc(s) {{
|
||
if (!s) return '';
|
||
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||
}}
|
||
|
||
function calcAlter(geburtstag) {{
|
||
const born = new Date(geburtstag + 'T00:00:00');
|
||
const tage = Math.floor((Date.now() - born) / 86400000);
|
||
if (tage < 0) return '';
|
||
if (tage < 30) return tage + ' Tag' + (tage !== 1 ? 'e' : '') + ' alt';
|
||
if (tage < 365) {{
|
||
const m = Math.floor(tage / 30);
|
||
return m + ' Monat' + (m !== 1 ? 'e' : '') + ' alt';
|
||
}}
|
||
const j = Math.floor(tage / 365);
|
||
const m = Math.floor((tage % 365) / 30);
|
||
return m > 0
|
||
? j + ' Jahr' + (j !== 1 ? 'e' : '') + ', ' + m + ' Monat' + (m !== 1 ? 'e' : '') + ' alt'
|
||
: j + ' Jahr' + (j !== 1 ? 'e' : '') + ' alt';
|
||
}}
|
||
|
||
async function load() {{
|
||
const card = document.getElementById('profile-card');
|
||
try {{
|
||
const resp = await fetch('/api/dogs/public/' + DOG_ID);
|
||
if (!resp.ok) {{
|
||
card.innerHTML = `
|
||
<div class="not-found">
|
||
<span class="icon">🐾</span>
|
||
<p>Dieses Profil ist nicht öffentlich oder wurde nicht gefunden.</p>
|
||
</div>`;
|
||
return;
|
||
}}
|
||
const dog = await resp.json();
|
||
|
||
const photoHTML = dog.foto_url
|
||
? `<img class="dog-photo" src="${{esc(dog.foto_url)}}" alt="${{esc(dog.name)}}">`
|
||
: `<div class="dog-photo-placeholder">🐕</div>`;
|
||
|
||
const pills = [];
|
||
if (dog.geburtstag) {{
|
||
pills.push(`<span class="info-pill">🎂 ${{calcAlter(dog.geburtstag)}}</span>`);
|
||
}}
|
||
|
||
const bioHTML = dog.bio
|
||
? `<div class="dog-bio">"${{esc(dog.bio)}}"</div>`
|
||
: '';
|
||
|
||
// Vorname des Besitzers
|
||
const vorname = dog.besitzer_name ? dog.besitzer_name.split(' ')[0] : '';
|
||
|
||
card.innerHTML = `
|
||
${{photoHTML}}
|
||
<h1 class="dog-name">${{esc(dog.name)}}</h1>
|
||
${{dog.rasse ? `<p class="dog-rasse">${{esc(dog.rasse)}}</p>` : '<p class="dog-rasse"></p>'}}
|
||
${{pills.length ? `<div class="info-row">${{pills.join('')}}</div>` : ''}}
|
||
${{bioHTML}}
|
||
${{vorname ? `<p class="besitzer">Besitzer: ${{esc(vorname)}}</p>` : ''}}
|
||
|
||
<div class="found-section">
|
||
<p class="found-hint">Hast du diesen Hund gefunden? Benachrichtige den Besitzer!</p>
|
||
<div class="found-fields">
|
||
<input class="found-input" type="text" id="found-msg"
|
||
placeholder="Kurze Nachricht (optional)" maxlength="200">
|
||
<input class="found-input" type="text" id="found-kontakt"
|
||
placeholder="Deine Telefonnummer / E-Mail (optional)" maxlength="100">
|
||
</div>
|
||
<button class="btn btn-primary found-btn" id="found-btn"
|
||
onclick="sendFound()">
|
||
🐾 Ich habe diesen Hund gefunden
|
||
</button>
|
||
<div class="success-msg" id="found-success">
|
||
✅ Benachrichtigung wurde gesendet. Der Besitzer wurde informiert!
|
||
</div>
|
||
<div class="error-msg" id="found-error"></div>
|
||
</div>
|
||
`;
|
||
}} catch(e) {{
|
||
card.innerHTML = `<div class="not-found"><span class="icon">⚠️</span><p>Fehler beim Laden.</p></div>`;
|
||
}}
|
||
}}
|
||
|
||
async function sendFound() {{
|
||
const btn = document.getElementById('found-btn');
|
||
const success = document.getElementById('found-success');
|
||
const error = document.getElementById('found-error');
|
||
const msg = document.getElementById('found-msg')?.value || '';
|
||
const kontakt = document.getElementById('found-kontakt')?.value || '';
|
||
|
||
btn.disabled = true;
|
||
btn.textContent = 'Sende…';
|
||
error.style.display = 'none';
|
||
|
||
try {{
|
||
const resp = await fetch('/api/dogs/public/' + DOG_ID + '/found', {{
|
||
method: 'POST',
|
||
headers: {{'Content-Type': 'application/json'}},
|
||
body: JSON.stringify({{ message: msg, kontakt: kontakt }}),
|
||
}});
|
||
if (!resp.ok) throw new Error('Fehler beim Senden.');
|
||
btn.style.display = 'none';
|
||
success.style.display = 'block';
|
||
}} catch(e) {{
|
||
btn.disabled = false;
|
||
btn.textContent = '🐾 Ich habe diesen Hund gefunden';
|
||
error.textContent = 'Fehler beim Senden. Bitte versuche es erneut.';
|
||
error.style.display = 'block';
|
||
}}
|
||
}}
|
||
|
||
load();
|
||
</script>
|
||
</body>
|
||
</html>"""
|
||
from fastapi.responses import HTMLResponse
|
||
return HTMLResponse(content=_html)
|
||
|
||
|
||
# ------------------------------------------------------------------
|
||
# Einladungsseite /teilen/{token} — SPA lädt + nimmt Einladung an
|
||
# ------------------------------------------------------------------
|
||
@app.get("/teilen/{token}")
|
||
async def invite_page(token: str):
|
||
from fastapi.responses import HTMLResponse
|
||
with open(f"{STATIC_DIR}/index.html", encoding="utf-8") as _f:
|
||
_html = _f.read()
|
||
_html = _html.replace(
|
||
'<link rel="canonical" href="https://banyaro.app/">',
|
||
'<meta name="robots" content="noindex"><link rel="canonical" href="https://banyaro.app/">'
|
||
)
|
||
return HTMLResponse(content=_html, headers={"Cache-Control": "no-store, no-cache"})
|
||
|
||
|
||
@app.get("/breeder/{zwingername}")
|
||
async def breeder_profile_page(zwingername: str):
|
||
from fastapi.responses import HTMLResponse
|
||
from urllib.parse import unquote
|
||
from database import db as _db
|
||
import html as _html_mod
|
||
name = unquote(zwingername)
|
||
desc = f"Hundezüchter {_html_mod.escape(name)} auf Ban Yaro — Wurfbörse, Stammbaum und mehr."
|
||
try:
|
||
with _db() as conn:
|
||
bp = conn.execute(
|
||
"SELECT bp.rasse, bp.beschreibung FROM breeder_profiles bp "
|
||
"JOIN users u ON u.id = bp.user_id WHERE bp.zwingername=? AND u.rolle='breeder' LIMIT 1",
|
||
(name,)
|
||
).fetchone()
|
||
if bp and bp["beschreibung"]:
|
||
desc = _html_mod.escape(bp["beschreibung"][:160])
|
||
except Exception:
|
||
pass
|
||
with open(f"{STATIC_DIR}/index.html", encoding="utf-8") as _f:
|
||
_page = _f.read()
|
||
_page = _page.replace(
|
||
'<link rel="canonical" href="https://banyaro.app/">',
|
||
f'<link rel="canonical" href="https://banyaro.app/breeder/{_html_mod.escape(zwingername)}">'
|
||
).replace(
|
||
'<title>Ban Yaro</title>',
|
||
f'<title>{_html_mod.escape(name)} — Hundezüchter auf Ban Yaro</title>'
|
||
f'\n <meta name="description" content="{desc}">'
|
||
f'\n <meta name="robots" content="index, follow">'
|
||
)
|
||
return HTMLResponse(content=_page, headers={"Cache-Control": "no-store, no-cache"})
|
||
|
||
|
||
@app.get("/litters")
|
||
async def litters_page():
|
||
return FileResponse(f"{STATIC_DIR}/index.html", headers={"Cache-Control": "no-store, no-cache"})
|
||
|
||
|
||
# ------------------------------------------------------------------
|
||
# Widget-Vorschau /widget
|
||
# ------------------------------------------------------------------
|
||
@app.get("/widget")
|
||
async def widget_page():
|
||
return FileResponse(f"{STATIC_DIR}/index.html", headers={"Cache-Control": "no-store, no-cache"})
|
||
|
||
|
||
# ------------------------------------------------------------------
|
||
# Digitaler Heimtierausweis /ausweis/{dog_id}
|
||
# ------------------------------------------------------------------
|
||
@app.get("/ausweis/{dog_id}")
|
||
async def ausweis_page(dog_id: int, request: Request):
|
||
from fastapi.responses import HTMLResponse
|
||
from auth import get_current_user_optional, decode_token
|
||
import json as _json
|
||
|
||
# Auth via Cookie
|
||
token = request.cookies.get("by_token")
|
||
user_id = None
|
||
if token:
|
||
try:
|
||
payload = decode_token(token)
|
||
user_id = int(payload["sub"])
|
||
except Exception:
|
||
pass
|
||
|
||
if not user_id:
|
||
return HTMLResponse(
|
||
'<meta charset="UTF-8"><p style="font-family:sans-serif;padding:2rem">'
|
||
'Bitte <a href="/">einloggen</a> um den Ausweis anzuzeigen.</p>',
|
||
status_code=401
|
||
)
|
||
|
||
from database import db as _db
|
||
with _db() as conn:
|
||
dog = conn.execute(
|
||
"""SELECT d.* FROM dogs d
|
||
LEFT JOIN dog_shares ds ON ds.dog_id=d.id AND ds.shared_with_id=? AND ds.accepted_at IS NOT NULL
|
||
WHERE d.id=? AND (d.user_id=? OR ds.id IS NOT NULL)""",
|
||
(user_id, dog_id, user_id)
|
||
).fetchone()
|
||
if not dog:
|
||
return HTMLResponse("<p>Hund nicht gefunden.</p>", status_code=404)
|
||
|
||
owner = conn.execute("SELECT name, email FROM users WHERE id=?", (dog["user_id"],)).fetchone()
|
||
|
||
health_rows = conn.execute(
|
||
"SELECT * FROM health WHERE dog_id=? ORDER BY datum DESC",
|
||
(dog_id,)
|
||
).fetchall()
|
||
|
||
vets = conn.execute(
|
||
"""SELECT DISTINCT t.name, t.strasse, t.plz, t.ort, t.telefon
|
||
FROM tieraerzte t
|
||
JOIN health h ON h.tierarzt_id = t.id
|
||
WHERE h.dog_id=?""",
|
||
(dog_id,)
|
||
).fetchall()
|
||
|
||
dog = dict(dog)
|
||
vets = [dict(v) for v in vets]
|
||
|
||
def esc(s):
|
||
if not s: return ""
|
||
return str(s).replace("&","&").replace("<","<").replace(">",">").replace('"',""")
|
||
|
||
def fmt_date(d):
|
||
if not d: return "–"
|
||
try:
|
||
from datetime import date
|
||
parts = d.split("-")
|
||
return f"{int(parts[2])}.{int(parts[1])}.{parts[0]}"
|
||
except Exception:
|
||
return d
|
||
|
||
geschlecht = {"m": "Rüde", "w": "Hündin"}.get(dog.get("geschlecht",""), "–")
|
||
|
||
# Impfungen
|
||
impfungen = [r for r in health_rows if r["typ"] == "impfung"]
|
||
# Medikamente (aktiv)
|
||
medis = [r for r in health_rows if r["typ"] == "medikament" and r["aktiv"]]
|
||
# Allergien
|
||
allergien = [r for r in health_rows if r["typ"] == "allergie"]
|
||
|
||
def health_rows_html(rows, cols):
|
||
if not rows:
|
||
return '<tr><td colspan="99" style="color:#999;font-style:italic">Keine Einträge</td></tr>'
|
||
out = ""
|
||
for r in rows:
|
||
out += "<tr>" + "".join(f"<td>{esc(r[c])}</td>" for c in cols) + "</tr>"
|
||
return out
|
||
|
||
photo_html = f'<img src="{esc(dog["foto_url"])}" alt="{esc(dog["name"])}" class="dog-photo">' if dog.get("foto_url") else '<div class="dog-photo-placeholder">🐕</div>'
|
||
|
||
vets_html = ""
|
||
for v in vets:
|
||
addr = ", ".join(filter(None, [v.get("strasse"), v.get("plz"), v.get("ort")]))
|
||
vets_html += f'<div class="vet-card"><strong>{esc(v["name"])}</strong>'
|
||
if addr: vets_html += f'<br><small>{esc(addr)}</small>'
|
||
if v.get("telefon"): vets_html += f'<br><small>☎ {esc(v["telefon"])}</small>'
|
||
vets_html += "</div>"
|
||
if not vets_html:
|
||
vets_html = '<span style="color:#999;font-style:italic">Keine Tierärzte eingetragen</span>'
|
||
|
||
html = f"""<!DOCTYPE html>
|
||
<html lang="de">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<meta name="robots" content="noindex">
|
||
<title>Heimtierausweis – {esc(dog["name"])}</title>
|
||
<style>
|
||
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
|
||
body {{ font-family: "Segoe UI", Arial, sans-serif; background: #FAF7F2; color: #1a1a1a; padding: 2rem; }}
|
||
.ausweis {{ max-width: 800px; margin: 0 auto; background: #fff; border-radius: 12px; box-shadow: 0 2px 20px rgba(0,0,0,.08); overflow: hidden; }}
|
||
.header {{ background: linear-gradient(135deg, #C4843A, #e8a857); color: #fff; padding: 2rem; display: flex; gap: 1.5rem; align-items: center; }}
|
||
.dog-photo {{ width: 100px; height: 100px; border-radius: 50%; object-fit: cover; border: 3px solid rgba(255,255,255,.6); flex-shrink: 0; }}
|
||
.dog-photo-placeholder {{ width: 100px; height: 100px; border-radius: 50%; background: rgba(255,255,255,.2); display: flex; align-items: center; justify-content: center; font-size: 2.5rem; flex-shrink: 0; }}
|
||
.header-info h1 {{ font-size: 1.8rem; font-weight: 700; }}
|
||
.header-info .rasse {{ opacity: .85; font-size: 1rem; margin-top: .2rem; }}
|
||
.meta-grid {{ display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: .5rem; margin-top: 1rem; }}
|
||
.meta-item {{ background: rgba(255,255,255,.15); border-radius: 8px; padding: .5rem .75rem; }}
|
||
.meta-item .label {{ font-size: .65rem; opacity: .8; text-transform: uppercase; letter-spacing: .05em; }}
|
||
.meta-item .value {{ font-weight: 600; font-size: .9rem; margin-top: .1rem; }}
|
||
.body {{ padding: 1.5rem 2rem; }}
|
||
.section {{ margin-bottom: 1.5rem; }}
|
||
.section h2 {{ font-size: .8rem; text-transform: uppercase; letter-spacing: .08em; color: #C4843A; font-weight: 700; margin-bottom: .75rem; padding-bottom: .4rem; border-bottom: 2px solid #f0e8dc; }}
|
||
table {{ width: 100%; border-collapse: collapse; font-size: .85rem; }}
|
||
th {{ text-align: left; font-size: .7rem; text-transform: uppercase; letter-spacing: .05em; color: #888; font-weight: 600; padding: .4rem .5rem; border-bottom: 1px solid #eee; }}
|
||
td {{ padding: .45rem .5rem; border-bottom: 1px solid #f5f5f5; vertical-align: top; }}
|
||
tr:last-child td {{ border-bottom: none; }}
|
||
.vet-card {{ display: inline-block; background: #f9f6f2; border: 1px solid #ede5d8; border-radius: 8px; padding: .6rem 1rem; margin-right: .5rem; margin-bottom: .5rem; font-size: .85rem; line-height: 1.5; }}
|
||
.print-btn {{ display: block; margin: 0 auto 1.5rem; padding: .6rem 2rem; background: #C4843A; color: #fff; border: none; border-radius: 8px; font-size: 1rem; cursor: pointer; }}
|
||
.footer {{ text-align: center; padding: 1rem; font-size: .7rem; color: #aaa; border-top: 1px solid #f0f0f0; }}
|
||
@media print {{
|
||
body {{ background: #fff; padding: 0; }}
|
||
.ausweis {{ box-shadow: none; border-radius: 0; }}
|
||
.print-btn {{ display: none; }}
|
||
.no-print {{ display: none; }}
|
||
}}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="ausweis">
|
||
<div class="header">
|
||
{photo_html}
|
||
<div class="header-info">
|
||
<h1>{esc(dog["name"])}</h1>
|
||
<div class="rasse">{esc(dog.get("rasse") or "Rasse unbekannt")}</div>
|
||
<div class="meta-grid">
|
||
<div class="meta-item"><div class="label">Geburtstag</div><div class="value">{fmt_date(dog.get("geburtstag"))}</div></div>
|
||
<div class="meta-item"><div class="label">Geschlecht</div><div class="value">{geschlecht}</div></div>
|
||
<div class="meta-item"><div class="label">Gewicht</div><div class="value">{f'{dog["gewicht_kg"]} kg' if dog.get("gewicht_kg") else "–"}</div></div>
|
||
<div class="meta-item"><div class="label">Transponder</div><div class="value">{esc(dog.get("chip_nr")) or "–"}</div></div>
|
||
{f'<div class="meta-item"><div class="label">Widerrist</div><div class="value">{dog["widerrist_cm"]} cm</div></div>' if dog.get("widerrist_cm") else ''}
|
||
<div class="meta-item"><div class="label">Besitzer</div><div class="value">{esc(owner["name"]) if owner else "–"}</div></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="body">
|
||
<div class="no-print" style="display:flex;gap:10px;flex-wrap:wrap;margin-bottom:16px">
|
||
<button onclick="window.history.length>1?window.history.back():(window.location.href='/')"
|
||
style="background:#f0e6d3;color:#7a4a1e;border:none;border-radius:100px;
|
||
padding:10px 20px;font-size:0.9rem;cursor:pointer;font-weight:600">
|
||
← Zurück zur App
|
||
</button>
|
||
<button class="print-btn" onclick="window.print()">🖨 Drucken / Als PDF speichern</button>
|
||
</div>
|
||
|
||
<div class="section">
|
||
<h2>Impfungen</h2>
|
||
<table>
|
||
<thead><tr><th>Impfung</th><th>Datum</th><th>Nächste Fälligkeit</th><th>Charge</th><th>Tierarzt</th></tr></thead>
|
||
<tbody>{health_rows_html(impfungen, ["bezeichnung","datum","naechstes","charge_nr","tierarzt_name"])}</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<div class="section">
|
||
<h2>Aktive Medikamente</h2>
|
||
<table>
|
||
<thead><tr><th>Medikament</th><th>Seit</th><th>Dosierung</th><th>Häufigkeit</th></tr></thead>
|
||
<tbody>{health_rows_html(medis, ["bezeichnung","datum","dosierung","haeufigkeit"])}</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<div class="section">
|
||
<h2>Allergien & Unverträglichkeiten</h2>
|
||
<table>
|
||
<thead><tr><th>Allergen</th><th>Schweregrad</th><th>Reaktion</th><th>Seit</th></tr></thead>
|
||
<tbody>{health_rows_html(allergien, ["bezeichnung","schweregrad","reaktion","datum"])}</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<div class="section">
|
||
<h2>Tierärzte</h2>
|
||
{vets_html}
|
||
</div>
|
||
</div>
|
||
<div class="footer">Erstellt mit BAN YARO · banyaro.app · {fmt_date(__import__("datetime").date.today().isoformat())}</div>
|
||
</div>
|
||
</body>
|
||
</html>"""
|
||
return HTMLResponse(html)
|
||
|
||
|
||
# ------------------------------------------------------------------
|
||
# SEO: Hunde-Knigge als server-gerenderete Seite /knigge
|
||
# ------------------------------------------------------------------
|
||
@app.get("/knigge")
|
||
async def knigge_page():
|
||
from fastapi.responses import HTMLResponse
|
||
|
||
begegnungen = [
|
||
("Fremder Hund", "Kurze Leine, ruhig bleiben. Hunde schnüffeln lassen wenn beide entspannt sind. Bei Eskalation: weglenken, Richtung wechseln. Freilaufende Hunde auf angeleine Hunde zulaufen lassen ist unhöflich — der angeleine Hund kann in Stress (Leinenfrust) geraten."),
|
||
("Kinder", "Hund nie unbeaufsichtigt mit fremden Kindern lassen. Kind fragen ob es streicheln darf. Hund seitlich positionieren, nicht zwischen Kind und Weg. Kinder sollten keinen direkten Augenkontakt mit unbekannten Hunden halten."),
|
||
("Radfahrer", "Hund rechtzeitig an die Seite nehmen. Fahrräder können für manche Hunde bedrohlich wirken. Frühzeitig Abstand gewinnen, ruhig bleiben, Hund bei sich halten."),
|
||
("Jogger", "Kurze Leine, Abstand halten, Hund nicht anspringen lassen. Jogger bewegen sich schnell — das kann Jagdinstinkt auslösen."),
|
||
("ÖPNV (Bus & Bahn)", "In Deutschland gilt im ÖPNV grundsätzlich Maulkorbpflicht für Hunde. Kleine Hunde in Transportbox fahren kostenlos oder zum Kindertarif. Große Hunde brauchen in den meisten Städten einen eigenen Fahrschein. Regeln variieren je nach Verkehrsverbund."),
|
||
("Supermarkt & Geschäfte", "Es gilt das Hausrecht des Betreibers. Ein 'Hunde willkommen'-Schild ist eine explizite Einladung. Im Zweifel immer fragen. Außen anbinden ist nur kurzzeitig und mit Sichtkontakt akzeptabel."),
|
||
("Restaurant", "Im Außenbereich erlauben viele Restaurants Hunde — aber Hausrecht gilt. Wenn ein anderer Gast Angst hat, ist Kompromissbereitschaft ein Zeichen guter Hundehaltung. Im Zweifelsfall Personal entscheiden lassen."),
|
||
("Spielplätze", "Hunde sind auf Kinderspielplätzen generell verboten (§ 2 Abs. 4 BImSchG und Gemeindesatzungen). Gilt auch für gut erzogene Hunde. Abstand halten, auch beim Vorbeigehen."),
|
||
]
|
||
|
||
szenarien = [
|
||
("Muss Kot immer aufgesammelt werden?", "Ja — auch im Gebüsch abseits des Weges. Kinder spielen überall, und Parasiten wie Spulwurm können für Menschen gefährlich sein. Bußgelder für liegengelassenen Hundekot liegen je nach Stadt zwischen 50 € und 500 €."),
|
||
("Leinenpflicht ohne Schild?", "Leinenpflicht ist Ländersache. Viele Bundesländer haben eine allgemeine Anleinpflicht in Ortschaften und öffentlichen Grünanlagen. Im Zweifel anleinen und die Gemeindesatzung prüfen."),
|
||
("Hund frei laufen trotz angeleintem Hund?", "Nein. Freilaufende Hunde auf angeleine Hunde zuzulassen ist unhöflich und kann den angeleinten Hund in Stress versetzen (Leinenfrust). Immer erst anleinen und fragen ob ein Treffen gewünscht ist."),
|
||
("Haftung bei Beißvorfall?", "Hundehalter haften verschuldensunabhängig für Schäden durch ihren Hund (§ 833 BGB). Eine Hunde-Haftpflichtversicherung ist in vielen Bundesländern Pflicht und in jedem Fall empfehlenswert."),
|
||
]
|
||
|
||
begs_html = "".join(
|
||
f'<div class="item"><h3>{titel}</h3><p>{text}</p></div>'
|
||
for titel, text in begegnungen
|
||
)
|
||
szen_html = "".join(
|
||
f'<div class="item"><h3>{frage}</h3><p>{antwort}</p></div>'
|
||
for frage, antwort in szenarien
|
||
)
|
||
|
||
html = """<!DOCTYPE html>
|
||
<html lang="de">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Hunde-Knigge — Regeln für Hundebesitzer | Ban Yaro</title>
|
||
<meta name="description" content="Der Hunde-Knigge: Begegnungen mit fremden Hunden, Kindern, Radfahrern, Regeln im ÖPNV, Leinenpflicht, Haftung — alles was Hundebesitzer wissen müssen.">
|
||
<meta name="robots" content="index, follow">
|
||
<link rel="canonical" href="https://banyaro.app/knigge">
|
||
<meta property="og:type" content="article">
|
||
<meta property="og:title" content="Hunde-Knigge — Regeln für Hundebesitzer | Ban Yaro">
|
||
<meta property="og:description" content="Begegnungen mit fremden Hunden, Kindern, Radfahrern, Regeln im ÖPNV, Leinenpflicht, Haftung — alles was Hundebesitzer wissen müssen.">
|
||
<meta property="og:url" content="https://banyaro.app/knigge">
|
||
<meta property="og:image" content="https://banyaro.app/icons/icon-512.png">
|
||
<meta property="og:locale" content="de_DE">
|
||
<meta property="og:site_name" content="Ban Yaro">
|
||
<script type="application/ld+json">
|
||
{"@context":"https://schema.org","@type":"FAQPage","mainEntity":[
|
||
{"@type":"Question","name":"Muss Hundekot immer aufgesammelt werden?","acceptedAnswer":{"@type":"Answer","text":"Ja — auch im Gebüsch abseits des Weges. Bußgelder liegen je nach Stadt zwischen 50 € und 500 €."}},
|
||
{"@type":"Question","name":"Gilt Leinenpflicht ohne Schild?","acceptedAnswer":{"@type":"Answer","text":"Leinenpflicht ist Ländersache. Viele Bundesländer haben eine allgemeine Anleinpflicht in Ortschaften. Im Zweifel anleinen."}},
|
||
{"@type":"Question","name":"Darf mein Hund auf einen angeleinten Hund zulaufen?","acceptedAnswer":{"@type":"Answer","text":"Nein. Freilaufende Hunde auf angeleine Hunde zulaufen lassen ist unhöflich und verursacht Leinenfrust."}},
|
||
{"@type":"Question","name":"Wer haftet bei einem Beißvorfall?","acceptedAnswer":{"@type":"Answer","text":"Hundehalter haften verschuldensunabhängig für Schäden durch ihren Hund (§ 833 BGB). Eine Haftpflichtversicherung ist in vielen Bundesländern Pflicht."}}
|
||
]}
|
||
</script>
|
||
<script type="application/ld+json">
|
||
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[
|
||
{"@type":"ListItem","position":1,"name":"Ban Yaro","item":"https://banyaro.app"},
|
||
{"@type":"ListItem","position":2,"name":"Hunde-Knigge","item":"https://banyaro.app/knigge"}
|
||
]}
|
||
</script>
|
||
<style>
|
||
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
||
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#FAF7F2;color:#1a1a1a;line-height:1.65}
|
||
a{color:#C4843A;text-decoration:none}
|
||
a:hover{text-decoration:underline}
|
||
header{background:linear-gradient(135deg,#C4843A,#e8a857);color:#fff;padding:2.5rem 1.5rem;text-align:center}
|
||
header h1{font-size:clamp(1.5rem,4vw,2.2rem);font-weight:800;margin-bottom:.5rem}
|
||
header p{opacity:.92;max-width:560px;margin:0 auto}
|
||
nav{background:#fff;border-bottom:1px solid #e8ddd0;padding:.65rem 1.5rem;display:flex;gap:1rem;align-items:center;flex-wrap:wrap;position:sticky;top:0;z-index:10}
|
||
nav .brand{font-weight:800;color:#C4843A;margin-right:auto}
|
||
nav a{font-size:.9rem;font-weight:500;color:#555}
|
||
nav a:hover{color:#C4843A;text-decoration:none}
|
||
.container{max-width:820px;margin:0 auto;padding:2.5rem 1.5rem}
|
||
section{margin-bottom:3rem}
|
||
h2{font-size:.82rem;font-weight:700;color:#C4843A;text-transform:uppercase;letter-spacing:.06em;margin-bottom:1.25rem;padding-bottom:.4rem;border-bottom:2px solid #f0e8dc}
|
||
.item{background:#fff;border:1px solid #e8ddd0;border-radius:12px;padding:1.25rem 1.5rem;margin-bottom:.9rem}
|
||
.item h3{font-size:1rem;font-weight:700;margin-bottom:.4rem;color:#1a1a1a}
|
||
.item p{font-size:.9rem;color:#444;line-height:1.65}
|
||
.cta-box{background:linear-gradient(135deg,#f5e6d3,#fdf6ef);border:1px solid #e8c99a;border-radius:14px;padding:1.75rem;text-align:center;margin-top:2.5rem}
|
||
.cta-box h3{font-size:1.1rem;font-weight:700;margin-bottom:.5rem}
|
||
.cta-box p{font-size:.9rem;color:#555;margin-bottom:1rem}
|
||
.cta-btn{display:inline-block;background:#C4843A;color:#fff;font-weight:700;padding:.65rem 1.75rem;border-radius:999px;font-size:.95rem}
|
||
.cta-btn:hover{background:#a86e2e;text-decoration:none}
|
||
footer{background:#1a1a1a;color:#aaa;text-align:center;padding:1.5rem;font-size:.82rem;margin-top:2rem}
|
||
footer a{color:#C4843A}
|
||
</style>
|
||
<script type="application/ld+json">
|
||
{{"@context":"https://schema.org","@type":"FAQPage","mainEntity":[
|
||
{{"@type":"Question","name":"Muss mein Hund in der Öffentlichkeit an der Leine?","acceptedAnswer":{{"@type":"Answer","text":"In Deutschland gilt Leinenpflicht in Innenstädten, Parks, auf Kinderspielplätzen und in Tiergehegen. In ländlichen Gebieten gibt es je nach Bundesland Ausnahmen. In der Brut- und Setzzeit (März–Juli) besteht vielerorts erweiterte Leinenpflicht auch auf Feldwegen."}}}},
|
||
{{"@type":"Question","name":"Darf ich meinen Hund im öffentlichen Nahverkehr mitnehmen?","acceptedAnswer":{{"@type":"Answer","text":"Kleine Hunde in einer Transporttasche fahren in der Regel kostenlos. Größere Hunde benötigen oft einen Kinderfahrschein und müssen angeleint und mit Maulkorb reisen. Die Regeln variieren je nach Verkehrsbetrieb."}}}},
|
||
{{"@type":"Question","name":"Wie verhalte ich mich bei der Begegnung mit anderen Hunden?","acceptedAnswer":{{"@type":"Answer","text":"Beim Aufeinandertreffen von Hunden: immer den anderen Hundehalter fragen, ob eine Begegnung erwünscht ist. Leinenstress vermeiden, indem du Abstand hältst oder ausweichst. Einen ängstlichen oder aggressiven Hund nie bedrängen lassen."}}}},
|
||
{{"@type":"Question","name":"Muss ich den Kot meines Hundes beseitigen?","acceptedAnswer":{{"@type":"Answer","text":"Ja, in Deutschland ist die Beseitigung von Hundekot auf öffentlichen Flächen gesetzlich vorgeschrieben. Bei Verstoß drohen Bußgelder von 25–300 €. Bitte immer Kotbeutel dabeihaben."}}}},
|
||
{{"@type":"Question","name":"Brauche ich eine Haftpflichtversicherung für meinen Hund?","acceptedAnswer":{{"@type":"Answer","text":"In den meisten deutschen Bundesländern ist eine Hundehaftpflichtversicherung Pflicht. Sie deckt Schäden ab, die dein Hund an Personen oder Sachen verursacht. Ausnahme: In Bayern ist sie freiwillig, wird aber dringend empfohlen."}}}}
|
||
]}}</script>
|
||
</head>
|
||
<body>
|
||
<header>
|
||
<h1>Hunde-Knigge</h1>
|
||
<p>Regeln, Tipps und häufige Fragen für Hundebesitzer — im Alltag, im ÖPNV und in der Community</p>
|
||
</header>
|
||
<nav>
|
||
<span class="brand">Ban Yaro</span>
|
||
<a href="/wiki/rassen">Rassen-Wiki</a>
|
||
<a href="/info">Über die App</a>
|
||
<a href="/" style="background:#C4843A;color:#fff;padding:.35rem 1rem;border-radius:999px;font-weight:700;font-size:.85rem;text-decoration:none">App öffnen</a>
|
||
</nav>
|
||
<div class="container">
|
||
<section>
|
||
<h2>Begegnungen im Alltag</h2>
|
||
""" + begs_html + """
|
||
</section>
|
||
<section>
|
||
<h2>Häufige Fragen & Regeln</h2>
|
||
""" + szen_html + """
|
||
</section>
|
||
<div class="cta-box">
|
||
<h3>Mehr Tipps in der Ban Yaro App</h3>
|
||
<p>Community-Abstimmungen zu kniffligen Situationen, KI-Situationsberater und alle Hunde-Funktionen kostenlos nutzen.</p>
|
||
<a href="/" class="cta-btn">Kostenlos starten</a>
|
||
</div>
|
||
</div>
|
||
<footer>
|
||
<strong style="color:#fff">Ban Yaro</strong> — Hunde-Knigge ·
|
||
<a href="/wiki/rassen">Rassen-Wiki</a> ·
|
||
<a href="https://banyaro.app">banyaro.app</a>
|
||
</footer>
|
||
</body>
|
||
</html>"""
|
||
return HTMLResponse(content=html, headers={"Cache-Control": "max-age=7200"})
|
||
|
||
|
||
# ------------------------------------------------------------------
|
||
# /presse — Presseseite
|
||
# ------------------------------------------------------------------
|
||
@app.get("/presse")
|
||
async def presse():
|
||
return FileResponse(f"{STATIC_DIR}/presse.html", headers={"Cache-Control": "max-age=3600"})
|
||
|
||
|
||
@app.get("/help")
|
||
async def help_page():
|
||
"""Öffentliche, server-gerenderte Hilfe/FAQ-Seite. Kein Login nötig —
|
||
Apple-Reviewer und Suchmaschinen kommen hier direkt rein."""
|
||
from fastapi.responses import HTMLResponse
|
||
import html as _html
|
||
from routes.help import _load_active_help_articles
|
||
|
||
KAT_LABEL = {
|
||
"installation": "Installation & PWA",
|
||
"erste_schritte": "Erste Schritte",
|
||
"standort": "Standort & Wetter",
|
||
"account": "Account & Passwort",
|
||
"features": "Features erklärt",
|
||
"probleme": "Technische Probleme",
|
||
}
|
||
articles = _load_active_help_articles()
|
||
# Gruppieren nach Kategorie, Reihenfolge aus KAT_LABEL
|
||
by_kat: dict[str, list] = {}
|
||
for a in articles:
|
||
by_kat.setdefault(a["kategorie"], []).append(a)
|
||
kat_order = [k for k in KAT_LABEL.keys() if k in by_kat] + [
|
||
k for k in by_kat.keys() if k not in KAT_LABEL
|
||
]
|
||
|
||
import json as _json
|
||
sections_html = ""
|
||
faq_items = []
|
||
for kat in kat_order:
|
||
label = KAT_LABEL.get(kat, kat.replace("_", " ").title())
|
||
items = "".join(
|
||
f'<details><summary>{_html.escape(a["frage"])}</summary>'
|
||
f'<div class="answer">{_html.escape(a["antwort"]).replace(chr(10), "<br>")}</div></details>'
|
||
for a in by_kat[kat]
|
||
)
|
||
sections_html += f'<section><h2>{_html.escape(label)}</h2>{items}</section>'
|
||
for a in by_kat[kat]:
|
||
faq_items.append({
|
||
"@type": "Question",
|
||
"name": a["frage"],
|
||
"acceptedAnswer": {"@type": "Answer", "text": a["antwort"]}
|
||
})
|
||
faq_json_ld = _json.dumps(faq_items, ensure_ascii=False)
|
||
|
||
html = f"""<!DOCTYPE html>
|
||
<html lang="de">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Hilfe & FAQ — Ban Yaro</title>
|
||
<meta name="description" content="Antworten zu Ban Yaro und Ban Yaro Go: Installation, Standort, Account, Features.">
|
||
<link rel="canonical" href="https://banyaro.app/help">
|
||
<meta name="robots" content="index, follow">
|
||
<link rel="icon" href="/icons/icon-180.png">
|
||
<style>
|
||
:root {{
|
||
--c-bg: #fbfaf6;
|
||
--c-text: #1c1917;
|
||
--c-text-sec: #57534e;
|
||
--c-primary: #C4843A;
|
||
--c-border: #e7e5e0;
|
||
--c-card: #fff;
|
||
}}
|
||
@media (prefers-color-scheme: dark) {{
|
||
:root {{
|
||
--c-bg: #0c0a09; --c-text: #f5f5f4; --c-text-sec: #a8a29e;
|
||
--c-border: #292524; --c-card: #1c1917;
|
||
}}
|
||
}}
|
||
* {{ box-sizing: border-box; }}
|
||
body {{
|
||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||
background: var(--c-bg); color: var(--c-text);
|
||
max-width: 760px; margin: 0 auto;
|
||
padding: 2rem 1.25rem 4rem; line-height: 1.55;
|
||
}}
|
||
a {{ color: var(--c-primary); text-decoration: none; }}
|
||
a:hover {{ text-decoration: underline; }}
|
||
h1 {{ font-size: 1.8rem; margin: 0 0 .25rem; font-weight: 800; }}
|
||
.lead {{ color: var(--c-text-sec); margin: 0 0 2rem; }}
|
||
section {{ margin-bottom: 2rem; }}
|
||
h2 {{ font-size: 1.1rem; margin: 0 0 .75rem;
|
||
color: var(--c-text); font-weight: 700;
|
||
padding-bottom: .25rem; border-bottom: 1px solid var(--c-border); }}
|
||
details {{
|
||
background: var(--c-card);
|
||
border: 1px solid var(--c-border);
|
||
border-radius: 12px;
|
||
margin-bottom: .6rem;
|
||
padding: 0;
|
||
}}
|
||
details summary {{
|
||
cursor: pointer;
|
||
padding: .9rem 1rem;
|
||
font-weight: 600;
|
||
list-style: none;
|
||
position: relative;
|
||
padding-right: 2.5rem;
|
||
}}
|
||
details summary::-webkit-details-marker {{ display: none; }}
|
||
details summary::after {{
|
||
content: "+";
|
||
position: absolute; right: 1rem; top: 50%;
|
||
transform: translateY(-50%);
|
||
font-weight: 400; color: var(--c-primary);
|
||
font-size: 1.4rem; transition: transform .15s;
|
||
}}
|
||
details[open] summary::after {{ content: "−"; }}
|
||
.answer {{
|
||
padding: 0 1rem 1rem;
|
||
color: var(--c-text-sec);
|
||
}}
|
||
.contact {{
|
||
background: var(--c-card);
|
||
border: 1px solid var(--c-border);
|
||
border-radius: 12px;
|
||
padding: 1.25rem;
|
||
margin-top: 2.5rem;
|
||
}}
|
||
.contact h2 {{ border-bottom: none; padding-bottom: 0; margin-bottom: .5rem; }}
|
||
.contact p {{ margin: .25rem 0; color: var(--c-text-sec); }}
|
||
nav.top {{ margin-bottom: 1.5rem; }}
|
||
</style>
|
||
<script type="application/ld+json">{{
|
||
"@context": "https://schema.org",
|
||
"@type": "FAQPage",
|
||
"mainEntity": {faq_json_ld}
|
||
}}</script>
|
||
</head>
|
||
<body>
|
||
<nav class="top"><a href="/">← banyaro.app</a></nav>
|
||
<h1>Hilfe & FAQ</h1>
|
||
<p class="lead">
|
||
Antworten zu Ban Yaro und Ban Yaro Go — der nativen iOS-App für unterwegs.
|
||
</p>
|
||
|
||
{sections_html}
|
||
|
||
<div class="contact">
|
||
<h2>Direkt-Kontakt</h2>
|
||
<p>Wenn du hier nichts findest, schreib uns:</p>
|
||
<p><a href="mailto:support@banyaro.app">support@banyaro.app</a></p>
|
||
<p style="font-size:.85rem;margin-top:1rem">
|
||
Mehr Hilfe gibt es nach dem Anmelden im Suchbereich von
|
||
<a href="/#hilfe">banyaro.app/#hilfe</a>.
|
||
</p>
|
||
</div>
|
||
|
||
<p style="text-align:center;margin-top:3rem;font-size:.85rem;color:var(--c-text-sec)">
|
||
<a href="/impressum">Impressum</a> · <a href="/datenschutz">Datenschutz</a> · <a href="/agb">AGB</a>
|
||
</p>
|
||
</body>
|
||
</html>"""
|
||
return HTMLResponse(content=html, headers={"Cache-Control": "max-age=600"})
|
||
|
||
|
||
@app.get("/konto-loeschen")
|
||
async def konto_loeschen():
|
||
from fastapi.responses import HTMLResponse
|
||
html = """<!DOCTYPE html>
|
||
<html lang="de">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Konto löschen — Ban Yaro</title>
|
||
<link rel="canonical" href="https://banyaro.app/konto-loeschen">
|
||
<meta name="robots" content="noindex">
|
||
<link rel="stylesheet" href="/css/design-system.css">
|
||
<style>
|
||
body { font-family: var(--font-sans); background: var(--c-bg); color: var(--c-text);
|
||
max-width: 600px; margin: 0 auto; padding: 2rem 1.5rem; }
|
||
h1 { font-size: 1.6rem; font-weight: 800; margin-bottom: 0.5rem; }
|
||
p { color: var(--c-text-secondary); line-height: 1.6; margin-bottom: 1rem; }
|
||
ol { color: var(--c-text-secondary); line-height: 2; padding-left: 1.5rem; }
|
||
.btn { display: inline-flex; align-items: center; gap: 0.5rem;
|
||
background: var(--c-primary); color: #fff; border: none;
|
||
border-radius: 100px; padding: 0.75rem 1.5rem;
|
||
font-size: 1rem; font-weight: 600; text-decoration: none;
|
||
margin-top: 1.5rem; cursor: pointer; }
|
||
.warn { background: var(--c-danger-subtle); border: 1px solid var(--c-danger-border);
|
||
border-radius: 12px; padding: 1rem 1.25rem; margin-bottom: 1.5rem;
|
||
color: var(--c-danger); font-size: 0.9rem; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<p style="margin-bottom:2rem"><a href="/" style="color:var(--c-primary);text-decoration:none">← Zurück zu Ban Yaro</a></p>
|
||
<h1>Konto löschen</h1>
|
||
<p>Du kannst dein Ban Yaro-Konto und alle zugehörigen Daten dauerhaft löschen.</p>
|
||
<div class="warn">
|
||
⚠️ Diese Aktion ist unwiderruflich. Alle Daten (Tagebuch, Gesundheit, Training, Fotos) werden dauerhaft gelöscht.
|
||
</div>
|
||
<p><strong>So löschst du dein Konto:</strong></p>
|
||
<ol>
|
||
<li>Öffne <a href="/" style="color:var(--c-primary)">banyaro.app</a> und melde dich an</li>
|
||
<li>Tippe auf das Menü-Symbol oben rechts</li>
|
||
<li>Gehe zu <strong>Einstellungen</strong></li>
|
||
<li>Scrolle nach unten zu <strong>„Konto löschen"</strong></li>
|
||
<li>Bestätige die Löschung</li>
|
||
</ol>
|
||
<a href="/" class="btn">Ban Yaro öffnen</a>
|
||
<p style="margin-top:2rem;font-size:0.85rem">
|
||
Alternativ kannst du die Löschung per E-Mail an
|
||
<a href="mailto:support@banyaro.app" style="color:var(--c-primary)">support@banyaro.app</a> beantragen.
|
||
</p>
|
||
</body>
|
||
</html>"""
|
||
return HTMLResponse(content=html, headers={"Cache-Control": "max-age=3600"})
|
||
|
||
|
||
# /force-update — SW + Cache-Killer für hartnäckige alte Versionen
|
||
# ------------------------------------------------------------------
|
||
@app.get("/force-update")
|
||
async def force_update():
|
||
from fastapi.responses import HTMLResponse
|
||
html = """<!DOCTYPE html><html><head><meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||
<meta name="robots" content="noindex, nofollow">
|
||
<title>Ban Yaro — Update</title>
|
||
<style>body{font-family:sans-serif;display:flex;align-items:center;justify-content:center;
|
||
height:100vh;margin:0;background:#0f1623;color:#fff;flex-direction:column;gap:16px}
|
||
p{color:#94a3b8;font-size:14px}
|
||
button{margin-top:24px;background:#C4843A;color:#fff;border:none;padding:12px 24px;
|
||
border-radius:8px;font-size:16px;cursor:pointer}</style></head>
|
||
<body>
|
||
<div>⏳ Einen Moment…</div>
|
||
<p id="s">Wir besorgen neue Leckerlis 🦴</p>
|
||
<button id="b" style="display:none" onclick="location.replace('/?_t='+Date.now())">App neu starten</button>
|
||
<script>
|
||
sessionStorage.setItem('by_skip_sw_reload','1');
|
||
// Cooldown setzen, BEVOR neu geladen wird — sonst kann ein direkter
|
||
// Aufruf von /force-update (ohne navigate()) bei kurzem Versions-Mismatch
|
||
// eine Dauerschleife auslösen, weil der Loop-Schutz in app.js nie greift.
|
||
try { localStorage.setItem('by_last_force_update', String(Date.now())); } catch(e) {}
|
||
// Cleanup IM HINTERGRUND starten (fire-and-forget) — kein await,
|
||
// kein Blockieren. Selbst wenn die Promises nie resolven (iOS-Bug),
|
||
// hängen wir nicht.
|
||
try {
|
||
if (navigator.serviceWorker) {
|
||
navigator.serviceWorker.getRegistrations()
|
||
.then(r => r.forEach(s => s.unregister().catch(() => {})))
|
||
.catch(() => {});
|
||
}
|
||
if (window.caches) {
|
||
caches.keys()
|
||
.then(k => k.forEach(c => caches.delete(c).catch(() => {})))
|
||
.catch(() => {});
|
||
}
|
||
} catch(e) {}
|
||
|
||
// Sofort reload — keine Promise-Abhängigkeit
|
||
setTimeout(() => location.replace('/?_t=' + Date.now()), 150);
|
||
|
||
// Fallback: falls Reload nach 3s noch nicht passiert ist
|
||
// (z.B. SW intercepted), Button anzeigen für manuellen Tap
|
||
setTimeout(() => { document.getElementById('b').style.display = ''; }, 3000);
|
||
|
||
// Fallback 2: nach 6s automatisch nochmal versuchen mit hartem reload
|
||
setTimeout(() => location.href = '/?_t=' + Date.now() + '&hard=1', 6000);
|
||
</script></body></html>"""
|
||
return HTMLResponse(content=html, headers={"Cache-Control": "no-store"})
|
||
|
||
|
||
# /partner — Influencer-Landingpage
|
||
# ------------------------------------------------------------------
|
||
@app.get("/partner")
|
||
async def partner_landing():
|
||
from fastapi.responses import HTMLResponse
|
||
from database import db as _db
|
||
with _db() as conn:
|
||
total_founders = conn.execute("SELECT COUNT(*) FROM users WHERE is_founder=1").fetchone()[0]
|
||
partners = conn.execute(
|
||
"""SELECT label, uses FROM partner_codes WHERE grants_founder=1 ORDER BY uses DESC LIMIT 5"""
|
||
).fetchall()
|
||
open_slots = max(0, 100 - total_founders)
|
||
|
||
partner_rows = ''.join([
|
||
f'<div class="pl-partner-row"><span class="pl-partner-name">{p["label"]}</span>'
|
||
f'<span class="pl-partner-score">{p["uses"]} Gründer</span></div>'
|
||
for p in partners
|
||
]) or '<div style="color:#94a3b8;font-size:0.85rem">Noch keine Partner aktiv — sei der Erste.</div>'
|
||
|
||
html = f"""<!DOCTYPE html>
|
||
<html lang="de">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Ban Yaro Partner — Werde Teil der ersten 100</title>
|
||
<meta name="description" content="Werde Ban Yaro Partner. Gib deiner Community exklusive Gründer-Lizenzen — nur 100 Plätze weltweit, nie wieder erhältlich.">
|
||
<link rel="canonical" href="https://banyaro.app/partner">
|
||
<meta name="robots" content="index, follow">
|
||
<meta property="og:title" content="Ban Yaro Partner">
|
||
<meta property="og:description" content="Gib deiner Community etwas Besonderes. 100 Gründer-Plätze. Exklusiv. Für immer.">
|
||
<meta property="og:image" content="https://banyaro.app/icons/icon-512.png">
|
||
<style>
|
||
*,*::before,*::after{{box-sizing:border-box;margin:0;padding:0}}
|
||
body{{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#faf9f7;color:#1a1a1a;line-height:1.6}}
|
||
a{{color:#C4843A;text-decoration:none}}
|
||
.pl-wrap{{max-width:680px;margin:0 auto;padding:24px 20px 80px}}
|
||
|
||
/* Hero */
|
||
.pl-hero{{text-align:center;padding:60px 0 48px;border-bottom:1px solid #e8e4df}}
|
||
.pl-logo{{width:72px;height:72px;border-radius:18px;margin:0 auto 24px;display:block}}
|
||
.pl-eyebrow{{font-size:0.75rem;font-weight:700;letter-spacing:.12em;text-transform:uppercase;color:#C4843A;margin-bottom:12px}}
|
||
.pl-h1{{font-size:clamp(1.8rem,5vw,2.8rem);font-weight:800;line-height:1.15;color:#111;margin-bottom:16px}}
|
||
.pl-sub{{font-size:1.05rem;color:#555;max-width:480px;margin:0 auto 32px}}
|
||
.pl-cta{{display:inline-block;background:#C4843A;color:#fff;font-weight:700;font-size:1rem;padding:14px 32px;border-radius:999px;text-decoration:none;transition:opacity .15s}}
|
||
.pl-cta:hover{{opacity:.88}}
|
||
|
||
/* Slot-Counter */
|
||
.pl-counter{{background:#fff;border:1px solid #e8e4df;border-radius:16px;padding:28px 24px;margin:40px 0;text-align:center}}
|
||
.pl-counter-num{{font-size:3rem;font-weight:800;color:#C4843A;line-height:1}}
|
||
.pl-counter-label{{font-size:0.85rem;color:#888;margin-top:4px}}
|
||
.pl-bar-wrap{{background:#f0ece8;border-radius:999px;height:10px;margin:16px 0 8px;overflow:hidden}}
|
||
.pl-bar{{background:linear-gradient(90deg,#C4843A,#e0a870);height:100%;border-radius:999px;width:{min(100, round(total_founders/100*100))}%}}
|
||
.pl-bar-labels{{display:flex;justify-content:space-between;font-size:0.75rem;color:#aaa}}
|
||
|
||
/* Vorteile */
|
||
.pl-benefits{{margin:40px 0}}
|
||
.pl-section-title{{font-size:1.1rem;font-weight:700;margin-bottom:20px;color:#111}}
|
||
.pl-benefit{{display:flex;gap:16px;align-items:flex-start;padding:16px 0;border-bottom:1px solid #f0ece8}}
|
||
.pl-benefit:last-child{{border-bottom:none}}
|
||
.pl-benefit-icon{{font-size:1.6rem;flex-shrink:0;width:40px;text-align:center}}
|
||
.pl-benefit-title{{font-weight:700;font-size:0.95rem;margin-bottom:2px}}
|
||
.pl-benefit-text{{font-size:0.88rem;color:#555}}
|
||
|
||
/* Wie es funktioniert */
|
||
.pl-steps{{margin:40px 0}}
|
||
.pl-step{{display:flex;gap:16px;align-items:flex-start;margin-bottom:24px}}
|
||
.pl-step-num{{width:32px;height:32px;border-radius:50%;background:#C4843A;color:#fff;font-weight:800;font-size:0.9rem;display:flex;align-items:center;justify-content:center;flex-shrink:0}}
|
||
.pl-step-text{{padding-top:4px;font-size:0.92rem;color:#333}}
|
||
.pl-step-text strong{{display:block;font-weight:700;color:#111;margin-bottom:2px}}
|
||
|
||
/* Leaderboard */
|
||
.pl-leaderboard{{background:#fff;border:1px solid #e8e4df;border-radius:16px;padding:24px;margin:40px 0}}
|
||
.pl-partner-row{{display:flex;justify-content:space-between;align-items:center;padding:10px 0;border-bottom:1px solid #f5f3f0;font-size:0.9rem}}
|
||
.pl-partner-row:last-child{{border-bottom:none}}
|
||
.pl-partner-name{{font-weight:600}}
|
||
.pl-partner-score{{color:#C4843A;font-weight:700}}
|
||
|
||
/* Kontakt */
|
||
.pl-contact{{background:linear-gradient(135deg,#C4843A,#d4944a);border-radius:20px;padding:40px 32px;text-align:center;color:#fff;margin:40px 0}}
|
||
.pl-contact h2{{font-size:1.5rem;font-weight:800;margin-bottom:12px}}
|
||
.pl-contact p{{font-size:0.95rem;opacity:.9;margin-bottom:24px;max-width:400px;margin-left:auto;margin-right:auto}}
|
||
.pl-contact-btn{{display:inline-block;background:#fff;color:#C4843A;font-weight:700;padding:12px 28px;border-radius:999px;font-size:0.95rem}}
|
||
|
||
/* Footer */
|
||
.pl-footer{{text-align:center;font-size:0.78rem;color:#aaa;padding-top:32px;border-top:1px solid #f0ece8}}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="pl-wrap">
|
||
|
||
<!-- Hero -->
|
||
<div class="pl-hero">
|
||
<img src="/icons/icon-192.png" alt="Ban Yaro" class="pl-logo">
|
||
<div class="pl-eyebrow">Ban Yaro · Influencer-Programm</div>
|
||
<h1 class="pl-h1">Gib deiner Community<br>etwas für immer.</h1>
|
||
<p class="pl-sub">100 Gründer-Plätze. Weltweit. Nie wieder erhältlich.<br>Als Partner bringst du deine Follower nach vorne — und steigst im Ranking auf.</p>
|
||
<a href="mailto:partner@banyaro.app" class="pl-cta">Jetzt Partner werden</a>
|
||
</div>
|
||
|
||
<!-- Slot-Counter -->
|
||
<div class="pl-counter">
|
||
<div class="pl-counter-num">{open_slots}</div>
|
||
<div class="pl-counter-label">Gründer-Plätze noch frei (von 100)</div>
|
||
<div class="pl-bar-wrap"><div class="pl-bar"></div></div>
|
||
<div class="pl-bar-labels"><span>0</span><span>{total_founders} vergeben</span><span>100</span></div>
|
||
</div>
|
||
|
||
<!-- Vorteile -->
|
||
<div class="pl-benefits">
|
||
<div class="pl-section-title">Was du und deine Community bekommen</div>
|
||
|
||
<div class="pl-benefit">
|
||
<div class="pl-benefit-icon">🏆</div>
|
||
<div>
|
||
<div class="pl-benefit-title">Gründer-Lizenz für deine Follower</div>
|
||
<div class="pl-benefit-text">Jeder der sich mit deinem Code registriert bekommt einen der 100 Gründer-Plätze — mit einer nummerierten Badge <strong>„Gründer #N"</strong> die dauerhaft im Profil und im Forum sichtbar ist. Nie wieder erhältlich.</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="pl-benefit">
|
||
<div class="pl-benefit-icon">🤝</div>
|
||
<div>
|
||
<div class="pl-benefit-title">Dein persönlicher Partner-Code</div>
|
||
<div class="pl-benefit-text">Du bekommst einen eigenen Code (z.B. <strong>HUNDEBLOG</strong>). Follower die sich damit registrieren werden automatisch Gründer — du siehst in Echtzeit wie viele du gebracht hast.</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="pl-benefit">
|
||
<div class="pl-benefit-icon">📊</div>
|
||
<div>
|
||
<div class="pl-benefit-title">Öffentliches Partner-Ranking</div>
|
||
<div class="pl-benefit-text">Auf der <a href="/app#gruender">Gründer-Seite</a> siehen alle wer die meisten Gründer gebracht hat. Das Ranking motiviert deine Follower mitzumachen — und stärkt deine Position gegenüber anderen Influencern.</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="pl-benefit">
|
||
<div class="pl-benefit-icon">💜</div>
|
||
<div>
|
||
<div class="pl-benefit-title">Partner-Badge für dich</div>
|
||
<div class="pl-benefit-text">Du selbst bekommst ein <strong>„Partner"</strong>-Badge in deinem Profil — sichtbar für alle Nutzer der App.</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="pl-benefit">
|
||
<div class="pl-benefit-icon">🎁</div>
|
||
<div>
|
||
<div class="pl-benefit-title">Lebenslang kostenlos — für immer</div>
|
||
<div class="pl-benefit-text">Gründer zahlen nie für Premium-Features — egal was wir in Zukunft einführen. Das ist ein echtes Dankeschön für die Pioniere.</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Wie es funktioniert -->
|
||
<div class="pl-steps">
|
||
<div class="pl-section-title">Wie es funktioniert</div>
|
||
<div class="pl-step">
|
||
<div class="pl-step-num">1</div>
|
||
<div class="pl-step-text"><strong>Kontakt aufnehmen</strong>Schreib uns kurz an partner@banyaro.app — wir richten deinen persönlichen Code ein.</div>
|
||
</div>
|
||
<div class="pl-step">
|
||
<div class="pl-step-num">2</div>
|
||
<div class="pl-step-text"><strong>Code teilen</strong>Du postest deinen Code in Story, Reel oder Post — deine Follower registrieren sich auf banyaro.app.</div>
|
||
</div>
|
||
<div class="pl-step">
|
||
<div class="pl-step-num">3</div>
|
||
<div class="pl-step-text"><strong>Gründer werden</strong>Jede Registrierung mit deinem Code sichert automatisch einen der 100 Gründer-Plätze. Du siehst deinen Fortschritt in Echtzeit.</div>
|
||
</div>
|
||
<div class="pl-step">
|
||
<div class="pl-step-num">4</div>
|
||
<div class="pl-step-text"><strong>Im Ranking aufsteigen</strong>Je mehr Gründer du bringst, desto höher dein Platz auf der öffentlichen Gründer-Seite.</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Leaderboard -->
|
||
{'<div class="pl-leaderboard"><div class="pl-section-title" style="margin-bottom:16px">🏅 Aktuelles Partner-Ranking</div>' + partner_rows + '</div>' if partners else ''}
|
||
|
||
<!-- Was ist Ban Yaro -->
|
||
<div style="background:#fff;border:1px solid #e8e4df;border-radius:16px;padding:28px 24px;margin:40px 0">
|
||
<div class="pl-section-title">Was ist Ban Yaro?</div>
|
||
<p style="font-size:0.9rem;color:#555;margin-bottom:12px">Ban Yaro ist die Hunde-App für alles was Halter brauchen — Tagebuch, Gesundheit, Routen, Giftköder-Alarm, Community. Kostenlos, ohne App Store, direkt im Browser oder als PWA.</p>
|
||
<a href="https://banyaro.app" style="font-weight:700;color:#C4843A;font-size:0.9rem">banyaro.app entdecken →</a>
|
||
</div>
|
||
|
||
<!-- CTA -->
|
||
<div class="pl-contact">
|
||
<h2>Bereit dabei zu sein?</h2>
|
||
<p>Schreib uns kurz wer du bist und auf welchem Kanal du aktiv bist — wir richten deinen Code binnen 24h ein.</p>
|
||
<a href="mailto:partner@banyaro.app?subject=Ban Yaro Partner&body=Hallo,%0A%0Aich bin interessiert am Ban Yaro Partner-Programm.%0A%0AKanal / Reichweite:%0A%0AViele Grüße" class="pl-contact-btn">📧 partner@banyaro.app</a>
|
||
</div>
|
||
|
||
<!-- Footer -->
|
||
<div class="pl-footer">
|
||
<a href="https://banyaro.app">banyaro.app</a> ·
|
||
<a href="/impressum">Impressum</a> ·
|
||
<a href="/datenschutz">Datenschutz</a>
|
||
</div>
|
||
|
||
</div>
|
||
</body>
|
||
</html>"""
|
||
return HTMLResponse(content=html, headers={"Cache-Control": "no-store, 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)
|
||
|
||
|
||
# ------------------------------------------------------------------
|
||
# Digitaler Hundepass — öffentlicher Share-Link (kein Login nötig)
|
||
# ------------------------------------------------------------------
|
||
@app.get("/pass/{token}")
|
||
async def passport_share_page(token: str):
|
||
from fastapi.responses import HTMLResponse
|
||
from database import db as _db
|
||
from datetime import date as _date
|
||
|
||
with _db() as conn:
|
||
share = conn.execute(
|
||
"SELECT * FROM passport_shares WHERE token=?", (token,)
|
||
).fetchone()
|
||
if not share:
|
||
return HTMLResponse(
|
||
'<meta charset="UTF-8"><style>body{font-family:sans-serif;padding:2rem;color:#333}</style>'
|
||
'<h2>Link nicht gefunden</h2><p>Dieser Hundepass-Link ist ungültig.</p>',
|
||
status_code=404
|
||
)
|
||
if share["valid_until"] < _date.today().isoformat():
|
||
return HTMLResponse(
|
||
'<meta charset="UTF-8"><style>body{font-family:sans-serif;padding:2rem;color:#333}</style>'
|
||
'<h2>Link abgelaufen</h2><p>Dieser Hundepass-Link ist nicht mehr gültig.</p>',
|
||
status_code=410
|
||
)
|
||
dog_id = share["dog_id"]
|
||
dog = conn.execute("SELECT * FROM dogs WHERE id=?", (dog_id,)).fetchone()
|
||
meta = conn.execute("SELECT * FROM dog_passport_meta WHERE dog_id=?", (dog_id,)).fetchone()
|
||
vaccs = conn.execute(
|
||
"SELECT * FROM vaccinations WHERE dog_id=? ORDER BY datum DESC", (dog_id,)
|
||
).fetchall()
|
||
meds = conn.execute(
|
||
"SELECT * FROM medications WHERE dog_id=? ORDER BY von DESC, id DESC", (dog_id,)
|
||
).fetchall()
|
||
def _fmt(d):
|
||
if not d:
|
||
return "–"
|
||
try:
|
||
from datetime import datetime as _dt
|
||
return _dt.strptime(d[:10], "%Y-%m-%d").strftime("%d.%m.%Y")
|
||
except Exception:
|
||
return d
|
||
|
||
dog = dict(dog)
|
||
meta = dict(meta) if meta else {}
|
||
vaccs = [dict(v) for v in vaccs]
|
||
meds = [dict(m) for m in meds]
|
||
|
||
_g_map = {"m": "Rüde", "w": "Hündin"}
|
||
|
||
vacc_rows = "".join(f"""
|
||
<tr>
|
||
<td>{v['krankheit'] or ''}</td>
|
||
<td>{_fmt(v['datum'])}</td>
|
||
<td>{_fmt(v['naechste'])}</td>
|
||
<td>{v['tierarzt'] or '–'}</td>
|
||
<td>{v['charge_nr'] or '–'}</td>
|
||
</tr>""" for v in vaccs) or "<tr><td colspan='5' style='color:#999'>Keine Einträge</td></tr>"
|
||
|
||
med_rows = "".join(f"""
|
||
<tr>
|
||
<td>{m['name'] or ''}</td>
|
||
<td>{m['dosierung'] or '–'}</td>
|
||
<td>{_fmt(m['von'])}</td>
|
||
<td>{_fmt(m['bis']) if m['bis'] else 'dauerhaft'}</td>
|
||
<td>{m['notiz'] or '–'}</td>
|
||
</tr>""" for m in meds) or "<tr><td colspan='5' style='color:#999'>Keine Einträge</td></tr>"
|
||
|
||
html = f"""<!DOCTYPE html>
|
||
<html lang="de">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<meta name="robots" content="noindex">
|
||
<title>Hundepass — {dog['name']}</title>
|
||
<style>
|
||
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
|
||
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||
background: #f5f7f5; color: #222; }}
|
||
.header {{ background: #28a764; color: #fff; padding: 24px 20px; text-align: center; }}
|
||
.header h1 {{ font-size: 1.5rem; margin-bottom: 4px; }}
|
||
.header p {{ font-size: 0.9rem; opacity: 0.85; }}
|
||
.container {{ max-width: 760px; margin: 24px auto; padding: 0 16px; }}
|
||
.card {{ background: #fff; border-radius: 12px; padding: 20px; margin-bottom: 20px;
|
||
box-shadow: 0 1px 4px rgba(0,0,0,.08); }}
|
||
.card h2 {{ font-size: 1rem; color: #28a764; margin-bottom: 14px; display: flex;
|
||
align-items: center; gap: 8px; border-bottom: 1px solid #e8f5ee; padding-bottom: 10px; }}
|
||
.info-grid {{ display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }}
|
||
.info-item label {{ font-size: 0.75rem; color: #888; display: block; margin-bottom: 2px; }}
|
||
.info-item span {{ font-size: 0.9rem; font-weight: 500; }}
|
||
table {{ width: 100%; border-collapse: collapse; font-size: 0.85rem; }}
|
||
th {{ background: #e8f5ee; text-align: left; padding: 8px; font-size: 0.8rem;
|
||
color: #444; font-weight: 600; }}
|
||
td {{ padding: 8px; border-bottom: 1px solid #f0f0f0; vertical-align: top; }}
|
||
tr:last-child td {{ border-bottom: none; }}
|
||
.footer {{ text-align: center; font-size: 0.75rem; color: #aaa; margin: 24px 0; }}
|
||
@media (max-width: 500px) {{ .info-grid {{ grid-template-columns: 1fr; }} }}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="header">
|
||
<h1>Ban Yaro</h1>
|
||
<p>Digitaler Hundepass — {dog['name']}</p>
|
||
</div>
|
||
<div class="container">
|
||
<div class="card">
|
||
<h2>Hundeangaben</h2>
|
||
<div class="info-grid">
|
||
<div class="info-item"><label>Name</label><span>{dog['name']}</span></div>
|
||
<div class="info-item"><label>Rasse</label><span>{dog.get('rasse') or '–'}</span></div>
|
||
<div class="info-item"><label>Geburtstag</label><span>{_fmt(dog.get('geburtstag'))}</span></div>
|
||
<div class="info-item"><label>Geschlecht</label><span>{_g_map.get(dog.get('geschlecht',''), '–')}</span></div>
|
||
<div class="info-item"><label>Chip-Nr.</label><span>{dog.get('chip_nr') or '–'}</span></div>
|
||
<div class="info-item"><label>Blutgruppe</label><span>{meta.get('blutgruppe') or '–'}</span></div>
|
||
</div>
|
||
{('<div style="margin-top:14px"><label style="font-size:.75rem;color:#888">Allergien</label>'
|
||
f'<div style="font-size:.9rem">{meta["allergien"]}</div></div>') if meta.get("allergien") else ''}
|
||
{('<div style="margin-top:10px"><label style="font-size:.75rem;color:#888">Besonderheiten</label>'
|
||
f'<div style="font-size:.9rem">{meta["besonderheiten"]}</div></div>') if meta.get("besonderheiten") else ''}
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h2>Impfungen</h2>
|
||
<table>
|
||
<thead><tr>
|
||
<th>Krankheit</th><th>Datum</th><th>Nächste</th><th>Tierarzt</th><th>Charge</th>
|
||
</tr></thead>
|
||
<tbody>{vacc_rows}</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h2>Medikamente</h2>
|
||
<table>
|
||
<thead><tr>
|
||
<th>Medikament</th><th>Dosierung</th><th>Von</th><th>Bis</th><th>Notiz</th>
|
||
</tr></thead>
|
||
<tbody>{med_rows}</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
<div class="footer">Erstellt mit Ban Yaro — banyaro.app</div>
|
||
</body>
|
||
</html>"""
|
||
return HTMLResponse(html)
|
||
|
||
|
||
# ------------------------------------------------------------------
|
||
# Wurfbörse /wurfboerse — SSR-Seite mit korrektem Canonical
|
||
# ------------------------------------------------------------------
|
||
@app.get("/wurfboerse")
|
||
async def wurfboerse_page():
|
||
from fastapi.responses import HTMLResponse
|
||
from database import db as _db
|
||
import html as _h
|
||
|
||
import json as _json
|
||
litters_html = ""
|
||
rows = []
|
||
ld_items = []
|
||
try:
|
||
with _db() as conn:
|
||
rows = conn.execute(
|
||
"""SELECT l.id, l.welpen_verfuegbar, l.preis_spanne, l.status,
|
||
bp.zwingername, bp.rasse,
|
||
wr.name AS rasse_name
|
||
FROM litters l
|
||
JOIN breeder_profiles bp ON bp.id = l.breeder_id
|
||
JOIN users u ON u.id = bp.user_id
|
||
LEFT JOIN wiki_rassen wr ON wr.id = bp.breed_id
|
||
WHERE l.sichtbar=1 AND u.rolle='breeder'
|
||
AND (l.sichtbar_bis IS NULL OR l.sichtbar_bis >= date('now'))
|
||
ORDER BY l.created_at DESC LIMIT 60"""
|
||
).fetchall()
|
||
for i, r in enumerate(rows, 1):
|
||
rasse_label = _h.escape(r["rasse_name"] or r["rasse"] or "")
|
||
zw = _h.escape(r["zwingername"] or "")
|
||
verfueg = r["welpen_verfuegbar"]
|
||
preis = _h.escape(r["preis_spanne"] or "")
|
||
status_map = {"geplant": "Geplant", "geboren": "Geboren", "verfuegbar": "Verfügbar"}
|
||
status_label = status_map.get(r["status"], r["status"])
|
||
litters_html += f"""<div class="litter-card">
|
||
<div class="litter-breed">{rasse_label or "Unbekannte Rasse"}</div>
|
||
<div class="litter-breeder">Züchter: <a href="/breeder/{_h.escape(r['zwingername'] or '')}">{zw}</a></div>
|
||
<div class="litter-meta">
|
||
<span class="badge">{status_label}</span>
|
||
{f'<span>{verfueg} Welpen verfügbar</span>' if verfueg else ''}
|
||
{f'<span>{preis}</span>' if preis else ''}
|
||
</div>
|
||
</div>"""
|
||
ld_items.append({
|
||
"@type": "ListItem",
|
||
"position": i,
|
||
"name": f"{r['rasse_name'] or r['rasse'] or 'Welpen'} — {r['zwingername'] or 'Züchter'}",
|
||
"url": f"https://banyaro.app/breeder/{r['zwingername']}"
|
||
})
|
||
except Exception:
|
||
pass
|
||
|
||
count_text = f"{len(rows)} Würfe" if litters_html else "Aktuell keine Würfe eingetragen"
|
||
ld_json = _json.dumps({
|
||
"@context": "https://schema.org",
|
||
"@type": "ItemList",
|
||
"name": "Wurfbörse — Hundewelpen bei Ban Yaro",
|
||
"description": "Aktuelle Würfe von geprüften Züchtern auf Ban Yaro",
|
||
"url": "https://banyaro.app/wurfboerse",
|
||
"numberOfItems": len(rows),
|
||
"itemListElement": ld_items
|
||
}, ensure_ascii=False)
|
||
|
||
html = f"""<!DOCTYPE html>
|
||
<html lang="de">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Wurfbörse — Hundewelpen bei Ban Yaro</title>
|
||
<meta name="description" content="Seriöse Hundewelpen von geprüften Züchtern auf Ban Yaro. Jetzt Welpen finden, Züchter kontaktieren und Stammbaum einsehen.">
|
||
<link rel="canonical" href="https://banyaro.app/wurfboerse">
|
||
<meta name="robots" content="index, follow">
|
||
<meta property="og:title" content="Wurfbörse — Hundewelpen bei Ban Yaro">
|
||
<meta property="og:description" content="Seriöse Hundewelpen von geprüften Züchtern auf Ban Yaro.">
|
||
<meta property="og:image" content="https://banyaro.app/icons/icon-512.png">
|
||
<link rel="icon" href="/icons/icon-180.png">
|
||
<script type="application/ld+json">{ld_json}</script>
|
||
<style>
|
||
*,*::before,*::after{{box-sizing:border-box;margin:0;padding:0}}
|
||
body{{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#fbfaf6;color:#1c1917;padding:0 0 4rem}}
|
||
.hero{{background:linear-gradient(135deg,#C4843A,#e8a857);color:#fff;padding:2.5rem 1.5rem;text-align:center}}
|
||
.hero h1{{font-size:clamp(1.6rem,4vw,2.2rem);font-weight:800;margin-bottom:.5rem}}
|
||
.hero p{{opacity:.9;font-size:1rem}}
|
||
.container{{max-width:720px;margin:2rem auto;padding:0 1rem}}
|
||
.count{{font-size:.85rem;color:#78716c;margin-bottom:1.5rem}}
|
||
.litter-card{{background:#fff;border-radius:12px;padding:1.2rem 1.4rem;margin-bottom:1rem;
|
||
box-shadow:0 1px 4px rgba(0,0,0,.08);border-left:4px solid #C4843A}}
|
||
.litter-breed{{font-size:1.05rem;font-weight:700;color:#1c1917;margin-bottom:.3rem}}
|
||
.litter-breeder{{font-size:.875rem;color:#57534e;margin-bottom:.5rem}}
|
||
.litter-breeder a{{color:#C4843A;text-decoration:none}}
|
||
.litter-meta{{display:flex;gap:.6rem;flex-wrap:wrap;font-size:.8rem;color:#78716c}}
|
||
.badge{{background:#fef3c7;color:#92400e;border-radius:100px;padding:.1rem .6rem;font-weight:600}}
|
||
.cta{{display:block;text-align:center;margin-top:2.5rem}}
|
||
.cta a{{background:#C4843A;color:#fff;border-radius:100px;padding:.85rem 2rem;
|
||
font-size:1rem;font-weight:700;text-decoration:none;display:inline-block}}
|
||
.empty{{text-align:center;padding:3rem 1rem;color:#78716c}}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="hero">
|
||
<h1>Wurfbörse</h1>
|
||
<p>Hundewelpen von geprüften Züchtern</p>
|
||
</div>
|
||
<div class="container">
|
||
<p class="count">{count_text}</p>
|
||
{litters_html or '<div class="empty"><p>Aktuell keine Würfe eingetragen.<br>Schau bald wieder vorbei!</p></div>'}
|
||
<div class="cta"><a href="/">Zur Ban Yaro App</a></div>
|
||
</div>
|
||
</body>
|
||
</html>"""
|
||
return HTMLResponse(content=html, headers={"Cache-Control": "max-age=1800"})
|
||
|
||
|
||
# SPA Fallback — ALLE nicht-API-Routen gehen zur index.html
|
||
@app.get("/{full_path:path}")
|
||
async def spa_fallback(full_path: str):
|
||
IS_STAGING = os.getenv("STAGING", "false").lower() == "true"
|
||
if IS_STAGING:
|
||
from fastapi.responses import HTMLResponse
|
||
with open(f"{STATIC_DIR}/index.html", encoding="utf-8") as f:
|
||
html = f.read()
|
||
html = html.replace(
|
||
'<link rel="apple-touch-icon" sizes="180x180" href="/icons/icon-180.png">',
|
||
'<link rel="apple-touch-icon" sizes="180x180" href="/icons/icon-180-staging.png">',
|
||
)
|
||
return HTMLResponse(content=html, headers={"Cache-Control": "no-store, no-cache"})
|
||
return FileResponse(
|
||
f"{STATIC_DIR}/index.html",
|
||
headers={"Cache-Control": "no-store, no-cache"}
|
||
)
|