"""
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' 'unsafe-inline' 'unsafe-eval' https://umami.motocamp.de; "
"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)
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/info", "monthly", "0.8"),
("https://banyaro.app/presse", "monthly", "0.7"),
("https://banyaro.app/wiki/rassen", "weekly", "0.8"),
("https://banyaro.app/knigge", "monthly", "0.7"),
("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.
{f'{zuchter_count} verifizierte {name}-Züchter · ' if zuchter_count > 0 else ''}{dogs_count} Nutzer haben diesen Hund · Alle 1003 Rassen
powered 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-store, no-cache"}) @app.get("/breeder/{zwingername}") async def breeder_profile_page(zwingername: str): return FileResponse(f"{STATIC_DIR}/index.html", 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( '' '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 startenDu kannst dein Ban Yaro-Konto und alle zugehörigen Daten dauerhaft löschen.
So löschst du dein Konto:
Alternativ kannst du die Löschung per E-Mail an support@banyaro.app beantragen.
""" 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 = """Wir besorgen neue Leckerlis 🦴
""" 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'100 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.appDieser Hundepass-Link ist ungültig.
', status_code=404 ) if share["valid_until"] < _date.today().isoformat(): return HTMLResponse( '' 'Dieser Hundepass-Link ist nicht mehr gültig.
', 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"""Digitaler Hundepass — {dog['name']}
| Krankheit | Datum | Nächste | Tierarzt | Charge |
|---|
| Medikament | Dosierung | Von | Bis | Notiz |
|---|