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:
@@ -11,9 +11,9 @@ COPY requirements.txt .
|
|||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
COPY app ./app
|
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
|
EXPOSE 8000
|
||||||
|
|
||||||
USER appuser
|
ENTRYPOINT ["/docker-entrypoint.sh"]
|
||||||
|
|
||||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
|
||||||
|
|||||||
@@ -1,20 +1,18 @@
|
|||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
import bcrypt
|
||||||
from jose import jwt
|
from jose import jwt
|
||||||
from passlib.context import CryptContext
|
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
|
||||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
|
||||||
|
|
||||||
|
|
||||||
def hash_password(password: str) -> str:
|
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:
|
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:
|
def create_access_token(subject: Any) -> str:
|
||||||
|
|||||||
45
app/main.py
45
app/main.py
@@ -2,7 +2,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
from fastapi import Depends, FastAPI, Form, HTTPException, Request, Response, status
|
from fastapi import Depends, FastAPI, Form, HTTPException, Request, Response, status
|
||||||
from fastapi.middleware.trustedhost import TrustedHostMiddleware
|
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.staticfiles import StaticFiles
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
from sqlalchemy import inspect, text
|
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.system import SendLog
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.schemas.user import UserCreate
|
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.config_store import get_config, set_config
|
||||||
from app.services.csrf import CSRF_COOKIE_NAME, ensure_csrf_cookie, validate_csrf
|
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.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:
|
def _base_context(request: Request, user: User, db: Session) -> dict:
|
||||||
return {
|
return {
|
||||||
"request": request,
|
|
||||||
"user": user,
|
"user": user,
|
||||||
"csrf_token": request.cookies.get(CSRF_COOKIE_NAME) or ensure_csrf_cookie(request),
|
"csrf_token": request.cookies.get(CSRF_COOKIE_NAME) or ensure_csrf_cookie(request),
|
||||||
"smtp": get_smtp_settings(db),
|
"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)
|
@app.get("/", response_class=HTMLResponse)
|
||||||
def index(request: Request):
|
def index(request: Request, db: Session = Depends(get_db)):
|
||||||
return RedirectResponse(url="/dashboard", status_code=status.HTTP_302_FOUND)
|
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)
|
@app.get("/login", response_class=HTMLResponse)
|
||||||
def login_page(request: Request):
|
def login_page(request: Request, db: Session = Depends(get_db)):
|
||||||
return templates.TemplateResponse("login.html", {"request": request, "csrf_token": request.cookies.get(CSRF_COOKIE_NAME) or ensure_csrf_cookie(request)})
|
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")
|
@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": ""}})
|
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()
|
logs = db.query(SendLog).order_by(SendLog.created_at.desc()).limit(20).all()
|
||||||
context["send_logs"] = logs
|
context["send_logs"] = logs
|
||||||
return templates.TemplateResponse("dashboard.html", context)
|
return _render(request, "dashboard.html", context)
|
||||||
|
|
||||||
|
|
||||||
@app.post("/dashboard", response_class=HTMLResponse)
|
@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(),
|
"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")
|
@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)):
|
def users_page(request: Request, db: Session = Depends(get_db), admin: User = Depends(get_admin_user)):
|
||||||
context = _base_context(request, admin, db)
|
context = _base_context(request, admin, db)
|
||||||
context["users"] = db.query(User).order_by(User.created_at.desc()).all()
|
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")
|
@app.post("/admin/users")
|
||||||
|
|||||||
@@ -7,6 +7,18 @@ from app.db.session import get_db
|
|||||||
from app.models.user import User
|
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:
|
def get_current_user(request: Request, db: Session = Depends(get_db)) -> User:
|
||||||
token = request.cookies.get("access_token")
|
token = request.cookies.get("access_token")
|
||||||
if not token:
|
if not token:
|
||||||
|
|||||||
5
docker-entrypoint.sh
Normal file
5
docker-entrypoint.sh
Normal 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
|
||||||
@@ -5,7 +5,7 @@ sqlalchemy
|
|||||||
alembic
|
alembic
|
||||||
python-multipart
|
python-multipart
|
||||||
httpx
|
httpx
|
||||||
passlib[bcrypt]
|
bcrypt
|
||||||
python-jose[cryptography]
|
python-jose[cryptography]
|
||||||
pydantic-settings
|
pydantic-settings
|
||||||
email-validator
|
email-validator
|
||||||
|
|||||||
Reference in New Issue
Block a user