from pathlib import Path from fastapi import Depends, FastAPI, Form, HTTPException, Request, Response, status from fastapi.middleware.trustedhost import TrustedHostMiddleware from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates from sqlalchemy import inspect, text from sqlalchemy.orm import Session from app.core.config import settings from app.core.security import create_access_token, hash_password, verify_password from app.db.session import Base, engine, get_db from app.models.system import SendLog from app.models.user import User from app.schemas.user import UserCreate from app.services.auth import get_admin_user, get_current_user, require_editor_or_admin from app.services.config_store import get_config, set_config from app.services.csrf import CSRF_COOKIE_NAME, ensure_csrf_cookie, validate_csrf from app.services.newsletter import create_html, create_plain_text, filter_articles from app.services.smtp_sender import get_smtp_settings, run_scheduled_send_if_due, send_newsletter from app.services.wiki import WikiService app = FastAPI(title=settings.app_name) allowed_hosts = [h.strip() for h in settings.allowed_hosts.split(",") if h.strip()] if allowed_hosts: app.add_middleware(TrustedHostMiddleware, allowed_hosts=allowed_hosts) app.mount("/static", StaticFiles(directory="app/static"), name="static") templates = Jinja2Templates(directory="app/templates") wiki_service = WikiService() @app.on_event("startup") def on_startup() -> None: Path("data").mkdir(parents=True, exist_ok=True) Base.metadata.create_all(bind=engine) _ensure_schema_upgrades() _bootstrap_admin() def _ensure_schema_upgrades() -> None: inspector = inspect(engine) with engine.begin() as conn: if "users" in inspector.get_table_names(): cols = {c["name"] for c in inspector.get_columns("users")} if "role" not in cols: conn.execute(text("ALTER TABLE users ADD COLUMN role VARCHAR(32) NOT NULL DEFAULT 'reader'")) conn.execute(text("UPDATE users SET role = CASE WHEN is_admin = 1 THEN 'admin' ELSE 'reader' END")) @app.middleware("http") async def add_security_headers(request: Request, call_next): response: Response = await call_next(request) response.headers["X-Content-Type-Options"] = "nosniff" response.headers["X-Frame-Options"] = "DENY" response.headers["Referrer-Policy"] = "same-origin" response.headers["Content-Security-Policy"] = "default-src 'self'; style-src 'self';" if not request.cookies.get(CSRF_COOKIE_NAME): response.set_cookie(CSRF_COOKIE_NAME, ensure_csrf_cookie(request), httponly=True, secure=settings.cookie_secure, samesite="strict") return response def _bootstrap_admin() -> None: db = next(get_db()) try: existing = db.query(User).filter(User.email == settings.admin_bootstrap_email).first() if existing: return user = User( email=settings.admin_bootstrap_email, full_name="Initial Admin", password_hash=hash_password(settings.admin_bootstrap_password), is_admin=True, role="admin", is_active=True, ) db.add(user) db.commit() finally: db.close() def _base_context(request: Request, user: User, db: Session) -> dict: return { "request": request, "user": user, "csrf_token": request.cookies.get(CSRF_COOKIE_NAME) or ensure_csrf_cookie(request), "smtp": get_smtp_settings(db), } @app.get("/", response_class=HTMLResponse) def index(request: Request): return RedirectResponse(url="/dashboard", status_code=status.HTTP_302_FOUND) @app.get("/login", response_class=HTMLResponse) def login_page(request: Request): return templates.TemplateResponse("login.html", {"request": request, "csrf_token": request.cookies.get(CSRF_COOKIE_NAME) or ensure_csrf_cookie(request)}) @app.post("/login") def login( request: Request, csrf_token: str = Form(...), email: str = Form(...), password: str = Form(...), db: Session = Depends(get_db), ): validate_csrf(request, csrf_token) user = db.query(User).filter(User.email == email, User.is_active.is_(True)).first() if not user or not verify_password(password, user.password_hash): raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Ungültige Zugangsdaten.") token = create_access_token(user.id) response = RedirectResponse(url="/dashboard", status_code=status.HTTP_302_FOUND) response.set_cookie("access_token", token, httponly=True, secure=settings.cookie_secure, samesite="strict") return response @app.post("/logout") def logout(request: Request, csrf_token: str = Form(...)): validate_csrf(request, csrf_token) response = RedirectResponse(url="/login", status_code=status.HTTP_302_FOUND) response.delete_cookie("access_token") return response @app.get("/dashboard", response_class=HTMLResponse) async def dashboard(request: Request, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)): context = _base_context(request, current_user, db) context.update({"articles": [], "raw_html": "", "plain_text": "", "filters": {"days": 30, "only_edited": False, "category": ""}}) logs = db.query(SendLog).order_by(SendLog.created_at.desc()).limit(20).all() context["send_logs"] = logs return templates.TemplateResponse("dashboard.html", context) @app.post("/dashboard", response_class=HTMLResponse) async def dashboard_generate( request: Request, csrf_token: str = Form(...), days: int = Form(30), only_edited: bool = Form(False), category: str = Form(""), db: Session = Depends(get_db), current_user: User = Depends(require_editor_or_admin), ): validate_csrf(request, csrf_token) days = max(1, min(days, 90)) articles = await wiki_service.get_recent_changes(days=days, only_edited=only_edited) filtered = filter_articles(articles, category_filter=category or None) title = f"Thomas-Krenn Wiki Newsletter ({days} Tage)" plain_text = create_plain_text(filtered, title) raw_html = create_html(filtered, title) run_scheduled_send_if_due(db, title, raw_html, plain_text) context = _base_context(request, current_user, db) context.update( { "articles": filtered, "raw_html": raw_html, "plain_text": plain_text, "filters": {"days": days, "only_edited": only_edited, "category": category}, "send_logs": db.query(SendLog).order_by(SendLog.created_at.desc()).limit(20).all(), } ) return templates.TemplateResponse("dashboard.html", context) @app.post("/newsletter/send-now") async def send_now( request: Request, csrf_token: str = Form(...), days: int = Form(30), only_edited: bool = Form(False), category: str = Form(""), db: Session = Depends(get_db), current_user: User = Depends(require_editor_or_admin), ): validate_csrf(request, csrf_token) articles = await wiki_service.get_recent_changes(days=max(1, min(days, 90)), only_edited=only_edited) filtered = filter_articles(articles, category_filter=category or None) title = f"Thomas-Krenn Wiki Newsletter ({days} Tage)" plain_text = create_plain_text(filtered, title) raw_html = create_html(filtered, title) recipients = [r.strip() for r in get_config(db, "smtp.recipients", "").split(",") if r.strip()] send_newsletter(db, title, raw_html, plain_text, recipients, scheduled=False) return RedirectResponse(url="/dashboard", status_code=status.HTTP_302_FOUND) @app.post("/admin/smtp") def update_smtp_settings( request: Request, csrf_token: str = Form(...), enabled: str | None = Form(None), host: str = Form(""), port: str = Form("587"), username: str = Form(""), password: str = Form(""), from_email: str = Form(""), use_tls: str | None = Form(None), recipients: str = Form(""), schedule_time: str = Form("08:00"), db: Session = Depends(get_db), admin: User = Depends(get_admin_user), ): validate_csrf(request, csrf_token) set_config(db, "smtp.enabled", "true" if enabled == "true" else "false") set_config(db, "smtp.host", host.strip()) set_config(db, "smtp.port", port.strip() or "587") set_config(db, "smtp.username", username.strip()) set_config(db, "smtp.password", password) set_config(db, "smtp.from_email", from_email.strip()) set_config(db, "smtp.use_tls", "true" if use_tls == "true" else "false") set_config(db, "smtp.recipients", recipients.strip()) set_config(db, "smtp.schedule_time", schedule_time.strip() or "08:00") return RedirectResponse(url="/dashboard", status_code=status.HTTP_302_FOUND) @app.get("/admin/users", response_class=HTMLResponse) def users_page(request: Request, db: Session = Depends(get_db), admin: User = Depends(get_admin_user)): context = _base_context(request, admin, db) context["users"] = db.query(User).order_by(User.created_at.desc()).all() return templates.TemplateResponse("users.html", context) @app.post("/admin/users") def create_user( request: Request, csrf_token: str = Form(...), email: str = Form(...), full_name: str = Form(...), password: str = Form(...), role: str = Form("reader"), db: Session = Depends(get_db), admin: User = Depends(get_admin_user), ): validate_csrf(request, csrf_token) payload = UserCreate(email=email, full_name=full_name, password=password, role=role) existing = db.query(User).filter(User.email == payload.email).first() if existing: raise HTTPException(status_code=400, detail="E-Mail existiert bereits.") user = User( email=payload.email, full_name=payload.full_name, password_hash=hash_password(payload.password), is_admin=payload.role == "admin", role=payload.role, is_active=True, ) db.add(user) db.commit() return RedirectResponse(url="/admin/users", status_code=status.HTTP_302_FOUND)