Files
TK-Wiki-Newsletter/app/main.py
smueller 7788c74cfe Refactor configuration and security features for improved development experience
- Updated the .env.example file to reflect a development environment setup with enhanced secret key requirements and local host settings.
- Modified the Docker Compose configuration to enhance security with read-only settings and no-new-privileges options.
- Updated requirements.txt to pin package versions for better dependency management.
- Enhanced the FastAPI application to include dynamic OpenAPI and documentation URLs based on the environment.
- Implemented session versioning in JWT tokens to improve security and user session management.
- Added new validation for user roles and password strength in schemas.
- Improved email sending logic to handle recipient lists more robustly and added logging for SMTP operations.
- Updated dashboard and profile templates to reflect new features and improve user experience.
2026-07-07 16:31:10 +02:00

871 lines
32 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import asyncio
import logging
from datetime import datetime
from pathlib import Path
from urllib.parse import urlencode
from pydantic import ValidationError
from fastapi import Depends, FastAPI, Form, HTTPException, Request, Response, status
from fastapi.middleware.trustedhost import TrustedHostMiddleware
from fastapi.responses import HTMLResponse, JSONResponse, 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.cookies import cookie_secure
from app.core.email_utils import parse_recipient_list
from app.core.rate_limit import check_login_rate_limit, clear_login_attempts, record_failed_login
from app.core.security import create_access_token, hash_password, verify_password
from app.core.startup_checks import validate_startup_config
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 PasswordChange, ProfileUpdate, UserCreate, UserRoleUpdate
from app.services.auth import can_view_sensitive_logs, get_admin_user, get_current_user, get_optional_user, require_editor_or_admin
from app.services.config_store import get_config, has_secret_config, set_config
from app.services.session_tokens import invalidate_user_sessions
from app.services.csrf import CSRF_COOKIE_NAME, ensure_csrf_cookie, generate_csrf_token, validate_csrf
from app.services.newsletter import (
DEFAULT_DISPLAY,
article_url,
create_html,
create_outlook_html,
create_plain_text,
create_subject,
filter_articles,
parse_display_options,
parse_highlights,
resolve_period,
split_articles_by_type,
)
from app.services.smtp_sender import get_smtp_settings, mark_scheduled_sent, schedule_is_due, send_newsletter
from app.services.wiki import WikiFetchError, WikiService
validate_startup_config()
_openapi_url = None if settings.is_production else "/openapi.json"
_docs_url = None if settings.is_production else "/docs"
_redoc_url = None if settings.is_production else "/redoc"
app = FastAPI(
title=settings.app_name,
docs_url=_docs_url,
redoc_url=_redoc_url,
openapi_url=_openapi_url,
)
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")
templates.env.globals["wiki_article_url"] = article_url
wiki_service = WikiService()
logger = logging.getLogger("newsletter.scheduler")
SCHEDULER_INTERVAL_SECONDS = 60
_scheduler_task: asyncio.Task | None = None
@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()
global _scheduler_task
_scheduler_task = asyncio.create_task(_scheduler_loop())
@app.on_event("shutdown")
async def on_shutdown() -> None:
if _scheduler_task is not None:
_scheduler_task.cancel()
async def _scheduler_loop() -> None:
"""Prüft periodisch, ob laut Zeitplan ein Newsletter-Versand fällig ist."""
while True:
try:
await asyncio.sleep(SCHEDULER_INTERVAL_SECONDS)
await _run_scheduled_send_if_due()
except asyncio.CancelledError:
break
except Exception:
logger.exception("Fehler im Scheduler-Loop")
async def _run_scheduled_send_if_due() -> None:
db = next(get_db())
try:
settings_snapshot = get_smtp_settings(db)
now = datetime.now()
if not schedule_is_due(settings_snapshot, now):
return
try:
days = int(settings_snapshot.get("gen_days") or "30")
except ValueError:
days = 30
gen = await _generate_newsletter(
period_mode=settings_snapshot.get("gen_period", "days"),
days=max(1, min(days, 90)),
article_type=settings_snapshot.get("gen_article_type", "all"),
category=settings_snapshot.get("gen_category", ""),
display=DEFAULT_DISPLAY,
editor_tip="",
highlight_list=[],
contact_email=settings_snapshot.get("reply_to") or settings_snapshot.get("from_email", ""),
)
if gen["wiki_error"] or not gen["articles"]:
# Kein Versand ohne Inhalt; erneuter Versuch beim nächsten Intervall.
logger.info("Geplanter Versand übersprungen (kein Inhalt/Wiki-Fehler).")
return
try:
recipients = parse_recipient_list(settings_snapshot.get("recipients", ""))
except ValueError:
logger.warning("Geplanter Versand übersprungen (ungültige Empfängerliste).")
return
send_newsletter(db, gen["subject"], gen["raw_html"], gen["plain_text"], recipients, scheduled=True)
mark_scheduled_sent(db, now)
finally:
db.close()
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"))
if "session_version" not in cols:
conn.execute(text("ALTER TABLE users ADD COLUMN session_version INTEGER NOT NULL DEFAULT 0"))
@app.middleware("http")
async def add_security_headers(request: Request, call_next):
cookie_token = request.cookies.get(CSRF_COOKIE_NAME)
request.state.csrf_token = cookie_token or generate_csrf_token()
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"
# Die Newsletter-Vorschau läuft in einem srcdoc-iframe und erbt diese CSP.
# E-Mail-HTML benötigt zwingend Inline-Styles; das Logo liegt auf einem externen https-Host.
path = request.url.path
if path.startswith("/dashboard"):
csp = (
"default-src 'self'; "
"img-src 'self' https: data:; "
"style-src 'self' 'unsafe-inline'; "
"script-src 'self'; "
"frame-src 'self';"
)
else:
csp = (
"default-src 'self'; "
"img-src 'self' https: data:; "
"style-src 'self'; "
"script-src 'self';"
)
response.headers["Content-Security-Policy"] = csp
if settings.is_production and cookie_secure(request):
response.headers["Strict-Transport-Security"] = f"max-age={settings.hsts_max_age}; includeSubDomains"
if path not in {"/login", "/static"} and not path.startswith("/static/"):
response.headers["Cache-Control"] = "no-store"
response.headers["Pragma"] = "no-cache"
if not cookie_token:
response.set_cookie(
CSRF_COOKIE_NAME,
request.state.csrf_token,
httponly=True,
secure=cookie_secure(request),
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:
if settings.admin_bootstrap_reset:
existing.password_hash = hash_password(settings.admin_bootstrap_password)
existing.is_admin = True
existing.role = "admin"
existing.is_active = True
db.commit()
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 _smtp_context(db: Session, *, include_recipients: bool) -> dict[str, str]:
smtp = get_smtp_settings(db)
safe = {key: value for key, value in smtp.items() if key != "password"}
safe["password_configured"] = "true" if has_secret_config(db, "smtp.password") or smtp.get("password") else "false"
if not include_recipients:
safe["recipients"] = ""
return safe
def _base_context(request: Request, user: User, db: Session) -> dict:
path = request.url.path
active_nav = "dashboard"
if path.startswith("/admin/users"):
active_nav = "users"
elif path.startswith("/profil"):
active_nav = "profile"
return {
"user": user,
"csrf_token": ensure_csrf_cookie(request),
"smtp": _smtp_context(db, include_recipients=can_view_sensitive_logs(user)),
"can_view_sensitive": can_view_sensitive_logs(user),
"active_nav": active_nav,
"editor_tip_max_length": settings.editor_tip_max_length,
"highlights_max_length": settings.highlights_max_length,
}
def _render(request: Request, name: str, context: dict) -> HTMLResponse:
return templates.TemplateResponse(request=request, name=name, context=context)
def _wants_html(request: Request) -> bool:
accept = request.headers.get("accept", "")
return "text/html" in accept or "*/*" in accept or accept == ""
@app.exception_handler(HTTPException)
async def http_exception_handler(request: Request, exc: HTTPException):
if exc.status_code == status.HTTP_401_UNAUTHORIZED and _wants_html(request):
return RedirectResponse(url="/login", status_code=status.HTTP_302_FOUND)
if exc.status_code == status.HTTP_403_FORBIDDEN and _wants_html(request):
return RedirectResponse(url="/dashboard", status_code=status.HTTP_302_FOUND)
return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail})
@app.get("/", response_class=HTMLResponse)
def index(request: Request, db: Session = Depends(get_db)):
user = get_optional_user(request, db)
target = "/dashboard" if user else "/login"
return RedirectResponse(url=target, status_code=status.HTTP_302_FOUND)
@app.get("/login", response_class=HTMLResponse)
def login_page(request: Request, db: Session = Depends(get_db)):
if get_optional_user(request, db):
return RedirectResponse(url="/dashboard", status_code=status.HTTP_302_FOUND)
return _render(
request,
"login.html",
{
"csrf_token": ensure_csrf_cookie(request),
"error_message": None,
},
)
@app.post("/login")
def login(
request: Request,
csrf_token: str = Form(...),
email: str = Form(...),
password: str = Form(...),
db: Session = Depends(get_db),
):
try:
validate_csrf(request, csrf_token)
except HTTPException:
return _render(
request,
"login.html",
{
"csrf_token": ensure_csrf_cookie(request),
"error_message": "Sitzung abgelaufen. Bitte erneut anmelden.",
},
)
try:
check_login_rate_limit(request, email)
except HTTPException:
return _render(
request,
"login.html",
{
"csrf_token": ensure_csrf_cookie(request),
"error_message": "Zu viele Anmeldeversuche. Bitte in einigen Minuten erneut versuchen.",
},
)
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):
record_failed_login(request, email)
return _render(
request,
"login.html",
{
"csrf_token": ensure_csrf_cookie(request),
"error_message": "Ungültige Zugangsdaten.",
},
)
clear_login_attempts(request, email)
token = create_access_token(user.id, session_version=user.session_version or 0)
max_age = settings.access_token_expire_minutes * 60
response = RedirectResponse(url="/dashboard", status_code=status.HTTP_302_FOUND)
response.set_cookie(
"access_token",
token,
httponly=True,
secure=cookie_secure(request),
samesite="strict",
max_age=max_age,
)
return response
@app.post("/logout")
def logout(
request: Request,
csrf_token: str = Form(...),
db: Session = Depends(get_db),
):
validate_csrf(request, csrf_token)
current_user = get_optional_user(request, db)
if current_user:
invalidate_user_sessions(current_user, db)
response = RedirectResponse(url="/login", status_code=status.HTTP_302_FOUND)
response.delete_cookie("access_token")
return response
def _empty_filters() -> dict:
return {
"days": 30,
"period": "last_month",
"article_type": "new",
"category": "",
"display": DEFAULT_DISPLAY,
"editor_tip": "",
"highlights": "",
}
async def _generate_newsletter(
*,
period_mode: str,
days: int,
article_type: str,
category: str,
display,
editor_tip: str,
highlight_list: list[str],
contact_email: str = "",
) -> dict:
p = resolve_period(period_mode, days)
result = {
"period": p,
"wiki_error": None,
"articles": [],
"articles_new": [],
"articles_edited": [],
"plain_text": "",
"raw_html": "",
"outlook_html": "",
"subject": "",
}
try:
articles = await wiki_service.get_recent_changes(
days=p["days"], article_type=article_type, start=p["start"], end=p["end"]
)
filtered = filter_articles(articles, category_filter=category or None)
new_articles, edited_articles = split_articles_by_type(filtered)
prange = (p["start_str"], p["end_str"])
result.update(
{
"articles": filtered,
"articles_new": new_articles,
"articles_edited": edited_articles,
"subject": create_subject(p["month_label"], p["days"]),
"plain_text": create_plain_text(filtered, p["days"], display, category, editor_tip, highlight_list, p["label"], prange, article_type, contact_email),
"raw_html": create_html(filtered, p["days"], display, category, editor_tip, highlight_list, p["label"], prange, article_type, p["month_label"], contact_email),
"outlook_html": create_outlook_html(filtered, p["days"], display, category, editor_tip, highlight_list, p["label"], prange, article_type, p["month_label"], contact_email),
}
)
except WikiFetchError as exc:
result["wiki_error"] = exc.message
return result
@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)
send_status = request.query_params.get("send_status")
send_result = None
if send_status:
send_result = {"status": send_status, "message": request.query_params.get("send_msg", "")}
context.update({
"articles": [],
"articles_new": [],
"articles_edited": [],
"raw_html": "",
"outlook_html": "",
"plain_text": "",
"subject": "",
"filters": _empty_filters(),
"wiki_error": None,
"send_result": send_result,
})
logs = db.query(SendLog).order_by(SendLog.created_at.desc()).limit(20).all()
context["send_logs"] = logs
return _render(request, "dashboard.html", context)
@app.post("/dashboard", response_class=HTMLResponse)
async def dashboard_generate(
request: Request,
csrf_token: str = Form(...),
period: str = Form("days"),
days: int = Form(30),
article_type: str = Form("all"),
category: str = Form(""),
show_date: str | None = Form(None),
show_user: str | None = Form(None),
show_category: str | None = Form(None),
editor_tip: str = Form(""),
highlights: str = Form(""),
db: Session = Depends(get_db),
current_user: User = Depends(require_editor_or_admin),
):
validate_csrf(request, csrf_token)
if len(editor_tip) > settings.editor_tip_max_length:
editor_tip = editor_tip[: settings.editor_tip_max_length]
if len(highlights) > settings.highlights_max_length:
highlights = highlights[: settings.highlights_max_length]
days = max(1, min(days, 90))
if article_type not in ("all", "new", "edited"):
article_type = "all"
display = parse_display_options(show_date, show_user, show_category)
highlight_list = parse_highlights(highlights)
smtp_cfg = get_smtp_settings(db)
contact_email = smtp_cfg["reply_to"] or smtp_cfg["from_email"]
gen = await _generate_newsletter(
period_mode=period,
days=days,
article_type=article_type,
category=category,
display=display,
editor_tip=editor_tip,
highlight_list=highlight_list,
contact_email=contact_email,
)
context = _base_context(request, current_user, db)
context.update(
{
"articles": gen["articles"],
"articles_new": gen["articles_new"],
"articles_edited": gen["articles_edited"],
"raw_html": gen["raw_html"],
"outlook_html": gen["outlook_html"],
"plain_text": gen["plain_text"],
"subject": gen["subject"],
"filters": {
"days": days,
"period": period,
"article_type": article_type,
"category": category,
"display": display,
"editor_tip": editor_tip,
"highlights": highlights,
},
"send_logs": db.query(SendLog).order_by(SendLog.created_at.desc()).limit(20).all(),
"wiki_error": gen["wiki_error"],
"send_result": None,
}
)
return _render(request, "dashboard.html", context)
@app.post("/newsletter/send-now", response_class=HTMLResponse)
async def send_now(
request: Request,
csrf_token: str = Form(...),
period: str = Form("days"),
days: int = Form(30),
article_type: str = Form("all"),
category: str = Form(""),
show_date: str | None = Form(None),
show_user: str | None = Form(None),
show_category: str | None = Form(None),
editor_tip: str = Form(""),
highlights: str = Form(""),
db: Session = Depends(get_db),
current_user: User = Depends(require_editor_or_admin),
):
validate_csrf(request, csrf_token)
if len(editor_tip) > settings.editor_tip_max_length:
editor_tip = editor_tip[: settings.editor_tip_max_length]
if len(highlights) > settings.highlights_max_length:
highlights = highlights[: settings.highlights_max_length]
display = parse_display_options(show_date, show_user, show_category)
if article_type not in ("all", "new", "edited"):
article_type = "all"
highlight_list = parse_highlights(highlights)
days = max(1, min(days, 90))
smtp_cfg = get_smtp_settings(db)
contact_email = smtp_cfg["reply_to"] or smtp_cfg["from_email"]
gen = await _generate_newsletter(
period_mode=period,
days=days,
article_type=article_type,
category=category,
display=display,
editor_tip=editor_tip,
highlight_list=highlight_list,
contact_email=contact_email,
)
send_result: dict[str, str]
if gen["wiki_error"]:
send_result = {"status": "failed", "message": f"Versand abgebrochen Wiki-Fehler: {gen['wiki_error']}"}
elif not gen["articles"]:
send_result = {"status": "failed", "message": "Versand abgebrochen im Zeitraum wurden keine Artikel gefunden."}
else:
try:
recipients = parse_recipient_list(get_config(db, "smtp.recipients", ""))
except ValueError as exc:
send_result = {"status": "failed", "message": f"Versand abgebrochen {exc}"}
else:
status_code, detail = send_newsletter(db, gen["subject"], gen["raw_html"], gen["plain_text"], recipients, scheduled=False)
send_result = {"status": status_code, "message": detail}
query = urlencode({"send_status": send_result["status"], "send_msg": send_result["message"]})
return RedirectResponse(url=f"/dashboard?{query}", status_code=status.HTTP_303_SEE_OTHER)
@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(""),
from_name: str = Form(""),
reply_to: str = Form(""),
use_tls: str | None = Form(None),
recipients: str = Form(""),
schedule_enabled: str | None = Form(None),
schedule_frequency: str = Form("monthly"),
schedule_time: str = Form("08:00"),
schedule_weekdays: list[str] = Form(default=[]),
schedule_dom: str = Form("1"),
schedule_acknowledged: str | None = Form(None),
gen_period: str = Form("days"),
gen_days: str = Form("30"),
gen_article_type: str = Form("all"),
gen_category: str = Form(""),
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())
if password.strip():
set_config(db, "smtp.password", password.strip())
set_config(db, "smtp.from_email", from_email.strip())
set_config(db, "smtp.from_name", from_name.strip())
set_config(db, "smtp.reply_to", reply_to.strip())
set_config(db, "smtp.use_tls", "true" if use_tls == "true" else "false")
try:
validated_recipients = parse_recipient_list(recipients)
set_config(db, "smtp.recipients", ", ".join(validated_recipients))
except ValueError:
set_config(db, "smtp.recipients", recipients.strip())
set_config(db, "smtp.schedule_enabled", "true" if schedule_enabled == "true" else "false")
set_config(db, "smtp.schedule_acknowledged", "true" if schedule_acknowledged == "true" else "false")
if schedule_frequency not in ("daily", "weekly", "monthly"):
schedule_frequency = "monthly"
set_config(db, "smtp.schedule_frequency", schedule_frequency)
set_config(db, "smtp.schedule_time", schedule_time.strip() or "08:00")
valid_weekdays = [d for d in schedule_weekdays if d in {"0", "1", "2", "3", "4", "5", "6"}]
set_config(db, "smtp.schedule_weekdays", ",".join(valid_weekdays))
try:
dom = max(1, min(int(schedule_dom), 28))
except ValueError:
dom = 1
set_config(db, "smtp.schedule_dom", str(dom))
if gen_period not in ("days", "last_month"):
gen_period = "days"
set_config(db, "smtp.gen_period", gen_period)
try:
gdays = max(1, min(int(gen_days), 90))
except ValueError:
gdays = 30
set_config(db, "smtp.gen_days", str(gdays))
if gen_article_type not in ("all", "new", "edited"):
gen_article_type = "all"
set_config(db, "smtp.gen_article_type", gen_article_type)
set_config(db, "smtp.gen_category", gen_category.strip())
return RedirectResponse(url="/dashboard", status_code=status.HTTP_302_FOUND)
def _users_context(request: Request, admin: User, db: Session, **extra) -> dict:
context = _base_context(request, admin, db)
context["users"] = db.query(User).order_by(User.created_at.desc()).all()
context.setdefault("error_message", None)
context.setdefault("success_message", None)
context.update(extra)
return context
def _format_validation_error(exc: ValidationError) -> str:
messages: list[str] = []
for err in exc.errors():
msg = err.get("msg", "Ungültige Eingabe")
if msg.startswith("Value error, "):
msg = msg[13:]
messages.append(msg)
return " ".join(messages) if messages else "Ungültige Eingabe."
@app.get("/admin/users", response_class=HTMLResponse)
def users_page(request: Request, db: Session = Depends(get_db), admin: User = Depends(get_admin_user)):
created = request.query_params.get("created") == "1"
return _render(
request,
"users.html",
_users_context(
request,
admin,
db,
success_message="Benutzer wurde erfolgreich angelegt." if created else None,
),
)
@app.post("/admin/users", response_class=HTMLResponse)
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),
):
try:
validate_csrf(request, csrf_token)
except HTTPException:
return _render(
request,
"users.html",
_users_context(request, admin, db, error_message="Sitzung abgelaufen. Bitte Seite neu laden und erneut versuchen."),
)
try:
payload = UserCreate(email=email.strip(), full_name=full_name.strip(), password=password, role=role)
except ValidationError as exc:
return _render(
request,
"users.html",
_users_context(request, admin, db, error_message=_format_validation_error(exc)),
)
existing = db.query(User).filter(User.email == payload.email).first()
if existing:
return _render(
request,
"users.html",
_users_context(request, admin, db, error_message="Diese E-Mail-Adresse ist bereits registriert."),
)
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?created=1", status_code=status.HTTP_302_FOUND)
def _profile_context(request: Request, user: User, db: Session, **extra) -> dict:
context = _base_context(request, user, db)
context.setdefault("error_message", None)
context.setdefault("pw_error_message", None)
context.setdefault("success_message", None)
context.update(extra)
return context
@app.get("/profil", response_class=HTMLResponse)
def profile_page(request: Request, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
success = None
if request.query_params.get("updated") == "1":
success = "Profil wurde aktualisiert."
elif request.query_params.get("pw") == "1":
success = "Passwort wurde geändert."
return _render(request, "profile.html", _profile_context(request, current_user, db, success_message=success))
@app.post("/profil", response_class=HTMLResponse)
def update_profile(
request: Request,
csrf_token: str = Form(...),
email: str = Form(...),
full_name: str = Form(...),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
try:
validate_csrf(request, csrf_token)
except HTTPException:
return _render(
request,
"profile.html",
_profile_context(request, current_user, db, error_message="Sitzung abgelaufen. Bitte Seite neu laden und erneut versuchen."),
)
try:
payload = ProfileUpdate(email=email.strip(), full_name=full_name.strip())
except ValidationError as exc:
return _render(
request,
"profile.html",
_profile_context(request, current_user, db, error_message=_format_validation_error(exc)),
)
clash = db.query(User).filter(User.email == payload.email, User.id != current_user.id).first()
if clash:
return _render(
request,
"profile.html",
_profile_context(request, current_user, db, error_message="Diese E-Mail-Adresse wird bereits verwendet."),
)
current_user.email = payload.email
current_user.full_name = payload.full_name
db.commit()
return RedirectResponse(url="/profil?updated=1", status_code=status.HTTP_302_FOUND)
@app.post("/profil/passwort", response_class=HTMLResponse)
def change_password(
request: Request,
csrf_token: str = Form(...),
current_password: str = Form(...),
new_password: str = Form(...),
confirm_password: str = Form(...),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
try:
validate_csrf(request, csrf_token)
except HTTPException:
return _render(
request,
"profile.html",
_profile_context(request, current_user, db, pw_error_message="Sitzung abgelaufen. Bitte Seite neu laden und erneut versuchen."),
)
if not verify_password(current_password, current_user.password_hash):
return _render(
request,
"profile.html",
_profile_context(request, current_user, db, pw_error_message="Das aktuelle Passwort ist nicht korrekt."),
)
if new_password != confirm_password:
return _render(
request,
"profile.html",
_profile_context(request, current_user, db, pw_error_message="Die neuen Passwörter stimmen nicht überein."),
)
try:
payload = PasswordChange(new_password=new_password)
except ValidationError as exc:
return _render(
request,
"profile.html",
_profile_context(request, current_user, db, pw_error_message=_format_validation_error(exc)),
)
current_user.password_hash = hash_password(payload.new_password)
invalidate_user_sessions(current_user, db)
db.commit()
return RedirectResponse(url="/profil?pw=1", status_code=status.HTTP_302_FOUND)
@app.post("/admin/users/{user_id}/role", response_class=HTMLResponse)
def update_user_role(
request: Request,
user_id: int,
csrf_token: str = Form(...),
role: str = Form(...),
db: Session = Depends(get_db),
admin: User = Depends(get_admin_user),
):
validate_csrf(request, csrf_token)
target = db.query(User).filter(User.id == user_id).first()
if not target:
return _render(request, "users.html", _users_context(request, admin, db, error_message="Benutzer nicht gefunden."))
if target.id == admin.id:
return _render(request, "users.html", _users_context(request, admin, db, error_message="Eigene Rolle kann hier nicht geändert werden."))
try:
payload = UserRoleUpdate(role=role)
except ValidationError as exc:
return _render(request, "users.html", _users_context(request, admin, db, error_message=_format_validation_error(exc)))
target.role = payload.role
target.is_admin = payload.role == "admin"
invalidate_user_sessions(target, db)
return RedirectResponse(url="/admin/users", status_code=status.HTTP_302_FOUND)
@app.post("/admin/users/{user_id}/toggle-active", response_class=HTMLResponse)
def toggle_user_active(
request: Request,
user_id: int,
csrf_token: str = Form(...),
db: Session = Depends(get_db),
admin: User = Depends(get_admin_user),
):
validate_csrf(request, csrf_token)
target = db.query(User).filter(User.id == user_id).first()
if not target:
return _render(request, "users.html", _users_context(request, admin, db, error_message="Benutzer nicht gefunden."))
if target.id == admin.id:
return _render(request, "users.html", _users_context(request, admin, db, error_message="Eigenes Konto kann nicht deaktiviert werden."))
target.is_active = not target.is_active
invalidate_user_sessions(target, db)
return RedirectResponse(url="/admin/users", status_code=status.HTTP_302_FOUND)