Refactor configuration and security features for improved development experience #11

Merged
smueller merged 1 commits from dev into main 2026-07-07 14:31:48 +00:00
22 changed files with 509 additions and 82 deletions

View File

@@ -1,20 +1,26 @@
APP_NAME=TK Wiki Newsletter Admin APP_NAME=TK Wiki Newsletter Admin
ENVIRONMENT=production # development = lokale Entwicklung ohne harte Produktions-Checks
SECRET_KEY=PLEASE_CHANGE_TO_A_LONG_RANDOM_SECRET # production = erzwingt starke Secrets, ALLOWED_HOSTS, COOKIE_SECURE=true
ENVIRONMENT=development
SECRET_KEY=PLEASE_CHANGE_TO_A_LONG_RANDOM_SECRET_AT_LEAST_32_CHARS
ALGORITHM=HS256 ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=120 ACCESS_TOKEN_EXPIRE_MINUTES=120
JWT_ISSUER=tk-wiki-newsletter-admin
JWT_AUDIENCE=tk-wiki-newsletter-admin
HOST_PORT=8080 HOST_PORT=8080
DATABASE_URL=sqlite:///./data/newsletter.db DATABASE_URL=sqlite:///./data/newsletter.db
# Beispiel PostgreSQL: # Beispiel PostgreSQL:
# DATABASE_URL=postgresql+psycopg://newsletter:newsletter@postgres:5432/newsletter # DATABASE_URL=postgresql+psycopg://newsletter:newsletter@postgres:5432/newsletter
WIKI_API_URL=https://www.thomas-krenn.com/de/wikiDE/api.php WIKI_API_URL=https://www.thomas-krenn.com/de/wikiDE/api.php
ALLOWED_HOSTS=* ALLOWED_HOSTS=localhost,127.0.0.1
COOKIE_SECURE=false COOKIE_SECURE=false
# Auf true setzen, wenn hinter HTTPS/TLS-Terminierung (Reverse Proxy) # Auf true setzen, wenn hinter HTTPS/TLS-Terminierung (Reverse Proxy) Pflicht in production
HSTS_MAX_AGE=31536000
ADMIN_BOOTSTRAP_EMAIL=admin@internal.local ADMIN_BOOTSTRAP_EMAIL=admin@internal.local
ADMIN_BOOTSTRAP_PASSWORD=ChangeMe123! ADMIN_BOOTSTRAP_PASSWORD=ChangeMe123!
# true = setzt Passwort/Rolle/Status des Bootstrap-Admins bei jedem Start # true = setzt Passwort/Rolle/Status des Bootstrap-Admins bei jedem Start
# auf die obigen Werte zurueck (auch wenn der User schon existiert). # auf die obigen Werte zurueck (auch wenn der User schon existiert).
# Nach erfolgreichem Login wieder auf false setzen, damit UI-Passwortaenderungen # In production nicht erlaubt. Nach Erst-Setup auf false lassen.
# nicht beim naechsten Neustart ueberschrieben werden.
ADMIN_BOOTSTRAP_RESET=false ADMIN_BOOTSTRAP_RESET=false
EDITOR_TIP_MAX_LENGTH=10000
HIGHLIGHTS_MAX_LENGTH=5000

View File

@@ -25,6 +25,12 @@ jobs:
- name: Repository auschecken - name: Repository auschecken
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Python-Abhängigkeiten prüfen (pip-audit)
run: |
python -m pip install --upgrade pip
pip install pip-audit
pip-audit -r requirements.txt
- name: Docker Buildx einrichten - name: Docker Buildx einrichten
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3

View File

@@ -3,21 +3,30 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings): class Settings(BaseSettings):
app_name: str = "TK Wiki Newsletter Admin" app_name: str = "TK Wiki Newsletter Admin"
environment: str = "production" environment: str = "development"
secret_key: str = "change-me-in-production" secret_key: str = "change-me-in-production"
algorithm: str = "HS256" algorithm: str = "HS256"
access_token_expire_minutes: int = 120 access_token_expire_minutes: int = 120
jwt_issuer: str = "tk-wiki-newsletter-admin"
jwt_audience: str = "tk-wiki-newsletter-admin"
database_url: str = "sqlite:///./data/newsletter.db" database_url: str = "sqlite:///./data/newsletter.db"
wiki_api_url: str = "https://www.thomas-krenn.com/de/wikiDE/api.php" wiki_api_url: str = "https://www.thomas-krenn.com/de/wikiDE/api.php"
allowed_hosts: str = "*" allowed_hosts: str = "localhost,127.0.0.1"
cookie_secure: bool = False cookie_secure: bool = False
hsts_max_age: int = 31536000
admin_bootstrap_email: str = "admin@internal.local" admin_bootstrap_email: str = "admin@internal.local"
admin_bootstrap_password: str = "ChangeMe123!" admin_bootstrap_password: str = "ChangeMe123!"
# Wenn true: setzt Passwort/Rolle/Status des Bootstrap-Admins beim Start # Wenn true: setzt Passwort/Rolle/Status des Bootstrap-Admins beim Start
# auf die .env-Werte zurück (auch wenn der User bereits existiert). # auf die .env-Werte zurück (auch wenn der User bereits existiert).
admin_bootstrap_reset: bool = False admin_bootstrap_reset: bool = False
editor_tip_max_length: int = 10000
highlights_max_length: int = 5000
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8") model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
@property
def is_production(self) -> bool:
return self.environment.lower() == "production"
settings = Settings() settings = Settings()

