"""
BAN YARO — FastAPI Hauptanwendung
"""
import os
import html
import logging
from collections import deque
from fastapi import FastAPI, Request
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse, JSONResponse
from starlette.middleware.base import BaseHTTPMiddleware
from fastapi.middleware.gzip import GZipMiddleware
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
# ------------------------------------------------------------------
@asynccontextmanager
async def lifespan(app: FastAPI):
logger.info("Ban Yaro startet...")
init_db()
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' 'unsafe-inline' 'unsafe-eval'; "
"style-src 'self' 'unsafe-inline'; "
"img-src 'self' data: blob: https:; "
"connect-src 'self' https:; "
"frame-ancestors 'none'; "
"base-uri 'self'; "
"form-action 'self';"
)
return response
app.add_middleware(SecurityHeadersMiddleware)
# Globales File-Upload-Limit (20 MB)
_MAX_UPLOAD_BYTES = 20 * 1024 * 1024
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 _CacheControlMiddleware(BaseHTTPMiddleware):
"""Setzt Cache-Control-Header für statische Assets.
CSS/JS: no-cache (ETag-Validierung) — iOS cached sonst ewig ohne Ablaufdatum.
Versioned Assets (?v=…): immutable — URL ändert sich bei Updates.
"""
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")):
if "v=" in str(request.url.query):
response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
else:
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/'):
response.headers['Cache-Control'] = 'public, max-age=31536000, immutable'
return response
app.add_middleware(MediaCacheMiddleware)
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.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
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(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(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"])
# ------------------------------------------------------------------
# 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)
app.mount("/media", StaticFiles(directory=MEDIA_DIR), name="media")
@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/info", "monthly", "0.9"),
("https://banyaro.app/wiki/rassen", "weekly", "0.8"),
("https://banyaro.app/knigge", "monthly", "0.8"),
("https://banyaro.app/wurfboerse", "daily", "0.8"),
]
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"))
events = conn.execute(
"SELECT id FROM events WHERE datum >= date('now') LIMIT 200"
).fetchall()
for e in events:
urls.append((f"https://banyaro.app/api/events/{e['id']}", "weekly", "0.5"))
# Ö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""" ' if foto else '
{r.get('gruppe') or ''}
{total} Rassen — Charakter, Eignung, Pflege auf einen Blick
{esc(b.get('text',''))}
{esc(r["beschreibung"])}
' '{esc(r["vorkommen_de"])}
' '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.
Kostenlos startenpowered by BAN YARO
""" 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): return FileResponse(f"{STATIC_DIR}/index.html", headers={"Cache-Control": "no-cache"}) @app.get("/breeder/{zwingername}") async def breeder_profile_page(zwingername: str): return FileResponse(f"{STATIC_DIR}/index.html", headers={"Cache-Control": "no-cache"}) @app.get("/litters") async def litters_page(): return FileResponse(f"{STATIC_DIR}/index.html", headers={"Cache-Control": "no-cache"}) # ------------------------------------------------------------------ # Widget-Vorschau /widget # ------------------------------------------------------------------ @app.get("/widget") async def widget_page(): return FileResponse(f"{STATIC_DIR}/index.html", headers={"Cache-Control": "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( '' 'Bitte einloggen um den Ausweis anzuzeigen.
', 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("Hund nicht gefunden.
", 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 '| Impfung | Datum | Nächste Fälligkeit | Charge | Tierarzt |
|---|
| Medikament | Seit | Dosierung | Häufigkeit |
|---|
| Allergen | Schweregrad | Reaktion | Seit |
|---|
{text}
{antwort}
Regeln, Tipps und häufige Fragen für Hundebesitzer — im Alltag, im ÖPNV und in der Community
Community-Abstimmungen zu kniffligen Situationen, KI-Situationsberater und alle Hunde-Funktionen kostenlos nutzen.
Kostenlos starten100 Gründer-Plätze. Weltweit. Nie wieder erhältlich.
Als Partner bringst du deine Follower nach vorne — und steigst im Ranking auf.
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.
banyaro.app entdecken →Schreib uns kurz wer du bist und auf welchem Kanal du aktiv bist — wir richten deinen Code binnen 24h ein.
📧 partner@banyaro.app