From 146d398ecbbdd6cd1f31cd859f3959eafa650500 Mon Sep 17 00:00:00 2001 From: smueller Date: Fri, 3 Jul 2026 11:35:25 +0200 Subject: [PATCH] initial commit --- .env.example | 13 ++ .gitignore | 6 + Dockerfile | 19 +++ README.md | 58 ++++++++ app/core/config.py | 20 +++ app/core/security.py | 24 ++++ app/db/session.py | 18 +++ app/main.py | 251 +++++++++++++++++++++++++++++++++++ app/models/system.py | 23 ++++ app/models/user.py | 16 +++ app/schemas/user.py | 19 +++ app/services/auth.py | 41 ++++++ app/services/config_store.py | 17 +++ app/services/csrf.py | 20 +++ app/services/newsletter.py | 76 +++++++++++ app/services/smtp_sender.py | 89 +++++++++++++ app/services/wiki.py | 78 +++++++++++ app/static/css/main.css | 14 ++ app/templates/base.html | 23 ++++ app/templates/dashboard.html | 124 +++++++++++++++++ app/templates/login.html | 16 +++ app/templates/users.html | 43 ++++++ docker-compose.yml | 20 +++ requirements.txt | 12 ++ 24 files changed, 1040 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 app/core/config.py create mode 100644 app/core/security.py create mode 100644 app/db/session.py create mode 100644 app/main.py create mode 100644 app/models/system.py create mode 100644 app/models/user.py create mode 100644 app/schemas/user.py create mode 100644 app/services/auth.py create mode 100644 app/services/config_store.py create mode 100644 app/services/csrf.py create mode 100644 app/services/newsletter.py create mode 100644 app/services/smtp_sender.py create mode 100644 app/services/wiki.py create mode 100644 app/static/css/main.css create mode 100644 app/templates/base.html create mode 100644 app/templates/dashboard.html create mode 100644 app/templates/login.html create mode 100644 app/templates/users.html create mode 100644 docker-compose.yml create mode 100644 requirements.txt diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..1a56370 --- /dev/null +++ b/.env.example @@ -0,0 +1,13 @@ +APP_NAME=TK Wiki Newsletter Admin +ENVIRONMENT=production +SECRET_KEY=PLEASE_CHANGE_TO_A_LONG_RANDOM_SECRET +ALGORITHM=HS256 +ACCESS_TOKEN_EXPIRE_MINUTES=120 +DATABASE_URL=sqlite:///./data/newsletter.db +# Beispiel PostgreSQL: +# DATABASE_URL=postgresql+psycopg://newsletter:newsletter@postgres:5432/newsletter +WIKI_API_URL=https://www.thomas-krenn.com/de/wiki/api.php +ALLOWED_HOSTS=* +COOKIE_SECURE=true +ADMIN_BOOTSTRAP_EMAIL=admin@internal.local +ADMIN_BOOTSTRAP_PASSWORD=ChangeMe123! diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5883b7d --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +__pycache__/ +.pytest_cache/ +.venv/ +.env +data/ +*.pyc diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..24f287f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,19 @@ +FROM python:3.12-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +WORKDIR /app + +RUN addgroup --system appgroup && adduser --system --ingroup appgroup appuser + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY app ./app + +EXPOSE 8000 + +USER appuser + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..4f832ef --- /dev/null +++ b/README.md @@ -0,0 +1,58 @@ +# TK Wiki Newsletter Admin + +Produktionsnahes internes Tool zur Erstellung von Thomas-Krenn.AG Newslettern auf Basis der letzten Wiki-Änderungen. + +## Funktionen + +- Login und Benutzerverwaltung mit RBAC (`admin`, `editor`, `reader`) +- Verpflichtender CSRF-Schutz auf allen POST-Formularen +- Filter in der Web-UI: + - Zeitraum in Tagen + - Nur bearbeitete Artikel + - Kategorie +- Datenquelle: MediaWiki API (`recentchanges`) +- Export: + - Plain Text (für Outlook) + - Raw HTML +- Optionaler SMTP-Versand: + - UI-konfigurierbar + - Verteilerlisten + - tägliche Zeitplanung + - Versandprotokoll +- Docker/Compose Betrieb + +## Start mit Docker + +1. Konfiguration anlegen: + + ```bash + cp .env.example .env + ``` + +2. Wichtige Werte in `.env` setzen: + - `SECRET_KEY` + - `ADMIN_BOOTSTRAP_EMAIL` + - `ADMIN_BOOTSTRAP_PASSWORD` + +3. Start: + + ```bash + docker compose up --build -d + ``` + +4. Zugriff: + - [http://localhost:8000/login](http://localhost:8000/login) + +## Sicherheits-Hinweise für Produktion + +- Reverse Proxy (z. B. Nginx/Traefik) mit TLS vor den Container setzen. +- `SECRET_KEY` lang und zufällig setzen. +- Bootstrap-Admin-Passwort nach erstem Login ändern. +- Netzwerkzugriff auf interne IPs/Netze beschränken. +- Regelmäßige Backups des `data` Volumes. + +## Rollenmodell + +- `reader`: darf Ergebnisse sehen +- `editor`: darf Newsletter generieren und sofort versenden +- `admin`: zusätzlich Benutzerverwaltung und SMTP-Konfiguration diff --git a/app/core/config.py b/app/core/config.py new file mode 100644 index 0000000..d4e4b55 --- /dev/null +++ b/app/core/config.py @@ -0,0 +1,20 @@ +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + app_name: str = "TK Wiki Newsletter Admin" + environment: str = "production" + secret_key: str = "change-me-in-production" + algorithm: str = "HS256" + access_token_expire_minutes: int = 120 + database_url: str = "sqlite:///./data/newsletter.db" + wiki_api_url: str = "https://www.thomas-krenn.com/de/wiki/api.php" + allowed_hosts: str = "*" + cookie_secure: bool = True + admin_bootstrap_email: str = "admin@internal.local" + admin_bootstrap_password: str = "ChangeMe123!" + + model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8") + + +settings = Settings() diff --git a/app/core/security.py b/app/core/security.py new file mode 100644 index 0000000..0b5f20a --- /dev/null +++ b/app/core/security.py @@ -0,0 +1,24 @@ +from datetime import datetime, timedelta, timezone +from typing import Any + +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) + + +def verify_password(plain_password: str, hashed_password: str) -> bool: + return pwd_context.verify(plain_password, hashed_password) + + +def create_access_token(subject: Any) -> str: + expires_delta = timedelta(minutes=settings.access_token_expire_minutes) + expire = datetime.now(timezone.utc) + expires_delta + to_encode = {"exp": expire, "sub": str(subject)} + return jwt.encode(to_encode, settings.secret_key, algorithm=settings.algorithm) diff --git a/app/db/session.py b/app/db/session.py new file mode 100644 index 0000000..6ec68a1 --- /dev/null +++ b/app/db/session.py @@ -0,0 +1,18 @@ +from sqlalchemy import create_engine +from sqlalchemy.orm import declarative_base, sessionmaker + +from app.core.config import settings + + +connect_args = {"check_same_thread": False} if settings.database_url.startswith("sqlite") else {} +engine = create_engine(settings.database_url, connect_args=connect_args, pool_pre_ping=True) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) +Base = declarative_base() + + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..19938da --- /dev/null +++ b/app/main.py @@ -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) diff --git a/app/models/system.py b/app/models/system.py new file mode 100644 index 0000000..8fe7364 --- /dev/null +++ b/app/models/system.py @@ -0,0 +1,23 @@ +from sqlalchemy import Boolean, Column, DateTime, Integer, String, Text, func + +from app.db.session import Base + + +class AppConfig(Base): + __tablename__ = "app_config" + + key = Column(String(100), primary_key=True) + value = Column(Text, nullable=False) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False) + + +class SendLog(Base): + __tablename__ = "send_logs" + + id = Column(Integer, primary_key=True, index=True) + subject = Column(String(255), nullable=False) + recipients = Column(Text, nullable=False) + status = Column(String(32), nullable=False) + detail = Column(Text, nullable=False) + created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) + scheduled = Column(Boolean, default=False, nullable=False) diff --git a/app/models/user.py b/app/models/user.py new file mode 100644 index 0000000..06a0d9c --- /dev/null +++ b/app/models/user.py @@ -0,0 +1,16 @@ +from sqlalchemy import Boolean, Column, DateTime, Integer, String, func + +from app.db.session import Base + + +class User(Base): + __tablename__ = "users" + + id = Column(Integer, primary_key=True, index=True) + email = Column(String(255), unique=True, index=True, nullable=False) + full_name = Column(String(255), nullable=False) + password_hash = Column(String(255), nullable=False) + is_admin = Column(Boolean, default=False, nullable=False) + role = Column(String(32), default="reader", nullable=False) + is_active = Column(Boolean, default=True, nullable=False) + created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) diff --git a/app/schemas/user.py b/app/schemas/user.py new file mode 100644 index 0000000..e98152b --- /dev/null +++ b/app/schemas/user.py @@ -0,0 +1,19 @@ +from pydantic import BaseModel, EmailStr, Field + + +class UserCreate(BaseModel): + email: EmailStr + full_name: str = Field(min_length=2, max_length=255) + password: str = Field(min_length=10, max_length=255) + role: str = Field(default="reader", pattern="^(admin|editor|reader)$") + + +class UserOut(BaseModel): + id: int + email: EmailStr + full_name: str + role: str + is_active: bool + + class Config: + from_attributes = True diff --git a/app/services/auth.py b/app/services/auth.py new file mode 100644 index 0000000..b92261c --- /dev/null +++ b/app/services/auth.py @@ -0,0 +1,41 @@ +from fastapi import Depends, HTTPException, Request, status +from jose import JWTError, jwt +from sqlalchemy.orm import Session + +from app.core.config import settings +from app.db.session import get_db +from app.models.user import User + + +def get_current_user(request: Request, db: Session = Depends(get_db)) -> User: + token = request.cookies.get("access_token") + if not token: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Nicht angemeldet.") + try: + 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: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Benutzer nicht gefunden.") + return user + + +def get_admin_user(current_user: User = Depends(get_current_user)) -> User: + if current_user.role != "admin": + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin-Rechte erforderlich.") + return current_user + + +def require_editor_or_admin(current_user: User = Depends(get_current_user)) -> User: + if current_user.role not in {"admin", "editor"}: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Editor- oder Admin-Rechte erforderlich.") + return current_user + + +def require_reader_or_higher(current_user: User = Depends(get_current_user)) -> User: + if current_user.role not in {"admin", "editor", "reader"}: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin-Rechte erforderlich.") + return current_user diff --git a/app/services/config_store.py b/app/services/config_store.py new file mode 100644 index 0000000..60e4fc2 --- /dev/null +++ b/app/services/config_store.py @@ -0,0 +1,17 @@ +from sqlalchemy.orm import Session + +from app.models.system import AppConfig + + +def get_config(db: Session, key: str, default: str = "") -> str: + row = db.query(AppConfig).filter(AppConfig.key == key).first() + return row.value if row else default + + +def set_config(db: Session, key: str, value: str) -> None: + row = db.query(AppConfig).filter(AppConfig.key == key).first() + if row: + row.value = value + else: + db.add(AppConfig(key=key, value=value)) + db.commit() diff --git a/app/services/csrf.py b/app/services/csrf.py new file mode 100644 index 0000000..907829a --- /dev/null +++ b/app/services/csrf.py @@ -0,0 +1,20 @@ +import secrets + +from fastapi import HTTPException, Request, status + + +CSRF_COOKIE_NAME = "csrf_token" +CSRF_FORM_FIELD = "csrf_token" + + +def ensure_csrf_cookie(request: Request) -> str: + token = request.cookies.get(CSRF_COOKIE_NAME) + if token: + return token + return secrets.token_urlsafe(32) + + +def validate_csrf(request: Request, form_token: str) -> None: + cookie_token = request.cookies.get(CSRF_COOKIE_NAME) + if not cookie_token or not form_token or not secrets.compare_digest(cookie_token, form_token): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="CSRF-Validierung fehlgeschlagen.") diff --git a/app/services/newsletter.py b/app/services/newsletter.py new file mode 100644 index 0000000..533b4c5 --- /dev/null +++ b/app/services/newsletter.py @@ -0,0 +1,76 @@ +from datetime import datetime +from html import escape +from typing import Any + + +def filter_articles( + articles: list[dict[str, Any]], + category_filter: str | None = None, +) -> list[dict[str, Any]]: + if not category_filter: + return articles + wanted = category_filter.strip().lower() + return [ + a + for a in articles + if any(c.lower() == wanted for c in a.get("categories", [])) + ] + + +def create_plain_text(articles: list[dict[str, Any]], title: str) -> str: + lines = [title, "=" * len(title), ""] + if not articles: + lines.append("Keine Artikel im gewählten Zeitraum gefunden.") + return "\n".join(lines) + + for idx, item in enumerate(articles, start=1): + stamp = _format_ts(item.get("timestamp")) + cat = ", ".join(item.get("categories", [])) or "Keine Kategorie" + comment = item.get("comment") or "-" + lines.extend( + [ + f"{idx}. {item.get('title', 'Ohne Titel')}", + f" Zeit: {stamp}", + f" Benutzer: {item.get('user', '-')}", + f" Kategorien: {cat}", + f" Kommentar: {comment}", + "", + ] + ) + return "\n".join(lines).strip() + + +def create_html(articles: list[dict[str, Any]], title: str) -> str: + if not articles: + return f"

