Refactor Dockerfile to use entrypoint script, update requirements for bcrypt, and enhance FastAPI app with optional user retrieval and improved error handling

This commit is contained in:
smueller
2026-07-03 11:48:43 +02:00
parent 995740937e
commit 0d7729287f
6 changed files with 59 additions and 19 deletions

View File

@@ -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"]

View File

@@ -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:

View File

@@ -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),
}
@app.get("/", response_class=HTMLResponse)
def index(request: Request):
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):
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")

View File

@@ -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:

5
docker-entrypoint.sh Normal file
View File

@@ -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

View File

@@ -5,7 +5,7 @@ sqlalchemy
alembic
python-multipart
httpx
passlib[bcrypt]
bcrypt
python-jose[cryptography]
pydantic-settings
email-validator