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

199 lines
7.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.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]
lang = seg[0] if seg and seg[0] in ("de", "en") else (req.session.get("lang") or "de")
tail = "/" + "/".join(seg[1:]) if (seg and seg[0] in ("de", "en") 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") 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 ---
@app.get("/{lang}/login", name="login_form", response_class=HTMLResponse)
def login_form(req: Request, lang: str):
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")
return RedirectResponse("/admin", 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(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 last so it doesn't conflict with other routes
app.include_router(home_router, tags=["Home"])