{escape(title)}

Keine Artikel im gewählten Zeitraum gefunden.

" + + blocks = [f"

{escape(title)}

", "") + return "\n".join(blocks) + + +def _format_ts(value: str | None) -> str: + if not value: + return "-" + try: + dt = datetime.strptime(value, "%Y-%m-%dT%H:%M:%SZ") + return dt.strftime("%d.%m.%Y %H:%M UTC") + except ValueError: + return value diff --git a/app/services/smtp_sender.py b/app/services/smtp_sender.py new file mode 100644 index 0000000..7642c32 --- /dev/null +++ b/app/services/smtp_sender.py @@ -0,0 +1,89 @@ +import smtplib +from datetime import datetime +from email.mime.text import MIMEText +from typing import Sequence + +from sqlalchemy.orm import Session + +from app.models.system import SendLog +from app.services.config_store import get_config + + +def get_smtp_settings(db: Session) -> dict[str, str]: + return { + "enabled": get_config(db, "smtp.enabled", "false"), + "host": get_config(db, "smtp.host", ""), + "port": get_config(db, "smtp.port", "587"), + "username": get_config(db, "smtp.username", ""), + "password": get_config(db, "smtp.password", ""), + "from_email": get_config(db, "smtp.from_email", ""), + "use_tls": get_config(db, "smtp.use_tls", "true"), + "recipients": get_config(db, "smtp.recipients", ""), + "schedule_time": get_config(db, "smtp.schedule_time", "08:00"), + "last_scheduled_date": get_config(db, "smtp.last_scheduled_date", ""), + } + + +def send_newsletter( + db: Session, + subject: str, + html_content: str, + text_content: str, + recipients: Sequence[str], + scheduled: bool = False, +) -> None: + settings = get_smtp_settings(db) + if settings["enabled"].lower() != "true": + _log(db, subject, recipients, "skipped", "SMTP ist deaktiviert.", scheduled) + return + if not settings["host"] or not settings["from_email"] or not recipients: + _log(db, subject, recipients, "failed", "SMTP unvollständig konfiguriert.", scheduled) + return + + msg = MIMEText(html_content, "html", "utf-8") + msg["Subject"] = subject + msg["From"] = settings["from_email"] + msg["To"] = ", ".join(recipients) + + try: + port = int(settings["port"]) + with smtplib.SMTP(settings["host"], port, timeout=20) as server: + if settings["use_tls"].lower() == "true": + server.starttls() + if settings["username"]: + server.login(settings["username"], settings["password"]) + server.sendmail(settings["from_email"], list(recipients), msg.as_string()) + _log(db, subject, recipients, "sent", "Versand erfolgreich.", scheduled) + except Exception as exc: + _log(db, subject, recipients, "failed", f"Versandfehler: {exc}", scheduled) + + +def run_scheduled_send_if_due(db: Session, subject: str, html_content: str, text_content: str) -> None: + settings = get_smtp_settings(db) + if settings["enabled"].lower() != "true": + return + now = datetime.now() + schedule_time = settings["schedule_time"] or "08:00" + if now.strftime("%H:%M") < schedule_time: + return + today = now.strftime("%Y-%m-%d") + if settings["last_scheduled_date"] == today: + return + recipients = [r.strip() for r in settings["recipients"].split(",") if r.strip()] + send_newsletter(db, subject, html_content, text_content, recipients, scheduled=True) + from app.services.config_store import set_config + + set_config(db, "smtp.last_scheduled_date", today) + + +def _log(db: Session, subject: str, recipients: Sequence[str], status: str, detail: str, scheduled: bool) -> None: + db.add( + SendLog( + subject=subject, + recipients=", ".join(recipients) if recipients else "-", + status=status, + detail=detail, + scheduled=scheduled, + ) + ) + db.commit() diff --git a/app/services/wiki.py b/app/services/wiki.py new file mode 100644 index 0000000..28bd866 --- /dev/null +++ b/app/services/wiki.py @@ -0,0 +1,78 @@ +from collections import defaultdict +from datetime import datetime, timedelta, timezone +from typing import Any + +import httpx + +from app.core.config import settings + + +class WikiService: + def __init__(self) -> None: + self.api_url = settings.wiki_api_url + + async def get_recent_changes(self, days: int = 30, only_edited: bool = False) -> list[dict[str, Any]]: + now = datetime.now(timezone.utc) + start = now - timedelta(days=days) + rcstart = now.strftime("%Y-%m-%dT%H:%M:%SZ") + rcend = start.strftime("%Y-%m-%dT%H:%M:%SZ") + + params = { + "action": "query", + "format": "json", + "list": "recentchanges", + "rcprop": "title|timestamp|user|comment|flags|sizes|loginfo", + "rclimit": "200", + "rcstart": rcstart, + "rcend": rcend, + "rcnamespace": "0", + } + if only_edited: + params["rctype"] = "edit" + else: + params["rctype"] = "new|edit" + + rows: list[dict[str, Any]] = [] + cont: dict[str, Any] = {} + async with httpx.AsyncClient(timeout=20.0) as client: + while True: + response = await client.get(self.api_url, params={**params, **cont}) + response.raise_for_status() + payload = response.json() + rows.extend(payload.get("query", {}).get("recentchanges", [])) + if "continue" not in payload: + break + cont = payload["continue"] + + titles = sorted({item["title"] for item in rows if item.get("title")}) + categories = await self._get_categories_for_titles(client, titles) + + for row in rows: + row["categories"] = categories.get(row.get("title", ""), []) + return rows + + async def _get_categories_for_titles(self, client: httpx.AsyncClient, titles: list[str]) -> dict[str, list[str]]: + result: dict[str, list[str]] = defaultdict(list) + if not titles: + return {} + + chunk_size = 25 + for i in range(0, len(titles), chunk_size): + chunk = titles[i : i + chunk_size] + params = { + "action": "query", + "format": "json", + "prop": "categories", + "cllimit": "max", + "titles": "|".join(chunk), + } + response = await client.get(self.api_url, params=params) + response.raise_for_status() + pages = response.json().get("query", {}).get("pages", {}) + for page in pages.values(): + title = page.get("title") + if not title: + continue + raw_categories = page.get("categories", []) + result[title] = [c.get("title", "").replace("Kategorie:", "") for c in raw_categories if c.get("title")] + return result diff --git a/app/static/css/main.css b/app/static/css/main.css new file mode 100644 index 0000000..cef2a18 --- /dev/null +++ b/app/static/css/main.css @@ -0,0 +1,14 @@ +* { box-sizing: border-box; } +body { margin: 0; font-family: Arial, sans-serif; background: #f5f7fb; color: #1f2430; } +.topbar { display: flex; justify-content: space-between; align-items: center; padding: 1rem 1.5rem; background: #0c2c5a; color: #fff; } +.container { max-width: 1100px; margin: 1rem auto; padding: 0 1rem; } +.card { background: #fff; border: 1px solid #dde3ef; border-radius: 8px; padding: 1rem; margin-bottom: 1rem; } +.narrow { max-width: 420px; margin: 2rem auto; } +.form-grid { display: grid; gap: 0.75rem; } +label { display: grid; gap: 0.3rem; font-weight: 600; } +input, button, textarea { font: inherit; padding: 0.55rem; } +textarea { width: 100%; resize: vertical; } +button { width: fit-content; background: #1b5dbf; color: #fff; border: 0; border-radius: 4px; cursor: pointer; } +table { width: 100%; border-collapse: collapse; } +th, td { border-bottom: 1px solid #e6ebf4; text-align: left; padding: 0.55rem; vertical-align: top; } +.checkbox { display: flex; align-items: center; gap: 0.45rem; font-weight: 500; } diff --git a/app/templates/base.html b/app/templates/base.html new file mode 100644 index 0000000..dc69878 --- /dev/null +++ b/app/templates/base.html @@ -0,0 +1,23 @@ + + + + + + {{ title if title else "TK Wiki Newsletter Admin" }} + + + +
+

