diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..3deda7d --- /dev/null +++ b/.env.example @@ -0,0 +1,27 @@ +# BAN YARO — Umgebungsvariablen +# Kopieren nach .env und anpassen + +ENV=development # development | production + +# Sicherheit +JWT_SECRET=bitte-aendern-langer-zufaelliger-string +JWT_EXPIRY_DAYS=30 + +# KI-Modus +# off = kein KI +# local = LM Studio auf DS (kostenlos, für Entwicklung) +# cloud = Claude API (nur für Premium-User, kostet Geld) +KI_MODE=local +KI_LOCAL_URL=http://10.47.11.10:1234/v1 +KI_LOCAL_MODEL=qwen2.5-7b-instruct + +# Claude API (nur setzen wenn KI_MODE=cloud oder als Fallback) +ANTHROPIC_API_KEY= + +# Cloud-Modell (nur bei KI_MODE=cloud) +KI_CLOUD_MODEL=claude-opus-4-6 + +# Push Notifications (VAPID Keys generieren mit: npx web-push generate-vapid-keys) +VAPID_PUBLIC_KEY= +VAPID_PRIVATE_KEY= +VAPID_CONTACT=mailto:admin@banyaro.app diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e51a866 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +.env +data/ +__pycache__/ +*.pyc +*.pyo +.DS_Store +*.db +*.db-wal +*.db-shm diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..56760ff --- /dev/null +++ b/Dockerfile @@ -0,0 +1,22 @@ +FROM python:3.12-slim + +WORKDIR /app + +# System-Dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + gcc \ + && rm -rf /var/lib/apt/lists/* + +# Python-Dependencies zuerst (Docker Layer Cache) +COPY backend/requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# App-Code +COPY backend/ . + +# Media-Verzeichnis +RUN mkdir -p /data/media/dogs /data/media/diary /data/media/poison + +EXPOSE 8000 + +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--proxy-headers"] diff --git a/backend/auth.py b/backend/auth.py new file mode 100644 index 0000000..2247fde --- /dev/null +++ b/backend/auth.py @@ -0,0 +1,119 @@ +""" +BAN YARO — Auth +JWT + Bcrypt. Einmal gebaut, von allen Routes genutzt. +""" + +import os +import jwt +import bcrypt +import logging +from datetime import datetime, timedelta, timezone +from fastapi import Depends, HTTPException, status, Request +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials + +from database import db + +logger = logging.getLogger(__name__) +JWT_SECRET = os.getenv("JWT_SECRET", "change-me-in-production") +JWT_ALGO = "HS256" +JWT_EXPIRY = int(os.getenv("JWT_EXPIRY_DAYS", "30")) + +security = HTTPBearer(auto_error=False) + + +# ------------------------------------------------------------------ +# Passwort +# ------------------------------------------------------------------ +def hash_password(password: str) -> str: + return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode() + + +def verify_password(password: str, hashed: str) -> bool: + return bcrypt.checkpw(password.encode(), hashed.encode()) + + +# ------------------------------------------------------------------ +# JWT +# ------------------------------------------------------------------ +def create_token(user_id: int, rolle: str) -> str: + payload = { + "sub": str(user_id), + "rolle": rolle, + "exp": datetime.now(timezone.utc) + timedelta(days=JWT_EXPIRY), + "iat": datetime.now(timezone.utc), + } + return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGO) + + +def decode_token(token: str) -> dict: + return jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGO]) + + +# ------------------------------------------------------------------ +# FastAPI Dependencies +# ------------------------------------------------------------------ +def _get_token_from_request( + request: Request, + credentials: HTTPAuthorizationCredentials = Depends(security), +) -> str | None: + """Token aus Bearer-Header oder HttpOnly-Cookie.""" + if credentials: + return credentials.credentials + return request.cookies.get("by_token") + + +def get_current_user( + request: Request, + credentials: HTTPAuthorizationCredentials = Depends(security), +): + """Dependency: gibt den eingeloggten User zurück oder wirft 401.""" + token = _get_token_from_request(request, credentials) + if not token: + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Nicht eingeloggt.") + + try: + payload = decode_token(token) + except jwt.ExpiredSignatureError: + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Session abgelaufen.") + except jwt.InvalidTokenError: + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Ungültiges Token.") + + user_id = int(payload["sub"]) + with db() as conn: + row = conn.execute( + "SELECT id, email, name, rolle, is_premium FROM users WHERE id=?", + (user_id,) + ).fetchone() + + if not row: + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "User nicht gefunden.") + + return dict(row) + + +def get_current_user_optional( + request: Request, + credentials: HTTPAuthorizationCredentials = Depends(security), +): + """Dependency: gibt User zurück falls eingeloggt, sonst None.""" + try: + return get_current_user(request, credentials) + except HTTPException: + return None + + +def require_premium(user=Depends(get_current_user)): + """Dependency: nur für Premium-User.""" + if not user["is_premium"]: + raise HTTPException( + status.HTTP_402_PAYMENT_REQUIRED, + "Dieses Feature erfordert Ban Yaro Premium." + ) + return user + + +def require_admin(user=Depends(get_current_user)): + """Dependency: nur für Admins.""" + if user["rolle"] != "admin": + raise HTTPException(status.HTTP_403_FORBIDDEN, "Kein Zugriff.") + return user diff --git a/backend/database.py b/backend/database.py new file mode 100644 index 0000000..209239f --- /dev/null +++ b/backend/database.py @@ -0,0 +1,237 @@ +""" +BAN YARO — Datenbank +SQLite mit WAL-Modus (bewährt von akku-werkstatt). +""" + +import sqlite3 +import os +import logging +from contextlib import contextmanager + +logger = logging.getLogger(__name__) +DB_PATH = os.getenv("DB_PATH", "/data/banyaro.db") + + +def get_connection() -> sqlite3.Connection: + conn = sqlite3.connect(DB_PATH, check_same_thread=False) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA foreign_keys=ON") + conn.execute("PRAGMA busy_timeout=5000") + return conn + + +@contextmanager +def db(): + conn = get_connection() + try: + yield conn + conn.commit() + except Exception: + conn.rollback() + raise + finally: + conn.close() + + +def init_db(): + """Erstellt alle Tabellen falls nicht vorhanden.""" + logger.info(f"Initialisiere Datenbank: {DB_PATH}") + os.makedirs(os.path.dirname(DB_PATH), exist_ok=True) + + with db() as conn: + conn.executescript(""" + + -- USERS + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + email TEXT NOT NULL UNIQUE, + pw_hash TEXT NOT NULL, + name TEXT NOT NULL, + rolle TEXT NOT NULL DEFAULT 'user', + is_premium INTEGER NOT NULL DEFAULT 0, + push_sub TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + last_login TEXT + ); + + -- HUNDE + CREATE TABLE IF NOT EXISTS dogs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name TEXT NOT NULL, + rasse TEXT, + geburtstag TEXT, + geschlecht TEXT, + gewicht_kg REAL, + chip_nr TEXT, + foto_url TEXT, + bio TEXT, + is_public INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + -- TAGEBUCH + CREATE TABLE IF NOT EXISTS diary ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE, + datum TEXT NOT NULL DEFAULT (date('now')), + typ TEXT NOT NULL DEFAULT 'eintrag', + titel TEXT, + text TEXT, + media_url TEXT, + tags TEXT, -- JSON Array + gps_lat REAL, + gps_lon REAL, + is_milestone INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS idx_diary_dog ON diary(dog_id, datum DESC); + + -- GESUNDHEIT + CREATE TABLE IF NOT EXISTS health ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE, + typ TEXT NOT NULL, -- impfung | entwurmung | tierarzt | medikament | gewicht + bezeichnung TEXT NOT NULL, + datum TEXT NOT NULL, + naechstes TEXT, + notiz TEXT, + wert REAL, -- für Gewicht o.ä. + einheit TEXT, + erinnerung INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS idx_health_dog ON health(dog_id, datum DESC); + + -- GIFTKÖDER-ALARM + CREATE TABLE IF NOT EXISTS poison ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id), + lat REAL NOT NULL, + lon REAL NOT NULL, + beschreibung TEXT, + typ TEXT DEFAULT 'unbekannt', + foto_url TEXT, + bestaetigt INTEGER NOT NULL DEFAULT 0, + bestaetigt_von INTEGER, + geloest INTEGER NOT NULL DEFAULT 0, + expires_at TEXT NOT NULL, -- auto-expire nach 7 Tagen + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS idx_poison_location ON poison(lat, lon); + CREATE INDEX IF NOT EXISTS idx_poison_active ON poison(geloest, expires_at); + + -- ORTE (hundefreundliche Orte, Kotbeutelspender, etc.) + CREATE TABLE IF NOT EXISTS places ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER REFERENCES users(id), + name TEXT NOT NULL, + typ TEXT NOT NULL, -- restaurant | shop | freilauf | kotbeutel | tierarzt | hundeschule + lat REAL NOT NULL, + lon REAL NOT NULL, + adresse TEXT, + website TEXT, + hund_rein INTEGER, + leine_pflicht INTEGER, + wasser_fuer_hunde INTEGER, + foto_url TEXT, + bewertung REAL DEFAULT 0, + anz_bewertungen INTEGER DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS idx_places_location ON places(lat, lon); + CREATE INDEX IF NOT EXISTS idx_places_typ ON places(typ); + + -- ROUTEN + CREATE TABLE IF NOT EXISTS routes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id), + name TEXT NOT NULL, + beschreibung TEXT, + gps_track TEXT NOT NULL, -- JSON Array von {lat, lon} + distanz_km REAL, + dauer_min INTEGER, + schwierigkeit TEXT DEFAULT 'leicht', + untergrund TEXT, -- wald | asphalt | wiese | mix + schatten INTEGER, + leine_empfohlen INTEGER, + bewertung REAL DEFAULT 0, + anz_bewertungen INTEGER DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + -- GASSI-TREFFEN + CREATE TABLE IF NOT EXISTS walks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id), + titel TEXT NOT NULL, + datum TEXT NOT NULL, + uhrzeit TEXT NOT NULL, + lat REAL NOT NULL, + lon REAL NOT NULL, + ort_name TEXT, + max_teilnehmer INTEGER DEFAULT 10, + beschreibung TEXT, + status TEXT NOT NULL DEFAULT 'offen', + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + -- GASSI-TREFFEN TEILNEHMER + CREATE TABLE IF NOT EXISTS walk_participants ( + walk_id INTEGER NOT NULL REFERENCES walks(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + dog_id INTEGER REFERENCES dogs(id), + joined_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (walk_id, user_id) + ); + + -- FORUM + CREATE TABLE IF NOT EXISTS forum_threads ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id), + dog_id INTEGER REFERENCES dogs(id), + titel TEXT NOT NULL, + kategorie TEXT NOT NULL DEFAULT 'allgemein', + rasse_tag TEXT, + plz TEXT, + views INTEGER NOT NULL DEFAULT 0, + pinned INTEGER NOT NULL DEFAULT 0, + locked INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS idx_threads_kat ON forum_threads(kategorie, created_at DESC); + + CREATE TABLE IF NOT EXISTS forum_posts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + thread_id INTEGER NOT NULL REFERENCES forum_threads(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id), + text TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + edited_at TEXT + ); + + -- PUSH SUBSCRIPTIONS (alternativ zu users.push_sub für mehrere Geräte) + CREATE TABLE IF NOT EXISTS push_subscriptions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + endpoint TEXT NOT NULL UNIQUE, + p256dh TEXT NOT NULL, + auth TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + -- PREMIUM-TRANSAKTIONEN (später für Zahlungsabwicklung) + CREATE TABLE IF NOT EXISTS premium_orders ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id), + status TEXT NOT NULL DEFAULT 'pending', + betrag_cent INTEGER NOT NULL, + zahlungsart TEXT, + valid_until TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + """) + + logger.info("Datenbank initialisiert.") diff --git a/backend/ki.py b/backend/ki.py new file mode 100644 index 0000000..6eb0f1f --- /dev/null +++ b/backend/ki.py @@ -0,0 +1,302 @@ +""" +BAN YARO — KI-Abstraktions-Layer + +Drei Modi: + - "off" → kein KI, Feature deaktiviert (Free-User ohne lokales Modell) + - "local" → LM Studio auf DS1621 (OpenAI-kompatibler Endpunkt, kostenlos) + - "cloud" → Claude API (nur für Premium-User, kostet Geld) + +Wird über KI_MODE Umgebungsvariable gesteuert: + KI_MODE=local → Entwicklung + Free-User auf DS + KI_MODE=cloud → Production + Premium-User + KI_MODE=off → kein KI verfügbar + +Wichtig: cloud-Aufrufe IMMER mit requires_premium=True schützen. + Kein API-Geld ohne zahlenden User. +""" + +import os +import logging +from typing import Optional +from functools import wraps + +logger = logging.getLogger(__name__) + +KI_MODE = os.getenv("KI_MODE", "local") # off | local | cloud +LOCAL_BASE_URL = os.getenv("KI_LOCAL_URL", "http://10.47.11.10:1234/v1") +LOCAL_MODEL = os.getenv("KI_LOCAL_MODEL", "qwen2.5-7b-instruct") +CLOUD_MODEL = os.getenv("KI_CLOUD_MODEL", "claude-opus-4-6") +ANTHROPIC_KEY = os.getenv("ANTHROPIC_API_KEY", "") + +# Lazy Imports — nur laden wenn wirklich benötigt +_openai_client = None +_anthropic_client = None + + +def _get_local_client(): + global _openai_client + if _openai_client is None: + from openai import OpenAI + _openai_client = OpenAI(base_url=LOCAL_BASE_URL, api_key="lm-studio") + return _openai_client + + +def _get_cloud_client(): + global _anthropic_client + if _anthropic_client is None: + import anthropic + _anthropic_client = anthropic.Anthropic(api_key=ANTHROPIC_KEY) + return _anthropic_client + + +class KIUnavailableError(Exception): + """KI-Feature nicht verfügbar (Off-Modus oder kein Premium).""" + pass + + +class KIPremiumRequired(Exception): + """Dieses Feature erfordert Ban Yaro Premium.""" + pass + + +# ------------------------------------------------------------------ +# Haupt-Aufruf-Funktion — zentraler Eingang für alle KI-Requests +# ------------------------------------------------------------------ +async def complete( + prompt: str, + system: str = "Du bist ein hilfreicher Assistent für Hundebesitzer.", + max_tokens: int = 512, + requires_premium: bool = False, + user_is_premium: bool = False, + json_mode: bool = False, +) -> str: + """ + KI-Completion. Wählt automatisch den richtigen Backend. + + Args: + prompt: User-Nachricht + system: System-Prompt + max_tokens: Maximale Antwortlänge + requires_premium: True = nur für Premium-User (nutzt Cloud) + user_is_premium: Ob der anfragende User Premium hat + json_mode: Antwort als JSON anfordern + + Returns: + KI-Antwort als String + + Raises: + KIPremiumRequired: Cloud-Feature ohne Premium + KIUnavailableError: KI komplett deaktiviert + """ + if KI_MODE == "off": + raise KIUnavailableError("KI ist deaktiviert.") + + # Premium-Check vor Cloud-Aufrufen + if requires_premium and not user_is_premium: + raise KIPremiumRequired( + "Dieses Feature ist Teil von Ban Yaro Premium." + ) + + # Cloud-Aufruf: nur wenn Premium UND cloud-Modus + if requires_premium and user_is_premium and KI_MODE == "cloud": + return await _cloud_complete(prompt, system, max_tokens, json_mode) + + # Lokaler Aufruf: Entwicklung + Free-User + if KI_MODE in ("local", "cloud"): + try: + return await _local_complete(prompt, system, max_tokens, json_mode) + except Exception as e: + logger.warning(f"Lokales KI-Modell nicht erreichbar: {e}") + if requires_premium and user_is_premium and ANTHROPIC_KEY: + logger.info("Fallback auf Cloud-KI.") + return await _cloud_complete(prompt, system, max_tokens, json_mode) + raise KIUnavailableError( + "KI-Modell momentan nicht erreichbar. Bitte später erneut versuchen." + ) from e + + raise KIUnavailableError("Unbekannter KI-Modus.") + + +async def _local_complete( + prompt: str, system: str, max_tokens: int, json_mode: bool +) -> str: + """LM Studio lokale Completion (synchron in Thread-Pool).""" + import asyncio + + def _sync(): + client = _get_local_client() + kwargs = dict( + model=LOCAL_MODEL, + messages=[ + {"role": "system", "content": system}, + {"role": "user", "content": prompt}, + ], + max_tokens=max_tokens, + temperature=0.3, + ) + if json_mode: + kwargs["response_format"] = {"type": "json_object"} + + response = client.chat.completions.create(**kwargs) + return response.choices[0].message.content.strip() + + return await asyncio.get_event_loop().run_in_executor(None, _sync) + + +async def _cloud_complete( + prompt: str, system: str, max_tokens: int, json_mode: bool +) -> str: + """Claude API Completion mit Prompt Caching.""" + import asyncio + + def _sync(): + client = _get_cloud_client() + system_content = [ + { + "type": "text", + "text": system, + "cache_control": {"type": "ephemeral"}, # Prompt Caching + } + ] + response = client.messages.create( + model=CLOUD_MODEL, + max_tokens=max_tokens, + system=system_content, + messages=[{"role": "user", "content": prompt}], + ) + return response.content[0].text.strip() + + return await asyncio.get_event_loop().run_in_executor(None, _sync) + + +# ------------------------------------------------------------------ +# Spezialisierte KI-Funktionen +# (hier liegen die konkreten Prompts — zentral, nicht in den Routes) +# ------------------------------------------------------------------ + +async def symptom_check(symptoms: str, dog_info: dict, + user_is_premium: bool = False) -> dict: + """ + Symptom-Triage für Hunde. + Lokal für alle, Premium bekommt detailliertere Analyse. + """ + rasse = dog_info.get("rasse", "unbekannt") + alter = dog_info.get("alter_jahre", "unbekannt") + system = ( + "Du bist ein erfahrener Veterinär-Assistent. " + "Deine Aufgabe ist eine erste Einschätzung von Symptomen beim Hund. " + "Antworte IMMER auf Deutsch und IMMER im angegebenen JSON-Format. " + "Überschreite nie deine Kompetenz — weise bei Unsicherheit zum Tierarzt." + ) + prompt = f""" +Hund: {rasse}, {alter} Jahre alt. +Symptome: {symptoms} + +Antworte NUR als JSON: +{{ + "dringlichkeit": "beobachten" | "tierarzt_heute" | "notfall", + "einschaetzung": "Kurze Einschätzung in 1-2 Sätzen", + "hinweise": ["Hinweis 1", "Hinweis 2"], + "zum_tierarzt_wenn": "Wann unbedingt zum Tierarzt" +}} +""" + result = await complete( + prompt, system, + max_tokens=400, + requires_premium=False, # Basis-Triage ist kostenlos + user_is_premium=user_is_premium, + json_mode=True, + ) + import json + try: + return json.loads(result) + except json.JSONDecodeError: + return { + "dringlichkeit": "tierarzt_heute", + "einschaetzung": result, + "hinweise": [], + "zum_tierarzt_wenn": "Bei Verschlechterung sofort.", + } + + +async def training_plan(problem: str, dog_info: dict, + user_is_premium: bool = False) -> str: + """ + Trainingsplan generieren — Premium-Feature. + """ + system = ( + "Du bist ein zertifizierter Hundetrainer. " + "Erstelle konkrete, positive, gewaltfreie Trainingspläne auf Deutsch." + ) + prompt = f""" +Hund: {dog_info.get('rasse')}, {dog_info.get('alter_jahre')} Jahre. +Problem: {problem} + +Erstelle einen 2-Wochen-Trainingsplan mit täglichen Übungen (max. 15 Min/Tag). +Strukturiert, konkret, motivierend. +""" + return await complete( + prompt, system, + max_tokens=800, + requires_premium=True, # Cloud-Feature — kostet Geld + user_is_premium=user_is_premium, + ) + + +async def diary_tags(entry_text: str) -> list[str]: + """ + Automatische Tags für Tagebucheinträge — läuft lokal, kostenlos. + """ + if KI_MODE == "off": + return [] + prompt = f""" +Tagebucheintrag: "{entry_text}" + +Vergib 2-4 passende Tags aus dieser Liste: +training, spaziergang, gesundheit, freunde, spielen, futter, tierarzt, +reise, meilenstein, lustig, traurig, wetter, sonstige + +Antworte NUR mit den Tags, kommagetrennt, keine Erklärung. +""" + try: + result = await complete(prompt, max_tokens=50, requires_premium=False) + return [t.strip().lower() for t in result.split(",") if t.strip()] + except Exception: + return [] + + +async def poison_description_check(description: str) -> dict: + """ + Giftköder-Beschreibung auf Plausibilität und Typ prüfen — lokal, kostenlos. + """ + if KI_MODE == "off": + return {"valid": True, "typ": "unbekannt"} + prompt = f""" +Giftköder-Meldung: "{description}" + +Bewerte als JSON: +{{ + "plausibel": true/false, + "typ": "fleisch" | "koeder" | "objekt" | "unbekannt", + "hinweis": "Kurzer Hinweis für andere Hundebesitzer (max 1 Satz)" +}} +""" + try: + import json + result = await complete(prompt, max_tokens=150, json_mode=True) + return json.loads(result) + except Exception: + return {"plausibel": True, "typ": "unbekannt", "hinweis": description} + + +# ------------------------------------------------------------------ +# Status-Endpoint (für Admin/Debug) +# ------------------------------------------------------------------ +def status() -> dict: + return { + "mode": KI_MODE, + "local_url": LOCAL_BASE_URL if KI_MODE != "off" else None, + "local_model": LOCAL_MODEL if KI_MODE != "off" else None, + "cloud_model": CLOUD_MODEL, + "cloud_key_set": bool(ANTHROPIC_KEY), + } diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..0ae2338 --- /dev/null +++ b/backend/main.py @@ -0,0 +1,114 @@ +""" +BAN YARO — FastAPI Hauptanwendung +""" + +import os +import logging +from fastapi import FastAPI, Request +from fastapi.staticfiles import StaticFiles +from fastapi.responses import FileResponse, JSONResponse +from contextlib import asynccontextmanager + +from database import init_db +import ki + +logging.basicConfig( + level = logging.INFO, + format = "%(asctime)s [%(levelname)s] %(name)s: %(message)s", +) +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}") + yield + 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, +) + + +# ------------------------------------------------------------------ +# 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 + +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"]) + + +# ------------------------------------------------------------------ +# 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.get("/manifest.json") +async def manifest(): + return FileResponse(f"{STATIC_DIR}/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-cache"} + ) + +# SPA Fallback — ALLE nicht-API-Routen gehen zur index.html +@app.get("/{full_path:path}") +async def spa_fallback(full_path: str): + return FileResponse( + f"{STATIC_DIR}/index.html", + headers={"Cache-Control": "no-cache"} + ) diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..904fb5d --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,10 @@ +fastapi==0.115.0 +uvicorn[standard]==0.30.6 +python-multipart==0.0.9 +pydantic[email]==2.8.2 +bcrypt==4.2.0 +PyJWT==2.9.0 +httpx==0.27.2 +openai==1.50.0 +anthropic==0.34.0 +pywebpush==2.0.0 diff --git a/backend/routes/__init__.py b/backend/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/routes/auth.py b/backend/routes/auth.py new file mode 100644 index 0000000..6fd851e --- /dev/null +++ b/backend/routes/auth.py @@ -0,0 +1,87 @@ +"""BAN YARO — Auth Routes""" + +from fastapi import APIRouter, HTTPException, Response, Depends +from pydantic import BaseModel, EmailStr +from database import db +from auth import ( + hash_password, verify_password, create_token, + get_current_user +) + +router = APIRouter() +COOKIE_NAME = "by_token" + + +class LoginRequest(BaseModel): + email: EmailStr + password: str + +class RegisterRequest(BaseModel): + email: EmailStr + password: str + name: str + + +def _set_cookie(response: Response, token: str): + response.set_cookie( + key=COOKIE_NAME, value=token, + httponly=True, secure=True, samesite="lax", + max_age=30 * 24 * 3600 + ) + + +@router.post("/register") +async def register(data: RegisterRequest, response: Response): + with db() as conn: + if conn.execute("SELECT 1 FROM users WHERE email=?", (data.email,)).fetchone(): + raise HTTPException(400, "E-Mail bereits registriert.") + conn.execute( + "INSERT INTO users (email, pw_hash, name) VALUES (?,?,?)", + (data.email, hash_password(data.password), data.name) + ) + user = conn.execute( + "SELECT id, rolle FROM users WHERE email=?", (data.email,) + ).fetchone() + + token = create_token(user["id"], user["rolle"]) + _set_cookie(response, token) + return {"token": token, "name": data.name} + + +@router.post("/login") +async def login(data: LoginRequest, response: Response): + with db() as conn: + user = conn.execute( + "SELECT id, pw_hash, name, rolle, is_premium FROM users WHERE email=?", + (data.email,) + ).fetchone() + + if not user or not verify_password(data.password, user["pw_hash"]): + raise HTTPException(401, "E-Mail oder Passwort falsch.") + + token = create_token(user["id"], user["rolle"]) + _set_cookie(response, token) + + with db() as conn: + conn.execute( + "UPDATE users SET last_login=datetime('now') WHERE id=?", (user["id"],) + ) + + return {"token": token, "name": user["name"], "is_premium": bool(user["is_premium"])} + + +@router.post("/logout") +async def logout(response: Response): + response.delete_cookie(COOKIE_NAME) + return {"ok": True} + + +@router.get("/me") +async def me(user=Depends(get_current_user)): + return { + "id": user["id"], + "name": user["name"], + "email": user["email"], + "rolle": user["rolle"], + "is_premium": bool(user["is_premium"]), + } diff --git a/backend/routes/diary.py b/backend/routes/diary.py new file mode 100644 index 0000000..069f740 --- /dev/null +++ b/backend/routes/diary.py @@ -0,0 +1,3 @@ +"""BAN YARO — Tagebuch Routes (Stub, wird in Sprint 1 ausgebaut)""" +from fastapi import APIRouter +router = APIRouter() diff --git a/backend/routes/dogs.py b/backend/routes/dogs.py new file mode 100644 index 0000000..ca63968 --- /dev/null +++ b/backend/routes/dogs.py @@ -0,0 +1,146 @@ +"""BAN YARO — Hunde-Profil Routes""" + +import os +import uuid +from fastapi import APIRouter, Depends, HTTPException, UploadFile, File +from pydantic import BaseModel +from typing import Optional +from database import db +from auth import get_current_user + +router = APIRouter() +MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media") + + +class DogCreate(BaseModel): + name: str + rasse: Optional[str] = None + geburtstag: Optional[str] = None + geschlecht: Optional[str] = None + gewicht_kg: Optional[float] = None + chip_nr: Optional[str] = None + bio: Optional[str] = None + is_public: bool = False + + +class DogUpdate(BaseModel): + name: Optional[str] = None + rasse: Optional[str] = None + geburtstag: Optional[str] = None + geschlecht: Optional[str] = None + gewicht_kg: Optional[float] = None + chip_nr: Optional[str] = None + bio: Optional[str] = None + is_public: Optional[bool] = None + + +@router.get("") +async def list_dogs(user=Depends(get_current_user)): + with db() as conn: + rows = conn.execute( + "SELECT * FROM dogs WHERE user_id=? ORDER BY id", (user["id"],) + ).fetchall() + return [dict(r) for r in rows] + + +@router.post("") +async def create_dog(data: DogCreate, user=Depends(get_current_user)): + with db() as conn: + conn.execute( + """INSERT INTO dogs (user_id, name, rasse, geburtstag, geschlecht, + gewicht_kg, chip_nr, bio, is_public) + VALUES (?,?,?,?,?,?,?,?,?)""", + (user["id"], data.name, data.rasse, data.geburtstag, + data.geschlecht, data.gewicht_kg, data.chip_nr, + data.bio, int(data.is_public)) + ) + dog = conn.execute( + "SELECT * FROM dogs WHERE user_id=? ORDER BY id DESC LIMIT 1", + (user["id"],) + ).fetchone() + return dict(dog) + + +@router.get("/{dog_id}") +async def get_dog(dog_id: int, user=Depends(get_current_user)): + with db() as conn: + dog = conn.execute( + "SELECT * FROM dogs WHERE id=? AND user_id=?", (dog_id, user["id"]) + ).fetchone() + if not dog: + raise HTTPException(404, "Hund nicht gefunden.") + return dict(dog) + + +@router.patch("/{dog_id}") +async def update_dog(dog_id: int, data: DogUpdate, user=Depends(get_current_user)): + fields = {k: v for k, v in data.model_dump().items() if v is not None} + if not fields: + raise HTTPException(400, "Keine Änderungen angegeben.") + + set_clause = ", ".join(f"{k}=?" for k in fields) + values = list(fields.values()) + [dog_id, user["id"]] + + with db() as conn: + conn.execute( + f"UPDATE dogs SET {set_clause} WHERE id=? AND user_id=?", values + ) + dog = conn.execute( + "SELECT * FROM dogs WHERE id=?", (dog_id,) + ).fetchone() + return dict(dog) + + +@router.delete("/{dog_id}", status_code=204) +async def delete_dog(dog_id: int, user=Depends(get_current_user)): + with db() as conn: + conn.execute( + "DELETE FROM dogs WHERE id=? AND user_id=?", (dog_id, user["id"]) + ) + + +@router.post("/{dog_id}/photo") +async def upload_photo( + dog_id: int, + file: UploadFile = File(...), + user=Depends(get_current_user) +): + # Hund gehört dem User? + with db() as conn: + dog = conn.execute( + "SELECT id FROM dogs WHERE id=? AND user_id=?", (dog_id, user["id"]) + ).fetchone() + if not dog: + raise HTTPException(404, "Hund nicht gefunden.") + + # Datei speichern + ext = os.path.splitext(file.filename or "")[1] or ".jpg" + filename = f"dog_{dog_id}_{uuid.uuid4().hex[:8]}{ext}" + path = os.path.join(MEDIA_DIR, "dogs", filename) + os.makedirs(os.path.dirname(path), exist_ok=True) + + content = await file.read() + with open(path, "wb") as f: + f.write(content) + + foto_url = f"/media/dogs/{filename}" + with db() as conn: + conn.execute("UPDATE dogs SET foto_url=? WHERE id=?", (foto_url, dog_id)) + + return {"foto_url": foto_url} + + +# Öffentliches Profil (für NFC-Tag, kein Login nötig) +@router.get("/public/{dog_id}") +async def public_dog_profile(dog_id: int): + with db() as conn: + dog = conn.execute( + """SELECT d.id, d.name, d.rasse, d.geburtstag, d.foto_url, d.bio, + u.name as besitzer_name + FROM dogs d JOIN users u ON d.user_id=u.id + WHERE d.id=? AND d.is_public=1""", + (dog_id,) + ).fetchone() + if not dog: + raise HTTPException(404, "Profil nicht gefunden oder nicht öffentlich.") + return dict(dog) diff --git a/backend/routes/health.py b/backend/routes/health.py new file mode 100644 index 0000000..db2c27d --- /dev/null +++ b/backend/routes/health.py @@ -0,0 +1,3 @@ +"""BAN YARO — health Routes (Stub, wird ausgebaut)""" +from fastapi import APIRouter +router = APIRouter() diff --git a/backend/routes/ki.py b/backend/routes/ki.py new file mode 100644 index 0000000..dd83ed0 --- /dev/null +++ b/backend/routes/ki.py @@ -0,0 +1,3 @@ +"""BAN YARO — ki Routes (Stub, wird ausgebaut)""" +from fastapi import APIRouter +router = APIRouter() diff --git a/backend/routes/poison.py b/backend/routes/poison.py new file mode 100644 index 0000000..7426d4f --- /dev/null +++ b/backend/routes/poison.py @@ -0,0 +1,3 @@ +"""BAN YARO — poison Routes (Stub, wird ausgebaut)""" +from fastapi import APIRouter +router = APIRouter() diff --git a/backend/routes/push.py b/backend/routes/push.py new file mode 100644 index 0000000..0e5ddaa --- /dev/null +++ b/backend/routes/push.py @@ -0,0 +1,3 @@ +"""BAN YARO — push Routes (Stub, wird ausgebaut)""" +from fastapi import APIRouter +router = APIRouter() diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..90b13a8 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,19 @@ +services: + banyaro: + build: . + container_name: ban-yaro + restart: unless-stopped + ports: + - "3010:8000" # DS-intern, NPM leitet banyaro.app weiter + volumes: + - ./data:/data # SQLite + Media persistent + env_file: + - .env + environment: + - DB_PATH=/data/banyaro.db + - MEDIA_DIR=/data/media + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/api/docs"] + interval: 30s + timeout: 10s + retries: 3