initial commit
This commit is contained in:
251
app/main.py
Normal file
251
app/main.py
Normal file
@@ -0,0 +1,251 @@
|
||||
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.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.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, 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
|
||||
from app.services.smtp_sender import get_smtp_settings, run_scheduled_send_if_due, send_newsletter
|
||||
from app.services.wiki import 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")
|
||||
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';"
|
||||
if not request.cookies.get(CSRF_COOKIE_NAME):
|
||||
response.set_cookie(CSRF_COOKIE_NAME, ensure_csrf_cookie(request), httponly=True, secure=settings.cookie_secure, 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 {
|
||||
"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):
|
||||
return RedirectResponse(url="/dashboard", 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)})
|
||||
|
||||
|
||||
@app.post("/login")
|
||||
def login(
|
||||
request: Request,
|
||||
csrf_token: str = Form(...),
|
||||
email: str = Form(...),
|
||||
password: str = Form(...),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
validate_csrf(request, csrf_token)
|
||||
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):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="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=settings.cookie_secure, 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": [], "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)
|
||||
|
||||
|
||||
@app.post("/dashboard", response_class=HTMLResponse)
|
||||
async def dashboard_generate(
|
||||
request: Request,
|
||||
csrf_token: str = Form(...),
|
||||
days: int = Form(30),
|
||||
only_edited: bool = Form(False),
|
||||
category: str = Form(""),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_editor_or_admin),
|
||||
):
|
||||
validate_csrf(request, csrf_token)
|
||||
days = max(1, min(days, 90))
|
||||
articles = await wiki_service.get_recent_changes(days=days, only_edited=only_edited)
|
||||
filtered = filter_articles(articles, category_filter=category or None)
|
||||
title = f"Thomas-Krenn Wiki Newsletter ({days} Tage)"
|
||||
plain_text = create_plain_text(filtered, title)
|
||||
raw_html = create_html(filtered, title)
|
||||
run_scheduled_send_if_due(db, title, raw_html, plain_text)
|
||||
context = _base_context(request, current_user, db)
|
||||
context.update(
|
||||
{
|
||||
"articles": filtered,
|
||||
"raw_html": raw_html,
|
||||
"plain_text": plain_text,
|
||||
"filters": {"days": days, "only_edited": only_edited, "category": category},
|
||||
"send_logs": db.query(SendLog).order_by(SendLog.created_at.desc()).limit(20).all(),
|
||||
}
|
||||
)
|
||||
return templates.TemplateResponse("dashboard.html", context)
|
||||
|
||||
|
||||
@app.post("/newsletter/send-now")
|
||||
async def send_now(
|
||||
request: Request,
|
||||
csrf_token: str = Form(...),
|
||||
days: int = Form(30),
|
||||
only_edited: bool = Form(False),
|
||||
category: str = Form(""),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_editor_or_admin),
|
||||
):
|
||||
validate_csrf(request, csrf_token)
|
||||
articles = await wiki_service.get_recent_changes(days=max(1, min(days, 90)), only_edited=only_edited)
|
||||
filtered = filter_articles(articles, category_filter=category or None)
|
||||
title = f"Thomas-Krenn Wiki Newsletter ({days} Tage)"
|
||||
plain_text = create_plain_text(filtered, title)
|
||||
raw_html = create_html(filtered, title)
|
||||
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 templates.TemplateResponse("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)
|
||||
Reference in New Issue
Block a user