30 Commits

Author SHA1 Message Date
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
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
42cef23957 Merge pull request 'Add 'from_name' field to SMTP settings and update email sending logic' (#7) from dev into main
Reviewed-on: #7
2026-07-07 14:00:40 +00:00
smueller
f2cfe8de3e Add 'from_name' field to SMTP settings and update email sending logic
- Introduced a new 'from_name' field in the SMTP settings to allow users to specify a sender name.
- Updated the dashboard template to include an input for the sender name.
- Modified the email sending logic to format the 'From' header with the sender name if provided, enhancing email presentation.
2026-07-07 16:00:22 +02:00
32f10c3197 Merge pull request 'dev' (#6) from dev into main
Reviewed-on: #6
2026-07-07 13:45:25 +00:00
smueller
0e37dac816 Update dashboard to handle newsletter send status and redirect accordingly
- Added logic to retrieve and display the send status and message on the dashboard.
- Modified the send_now function to redirect to the dashboard with query parameters for status and message.
- Removed redundant context updates in the send_now function for cleaner code.
2026-07-07 15:44:37 +02:00
smueller
3d0f5e4f04 Enhance newsletter functionality with contact email support
- Added support for a contact email in newsletter generation, allowing users to specify a reply-to address.
- Updated the SMTP settings to include a reply-to field in the configuration.
- Modified the newsletter templates to display the contact email in plain text and HTML formats.
- Improved the dashboard UI to allow users to set the reply-to address when configuring SMTP settings.
2026-07-07 15:42:12 +02:00
2c04c70e78 Merge pull request 'dev' (#5) from dev into main
Reviewed-on: #5
2026-07-07 13:37:15 +00:00
smueller
9a0871381a Implement newsletter scheduling features
- Added a background scheduler to manage newsletter dispatch based on user-defined settings.
- Enhanced the SMTP settings to include scheduling options such as frequency, time, and specific weekdays.
- Updated the dashboard UI to allow users to configure scheduling preferences for automated newsletter sending.
- Modified the newsletter generation logic to accommodate new scheduling parameters and ensure proper content delivery.
2026-07-07 15:36:48 +02:00
smueller
6b0402103a Add profile management features
- Introduced profile update and password change functionalities in main.py.
- Added new ProfileUpdate and PasswordChange schemas in user.py.
- Updated base.html to include a profile link in the navigation and modified user chip for better accessibility.
- Enhanced CSS for user chip links to improve UI interaction.
2026-07-07 15:26:29 +02:00
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
smueller
9c75357150 Update login form to remove email placeholder
- Removed the placeholder text from the email input field in the login form for a cleaner user interface.
2026-07-07 15:17:55 +02:00
d3c6994d75 Merge pull request 'main' (#4) from main into dev
Reviewed-on: #4
2026-07-07 13:14:36 +00:00
26 changed files with 1252 additions and 179 deletions

View File

@@ -1,20 +1,33 @@
APP_NAME=TK Wiki Newsletter Admin
ENVIRONMENT=production
SECRET_KEY=PLEASE_CHANGE_TO_A_LONG_RANDOM_SECRET
# development = lokale Entwicklung ohne harte Produktions-Checks
# 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
ACCESS_TOKEN_EXPIRE_MINUTES=120
JWT_ISSUER=tk-wiki-newsletter-admin
JWT_AUDIENCE=tk-wiki-newsletter-admin
HOST_PORT=8080
DATABASE_URL=sqlite:///./data/newsletter.db
# Beispiel PostgreSQL:
# DATABASE_URL=postgresql+psycopg://newsletter:newsletter@postgres:5432/newsletter
WIKI_API_URL=https://www.thomas-krenn.com/de/wikiDE/api.php
ALLOWED_HOSTS=*
ALLOWED_HOSTS=localhost,127.0.0.1
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_PASSWORD=ChangeMe123!
# true = setzt Passwort/Rolle/Status des Bootstrap-Admins bei jedem Start
# auf die obigen Werte zurueck (auch wenn der User schon existiert).
# Nach erfolgreichem Login wieder auf false setzen, damit UI-Passwortaenderungen
# nicht beim naechsten Neustart ueberschrieben werden.
# In production nicht erlaubt. Nach Erst-Setup auf false lassen.
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
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
uses: docker/setup-buildx-action@v3

133
README.md
View File

@@ -21,11 +21,15 @@ Produktionsnahes internes Tool zur Erstellung von Thomas-Krenn.AG Newslettern au
- Plain Text (für Outlook)
- Raw HTML
- Optionaler SMTP-Versand:
- UI-konfigurierbar
- Verteilerlisten
- tägliche Zeitplanung
- Versandprotokoll
- Docker/Compose Betrieb, inkl. automatischem Image-Build & Push in die Gitea Container Registry (per Versions-Tag)
- UI-konfigurierbar (nur für Admins)
- Verteilerlisten mit E-Mail-Validierung
- 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)
- **„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
@@ -35,11 +39,17 @@ Produktionsnahes internes Tool zur Erstellung von Thomas-Krenn.AG Newslettern au
cp .env.example .env
```
2. Wichtige Werte in `.env` setzen:
- `HOST_PORT` (z. B. `8080`, falls `8000` bereits belegt ist)
- `SECRET_KEY`
- `ADMIN_BOOTSTRAP_EMAIL`
- `ADMIN_BOOTSTRAP_PASSWORD`
2. Wichtige Werte in `.env` setzen (siehe auch [Konfiguration](#konfiguration-env)):
| Variable | Lokal (Entwicklung) | Produktion |
|----------|---------------------|------------|
| `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:
@@ -50,6 +60,30 @@ Produktionsnahes internes Tool zur Erstellung von Thomas-Krenn.AG Newslettern au
4. Zugriff:
- [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)
Fertige Images werden automatisch in die Gitea Container Registry veröffentlicht:
@@ -69,6 +103,9 @@ docker run -d --name tk-newsletter-admin \
--env-file .env \
-p 8080:8000 \
-v newsletter_data:/app/data \
--read-only \
--tmpfs /tmp \
--security-opt no-new-privileges:true \
git.hexahost.dev/smueller/tk-wiki-newsletter:latest
```
@@ -81,11 +118,14 @@ services:
# 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)
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`).
- **Vor dem Build**: `pip-audit` prüft die Python-Abhängigkeiten aus `requirements.txt`.
- **Release-Ablauf** (Build bei Merge `dev` → `main` mit Version):
```bash
@@ -104,25 +144,74 @@ Der Workflow `.gitea/workflows/docker-build.yml` baut und pusht das Image über
## 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.
- **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.
- 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
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
- 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.
- **Passwort ändern** (Profil) oder **Abmelden** beendet alle aktiven Sessions des Benutzers.
## Rollenmodell
- `reader`: darf Ergebnisse sehen
- `editor`: darf Newsletter generieren und sofort versenden
- `admin`: zusätzlich Benutzerverwaltung und SMTP-Konfiguration
| Rolle | Rechte |
|-------|--------|
| `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
class Settings(BaseSettings):
app_name: str = "TK Wiki Newsletter Admin"
environment: str = "production"
environment: str = "development"
secret_key: str = "change-me-in-production"
algorithm: str = "HS256"
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"
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
hsts_max_age: int = 31536000
admin_bootstrap_email: str = "admin@internal.local"
admin_bootstrap_password: str = "ChangeMe123!"
# Wenn true: setzt Passwort/Rolle/Status des Bootstrap-Admins beim Start
# auf die .env-Werte zurück (auch wenn der User bereits existiert).
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()

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"))
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)
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)
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

