6 Commits

11 changed files with 626 additions and 0 deletions

10
.dockerignore Normal file
View File

@@ -0,0 +1,10 @@
.git
.venv
__pycache__
*.pyc
*.pyo
*.pyd
*.log
build/
*.ics
*.pdf

15
Dockerfile Normal file
View File

@@ -0,0 +1,15 @@
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /app
COPY web/requirements-web.txt /app/web/requirements-web.txt
RUN pip install --no-cache-dir -r /app/web/requirements-web.txt
COPY . /app
EXPOSE 8000
CMD ["python", "-m", "uvicorn", "web.app:app", "--host", "0.0.0.0", "--port", "8000"]

View File

@@ -27,6 +27,21 @@ Interaktives Textmenü:
./start.sh ./start.sh
``` ```
### 3) Web (MVP für Mobilgeräte)
Browser-Variante mit Upload + direktem ICS-Download:
```bash
./start_web.sh
```
Details siehe [WEB_README.md](WEB_README.md).
Docker-Variante (Server):
```bash
docker compose up -d --build
```
--- ---
## ⚡ Schnellstart (60 Sekunden) ## ⚡ Schnellstart (60 Sekunden)
@@ -149,6 +164,7 @@ Die erzeugten `.ics`-Dateien lassen sich u. a. in folgende Kalender importieren:
- [INSTALL.md](INSTALL.md) - Linux-Installation mit Menüeintrag - [INSTALL.md](INSTALL.md) - Linux-Installation mit Menüeintrag
- [WXPYTHON_README.md](WXPYTHON_README.md) - wxPython-spezifische Hinweise - [WXPYTHON_README.md](WXPYTHON_README.md) - wxPython-spezifische Hinweise
- [WEB_README.md](WEB_README.md) - Web-Version (Browser/Mobil)
- [BUILD_STANDALONE.md](BUILD_STANDALONE.md) - Standalone-Builds/Packaging - [BUILD_STANDALONE.md](BUILD_STANDALONE.md) - Standalone-Builds/Packaging
- [QUICKSTART.md](QUICKSTART.md) - Kurzanleitung Kalender-Import - [QUICKSTART.md](QUICKSTART.md) - Kurzanleitung Kalender-Import
- [ZUSAMMENFASSUNG.md](ZUSAMMENFASSUNG.md) - Projekt- und Changelog-Übersicht - [ZUSAMMENFASSUNG.md](ZUSAMMENFASSUNG.md) - Projekt- und Changelog-Übersicht

259
WEB_README.md Normal file
View File