16
app/core/email_utils.py Normal file
View File

@@ -0,0 +1,16 @@
from pydantic import EmailStr, TypeAdapter
_email_adapter = TypeAdapter(EmailStr)
def parse_recipient_list(raw: str) -> list[str]:
recipients: list[str] = []
for part in raw.split(","):
addr = part.strip()
if not addr:
continue
if any(ch in addr for ch in ("\n", "\r", "\0")):
raise ValueError(f"Ungültige E-Mail-Adresse: {addr!r}")
_email_adapter.validate_python(addr)
recipients.append(addr)
return recipients

View File

@@ -0,0 +1,18 @@
import re
_PASSWORD_RULES: tuple[tuple[re.Pattern[str], str], ...] = (
(re.compile(r".{10,}"), "Mindestens 10 Zeichen."),
(re.compile(r"[A-Z]"), "Mindestens ein Großbuchstabe."),
(re.compile(r"[a-z]"), "Mindestens ein Kleinbuchstabe."),
(re.compile(r"\d"), "Mindestens eine Ziffer."),
)
def password_policy_errors(password: str) -> list[str]:
return [msg for pattern, msg in _PASSWORD_RULES if not pattern.search(password)]
def validate_password_strength(password: str) -> None:
errors = password_policy_errors(password)
if errors:
raise ValueError(errors[0])

35
app/core/rate_limit.py Normal file
View File

@@ -0,0 +1,35 @@
import time
from collections import defaultdict
from fastapi import HTTPException, Request, status
_WINDOW_SECONDS = 300
_MAX_ATTEMPTS = 5
_attempts: dict[str, list[float]] = defaultdict(list)
def _client_key(request: Request, email: str) -> str:
forwarded = request.headers.get("x-forwarded-for", "")
ip = forwarded.split(",")[0].strip() if forwarded else (request.client.host if request.client else "unknown")
return f"{ip}:{email.strip().lower()}"
def check_login_rate_limit(request: Request, email: str) -> None:
key = _client_key(request, email)
now = time.monotonic()
window_start = now - _WINDOW_SECONDS
recent = [t for t in _attempts[key] if t >= window_start]
_attempts[key] = recent
if len(recent) >= _MAX_ATTEMPTS:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="Zu viele Anmeldeversuche. Bitte in einigen Minuten erneut versuchen.",
)
def record_failed_login(request: Request, email: str) -> None:
_attempts[_client_key(request, email)].append(time.monotonic())
def clear_login_attempts(request: Request, email: str) -> None:
_attempts.pop(_client_key(request, email), None)

View File

@@ -0,0 +1,28 @@
import base64
import hashlib
from cryptography.fernet import Fernet, InvalidToken
from app.core.config import settings
def _fernet() -> Fernet:
digest = hashlib.sha256(settings.secret_key.encode("utf-8")).digest()
key = base64.urlsafe_b64encode(digest)
return Fernet(key)
def encrypt_secret(plain: str) -> str:
if not plain:
return ""
return _fernet().encrypt(plain.encode("utf-8")).decode("utf-8")
def decrypt_secret(cipher: str) -> str:
if not cipher:
return ""
try:
return _fernet().decrypt(cipher.encode("utf-8")).decode("utf-8")
except (InvalidToken, ValueError):
# Bestehende Klartext-Einträge aus älteren Installationen.
return cipher

View File

@@ -15,8 +15,24 @@ def verify_password(plain_password: str, hashed_password: str) -> bool:
return bcrypt.checkpw(plain_password.encode("utf-8"), hashed_password.encode("utf-8")) 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, session_version: int = 0) -> str:
expires_delta = timedelta(minutes=settings.access_token_expire_minutes) expires_delta = timedelta(minutes=settings.access_token_expire_minutes)
expire = datetime.now(timezone.utc) + expires_delta expire = datetime.now(timezone.utc) + expires_delta
to_encode = {"exp": expire, "sub": str(subject)} to_encode = {
"exp": expire,
"sub": str(subject),
"sv": session_version,
"iss": settings.jwt_issuer,
"aud": settings.jwt_audience,
}
return jwt.encode(to_encode, settings.secret_key, algorithm=settings.algorithm) return jwt.encode(to_encode, settings.secret_key, algorithm=settings.algorithm)
def decode_access_token(token: str) -> dict[str, Any]:
return jwt.decode(
token,
settings.secret_key,
algorithms=[settings.algorithm],
issuer=settings.jwt_issuer,
audience=settings.jwt_audience,
)

View File