TK Wiki Newsletter Admin

+ {% if user %} +
+ + +
+ {% endif %} +
+
+ {% block content %}{% endblock %} +
+ + diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html new file mode 100644 index 0000000..28b37fa --- /dev/null +++ b/app/templates/dashboard.html @@ -0,0 +1,124 @@ +{% extends "base.html" %} +{% block content %} +
+

Newsletter erstellen

+
+ + + + + {% if user.role in ["admin", "editor"] %} + + {% endif %} +
+ {% if user.role == "admin" %} +

Zur Benutzerverwaltung

+ {% endif %} +
+ +{% if user.role in ["admin", "editor"] %} +
+

Optionaler SMTP-Versand

+
+ + + + + +
+
+{% endif %} + +{% if user.role == "admin" %} +
+

SMTP-Konfiguration (optional)

+
+ + + + + + + + + + + +
+
+{% endif %} + +
+

Output: Outlook Text

+ +
+ +
+

Output: Raw HTML

+ +
+ +
+

Gefundene Artikel ({{ articles|length }})

+ + + + + + {% for item in articles %} + + + + + + + {% endfor %} + +
TitelZeitBenutzerKategorien
{{ item.title }}{{ item.timestamp }}{{ item.user }}{{ item.categories|join(", ") }}
+
+ +
+