@@ -0,0 +1,259 @@
# 🌐 Web-Version (MVP)
Diese Variante stellt den PDF-zu-ICS-Konverter im Browser bereit, damit die Nutzung auch auf mobilen Geräten möglich ist.
## Starten
### Linux/macOS
```bash
./start_web.sh
```
### Windows
Doppelklick auf `start_web.cmd`
Danach im Browser öffnen:
- Lokal: `http://localhost:8000`
- Im Netzwerk (z. B. Smartphone): `http://<IP-des-Rechners>:8000`
## Docker (Server ohne VPN)
Diese Variante ist für deinen aktuellen Wunsch geeignet: öffentlich erreichbar ohne VPN.
### 1) Starten
```bash
docker compose up -d --build
```
Aufruf:
- Direkt per IP/Port: `http://<SERVER-IP>:8000`
- Oder mit Domain über Reverse Proxy (empfohlen)
### 2) Status und Logs
```bash
docker compose ps
docker compose logs -f pdf-to-ics-web
```
### 3) Stoppen / Update
```bash
docker compose down
git pull
docker compose up -d --build
```
### 4) Optional: App-Login aktivieren
In `docker-compose.yml` die beiden Variablen aktivieren:
```yaml
environment:
- WEB_AUTH_USER=kalender
- WEB_AUTH_PASSWORD=BitteSicheresPasswortSetzen
```
Dann neu starten:
```bash
docker compose up -d --build
```
Hinweis: Ohne VPN ist mindestens HTTPS + Basic Auth empfohlen, wenn die App öffentlich im Internet hängt.
## Funktionen
- PDF-Datei hochladen
- Optional Ruhetage ausschließen
- Optional Urlaub ausschließen
- ICS-Datei direkt herunterladen
## Hinweise für mobile Nutzung
- Smartphone und Server müssen im gleichen Netzwerk sein (lokaler Betrieb)
- Bei Internet-Betrieb sollte HTTPS und ein Reverse Proxy (z. B. Nginx) genutzt werden
- Hochgeladene Dateien werden nur temporär verarbeitet
## Technischer Aufbau
- `web/app.py` FastAPI-Backend + Upload/Download-Endpunkte
- `web/templates/index.html` mobile Web-Oberfläche
- `web/requirements-web.txt` Web-spezifische Abhängigkeiten
## Produktion (Kurz)
Beispiel mit Uvicorn direkt:
```bash
.venv/bin/python -m uvicorn web.app:app --host 0.0.0.0 --port 8000
```
Optional mit App-Auth (zusätzliche Schutzschicht):
```bash
WEB_AUTH_USER=kalender WEB_AUTH_PASSWORD='StarkesPasswort' \
.venv/bin/python -m uvicorn web.app:app --host 0.0.0.0 --port 8000
```
Empfohlen für Internet-Betrieb:
- Uvicorn hinter Nginx
- HTTPS aktivieren
- Upload-Größenlimit setzen
- Zugriff absichern (z. B. Basic Auth oder Login)
## App-Auth (optional, zusätzlich zu Nginx)
Wenn `WEB_AUTH_USER` und `WEB_AUTH_PASSWORD` gesetzt sind, schützt die App alle Endpunkte per HTTP Basic Auth.
Linux/macOS Beispiel:
```bash
export WEB_AUTH_USER=kalender
export WEB_AUTH_PASSWORD='StarkesPasswort'
./start_web.sh
```
Windows (PowerShell) Beispiel:
```powershell
$env:WEB_AUTH_USER='kalender'
$env:WEB_AUTH_PASSWORD='StarkesPasswort'
./start_web.cmd
```
Hinweis: Für öffentlich erreichbare Server weiterhin Nginx + HTTPS verwenden.
## Öffentliches Deployment (HTTPS)
Beispiel für Ubuntu-Server mit Domain `ics.example.de`.
### 1) App als Service starten
`/etc/systemd/system/pdf-to-ics-web.service`
```ini
[Unit]
Description=PDF to ICS Web
After=network.target
[Service]
User=www-data
WorkingDirectory=/opt/pdf_to_ics
ExecStart=/opt/pdf_to_ics/.venv/bin/python -m uvicorn web.app:app --host 127.0.0.1 --port 8000
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target
```
Aktivieren:
```bash
sudo systemctl daemon-reload
sudo systemctl enable --now pdf-to-ics-web
sudo systemctl status pdf-to-ics-web
```
### 2) Nginx als Reverse Proxy
`/etc/nginx/sites-available/pdf-to-ics`
```nginx
server {
listen 80;
server_name ics.example.de;
client_max_body_size 10M;
auth_basic "Geschuetzter Bereich";
auth_basic_user_file /etc/nginx/.htpasswd-pdf-to-ics;
location / {
proxy_pass http://127.0.0.1:8000;
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 $scheme;
}
}
```
Aktivieren:
```bash
sudo ln -s /etc/nginx/sites-available/pdf-to-ics /etc/nginx/sites-enabled/pdf-to-ics
sudo nginx -t
sudo systemctl reload nginx
```
### 2b) Basic Auth einrichten (empfohlen)
```bash
sudo apt-get update
sudo apt-get install -y apache2-utils
sudo htpasswd -c /etc/nginx/.htpasswd-pdf-to-ics kalender
sudo nginx -t
sudo systemctl reload nginx
```
Weitere Nutzer hinzufügen (ohne `-c`):
```bash
sudo htpasswd /etc/nginx/.htpasswd-pdf-to-ics weiterer_user
```
Schnelltest:
```bash
curl -I https://ics.example.de
```
Erwartung: zuerst `401 Unauthorized`, mit Login im Browser dann Zugriff.
### 2c) IP-Whitelist (optional, zusätzlich)
Wenn nur bestimmte Netze zugreifen sollen, kann Nginx den Zugriff auf IP-Bereiche begrenzen.
Beispiel (lokales Netz + einzelne feste IP):
```nginx
location / {
allow 192.168.178.0/24;
allow 203.0.113.10;
deny all;
proxy_pass http://127.0.0.1:8000;
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 $scheme;
}
```
Danach prüfen und neu laden:
```bash
sudo nginx -t
sudo systemctl reload nginx
```
Kombiniert mit Basic Auth ist das eine robuste Mindestabsicherung.
### 3) HTTPS mit Let's Encrypt
```bash
sudo apt-get update
sudo apt-get install -y certbot python3-certbot-nginx
sudo certbot --nginx -d ics.example.de
```
Test der Erneuerung:
```bash
sudo certbot renew --dry-run
```
### 4) Mindest-Sicherheit
- Zugriffe absichern (mindestens Basic Auth)
- Optional zusätzlich per IP-Whitelist einschränken
- Upload-Limit klein halten (`client_max_body_size`)
- Server und Pakete regelmäßig aktualisieren

