diff --git a/.env.example b/.env.example index a9da6c9..ca55781 100644 --- a/.env.example +++ b/.env.example @@ -7,8 +7,9 @@ HOST_PORT=8080 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 +WIKI_API_URL=https://www.thomas-krenn.com/de/wikiDE/api.php ALLOWED_HOSTS=* -COOKIE_SECURE=true +COOKIE_SECURE=false +# Auf true setzen, wenn hinter HTTPS/TLS-Terminierung (Reverse Proxy) ADMIN_BOOTSTRAP_EMAIL=admin@internal.local ADMIN_BOOTSTRAP_PASSWORD=ChangeMe123! diff --git a/README.md b/README.md index 0c7f458..0382256 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Produktionsnahes internes Tool zur Erstellung von Thomas-Krenn.AG Newslettern au - Zeitraum in Tagen - Nur bearbeitete Artikel - Kategorie -- Datenquelle: MediaWiki API (`recentchanges`) +- Datenquelle: MediaWiki API (`recentchanges` über `wikiDE/api.php`) - Export: - Plain Text (für Outlook) - Raw HTML diff --git a/app/core/config.py b/app/core/config.py index d4e4b55..815bb5c 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -8,9 +8,9 @@ class Settings(BaseSettings): 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" + wiki_api_url: str = "https://www.thomas-krenn.com/de/wikiDE/api.php" allowed_hosts: str = "*" - cookie_secure: bool = True + cookie_secure: bool = False admin_bootstrap_email: str = "admin@internal.local" admin_bootstrap_password: str = "ChangeMe123!" diff --git a/app/core/cookies.py b/app/core/cookies.py new file mode 100644 index 0000000..604ecc6 --- /dev/null +++ b/app/core/cookies.py @@ -0,0 +1,13 @@ +from fastapi import Request + +from app.core.config import settings + + +def cookie_secure(request: Request) -> bool: + """Secure-Flag nur setzen, wenn aktiviert und die Anfrage wirklich per HTTPS läuft.""" + if not settings.cookie_secure: + return False + forwarded = request.headers.get("x-forwarded-proto", "") + if forwarded: + return forwarded.split(",")[0].strip().lower() == "https" + return request.url.scheme == "https" diff --git a/app/main.py b/app/main.py index b263746..47bc233 100644 --- a/app/main.py +++ b/app/main.py @@ -9,6 +9,7 @@ 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 @@ -17,9 +18,18 @@ 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 create_html, create_plain_text, filter_articles +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 WikiService +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()] @@ -27,6 +37,7 @@ 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() @@ -54,9 +65,9 @@ async def add_security_headers(request: Request, call_next): 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';" + 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=settings.cookie_secure, samesite="strict") + response.set_cookie(CSRF_COOKIE_NAME, ensure_csrf_cookie(request), httponly=True, secure=cookie_secure(request), samesite="strict") return response @@ -120,7 +131,10 @@ def login_page(request: Request, db: Session = Depends(get_db)): return _render( request, "login.html", - {"csrf_token": request.cookies.get(CSRF_COOKIE_NAME) or ensure_csrf_cookie(request)}, + { + "csrf_token": request.cookies.get(CSRF_COOKIE_NAME) or ensure_csrf_cookie(request), + "error_message": None, + }, ) @@ -132,13 +146,30 @@ def login( password: str = Form(...), db: Session = Depends(get_db), ): - validate_csrf(request, csrf_token) + 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): - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Ungültige Zugangsdaten.") + 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=settings.cookie_secure, samesite="strict") + response.set_cookie("access_token", token, httponly=True, secure=cookie_secure(request), samesite="strict") return response @@ -153,7 +184,20 @@ def logout(request: Request, csrf_token: str = Form(...)): @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": ""}}) + 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) @@ -164,27 +208,50 @@ async def dashboard_generate( request: Request, csrf_token: str = Form(...), days: int = Form(30), - only_edited: bool = Form(False), + 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)) - 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) + 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, "category": category}, + "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) @@ -195,17 +262,25 @@ async def send_now( request: Request, csrf_token: str = Form(...), days: int = Form(30), - only_edited: bool = Form(False), + 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) - articles = await wiki_service.get_recent_changes(days=max(1, min(days, 90)), only_edited=only_edited) + 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 = f"Thomas-Krenn Wiki Newsletter ({days} Tage)" - plain_text = create_plain_text(filtered, title) - raw_html = create_html(filtered, title) + 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) diff --git a/app/services/newsletter.py b/app/services/newsletter.py index 533b4c5..464e0b4 100644 --- a/app/services/newsletter.py +++ b/app/services/newsletter.py @@ -1,6 +1,38 @@ -from datetime import datetime +from datetime import datetime, timedelta from html import escape -from typing import Any +from typing import Any, TypedDict +from urllib.parse import quote + +WIKI_ARTICLE_BASE = "https://www.thomas-krenn.com/de/wiki/" +WIKI_HOME_URL = "https://www.thomas-krenn.com/de/wiki/Hauptseite" +ORG_NAME = "Thomas-Krenn.AG" +LINE = "=" * 78 +SUBLINE = "-" * 78 + + +class DisplayOptions(TypedDict): + show_date: bool + show_user: bool + show_category: bool + + +DEFAULT_DISPLAY: DisplayOptions = { + "show_date": True, + "show_user": True, + "show_category": True, +} + + +def parse_display_options( + show_date: str | None = None, + show_user: str | None = None, + show_category: str | None = None, +) -> DisplayOptions: + return { + "show_date": show_date == "true", + "show_user": show_user == "true", + "show_category": show_category == "true", + } def filter_articles( @@ -17,60 +49,280 @@ def filter_articles( ] -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) +def split_articles_by_type(articles: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: + sorted_articles = sorted(articles, key=lambda a: a.get("timestamp", ""), reverse=True) + new_articles: list[dict[str, Any]] = [] + edited_articles: list[dict[str, Any]] = [] + new_titles: set[str] = set() - 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 "-" + for item in sorted_articles: + title = item.get("title", "") + if not title or item.get("type") != "new": + continue + if title not in new_titles: + new_articles.append(item) + new_titles.add(title) + + seen_edited: set[str] = set() + for item in sorted_articles: + title = item.get("title", "") + if not title or title in new_titles or title in seen_edited: + continue + edited_articles.append(item) + seen_edited.add(title) + + return new_articles, edited_articles + + +def create_subject(days: int, category: str = "") -> str: + base = f"Interner Wiki-Newsletter – {ORG_NAME} ({days} Tage)" + if category.strip(): + return f"{base} | Kategorie: {category.strip()}" + return base + + +def create_plain_text( + articles: list[dict[str, Any]], + days: int, + display: DisplayOptions | None = None, + category: str = "", +) -> str: + display = display or DEFAULT_DISPLAY + new_articles, edited_articles = split_articles_by_type(articles) + period_start, period_end = _period_range(days) + created_at = datetime.now().strftime("%d.%m.%Y %H:%M") + + lines = [ + LINE, + f"{ORG_NAME.upper()} – INTERNER WIKI-NEWSLETTER", + LINE, + f"Zeitraum: {period_start} bis {period_end} ({days} Tage)", + f"Erstellt am: {created_at}", + ] + if category.strip(): + lines.append(f"Kategorie: {category.strip()}") + lines.extend( + [ + "", + "Liebe Kolleginnen und Kollegen,", + "", + "im Thomas-Krenn-Wiki wurden im ausgewählten Zeitraum folgende Artikel", + "neu veröffentlicht bzw. aktualisiert:", + "", + ] + ) + + if not new_articles and not edited_articles: lines.extend( [ - f"{idx}. {item.get('title', 'Ohne Titel')}", - f" Zeit: {stamp}", - f" Benutzer: {item.get('user', '-')}", - f" Kategorien: {cat}", - f" Kommentar: {comment}", + SUBLINE, + "HINWEIS", + SUBLINE, + "Im gewählten Zeitraum wurden keine passenden Wiki-Änderungen gefunden.", "", + _plain_footer(new_articles, edited_articles), ] ) + return "\n".join(lines).strip() + + lines.extend(_plain_section("NEUE ARTIKEL", new_articles, display, empty_text="Keine neuen Artikel im Zeitraum.")) + lines.append("") + lines.extend( + _plain_section( + "BEARBEITETE ARTIKEL", + edited_articles, + display, + empty_text="Keine bearbeiteten Artikel im Zeitraum.", + ) + ) + lines.append("") + lines.append(_plain_footer(new_articles, edited_articles)) return "\n".join(lines).strip() -def create_html(articles: list[dict[str, Any]], title: str) -> str: - if not articles: - return f"
Keine Artikel im gewählten Zeitraum gefunden.
" +def create_html( + articles: list[dict[str, Any]], + days: int, + display: DisplayOptions | None = None, + category: str = "", +) -> str: + display = display or DEFAULT_DISPLAY + new_articles, edited_articles = split_articles_by_type(articles) + period_start, period_end = _period_range(days) + created_at = datetime.now().strftime("%d.%m.%Y %H:%M") + subject = escape(create_subject(days, category)) - blocks = [f"Kategorie: {escape(category.strip())}
' + + if not new_articles and not edited_articles: + body = 'Im gewählten Zeitraum wurden keine passenden Wiki-Änderungen gefunden.
' + else: + body = "\n".join( + [ + _html_section("Neue Artikel", new_articles, display, "Keine neuen Artikel im Zeitraum."), + _html_section("Bearbeitete Artikel", edited_articles, display, "Keine bearbeiteten Artikel im Zeitraum."), + ] ) - blocks.append("| + + |
{escape(empty_text)}
') + return "\n".join(blocks) + + blocks.append('|
+ Zusammenfassung + Neue Artikel: {len(new_articles)} + Bearbeitete Artikel: {len(edited_articles)} + Gesamt: {total} + |