1 Commits

Author SHA1 Message Date
smueller
2bc0a19ada Merge branch 'dev'
All checks were successful
Docker Image bauen & veröffentlichen / build-and-push (push) Successful in 40s
2026-07-07 15:18:19 +02:00
25 changed files with 178 additions and 1220 deletions

View File

@@ -1,33 +1,20 @@
APP_NAME=TK Wiki Newsletter Admin APP_NAME=TK Wiki Newsletter Admin
# development = lokale Entwicklung ohne harte Produktions-Checks ENVIRONMENT=production
# production = erzwingt starke Secrets, ALLOWED_HOSTS, COOKIE_SECURE=true SECRET_KEY=PLEASE_CHANGE_TO_A_LONG_RANDOM_SECRET
ENVIRONMENT=development
# Bitte ein langes, zufälliges Secret mit mindestens 32 Zeichen generieren und hier eintragen
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=localhost,127.0.0.1 ALLOWED_HOSTS=*
COOKIE_SECURE=false COOKIE_SECURE=false
# Auf true setzen, wenn hinter HTTPS/TLS-Terminierung (Reverse Proxy) Pflicht in production # Auf true setzen, wenn hinter HTTPS/TLS-Terminierung (Reverse Proxy)
HSTS_MAX_AGE=31536000
# Admin-Bootstrap-Daten
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).
# In production nicht erlaubt. Nach Erst-Setup auf false lassen. # Nach erfolgreichem Login wieder auf false setzen, damit UI-Passwortaenderungen
# nicht beim naechsten Neustart ueberschrieben werden.
ADMIN_BOOTSTRAP_RESET=false ADMIN_BOOTSTRAP_RESET=false
# Maximale Länge für Editor-Tips
EDITOR_TIP_MAX_LENGTH=10000
# Maximale Länge für Highlights
HIGHLIGHTS_MAX_LENGTH=5000
# Standardmäßig ausgeschlossene Bearbeiter (kommagetrennt)
# Beispiel: Aranzinger, Testuser
DEFAULT_EXCLUDED_USERS=Aranzinger

View File

@@ -25,12 +25,6 @@ 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

133
README.md
View File