12
docker-compose.yml Normal file
View File

@@ -0,0 +1,12 @@
services:
pdf-to-ics-web:
build: .
container_name: pdf-to-ics-web
restart: unless-stopped
ports:
- "8000:8000"
environment:
- TZ=Europe/Berlin
# Optional aktivieren für App-Login:
# - WEB_AUTH_USER=kalender
# - WEB_AUTH_PASSWORD=BitteSicheresPasswortSetzen

22
start_web.cmd Normal file
View File

@@ -0,0 +1,22 @@
@echo off
REM PDF zu ICS Web-MVP starten (Windows)
setlocal enabledelayedexpansion
cd /d "%~dp0"
if not exist ".venv" (
echo 📦 Python-Umgebung wird eingerichtet...
py -3 -m venv .venv --upgrade-deps
if errorlevel 1 (
python -m venv .venv --upgrade-deps
)
)
.venv\Scripts\python.exe -c "import fastapi" 2>nul
if errorlevel 1 (
echo 📚 Installiere Web-Abhängigkeiten...
call .venv\Scripts\python.exe -m pip install -q -r web\requirements-web.txt
)
echo 🌐 Starte Web-App auf http://0.0.0.0:8000
call .venv\Scripts\python.exe -m uvicorn web.app:app --host 0.0.0.0 --port 8000

31
start_web.sh Executable file
View File

@@ -0,0 +1,31 @@
#!/bin/bash
# PDF zu ICS Web-MVP starten (Linux/macOS)
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
cd "$SCRIPT_DIR"
PYTHON_CMD=""
if command -v python3 &> /dev/null; then
PYTHON_CMD="python3"
elif command -v python &> /dev/null; then
PYTHON_CMD="python"
else
echo "❌ Fehler: Python nicht gefunden!"
exit 1
fi
if [ ! -d ".venv" ]; then
echo "📦 Python-Umgebung wird eingerichtet..."
$PYTHON_CMD -m venv .venv --upgrade-deps || exit 1
fi
PYTHON_VENV=".venv/bin/python"
if ! $PYTHON_VENV -c "import fastapi" 2>/dev/null; then
echo "📚 Installiere Web-Abhängigkeiten..."
$PYTHON_VENV -m pip install -q -r web/requirements-web.txt || exit 1
fi
echo "🌐 Starte Web-App auf http://0.0.0.0:8000"
exec $PYTHON_VENV -m uvicorn web.app:app --host 0.0.0.0 --port 8000

0
web/__init__.py Normal file
View File

132
web/app.py Normal file
View File

@@ -0,0 +1,132 @@
#!/usr/bin/env python3
"""
Web-MVP für PDF zu ICS Konverter
"""
import re
import sys
import tempfile
from pathlib import Path
import os
import secrets
from fastapi import Depends, FastAPI, File, Form, HTTPException, Request, UploadFile, status
from fastapi.responses import FileResponse, HTMLResponse
from fastapi.security import HTTPBasic, HTTPBasicCredentials
from fastapi.templating import Jinja2Templates
from starlette.background import BackgroundTask
PROJECT_ROOT = Path(__file__).resolve().parent.parent
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from pdf_to_ics import create_ics_from_dienstplan, extract_dienstplan_data
app = FastAPI(title="PDF zu ICS Web")
templates = Jinja2Templates(directory=str(Path(__file__).parent / "templates"))
security = HTTPBasic()
def require_auth(credentials: HTTPBasicCredentials = Depends(security)):
expected_user = os.getenv("WEB_AUTH_USER")
expected_password = os.getenv("WEB_AUTH_PASSWORD")
if not expected_user or not expected_password:
return
valid_user = secrets.compare_digest(credentials.username, expected_user)
valid_password = secrets.compare_digest(credentials.password, expected_password)
if not (valid_user and valid_password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Unauthorized",
headers={"WWW-Authenticate": "Basic"},
)
@app.get("/", response_class=HTMLResponse)
def index(request: Request, _: None = Depends(require_auth)):
return templates.TemplateResponse(
"index.html",
{
"request": request,
"error": None,
},
)
@app.post("/convert")
async def convert_pdf(
request: Request,
file: UploadFile = File(...),
exclude_rest: bool = Form(False),
exclude_vacation: bool = Form(False),
_: None = Depends(require_auth),
):
filename = file.filename or "dienstplan.pdf"
if not filename.lower().endswith(".pdf"):
return templates.TemplateResponse(
"index.html",
{
"request": request,
"error": "Bitte eine PDF-Datei hochladen.",
},
status_code=400,
)
data = await file.read()
if not data:
return templates.TemplateResponse(
"index.html",
{
"request": request,
"error": "Die Datei ist leer.",
},
status_code=400,
)
with tempfile.TemporaryDirectory(prefix="pdf_to_ics_web_") as temp_dir:
temp_dir_path = Path(temp_dir)
pdf_path = temp_dir_path / "upload.pdf"
pdf_path.write_bytes(data)
dienstplan = extract_dienstplan_data(str(pdf_path))
if not dienstplan.get("events"):
return templates.TemplateResponse(
"index.html",
{
"request": request,
"error": "Keine Dienstplan-Events in der PDF gefunden.",
},
status_code=422,
)
safe_name = re.sub(r"[^A-Za-z0-9._-]", "_", Path(filename).stem)
ics_filename = f"{safe_name}.ics"
ics_path = temp_dir_path / ics_filename
create_ics_from_dienstplan(
dienstplan,
str(ics_path),
exclude_rest=exclude_rest,
exclude_vacation=exclude_vacation,
)
content = ics_path.read_bytes()
response_tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".ics", prefix="pdf_to_ics_out_")
response_tmp.write(content)
response_tmp.close()
return FileResponse(
path=response_tmp.name,
media_type="text/calendar",
filename=ics_filename,
headers={"Cache-Control": "no-store"},
background=BackgroundTask(lambda path: os.remove(path) if os.path.exists(path) else None, response_tmp.name),
)
@app.get("/health")
def health(_: None = Depends(require_auth)):
return {"status": "ok"}