@@ -0,0 +1,34 @@
import sys
from app.core.config import settings
_WEAK_SECRET_KEYS = frozenset(
{
"change-me-in-production",
"please_change_to_a_long_random_secret",
"secret",
"changeme",
}
)
_WEAK_BOOTSTRAP_PASSWORDS = frozenset({"changeme123!", "change_me_123", "admin123!"})
def validate_startup_config() -> None:
errors: list[str] = []
if settings.environment.lower() == "production":
if settings.secret_key.lower() in _WEAK_SECRET_KEYS or len(settings.secret_key) < 32:
errors.append("SECRET_KEY muss in Produktion mindestens 32 Zeichen lang und zufällig sein.")
if settings.admin_bootstrap_password.lower() in _WEAK_BOOTSTRAP_PASSWORDS:
errors.append("ADMIN_BOOTSTRAP_PASSWORD ist zu schwach für Produktion.")
if settings.admin_bootstrap_reset:
errors.append("ADMIN_BOOTSTRAP_RESET darf in Produktion nicht aktiv sein.")
if settings.allowed_hosts.strip() == "*":
errors.append("ALLOWED_HOSTS darf in Produktion nicht '*' sein konkrete Hostnamen setzen.")
if not settings.cookie_secure:
errors.append("COOKIE_SECURE muss in Produktion auf true gesetzt sein (HTTPS/TLS-Terminierung).")
if errors:
for message in errors:
print(f"STARTUP-FEHLER: {message}", file=sys.stderr)
sys.exit(1)

View File