Versandprotokoll (letzte 20)

+ + + + {% for log in send_logs %} + + + + + + + + {% endfor %} + +
ZeitStatusBetreffEmpfängerDetails
{{ log.created_at }}{{ log.status }}{{ log.subject }}{{ log.recipients }}{{ log.detail }}
+
+{% endblock %} diff --git a/app/templates/login.html b/app/templates/login.html new file mode 100644 index 0000000..0f441cd --- /dev/null +++ b/app/templates/login.html @@ -0,0 +1,16 @@ +{% extends "base.html" %} +{% block content %} +
+

Login

+
+ + + + +
+
+{% endblock %} diff --git a/app/templates/users.html b/app/templates/users.html new file mode 100644 index 0000000..be87f3d --- /dev/null +++ b/app/templates/users.html @@ -0,0 +1,43 @@ +{% extends "base.html" %} +{% block content %} +
+

Benutzerverwaltung

+
+ + + + + + +
+
+ +
+

Bestehende Benutzer

+ + + + {% for u in users %} + + + + + + + {% endfor %} + +
E-MailNameRolleAktiv
{{ u.email }}{{ u.full_name }}{{ u.role }}{{ "Ja" if u.is_active else "Nein" }}
+
+{% endblock %} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..1aae7fa --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,20 @@ +services: + newsletter-admin: + build: . + container_name: tk-newsletter-admin + ports: + - "8000:8000" + env_file: + - .env + volumes: + - newsletter_data:/app/data + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8000/login').read()\""] + interval: 30s + timeout: 5s + retries: 3 + start_period: 20s + +volumes: + newsletter_data: diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..a7e9c8d --- /dev/null +++ b/requirements.txt @@ -0,0 +1,12 @@ +fastapi +uvicorn[standard] +jinja2 +sqlalchemy +alembic +python-multipart +httpx +passlib[bcrypt] +python-jose[cryptography] +pydantic-settings +email-validator +psycopg[binary]