From 0d7729287f05b5ce7419f15355f8f28192ced1b2 Mon Sep 17 00:00:00 2001 From: smueller Date: Fri, 3 Jul 2026 11:48:43 +0200 Subject: [PATCH] Refactor Dockerfile to use entrypoint script, update requirements for bcrypt, and enhance FastAPI app with optional user retrieval and improved error handling --- Dockerfile | 6 +++--- app/core/security.py | 8 +++----- app/main.py | 45 ++++++++++++++++++++++++++++++++++---------- app/services/auth.py | 12 ++++++++++++ docker-entrypoint.sh | 5 +++++ requirements.txt | 2 +- 6 files changed, 59 insertions(+), 19 deletions(-) create mode 100644 docker-entrypoint.sh diff --git a/Dockerfile b/Dockerfile index 24f287f..f2c9cd0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,9 +11,9 @@ COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY app ./app +COPY docker-entrypoint.sh /docker-entrypoint.sh +RUN sed -i 's/\r$//' /docker-entrypoint.sh && chmod +x /docker-entrypoint.sh EXPOSE 8000 -USER appuser - -CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] +ENTRYPOINT ["/docker-entrypoint.sh"] diff --git a/app/core/security.py b/app/core/security.py index 0b5f20a..664b118 100644 --- a/app/core/security.py +++ b/app/core/security.py @@ -1,20 +1,18 @@ from datetime import datetime, timedelta, timezone from typing import Any +import bcrypt from jose import jwt -from passlib.context import CryptContext from app.core.config import settings -pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") - def hash_password(password: str) -> str: - return pwd_context.hash(password) + return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8") def verify_password(plain_password: str, hashed_password: str) -> bool: - return pwd_context.verify(plain_password, hashed_password) + return bcrypt.checkpw(plain_password.encode("utf-8"), hashed_password.encode("utf-8")) def create_access_token(subject: Any) -> str: diff --git a/app/main.py b/app/main.py index 19938da..b263746 100644 --- a/app/main.py +++ b/app/main.py @@ -2,7 +2,7 @@ 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.responses import HTMLResponse, JSONResponse, RedirectResponse from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates from sqlalchemy import inspect, text @@ -14,7 +14,7 @@ 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.auth import get_admin_user, get_current_user, get_optional_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 @@ -82,21 +82,46 @@ def _bootstrap_admin() -> None: 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), } +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): - return RedirectResponse(url="/dashboard", status_code=status.HTTP_302_FOUND) +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): - return templates.TemplateResponse("login.html", {"request": request, "csrf_token": request.cookies.get(CSRF_COOKIE_NAME) or ensure_csrf_cookie(request)}) +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": request.cookies.get(CSRF_COOKIE_NAME) or ensure_csrf_cookie(request)}, + ) @app.post("/login") @@ -131,7 +156,7 @@ async def dashboard(request: Request, db: Session = Depends(get_db), current_use 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) + return _render(request, "dashboard.html", context) @app.post("/dashboard", response_class=HTMLResponse) @@ -162,7 +187,7 @@ async def dashboard_generate( "send_logs": db.query(SendLog).order_by(SendLog.created_at.desc()).limit(20).all(), } ) - return templates.TemplateResponse("dashboard.html", context) + return _render(request, "dashboard.html", context) @app.post("/newsletter/send-now") @@ -219,7 +244,7 @@ def update_smtp_settings( 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) + return _render(request, "users.html", context) @app.post("/admin/users") diff --git a/app/services/auth.py b/app/services/auth.py index b92261c..e2fe640 100644 --- a/app/services/auth.py +++ b/app/services/auth.py @@ -7,6 +7,18 @@ from app.db.session import get_db from app.models.user import User +def get_optional_user(request: Request, db: Session = Depends(get_db)) -> User | None: + token = request.cookies.get("access_token") + if not token: + return None + try: + payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm]) + user_id = int(payload.get("sub")) + except (JWTError, TypeError, ValueError): + return None + return db.query(User).filter(User.id == user_id, User.is_active.is_(True)).first() + + def get_current_user(request: Request, db: Session = Depends(get_db)) -> User: token = request.cookies.get("access_token") if not token: diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100644 index 0000000..bb96b57 --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,5 @@ +#!/bin/sh +set -e +mkdir -p /app/data +chown -R appuser:appgroup /app/data +exec runuser -u appuser -- uvicorn app.main:app --host 0.0.0.0 --port 8000 diff --git a/requirements.txt b/requirements.txt index a7e9c8d..7f08715 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,7 @@ sqlalchemy alembic python-multipart httpx -passlib[bcrypt] +bcrypt python-jose[cryptography] pydantic-settings email-validator