Files
Crumb-Core-v.1/app/main.py

241 lines
9.5 KiB
Python

# 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.admin_vectors import router as admin_vectors_router
from routers.admin_roles import router as admin_config_router
from routers.admin_roles import router as admin_config_router
from routers.docs_reader import router as docs_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.pulse import router as pulse_router
from routers.crumbforest_roles import router as roles_router
SECRET = os.getenv("APP_SECRET", "dev-secret-change-me")
app = FastAPI(docs_url="/api/docs", redoc_url=None)
# --- 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(admin_config_router, prefix="/admin", tags=["Admin Config"])
app.include_router(docs_router, tags=["Docs"])
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"])
app.include_router(pulse_router, tags=["Pulse"])
# 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"])