21 Commits

Author SHA1 Message Date
smueller
50ea436980 Merge branch 'dev' of ssh://git.hexahost.dev:8006/smueller/TK-Wiki-Newsletter into dev 2026-07-09 12:49:28 +02:00
smueller
555fd2c6ba Ignore pyasn1 CVE in pip-audit until python-jose supports pyasn1 0.6+.
All checks were successful
Docker Image bauen & veröffentlichen / build-and-push (push) Successful in 1m9s
python-jose 3.4.0 still pins pyasn1<0.5.0, so this transitive finding has no practical fix path yet.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 12:49:02 +02:00
smueller
070e99303a Update dependencies in requirements.txt and enhance Docker build workflow
- Updated jinja2, python-multipart, python-jose, and cryptography to their latest versions for improved security and functionality.
- Modified the Docker build workflow to include specific vulnerability ignores for pip-audit, ensuring smoother dependency checks while addressing known issues.
2026-07-09 12:46:34 +02:00
9bb799a785 Merge pull request 'main' (#14) from main into dev
Reviewed-on: #14
2026-07-09 08:59:59 +00:00
smueller
8a556b18ea Add field validation for configuration settings in Settings class
- Introduced a field validator to parse integer values with inline comments for access token expiration, HSTS max age, editor tip max length, and highlights max length.
- Enhanced the configuration management by ensuring cleaner input handling for specific settings.
2026-07-09 10:59:37 +02:00
smueller
1674efd587 Enhance Docker build workflow by adding virtual environment setup
- Introduced a Python virtual environment for dependency management.
- Updated pip installation commands to use the virtual environment context.
2026-07-09 10:55:25 +02:00
smueller
44d14d94de Merge dev into main for release
Some checks failed
Docker Image bauen & veröffentlichen / build-and-push (push) Failing after 12s
2026-07-09 10:53:12 +02:00
smueller
48410d39da Add editor exclusion defaults and newsletter filtering controls.
This makes it possible to exclude specific editors (e.g. Aranzinger) from edited-article results by default, including dashboard generation and scheduled SMTP runs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 10:53:01 +02:00
c0a44285e6 Merge pull request 'Update README.md with enhanced configuration details and security measures' (#13) from dev into main
Reviewed-on: #13
2026-07-07 14:46:14 +00:00
smueller
b8073f659d Update README.md with enhanced configuration details and security measures
- Clarified SMTP sending options, including BCC support and email validation for distribution lists.
- Updated instructions for setting environment variables in `.env`, emphasizing security requirements for production.
- Added notes on password policies, rate limiting, and user role management in the admin section.
- Enhanced Docker/Compose setup with security hardening features and clarified the automatic build process.
2026-07-07 16:45:51 +02:00
7c340fa172 Merge pull request 'Update .env.example with enhanced configuration details for development' (#12) from dev into main
Reviewed-on: #12
2026-07-07 14:42:42 +00:00
smueller
4999efdacb Update .env.example with enhanced configuration details for development
- Added instructions for generating a secure secret key with a minimum length requirement.
- Included new configuration options for admin bootstrap data and maximum lengths for editor tips and highlights.
2026-07-07 16:41:41 +02:00
3f339b3dae Merge pull request 'Refactor configuration and security features for improved development experience' (#11) from dev into main
Reviewed-on: #11
2026-07-07 14:31:48 +00:00
smueller
7788c74cfe Refactor configuration and security features for improved development experience
- Updated the .env.example file to reflect a development environment setup with enhanced secret key requirements and local host settings.
- Modified the Docker Compose configuration to enhance security with read-only settings and no-new-privileges options.
- Updated requirements.txt to pin package versions for better dependency management.
- Enhanced the FastAPI application to include dynamic OpenAPI and documentation URLs based on the environment.
- Implemented session versioning in JWT tokens to improve security and user session management.
- Added new validation for user roles and password strength in schemas.
- Improved email sending logic to handle recipient lists more robustly and added logging for SMTP operations.
- Updated dashboard and profile templates to reflect new features and improve user experience.
2026-07-07 16:31:10 +02:00
ca4063443d Merge pull request 'Enhance newsletter footer with creator attribution' (#10) from dev into main
Reviewed-on: #10
2026-07-07 14:12:47 +00:00
smueller
d2df1d2bed Enhance newsletter footer with creator attribution
- Updated the internal newsletter HTML templates to include a creator attribution in the footer.
- Added "Made with ❤️ by Samuel" to both the HTML and Outlook formats for a personalized touch.
2026-07-07 16:12:26 +02:00
64f26c596d Merge pull request 'Update newsletter HTML templates for improved styling and consistency' (#9) from dev into main
Reviewed-on: #9
2026-07-07 14:09:50 +00:00
smueller
f7ce3b65fe Update newsletter HTML templates for improved styling and consistency
- Adjusted padding in the Outlook HTML format for better layout and readability.
- Added a space in the footer message for enhanced clarity in the internal newsletter notice.
2026-07-07 16:09:33 +02:00
9b3bf4aa4e Merge pull request 'dev' (#8) from dev into main
Reviewed-on: #8
2026-07-07 14:05:50 +00:00
smueller
f67bec218e Refactor contact information presentation in newsletter templates
- Updated the plain text and HTML formats to enhance the phrasing of contact information.
- Adjusted styling and layout for better readability and consistency across different email clients.
- Improved the structure of the contact section in the Outlook HTML format for a cleaner appearance.
2026-07-07 16:05:23 +02:00
smueller
90972f1afa Refactor newsletter generation to improve contact information handling
- Added a new function to generate a plain text contact line based on the provided email.
- Updated the plain text and HTML newsletter templates to include the contact information dynamically.
- Removed direct contact email handling from the footer function to streamline the code and enhance readability.
2026-07-07 16:04:00 +02:00
24 changed files with 748 additions and 137 deletions

View File

@@ -1,20 +1,33 @@
APP_NAME=TK Wiki Newsletter Admin APP_NAME=TK Wiki Newsletter Admin
ENVIRONMENT=production # development = lokale Entwicklung ohne harte Produktions-Checks
SECRET_KEY=PLEASE_CHANGE_TO_A_LONG_RANDOM_SECRET # production = erzwingt starke Secrets, ALLOWED_HOSTS, COOKIE_SECURE=true
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=* ALLOWED_HOSTS=localhost,127.0.0.1
COOKIE_SECURE=false COOKIE_SECURE=false
# Auf true setzen, wenn hinter HTTPS/TLS-Terminierung (Reverse Proxy) # Auf true setzen, wenn hinter HTTPS/TLS-Terminierung (Reverse Proxy) Pflicht in production
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).
# Nach erfolgreichem Login wieder auf false setzen, damit UI-Passwortaenderungen # In production nicht erlaubt. Nach Erst-Setup auf false lassen.
# 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,6 +25,28 @@ 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 venv .venv
. .venv/bin/activate
python -m pip install --upgrade pip
python -m pip install pip-audit
# Hinweis:
# - Direkte Pakete werden auf gefixte Versionen gepinnt (requirements.txt).
# - Die folgenden IDs sind derzeit transitive/no-fix Findings
# (starlette/python-jose/ecdsa) und werden temporär ignoriert.
pip-audit -r requirements.txt \
--ignore-vuln PYSEC-2025-185 \
--ignore-vuln PYSEC-2026-1325 \
--ignore-vuln PYSEC-2026-161 \
--ignore-vuln PYSEC-2026-249 \
--ignore-vuln PYSEC-2026-248 \
--ignore-vuln PYSEC-2026-1942 \
--ignore-vuln PYSEC-2026-1941 \
--ignore-vuln CVE-2026-48818 \
--ignore-vuln CVE-2026-48817 \
--ignore-vuln CVE-2026-30922
- 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,13 +21,15 @@ 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 - UI-konfigurierbar (nur für Admins)
- Verteilerlisten - Verteilerlisten mit E-Mail-Validierung
- flexibler Zeitplan (täglich / wöchentlich an bestimmten Wochentagen / monatlich an einem Tag) über einen Hintergrund-Scheduler - Versand per **BCC** (Empfänger nicht im sichtbaren `To:`-Header)
- flexibler Zeitplan (täglich / wöchentlich / monatlich) über einen Hintergrund-Scheduler
- eigene Inhalts-Vorgaben für den geplanten Versand (Zeitraum, Artikel-Auswahl, Kategorie) - eigene Inhalts-Vorgaben für den geplanten Versand (Zeitraum, Artikel-Auswahl, Kategorie)
- manueller Versand nur per Button „Generieren“ verschickt nie automatisch - **„Generieren“ verschickt nie** manueller Versand nur per Button „Newsletter jetzt senden“
- Versandprotokoll - automatischer Versand nur mit **zwei** aktiven Häkchen: Zeitplan + Bestätigung „ohne manuelle Freigabe“
- Docker/Compose Betrieb, inkl. automatischem Image-Build & Push in die Gitea Container Registry (per Versions-Tag) - 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
@@ -37,11 +39,17 @@ 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: 2. Wichtige Werte in `.env` setzen (siehe auch [Konfiguration](#konfiguration-env)):
- `HOST_PORT` (z. B. `8080`, falls `8000` bereits belegt ist)
- `SECRET_KEY` | Variable | Lokal (Entwicklung) | Produktion |
- `ADMIN_BOOTSTRAP_EMAIL` |----------|---------------------|------------|
- `ADMIN_BOOTSTRAP_PASSWORD` | `ENVIRONMENT` | `development` | `production` |
| `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:
@@ -52,6 +60,30 @@ 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:
@@ -71,6 +103,9 @@ 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
``` ```
@@ -83,11 +118,14 @@ 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
@@ -106,25 +144,74 @@ 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: `ADMIN_BOOTSTRAP_RESET=true` setzen und Container neu starten: - Um Passwort/Rolle/Status auf die `.env`-Werte zurückzusetzen (**nur Entwicklung**): `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, damit UI-Passwortänderungen nicht bei jedem Neustart überschrieben werden. Nach erfolgreichem Login `ADMIN_BOOTSTRAP_RESET=false` setzen. In `ENVIRONMENT=production` ist `ADMIN_BOOTSTRAP_RESET=true` nicht erlaubt die App startet dann nicht.
## Sicherheits-Hinweise für Produktion - **Passwort ändern** (Profil) oder **Abmelden** beendet alle aktiven Sessions des Benutzers.
- 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
- `reader`: darf Ergebnisse sehen | Rolle | Rechte |
- `editor`: darf Newsletter generieren und sofort versenden |-------|--------|
- `admin`: zusätzlich Benutzerverwaltung und SMTP-Konfiguration | `reader` | Newsletter-Vorschau und Artikellisten ansehen |
| `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

@@ -1,23 +1,48 @@
from pydantic import field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict 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 = "production" environment: str = "development"
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 = "*" allowed_hosts: str = "localhost,127.0.0.1"
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") model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
@field_validator(
"access_token_expire_minutes",
"hsts_max_age",
"editor_tip_max_length",
"highlights_max_length",
mode="before",
)
@classmethod
def _parse_int_with_inline_comment(cls, value):
if isinstance(value, str):
cleaned = value.split("#", 1)[0].strip()
return int(cleaned) if cleaned else value
return value
@property
def is_production(self) -> bool:
return self.environment.lower() == "production"
settings = Settings() settings = Settings()

16
app/core/email_utils.py Normal file
View File

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

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

35
app/core/rate_limit.py Normal file
View File

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

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

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

@@ -16,13 +16,17 @@ 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 from app.schemas.user import PasswordChange, ProfileUpdate, UserCreate, UserRoleUpdate
from app.services.auth import get_admin_user, get_current_user, get_optional_user, require_editor_or_admin 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.config_store import get_config, set_config from app.services.config_store import get_config, has_secret_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,
@@ -32,6 +36,7 @@ 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,
@@ -40,7 +45,17 @@ from app.services.newsletter import (
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, mark_scheduled_sent, schedule_is_due, send_newsletter
from app.services.wiki import WikiFetchError, WikiService from app.services.wiki import WikiFetchError, WikiService
app = FastAPI(title=settings.app_name) validate_startup_config()
_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)
@@ -103,13 +118,19 @@ async def _run_scheduled_send_if_due() -> None:
editor_tip="", editor_tip="",
highlight_list=[], highlight_list=[],
contact_email=settings_snapshot.get("reply_to") or settings_snapshot.get("from_email", ""), 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"]: if gen["wiki_error"] or not gen["articles"]:
# Kein Versand ohne Inhalt; erneuter Versuch beim nächsten Intervall. # Kein Versand ohne Inhalt; erneuter Versuch beim nächsten Intervall.
logger.info("Geplanter Versand übersprungen (kein Inhalt/Wiki-Fehler).") logger.info("Geplanter Versand übersprungen (kein Inhalt/Wiki-Fehler).")
return return
recipients = [r.strip() for r in settings_snapshot.get("recipients", "").split(",") if r.strip()] 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) send_newsletter(db, gen["subject"], gen["raw_html"], gen["plain_text"], recipients, scheduled=True)
mark_scheduled_sent(db, now) mark_scheduled_sent(db, now)
finally: finally:
@@ -124,6 +145,8 @@ 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")
@@ -137,12 +160,28 @@ 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.
response.headers["Content-Security-Policy"] = ( path = request.url.path
if path.startswith("/dashboard"):
csp = (
"default-src 'self'; " "default-src 'self'; "
"img-src 'self' https: data:; " "img-src 'self' https: data:; "
"style-src 'self' 'unsafe-inline'; " "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';" "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,
@@ -180,6 +219,15 @@ 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"
@@ -190,8 +238,11 @@ def _base_context(request: Request, user: User, db: Session) -> dict:
return { return {
"user": user, "user": user,
"csrf_token": ensure_csrf_cookie(request), "csrf_token": ensure_csrf_cookie(request),
"smtp": get_smtp_settings(db), "smtp": _smtp_context(db, include_recipients=can_view_sensitive_logs(user)),
"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,
} }
@@ -253,8 +304,20 @@ 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",
@@ -263,15 +326,31 @@ def login(
"error_message": "Ungültige Zugangsdaten.", "error_message": "Ungültige Zugangsdaten.",
}, },
) )
token = create_access_token(user.id) clear_login_attempts(request, email)
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("access_token", token, httponly=True, secure=cookie_secure(request), samesite="strict") response.set_cookie(
"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(request: Request, csrf_token: str = Form(...)): def logout(
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
@@ -283,6 +362,7 @@ def _empty_filters() -> dict:
"period": "last_month", "period": "last_month",
"article_type": "new", "article_type": "new",
"category": "", "category": "",
"excluded_users": settings.default_excluded_users,
"display": DEFAULT_DISPLAY, "display": DEFAULT_DISPLAY,
"editor_tip": "", "editor_tip": "",
"highlights": "", "highlights": "",
@@ -299,6 +379,7 @@ async def _generate_newsletter(
editor_tip: str, editor_tip: str,
highlight_list: list[str], highlight_list: list[str],
contact_email: str = "", contact_email: str = "",
excluded_users_raw: str = "",
) -> dict: ) -> dict:
p = resolve_period(period_mode, days) p = resolve_period(period_mode, days)
result = { result = {
@@ -313,11 +394,12 @@ 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) new_articles, edited_articles = split_articles_by_type(filtered, excluded_edited_users=excluded_users)
prange = (p["start_str"], p["end_str"]) prange = (p["start_str"], p["end_str"])
result.update( result.update(
{ {
@@ -325,9 +407,15 @@ async def _generate_newsletter(
"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["month_label"], p["days"]),
"plain_text": create_plain_text(filtered, p["days"], display, category, editor_tip, highlight_list, p["label"], prange, article_type, contact_email), "plain_text": create_plain_text(
"raw_html": create_html(filtered, p["days"], display, category, editor_tip, highlight_list, p["label"], prange, article_type, p["month_label"], contact_email), filtered, p["days"], display, category, editor_tip, highlight_list, p["label"], prange, article_type, 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), ),
"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:
@@ -367,6 +455,7 @@ 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),
@@ -376,6 +465,10 @@ 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"
@@ -393,6 +486,7 @@ async def dashboard_generate(
editor_tip=editor_tip, editor_tip=editor_tip,
highlight_list=highlight_list, highlight_list=highlight_list,
contact_email=contact_email, contact_email=contact_email,
excluded_users_raw=excluded_users,
) )
context = _base_context(request, current_user, db) context = _base_context(request, current_user, db)
@@ -410,6 +504,7 @@ 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,
@@ -430,6 +525,7 @@ 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),
@@ -439,6 +535,10 @@ 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"
@@ -456,6 +556,7 @@ async def send_now(
editor_tip=editor_tip, editor_tip=editor_tip,
highlight_list=highlight_list, highlight_list=highlight_list,
contact_email=contact_email, contact_email=contact_email,
excluded_users_raw=excluded_users,
) )
send_result: dict[str, str] send_result: dict[str, str]
@@ -464,7 +565,11 @@ 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:
recipients = [r.strip() for r in get_config(db, "smtp.recipients", "").split(",") if r.strip()] try:
recipients = parse_recipient_list(get_config(db, "smtp.recipients", ""))
except ValueError as exc:
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) status_code, detail = send_newsletter(db, gen["subject"], gen["raw_html"], gen["plain_text"], recipients, scheduled=False)
send_result = {"status": status_code, "message": detail} send_result = {"status": status_code, "message": detail}
@@ -491,10 +596,12 @@ def update_smtp_settings(
schedule_time: str = Form("08:00"), schedule_time: str = Form("08:00"),
schedule_weekdays: list[str] = Form(default=[]), schedule_weekdays: list[str] = Form(default=[]),
schedule_dom: str = Form("1"), schedule_dom: str = Form("1"),
schedule_acknowledged: str | None = Form(None),
gen_period: str = Form("days"), gen_period: str = Form("days"),
gen_days: str = Form("30"), gen_days: str = Form("30"),
gen_article_type: str = Form("all"), gen_article_type: str = Form("all"),
gen_category: str = Form(""), 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),
): ):
@@ -503,14 +610,20 @@ 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())
set_config(db, "smtp.password", password) if password.strip():
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.from_name", from_name.strip())
set_config(db, "smtp.reply_to", reply_to.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:
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.recipients", recipients.strip())
set_config(db, "smtp.schedule_enabled", "true" if schedule_enabled == "true" else "false") 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"): if schedule_frequency not in ("daily", "weekly", "monthly"):
schedule_frequency = "monthly" schedule_frequency = "monthly"
set_config(db, "smtp.schedule_frequency", schedule_frequency) set_config(db, "smtp.schedule_frequency", schedule_frequency)
@@ -535,6 +648,7 @@ def update_smtp_settings(
gen_article_type = "all" gen_article_type = "all"
set_config(db, "smtp.gen_article_type", gen_article_type) set_config(db, "smtp.gen_article_type", gen_article_type)
set_config(db, "smtp.gen_category", gen_category.strip()) 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)
@@ -725,5 +839,50 @@ def change_password(
) )
current_user.password_hash = hash_password(payload.new_password) current_user.password_hash = hash_password(payload.new_password)
invalidate_user_sessions(current_user, db)
db.commit() db.commit()
return RedirectResponse(url="/profil?pw=1", status_code=status.HTTP_302_FOUND) 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,4 +13,5 @@ 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,4 +1,6 @@
from pydantic import BaseModel, EmailStr, Field from pydantic import BaseModel, EmailStr, Field, field_validator
from app.core.password_policy import validate_password_strength
class UserCreate(BaseModel): class UserCreate(BaseModel):
@@ -7,6 +9,12 @@ 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): class ProfileUpdate(BaseModel):
email: EmailStr email: EmailStr
@@ -16,6 +24,16 @@ class ProfileUpdate(BaseModel):
class PasswordChange(BaseModel): class PasswordChange(BaseModel):
new_password: str = Field(min_length=10, max_length=255) 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,42 +1,45 @@
from fastapi import Depends, HTTPException, Request, status from fastapi import Depends, HTTPException, Request, status
from jose import JWTError, jwt from jose import JWTError
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.config import settings from app.core.security import decode_access_token
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
try: return _user_from_token(token, db)
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.")
try: user = _user_from_token(token, db)
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="Benutzer nicht gefunden.") raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Ungültiger oder abgelaufener Token.")
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" and not current_user.is_admin: if current_user.role != "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
@@ -47,7 +50,5 @@ def require_editor_or_admin(current_user: User = Depends(get_current_user)) -> U
return current_user return current_user
def require_reader_or_higher(current_user: User = Depends(get_current_user)) -> User: def can_view_sensitive_logs(user: User) -> bool:
if current_user.role not in {"admin", "editor", "reader"}: return user.role in {"admin", "editor"}
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin-Rechte erforderlich.")
return current_user

View File

@@ -1,17 +1,29 @@
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()
return row.value if row else default if not row:
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 = value row.value = stored
else: else:
db.add(AppConfig(key=key, value=value)) db.add(AppConfig(key=key, value=stored))
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,11 +76,21 @@ def filter_articles(
] ]
def split_articles_by_type(articles: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: def parse_excluded_users(raw: str | None) -> set[str]:
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", "")
@@ -93,8 +103,11 @@ def split_articles_by_type(articles: list[dict[str, Any]]) -> tuple[list[dict[st
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)
@@ -204,9 +217,10 @@ def create_plain_text(
period: tuple[str, str] | None = None, period: tuple[str, str] | None = None,
article_type: str = "all", article_type: str = "all",
contact_email: str = "", 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) new_articles, edited_articles = split_articles_by_type(articles, excluded_edited_users=excluded_edited_users)
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)
@@ -231,6 +245,7 @@ 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:
@@ -251,7 +266,7 @@ def create_plain_text(
] ]
) )
lines.extend(_plain_editor_tip(editor_tip)) lines.extend(_plain_editor_tip(editor_tip))
lines.append(_plain_footer(new_articles, edited_articles, contact_email)) lines.append(_plain_footer(new_articles, edited_articles))
return "\n".join(lines).strip() return "\n".join(lines).strip()
if show_new: if show_new:
@@ -268,10 +283,19 @@ def create_plain_text(
) )
lines.append("") lines.append("")
lines.extend(_plain_editor_tip(editor_tip)) lines.extend(_plain_editor_tip(editor_tip))
lines.append(_plain_footer(new_articles, edited_articles, contact_email)) lines.append(_plain_footer(new_articles, edited_articles))
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:
@@ -298,9 +322,10 @@ def create_html(
article_type: str = "all", article_type: str = "all",
month_label: str | None = None, month_label: str | None = None,
contact_email: str = "", 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) new_articles, edited_articles = split_articles_by_type(articles, excluded_edited_users=excluded_edited_users)
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)
@@ -339,12 +364,18 @@ def create_html(
if contact_email.strip(): if contact_email.strip():
ce = escape(contact_email.strip()) ce = escape(contact_email.strip())
contact_html = ( contact_html = (
f'Fragen oder ein Themenwunsch? Meldet euch gern beim ' f'Fragen oder ein Themenwunsch? '
f'<a href="mailto:{ce}" style="color:{COLOR_TK_ORANGE};text-decoration:underline;">Wiki-Team</a>.' f'<a href="mailto:{ce}" style="color:{COLOR_TK_ORANGE};text-decoration:underline;">Meldet euch gern beim Wiki-Team</a>.'
) )
else: else:
contact_html = "Fragen oder ein Themenwunsch? Meldet euch gern beim Wiki-Team." 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>
@@ -407,6 +438,7 @@ def create_html(
</table> </table>
</td> </td>
</tr> </tr>
{contact_section}
{category_hint} {category_hint}
{highlights_section} {highlights_section}
{body} {body}
@@ -422,7 +454,6 @@ 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};">
{contact_html}<br><br>
Viele Grüße<br> Viele Grüße<br>
Euer Thomas-Krenn-Wiki Team Euer Thomas-Krenn-Wiki Team
</td> </td>
@@ -436,7 +467,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</span> <span style="color:#AEB2B7;">Automatisch erstellter interner Newsletter &middot; Nur für den internen Gebrauch &middot; Made with ❤️ by Samuel</span>
</div> </div>
</td> </td>
</tr> </tr>
@@ -691,10 +722,11 @@ def create_outlook_html(
article_type: str = "all", article_type: str = "all",
month_label: str | None = None, month_label: str | None = None,
contact_email: str = "", 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) new_articles, edited_articles = split_articles_by_type(articles, excluded_edited_users=excluded_edited_users)
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)
@@ -739,17 +771,19 @@ def create_outlook_html(
editor_tip_row = _outlook_editor_tip(editor_tip) editor_tip_row = _outlook_editor_tip(editor_tip)
if contact_email.strip(): if contact_email.strip():
contact_inner = ( contact_block = (
f'{_outlook_font("Fragen oder ein Themenwunsch? Meldet euch gern beim Wiki-Team: ", size="1")}' f'<p style="margin:12px 0 20px;">'
f'{_outlook_link(contact_email.strip(), f"mailto:{contact_email.strip()}", color=OUTLOOK_ORANGE, bold=False)}' 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>"
) )
contact_row = f"""<tr>
<td {_outlook_td_attrs(extra_style="padding:12px 0 0;")}>
{contact_inner}
</td>
</tr>"""
else: else:
contact_row = "" 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">
@@ -799,6 +833,7 @@ 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}">
@@ -806,14 +841,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 0;")}> <td {_outlook_td_attrs(extra_style="padding:20px 0 24px;")}>
{_outlook_cta(WIKI_HOME_URL, "Zum Thomas-Krenn-Wiki →")} {_outlook_cta(WIKI_HOME_URL, "Zum Thomas-Krenn-Wiki →")}
</td> </td>
</tr> </tr>
{contact_row}
<tr> <tr>
<td {_outlook_td_attrs(extra_style=f"padding:16px 0 0;border-top:1px solid {OUTLOOK_BORDER};")}> <td {_outlook_td_attrs(extra_style=f"padding:20px 0 0;border-top:1px solid {OUTLOOK_BORDER};")}>
{_outlook_font(f"Automatisch erstellter interner Newsletter · Nur für den Gebrauch bei {ORG_NAME}", color=OUTLOOK_MUTED, size="1")} {_outlook_font(f"Automatisch erstellter interner Newsletter · Nur für den Gebrauch bei {ORG_NAME} · Made with ❤️ by Samuel", color=OUTLOOK_MUTED, size="1")}
</td> </td>
</tr> </tr>
</table> </table>
@@ -961,12 +995,8 @@ def _plain_item(idx: int, item: dict[str, Any], display: DisplayOptions) -> list
def _plain_footer( def _plain_footer(
new_articles: list[dict[str, Any]], new_articles: list[dict[str, Any]],
edited_articles: list[dict[str, Any]], edited_articles: list[dict[str, Any]],
contact_email: str = "",
) -> str: ) -> str:
total = len(new_articles) + len(edited_articles) total = len(new_articles) + len(edited_articles)
contact_lines = []
if contact_email.strip():
contact_lines = [f"Fragen oder ein Themenwunsch? E-Mail an das Wiki-Team: {contact_email.strip()}", ""]
return "\n".join( return "\n".join(
[ [
SUBLINE, SUBLINE,
@@ -978,7 +1008,6 @@ def _plain_footer(
"", "",
f"Wiki: {WIKI_HOME_URL}", f"Wiki: {WIKI_HOME_URL}",
"", "",
*contact_lines,
SUBLINE, SUBLINE,
"Dieser Newsletter wurde automatisch erstellt und ist nur für den internen", "Dieser Newsletter wurde automatisch erstellt und ist nur für den internen",
f"Gebrauch bei {ORG_NAME} bestimmt.", f"Gebrauch bei {ORG_NAME} bestimmt.",

View File

@@ -0,0 +1,8 @@
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,3 +1,4 @@
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
@@ -7,9 +8,13 @@ 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 {
@@ -25,6 +30,7 @@ def get_smtp_settings(db: Session) -> dict[str, str]:
"recipients": get_config(db, "smtp.recipients", ""), "recipients": get_config(db, "smtp.recipients", ""),
# Zeitplan # Zeitplan
"schedule_enabled": get_config(db, "smtp.schedule_enabled", "false"), "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_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_weekdays": get_config(db, "smtp.schedule_weekdays", "0"),
@@ -35,6 +41,7 @@ def get_smtp_settings(db: Session) -> dict[str, str]:
"gen_days": get_config(db, "smtp.gen_days", "30"), "gen_days": get_config(db, "smtp.gen_days", "30"),
"gen_article_type": get_config(db, "smtp.gen_article_type", "new"), "gen_article_type": get_config(db, "smtp.gen_article_type", "new"),
"gen_category": get_config(db, "smtp.gen_category", ""), "gen_category": get_config(db, "smtp.gen_category", ""),
"gen_excluded_users": get_config(db, "smtp.gen_excluded_users", settings.default_excluded_users),
} }
@@ -44,6 +51,8 @@ def schedule_is_due(settings: dict[str, str], now: datetime) -> bool:
return False return False
if (settings.get("schedule_enabled") or "false").lower() != "true": if (settings.get("schedule_enabled") or "false").lower() != "true":
return False return False
if (settings.get("schedule_acknowledged") or "false").lower() != "true":
return False
freq = (settings.get("schedule_frequency") or "off").lower() freq = (settings.get("schedule_frequency") or "off").lower()
if freq == "off": if freq == "off":
return False return False
@@ -88,6 +97,13 @@ 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"))
@@ -96,7 +112,8 @@ def send_newsletter(
msg["From"] = formataddr((settings["from_name"], settings["from_email"])) msg["From"] = formataddr((settings["from_name"], settings["from_email"]))
else: else:
msg["From"] = settings["from_email"] msg["From"] = settings["from_email"]
msg["To"] = ", ".join(recipients) msg["To"] = settings["from_email"]
msg["Bcc"] = ", ".join(validated_recipients)
if settings.get("reply_to"): if settings.get("reply_to"):
msg["Reply-To"] = settings["reply_to"] msg["Reply-To"] = settings["reply_to"]
@@ -107,13 +124,14 @@ 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"], list(recipients), msg.as_string()) server.sendmail(settings["from_email"], validated_recipients, msg.as_string())
detail = f"Versand erfolgreich an {len(recipients)} Empfänger." detail = f"Versand erfolgreich an {len(validated_recipients)} Empfänger."
_log(db, subject, recipients, "sent", detail, scheduled) _log(db, subject, validated_recipients, "sent", detail, scheduled)
return "sent", detail return "sent", detail
except Exception as exc: except Exception as exc:
detail = f"Versandfehler: {exc}" logger.exception("SMTP-Versand fehlgeschlagen")
_log(db, subject, recipients, "failed", detail, scheduled) detail = "Versandfehler: E-Mail konnte nicht zugestellt werden. Details stehen im Server-Log."
_log(db, subject, validated_recipients, "failed", detail, scheduled)
return "failed", detail return "failed", detail

View File

@@ -740,6 +740,21 @@ body.login-page .container {
user-select: none; 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

@@ -49,7 +49,11 @@
<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>
@@ -73,11 +77,11 @@
</label> </label>
</fieldset> </fieldset>
<label>Top-Highlights (optional) <label>Top-Highlights (optional)
<textarea name="highlights" rows="3" placeholder="Ein Artikeltitel pro Zeile muss exakt einem gefundenen Artikel entsprechen (max. 3)">{{ filters.highlights }}</textarea> <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>
</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" placeholder="Optionaler Hinweis der Redaktion für diese Ausgabe…">{{ filters.editor_tip }}</textarea> <textarea name="editor_tip" rows="3" maxlength="{{ editor_tip_max_length }}" 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 %}
@@ -122,7 +126,11 @@
<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"] %}
@@ -131,6 +139,7 @@
<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' }}">
@@ -250,7 +259,7 @@
<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" value="{{ smtp.password }}"> <input type="password" name="password" autocomplete="new-password" placeholder="{% if smtp.password_configured == 'true' %}•••••••• (unverändert lassen){% else %}SMTP-Passwort{% endif %}">
</label> </label>
</div> </div>
<label>Absender-Name <label>Absender-Name
@@ -278,7 +287,11 @@
<input type="checkbox" name="schedule_enabled" value="true" {% if smtp.schedule_enabled == "true" %}checked{% endif %}> <input type="checkbox" name="schedule_enabled" value="true" {% if smtp.schedule_enabled == "true" %}checked{% endif %}>
Automatischen Versand aktivieren Automatischen Versand aktivieren
</label> </label>
<p class="hint" style="margin:0 0 0.6rem;">Ausschalten, wenn du vor dem Versenden noch Redaktionsnotiz oder Top-Highlights setzen möchtest dann manuell über „Newsletter jetzt senden“.</p> <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"> <div class="form-row">
<label>Häufigkeit <label>Häufigkeit
<select name="schedule_frequency"> <select name="schedule_frequency">
@@ -331,6 +344,10 @@
<input type="text" name="gen_category" value="{{ smtp.gen_category }}"> <input type="text" name="gen_category" value="{{ smtp.gen_category }}">
</label> </label>
</div> </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> </fieldset>
<button type="submit">Einstellungen speichern</button> <button type="submit">Einstellungen speichern</button>
@@ -339,6 +356,7 @@
</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>
@@ -365,6 +383,7 @@
</table> </table>
</div> </div>
</section> </section>
{% endif %}
</div> </div>

View File

@@ -48,7 +48,7 @@
</label> </label>
<div class="form-row"> <div class="form-row">
<label>Neues Passwort <label>Neues Passwort
<input type="password" name="new_password" required minlength="10" autocomplete="new-password" placeholder="Mindestens 10 Zeichen"> <input type="password" name="new_password" required minlength="10" autocomplete="new-password" placeholder="Mind. 10 Zeichen, Groß/Klein/Ziffer">
</label> </label>
<label>Neues Passwort bestätigen <label>Neues Passwort bestätigen
<input type="password" name="confirm_password" required minlength="10" autocomplete="new-password"> <input type="password" name="confirm_password" required minlength="10" autocomplete="new-password">

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