# app/main.py import os, hashlib from typing import Optional from urllib.parse import urlparse from fastapi import FastAPI, Request, Form, Depends, HTTPException from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse, PlainTextResponse from fastapi.staticfiles import StaticFiles from fastapi.middleware.cors import CORSMiddleware from starlette.middleware.sessions import SessionMiddleware from jinja2 import Environment, FileSystemLoader, select_autoescape from passlib.hash import bcrypt from pymysql.cursors import DictCursor import pymysql from slowapi import _rate_limit_exceeded_handler from slowapi.errors import RateLimitExceeded from deps import get_db, current_user # keine Kreis-Imports from routers.admin_post import router as admin_posts_router from routers.admin_rag import router as admin_rag_router from routers.admin_logs import router as admin_logs_router from routers.admin_vectors import router as admin_vectors_router from routers.diary_rag import router as diary_rag_router from routers.document_rag import router as document_rag_router from routers.home import router as home_router from routers.chat import router as chat_router from routers.chat_page import router as chat_page_router from routers.crumbforest_roles import router as roles_router SECRET = os.getenv("APP_SECRET", "dev-secret-change-me") app = FastAPI() # --- Rate Limiting --- from routers.chat import limiter app.state.limiter = limiter app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) # --- Static Files --- app.mount("/static", StaticFiles(directory="static"), name="static") # --- Middleware --- app.add_middleware(SessionMiddleware, secret_key=SECRET, same_site="lax", https_only=False) # CORS: Restrictive for production (allow localhost for dev) allowed_origins = os.getenv("CORS_ORIGINS", "http://localhost:8000,http://127.0.0.1:8000").split(",") app.add_middleware( CORSMiddleware, allow_origins=allowed_origins, # Set via CORS_ORIGINS env var allow_credentials=True, allow_methods=["GET", "POST"], # Only needed methods allow_headers=["Content-Type", "Accept"], # Minimal headers ) # --- Templates & Render --- def init_templates(app: FastAPI): env = Environment( loader=FileSystemLoader("templates"), autoescape=select_autoescape(["html", "htm"]) ) app.state.tpl_env = env def render(req: Request, template: str, **ctx): # Gemeinsamer Kontext path = req.url.path or "/" seg = [p for p in path.split("/") if p] # Priority: 1. Query Param, 2. Path Prefix, 3. Session, 4. Default 'de' # Also added "fr" to supported languages query_lang = req.query_params.get("lang") supported_langs = ("de", "en", "fr") if query_lang and query_lang in supported_langs: lang = query_lang req.session["lang"] = lang # Persist selection elif seg and seg[0] in supported_langs: lang = seg[0] req.session["lang"] = lang # Persist selection from path else: lang = req.session.get("lang") or "de" tail = "/" + "/".join(seg[1:]) if (seg and seg[0] in supported_langs and len(seg) > 1) else "/" user = req.session.get("user") flashes = req.session.pop("_flashes", []) base_ctx = dict( req=req, lang=lang, path_tail=tail, user=user, flashes=flashes, ) base_ctx.update(ctx) tpl = app.state.tpl_env.get_template(template) return HTMLResponse(tpl.render(**base_ctx)) app.state.render = render @app.on_event("startup") def _startup(): init_templates(app) # --- kleine Utils --- def flash(req: Request, message: str, category: str = "info"): arr = req.session.get("_flashes", []) arr.append({"msg": message, "cat": category}) req.session["_flashes"] = arr def get_lang_from_path(path: str, default="de"): seg = [p for p in (path or "/").split("/") if p] return seg[0] if seg and seg[0] in ("de", "en", "fr") else default # --- Health & Dev helpers --- @app.get("/health") def health(): return {"ok": True} @app.get("/__routes", response_class=JSONResponse) def list_routes(): data = [] for r in app.router.routes: if hasattr(r, "path") and hasattr(r, "name") and hasattr(r, "methods"): data.append({"path": r.path, "name": r.name, "methods": sorted(list(r.methods))}) return data @app.get("/__whoami", response_class=JSONResponse) def whoami(req: Request): lang = get_lang_from_path(req.url.path, req.session.get("lang", "de")) return {"user": req.session.get("user"), "lang": lang} @app.get("/favicon.ico") def favicon(): return PlainTextResponse("", status_code=204) # --- Root & Home --- # Root path is now handled by home router # Old authenticated home page moved to /{lang}/ for backwards compatibility @app.get("/{lang}/", name="authenticated_home", response_class=HTMLResponse) def authenticated_home(req: Request, lang: str, user = Depends(current_user)): req.session["lang"] = lang return req.app.state.render(req, "pages/home.html", seo={"title": "Crumbforest", "desc": "Wuuuuhuuu!"}) # --- Login / Logout --- # Explicit /login catch-all to prevent it matching /{lang}/login with lang="login" @app.get("/login", include_in_schema=False) def login_redirect(req: Request): lang = req.session.get("lang") or "de" return RedirectResponse(f"/{lang}/login", status_code=302) @app.get("/{lang}/login", name="login_form", response_class=HTMLResponse) def login_form(req: Request, lang: str): # Prevent "login" as lang if it slipped through if lang == "login": return RedirectResponse("/de/login", status_code=302) req.session["lang"] = lang return req.app.state.render(req, "pages/login.html", seo={"title": "Login", "desc": ""}) @app.post("/{lang}/login", name="login_post") def login_post( req: Request, lang: str, email: str = Form(...), password: str = Form(...), ): req.session["lang"] = lang # prüfe User try: with get_db().cursor(DictCursor) as cur: cur.execute("SELECT id, email, pass_hash, role, locale, user_group, theme, accessibility FROM users WHERE email=%s", (email,)) row = cur.fetchone() except Exception as e: raise HTTPException(status_code=500, detail=f"db error: {e}") if not row or not bcrypt.verify(password, row["pass_hash"]): flash(req, "Invalid credentials", "error") # 200 lassen, damit Fehler im Formular sichtbar bleibt return req.app.state.render(req, "pages/login.html", seo={"title": "Login"}, form={"email": email}) # Erfolg req.session["user"] = { "id": row["id"], "email": row["email"], "role": row["role"], "locale": row["locale"], "user_group": row.get("user_group", "demo"), "theme": row.get("theme", "pico-default"), "accessibility": row.get("accessibility") } flash(req, f"Welcome, {row['email']}", "success") # Redirect based on role with preserved language target = "/admin" if row["role"] == "admin" else "/crumbforest/roles" return RedirectResponse(f"{target}?lang={lang}", status_code=302) @app.post("/logout", name="logout") def logout(req: Request): req.session.pop("user", None) flash(req, "Logged out", "info") lang = req.session.get("lang", "de") return RedirectResponse(f"/{lang}/", status_code=302) # --- Admin Dashboard --- @app.get("/admin", name="admin_dashboard", response_class=HTMLResponse) def admin_dashboard(req: Request, user = Depends(current_user)): if not user: return RedirectResponse(f"/{req.session.get('lang','de')}/login", status_code=302) if user.get("role") != "admin": return HTMLResponse("403 admin only", status_code=403) return req.app.state.render(req, "pages/admin.html", seo={"title": "Admin", "desc": ""}) # --- kleine API-Demo --- @app.get("/api/hello", name="api_hello") def api_hello(req: Request, lang: Optional[str] = None): lang = lang or req.session.get("lang") or "de" user = req.session.get("user", {}).get("email") msg = "Hallo Welt" if lang == "de" else "Hello World" return {"message": msg, "lang": lang, "user": user} # --- Router mounten (ohne Kreisimport) --- app.include_router(admin_posts_router, prefix="/admin") app.include_router(admin_rag_router, prefix="/admin/rag", tags=["RAG"]) app.include_router(admin_logs_router, prefix="/admin", tags=["Admin Logs"]) app.include_router(admin_vectors_router, prefix="/admin", tags=["Admin Vectors"]) app.include_router(diary_rag_router, prefix="/api/diary", tags=["Diary RAG"]) app.include_router(document_rag_router, prefix="/api/documents", tags=["Documents RAG"]) app.include_router(chat_router, tags=["Chat"]) app.include_router(chat_page_router, tags=["Chat"]) app.include_router(roles_router, tags=["Roles"]) # Mount home router with lang prefix FIRST so it takes precedence app.include_router(home_router, prefix="/{lang}", tags=["Home"]) # Mount home router at root app.include_router(home_router, tags=["Home"])