@@ -1,4 +1,8 @@
import asyncio
import logging
from datetime import datetime
from pathlib import Path
from urllib.parse import urlencode
from pydantic import ValidationError
@@ -12,13 +16,17 @@ from sqlalchemy.orm import Session
from app.core.config import settings
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.startup_checks import validate_startup_config
from app.db.session import Base, engine, get_db
from app.models.system import SendLog
from app.models.user import User
from app.schemas.user import UserCreate
from app.services.auth import get_admin_user, get_current_user, get_optional_user, require_editor_or_admin
from app.services.config_store import get_config, set_config
from app.schemas.user import PasswordChange, ProfileUpdate, UserCreate, UserRoleUpdate
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, 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.newsletter import (
DEFAULT_DISPLAY,
@@ -28,15 +36,26 @@ from app.services.newsletter import (
create_plain_text,
create_subject,
filter_articles,
parse_excluded_users,
parse_display_options,
parse_highlights,
resolve_period,
split_articles_by_type,
)
from app.services.smtp_sender import get_smtp_settings, run_scheduled_send_if_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
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()]
if allowed_hosts:
app.add_middleware(TrustedHostMiddleware, allowed_hosts=allowed_hosts)
@@ -45,6 +64,10 @@ templates = Jinja2Templates(directory="app/templates")
templates.env.globals["wiki_article_url"] = article_url
wiki_service = WikiService()
logger = logging.getLogger("newsletter.scheduler")
SCHEDULER_INTERVAL_SECONDS = 60
_scheduler_task: asyncio.Task | None = None
@app.on_event("startup")
def on_startup() -> None:
@@ -52,6 +75,66 @@ def on_startup() -> None:
Base.metadata.create_all(bind=engine)
_ensure_schema_upgrades()
_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:
@@ -62,6 +145,8 @@ def _ensure_schema_upgrades() -> None:
if "role" not in cols:
conn.execute(text("ALTER TABLE users ADD COLUMN role VARCHAR(32) NOT NULL DEFAULT 'reader'"))
conn.execute(text("UPDATE users SET role = CASE WHEN is_admin = 1 THEN 'admin' ELSE 'reader' END"))
if "session_version" not in cols:
conn.execute(text("ALTER TABLE users ADD COLUMN session_version INTEGER NOT NULL DEFAULT 0"))
@app.middleware("http")
@@ -75,12 +160,28 @@ async def add_security_headers(request: Request, call_next):
response.headers["Referrer-Policy"] = "same-origin"
# 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.
response.headers["Content-Security-Policy"] = (
"default-src 'self'; "
"img-src 'self' https: data:; "
"style-src 'self' 'unsafe-inline'; "
"script-src 'self';"
)
path = request.url.path
if path.startswith("/dashboard"):
csp = (
"default-src 'self'; "
"img-src 'self' https: data:; "
"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:
response.set_cookie(
CSRF_COOKIE_NAME,
@@ -118,16 +219,30 @@ def _bootstrap_admin() -> None:
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:
path = request.url.path
active_nav = "dashboard"
if path.startswith("/admin/users"):
active_nav = "users"
elif path.startswith("/profil"):
active_nav = "profile"
return {
"user": user,
"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,
"editor_tip_max_length": settings.editor_tip_max_length,
"highlights_max_length": settings.highlights_max_length,
}
@@ -189,8 +304,20 @@ def login(
"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()
if not user or not verify_password(password, user.password_hash):
record_failed_login(request, email)
return _render(
request,
"login.html",
@@ -199,15 +326,31 @@ def login(
"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.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
@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)
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.delete_cookie("access_token")
return response
@@ -216,9 +359,10 @@ def logout(request: Request, csrf_token: str = Form(...)):
def _empty_filters() -> dict:
return {
"days": 30,
"period": "days",
"article_type": "all",
"period": "last_month",
"article_type": "new",
"category": "",
"excluded_users": settings.default_excluded_users,
"display": DEFAULT_DISPLAY,
"editor_tip": "",
"highlights": "",
@@ -234,6 +378,8 @@ async def _generate_newsletter(
display,
editor_tip: str,
highlight_list: list[str],
contact_email: str = "",
excluded_users_raw: str = "",
) -> dict:
p = resolve_period(period_mode, days)
result = {
@@ -248,21 +394,28 @@ async def _generate_newsletter(
"subject": "",
}
try:
excluded_users = parse_excluded_users(excluded_users_raw)
articles = await wiki_service.get_recent_changes(
days=p["days"], article_type=article_type, start=p["start"], end=p["end"]
)
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"])
result.update(
{
"articles": filtered,
"articles_new": new_articles,
"articles_edited": edited_articles,
"subject": create_subject(p["days"], category, p["label"]),
"plain_text": create_plain_text(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),
"outlook_html": create_outlook_html(filtered, p["days"], display, category, editor_tip, highlight_list, p["label"], prange, article_type),
"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, excluded_users
),
"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:
@@ -273,6 +426,10 @@ async def _generate_newsletter(
@app.get("/dashboard", response_class=HTMLResponse)
async def dashboard(request: Request, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
context = _base_context(request, current_user, db)
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({
"articles": [],
"articles_new": [],
@@ -283,7 +440,7 @@ async def dashboard(request: Request, db: Session = Depends(get_db), current_use
"subject": "",
"filters": _empty_filters(),
"wiki_error": None,
"send_result": None,
"send_result": send_result,
})
logs = db.query(SendLog).order_by(SendLog.created_at.desc()).limit(20).all()
context["send_logs"] = logs
@@ -298,6 +455,7 @@ async def dashboard_generate(
days: int = Form(30),
article_type: str = Form("all"),
category: str = Form(""),
excluded_users: str = Form(""),
show_date: str | None = Form(None),
show_user: str | None = Form(None),
show_category: str | None = Form(None),
@@ -307,11 +465,17 @@ async def dashboard_generate(
current_user: User = Depends(require_editor_or_admin),
):
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))
if article_type not in ("all", "new", "edited"):
article_type = "all"
display = parse_display_options(show_date, show_user, show_category)
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(
period_mode=period,
@@ -321,9 +485,9 @@ async def dashboard_generate(
display=display,
editor_tip=editor_tip,
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.update(
@@ -340,6 +504,7 @@ async def dashboard_generate(
"period": period,
"article_type": article_type,
"category": category,
"excluded_users": excluded_users,
"display": display,
"editor_tip": editor_tip,
"highlights": highlights,
@@ -360,6 +525,7 @@ async def send_now(
days: int = Form(30),
article_type: str = Form("all"),
category: str = Form(""),
excluded_users: str = Form(""),
show_date: str | None = Form(None),
show_user: str | None = Form(None),
show_category: str | None = Form(None),
@@ -369,11 +535,17 @@ async def send_now(
current_user: User = Depends(require_editor_or_admin),
):
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)
if article_type not in ("all", "new", "edited"):
article_type = "all"
highlight_list = parse_highlights(highlights)
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(
period_mode=period,
@@ -383,6 +555,8 @@ async def send_now(
display=display,
editor_tip=editor_tip,
highlight_list=highlight_list,
contact_email=contact_email,
excluded_users_raw=excluded_users,
)
send_result: dict[str, str]
@@ -391,35 +565,16 @@ async def send_now(
elif not gen["articles"]:
send_result = {"status": "failed", "message": "Versand abgebrochen im Zeitraum wurden keine Artikel gefunden."}
else:
recipients = [r.strip() for r in get_config(db, "smtp.recipients", "").split(",") if r.strip()]
status_code, detail = send_newsletter(db, gen["subject"], gen["raw_html"], gen["plain_text"], recipients, scheduled=False)
send_result = {"status": status_code, "message": detail}
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)
send_result = {"status": status_code, "message": detail}
context = _base_context(request, current_user, db)
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)
query = urlencode({"send_status": send_result["status"], "send_msg": send_result["message"]})
return RedirectResponse(url=f"/dashboard?{query}", status_code=status.HTTP_303_SEE_OTHER)
@app.post("/admin/smtp")
@@ -432,9 +587,21 @@ def update_smtp_settings(
username: str = Form(""),
password: str = Form(""),
from_email: str = Form(""),
from_name: str = Form(""),
reply_to: str = Form(""),
use_tls: str | None = Form(None),
recipients: str = Form(""),
schedule_enabled: str | None = Form(None),
schedule_frequency: str = Form("monthly"),
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),
admin: User = Depends(get_admin_user),
):
@@ -443,11 +610,45 @@ def update_smtp_settings(
set_config(db, "smtp.host", host.strip())
set_config(db, "smtp.port", port.strip() or "587")
set_config(db, "smtp.username", username.strip())
set_config(db, "smtp.password", password)
if password.strip():
set_config(db, "smtp.password", password.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.recipients", recipients.strip())
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.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")
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)
@@ -533,3 +734,155 @@ def create_user(
db.add(user)
db.commit()
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,4 +13,5 @@ class User(Base):
is_admin = Column(Boolean, default=False, nullable=False)
role = Column(String(32), default="reader", 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)

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):
@@ -7,6 +9,31 @@ class UserCreate(BaseModel):
password: str = Field(min_length=10, max_length=255)
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):
id: int

View File

@@ -1,42 +1,45 @@
from fastapi import Depends, HTTPException, Request, status
from jose import JWTError, jwt
from jose import JWTError
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.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:
token = request.cookies.get("access_token")
if not token:
return None
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()
return _user_from_token(token, db)
def get_current_user(request: Request, db: Session = Depends(get_db)) -> User:
token = request.cookies.get("access_token")
if not token:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Nicht angemeldet.")
try:
payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm])
user_id = int(payload.get("sub"))
except (JWTError, TypeError, ValueError) as exc:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Ungültiger Token.") from exc
user = db.query(User).filter(User.id == user_id, User.is_active.is_(True)).first()
user = _user_from_token(token, db)
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
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.")
return current_user
@@ -47,7 +50,5 @@ def require_editor_or_admin(current_user: User = Depends(get_current_user)) -> U
return current_user
def require_reader_or_higher(current_user: User = Depends(get_current_user)) -> User:
if current_user.role not in {"admin", "editor", "reader"}:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin-Rechte erforderlich.")
return current_user
def can_view_sensitive_logs(user: User) -> bool:
return user.role in {"admin", "editor"}

View File

@@ -1,17 +1,29 @@
from sqlalchemy.orm import Session
from app.core.secrets_crypto import decrypt_secret, encrypt_secret
from app.models.system import AppConfig
SECRET_CONFIG_KEYS = frozenset({"smtp.password"})
def get_config(db: Session, key: str, default: str = "") -> str:
row = db.query(AppConfig).filter(AppConfig.key == key).first()
return row.value if row else default
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:
stored = encrypt_secret(value) if key in SECRET_CONFIG_KEYS else value
row = db.query(AppConfig).filter(AppConfig.key == key).first()
if row:
row.value = value
row.value = stored
else:
db.add(AppConfig(key=key, value=value))
db.add(AppConfig(key=key, value=stored))
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)
new_articles: list[dict[str, Any]] = []
edited_articles: list[dict[str, Any]] = []
new_titles: set[str] = set()
excluded_edited_users = excluded_edited_users or set()
for item in sorted_articles:
title = item.get("title", "")
@@ -93,20 +103,20 @@ def split_articles_by_type(articles: list[dict[str, Any]]) -> tuple[list[dict[st
seen_edited: set[str] = set()
for item in sorted_articles:
title = item.get("title", "")
editor = str(item.get("user", "")).strip().lower()
if not title or title in new_titles or title in seen_edited:
continue
if editor in excluded_edited_users:
continue
edited_articles.append(item)
seen_edited.add(title)
return new_articles, edited_articles
def create_subject(days: int, category: str = "", period_label: str | None = None) -> str:
label = period_label or f"{days} Tage"
base = f"Interner Wiki-Newsletter {ORG_NAME} ({label})"
if category.strip():
return f"{base} | Kategorie: {category.strip()}"
return base
def create_subject(month_label: str | None = None, days: int = 30) -> str:
label = month_label or f"{days} Tage"
return f"Neue Wiki-Artikel Veröffentlichungen vom {label} | Wiki-Redaktion"
_MONTHS_DE = [
@@ -138,6 +148,7 @@ def resolve_period(mode: str, days: int) -> dict[str, Any]:
"start_str": start.strftime("%d.%m.%Y"),
"end_str": end.strftime("%d.%m.%Y"),
"label": _month_label(start),
"month_label": _month_label(start),
"days": max(1, (end - start).days),
}
days = max(1, min(days, 90))
@@ -150,6 +161,7 @@ def resolve_period(mode: str, days: int) -> dict[str, Any]:
"start_str": start.strftime("%d.%m.%Y"),
"end_str": end.strftime("%d.%m.%Y"),
"label": f"{days} Tage",
"month_label": _month_label(end),
"days": days,
}
@@ -204,9 +216,11 @@ def create_plain_text(
period_label: str | None = None,
period: tuple[str, str] | None = None,
article_type: str = "all",
contact_email: str = "",
excluded_edited_users: set[str] | None = None,
) -> str:
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_edited = article_type in ("all", "edited")
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)
if highlight_articles:
@@ -272,6 +287,15 @@ def create_plain_text(
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]:
text = (editor_tip or "").strip()
if not text:
@@ -296,14 +320,17 @@ def create_html(
period_label: str | None = None,
period: tuple[str, str] | None = None,
article_type: str = "all",
month_label: str | None = None,
contact_email: str = "",
excluded_edited_users: set[str] | None = None,
) -> str:
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_edited = article_type in ("all", "edited")
period_start, period_end = period if period else _period_range(days)
total = len(new_articles) + len(edited_articles)
subject = escape(create_subject(days, category, period_label))
subject = escape(create_subject(month_label or period_label, days))
preheader = escape(_summary_sentence(total))
period_label = escape(f"{period_start} {period_end}")
summary_text = escape(_summary_sentence(total))
@@ -334,6 +361,21 @@ def create_html(
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>
<html lang="de" xmlns="http://www.w3.org/1999/xhtml" xmlns:o="urn:schemas-microsoft-com:office:office">
<head>
@@ -396,6 +438,7 @@ def create_html(
</table>
</td>
</tr>
{contact_section}
{category_hint}
{highlights_section}
{body}
@@ -411,7 +454,6 @@ def create_html(
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr>
<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>
Euer Thomas-Krenn-Wiki Team
</td>
@@ -425,7 +467,7 @@ def create_html(
Thomas-Krenn.AG | Speltenbach-Steinäcker 1 | D-94078 Freyung<br>
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>
<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>
</td>
</tr>
@@ -678,15 +720,18 @@ def create_outlook_html(
period_label: str | None = None,
period: tuple[str, str] | None = None,
article_type: str = "all",
month_label: str | None = None,
contact_email: str = "",
excluded_edited_users: set[str] | None = None,
) -> str:
"""Word/Outlook-kompatibles HTML mit font-Tags und bgcolor (überlebt Einfügen in Outlook)."""
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_edited = article_type in ("all", "edited")
period_start, period_end = period if period else _period_range(days)
total = len(new_articles) + len(edited_articles)
subject = escape(create_subject(days, category, period_label))
subject = escape(create_subject(month_label or period_label, days))
period_label = f"{period_start} {period_end}"
summary_text = _summary_sentence(total)
@@ -725,6 +770,21 @@ def create_outlook_html(
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>
<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>
@@ -773,6 +833,7 @@ def create_outlook_html(
<td {_outlook_td_attrs(extra_style="padding:20px;")}>
<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>
{contact_block}
{category_block}
{highlights_rows}
<table width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="{OUTLOOK_WHITE}" style="background-color:{OUTLOOK_WHITE} !important;" data-ogsb="{OUTLOOK_WHITE}">
@@ -780,13 +841,13 @@ def create_outlook_html(
{summary_row}
{editor_tip_row}
<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 →")}
</td>
</tr>
<tr>
<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}", color=OUTLOOK_MUTED, size="1")}
<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} · Made with ❤️ by Samuel", color=OUTLOOK_MUTED, size="1")}
</td>
</tr>
</table>
@@ -931,7 +992,10 @@ def _plain_item(idx: int, item: dict[str, Any], display: DisplayOptions) -> list
return lines
def _plain_footer(new_articles: list[dict[str, Any]], edited_articles: list[dict[str, Any]]) -> str:
def _plain_footer(
new_articles: list[dict[str, Any]],
edited_articles: list[dict[str, Any]],
) -> str:
total = len(new_articles) + len(edited_articles)
return "\n".join(
[

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,14 +1,20 @@
import logging
import smtplib
from datetime import datetime
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email.utils import formataddr
from typing import Sequence
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.services.config_store import get_config
logger = logging.getLogger("newsletter.smtp")
def get_smtp_settings(db: Session) -> dict[str, str]:
return {
@@ -18,13 +24,61 @@ def get_smtp_settings(db: Session) -> dict[str, str]:
"username": get_config(db, "smtp.username", ""),
"password": get_config(db, "smtp.password", ""),
"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"),
"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_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", ""),
# 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(
db: Session,
subject: str,
@@ -43,12 +97,25 @@ def send_newsletter(
_log(db, subject, recipients, "failed", detail, scheduled)
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.attach(MIMEText(text_content, "plain", "utf-8"))
msg.attach(MIMEText(html_content, "html", "utf-8"))
msg["Subject"] = subject
msg["From"] = settings["from_email"]
msg["To"] = ", ".join(recipients)
if settings.get("from_name"):
msg["From"] = formataddr((settings["from_name"], settings["from_email"]))
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:
port = int(settings["port"])
@@ -57,32 +124,21 @@ def send_newsletter(
server.starttls()
if settings["username"]:
server.login(settings["username"], settings["password"])
server.sendmail(settings["from_email"], list(recipients), msg.as_string())
detail = f"Versand erfolgreich an {len(recipients)} Empfänger."
_log(db, subject, recipients, "sent", detail, scheduled)
server.sendmail(settings["from_email"], validated_recipients, msg.as_string())
detail = f"Versand erfolgreich an {len(validated_recipients)} Empfänger."
_log(db, subject, validated_recipients, "sent", detail, scheduled)
return "sent", detail
except Exception as exc:
detail = f"Versandfehler: {exc}"
_log(db, subject, recipients, "failed", detail, scheduled)
logger.exception("SMTP-Versand fehlgeschlagen")
detail = "Versandfehler: E-Mail konnte nicht zugestellt werden. Details stehen im Server-Log."
_log(db, subject, validated_recipients, "failed", detail, scheduled)
return "failed", detail
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)
def mark_scheduled_sent(db: Session, now: datetime) -> None:
from app.services.config_store import set_config
set_config(db, "smtp.last_scheduled_date", today)
set_config(db, "smtp.last_scheduled_date", now.strftime("%Y-%m-%d"))
def _log(db: Session, subject: str, recipients: Sequence[str], status: str, detail: str, scheduled: bool) -> None:

View File

@@ -115,6 +115,15 @@ a:hover { color: var(--tk-orange-dark); text-decoration: underline; }
color: #CCCCCC;
}
a.user-chip-link {
text-decoration: none;
cursor: pointer;
}
a.user-chip-link:hover {
color: #FFFFFF;
}
.topbar .btn-logout {
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.35);
@@ -698,6 +707,54 @@ body.login-page .container {
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 ── */
.site-footer {
text-align: center;

View File

@@ -4,7 +4,7 @@
<meta charset="utf-8">
<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>
<link rel="stylesheet" href="/static/css/main.css?v=4">
<link rel="stylesheet" href="/static/css/main.css?v=6">
</head>
<body class="{% block body_class %}{% endblock %}">
<header class="topbar">
@@ -22,11 +22,12 @@
{% if user.role == "admin" %}
<a href="/admin/users" class="{% if active_nav == 'users' %}active{% endif %}">Benutzer</a>
{% endif %}
<a href="/profil" class="{% if active_nav == 'profile' %}active{% endif %}">Profil</a>
</nav>
<div class="user-chip">
<a href="/profil" class="user-chip user-chip-link">
<span>{{ user.full_name }}</span>
<span class="badge badge-{{ user.role }}">{{ user.role }}</span>
</div>
</a>
<form method="post" action="/logout">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="btn-logout">Abmelden</button>

View File

@@ -49,7 +49,11 @@
<label>Kategorie (optional)
<input type="text" name="category" value="{{ filters.category }}" placeholder="z. B. Linux">
</label>
<label>Bearbeiter ausschließen (optional)
<input type="text" name="excluded_users" value="{{ filters.excluded_users }}" placeholder="z. B. Aranzinger, Testuser">
</label>
</div>
<p class="hint">Nur bei „Bearbeitete Artikel“ relevant: Benutzer in dieser Liste werden aus den bearbeiteten Artikeln entfernt.</p>
<label>Artikel-Auswahl
<select name="article_type">
<option value="all" {% if filters.article_type == 'all' %}selected{% endif %}>Alle (neue &amp; bearbeitete)</option>
@@ -73,11 +77,11 @@
</label>
</fieldset>
<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>
<p class="hint">Hervorgehobene Artikel erscheinen als „Top Highlights“-Box oben im Newsletter.</p>
<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>
<p class="hint">Wird nur angezeigt, wenn Text hinterlegt ist erscheint als eigene Box im Newsletter.</p>
{% 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;">
<strong>Betreff:</strong> {{ subject }}<br>
{% if can_view_sensitive %}
<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 %}
</p>
{% if user.role in ["admin", "editor"] %}
@@ -131,6 +139,7 @@
<input type="hidden" name="period" value="{{ filters.period }}">
<input type="hidden" name="days" value="{{ filters.days }}">
<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="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' }}">
@@ -250,12 +259,20 @@
<input type="text" name="username" value="{{ smtp.username }}">
</label>
<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>
</div>
<label>Absender E-Mail
<input type="email" name="from_email" value="{{ smtp.from_email }}">
<label>Absender-Name
<input type="text" name="from_name" value="{{ smtp.from_name }}" placeholder="Thomas-Krenn Wiki-Team">
</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">
<input type="checkbox" name="use_tls" value="true" {% if smtp.use_tls == "true" %}checked{% endif %}>
STARTTLS verwenden
@@ -263,15 +280,83 @@
<label>Verteilerliste (kommagetrennt)
<textarea rows="3" name="recipients" placeholder="kollege@firma.de, team@firma.de">{{ smtp.recipients }}</textarea>
</label>
<label>Geplanter täglicher Versand (HH:MM)
<input type="time" name="schedule_time" value="{{ smtp.schedule_time }}">
</label>
<fieldset class="schedule-box">
<legend>Geplanter Versand</legend>
<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>
</form>
</div>
</details>
{% endif %}
{% if can_view_sensitive %}
<section class="card">
<div class="card-header">
<h3>Versandprotokoll</h3>
@@ -298,6 +383,7 @@
</table>
</div>
</section>
{% endif %}
</div>

View File

@@ -11,7 +11,7 @@
<form method="post" action="/login" class="form-grid">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label>E-Mail
<input type="email" name="email" required autocomplete="username" placeholder="name@firma.de">
<input type="email" name="email" required autocomplete="username">
</label>
<label>Passwort
<input type="password" name="password" required autocomplete="current-password">

View File

@@ -0,0 +1,74 @@
{% 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 class="form-row">
<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>Rolle
<select name="role">
@@ -52,15 +52,40 @@
<div class="table-wrap">
<table>
<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>
<tbody>
{% for u in users %}
<tr>
<td>{{ u.email }}</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>
{% 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>
{% endfor %}
</tbody>
@@ -78,6 +103,12 @@
<li><strong>Admin</strong> Benutzer &amp; SMTP</li>
</ul>
</div>
<div class="sidebar-card">
<h4>Sicherheit</h4>
<ul>
<li>Rollen- oder Statusänderung beendet alle aktiven Sitzungen des Benutzers.</li>
</ul>
</div>
</aside>
</div>

View File

@@ -9,6 +9,11 @@ services:
volumes:
- newsletter_data:/app/data
restart: unless-stopped
security_opt:
- no-new-privileges:true
read_only: true
tmpfs:
- /tmp
healthcheck:
test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8000/login').read()\""]
interval: 30s

View File

@@ -1,12 +1,13 @@
fastapi
uvicorn[standard]
jinja2
sqlalchemy
alembic
python-multipart
httpx
bcrypt
python-jose[cryptography]
pydantic-settings
email-validator
psycopg[binary]
fastapi==0.115.6
uvicorn[standard]==0.32.1
jinja2==3.1.6
sqlalchemy==2.0.36
alembic==1.14.0
python-multipart==0.0.31
httpx==0.28.1
bcrypt==4.2.1
python-jose[cryptography]==3.4.0
pydantic-settings==2.6.1
email-validator==2.2.0
psycopg[binary]==3.2.13
cryptography==48.0.1