diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..1d03a51 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,57 @@ +{ + "name": "Serienbrief Dev", + "dockerComposeFile": [ + "../docker-compose.yml", + "../docker-compose.override.yml" + ], + "service": "web", + "workspaceFolder": "/app", + "remoteUser": "app", + "shutdownAction": "stopCompose", + + "customizations": { + "vscode": { + "extensions": [ + "ms-python.python", + "ms-python.vscode-pylance", + "ms-python.debugpy", + "charliermarsh.ruff", + "batisteo.vscode-django", + "ms-azuretools.vscode-docker", + "redhat.vscode-yaml", + "tamasfe.even-better-toml", + "eamodio.gitlens", + "njpwerner.autodocstring" + ], + "settings": { + "python.defaultInterpreterPath": "/usr/local/bin/python", + "python.terminal.activateEnvironment": false, + "python.testing.pytestEnabled": true, + "python.testing.pytestArgs": ["app"], + "[python]": { + "editor.formatOnSave": true, + "editor.defaultFormatter": "charliermarsh.ruff", + "editor.codeActionsOnSave": { + "source.organizeImports": "explicit", + "source.fixAll": "explicit" + } + }, + "files.exclude": { + "**/__pycache__": true, + "**/*.pyc": true + } + } + } + }, + + "forwardPorts": [8000, 5678, 5432, 6379, 8080], + "portsAttributes": { + "8000": { "label": "Django runserver" }, + "5678": { "label": "debugpy" }, + "8080": { "label": "App-Nginx" }, + "5432": { "label": "PostgreSQL" }, + "6379": { "label": "Redis" } + }, + + "postCreateCommand": "pip install --user -e . 2>/dev/null || true && python manage.py migrate --noinput || true" +} diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c6e34ed --- /dev/null +++ b/.env.example @@ -0,0 +1,46 @@ +# ============================================================================= +# Serienbrief – Environment +# Kopieren als .env. NICHT ins Git committen. +# ============================================================================= + +# --- Deployment ------------------------------------------------------------- +APP_VERSION=1.0.0 +APP_UID=10001 +APP_GID=10001 + +# Bind des internen Nginx-Sockets. Wird vom äußeren Reverse-Proxy angesprochen. +# - 127.0.0.1: nur lokal (z.B. wenn der äußere Proxy auf demselben Host läuft) +# - LAN-IP: wenn der äußere Proxy auf einem anderen Host steht +APP_BIND_IP=127.0.0.1 +APP_BIND_PORT=8080 + +# --- Django ----------------------------------------------------------------- +# openssl rand -base64 64 +DJANGO_SECRET_KEY=CHANGE_ME_LONG_RANDOM_STRING +DJANGO_DEBUG=False + +# Hosts, unter denen die App erreichbar ist (Domain des äußeren Proxys). +DJANGO_ALLOWED_HOSTS=serienbrief.lan,127.0.0.1,localhost + +# Trusted Origins für CSRF – HTTPS-URL des äußeren Proxys. +CSRF_TRUSTED_ORIGINS=https://serienbrief.lan + +# Wichtig hinter HTTPS-terminierendem Proxy: erlaubt Django, das Schema aus +# dem X-Forwarded-Proto-Header zu übernehmen. +USE_X_FORWARDED_HOST=True +SECURE_PROXY_SSL_HEADER=HTTP_X_FORWARDED_PROTO,https + +# --- PostgreSQL ------------------------------------------------------------- +POSTGRES_DB=serienbrief +POSTGRES_USER=serienbrief +# Passwort liegt in ./secrets/postgres_password.txt +DATABASE_URL=postgres://serienbrief:__FROM_SECRET__@db:5432/serienbrief + +# --- Redis ------------------------------------------------------------------ +# openssl rand -base64 32 +REDIS_PASSWORD=CHANGE_ME_REDIS_PASSWORD +CELERY_BROKER_URL=redis://:${REDIS_PASSWORD}@redis:6379/0 +CELERY_RESULT_BACKEND=redis://:${REDIS_PASSWORD}@redis:6379/1 + +# --- Retention -------------------------------------------------------------- +JOB_RETENTION_DAYS=30 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4fd059b --- /dev/null +++ b/.gitignore @@ -0,0 +1,39 @@ +# --- Secrets / Env --- +.env +.env.* +!.env.example +secrets/ +*.key +*.pem + +# --- Python --- +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +.pytest_cache/ +.ruff_cache/ +.mypy_cache/ +.coverage +htmlcov/ +.tox/ + +# --- Django --- +*.sqlite3 +staticfiles/ +media/ + +# --- Backups & Data --- +backups/ +postgres_data/ +redis_data/ + +# --- IDE --- +.idea/ +*.swp +*.swo + +# --- OS --- +.DS_Store +Thumbs.db diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..8fb2c50 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,14 @@ +{ + "recommendations": [ + "ms-python.python", + "ms-python.vscode-pylance", + "ms-python.debugpy", + "charliermarsh.ruff", + "batisteo.vscode-django", + "ms-azuretools.vscode-docker", + "ms-vscode-remote.remote-containers", + "redhat.vscode-yaml", + "tamasfe.even-better-toml", + "eamodio.gitlens" + ] +} diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..de50b70 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,53 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Django: attach (debugpy in Container)", + "type": "debugpy", + "request": "attach", + "connect": { "host": "127.0.0.1", "port": 5678 }, + "pathMappings": [ + { + "localRoot": "${workspaceFolder}/app", + "remoteRoot": "/app" + } + ], + "justMyCode": false, + "django": true + }, + { + "name": "Django: runserver (lokal, ohne Docker)", + "type": "debugpy", + "request": "launch", + "program": "${workspaceFolder}/app/manage.py", + "args": ["runserver", "127.0.0.1:8000"], + "django": true, + "justMyCode": false, + "env": { + "DJANGO_SETTINGS_MODULE": "config.settings.dev" + } + }, + { + "name": "Celery Worker (lokal, ohne Docker)", + "type": "debugpy", + "request": "launch", + "module": "celery", + "args": ["-A", "config", "worker", "--loglevel=debug", "--concurrency=1"], + "cwd": "${workspaceFolder}/app", + "justMyCode": false, + "env": { + "DJANGO_SETTINGS_MODULE": "config.settings.dev" + } + }, + { + "name": "Pytest: aktuelle Datei", + "type": "debugpy", + "request": "launch", + "module": "pytest", + "args": ["${file}", "-v"], + "cwd": "${workspaceFolder}/app", + "console": "integratedTerminal", + "justMyCode": false + } + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..ef08bb5 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,38 @@ +{ + "python.analysis.extraPaths": ["app"], + "python.analysis.typeCheckingMode": "basic", + "python.testing.pytestEnabled": true, + "python.testing.pytestArgs": ["app"], + "python.testing.cwd": "${workspaceFolder}/app", + + "[python]": { + "editor.formatOnSave": true, + "editor.defaultFormatter": "charliermarsh.ruff", + "editor.codeActionsOnSave": { + "source.organizeImports": "explicit", + "source.fixAll": "explicit" + } + }, + + "ruff.lineLength": 100, + + "files.associations": { + "**/templates/**/*.html": "django-html", + "**/requirements*.txt": "pip-requirements" + }, + "emmet.includeLanguages": { + "django-html": "html" + }, + + "files.exclude": { + "**/__pycache__": true, + "**/*.pyc": true, + "**/.pytest_cache": true, + "**/.ruff_cache": true + }, + "search.exclude": { + "**/staticfiles": true, + "**/media": true, + "**/backups": true + } +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..74b4cda --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,72 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "compose: up", + "type": "shell", + "command": "docker compose up -d", + "problemMatcher": [] + }, + { + "label": "compose: down", + "type": "shell", + "command": "docker compose down", + "problemMatcher": [] + }, + { + "label": "compose: logs web", + "type": "shell", + "command": "docker compose logs -f web", + "problemMatcher": [] + }, + { + "label": "compose: rebuild web", + "type": "shell", + "command": "docker compose build web && docker compose up -d web", + "problemMatcher": [] + }, + { + "label": "django: makemigrations", + "type": "shell", + "command": "docker compose exec web python manage.py makemigrations", + "problemMatcher": [] + }, + { + "label": "django: migrate", + "type": "shell", + "command": "docker compose exec web python manage.py migrate", + "problemMatcher": [] + }, + { + "label": "django: shell", + "type": "shell", + "command": "docker compose exec web python manage.py shell", + "problemMatcher": [] + }, + { + "label": "django: createsuperuser", + "type": "shell", + "command": "docker compose exec web python manage.py createsuperuser", + "problemMatcher": [] + }, + { + "label": "django: start debugpy", + "type": "shell", + "command": "docker compose exec web python -m debugpy --listen 0.0.0.0:5678 manage.py runserver 0.0.0.0:8000 --noreload", + "problemMatcher": [] + }, + { + "label": "tests: pytest", + "type": "shell", + "command": "docker compose exec web pytest -v", + "group": "test", + "problemMatcher": [] + }, + { + "label": "lint: ruff", + "type": "shell", + "command": "docker compose exec web ruff check .", + "problemMatcher": [] + } + ] +} diff --git a/README.md b/README.md index 17bb9e1..5ba21be 100644 --- a/README.md +++ b/README.md @@ -384,28 +384,117 @@ docker compose logs -f web docker compose exec web python manage.py createsuperuser ``` -### Externen Proxy konfigurieren (Beispiel) +### Externen Reverse-Proxy konfigurieren + +Der Stack liefert intern HTTP auf `${APP_BIND_IP}:${APP_BIND_PORT}` aus (Default `127.0.0.1:8080`). Der externe Proxy übernimmt TLS-Terminierung und die nach außen sichtbare Domain. + +#### Voraussetzungen für externen Zugriff + +Wenn der Proxy auf einem **anderen Host** läuft (typisch bei Nginx Proxy Manager als eigener Container/VM), muss `APP_BIND_IP` von `127.0.0.1` auf eine LAN-erreichbare Adresse umgestellt werden: + +```bash +# LAN-IP des Docker-Hosts ermitteln +ip -4 -br a | grep -v lo + +# In .env eintragen (Beispiel) +sed -i 's/^APP_BIND_IP=.*/APP_BIND_IP=192.168.10.42/' .env + +docker compose up -d +``` + +Alternativ `0.0.0.0` für alle Interfaces — die explizite IP ist sicherheitstechnisch sauberer. + +Firewall absichern, damit nur der Proxy-Host auf 8080 zugreifen darf: + +```bash +sudo ufw allow from to any port 8080 proto tcp comment 'reverse proxy' +sudo ufw deny 8080 +``` + +#### Django-Seite anpassen + +Die externe Domain muss in `.env` eingetragen sein, sonst lehnt Django die Requests ab (400 Bad Request bzw. 403 CSRF): + +```env +DJANGO_ALLOWED_HOSTS=serienbrief.example.lan,localhost,127.0.0.1 +CSRF_TRUSTED_ORIGINS=https://serienbrief.example.lan,http://localhost:8080,http://127.0.0.1:8080 +``` + +Nach Änderung `docker compose restart web`. + +#### Variante A: Nginx Proxy Manager (NPM) + +Im NPM-Webinterface unter **Proxy Hosts → Add Proxy Host**: + +| Feld | Wert | +|---|---| +| Domain Names | `serienbrief.example.lan` | +| Scheme | `http` | +| Forward Hostname / IP | LAN-IP des Docker-Hosts (z.B. `192.168.10.42`) | +| Forward Port | `8080` | +| Cache Assets | aus | +| Block Common Exploits | an | +| Websockets Support | an (HTMX nutzt nur AJAX, schadet aber nicht) | + +Im Tab **Custom Locations** oder **Advanced** zusätzlich: + +```nginx +client_max_body_size 50M; +proxy_set_header X-Forwarded-Proto $scheme; +proxy_set_header X-Forwarded-Host $host; +proxy_set_header X-Real-IP $remote_addr; +proxy_read_timeout 300s; +proxy_send_timeout 300s; +``` + +NPM setzt `Host`, `X-Forwarded-For` und Standard-Header bereits selbst. **Pflicht** ist nur `X-Forwarded-Proto $scheme`, weil Djangos `SECURE_PROXY_SSL_HEADER` darauf basiert. + +TLS-Zertifikat (Let's Encrypt für interne Domains via DNS-Challenge, oder eigenes CA-Cert) im Tab **SSL** zuweisen. + +#### Variante B: Generisches Nginx / OpenResty ```nginx server { listen 443 ssl http2; - server_name serienbrief.lan; + server_name serienbrief.example.lan; ssl_certificate /etc/ssl/lan/serienbrief.crt; ssl_certificate_key /etc/ssl/lan/serienbrief.key; + client_max_body_size 50M; # für CSV/DOCX-Uploads + location / { proxy_pass http://:8080; + proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto https; + proxy_set_header X-Forwarded-Host $host; + proxy_read_timeout 300s; + proxy_send_timeout 300s; proxy_redirect off; - client_max_body_size 50M; # für CSV/DOCX‑Uploads } } ``` +#### Diagnose bei 502 Bad Gateway + +Vom Proxy-Host aus testen: + +```bash +# TCP-Connect — "succeeded" erwartet +nc -zv 8080 + +# HTTP-Request — 200/302 erwartet +curl -v -H "Host: serienbrief.example.lan" http://:8080/ +``` + +Typische Ursachen: +- `Connection refused` → `APP_BIND_IP` steht noch auf `127.0.0.1` +- `Timeout` → Firewall blockt +- 502 ohne Connect-Fehler → falsche Upstream-IP im Proxy-Eintrag + --- ## Entwicklung in VS Code diff --git a/vorlagen/.~lock.entwurf_politikerbrief.docx# b/vorlagen/.~lock.entwurf_politikerbrief.docx# new file mode 100644 index 0000000..f1bf04f --- /dev/null +++ b/vorlagen/.~lock.entwurf_politikerbrief.docx# @@ -0,0 +1 @@ +,hans,jacboy,21.05.2026 15:40,file:///home/hans/.config/libreoffice/4; \ No newline at end of file diff --git a/vorlagen/.~lock.vorlage_politikerbriefe_2023.odt# b/vorlagen/.~lock.vorlage_politikerbriefe_2023.odt# new file mode 100644 index 0000000..7be0412 --- /dev/null +++ b/vorlagen/.~lock.vorlage_politikerbriefe_2023.odt# @@ -0,0 +1 @@ +,hans,jacboy,21.05.2026 15:32,file:///home/hans/.config/libreoffice/4; \ No newline at end of file diff --git a/vorlagen/entwurf_politikerbrief.docx b/vorlagen/entwurf_politikerbrief.docx new file mode 100644 index 0000000..24bab9f Binary files /dev/null and b/vorlagen/entwurf_politikerbrief.docx differ diff --git a/vorlagen/vorlage_politikerbriefe_2023.odt b/vorlagen/vorlage_politikerbriefe_2023.odt new file mode 100644 index 0000000..d7392fa Binary files /dev/null and b/vorlagen/vorlage_politikerbriefe_2023.odt differ