@@ -16,13 +16,17 @@ from sqlalchemy.orm import Session
from app.core.config import settings from app.core.config import settings
from app.core.cookies import cookie_secure 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.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.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 PasswordChange, ProfileUpdate, UserCreate from app.schemas.user import PasswordChange, ProfileUpdate, UserCreate, UserRoleUpdate
from app.services.auth import get_admin_user, get_current_user, get_optional_user, require_editor_or_admin 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, set_config 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.csrf import CSRF_COOKIE_NAME, ensure_csrf_cookie, generate_csrf_token, validate_csrf
from app.services.newsletter import ( from app.services.newsletter import (
DEFAULT_DISPLAY, DEFAULT_DISPLAY,
@@ -40,7 +44,17 @@ from app.services.newsletter import (
from app.services.smtp_sender import get_smtp_settings, mark_scheduled_sent, schedule_is_due, send_newsletter from app.services.smtp_sender import get_smtp_settings, mark_scheduled_sent, schedule_is_due, send_newsletter
from app.services.wiki import WikiFetchError, WikiService from app.services.wiki import WikiFetchError, WikiService
app = FastAPI(title=settings.app_name) 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()] allowed_hosts = [h.strip() for h in settings.allowed_hosts.split(",") if h.strip()]
if allowed_hosts: if allowed_hosts:
app.add_middleware(TrustedHostMiddleware, allowed_hosts=allowed_hosts) app.add_middleware(TrustedHostMiddleware, allowed_hosts=allowed_hosts)
@@ -109,7 +123,12 @@ async def _run_scheduled_send_if_due() -> None:
logger.info("Geplanter Versand übersprungen (kein Inhalt/Wiki-Fehler).") logger.info("Geplanter Versand übersprungen (kein Inhalt/Wiki-Fehler).")
return return
recipients = [r.strip() for r in settings_snapshot.get("recipients", "").split(",") if r.strip()] 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) send_newsletter(db, gen["subject"], gen["raw_html"], gen["plain_text"], recipients, scheduled=True)
mark_scheduled_sent(db, now) mark_scheduled_sent(db, now)
finally: finally:
@@ -124,6 +143,8 @@ def _ensure_schema_upgrades() -> None:
if "role" not in cols: if "role" not in cols:
conn.execute(text("ALTER TABLE users ADD COLUMN role VARCHAR(32) NOT NULL DEFAULT 'reader'")) 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")) 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") @app.middleware("http")
@@ -137,12 +158,28 @@ async def add_security_headers(request: Request, call_next):
response.headers["Referrer-Policy"] = "same-origin" response.headers["Referrer-Policy"] = "same-origin"
# Die Newsletter-Vorschau läuft in einem srcdoc-iframe und erbt diese CSP. # 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. # E-Mail-HTML benötigt zwingend Inline-Styles; das Logo liegt auf einem externen https-Host.
response.headers["Content-Security-Policy"] = ( path = request.url.path
if path.startswith("/dashboard"):
csp = (
"default-src 'self'; " "default-src 'self'; "
"img-src 'self' https: data:; " "img-src 'self' https: data:; "
"style-src 'self' 'unsafe-inline'; " "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';" "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: if not cookie_token:
response.set_cookie( response.set_cookie(
CSRF_COOKIE_NAME, CSRF_COOKIE_NAME,
@@ -180,6 +217,15 @@ def _bootstrap_admin() -> None:
db.close() 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: def _base_context(request: Request, user: User, db: Session) -> dict:
path = request.url.path path = request.url.path
active_nav = "dashboard" active_nav = "dashboard"
@@ -190,8 +236,11 @@ def _base_context(request: Request, user: User, db: Session) -> dict:
return { return {
"user": user, "user": user,
"csrf_token": ensure_csrf_cookie(request), "csrf_token": ensure_csrf_cookie(request),
"smtp": get_smtp_settings(db), "smtp": _smtp_context(db, include_recipients=can_view_sensitive_logs(user)),
"can_view_sensitive": can_view_sensitive_logs(user),
"active_nav": active_nav, "active_nav": active_nav,
"editor_tip_max_length": settings.editor_tip_max_length,
"highlights_max_length": settings.highlights_max_length,
} }
@@ -253,8 +302,20 @@ def login(
"error_message": "Sitzung abgelaufen. Bitte erneut anmelden.", "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() 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): if not user or not verify_password(password, user.password_hash):
record_failed_login(request, email)
return _render( return _render(
request, request,
"login.html", "login.html",
@@ -263,15 +324,31 @@ def login(
"error_message": "Ungültige Zugangsdaten.", "error_message": "Ungültige Zugangsdaten.",
}, },
) )
token = create_access_token(user.id) 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 = RedirectResponse(url="/dashboard", status_code=status.HTTP_302_FOUND)
response.set_cookie("access_token", token, httponly=True, secure=cookie_secure(request), samesite="strict") response.set_cookie(
"access_token",
token,
httponly=True,
secure=cookie_secure(request),
samesite="strict",
max_age=max_age,
)
return response return response
@app.post("/logout") @app.post("/logout")
def logout(request: Request, csrf_token: str = Form(...)): def logout(
request: Request,
csrf_token: str = Form(...),
db: Session = Depends(get_db),
):
validate_csrf(request, csrf_token) 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 = RedirectResponse(url="/login", status_code=status.HTTP_302_FOUND)
response.delete_cookie("access_token") response.delete_cookie("access_token")
return response return response
@@ -376,6 +453,10 @@ async def dashboard_generate(
current_user: User = Depends(require_editor_or_admin), current_user: User = Depends(require_editor_or_admin),
): ):
validate_csrf(request, csrf_token) 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)) days = max(1, min(days, 90))
if article_type not in ("all", "new", "edited"): if article_type not in ("all", "new", "edited"):
article_type = "all" article_type = "all"
@@ -439,6 +520,10 @@ async def send_now(
current_user: User = Depends(require_editor_or_admin), current_user: User = Depends(require_editor_or_admin),
): ):
validate_csrf(request, csrf_token) 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) display = parse_display_options(show_date, show_user, show_category)
if article_type not in ("all", "new", "edited"): if article_type not in ("all", "new", "edited"):
article_type = "all" article_type = "all"
@@ -464,7 +549,11 @@ async def send_now(
elif not gen["articles"]: elif not gen["articles"]:
send_result = {"status": "failed", "message": "Versand abgebrochen im Zeitraum wurden keine Artikel gefunden."} send_result = {"status": "failed", "message": "Versand abgebrochen im Zeitraum wurden keine Artikel gefunden."}
else: else:
recipients = [r.strip() for r in get_config(db, "smtp.recipients", "").split(",") if r.strip()] 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) status_code, detail = send_newsletter(db, gen["subject"], gen["raw_html"], gen["plain_text"], recipients, scheduled=False)
send_result = {"status": status_code, "message": detail} send_result = {"status": status_code, "message": detail}
@@ -491,6 +580,7 @@ def update_smtp_settings(
schedule_time: str = Form("08:00"), schedule_time: str = Form("08:00"),
schedule_weekdays: list[str] = Form(default=[]), schedule_weekdays: list[str] = Form(default=[]),
schedule_dom: str = Form("1"), schedule_dom: str = Form("1"),
schedule_acknowledged: str | None = Form(None),
gen_period: str = Form("days"), gen_period: str = Form("days"),
gen_days: str = Form("30"), gen_days: str = Form("30"),
gen_article_type: str = Form("all"), gen_article_type: str = Form("all"),
@@ -503,14 +593,20 @@ def update_smtp_settings(
set_config(db, "smtp.host", host.strip()) set_config(db, "smtp.host", host.strip())
set_config(db, "smtp.port", port.strip() or "587") set_config(db, "smtp.port", port.strip() or "587")
set_config(db, "smtp.username", username.strip()) set_config(db, "smtp.username", username.strip())
set_config(db, "smtp.password", password) if password.strip():
set_config(db, "smtp.password", password.strip())
set_config(db, "smtp.from_email", from_email.strip()) set_config(db, "smtp.from_email", from_email.strip())
set_config(db, "smtp.from_name", from_name.strip()) set_config(db, "smtp.from_name", from_name.strip())
set_config(db, "smtp.reply_to", reply_to.strip()) set_config(db, "smtp.reply_to", reply_to.strip())
set_config(db, "smtp.use_tls", "true" if use_tls == "true" else "false") 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.recipients", recipients.strip())
set_config(db, "smtp.schedule_enabled", "true" if schedule_enabled == "true" else "false") 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"): if schedule_frequency not in ("daily", "weekly", "monthly"):
schedule_frequency = "monthly" schedule_frequency = "monthly"
set_config(db, "smtp.schedule_frequency", schedule_frequency) set_config(db, "smtp.schedule_frequency", schedule_frequency)
@@ -725,5 +821,50 @@ def change_password(
) )
current_user.password_hash = hash_password(payload.new_password) current_user.password_hash = hash_password(payload.new_password)
invalidate_user_sessions(current_user, db)
db.commit() db.commit()
return RedirectResponse(url="/profil?pw=1", status_code=status.HTTP_302_FOUND) 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)

View File