@@ -21,15 +21,11 @@ Produktionsnahes internes Tool zur Erstellung von Thomas-Krenn.AG Newslettern au
- Plain Text (für Outlook) - Plain Text (für Outlook)
- Raw HTML - Raw HTML
- Optionaler SMTP-Versand: - Optionaler SMTP-Versand:
- UI-konfigurierbar (nur für Admins) - UI-konfigurierbar
- Verteilerlisten mit E-Mail-Validierung - Verteilerlisten
- Versand per **BCC** (Empfänger nicht im sichtbaren `To:`-Header) - tägliche Zeitplanung
- flexibler Zeitplan (täglich / wöchentlich / monatlich) über einen Hintergrund-Scheduler - Versandprotokoll
- eigene Inhalts-Vorgaben für den geplanten Versand (Zeitraum, Artikel-Auswahl, Kategorie) - Docker/Compose Betrieb, inkl. automatischem Image-Build & Push in die Gitea Container Registry (per Versions-Tag)
- **„Generieren“ verschickt nie** manueller Versand nur per Button „Newsletter jetzt senden“
- automatischer Versand nur mit **zwei** aktiven Häkchen: Zeitplan + Bestätigung „ohne manuelle Freigabe“
- Versandprotokoll (nur für Editor/Admin sichtbar)
- Docker/Compose Betrieb mit Container-Hardening, inkl. automatischem Image-Build & Push in die Gitea Container Registry (per Versions-Tag)
## Start mit Docker ## Start mit Docker
@@ -39,17 +35,11 @@ Produktionsnahes internes Tool zur Erstellung von Thomas-Krenn.AG Newslettern au
cp .env.example .env cp .env.example .env
``` ```
2. Wichtige Werte in `.env` setzen (siehe auch [Konfiguration](#konfiguration-env)): 2. Wichtige Werte in `.env` setzen:
- `HOST_PORT` (z. B. `8080`, falls `8000` bereits belegt ist)
| Variable | Lokal (Entwicklung) | Produktion | - `SECRET_KEY`
|----------|---------------------|------------| - `ADMIN_BOOTSTRAP_EMAIL`
| `ENVIRONMENT` | `development` | `production` | - `ADMIN_BOOTSTRAP_PASSWORD`
| `HOST_PORT` | z. B. `8080` | nach Bedarf |
| `SECRET_KEY` | beliebig lang | mind. 32 zufällige Zeichen |
| `ALLOWED_HOSTS` | `localhost,127.0.0.1` | konkreter Hostname |
| `COOKIE_SECURE` | `false` | `true` (mit TLS) |
| `ADMIN_BOOTSTRAP_EMAIL` | Admin-E-Mail | Admin-E-Mail |
| `ADMIN_BOOTSTRAP_PASSWORD` | Bootstrap-Passwort | starkes Passwort |
3. Start: 3. Start:
@@ -60,30 +50,6 @@ Produktionsnahes internes Tool zur Erstellung von Thomas-Krenn.AG Newslettern au
4. Zugriff: 4. Zugriff:
- [http://localhost:8080/login](http://localhost:8080/login) (oder dein `HOST_PORT`) - [http://localhost:8080/login](http://localhost:8080/login) (oder dein `HOST_PORT`)
> **Hinweis:** Mit `ENVIRONMENT=production` prüft die App beim Start strikt die Konfiguration. Fehlen starke Secrets oder sind unsichere Defaults gesetzt, startet der Container nicht.
## Konfiguration (`.env`)
Vollständige Vorlage: `.env.example`
| Variable | Beschreibung |
|----------|--------------|
| `ENVIRONMENT` | `development` = lokale Entwicklung ohne harte Checks; `production` = erzwingt sichere Einstellungen |
| `SECRET_KEY` | JWT-Signierung und Verschlüsselung von SMTP-Passwörtern in der DB |
| `ALGORITHM` | JWT-Algorithmus (Standard: `HS256`) |
| `ACCESS_TOKEN_EXPIRE_MINUTES` | Gültigkeit der Login-Session in Minuten (Standard: `120`) |
| `JWT_ISSUER` / `JWT_AUDIENCE` | JWT-Claims zur Token-Validierung |
| `HOST_PORT` | Host-Port für Docker Compose (nur Compose, nicht App-intern) |
| `DATABASE_URL` | SQLite (Standard) oder PostgreSQL |
| `WIKI_API_URL` | MediaWiki-API-Endpunkt |
| `ALLOWED_HOSTS` | Kommagetrennte erlaubte Host-Header |
| `COOKIE_SECURE` | `Secure`-Flag für Session- und CSRF-Cookies (Pflicht `true` in Produktion) |
| `HSTS_MAX_AGE` | HSTS-Header-Dauer in Sekunden (nur bei HTTPS) |
| `ADMIN_BOOTSTRAP_EMAIL` / `ADMIN_BOOTSTRAP_PASSWORD` | Erster Admin-Account beim Start |
| `ADMIN_BOOTSTRAP_RESET` | Admin auf `.env`-Werte zurücksetzen (nur Entwicklung/Notfall, in Produktion verboten) |
| `EDITOR_TIP_MAX_LENGTH` | Max. Zeichen für Redaktionsnotiz (Standard: `10000`) |
| `HIGHLIGHTS_MAX_LENGTH` | Max. Zeichen für Top-Highlights (Standard: `5000`) |
## Container-Image (Gitea Packages) ## Container-Image (Gitea Packages)
Fertige Images werden automatisch in die Gitea Container Registry veröffentlicht: Fertige Images werden automatisch in die Gitea Container Registry veröffentlicht:
@@ -103,9 +69,6 @@ docker run -d --name tk-newsletter-admin \
--env-file .env \ --env-file .env \
-p 8080:8000 \ -p 8080:8000 \
-v newsletter_data:/app/data \ -v newsletter_data:/app/data \
--read-only \
--tmpfs /tmp \
--security-opt no-new-privileges:true \
git.hexahost.dev/smueller/tk-wiki-newsletter:latest git.hexahost.dev/smueller/tk-wiki-newsletter:latest
``` ```
@@ -118,14 +81,11 @@ services:
# build: . # <- nicht mehr nötig, wenn das fertige Image genutzt wird # build: . # <- nicht mehr nötig, wenn das fertige Image genutzt wird
``` ```
Die mitgelieferte `docker-compose.yml` setzt bereits `read_only`, `no-new-privileges` und ein `tmpfs` für `/tmp`. Persistente Daten liegen im Volume `newsletter_data` unter `/app/data`.
### Automatischer Build (CI/CD) ### Automatischer Build (CI/CD)
Der Workflow `.gitea/workflows/docker-build.yml` baut und pusht das Image über Gitea Actions. Der Workflow `.gitea/workflows/docker-build.yml` baut und pusht das Image über Gitea Actions.
- **Auslöser**: nur beim Setzen eines Versions-Tags `v*` (kein Build bei normalen Pushes auf `dev`/`main`). - **Auslöser**: nur beim Setzen eines Versions-Tags `v*` (kein Build bei normalen Pushes auf `dev`/`main`).
- **Vor dem Build**: `pip-audit` prüft die Python-Abhängigkeiten aus `requirements.txt`.
- **Release-Ablauf** (Build bei Merge `dev` → `main` mit Version): - **Release-Ablauf** (Build bei Merge `dev` → `main` mit Version):
```bash ```bash
@@ -144,74 +104,25 @@ Der Workflow `.gitea/workflows/docker-build.yml` baut und pusht das Image über
## Admin-Login & Passwort zurücksetzen ## Admin-Login & Passwort zurücksetzen
- Das Login erfolgt über **E-Mail + Passwort** (kein separater Benutzername). Beim ersten Start wird der Admin aus `ADMIN_BOOTSTRAP_EMAIL` / `ADMIN_BOOTSTRAP_PASSWORD` angelegt. - Das Login erfolgt über **E-Mail + Passwort** (kein separater Benutzername). Beim ersten Start wird der Admin aus `ADMIN_BOOTSTRAP_EMAIL` / `ADMIN_BOOTSTRAP_PASSWORD` angelegt.
- **Passwortpolitik:** mindestens 10 Zeichen, mindestens ein Großbuchstabe, ein Kleinbuchstabe und eine Ziffer.
- **Rate-Limiting:** nach 5 fehlgeschlagenen Anmeldeversuchen pro IP/E-Mail innerhalb von 5 Minuten wird der Login temporär blockiert.
- **Wichtig:** Existiert der Admin bereits (persistente DB im Volume `newsletter_data`), wird eine spätere Passwort-Änderung in der `.env` normalerweise **nicht** übernommen. - **Wichtig:** Existiert der Admin bereits (persistente DB im Volume `newsletter_data`), wird eine spätere Passwort-Änderung in der `.env` normalerweise **nicht** übernommen.
- Um Passwort/Rolle/Status auf die `.env`-Werte zurückzusetzen (**nur Entwicklung**): `ADMIN_BOOTSTRAP_RESET=true` setzen und Container neu starten: - Um Passwort/Rolle/Status auf die `.env`-Werte zurückzusetzen: `ADMIN_BOOTSTRAP_RESET=true` setzen und Container neu starten:
```bash ```bash
docker compose up -d --force-recreate docker compose up -d --force-recreate
``` ```
Nach erfolgreichem Login `ADMIN_BOOTSTRAP_RESET=false` setzen. In `ENVIRONMENT=production` ist `ADMIN_BOOTSTRAP_RESET=true` nicht erlaubt die App startet dann nicht. Nach erfolgreichem Login `ADMIN_BOOTSTRAP_RESET=false` setzen, damit UI-Passwortänderungen nicht bei jedem Neustart überschrieben werden.
- **Passwort ändern** (Profil) oder **Abmelden** beendet alle aktiven Sessions des Benutzers. ## 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 ## Rollenmodell
| Rolle | Rechte | - `reader`: darf Ergebnisse sehen
|-------|--------| - `editor`: darf Newsletter generieren und sofort versenden
| `reader` | Newsletter-Vorschau und Artikellisten ansehen | - `admin`: zusätzlich Benutzerverwaltung und SMTP-Konfiguration
| `editor` | Newsletter generieren, exportieren und manuell versenden; Versandprotokoll und Empfängerliste einsehen |
| `admin` | Zusätzlich Benutzerverwaltung (anlegen, Rolle ändern, aktivieren/deaktivieren) und SMTP-Konfiguration |
Admins können in der Benutzerverwaltung (`/admin/users`):
- neue Benutzer mit Rolle anlegen
- die Rolle bestehender Benutzer ändern (beendet deren Sessions)
- Benutzer aktivieren oder deaktivieren (beendet deren Sessions)
Das eigene Konto kann weder deaktiviert noch die eigene Rolle geändert werden.
## Sicherheit
### Eingebaute Schutzmaßnahmen
- **bcrypt** für Passwort-Hashes
- **CSRF** Double-Submit-Cookie auf allen POST-Formularen
- **HttpOnly** + **SameSite=Strict** Session-Cookies; `Secure` bei HTTPS (`COOKIE_SECURE=true`)
- **JWT** mit `iss`, `aud`, Ablaufzeit und Session-Version (`session_version`)
- Session-Invalidierung bei Logout, Passwortwechsel, Rollen- oder Statusänderung
- **SMTP-Passwort** verschlüsselt in der Datenbank (Fernet), nicht im HTML-Formular
- **Security-Header:** CSP, `X-Frame-Options`, `X-Content-Type-Options`, `Referrer-Policy`, HSTS (bei HTTPS), `Cache-Control: no-store` auf geschützten Seiten
- **OpenAPI** (`/docs`, `/redoc`) in Produktion deaktiviert
- Abhängigkeiten in `requirements.txt` versioniert; CI führt `pip-audit` aus
### Produktions-Checkliste
Bei `ENVIRONMENT=production` erzwingt die App beim Start:
- `SECRET_KEY` mit mindestens 32 Zeichen (keine bekannten Defaults)
- starkes `ADMIN_BOOTSTRAP_PASSWORD`
- `ADMIN_BOOTSTRAP_RESET=false`
- `ALLOWED_HOSTS` mit konkreten Hostnamen (nicht `*`)
- `COOKIE_SECURE=true`
Zusätzlich empfohlen:
- Reverse Proxy (z. B. Nginx/Traefik) mit TLS vor den Container; Proxy muss `X-Forwarded-Proto: https` setzen
- Bootstrap-Admin-Passwort nach erstem Login im Profil ändern
- Netzwerkzugriff auf interne IPs/Netze beschränken
- Regelmäßige Backups des `newsletter_data` Volumes (SQLite-Datei unverschlüsselt)
### Beispiel `.env` für Produktion
```env
ENVIRONMENT=production
SECRET_KEY=<mind. 32 zufällige Zeichen>
ALLOWED_HOSTS=newsletter.intern.firma.de
COOKIE_SECURE=true
ADMIN_BOOTSTRAP_EMAIL=admin@firma.de
ADMIN_BOOTSTRAP_PASSWORD=<starkes Passwort>
ADMIN_BOOTSTRAP_RESET=false
```

View File

@@ -3,31 +3,21 @@ 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 = "development" environment: str = "production"
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 = "localhost,127.0.0.1" allowed_hosts: str = "*"
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
default_excluded_users: str = "Aranzinger"
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore") model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
@property
def is_production(self) -> bool:
return self.environment.lower() == "production"
settings = Settings() settings = Settings()

View File

@@ -1,16 +0,0 @@
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

@@ -1,18 +0,0 @@
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])

View File

@@ -1,35 +0,0 @@
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

@@ -1,28 +0,0 @@
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,24 +15,8 @@ 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, session_version: int = 0) -> str: def create_access_token(subject: Any) -> 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 = { to_encode = {"exp": expire, "sub": str(subject)}
"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

@@ -1,34 +0,0 @@
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

@@ -1,8 +1,4 @@
import asyncio
import logging
from datetime import datetime
from pathlib import Path from pathlib import Path
from urllib.parse import urlencode
from pydantic import ValidationError from pydantic import ValidationError
@@ -16,17 +12,13 @@ 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, UserRoleUpdate from app.schemas.user import UserCreate
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.auth import get_admin_user, get_current_user, get_optional_user, require_editor_or_admin
from app.services.config_store import get_config, has_secret_config, set_config from app.services.config_store import get_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,
@@ -36,26 +28,15 @@ from app.services.newsletter import (
create_plain_text, create_plain_text,
create_subject, create_subject,
filter_articles, filter_articles,
parse_excluded_users,
parse_display_options, parse_display_options,
parse_highlights, parse_highlights,
resolve_period, resolve_period,
split_articles_by_type, split_articles_by_type,
) )
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, run_scheduled_send_if_due, send_newsletter
from app.services.wiki import WikiFetchError, WikiService from app.services.wiki import WikiFetchError, WikiService
validate_startup_config() app = FastAPI(title=settings.app_name)
_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)
@@ -64,10 +45,6 @@ templates = Jinja2Templates(directory="app/templates")
templates.env.globals["wiki_article_url"] = article_url templates.env.globals["wiki_article_url"] = article_url
wiki_service = WikiService() wiki_service = WikiService()
logger = logging.getLogger("newsletter.scheduler")
SCHEDULER_INTERVAL_SECONDS = 60
_scheduler_task: asyncio.Task | None = None
@app.on_event("startup") @app.on_event("startup")
def on_startup() -> None: def on_startup() -> None:
@@ -75,66 +52,6 @@ def on_startup() -> None:
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
_ensure_schema_upgrades() _ensure_schema_upgrades()
_bootstrap_admin() _bootstrap_admin()
global _scheduler_task
_scheduler_task = asyncio.create_task(_scheduler_loop())
@app.on_event("shutdown")
async def on_shutdown() -> None:
if _scheduler_task is not None:
_scheduler_task.cancel()
async def _scheduler_loop() -> None:
"""Prüft periodisch, ob laut Zeitplan ein Newsletter-Versand fällig ist."""
while True:
try:
await asyncio.sleep(SCHEDULER_INTERVAL_SECONDS)
await _run_scheduled_send_if_due()
except asyncio.CancelledError:
break
except Exception:
logger.exception("Fehler im Scheduler-Loop")
async def _run_scheduled_send_if_due() -> None:
db = next(get_db())
try:
settings_snapshot = get_smtp_settings(db)
now = datetime.now()
if not schedule_is_due(settings_snapshot, now):
return
try:
days = int(settings_snapshot.get("gen_days") or "30")
except ValueError:
days = 30
gen = await _generate_newsletter(
period_mode=settings_snapshot.get("gen_period", "days"),
days=max(1, min(days, 90)),
article_type=settings_snapshot.get("gen_article_type", "all"),
category=settings_snapshot.get("gen_category", ""),
display=DEFAULT_DISPLAY,
editor_tip="",
highlight_list=[],
contact_email=settings_snapshot.get("reply_to") or settings_snapshot.get("from_email", ""),
excluded_users_raw=settings_snapshot.get("gen_excluded_users", ""),
)
if gen["wiki_error"] or not gen["articles"]:
# Kein Versand ohne Inhalt; erneuter Versuch beim nächsten Intervall.
logger.info("Geplanter Versand übersprungen (kein Inhalt/Wiki-Fehler).")
return
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)
mark_scheduled_sent(db, now)
finally:
db.close()
def _ensure_schema_upgrades() -> None: def _ensure_schema_upgrades() -> None:
@@ -145,8 +62,6 @@ 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")
@@ -160,28 +75,12 @@ 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.
path = request.url.path response.headers["Content-Security-Policy"] = (
if path.startswith("/dashboard"): "default-src 'self'; "
csp = ( "img-src 'self' https: data:; "
"default-src 'self'; " "style-src 'self' 'unsafe-inline'; "
"img-src 'self' https: data:; " "script-src 'self';"
"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';"
)
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,
@@ -219,30 +118,16 @@ 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"
if path.startswith("/admin/users"): if path.startswith("/admin/users"):
active_nav = "users" active_nav = "users"
elif path.startswith("/profil"):
active_nav = "profile"
return { return {
"user": user, "user": user,
"csrf_token": ensure_csrf_cookie(request), "csrf_token": ensure_csrf_cookie(request),
"smtp": _smtp_context(db, include_recipients=can_view_sensitive_logs(user)), "smtp": get_smtp_settings(db),
"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,
} }
@@ -304,20 +189,8 @@ 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",
@@ -326,31 +199,15 @@ def login(
"error_message": "Ungültige Zugangsdaten.", "error_message": "Ungültige Zugangsdaten.",
}, },
) )
clear_login_attempts(request, email) token = create_access_token(user.id)
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( response.set_cookie("access_token", token, httponly=True, secure=cookie_secure(request), samesite="strict")
"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( def logout(request: Request, csrf_token: str = Form(...)):
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
@@ -359,10 +216,9 @@ def logout(
def _empty_filters() -> dict: def _empty_filters() -> dict:
return { return {
"days": 30, "days": 30,
"period": "last_month", "period": "days",
"article_type": "new", "article_type": "all",
"category": "", "category": "",
"excluded_users": settings.default_excluded_users,
"display": DEFAULT_DISPLAY, "display": DEFAULT_DISPLAY,
"editor_tip": "", "editor_tip": "",
"highlights": "", "highlights": "",
@@ -378,8 +234,6 @@ async def _generate_newsletter(
display, display,
editor_tip: str, editor_tip: str,
highlight_list: list[str], highlight_list: list[str],
contact_email: str = "",
excluded_users_raw: str = "",
) -> dict: ) -> dict:
p = resolve_period(period_mode, days) p = resolve_period(period_mode, days)
result = { result = {
@@ -394,28 +248,21 @@ async def _generate_newsletter(
"subject": "", "subject": "",
} }
try: try:
excluded_users = parse_excluded_users(excluded_users_raw)
articles = await wiki_service.get_recent_changes( articles = await wiki_service.get_recent_changes(
days=p["days"], article_type=article_type, start=p["start"], end=p["end"] days=p["days"], article_type=article_type, start=p["start"], end=p["end"]
) )
filtered = filter_articles(articles, category_filter=category or None) filtered = filter_articles(articles, category_filter=category or None)
new_articles, edited_articles = split_articles_by_type(filtered, excluded_edited_users=excluded_users) new_articles, edited_articles = split_articles_by_type(filtered)
prange = (p["start_str"], p["end_str"]) prange = (p["start_str"], p["end_str"])
result.update( result.update(
{ {
"articles": filtered, "articles": filtered,
"articles_new": new_articles, "articles_new": new_articles,
"articles_edited": edited_articles, "articles_edited": edited_articles,
"subject": create_subject(p["month_label"], p["days"]), "subject": create_subject(p["days"], category, p["label"]),
"plain_text": create_plain_text( "plain_text": create_plain_text(filtered, p["days"], display, category, editor_tip, highlight_list, p["label"], prange, article_type),
filtered, p["days"], display, category, editor_tip, highlight_list, p["label"], prange, article_type, contact_email, excluded_users "raw_html": create_html(filtered, p["days"], display, category, editor_tip, highlight_list, p["label"], prange, article_type),
), "outlook_html": create_outlook_html(filtered, p["days"], display, category, editor_tip, highlight_list, p["label"], prange, article_type),
"raw_html": create_html(
filtered, p["days"], display, category, editor_tip, highlight_list, p["label"], prange, article_type, p["month_label"], contact_email, excluded_users
),
"outlook_html": create_outlook_html(
filtered, p["days"], display, category, editor_tip, highlight_list, p["label"], prange, article_type, p["month_label"], contact_email, excluded_users
),
} }
) )
except WikiFetchError as exc: except WikiFetchError as exc:
@@ -426,10 +273,6 @@ async def _generate_newsletter(
@app.get("/dashboard", response_class=HTMLResponse) @app.get("/dashboard", response_class=HTMLResponse)
async def dashboard(request: Request, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)): 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 = _base_context(request, current_user, db)
send_status = request.query_params.get("send_status")
send_result = None
if send_status:
send_result = {"status": send_status, "message": request.query_params.get("send_msg", "")}
context.update({ context.update({
"articles": [], "articles": [],
"articles_new": [], "articles_new": [],
@@ -440,7 +283,7 @@ async def dashboard(request: Request, db: Session = Depends(get_db), current_use
"subject": "", "subject": "",
"filters": _empty_filters(), "filters": _empty_filters(),
"wiki_error": None, "wiki_error": None,
"send_result": send_result, "send_result": None,
}) })
logs = db.query(SendLog).order_by(SendLog.created_at.desc()).limit(20).all() logs = db.query(SendLog).order_by(SendLog.created_at.desc()).limit(20).all()
context["send_logs"] = logs context["send_logs"] = logs
@@ -455,7 +298,6 @@ async def dashboard_generate(
days: int = Form(30), days: int = Form(30),
article_type: str = Form("all"), article_type: str = Form("all"),
category: str = Form(""), category: str = Form(""),
excluded_users: str = Form(""),
show_date: str | None = Form(None), show_date: str | None = Form(None),
show_user: str | None = Form(None), show_user: str | None = Form(None),
show_category: str | None = Form(None), show_category: str | None = Form(None),
@@ -465,17 +307,11 @@ 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"
display = parse_display_options(show_date, show_user, show_category) display = parse_display_options(show_date, show_user, show_category)
highlight_list = parse_highlights(highlights) highlight_list = parse_highlights(highlights)
smtp_cfg = get_smtp_settings(db)
contact_email = smtp_cfg["reply_to"] or smtp_cfg["from_email"]
gen = await _generate_newsletter( gen = await _generate_newsletter(
period_mode=period, period_mode=period,
@@ -485,9 +321,9 @@ async def dashboard_generate(
display=display, display=display,
editor_tip=editor_tip, editor_tip=editor_tip,
highlight_list=highlight_list, highlight_list=highlight_list,
contact_email=contact_email,
excluded_users_raw=excluded_users,
) )
if not gen["wiki_error"]:
run_scheduled_send_if_due(db, gen["subject"], gen["raw_html"], gen["plain_text"])
context = _base_context(request, current_user, db) context = _base_context(request, current_user, db)
context.update( context.update(
@@ -504,7 +340,6 @@ async def dashboard_generate(
"period": period, "period": period,
"article_type": article_type, "article_type": article_type,
"category": category, "category": category,
"excluded_users": excluded_users,
"display": display, "display": display,
"editor_tip": editor_tip, "editor_tip": editor_tip,
"highlights": highlights, "highlights": highlights,
@@ -525,7 +360,6 @@ async def send_now(
days: int = Form(30), days: int = Form(30),
article_type: str = Form("all"), article_type: str = Form("all"),
category: str = Form(""), category: str = Form(""),
excluded_users: str = Form(""),
show_date: str | None = Form(None), show_date: str | None = Form(None),
show_user: str | None = Form(None), show_user: str | None = Form(None),
show_category: str | None = Form(None), show_category: str | None = Form(None),
@@ -535,17 +369,11 @@ 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"
highlight_list = parse_highlights(highlights) highlight_list = parse_highlights(highlights)
days = max(1, min(days, 90)) days = max(1, min(days, 90))
smtp_cfg = get_smtp_settings(db)
contact_email = smtp_cfg["reply_to"] or smtp_cfg["from_email"]
gen = await _generate_newsletter( gen = await _generate_newsletter(
period_mode=period, period_mode=period,
@@ -555,8 +383,6 @@ async def send_now(
display=display, display=display,
editor_tip=editor_tip, editor_tip=editor_tip,
highlight_list=highlight_list, highlight_list=highlight_list,
contact_email=contact_email,
excluded_users_raw=excluded_users,
) )
send_result: dict[str, str] send_result: dict[str, str]
@@ -565,16 +391,35 @@ 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:
try: recipients = [r.strip() for r in get_config(db, "smtp.recipients", "").split(",") if r.strip()]
recipients = parse_recipient_list(get_config(db, "smtp.recipients", "")) status_code, detail = send_newsletter(db, gen["subject"], gen["raw_html"], gen["plain_text"], recipients, scheduled=False)
except ValueError as exc: send_result = {"status": status_code, "message": detail}
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)
send_result = {"status": status_code, "message": detail}
query = urlencode({"send_status": send_result["status"], "send_msg": send_result["message"]}) context = _base_context(request, current_user, db)
return RedirectResponse(url=f"/dashboard?{query}", status_code=status.HTTP_303_SEE_OTHER) context.update(
{
"articles": gen["articles"],
"articles_new": gen["articles_new"],
"articles_edited": gen["articles_edited"],
"raw_html": gen["raw_html"],
"outlook_html": gen["outlook_html"],
"plain_text": gen["plain_text"],
"subject": gen["subject"],
"filters": {
"days": days,
"period": period,
"article_type": article_type,
"category": category,
"display": display,
"editor_tip": editor_tip,
"highlights": highlights,
},
"send_logs": db.query(SendLog).order_by(SendLog.created_at.desc()).limit(20).all(),
"wiki_error": gen["wiki_error"],
"send_result": send_result,
}
)
return _render(request, "dashboard.html", context)
@app.post("/admin/smtp") @app.post("/admin/smtp")
@@ -587,21 +432,9 @@ def update_smtp_settings(
username: str = Form(""), username: str = Form(""),
password: str = Form(""), password: str = Form(""),
from_email: str = Form(""), from_email: str = Form(""),
from_name: str = Form(""),
reply_to: str = Form(""),
use_tls: str | None = Form(None), use_tls: str | None = Form(None),
recipients: str = Form(""), recipients: str = Form(""),
schedule_enabled: str | None = Form(None),
schedule_frequency: str = Form("monthly"),
schedule_time: str = Form("08:00"), schedule_time: str = Form("08:00"),
schedule_weekdays: list[str] = Form(default=[]),
schedule_dom: str = Form("1"),
schedule_acknowledged: str | None = Form(None),
gen_period: str = Form("days"),
gen_days: str = Form("30"),
gen_article_type: str = Form("all"),
gen_category: str = Form(""),
gen_excluded_users: str = Form(""),
db: Session = Depends(get_db), db: Session = Depends(get_db),
admin: User = Depends(get_admin_user), admin: User = Depends(get_admin_user),
): ):
@@ -610,45 +443,11 @@ 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())
if password.strip(): set_config(db, "smtp.password", password)
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.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: set_config(db, "smtp.recipients", recipients.strip())
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.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"):
schedule_frequency = "monthly"
set_config(db, "smtp.schedule_frequency", schedule_frequency)
set_config(db, "smtp.schedule_time", schedule_time.strip() or "08:00") set_config(db, "smtp.schedule_time", schedule_time.strip() or "08:00")
valid_weekdays = [d for d in schedule_weekdays if d in {"0", "1", "2", "3", "4", "5", "6"}]
set_config(db, "smtp.schedule_weekdays", ",".join(valid_weekdays))
try:
dom = max(1, min(int(schedule_dom), 28))
except ValueError:
dom = 1
set_config(db, "smtp.schedule_dom", str(dom))
if gen_period not in ("days", "last_month"):
gen_period = "days"
set_config(db, "smtp.gen_period", gen_period)
try:
gdays = max(1, min(int(gen_days), 90))
except ValueError:
gdays = 30
set_config(db, "smtp.gen_days", str(gdays))
if gen_article_type not in ("all", "new", "edited"):
gen_article_type = "all"
set_config(db, "smtp.gen_article_type", gen_article_type)
set_config(db, "smtp.gen_category", gen_category.strip())
set_config(db, "smtp.gen_excluded_users", gen_excluded_users.strip())
return RedirectResponse(url="/dashboard", status_code=status.HTTP_302_FOUND) return RedirectResponse(url="/dashboard", status_code=status.HTTP_302_FOUND)
@@ -734,155 +533,3 @@ def create_user(
db.add(user) db.add(user)
db.commit() db.commit()
return RedirectResponse(url="/admin/users?created=1", status_code=status.HTTP_302_FOUND) return RedirectResponse(url="/admin/users?created=1", status_code=status.HTTP_302_FOUND)
def _profile_context(request: Request, user: User, db: Session, **extra) -> dict:
context = _base_context(request, user, db)
context.setdefault("error_message", None)
context.setdefault("pw_error_message", None)
context.setdefault("success_message", None)
context.update(extra)
return context
@app.get("/profil", response_class=HTMLResponse)
def profile_page(request: Request, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
success = None
if request.query_params.get("updated") == "1":
success = "Profil wurde aktualisiert."
elif request.query_params.get("pw") == "1":
success = "Passwort wurde geändert."
return _render(request, "profile.html", _profile_context(request, current_user, db, success_message=success))
@app.post("/profil", response_class=HTMLResponse)
def update_profile(
request: Request,
csrf_token: str = Form(...),
email: str = Form(...),
full_name: str = Form(...),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
try:
validate_csrf(request, csrf_token)
except HTTPException:
return _render(
request,
"profile.html",
_profile_context(request, current_user, db, error_message="Sitzung abgelaufen. Bitte Seite neu laden und erneut versuchen."),
)
try:
payload = ProfileUpdate(email=email.strip(), full_name=full_name.strip())
except ValidationError as exc:
return _render(
request,
"profile.html",
_profile_context(request, current_user, db, error_message=_format_validation_error(exc)),
)
clash = db.query(User).filter(User.email == payload.email, User.id != current_user.id).first()
if clash:
return _render(
request,
"profile.html",
_profile_context(request, current_user, db, error_message="Diese E-Mail-Adresse wird bereits verwendet."),
)
current_user.email = payload.email
current_user.full_name = payload.full_name
db.commit()
return RedirectResponse(url="/profil?updated=1", status_code=status.HTTP_302_FOUND)
@app.post("/profil/passwort", response_class=HTMLResponse)
def change_password(
request: Request,
csrf_token: str = Form(...),
current_password: str = Form(...),
new_password: str = Form(...),
confirm_password: str = Form(...),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
try:
validate_csrf(request, csrf_token)
except HTTPException:
return _render(
request,
"profile.html",
_profile_context(request, current_user, db, pw_error_message="Sitzung abgelaufen. Bitte Seite neu laden und erneut versuchen."),
)
if not verify_password(current_password, current_user.password_hash):
return _render(
request,
"profile.html",
_profile_context(request, current_user, db, pw_error_message="Das aktuelle Passwort ist nicht korrekt."),
)
if new_password != confirm_password:
return _render(
request,
"profile.html",
_profile_context(request, current_user, db, pw_error_message="Die neuen Passwörter stimmen nicht überein."),
)
try:
payload = PasswordChange(new_password=new_password)
except ValidationError as exc:
return _render(
request,
"profile.html",
_profile_context(request, current_user, db, pw_error_message=_format_validation_error(exc)),
)
current_user.password_hash = hash_password(payload.new_password)
invalidate_user_sessions(current_user, db)
db.commit()
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,5 +13,4 @@ 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,6 +1,4 @@
from pydantic import BaseModel, EmailStr, Field, field_validator from pydantic import BaseModel, EmailStr, Field
from app.core.password_policy import validate_password_strength
class UserCreate(BaseModel): class UserCreate(BaseModel):
@@ -9,31 +7,6 @@ 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):
email: EmailStr
full_name: str = Field(min_length=2, max_length=255)
class PasswordChange(BaseModel):
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,45 +1,42 @@
from fastapi import Depends, HTTPException, Request, status from fastapi import Depends, HTTPException, Request, status
from jose import JWTError from jose import JWTError, jwt
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.security import decode_access_token from app.core.config import settings
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
return _user_from_token(token, db) try:
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.")
user = _user_from_token(token, db) 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: if not user:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Ungültiger oder abgelaufener Token.") raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Benutzer nicht gefunden.")
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": if current_user.role != "admin" and not current_user.is_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
@@ -50,5 +47,7 @@ def require_editor_or_admin(current_user: User = Depends(get_current_user)) -> U
return current_user return current_user
def can_view_sensitive_logs(user: User) -> bool: def require_reader_or_higher(current_user: User = Depends(get_current_user)) -> User:
return user.role in {"admin", "editor"} if current_user.role not in {"admin", "editor", "reader"}:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin-Rechte erforderlich.")
return current_user

View File

@@ -1,29 +1,17 @@
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()
if not row: return row.value if row else default
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 = stored row.value = value
else: else:
db.add(AppConfig(key=key, value=stored)) db.add(AppConfig(key=key, value=value))
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

@@ -76,21 +76,11 @@ def filter_articles(
] ]
def parse_excluded_users(raw: str | None) -> set[str]: def split_articles_by_type(articles: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
if not raw:
return set()
return {part.strip().lower() for part in raw.split(",") if part.strip()}
def split_articles_by_type(
articles: list[dict[str, Any]],
excluded_edited_users: set[str] | None = None,
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
sorted_articles = sorted(articles, key=lambda a: a.get("timestamp", ""), reverse=True) sorted_articles = sorted(articles, key=lambda a: a.get("timestamp", ""), reverse=True)
new_articles: list[dict[str, Any]] = [] new_articles: list[dict[str, Any]] = []
edited_articles: list[dict[str, Any]] = [] edited_articles: list[dict[str, Any]] = []
new_titles: set[str] = set() new_titles: set[str] = set()
excluded_edited_users = excluded_edited_users or set()
for item in sorted_articles: for item in sorted_articles:
title = item.get("title", "") title = item.get("title", "")
@@ -103,20 +93,20 @@ def split_articles_by_type(
seen_edited: set[str] = set() seen_edited: set[str] = set()
for item in sorted_articles: for item in sorted_articles:
title = item.get("title", "") title = item.get("title", "")
editor = str(item.get("user", "")).strip().lower()
if not title or title in new_titles or title in seen_edited: if not title or title in new_titles or title in seen_edited:
continue continue
if editor in excluded_edited_users:
continue
edited_articles.append(item) edited_articles.append(item)
seen_edited.add(title) seen_edited.add(title)
return new_articles, edited_articles return new_articles, edited_articles
def create_subject(month_label: str | None = None, days: int = 30) -> str: def create_subject(days: int, category: str = "", period_label: str | None = None) -> str:
label = month_label or f"{days} Tage" label = period_label or f"{days} Tage"
return f"Neue Wiki-Artikel Veröffentlichungen vom {label} | Wiki-Redaktion" base = f"Interner Wiki-Newsletter {ORG_NAME} ({label})"
if category.strip():
return f"{base} | Kategorie: {category.strip()}"
return base
_MONTHS_DE = [ _MONTHS_DE = [
@@ -148,7 +138,6 @@ def resolve_period(mode: str, days: int) -> dict[str, Any]:
"start_str": start.strftime("%d.%m.%Y"), "start_str": start.strftime("%d.%m.%Y"),
"end_str": end.strftime("%d.%m.%Y"), "end_str": end.strftime("%d.%m.%Y"),
"label": _month_label(start), "label": _month_label(start),
"month_label": _month_label(start),
"days": max(1, (end - start).days), "days": max(1, (end - start).days),
} }
days = max(1, min(days, 90)) days = max(1, min(days, 90))
@@ -161,7 +150,6 @@ def resolve_period(mode: str, days: int) -> dict[str, Any]:
"start_str": start.strftime("%d.%m.%Y"), "start_str": start.strftime("%d.%m.%Y"),
"end_str": end.strftime("%d.%m.%Y"), "end_str": end.strftime("%d.%m.%Y"),
"label": f"{days} Tage", "label": f"{days} Tage",
"month_label": _month_label(end),
"days": days, "days": days,
} }
@@ -216,11 +204,9 @@ def create_plain_text(
period_label: str | None = None, period_label: str | None = None,
period: tuple[str, str] | None = None, period: tuple[str, str] | None = None,
article_type: str = "all", article_type: str = "all",
contact_email: str = "",
excluded_edited_users: set[str] | None = None,
) -> str: ) -> str:
display = display or DEFAULT_DISPLAY display = display or DEFAULT_DISPLAY
new_articles, edited_articles = split_articles_by_type(articles, excluded_edited_users=excluded_edited_users) new_articles, edited_articles = split_articles_by_type(articles)
show_new = article_type in ("all", "new") show_new = article_type in ("all", "new")
show_edited = article_type in ("all", "edited") show_edited = article_type in ("all", "edited")
period_start, period_end = period if period else _period_range(days) period_start, period_end = period if period else _period_range(days)
@@ -245,7 +231,6 @@ def create_plain_text(
"", "",
] ]
) )
lines.extend(_plain_contact_line(contact_email))
highlight_articles = _match_highlights(new_articles + edited_articles, highlights) highlight_articles = _match_highlights(new_articles + edited_articles, highlights)
if highlight_articles: if highlight_articles:
@@ -287,15 +272,6 @@ def create_plain_text(
return "\n".join(lines).strip() return "\n".join(lines).strip()
def _plain_contact_line(contact_email: str) -> list[str]:
if contact_email.strip():
return [
f"Fragen oder ein Themenwunsch? Meldet euch gern beim Wiki-Team ({contact_email.strip()}).",
"",
]
return ["Fragen oder ein Themenwunsch? Meldet euch gern beim Wiki-Team.", ""]
def _plain_editor_tip(editor_tip: str) -> list[str]: def _plain_editor_tip(editor_tip: str) -> list[str]:
text = (editor_tip or "").strip() text = (editor_tip or "").strip()
if not text: if not text:
@@ -320,17 +296,14 @@ def create_html(
period_label: str | None = None, period_label: str | None = None,
period: tuple[str, str] | None = None, period: tuple[str, str] | None = None,
article_type: str = "all", article_type: str = "all",
month_label: str | None = None,
contact_email: str = "",
excluded_edited_users: set[str] | None = None,
) -> str: ) -> str:
display = display or DEFAULT_DISPLAY display = display or DEFAULT_DISPLAY
new_articles, edited_articles = split_articles_by_type(articles, excluded_edited_users=excluded_edited_users) new_articles, edited_articles = split_articles_by_type(articles)
show_new = article_type in ("all", "new") show_new = article_type in ("all", "new")
show_edited = article_type in ("all", "edited") show_edited = article_type in ("all", "edited")
period_start, period_end = period if period else _period_range(days) period_start, period_end = period if period else _period_range(days)
total = len(new_articles) + len(edited_articles) total = len(new_articles) + len(edited_articles)
subject = escape(create_subject(month_label or period_label, days)) subject = escape(create_subject(days, category, period_label))
preheader = escape(_summary_sentence(total)) preheader = escape(_summary_sentence(total))
period_label = escape(f"{period_start} {period_end}") period_label = escape(f"{period_start} {period_end}")
summary_text = escape(_summary_sentence(total)) summary_text = escape(_summary_sentence(total))
@@ -361,21 +334,6 @@ def create_html(
f'<strong style="color:{COLOR_HEADING};">Filter:</strong> Kategorie {escape(category.strip())}</p></td></tr>' f'<strong style="color:{COLOR_HEADING};">Filter:</strong> Kategorie {escape(category.strip())}</p></td></tr>'
) )
if contact_email.strip():
ce = escape(contact_email.strip())
contact_html = (
f'Fragen oder ein Themenwunsch? '
f'<a href="mailto:{ce}" style="color:{COLOR_TK_ORANGE};text-decoration:underline;">Meldet euch gern beim Wiki-Team</a>.'
)
else:
contact_html = "Fragen oder ein Themenwunsch? Meldet euch gern beim Wiki-Team."
contact_section = (
f'<tr><td style="padding:16px 32px 24px 32px;" class="mobile-padding">'
f'<p style="margin:8px 0 0;font-family:{FONT};font-size:15px;line-height:24px;color:{COLOR_TEXT};">{contact_html}</p>'
f"</td></tr>"
)
return f"""<!DOCTYPE html> return f"""<!DOCTYPE html>
<html lang="de" xmlns="http://www.w3.org/1999/xhtml" xmlns:o="urn:schemas-microsoft-com:office:office"> <html lang="de" xmlns="http://www.w3.org/1999/xhtml" xmlns:o="urn:schemas-microsoft-com:office:office">
<head> <head>
@@ -438,7 +396,6 @@ def create_html(
</table> </table>
</td> </td>
</tr> </tr>
{contact_section}
{category_hint} {category_hint}
{highlights_section} {highlights_section}
{body} {body}
@@ -454,6 +411,7 @@ def create_html(
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"> <table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr> <tr>
<td style="font-family:{FONT};font-size:15px;line-height:24px;color:{COLOR_TEXT};"> <td style="font-family:{FONT};font-size:15px;line-height:24px;color:{COLOR_TEXT};">
Fragen oder ein Themenwunsch? Meldet euch gern beim Wiki-Team.<br><br>
Viele Grüße<br> Viele Grüße<br>
Euer Thomas-Krenn-Wiki Team Euer Thomas-Krenn-Wiki Team
</td> </td>
@@ -467,7 +425,7 @@ def create_html(
Thomas-Krenn.AG | Speltenbach-Steinäcker 1 | D-94078 Freyung<br> Thomas-Krenn.AG | Speltenbach-Steinäcker 1 | D-94078 Freyung<br>
Tel.: +49 8551 9150 0 | Fax: +49 8551 9150 55 | Tel.: +49 8551 9150 0 | Fax: +49 8551 9150 55 |
<a href="{WIKI_HOME_URL}" target="_blank" style="color:{COLOR_TK_ORANGE};text-decoration:underline;">thomas-krenn.com</a><br> <a href="{WIKI_HOME_URL}" target="_blank" style="color:{COLOR_TK_ORANGE};text-decoration:underline;">thomas-krenn.com</a><br>
<span style="color:#AEB2B7;">Automatisch erstellter interner Newsletter &middot; Nur für den internen Gebrauch &middot; Made with ❤️ by Samuel</span> <span style="color:#AEB2B7;">Automatisch erstellter interner Newsletter &middot; Nur für den internen Gebrauch</span>
</div> </div>
</td> </td>
</tr> </tr>
@@ -720,18 +678,15 @@ def create_outlook_html(
period_label: str | None = None, period_label: str | None = None,
period: tuple[str, str] | None = None, period: tuple[str, str] | None = None,
article_type: str = "all", article_type: str = "all",
month_label: str | None = None,
contact_email: str = "",
excluded_edited_users: set[str] | None = None,
) -> str: ) -> str:
"""Word/Outlook-kompatibles HTML mit font-Tags und bgcolor (überlebt Einfügen in Outlook).""" """Word/Outlook-kompatibles HTML mit font-Tags und bgcolor (überlebt Einfügen in Outlook)."""
display = display or DEFAULT_DISPLAY display = display or DEFAULT_DISPLAY
new_articles, edited_articles = split_articles_by_type(articles, excluded_edited_users=excluded_edited_users) new_articles, edited_articles = split_articles_by_type(articles)
show_new = article_type in ("all", "new") show_new = article_type in ("all", "new")
show_edited = article_type in ("all", "edited") show_edited = article_type in ("all", "edited")
period_start, period_end = period if period else _period_range(days) period_start, period_end = period if period else _period_range(days)
total = len(new_articles) + len(edited_articles) total = len(new_articles) + len(edited_articles)
subject = escape(create_subject(month_label or period_label, days)) subject = escape(create_subject(days, category, period_label))
period_label = f"{period_start} {period_end}" period_label = f"{period_start} {period_end}"
summary_text = _summary_sentence(total) summary_text = _summary_sentence(total)
@@ -770,21 +725,6 @@ def create_outlook_html(
editor_tip_row = _outlook_editor_tip(editor_tip) editor_tip_row = _outlook_editor_tip(editor_tip)
if contact_email.strip():
contact_block = (
f'<p style="margin:12px 0 20px;">'
f'{_outlook_font("Fragen oder ein Themenwunsch? ", size="1")}'
f'{_outlook_link("Meldet euch gern beim Wiki-Team", f"mailto:{contact_email.strip()}", color=OUTLOOK_ORANGE, bold=False, size="1")}'
f'{_outlook_font(".", size="1")}'
f"</p>"
)
else:
contact_block = (
f'<p style="margin:12px 0 20px;">'
f'{_outlook_font("Fragen oder ein Themenwunsch? Meldet euch gern beim Wiki-Team.", size="1")}'
f"</p>"
)
return f"""<!DOCTYPE html> return f"""<!DOCTYPE html>
<html lang="de" xmlns="http://www.w3.org/1999/xhtml" xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:w="urn:schemas-microsoft-com:office:word"> <html lang="de" xmlns="http://www.w3.org/1999/xhtml" xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:w="urn:schemas-microsoft-com:office:word">
<head> <head>
@@ -833,7 +773,6 @@ def create_outlook_html(
<td {_outlook_td_attrs(extra_style="padding:20px;")}> <td {_outlook_td_attrs(extra_style="padding:20px;")}>
<p style="margin:0 0 8px;">{_outlook_font("Servus,")}</p> <p style="margin:0 0 8px;">{_outlook_font("Servus,")}</p>
<p style="margin:0 0 16px;">{_outlook_font(f"hier ist deine Übersicht mit den neuesten Beiträgen aus dem Thomas-Krenn-Wiki vom {period_start} bis {period_end}.")}</p> <p style="margin:0 0 16px;">{_outlook_font(f"hier ist deine Übersicht mit den neuesten Beiträgen aus dem Thomas-Krenn-Wiki vom {period_start} bis {period_end}.")}</p>
{contact_block}
{category_block} {category_block}
{highlights_rows} {highlights_rows}
<table width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="{OUTLOOK_WHITE}" style="background-color:{OUTLOOK_WHITE} !important;" data-ogsb="{OUTLOOK_WHITE}"> <table width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="{OUTLOOK_WHITE}" style="background-color:{OUTLOOK_WHITE} !important;" data-ogsb="{OUTLOOK_WHITE}">
@@ -841,13 +780,13 @@ def create_outlook_html(
{summary_row} {summary_row}
{editor_tip_row} {editor_tip_row}
<tr> <tr>
<td {_outlook_td_attrs(extra_style="padding:20px 0 24px;")}> <td {_outlook_td_attrs(extra_style="padding:20px 0 0;")}>
{_outlook_cta(WIKI_HOME_URL, "Zum Thomas-Krenn-Wiki →")} {_outlook_cta(WIKI_HOME_URL, "Zum Thomas-Krenn-Wiki →")}
</td> </td>
</tr> </tr>
<tr> <tr>
<td {_outlook_td_attrs(extra_style=f"padding:20px 0 0;border-top:1px solid {OUTLOOK_BORDER};")}> <td {_outlook_td_attrs(extra_style=f"padding:16px 0 0;border-top:1px solid {OUTLOOK_BORDER};")}>
{_outlook_font(f"Automatisch erstellter interner Newsletter · Nur für den Gebrauch bei {ORG_NAME} · Made with ❤️ by Samuel", color=OUTLOOK_MUTED, size="1")} {_outlook_font(f"Automatisch erstellter interner Newsletter · Nur für den Gebrauch bei {ORG_NAME}", color=OUTLOOK_MUTED, size="1")}
</td> </td>
</tr> </tr>
</table> </table>
@@ -992,10 +931,7 @@ def _plain_item(idx: int, item: dict[str, Any], display: DisplayOptions) -> list
return lines return lines
def _plain_footer( def _plain_footer(new_articles: list[dict[str, Any]], edited_articles: list[dict[str, Any]]) -> str:
new_articles: list[dict[str, Any]],
edited_articles: list[dict[str, Any]],
) -> str:
total = len(new_articles) + len(edited_articles) total = len(new_articles) + len(edited_articles)
return "\n".join( return "\n".join(
[ [

View File

@@ -1,8 +0,0 @@
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,20 +1,14 @@
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
from email.mime.text import MIMEText from email.mime.text import MIMEText
from email.utils import formataddr
from typing import Sequence from typing import Sequence
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.config import settings
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 {
@@ -24,61 +18,13 @@ def get_smtp_settings(db: Session) -> dict[str, str]:
"username": get_config(db, "smtp.username", ""), "username": get_config(db, "smtp.username", ""),
"password": get_config(db, "smtp.password", ""), "password": get_config(db, "smtp.password", ""),
"from_email": get_config(db, "smtp.from_email", ""), "from_email": get_config(db, "smtp.from_email", ""),
"from_name": get_config(db, "smtp.from_name", ""),
"reply_to": get_config(db, "smtp.reply_to", ""),
"use_tls": get_config(db, "smtp.use_tls", "true"), "use_tls": get_config(db, "smtp.use_tls", "true"),
"recipients": get_config(db, "smtp.recipients", ""), "recipients": get_config(db, "smtp.recipients", ""),
# Zeitplan
"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_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_dom": get_config(db, "smtp.schedule_dom", "1"),
"last_scheduled_date": get_config(db, "smtp.last_scheduled_date", ""), "last_scheduled_date": get_config(db, "smtp.last_scheduled_date", ""),
# Generierungs-Vorgaben für den geplanten Versand
"gen_period": get_config(db, "smtp.gen_period", "last_month"),
"gen_days": get_config(db, "smtp.gen_days", "30"),
"gen_article_type": get_config(db, "smtp.gen_article_type", "new"),
"gen_category": get_config(db, "smtp.gen_category", ""),
"gen_excluded_users": get_config(db, "smtp.gen_excluded_users", settings.default_excluded_users),
} }
def schedule_is_due(settings: dict[str, str], now: datetime) -> bool:
"""Prüft, ob laut Zeitplan jetzt ein Versand fällig ist (mit Tages-Dedupe)."""
if settings["enabled"].lower() != "true":
return False
if (settings.get("schedule_enabled") or "false").lower() != "true":
return False
if (settings.get("schedule_acknowledged") or "false").lower() != "true":
return False
freq = (settings.get("schedule_frequency") or "off").lower()
if freq == "off":
return False
schedule_time = settings.get("schedule_time") or "08:00"
if now.strftime("%H:%M") < schedule_time:
return False
today = now.strftime("%Y-%m-%d")
if settings.get("last_scheduled_date") == today:
return False
if freq == "daily":
return True
if freq == "weekly":
weekdays = {d.strip() for d in (settings.get("schedule_weekdays") or "").split(",") if d.strip() != ""}
return str(now.weekday()) in weekdays
if freq == "monthly":
try:
dom = int(settings.get("schedule_dom") or "1")
except ValueError:
dom = 1
return now.day == max(1, min(dom, 28))
return False
def send_newsletter( def send_newsletter(
db: Session, db: Session,
subject: str, subject: str,
@@ -97,25 +43,12 @@ 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"))
msg["Subject"] = subject msg["Subject"] = subject
if settings.get("from_name"): msg["From"] = settings["from_email"]
msg["From"] = formataddr((settings["from_name"], settings["from_email"])) msg["To"] = ", ".join(recipients)
else:
msg["From"] = settings["from_email"]
msg["To"] = settings["from_email"]
msg["Bcc"] = ", ".join(validated_recipients)
if settings.get("reply_to"):
msg["Reply-To"] = settings["reply_to"]
try: try:
port = int(settings["port"]) port = int(settings["port"])
@@ -124,21 +57,32 @@ 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"], validated_recipients, msg.as_string()) server.sendmail(settings["from_email"], list(recipients), msg.as_string())
detail = f"Versand erfolgreich an {len(validated_recipients)} Empfänger." detail = f"Versand erfolgreich an {len(recipients)} Empfänger."
_log(db, subject, validated_recipients, "sent", detail, scheduled) _log(db, subject, recipients, "sent", detail, scheduled)
return "sent", detail return "sent", detail
except Exception as exc: except Exception as exc:
logger.exception("SMTP-Versand fehlgeschlagen") detail = f"Versandfehler: {exc}"
detail = "Versandfehler: E-Mail konnte nicht zugestellt werden. Details stehen im Server-Log." _log(db, subject, recipients, "failed", detail, scheduled)
_log(db, subject, validated_recipients, "failed", detail, scheduled)
return "failed", detail return "failed", detail
def mark_scheduled_sent(db: Session, now: datetime) -> None: 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 from app.services.config_store import set_config
set_config(db, "smtp.last_scheduled_date", now.strftime("%Y-%m-%d")) set_config(db, "smtp.last_scheduled_date", today)
def _log(db: Session, subject: str, recipients: Sequence[str], status: str, detail: str, scheduled: bool) -> None: def _log(db: Session, subject: str, recipients: Sequence[str], status: str, detail: str, scheduled: bool) -> None:

View File

@@ -115,15 +115,6 @@ a:hover { color: var(--tk-orange-dark); text-decoration: underline; }
color: #CCCCCC; color: #CCCCCC;
} }
a.user-chip-link {
text-decoration: none;
cursor: pointer;
}
a.user-chip-link:hover {
color: #FFFFFF;
}
.topbar .btn-logout { .topbar .btn-logout {
background: transparent; background: transparent;
border: 1px solid rgba(255, 255, 255, 0.35); border: 1px solid rgba(255, 255, 255, 0.35);
@@ -707,54 +698,6 @@ body.login-page .container {
flex-wrap: wrap; flex-wrap: wrap;
} }
/* ── Zeitplan ── */
.schedule-box {
border: 1px solid var(--tk-border);
border-radius: 8px;
padding: 0.85rem 1rem 1rem;
margin: 0.25rem 0;
}
.schedule-box legend {
font-weight: 600;
padding: 0 0.4rem;
color: var(--tk-orange-dark, #c15200);
}
.weekday-row {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
margin: 0.25rem 0 0.75rem;
}
.weekday-chip {
display: inline-flex;
align-items: center;
gap: 0.3rem;
border: 1px solid var(--tk-border);
border-radius: 6px;
padding: 0.3rem 0.55rem;
font-size: 0.85rem;
cursor: pointer;
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

@@ -4,7 +4,7 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}{{ title if title else "Wiki Newsletter Admin" }}{% endblock %} · Thomas-Krenn.AG</title> <title>{% block title %}{{ title if title else "Wiki Newsletter Admin" }}{% endblock %} · Thomas-Krenn.AG</title>
<link rel="stylesheet" href="/static/css/main.css?v=6"> <link rel="stylesheet" href="/static/css/main.css?v=4">
</head> </head>
<body class="{% block body_class %}{% endblock %}"> <body class="{% block body_class %}{% endblock %}">
<header class="topbar"> <header class="topbar">
@@ -22,12 +22,11 @@
{% if user.role == "admin" %} {% if user.role == "admin" %}
<a href="/admin/users" class="{% if active_nav == 'users' %}active{% endif %}">Benutzer</a> <a href="/admin/users" class="{% if active_nav == 'users' %}active{% endif %}">Benutzer</a>
{% endif %} {% endif %}
<a href="/profil" class="{% if active_nav == 'profile' %}active{% endif %}">Profil</a>
</nav> </nav>
<a href="/profil" class="user-chip user-chip-link"> <div class="user-chip">
<span>{{ user.full_name }}</span> <span>{{ user.full_name }}</span>
<span class="badge badge-{{ user.role }}">{{ user.role }}</span> <span class="badge badge-{{ user.role }}">{{ user.role }}</span>
</a> </div>
<form method="post" action="/logout"> <form method="post" action="/logout">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="btn-logout">Abmelden</button> <button type="submit" class="btn-logout">Abmelden</button>

View File

@@ -49,11 +49,7 @@
<label>Kategorie (optional) <label>Kategorie (optional)
<input type="text" name="category" value="{{ filters.category }}" placeholder="z. B. Linux"> <input type="text" name="category" value="{{ filters.category }}" placeholder="z. B. Linux">
</label> </label>
<label>Bearbeiter ausschließen (optional)
<input type="text" name="excluded_users" value="{{ filters.excluded_users }}" placeholder="z. B. Aranzinger, Testuser">
</label>
</div> </div>
<p class="hint">Nur bei „Bearbeitete Artikel“ relevant: Benutzer in dieser Liste werden aus den bearbeiteten Artikeln entfernt.</p>
<label>Artikel-Auswahl <label>Artikel-Auswahl
<select name="article_type"> <select name="article_type">
<option value="all" {% if filters.article_type == 'all' %}selected{% endif %}>Alle (neue &amp; bearbeitete)</option> <option value="all" {% if filters.article_type == 'all' %}selected{% endif %}>Alle (neue &amp; bearbeitete)</option>
@@ -77,11 +73,11 @@
</label> </label>
</fieldset> </fieldset>
<label>Top-Highlights (optional) <label>Top-Highlights (optional)
<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> <textarea name="highlights" rows="3" 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" maxlength="{{ editor_tip_max_length }}" placeholder="Optionaler Hinweis der Redaktion für diese Ausgabe…">{{ filters.editor_tip }}</textarea> <textarea name="editor_tip" rows="3" 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 %}
@@ -126,11 +122,7 @@
<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"] %}
@@ -139,7 +131,6 @@
<input type="hidden" name="period" value="{{ filters.period }}"> <input type="hidden" name="period" value="{{ filters.period }}">
<input type="hidden" name="days" value="{{ filters.days }}"> <input type="hidden" name="days" value="{{ filters.days }}">
<input type="hidden" name="category" value="{{ filters.category }}"> <input type="hidden" name="category" value="{{ filters.category }}">
<input type="hidden" name="excluded_users" value="{{ filters.excluded_users }}">
<input type="hidden" name="article_type" value="{{ filters.article_type }}"> <input type="hidden" name="article_type" value="{{ filters.article_type }}">
<input type="hidden" name="show_date" value="{{ 'true' if filters.display.show_date else 'false' }}"> <input type="hidden" name="show_date" value="{{ 'true' if filters.display.show_date else 'false' }}">
<input type="hidden" name="show_user" value="{{ 'true' if filters.display.show_user else 'false' }}"> <input type="hidden" name="show_user" value="{{ 'true' if filters.display.show_user else 'false' }}">
@@ -259,20 +250,12 @@
<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" autocomplete="new-password" placeholder="{% if smtp.password_configured == 'true' %}•••••••• (unverändert lassen){% else %}SMTP-Passwort{% endif %}"> <input type="password" name="password" value="{{ smtp.password }}">
</label> </label>
</div> </div>
<label>Absender-Name <label>Absender E-Mail
<input type="text" name="from_name" value="{{ smtp.from_name }}" placeholder="Thomas-Krenn Wiki-Team"> <input type="email" name="from_email" value="{{ smtp.from_email }}">
</label> </label>
<div class="form-row">
<label>Absender E-Mail
<input type="email" name="from_email" value="{{ smtp.from_email }}">
</label>
<label>Antwort-Adresse (Reply-To)
<input type="email" name="reply_to" value="{{ smtp.reply_to }}" placeholder="wiki-team@firma.de">
</label>
</div>
<label class="checkbox"> <label class="checkbox">
<input type="checkbox" name="use_tls" value="true" {% if smtp.use_tls == "true" %}checked{% endif %}> <input type="checkbox" name="use_tls" value="true" {% if smtp.use_tls == "true" %}checked{% endif %}>
STARTTLS verwenden STARTTLS verwenden
@@ -280,83 +263,15 @@
<label>Verteilerliste (kommagetrennt) <label>Verteilerliste (kommagetrennt)
<textarea rows="3" name="recipients" placeholder="kollege@firma.de, team@firma.de">{{ smtp.recipients }}</textarea> <textarea rows="3" name="recipients" placeholder="kollege@firma.de, team@firma.de">{{ smtp.recipients }}</textarea>
</label> </label>
<label>Geplanter täglicher Versand (HH:MM)
<fieldset class="schedule-box"> <input type="time" name="schedule_time" value="{{ smtp.schedule_time }}">
<legend>Geplanter Versand</legend> </label>
<label class="checkbox">
<input type="checkbox" name="schedule_enabled" value="true" {% if smtp.schedule_enabled == "true" %}checked{% endif %}>
Automatischen Versand aktivieren
</label>
<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">
<label>Häufigkeit
<select name="schedule_frequency">
<option value="daily" {% if smtp.schedule_frequency == 'daily' %}selected{% endif %}>Täglich</option>
<option value="weekly" {% if smtp.schedule_frequency == 'weekly' %}selected{% endif %}>Wöchentlich</option>
<option value="monthly" {% if smtp.schedule_frequency == 'monthly' %}selected{% endif %}>Monatlich</option>
</select>
</label>
<label>Uhrzeit (HH:MM)
<input type="time" name="schedule_time" value="{{ smtp.schedule_time }}">
</label>
</div>
{% set active_days = smtp.schedule_weekdays.split(',') %}
<label>Wochentage (nur bei „Wöchentlich“)</label>
<div class="weekday-row">
{% for value, label in [('0','Mo'), ('1','Di'), ('2','Mi'), ('3','Do'), ('4','Fr'), ('5','Sa'), ('6','So')] %}
<label class="weekday-chip">
<input type="checkbox" name="schedule_weekdays" value="{{ value }}" {% if value in active_days %}checked{% endif %}>
{{ label }}
</label>
{% endfor %}
</div>
<label>Tag im Monat (nur bei „Monatlich“, 128)
<input type="number" name="schedule_dom" min="1" max="28" value="{{ smtp.schedule_dom }}">
</label>
<p class="hint" style="margin:0.5rem 0 0.25rem;">Inhalt des geplanten Newsletters:</p>
<div class="form-row">
<label>Zeitraum
<select name="gen_period">
<option value="days" {% if smtp.gen_period != 'last_month' %}selected{% endif %}>Letzte X Tage</option>
<option value="last_month" {% if smtp.gen_period == 'last_month' %}selected{% endif %}>Letzter Monat</option>
</select>
</label>
<label>Tage (bei „Letzte X Tage“)
<input type="number" name="gen_days" min="1" max="90" value="{{ smtp.gen_days }}">
</label>
</div>
<div class="form-row">
<label>Artikel-Auswahl
<select name="gen_article_type">
<option value="all" {% if smtp.gen_article_type == 'all' %}selected{% endif %}>Alle</option>
<option value="new" {% if smtp.gen_article_type == 'new' %}selected{% endif %}>Nur neue</option>
<option value="edited" {% if smtp.gen_article_type == 'edited' %}selected{% endif %}>Nur bearbeitete</option>
</select>
</label>
<label>Kategorie (optional)
<input type="text" name="gen_category" value="{{ smtp.gen_category }}">
</label>
</div>
<label>Bearbeiter ausschließen (optional)
<input type="text" name="gen_excluded_users" value="{{ smtp.gen_excluded_users }}" placeholder="z. B. Aranzinger, Testuser">
</label>
<p class="hint" style="margin-top:-0.2rem;">Diese Benutzer werden bei „Bearbeitete Artikel“ ignoriert (gilt für den geplanten Versand).</p>
</fieldset>
<button type="submit">Einstellungen speichern</button> <button type="submit">Einstellungen speichern</button>
</form> </form>
</div> </div>
</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>
@@ -383,7 +298,6 @@
</table> </table>
</div> </div>
</section> </section>
{% endif %}
</div> </div>

View File

@@ -1,74 +0,0 @@
{% extends "base.html" %}
{% block title %}Mein Profil{% endblock %}
{% block content %}
<div class="page-header">
<h2>Mein Profil</h2>
<p class="page-lead"><a href="/dashboard">← Zurück zum Dashboard</a></p>
</div>
{% if success_message %}
<p class="hint" style="color:var(--tk-success);font-weight:600;">{{ success_message }}</p>
{% endif %}
<div class="layout-2col">
<div class="main-col">
<section class="card card-accent-orange">
<div class="card-header">
<h3>Profil bearbeiten</h3>
</div>
{% if error_message %}
<p class="error">{{ error_message }}</p>
{% endif %}
<form method="post" action="/profil" class="form-grid">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div class="form-row">
<label>Vollständiger Name
<input type="text" name="full_name" required minlength="2" value="{{ user.full_name }}">
</label>
<label>E-Mail
<input type="email" name="email" required value="{{ user.email }}">
</label>
</div>
<button type="submit" class="btn-primary">Profil speichern</button>
</form>
</section>
<section class="card">
<div class="card-header">
<h3>Passwort ändern</h3>
</div>
{% if pw_error_message %}
<p class="error">{{ pw_error_message }}</p>
{% endif %}
<form method="post" action="/profil/passwort" class="form-grid">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label>Aktuelles Passwort
<input type="password" name="current_password" required autocomplete="current-password">
</label>
<div class="form-row">
<label>Neues Passwort
<input type="password" name="new_password" required minlength="10" autocomplete="new-password" placeholder="Mind. 10 Zeichen, Groß/Klein/Ziffer">
</label>
<label>Neues Passwort bestätigen
<input type="password" name="confirm_password" required minlength="10" autocomplete="new-password">
</label>
</div>
<button type="submit" class="btn-primary">Passwort ändern</button>
</form>
</section>
</div>
<aside class="sidebar-col">
<div class="sidebar-card">
<h4>Konto</h4>
<ul>
<li><strong>Rolle:</strong> {{ user.role }}</li>
<li><strong>Status:</strong> {{ "Aktiv" if user.is_active else "Inaktiv" }}</li>
</ul>
<p class="hint">Die Rolle kann nur ein Administrator ändern.</p>
</div>
</aside>
</div>
{% endblock %}

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="Mind. 10 Zeichen, Groß/Klein/Ziffer"> <input type="password" name="password" required minlength="10" placeholder="Mindestens 10 Zeichen">
</label> </label>
<label>Rolle <label>Rolle
<select name="role"> <select name="role">
@@ -52,40 +52,15 @@
<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><th>Aktionen</th></tr> <tr><th>E-Mail</th><th>Name</th><th>Rolle</th><th>Status</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> <td><span class="badge badge-{{ u.role }}">{{ u.role }}</span></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>
@@ -103,12 +78,6 @@
<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,11 +9,6 @@ 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,13 +1,12 @@
fastapi==0.115.6 fastapi
uvicorn[standard]==0.32.1 uvicorn[standard]
jinja2==3.1.4 jinja2
sqlalchemy==2.0.36 sqlalchemy
alembic==1.14.0 alembic
python-multipart==0.0.20 python-multipart
httpx==0.28.1 httpx
bcrypt==4.2.1 bcrypt
python-jose[cryptography]==3.3.0 python-jose[cryptography]
pydantic-settings==2.6.1 pydantic-settings
email-validator==2.2.0 email-validator
psycopg[binary]==3.2.13 psycopg[binary]
cryptography==44.0.0