241 lines
9.5 KiB
Python
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"])
|