@@ -13,4 +13,5 @@ class User(Base):
is_admin = Column(Boolean, default=False, nullable=False) is_admin = Column(Boolean, default=False, nullable=False)
role = Column(String(32), default="reader", nullable=False) role = Column(String(32), default="reader", nullable=False)
is_active = Column(Boolean, default=True, nullable=False) is_active = Column(Boolean, default=True, nullable=False)
session_version = Column(Integer, default=0, nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)

View File

@@ -1,4 +1,6 @@
from pydantic import BaseModel, EmailStr, Field from pydantic import BaseModel, EmailStr, Field, field_validator
from app.core.password_policy import validate_password_strength
class UserCreate(BaseModel): class UserCreate(BaseModel):
@@ -7,6 +9,12 @@ class UserCreate(BaseModel):
password: str = Field(min_length=10, max_length=255) password: str = Field(min_length=10, max_length=255)
role: str = Field(default="reader", pattern="^(admin|editor|reader)$") role: str = Field(default="reader", pattern="^(admin|editor|reader)$")
@field_validator("password")
@classmethod
def strong_password(cls, value: str) -> str:
validate_password_strength(value)
return value
class ProfileUpdate(BaseModel): class ProfileUpdate(BaseModel):
email: EmailStr email: EmailStr
@@ -16,6 +24,16 @@ class ProfileUpdate(BaseModel):
class PasswordChange(BaseModel): class PasswordChange(BaseModel):
new_password: str = Field(min_length=10, max_length=255) new_password: str = Field(min_length=10, max_length=255)
@field_validator("new_password")
@classmethod
def strong_password(cls, value: str) -> str:
validate_password_strength(value)
return value
class UserRoleUpdate(BaseModel):
role: str = Field(pattern="^(admin|editor|reader)$")
class UserOut(BaseModel): class UserOut(BaseModel):
id: int id: int

View File

@@ -1,42 +1,45 @@
from fastapi import Depends, HTTPException, Request, status from fastapi import Depends, HTTPException, Request, status
from jose import JWTError, jwt from jose import JWTError
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.config import settings from app.core.security import decode_access_token
from app.db.session import get_db from app.db.session import get_db
from app.models.user import User from app.models.user import User
def _user_from_token(token: str, db: Session) -> User | None:
try:
payload = decode_access_token(token)
user_id = int(payload.get("sub"))
token_sv = int(payload.get("sv", 0))
except (JWTError, TypeError, ValueError):
return None
user = db.query(User).filter(User.id == user_id, User.is_active.is_(True)).first()
if not user or (user.session_version or 0) != token_sv:
return None
return user
def get_optional_user(request: Request, db: Session = Depends(get_db)) -> User | None: def get_optional_user(request: Request, db: Session = Depends(get_db)) -> User | None:
token = request.cookies.get("access_token") token = request.cookies.get("access_token")
if not token: if not token:
return None return None
try: return _user_from_token(token, db)
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:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Nicht angemeldet.") raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Nicht angemeldet.")
try: user = _user_from_token(token, db)
payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm])
user_id = int(payload.get("sub"))
except (JWTError, TypeError, ValueError) as exc:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Ungültiger Token.") from exc
user = db.query(User).filter(User.id == user_id, User.is_active.is_(True)).first()
if not user: if not user:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Benutzer nicht gefunden.") raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Ungültiger oder abgelaufener Token.")
return user return user
def get_admin_user(current_user: User = Depends(get_current_user)) -> User: def get_admin_user(current_user: User = Depends(get_current_user)) -> User:
if current_user.role != "admin" and not current_user.is_admin: if current_user.role != "admin":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin-Rechte erforderlich.") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin-Rechte erforderlich.")
return current_user return current_user
@@ -47,7 +50,5 @@ def require_editor_or_admin(current_user: User = Depends(get_current_user)) -> U
return current_user return current_user
def require_reader_or_higher(current_user: User = Depends(get_current_user)) -> User: def can_view_sensitive_logs(user: User) -> bool:
if current_user.role not in {"admin", "editor", "reader"}: return user.role in {"admin", "editor"}
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin-Rechte erforderlich.")
return current_user

View File

@@ -1,17 +1,29 @@
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.secrets_crypto import decrypt_secret, encrypt_secret
from app.models.system import AppConfig from app.models.system import AppConfig
SECRET_CONFIG_KEYS = frozenset({"smtp.password"})
def get_config(db: Session, key: str, default: str = "") -> str: def get_config(db: Session, key: str, default: str = "") -> str:
row = db.query(AppConfig).filter(AppConfig.key == key).first() row = db.query(AppConfig).filter(AppConfig.key == key).first()
return row.value if row else default if not row:
return default
if key in SECRET_CONFIG_KEYS:
return decrypt_secret(row.value)
return row.value
def set_config(db: Session, key: str, value: str) -> None: def set_config(db: Session, key: str, value: str) -> None:
stored = encrypt_secret(value) if key in SECRET_CONFIG_KEYS else value
row = db.query(AppConfig).filter(AppConfig.key == key).first() row = db.query(AppConfig).filter(AppConfig.key == key).first()
if row: if row:
row.value = value row.value = stored
else: else:
db.add(AppConfig(key=key, value=value)) db.add(AppConfig(key=key, value=stored))
db.commit() db.commit()
def has_secret_config(db: Session, key: str) -> bool:
return db.query(AppConfig).filter(AppConfig.key == key).first() is not None

