- Change WIKI_API_URL in .env.example and config to point to the new API endpoint. - Set COOKIE_SECURE to false for local development in .env.example and config. - Improve the newsletter generation logic to handle new and edited articles separately. - Add display options for showing date, user, and category in the newsletter output. - Enhance error handling for Wiki API requests and update templates for better user feedback. - Update CSS for new UI elements and improve overall layout in dashboard and login pages.
352 lines
13 KiB
Python
352 lines
13 KiB
Python
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, 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.security import create_access_token, hash_password, verify_password
|
|
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, 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 (
|
|
DEFAULT_DISPLAY,
|
|
article_url,
|
|
create_html,
|
|
create_plain_text,
|
|
create_subject,
|
|
filter_articles,
|
|
parse_display_options,
|
|
split_articles_by_type,
|
|
)
|
|
from app.services.smtp_sender import get_smtp_settings, run_scheduled_send_if_due, send_newsletter
|
|
from app.services.wiki import WikiFetchError, WikiService
|
|
|
|
app = FastAPI(title=settings.app_name)
|
|
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()
|
|
|
|
|
|
@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()
|
|
|
|
|
|
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"))
|
|
|
|
|
|
@app.middleware("http")
|
|
async def add_security_headers(request: Request, call_next):
|
|
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"
|
|
response.headers["Content-Security-Policy"] = "default-src 'self'; style-src 'self'; script-src 'self';"
|
|
if not request.cookies.get(CSRF_COOKIE_NAME):
|
|
response.set_cookie(CSRF_COOKIE_NAME, ensure_csrf_cookie(request), 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:
|
|
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 _base_context(request: Request, user: User, db: Session) -> dict:
|
|
return {
|
|
"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, 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": request.cookies.get(CSRF_COOKIE_NAME) or 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": request.cookies.get(CSRF_COOKIE_NAME) or ensure_csrf_cookie(request),
|
|
"error_message": "Sitzung abgelaufen. Bitte erneut anmelden.",
|
|
},
|
|
)
|
|
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):
|
|
return _render(
|
|
request,
|
|
"login.html",
|
|
{
|
|
"csrf_token": request.cookies.get(CSRF_COOKIE_NAME) or ensure_csrf_cookie(request),
|
|
"error_message": "Ungültige Zugangsdaten.",
|
|
},
|
|
)
|
|
token = create_access_token(user.id)
|
|
response = RedirectResponse(url="/dashboard", status_code=status.HTTP_302_FOUND)
|
|
response.set_cookie("access_token", token, httponly=True, secure=cookie_secure(request), samesite="strict")
|
|
return response
|
|
|
|
|
|
@app.post("/logout")
|
|
def logout(request: Request, csrf_token: str = Form(...)):
|
|
validate_csrf(request, csrf_token)
|
|
response = RedirectResponse(url="/login", status_code=status.HTTP_302_FOUND)
|
|
response.delete_cookie("access_token")
|
|
return response
|
|
|
|
|
|
@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)
|
|
context.update({
|
|
"articles": [],
|
|
"articles_new": [],
|
|
"articles_edited": [],
|
|
"raw_html": "",
|
|
"plain_text": "",
|
|
"filters": {
|
|
"days": 30,
|
|
"only_edited": False,
|
|
"category": "",
|
|
"display": DEFAULT_DISPLAY,
|
|
},
|
|
"wiki_error": None,
|
|
})
|
|
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(...),
|
|
days: int = Form(30),
|
|
only_edited: str | None = Form(None),
|
|
category: str = Form(""),
|
|
show_date: str | None = Form(None),
|
|
show_user: str | None = Form(None),
|
|
show_category: str | None = Form(None),
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(require_editor_or_admin),
|
|
):
|
|
validate_csrf(request, csrf_token)
|
|
days = max(1, min(days, 90))
|
|
only_edited_flag = only_edited == "true"
|
|
display = parse_display_options(show_date, show_user, show_category)
|
|
wiki_error = None
|
|
filtered: list = []
|
|
articles_new: list = []
|
|
articles_edited: list = []
|
|
plain_text = ""
|
|
raw_html = ""
|
|
try:
|
|
articles = await wiki_service.get_recent_changes(days=days, only_edited=only_edited_flag)
|
|
filtered = filter_articles(articles, category_filter=category or None)
|
|
articles_new, articles_edited = split_articles_by_type(filtered)
|
|
title = create_subject(days, category)
|
|
plain_text = create_plain_text(filtered, days, display, category)
|
|
raw_html = create_html(filtered, days, display, category)
|
|
run_scheduled_send_if_due(db, title, raw_html, plain_text)
|
|
except WikiFetchError as exc:
|
|
wiki_error = exc.message
|
|
context = _base_context(request, current_user, db)
|
|
context.update(
|
|
{
|
|
"articles": filtered,
|
|
"articles_new": articles_new,
|
|
"articles_edited": articles_edited,
|
|
"raw_html": raw_html,
|
|
"plain_text": plain_text,
|
|
"filters": {
|
|
"days": days,
|
|
"only_edited": only_edited_flag,
|
|
"category": category,
|
|
"display": display,
|
|
},
|
|
"send_logs": db.query(SendLog).order_by(SendLog.created_at.desc()).limit(20).all(),
|
|
"wiki_error": wiki_error,
|
|
}
|
|
)
|
|
return _render(request, "dashboard.html", context)
|
|
|
|
|
|
@app.post("/newsletter/send-now")
|
|
async def send_now(
|
|
request: Request,
|
|
csrf_token: str = Form(...),
|
|
days: int = Form(30),
|
|
only_edited: str | None = Form(None),
|
|
category: str = Form(""),
|
|
show_date: str | None = Form(None),
|
|
show_user: str | None = Form(None),
|
|
show_category: str | None = Form(None),
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(require_editor_or_admin),
|
|
):
|
|
validate_csrf(request, csrf_token)
|
|
display = parse_display_options(show_date, show_user, show_category)
|
|
only_edited_flag = only_edited == "true"
|
|
try:
|
|
articles = await wiki_service.get_recent_changes(days=max(1, min(days, 90)), only_edited=only_edited_flag)
|
|
except WikiFetchError as exc:
|
|
raise HTTPException(status_code=502, detail=exc.message) from exc
|
|
filtered = filter_articles(articles, category_filter=category or None)
|
|
title = create_subject(max(1, min(days, 90)), category)
|
|
plain_text = create_plain_text(filtered, max(1, min(days, 90)), display, category)
|
|
raw_html = create_html(filtered, max(1, min(days, 90)), display, category)
|
|
recipients = [r.strip() for r in get_config(db, "smtp.recipients", "").split(",") if r.strip()]
|
|
send_newsletter(db, title, raw_html, plain_text, recipients, scheduled=False)
|
|
return RedirectResponse(url="/dashboard", status_code=status.HTTP_302_FOUND)
|
|
|
|
|
|
@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(""),
|
|
use_tls: str | None = Form(None),
|
|
recipients: str = Form(""),
|
|
schedule_time: str = Form("08:00"),
|
|
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())
|
|
set_config(db, "smtp.password", password)
|
|
set_config(db, "smtp.from_email", from_email.strip())
|
|
set_config(db, "smtp.use_tls", "true" if use_tls == "true" else "false")
|
|
set_config(db, "smtp.recipients", recipients.strip())
|
|
set_config(db, "smtp.schedule_time", schedule_time.strip() or "08:00")
|
|
return RedirectResponse(url="/dashboard", status_code=status.HTTP_302_FOUND)
|
|
|
|
|
|
@app.get("/admin/users", response_class=HTMLResponse)
|
|
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 _render(request, "users.html", context)
|
|
|
|
|
|
@app.post("/admin/users")
|
|
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),
|
|
):
|
|
validate_csrf(request, csrf_token)
|
|
payload = UserCreate(email=email, full_name=full_name, password=password, role=role)
|
|
existing = db.query(User).filter(User.email == payload.email).first()
|
|
if existing:
|
|
raise HTTPException(status_code=400, detail="E-Mail existiert bereits.")
|
|
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", status_code=status.HTTP_302_FOUND)
|