9
web/requirements-web.txt Normal file
View File

@@ -0,0 +1,9 @@
fastapi>=0.115.0
uvicorn>=0.30.0
jinja2>=3.1.0
python-multipart>=0.0.9
pdfplumber
icalendar
pytz
pypdf2
packaging

120
web/templates/index.html Normal file
View File

@@ -0,0 +1,120 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>PDF zu ICS (Web)</title>
<style>
:root { color-scheme: light; }
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif;
background: #f5f7fb;
color: #1f2937;
}
.wrap {
max-width: 520px;
margin: 0 auto;
padding: 20px 14px 28px;
}
.card {
background: #ffffff;
border-radius: 14px;
padding: 18px;
box-shadow: 0 1px 4px rgba(0,0,0,0.08);
}
h1 {
margin: 0 0 10px;
font-size: 1.3rem;
}
p {
margin: 0 0 16px;
color: #4b5563;
line-height: 1.4;
}
label {
display: block;
font-weight: 600;
margin: 12px 0 8px;
}
input[type=file] {
width: 100%;
padding: 10px;
border: 1px solid #d1d5db;
border-radius: 10px;
background: #fff;
box-sizing: border-box;
}
.options {
margin: 12px 0;
display: grid;
gap: 8px;
}
.option {
display: flex;
gap: 10px;
align-items: center;
font-weight: 500;
color: #111827;
}
button {
width: 100%;
border: 0;
border-radius: 10px;
padding: 12px;
font-size: 1rem;
font-weight: 700;
background: #2563eb;
color: #fff;
margin-top: 6px;
cursor: pointer;
}
button:active { transform: translateY(1px); }
.error {
margin: 0 0 12px;
padding: 10px;
border-radius: 10px;
background: #fef2f2;
color: #991b1b;
border: 1px solid #fecaca;
}
.hint {
margin-top: 12px;
font-size: 0.9rem;
color: #6b7280;
}
</style>
</head>
<body>
<main class="wrap">
<section class="card">
<h1>PDF zu ICS Konverter</h1>
<p>PDF hochladen, konvertieren und die ICS-Datei direkt herunterladen.</p>
{% if error %}
<div class="error">{{ error }}</div>
{% endif %}
<form action="/convert" method="post" enctype="multipart/form-data">
<label for="file">Dienstplan-PDF</label>
<input id="file" name="file" type="file" accept="application/pdf,.pdf" required />
<div class="options">
<label class="option">
<input type="checkbox" name="exclude_rest" value="true" />
Ruhetage ausschließen
</label>
<label class="option">
<input type="checkbox" name="exclude_vacation" value="true" />
Urlaub ausschließen
</label>
</div>
<button type="submit">ICS erstellen</button>
</form>
<div class="hint">Hinweis: Die Datei wird nur temporär für die Konvertierung verarbeitet.</div>
</section>
</main>
</body>
</html>