View File

@@ -0,0 +1,8 @@
from sqlalchemy.orm import Session
from app.models.user import User
def invalidate_user_sessions(user: User, db: Session) -> None:
user.session_version = (user.session_version or 0) + 1
db.commit()

View File

@@ -1,3 +1,4 @@
import logging
import smtplib import smtplib
from datetime import datetime from datetime import datetime
from email.mime.multipart import MIMEMultipart from email.mime.multipart import MIMEMultipart
@@ -7,9 +8,12 @@ from typing import Sequence
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.email_utils import parse_recipient_list
from app.models.system import SendLog from app.models.system import SendLog
from app.services.config_store import get_config from app.services.config_store import get_config
logger = logging.getLogger("newsletter.smtp")
def get_smtp_settings(db: Session) -> dict[str, str]: def get_smtp_settings(db: Session) -> dict[str, str]:
return { return {
@@ -25,6 +29,7 @@ def get_smtp_settings(db: Session) -> dict[str, str]:
"recipients": get_config(db, "smtp.recipients", ""), "recipients": get_config(db, "smtp.recipients", ""),
# Zeitplan # Zeitplan
"schedule_enabled": get_config(db, "smtp.schedule_enabled", "false"), "schedule_enabled": get_config(db, "smtp.schedule_enabled", "false"),
"schedule_acknowledged": get_config(db, "smtp.schedule_acknowledged", "false"),
"schedule_frequency": get_config(db, "smtp.schedule_frequency", "monthly"), "schedule_frequency": get_config(db, "smtp.schedule_frequency", "monthly"),
"schedule_time": get_config(db, "smtp.schedule_time", "08:00"), "schedule_time": get_config(db, "smtp.schedule_time", "08:00"),
"schedule_weekdays": get_config(db, "smtp.schedule_weekdays", "0"), "schedule_weekdays": get_config(db, "smtp.schedule_weekdays", "0"),
@@ -44,6 +49,8 @@ def schedule_is_due(settings: dict[str, str], now: datetime) -> bool:
return False return False
if (settings.get("schedule_enabled") or "false").lower() != "true": if (settings.get("schedule_enabled") or "false").lower() != "true":
return False return False
if (settings.get("schedule_acknowledged") or "false").lower() != "true":
return False
freq = (settings.get("schedule_frequency") or "off").lower() freq = (settings.get("schedule_frequency") or "off").lower()
if freq == "off": if freq == "off":
return False return False
@@ -88,6 +95,13 @@ def send_newsletter(
_log(db, subject, recipients, "failed", detail, scheduled) _log(db, subject, recipients, "failed", detail, scheduled)
return "failed", detail return "failed", detail
try:
validated_recipients = parse_recipient_list(",".join(recipients))
except ValueError as exc:
detail = f"Ungültige Empfängerliste: {exc}"
_log(db, subject, recipients, "failed", detail, scheduled)
return "failed", detail
msg = MIMEMultipart("alternative") msg = MIMEMultipart("alternative")
msg.attach(MIMEText(text_content, "plain", "utf-8")) msg.attach(MIMEText(text_content, "plain", "utf-8"))
msg.attach(MIMEText(html_content, "html", "utf-8")) msg.attach(MIMEText(html_content, "html", "utf-8"))
@@ -96,7 +110,8 @@ def send_newsletter(
msg["From"] = formataddr((settings["from_name"], settings["from_email"])) msg["From"] = formataddr((settings["from_name"], settings["from_email"]))
else: else:
msg["From"] = settings["from_email"] msg["From"] = settings["from_email"]
msg["To"] = ", ".join(recipients) msg["To"] = settings["from_email"]
msg["Bcc"] = ", ".join(validated_recipients)
if settings.get("reply_to"): if settings.get("reply_to"):
msg["Reply-To"] = settings["reply_to"] msg["Reply-To"] = settings["reply_to"]
@@ -107,13 +122,14 @@ def send_newsletter(
server.starttls() server.starttls()
if settings["username"]: if settings["username"]:
server.login(settings["username"], settings["password"]) server.login(settings["username"], settings["password"])
server.sendmail(settings["from_email"], list(recipients), msg.as_string()) server.sendmail(settings["from_email"], validated_recipients, msg.as_string())
detail = f"Versand erfolgreich an {len(recipients)} Empfänger." detail = f"Versand erfolgreich an {len(validated_recipients)} Empfänger."
_log(db, subject, recipients, "sent", detail, scheduled) _log(db, subject, validated_recipients, "sent", detail, scheduled)
return "sent", detail return "sent", detail
except Exception as exc: except Exception as exc:
detail = f"Versandfehler: {exc}" logger.exception("SMTP-Versand fehlgeschlagen")
_log(db, subject, recipients, "failed", detail, scheduled) detail = "Versandfehler: E-Mail konnte nicht zugestellt werden. Details stehen im Server-Log."
_log(db, subject, validated_recipients, "failed", detail, scheduled)
return "failed", detail return "failed", detail

View File

@@ -740,6 +740,21 @@ body.login-page .container {
user-select: none; user-select: none;
} }
.inline-form {
display: inline;
margin: 0;
}
.inline-form select,
.inline-form button {
font-size: 0.85rem;
}
.btn-small {
padding: 0.35rem 0.65rem;
font-size: 0.82rem;
}
/* ── Footer ── */ /* ── Footer ── */
.site-footer { .site-footer {
text-align: center; text-align: center;

View File

@@ -73,11 +73,11 @@
</label> </label>
</fieldset> </fieldset>
<label>Top-Highlights (optional) <label>Top-Highlights (optional)
<textarea name="highlights" rows="3" placeholder="Ein Artikeltitel pro Zeile muss exakt einem gefundenen Artikel entsprechen (max. 3)">{{ filters.highlights }}</textarea> <textarea name="highlights" rows="3" maxlength="{{ highlights_max_length }}" placeholder="Ein Artikeltitel pro Zeile muss exakt einem gefundenen Artikel entsprechen (max. 3)">{{ filters.highlights }}</textarea>
</label> </label>
<p class="hint">Hervorgehobene Artikel erscheinen als „Top Highlights“-Box oben im Newsletter.</p> <p class="hint">Hervorgehobene Artikel erscheinen als „Top Highlights“-Box oben im Newsletter.</p>
<label>Redaktionsnotiz (optional) <label>Redaktionsnotiz (optional)
<textarea name="editor_tip" rows="3" placeholder="Optionaler Hinweis der Redaktion für diese Ausgabe…">{{ filters.editor_tip }}</textarea> <textarea name="editor_tip" rows="3" maxlength="{{ editor_tip_max_length }}" placeholder="Optionaler Hinweis der Redaktion für diese Ausgabe…">{{ filters.editor_tip }}</textarea>
</label> </label>
<p class="hint">Wird nur angezeigt, wenn Text hinterlegt ist erscheint als eigene Box im Newsletter.</p> <p class="hint">Wird nur angezeigt, wenn Text hinterlegt ist erscheint als eigene Box im Newsletter.</p>
{% if wiki_error %} {% if wiki_error %}
@@ -122,7 +122,11 @@
<p class="hint" style="margin:0 0 0.75rem;">Bitte oben die Vorschau prüfen. Beim Versenden wird genau dieser Newsletter (aktuelle Filter) an die Verteilerliste geschickt.</p> <p class="hint" style="margin:0 0 0.75rem;">Bitte oben die Vorschau prüfen. Beim Versenden wird genau dieser Newsletter (aktuelle Filter) an die Verteilerliste geschickt.</p>
<p class="hint" style="margin:0 0 0.75rem;"> <p class="hint" style="margin:0 0 0.75rem;">
<strong>Betreff:</strong> {{ subject }}<br> <strong>Betreff:</strong> {{ subject }}<br>
{% if can_view_sensitive %}
<strong>Empfänger:</strong> {{ smtp.recipients if smtp.recipients else "— keine konfiguriert —" }} <strong>Empfänger:</strong> {{ smtp.recipients if smtp.recipients else "— keine konfiguriert —" }}
{% else %}
<strong>Empfänger:</strong> Nur für Editor/Admin sichtbar
{% endif %}
{% if smtp.enabled != "true" %}<br><strong style="color:var(--tk-orange-dark);">SMTP ist deaktiviert</strong> bitte zuerst in „SMTP-Konfiguration“ aktivieren.{% endif %} {% if smtp.enabled != "true" %}<br><strong style="color:var(--tk-orange-dark);">SMTP ist deaktiviert</strong> bitte zuerst in „SMTP-Konfiguration“ aktivieren.{% endif %}
</p> </p>
{% if user.role in ["admin", "editor"] %} {% if user.role in ["admin", "editor"] %}
@@ -250,7 +254,7 @@
<input type="text" name="username" value="{{ smtp.username }}"> <input type="text" name="username" value="{{ smtp.username }}">
</label> </label>
<label>Passwort <label>Passwort
<input type="password" name="password" value="{{ smtp.password }}"> <input type="password" name="password" autocomplete="new-password" placeholder="{% if smtp.password_configured == 'true' %}•••••••• (unverändert lassen){% else %}SMTP-Passwort{% endif %}">
</label> </label>
</div> </div>
<label>Absender-Name <label>Absender-Name
@@ -278,7 +282,11 @@
<input type="checkbox" name="schedule_enabled" value="true" {% if smtp.schedule_enabled == "true" %}checked{% endif %}> <input type="checkbox" name="schedule_enabled" value="true" {% if smtp.schedule_enabled == "true" %}checked{% endif %}>
Automatischen Versand aktivieren Automatischen Versand aktivieren
</label> </label>
<p class="hint" style="margin:0 0 0.6rem;">Ausschalten, wenn du vor dem Versenden noch Redaktionsnotiz oder Top-Highlights setzen möchtest dann manuell über „Newsletter jetzt senden“.</p> <label class="checkbox">
<input type="checkbox" name="schedule_acknowledged" value="true" {% if smtp.schedule_acknowledged == "true" %}checked{% endif %}>
Ich bestätige den automatischen Versand ohne manuelle Freigabe (ohne Redaktionsnotiz/Highlights)
</label>
<p class="hint" style="margin:0 0 0.6rem;">Beide Häkchen sind nötig, damit der Zeitplan greift. Für manuelle Kontrolle nur den ersten Schalter nutzen und „Newsletter jetzt senden“ verwenden.</p>
<div class="form-row"> <div class="form-row">
<label>Häufigkeit <label>Häufigkeit
<select name="schedule_frequency"> <select name="schedule_frequency">
@@ -339,6 +347,7 @@
</details> </details>
{% endif %} {% endif %}
{% if can_view_sensitive %}
<section class="card"> <section class="card">
<div class="card-header"> <div class="card-header">
<h3>Versandprotokoll</h3> <h3>Versandprotokoll</h3>
@@ -365,6 +374,7 @@
</table> </table>
</div> </div>
</section> </section>
{% endif %}
</div> </div>

View File

@@ -48,7 +48,7 @@
</label> </label>
<div class="form-row"> <div class="form-row">
<label>Neues Passwort <label>Neues Passwort
<input type="password" name="new_password" required minlength="10" autocomplete="new-password" placeholder="Mindestens 10 Zeichen"> <input type="password" name="new_password" required minlength="10" autocomplete="new-password" placeholder="Mind. 10 Zeichen, Groß/Klein/Ziffer">
</label> </label>
<label>Neues Passwort bestätigen <label>Neues Passwort bestätigen
<input type="password" name="confirm_password" required minlength="10" autocomplete="new-password"> <input type="password" name="confirm_password" required minlength="10" autocomplete="new-password">

View File

@@ -31,7 +31,7 @@
</div> </div>
<div class="form-row"> <div class="form-row">
<label>Passwort <label>Passwort
<input type="password" name="password" required minlength="10" placeholder="Mindestens 10 Zeichen"> <input type="password" name="password" required minlength="10" placeholder="Mind. 10 Zeichen, Groß/Klein/Ziffer">
</label> </label>
<label>Rolle <label>Rolle
<select name="role"> <select name="role">
@@ -52,15 +52,40 @@
<div class="table-wrap"> <div class="table-wrap">
<table> <table>
<thead> <thead>
<tr><th>E-Mail</th><th>Name</th><th>Rolle</th><th>Status</th></tr> <tr><th>E-Mail</th><th>Name</th><th>Rolle</th><th>Status</th><th>Aktionen</th></tr>
</thead> </thead>
<tbody> <tbody>
{% for u in users %} {% for u in users %}
<tr> <tr>
<td>{{ u.email }}</td> <td>{{ u.email }}</td>
<td>{{ u.full_name }}</td> <td>{{ u.full_name }}</td>
<td><span class="badge badge-{{ u.role }}">{{ u.role }}</span></td> <td>
{% if u.id == user.id %}
<span class="badge badge-{{ u.role }}">{{ u.role }}</span>
{% else %}
<form method="post" action="/admin/users/{{ u.id }}/role" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<select name="role" onchange="this.form.submit()">
<option value="reader" {% if u.role == 'reader' %}selected{% endif %}>reader</option>
<option value="editor" {% if u.role == 'editor' %}selected{% endif %}>editor</option>
<option value="admin" {% if u.role == 'admin' %}selected{% endif %}>admin</option>
</select>
</form>
{% endif %}
</td>
<td>{{ "Aktiv" if u.is_active else "Inaktiv" }}</td> <td>{{ "Aktiv" if u.is_active else "Inaktiv" }}</td>
<td>
{% if u.id != user.id %}
<form method="post" action="/admin/users/{{ u.id }}/toggle-active" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="btn-secondary btn-small">
{% if u.is_active %}Deaktivieren{% else %}Aktivieren{% endif %}
</button>
</form>
{% else %}
<span class="hint">Eigenes Konto</span>
{% endif %}
</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
@@ -78,6 +103,12 @@
<li><strong>Admin</strong> Benutzer &amp; SMTP</li> <li><strong>Admin</strong> Benutzer &amp; SMTP</li>
</ul> </ul>
</div> </div>
<div class="sidebar-card">
<h4>Sicherheit</h4>
<ul>
<li>Rollen- oder Statusänderung beendet alle aktiven Sitzungen des Benutzers.</li>
</ul>
</div>
</aside> </aside>
</div> </div>

View File

@@ -9,6 +9,11 @@ services:
volumes: volumes:
- newsletter_data:/app/data - newsletter_data:/app/data
restart: unless-stopped restart: unless-stopped
security_opt:
- no-new-privileges:true
read_only: true
tmpfs:
- /tmp
healthcheck: healthcheck:
test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8000/login').read()\""] test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8000/login').read()\""]
interval: 30s interval: 30s

View File

@@ -1,12 +1,13 @@
fastapi fastapi==0.115.6
uvicorn[standard] uvicorn[standard]==0.32.1
jinja2 jinja2==3.1.4
sqlalchemy sqlalchemy==2.0.36
alembic alembic==1.14.0
python-multipart python-multipart==0.0.20
httpx httpx==0.28.1
bcrypt bcrypt==4.2.1
python-jose[cryptography] python-jose[cryptography]==3.3.0
pydantic-settings pydantic-settings==2.6.1
email-validator email-validator==2.2.0
psycopg[binary] psycopg[binary]==3.2.13
cryptography==44.0.0