diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d95b21c --- /dev/null +++ b/.env.example @@ -0,0 +1,41 @@ +# --- Flask Core --- +# Geheimer Schlüssel für Session-Signierung. Erzeugen z.B. mit: +# python -c "import secrets; print(secrets.token_hex(32))" +SECRET_KEY=change-me-to-a-random-secret + +# Umgebung: development | production +FLASK_ENV=development + +# Domain/Host (erst final setzen, wenn Domain feststeht). Beispiel: hub.example.com +# Leer lassen für lokale Entwicklung (IP:Port). +SERVER_NAME= + +# --- Datenbank --- +# SQLite-Pfad. Standard: instance/hub.sqlite (relativ zur instance/-Ordner). +# Für Postgres später z.B.: postgresql://user:pass@localhost/hub +DATABASE_URL=sqlite:///hub.sqlite + +# --- Auth / Self-Registration --- +# Komma-getrennte Allowlist erlaubter E-Mail-Adressen (vorerst nur eine). +ALLOWED_EMAILS=deine@adresse.com + +# Gültigkeitsdauer des Login-Codes in Minuten. +LOGIN_CODE_TTL_MINUTES=10 + +# Rate-Limit für Code-Anfragen (Flask-Limiter Syntax), z.B. "5 per hour". +LOGIN_CODE_RATE_LIMIT=5 per hour + +# Speicher-Backend für Rate-Limits. +# Dev/Einzelworker: memory:// | Produktion (mehrere gunicorn-Worker): redis://localhost:6379 +RATELIMIT_STORAGE_URI=memory:// + +# --- SMTP / Mailversand --- +# Anbieter frei wechselbar (Proton Bridge lokal, oder EU-Relay wie Infomaniak/Mailjet). +MAIL_SERVER=127.0.0.1 +MAIL_PORT=1025 +MAIL_USE_TLS=false +MAIL_USE_SSL=false +MAIL_USERNAME= +MAIL_PASSWORD= +# Absenderadresse der Login-Mails. +MAIL_DEFAULT_SENDER=hub@example.com diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..33aa898 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Vendored Schriftdateien als Binär behandeln (kein Diff/Textmerge). +*.ttf binary diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..10223c2 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,213 @@ +# CLAUDE.md — Projekt-Hub + +Diese Datei beschreibt den Kontext, die Architektur und die Konventionen für dieses Projekt. +Sie dient Claude (und anderen Entwicklern) als Einstiegspunkt, bevor Code geschrieben wird. + +## Hintergrund / Idee + +Der Nutzer hat ADHS und führt ein analoges Bullet Journal (Logbuch, Weekly Bridge, Monthly +Log). Projektbezogene Arbeitsaufgaben sollen davon getrennt digital verwaltet werden. + +Ein erster Prototyp war eine einzelne HTML-Datei mit LocalStorage (Projekte, Aufgaben, +Fälligkeitsdaten, Export/Import als JSON). Diese Datei dient als **funktionale Referenz** +für Feature-Set und UI/UX — siehe `reference/projekte.html` (wird vom Nutzer bereitgestellt). + +Das Ziel jetzt: aus dieser Idee eine **Flask-Webapp** machen, die auf einem eigenen VPS +läuft (volle Datenhoheit, kein Drittanbieter-Cloud-Dienst). Diese App ist der erste Baustein +eines **Hubs**, der später um weitere kleine Apps/Module erweitert wird — alle hinter einem +gemeinsamen Login. + +## Kernprinzipien + +1. **Datenschutz / Selbsthosting**: Alle Daten bleiben auf dem eigenen VPS. Keine externen + Tracker, kein CDN-Zwang für kritische Funktionen (Fallback ohne Internet sollte + funktionieren oder zumindest nicht hart brechen). +2. **ADHS-freundlich**: Niedrigschwellig, wenig Klicks, schnelles Erfassen ("Brain dump"), + sofortiges visuelles Feedback (Fälligkeits-Badges: überfällig/bald/ok), keine + überladenen Screens. +3. **Erweiterbarkeit**: Architektur so anlegen, dass weitere "Mini-Apps" als eigene + Module/Blueprints unter demselben Login + derselben Navigation ergänzt werden können, + ohne den Kern umzubauen. +4. **Einfacher Login**: Kein Passwort. Login per E-Mail-Adresse + zeitlich begrenztem + Code (10 Minuten gültig), der per Mail zugeschickt wird ("Magic Code" / OTP-Login). + +## Tech-Stack + +- **Backend**: Python, Flask +- **Datenbank**: SQLite (ausreichend für Einzelnutzer/kleine Gruppe; Pfad konfigurierbar, + später ggf. auf Postgres migrierbar — daher SQLAlchemy als ORM verwenden) +- **Auth**: Flask-Login für Session-Verwaltung + eigene Logik für E-Mail-Code-Versand + (Flask-Mail oder smtplib, SMTP-Zugangsdaten über Umgebungsvariablen) +- **Frontend**: Server-seitig gerenderte Templates (Jinja2) + minimal JavaScript für + interaktive Elemente (Checkbox-Toggle, Filter, evtl. htmx für dynamische Updates ohne + vollständigen Page-Reload). Kein schweres SPA-Framework — Ladezeit und Einfachheit + haben Priorität. +- **Styling**: Eigenes CSS im Stil des Prototyps (Dark Theme, siehe Referenzdatei) als + Basis-Theme für den ganzen Hub, das von allen Modulen genutzt wird. +- **Deployment**: VPS, lauffähig hinter einem Reverse Proxy (nginx) via gunicorn. + `.env`-Datei für Secrets (SMTP, SECRET_KEY, Datenbankpfad). `requirements.txt` pflegen. + +## Architektur-Überblick + +``` +projekt-hub/ +├── app/ +│ ├── __init__.py # App-Factory, Extensions-Init, Blueprint-Registrierung +│ ├── config.py # Config-Klassen (liest aus .env) +│ ├── extensions.py # db, login_manager, mail etc. +│ ├── models.py # User, LoginCode (gemeinsame Modelle) +│ ├── auth/ # Auth-Modul (Blueprint) +│ │ ├── __init__.py +│ │ ├── routes.py # /login, /verify, /logout +│ │ └── forms.py # E-Mail-Eingabe, Code-Eingabe +│ ├── projects/ # Modul 1: Projekte & Aufgaben (aus dem Prototyp) +│ │ ├── __init__.py +│ │ ├── routes.py +│ │ ├── models.py # Project, Task +│ │ └── templates/projects/ +│ ├── templates/ +│ │ ├── base.html # gemeinsames Layout, Navigation, Theme +│ │ └── auth/ +│ │ ├── login.html +│ │ └── verify.html +│ └── static/ +│ └── css/main.css # Dark-Theme-Basis aus dem Prototyp +├── migrations/ # Flask-Migrate / Alembic +├── instance/ # SQLite-Datei (nicht versionieren) +├── .env.example +├── requirements.txt +├── wsgi.py +└── run.py # lokaler Dev-Server +``` + +### Erweiterbarkeit für künftige Module + +Jedes neue Mini-App-Modul: +- ist ein eigenes Python-Package mit eigenem Blueprint (`app//`) +- bringt eigene Models, Routes, Templates mit (Templates unter `templates//`) +- wird in `app/__init__.py` per `app.register_blueprint(...)` eingebunden +- erscheint automatisch als Eintrag in der gemeinsamen Navigation (`base.html`), idealerweise + über eine zentrale Liste/Registry, die Modulname + URL + Icon enthält +- nutzt denselben `current_user` (Flask-Login) — kein eigenes Auth nötig + +Ziel: ein neues Modul hinzufügen = neuen Ordner anlegen, Blueprint registrieren, +Navigationseintrag ergänzen. Kern-App, Auth und Theme bleiben unangetastet. + +## Datenmodell (Modul "Projekte") + +**User** +- id +- email (unique) +- created_at + +**LoginCode** +- id +- user_id (FK) +- code (6-stellig, numerisch) +- created_at +- expires_at (created_at + 10 Minuten) +- used (bool) + +**Project** +- id +- user_id (FK) +- name +- color (Hex-Wert, aus fester Palette wie im Prototyp) +- created_at + +**Task** +- id +- user_id (FK) +- project_id (FK, nullable — Aufgaben ohne Projekt erlaubt) +- text +- due_date (nullable) +- done (bool, default False) +- created_at + +## Auth-Flow (E-Mail-Code-Login) + +1. Nutzer gibt E-Mail-Adresse auf `/login` ein. +2. Backend prüft, ob User mit dieser E-Mail existiert — falls nicht, wird er angelegt + (passwortlose Self-Registration; ggf. später auf Allowlist beschränkbar für + Einzelnutzer-Setup). +3. Backend generiert 6-stelligen Code, speichert Hash + `expires_at = now + 10min`, + sendet Code per E-Mail (Betreff klar, kurzer Text, kein HTML-Tracking). +4. Nutzer landet auf `/verify`, gibt Code ein. +5. Backend prüft: Code korrekt, nicht abgelaufen, nicht bereits benutzt → Login via + Flask-Login (`login_user`), Code als `used` markieren. +6. Alte/abgelaufene Codes regelmäßig aufräumen (z.B. bei jedem neuen Code-Request für + denselben User). +7. Rate-Limiting: max. X Code-Anfragen pro E-Mail pro Stunde, um Spam/Missbrauch zu + verhindern (z.B. Flask-Limiter). + +Sicherheitsaspekte: +- Code wird gehasht gespeichert (nicht im Klartext in der DB). +- Session-Cookie: `Secure`, `HttpOnly`, `SameSite=Lax`. +- HTTPS wird vom Reverse Proxy (nginx + Let's Encrypt) terminiert — App selbst geht von + HTTPS aus (`PREFERRED_URL_SCHEME = 'https'`). + +## Feature-Set Modul "Projekte" (aus dem Prototyp übernehmen) + +- Projekte anlegen/löschen, mit fester Farbpalette (zyklisch zugewiesen) +- Aufgaben anlegen mit: Text, optionalem Projekt, optionalem Fälligkeitsdatum +- Aufgaben abhaken (done/undone), löschen +- Fälligkeits-Badges: überfällig (rot), diese Woche (orange), normal (grün), kein Datum +- Ansicht "Nach Projekt" (gruppiert, mit Klapp-/Zähl-Funktion) + Ansicht "Alle Aufgaben" + (gefiltert: alle / offen / überfällig / diese Woche / erledigt) +- Statistik-Leiste (offen / erledigt / überfällig / Anzahl Projekte) +- Da Daten jetzt serverseitig in SQLite liegen und auf dem VPS gesichert werden, + entfällt der JSON-Export/Import als Sync-/Backup-Mechanismus. Stattdessen: + **PDF-Export** — eine projektbasierte To-Do-Liste zum Ausdrucken (offene und + erledigte Aufgaben, erledigte abgehakt/durchgestrichen, Aufgaben ohne Projekt in + eigenem Abschnitt). Erzeugt mit fpdf2 (reines Python, keine System-Abhängigkeiten); + DejaVu-Schrift im Repo mitgeliefert für identisches Rendering auf dem VPS. + +## Nicht-Ziele (für jetzt) + +- Keine Multi-Tenant-Mandantentrennung über User hinaus (Familien-/Team-Sharing später, + falls gewünscht) +- Kein OAuth/Social Login +- Kein Offline-First/PWA-Sync (kann später ergänzt werden) +- Kein Rich-Text/Markdown in Aufgaben (bewusst simpel halten) + +## Nächste Schritte (Vorschlag für Umsetzungsreihenfolge) + +1. Projektgerüst (App-Factory, Config, `.env.example`, `requirements.txt`) +2. Models + DB-Migration (User, LoginCode, Project, Task) +3. Auth-Modul (E-Mail-Code-Login, Templates für `login.html` / `verify.html`) +4. Basis-Layout `base.html` mit Navigation + Dark-Theme-CSS (aus Prototyp übernehmen) +5. Modul "Projekte": Routes + Templates, 1:1-Funktionsumfang aus dem HTML-Prototyp +6. PDF-Export (projektbasierte To-Do-Liste zum Ausdrucken, fpdf2) +7. Deployment-Vorbereitung: `wsgi.py`, gunicorn-Konfiguration, Beispiel-nginx-Config, + systemd-Service-Datei + +## Entscheidungen (geklärt) + +- **SMTP-Anbieter**: Proton Mail (eigene Adresse). Da Proton Mail Bridge eine + Desktop-Anwendung ist und nur mit zahlendem Plan läuft, wird sie testweise auf dem + VPS via Docker betrieben (lokaler SMTP-Relay auf `127.0.0.1`). Falls das auf dem + Server unzuverlässig läuft, ist ein **Fallback auf einen EU-SMTP-Relay-Dienst** + (z.B. Infomaniak, Mailjet EU-Region) als austauschbare Alternative vorgesehen — die + App liest SMTP-Host/Port/User/Passwort ausschließlich aus `.env`, sodass der Anbieter + ohne Codeänderung wechselbar ist. +- **Self-Registration**: Vorerst nur **eine** erlaubte E-Mail-Adresse (Allowlist mit + einem Eintrag in `.env`, z.B. `ALLOWED_EMAILS=deine@adresse.com`). Spätere + Erweiterung auf mehrere Adressen ist durch eine kommagetrennte Liste oder eigene + DB-Tabelle vorbereitet, aber jetzt nicht nötig. +- **Domain**: Noch nicht final. App wird so konfiguriert, dass Domain/Host über `.env` + (`SERVER_NAME` bzw. Reverse-Proxy-Konfiguration) gesetzt wird. Bis zur + Domain-Entscheidung ist die App über IP:Port oder eine Subdomain-Platzhalter + erreichbar; Cookie-Settings (`SESSION_COOKIE_DOMAIN`) werden erst final gesetzt, + wenn die Domain feststeht. + +## Referenzdatei + +`reference/projekte.html` — der funktionsfähige HTML/JS-Prototyp (Dark Theme, +LocalStorage, Export/Import). Dient als Vorlage für: +- Farbpalette und CSS-Variablen (Basis für `static/css/main.css`) +- UI-Struktur (Stats-Leiste, Formular, Projekt-Karten, Aufgabenliste, Filter-Buttons) +- Fälligkeits-Badge-Logik (`dueBadge`-Funktion → als Jinja-Filter oder Python-Helper + übernehmen) +- Datenmodell-Felder (Project: id/name/color; Task: id/text/project/due/done) + + diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md new file mode 100644 index 0000000..91ec78b --- /dev/null +++ b/DEPLOYMENT.md @@ -0,0 +1,364 @@ +# DEPLOYMENT.md — Projekt-Hub auf dem VPS installieren + +Diese Anleitung beschreibt, wie die fertige Flask-App (siehe `CLAUDE.md` und +`TASKLISTE.md`) auf einem Ubuntu-VPS produktiv läuft. Zielarchitektur: + +``` +Internet → nginx (Port 80/443, TLS) → gunicorn (Unix-Socket) → Flask-App → SQLite +``` + +systemd sorgt dafür, dass die App automatisch beim Booten startet und bei einem Absturz neu startet. + +Vorausgesetzt: Ubuntu-VPS mit SSH-Zugang und sudo-Rechten. Domain ist optional — +diese Anleitung funktioniert zunächst auch nur mit der Server-IP (HTTPS dann erst, +sobald eine Domain feststeht). + +--- + +## 1. System vorbereiten + +```bash +sudo apt update && sudo apt upgrade -y +sudo apt install -y python3 python3-venv python3-pip nginx git +``` + +Eigenen, nicht-root Nutzer für die App anlegen (falls noch nicht vorhanden): + +```bash +sudo adduser projekthub +sudo usermod -aG sudo projekthub # optional, falls dieser Nutzer auch administrieren soll +``` + +Ab hier alles als `projekthub`-Nutzer ausführen (`su - projekthub`), außer wo +explizit `sudo` für Systemdienste nötig ist. + +--- + +## 2. Code auf den Server bringen + +Variante A — über Git (empfohlen, falls Repo z.B. auf einem privaten Git-Server +oder GitHub liegt): + +```bash +cd ~ +git clone projekt-hub +cd projekt-hub +``` + +Variante B — Dateien direkt hochladen (z.B. von deinem Rechner aus): + +```bash +scp -r projekt-hub/ projekthub@DEINE_SERVER_IP:~/projekt-hub +``` + +--- + +## 3. Python-Umgebung einrichten + +```bash +cd ~/projekt-hub +python3 -m venv .venv +source .venv/bin/activate +pip install --upgrade pip +pip install -r requirements.txt +``` + +`gunicorn` und der Redis-Client sind bereits in `requirements.txt` enthalten — +kein separates Installieren nötig. Der **Redis-Server** (für Rate-Limiting bei +mehreren Workern, siehe Abschnitt 8a) ist ein Systemdienst und wird separat +installiert: + +```bash +sudo apt install -y redis-server +sudo systemctl enable --now redis-server +``` + +--- + +## 4. Konfigurationsdatei `.env` anlegen + +```bash +cp .env.example .env +nano .env +``` + +Wichtige Werte für die Produktion: + +```ini +FLASK_ENV=production +SECRET_KEY= +# Relativer sqlite-Name wird automatisch in den instance/-Ordner aufgelöst +# (-> instance/hub.sqlite). Keinen instance/-Pfad voranstellen. +DATABASE_URL=sqlite:///hub.sqlite + +# Login +ALLOWED_EMAILS=deine@adresse.com + +# Rate-Limiting über gemeinsames Backend (mehrere Worker), siehe Abschnitt 8a +RATELIMIT_STORAGE_URI=redis://localhost:6379 + +# Mail (siehe Abschnitt 7) +MAIL_SERVER=127.0.0.1 +MAIL_PORT=1025 +MAIL_USERNAME=deine@adresse.com +MAIL_PASSWORD= +MAIL_USE_TLS=true + +# Domain (sobald bekannt, siehe Abschnitt 9) +SERVER_NAME= +SESSION_COOKIE_SECURE=true +``` + +Einen zufälligen `SECRET_KEY` generieren: + +```bash +python3 -c "import secrets; print(secrets.token_hex(32))" +``` + +`.env` darf **niemals** ins Git-Repo committen — sicherstellen, dass `.gitignore` +`.env` enthält. + +--- + +## 5. Datenbank initialisieren + +```bash +mkdir -p instance +flask db upgrade +``` + +Prüfen, dass `instance/hub.sqlite` existiert: + +```bash +ls -la instance/ +``` + +--- + +## 6. Testlauf mit dem Flask-Dev-Server (nur zur Kontrolle) + +```bash +flask run --host=0.0.0.0 --port=5000 +``` + +Im Browser `http://DEINE_SERVER_IP:5000` aufrufen — die Login-Seite sollte +erscheinen. Danach mit `Strg+C` wieder stoppen; der Dev-Server ist **nicht** für +Dauerbetrieb gedacht. + +--- + +## 7. E-Mail-Versand über Proton einrichten + +### Option A: Proton Mail Bridge (Docker) + +Die Bridge initialisiert beim Start lokale IMAP/SMTP-Server, über die die App +Mails verschicken kann. Auf dem VPS läuft sie containerisiert: + +```bash +sudo apt install -y docker.io +sudo systemctl enable --now docker + +docker volume create proton-bridge +docker run -d --name proton-bridge \ + -v proton-bridge:/root \ + -p 127.0.0.1:1025:25 \ + -p 127.0.0.1:1143:143 \ + spameier/proton-bridge +``` + +Danach einmalig in den Container einloggen und die Bridge mit den Proton-Zugangsdaten +verbinden: + +```bash +docker exec -it proton-bridge bridge --cli +# im Bridge-CLI: "login" eingeben und den Anweisungen folgen +``` + +Die Bridge zeigt danach lokale SMTP-Zugangsdaten (Benutzername + generiertes +Passwort) an — diese in die `.env` unter `MAIL_USERNAME` / `MAIL_PASSWORD` +eintragen, `MAIL_SERVER=127.0.0.1`, `MAIL_PORT=1025`. + +**Bekannte Stolpersteine:** Proton Bridge nutzt ein selbstsigniertes Zertifikat und initiiert den TLS-Handshake selbst, was bei direkter Relay-Einbindung zu Problemen führen kann. Falls der Mailversand aus Flask nicht klappt, hilft es, +Postfix als lokalen Zwischen-Relay zu betreiben, der an die Bridge weiterleitet — +oder direkt auf Option B auszuweichen. + +### Option B: EU-SMTP-Relay als Fallback + +Falls Option A zu instabil ist: bei einem europäischen Transactional-Mail-Anbieter +(z.B. Infomaniak, Mailjet EU) ein kostenloses/günstiges Konto anlegen, SMTP-Zugangsdaten +erhalten und in `.env` eintragen — `MAIL_SERVER`, `MAIL_PORT`, `MAIL_USERNAME`, +`MAIL_PASSWORD`, `MAIL_USE_TLS` entsprechend anpassen. **Am Flask-Code ändert sich +nichts**, da die App generisch über `Flask-Mail`/SMTP konfiguriert ist. + +### Testmail verschicken + +```bash +flask shell +``` +```python +from app.extensions import mail +from flask_mail import Message +msg = Message("Testmail Projekt-Hub", recipients=["deine@adresse.com"]) +msg.body = "Funktioniert!" +mail.send(msg) +``` + +--- + +## 8. gunicorn + systemd einrichten + +Die Gunicorn-Konfiguration liegt versioniert im Repo: **`gunicorn.conf.py`** +(Worker-Anzahl, Socket, Logging). Startbefehl testen (im `.venv`): + +```bash +gunicorn -c gunicorn.conf.py wsgi:app +``` + +Nginx an Gunicorn über einen Unix-Socket statt TCP-Port anzubinden reduziert den TCP-Overhead und ist die bevorzugte Variante, wenn beide auf demselben Host laufen. Mit `Strg+C` stoppen, danach den systemd-Service einrichten. + +Die systemd-Unit liegt ebenfalls im Repo (**`deploy/projekt-hub.service`**) und +lädt die Gunicorn-Config per `-c`. Kopieren und ggf. Pfade/Nutzer anpassen: + +```bash +sudo cp deploy/projekt-hub.service /etc/systemd/system/projekt-hub.service +sudo nano /etc/systemd/system/projekt-hub.service # Pfade/Nutzer prüfen +``` + +Die Worker-Anzahl steuert `gunicorn.conf.py` (Default: (2 × CPU-Kerne) + 1, +per env `GUNICORN_WORKERS` überschreibbar). + +Service aktivieren und starten: + +```bash +sudo systemctl daemon-reload +sudo systemctl enable projekt-hub +sudo systemctl start projekt-hub +sudo systemctl status projekt-hub +``` + +--- + +## 8a. Redis für Rate-Limiting + +Flask-Limiter (Login-Code-Anfragen) braucht bei **mehreren** gunicorn-Workern ein +gemeinsames Speicher-Backend — sonst zählt jeder Worker eigene Limits und das +Limit greift faktisch nicht. Redis wurde in Abschnitt 3 installiert; in der `.env` +verweist `RATELIMIT_STORAGE_URI` darauf: + +```ini +RATELIMIT_STORAGE_URI=redis://localhost:6379 +``` + +Damit Redis vor der App startet, in der systemd-Unit (auskommentierte Zeilen +aktivieren): + +```ini +After=network.target redis-server.service +Wants=redis-server.service +``` + +Schnelltest, dass Redis läuft: `redis-cli ping` → `PONG`. Wer bewusst nur **einen** +Worker fährt, kann auch bei `memory://` bleiben. + +Logs ansehen: + +```bash +sudo journalctl -u projekt-hub -f +``` + +--- + +## 9. nginx als Reverse Proxy + +Die nginx-Vorlage liegt im Repo: **`deploy/nginx/projekt-hub.conf`** (Socket-Proxy ++ Static-Auslieferung). Kopieren und `server_name` (zunächst Server-IP) anpassen: + +```bash +sudo cp deploy/nginx/projekt-hub.conf /etc/nginx/sites-available/projekt-hub +sudo nano /etc/nginx/sites-available/projekt-hub # server_name / Pfade prüfen +``` + +Aktivieren: + +```bash +sudo ln -s /etc/nginx/sites-available/projekt-hub /etc/nginx/sites-enabled/ +sudo nginx -t +sudo systemctl reload nginx +``` + +Im Browser `http://DEINE_SERVER_IP` aufrufen — die Login-Seite sollte jetzt über +nginx ausgeliefert werden. + +--- + +## 10. Domain & HTTPS (sobald die Domain feststeht) + +1. DNS A-Record der gewählten Domain/Subdomain auf die Server-IP zeigen lassen +2. In der nginx-Config `server_name` auf die Domain ändern, `nginx -t` + + `systemctl reload nginx` +3. Certbot installieren und Zertifikat holen: + ```bash + sudo apt install -y certbot python3-certbot-nginx + sudo certbot --nginx -d deine-domain.de + ``` + Certbot passt die nginx-Config automatisch für HTTPS + Redirect von HTTP an. +4. In `.env`: `SERVER_NAME=deine-domain.de`, `SESSION_COOKIE_SECURE=true`, + `PREFERRED_URL_SCHEME=https` setzen, danach App neu starten: + ```bash + sudo systemctl restart projekt-hub + ``` + +--- + +## 11. Updates einspielen + +Wenn neue Features (z.B. weitere Module) hinzukommen: + +```bash +cd ~/projekt-hub +git pull # oder neue Dateien per scp hochladen +source .venv/bin/activate +pip install -r requirements.txt # falls neue Abhängigkeiten +flask db upgrade # falls neue Migrationen +sudo systemctl restart projekt-hub +``` + +--- + +## 12. Backup + +Die gesamte App-Datenbank ist eine einzelne SQLite-Datei. Einfaches Backup per +Cronjob (täglich, Beispiel): + +```bash +crontab -e +``` + +Zeile hinzufügen: + +``` +0 3 * * * cp /home/projekthub/projekt-hub/instance/hub.sqlite /home/projekthub/backups/hub-$(date +\%F).sqlite +``` + +Vorher den Ordner anlegen: `mkdir -p ~/backups`. Da SQLite auch während Schreib- +zugriffen kopiert werden kann, ist für ein konsistentes Backup `sqlite3 hub.sqlite +".backup '/pfad/hub-$(date +%F).sqlite'"` die sauberere Variante. + +> Hinweis: Der frühere JSON-Export wurde durch den **PDF-Export** ersetzt (eine +> druckbare, projektbasierte To-Do-Liste, kein Datensicherungs-Format). Die +> Datensicherung erfolgt also ausschließlich über die SQLite-Datei wie oben. + +--- + +## 13. Sicherheits-Checkliste (kurz) + +- [ ] `.env` hat restriktive Dateirechte: `chmod 600 .env` +- [ ] `FLASK_ENV=production` gesetzt (kein Debug-Modus) +- [ ] Firewall (`ufw`) aktiv, nur Ports 22, 80, 443 offen: + ```bash + sudo ufw allow OpenSSH + sudo ufw allow 'Nginx Full' + sudo ufw enable + ``` +- [ ] SSH-Login per Passwort deaktiviert, nur Key-Auth (falls noch nicht der Fall) +- [ ] Regelmäßige `apt upgrade` einplanen (z.B. `unattended-upgrades`) diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/TASKLISTE.md b/TASKLISTE.md new file mode 100644 index 0000000..ef31129 --- /dev/null +++ b/TASKLISTE.md @@ -0,0 +1,166 @@ +# TASKLISTE — Projekt-Hub Schritt für Schritt + +Diese Liste begleitet dich von der Idee bis zur laufenden App auf deinem VPS. +Reihenfolge ist bewusst gewählt: erst lokal entwickeln und testen, dann deployen. +Häkchen kannst du selbst setzen, indem du `[ ]` zu `[x]` änderst. + +Querverweis: technische Details zum Server-Setup stehen in `DEPLOYMENT.md`. +Architektur- und Designentscheidungen stehen in `CLAUDE.md`. + +--- + +## Phase 0 — Vorbereitung (lokal, einmalig) + +- [ ] Python 3.11+ auf deinem Rechner installiert prüfen (`python3 --version`) +- [ ] Projektordner anlegen, z.B. `projekt-hub/` +- [ ] Git-Repository initialisieren (`git init`) — auch wenn du es nur lokal/privat + nutzt, hilft Versionskontrolle enorm +- [ ] `CLAUDE.md` und `reference/projekte.html` in den Ordner legen (sind bereits + vorbereitet) +- [ ] `.gitignore` anlegen mit mindestens: `.env`, `instance/`, `__pycache__/`, + `*.pyc`, `.venv/` + +--- + +## Phase 1 — Projektgerüst mit Claude erstellen + +- [ ] Mit Claude Code (oder Claude in diesem Chat) die `CLAUDE.md` als Kontext geben + und das Grundgerüst generieren lassen: + - App-Factory (`app/__init__.py`) + - Config-Klassen (`app/config.py`) + - Extensions (`app/extensions.py`: db, login_manager, mail) + - `requirements.txt` + - `.env.example` + - `wsgi.py` und `run.py` +- [ ] Virtuelle Umgebung anlegen und aktivieren: + ```bash + python3 -m venv .venv + source .venv/bin/activate + ``` +- [ ] Abhängigkeiten installieren: `pip install -r requirements.txt` +- [ ] `.env` aus `.env.example` erstellen und lokale Platzhalterwerte eintragen + (echte Proton-Zugangsdaten erst später, siehe Phase 4) +- [ ] App startet lokal ohne Fehler: `flask run` bzw. `python run.py` + +--- + +## Phase 2 — Datenmodell & Datenbank + +- [ ] Models erstellen lassen: `User`, `LoginCode`, `Project`, `Task` (siehe + Datenmodell-Abschnitt in `CLAUDE.md`) +- [ ] Flask-Migrate einrichten (`flask db init`) +- [ ] Erste Migration erzeugen und anwenden: + ```bash + flask db migrate -m "initial models" + flask db upgrade + ``` +- [ ] Prüfen, dass `instance/app.db` (oder ähnlich) erstellt wurde + +--- + +## Phase 3 — Auth-Modul (E-Mail-Code-Login) + +- [ ] Blueprint `auth` mit Routen `/login`, `/verify`, `/logout` erstellen lassen +- [ ] Templates `login.html` (E-Mail-Eingabe) und `verify.html` (Code-Eingabe) im + Dark-Theme-Stil des Prototyps +- [ ] Logik: 6-stelligen Code generieren, gehasht in `LoginCode` speichern, + `expires_at` = jetzt + 10 Minuten +- [ ] Logik: Code per Mail versenden (E-Mail-Versand erst mit Dummy/Konsole testen — + „Code wird in Terminal ausgegeben statt gemailt“, bis SMTP steht) +- [ ] Logik: Code-Verifizierung (korrekt? abgelaufen? schon benutzt?) +- [ ] Allowlist-Check: nur `ALLOWED_EMAILS` aus `.env` darf sich einloggen +- [ ] Flask-Login einrichten: `login_user`, `@login_required`, `current_user` +- [ ] Rate-Limiting für `/login` einrichten (Flask-Limiter), z.B. max. 5 Anfragen/Stunde +- [ ] Lokal testen: kompletter Login-Durchlauf mit Code aus Terminal-Ausgabe + +--- + +## Phase 4 — E-Mail-Versand mit Proton einrichten + +- [ ] Proton-Mail-Plan prüfen: unterstützt Bridge (zahlender Plan erforderlich) +- [ ] **Option A (Proton Bridge)**: Bridge-Container/Setup gemäß `DEPLOYMENT.md` + einrichten, lokale SMTP-Zugangsdaten generieren +- [ ] **Option B (Fallback EU-Relay)**: falls Bridge auf dem Server Probleme macht, + alternativen EU-SMTP-Anbieter einrichten (z.B. Infomaniak) — Zugangsdaten in + `.env` eintragen, Code bleibt unverändert (nur SMTP-Settings ändern sich) +- [ ] `.env` mit echten SMTP-Zugangsdaten füllen (`MAIL_SERVER`, `MAIL_PORT`, + `MAIL_USERNAME`, `MAIL_PASSWORD`, `MAIL_USE_TLS`) +- [ ] Testmail lokal verschicken (kleines Testskript oder über `/login`-Flow) +- [ ] Mail-Text prüfen: kurz, klar, kein Tracking, Code gut lesbar (z.B. groß, + monospace) + +--- + +## Phase 5 — Basis-Layout & Theme + +- [ ] `base.html` erstellen: Navigation (Platzhalter für künftige Module), Header, + Logout-Button +- [ ] `static/css/main.css` aus dem Farbschema von `reference/projekte.html` + übernehmen (CSS-Variablen: `--bg`, `--surface`, `--accent`, etc.) +- [ ] Login-/Verify-Templates an `base.html` anpassen +- [ ] Mobile-Ansicht prüfen (Viewport, Touch-Targets groß genug) + +--- + +## Phase 6 — Modul „Projekte“ (Kernfunktion) + +- [ ] Blueprint `projects` erstellen +- [ ] Routen: Übersicht, Projekt anlegen/löschen, Aufgabe anlegen/löschen/abhaken +- [ ] Template: Stats-Leiste (offen/erledigt/überfällig/Projekte) +- [ ] Template: Formular „Neue Aufgabe“ (Text, Projekt-Auswahl, Fälligkeitsdatum) +- [ ] Template: Projekt-Karten mit Aufgabenliste, Klapp-Funktion +- [ ] Template: „Alle Aufgaben“-Liste mit Filtern (alle/offen/überfällig/diese + Woche/erledigt) +- [ ] Fälligkeits-Badge-Logik aus dem Prototyp als Python-Helper/Jinja-Filter + übernehmen (überfällig/bald/ok/kein Datum) +- [ ] Alles `@login_required` und auf `current_user` beschränkt (jeder Nutzer sieht + nur eigene Daten) +- [ ] Manuell durchklicken: Projekt anlegen → Aufgaben anlegen → abhaken → filtern → + löschen — entspricht der Funktionalität des HTML-Prototyps + +--- + +## Phase 7 — Backup-Export + +- [ ] Route `/export` (oder „Einstellungen“-Bereich): erzeugt JSON-Datei mit allen + Projekten und Aufgaben des eingeloggten Users zum Download +- [ ] Kurzer Hinweistext in der UI: „Lade regelmäßig ein Backup herunter und speichere + es z.B. auf Proton Drive“ + +--- + +## Phase 8 — Lokaler Gesamttest + +- [ ] Kompletten Flow einmal komplett durchspielen: Login per Code → Projekte/Aufgaben + anlegen → Backup exportieren → Logout → erneut einloggen → Daten noch da? +- [ ] Mit zwei verschiedenen Browsern/Geräten im selben WLAN testen (Mobile-Ansicht!) +- [ ] `requirements.txt` final aktualisieren (`pip freeze > requirements.txt` — + danach manuell aufräumen, nur wirklich benötigte Pakete behalten) + +--- + +## Phase 9 — Deployment auf den VPS + +→ Details siehe `DEPLOYMENT.md`. Kurzfassung: + +- [ ] Code auf den VPS bringen (Git-Push/Pull oder `scp`) +- [ ] Python-Umgebung auf dem Server einrichten +- [ ] `.env` auf dem Server mit echten Produktionswerten anlegen (NICHT ins + Repo committen) +- [ ] Datenbank-Migration auf dem Server ausführen +- [ ] gunicorn + systemd-Service einrichten +- [ ] nginx als Reverse Proxy konfigurieren +- [ ] HTTPS via Let's Encrypt einrichten (sobald Domain feststeht) +- [ ] Proton-Mail-Versand auf dem Server testen (Testmail) +- [ ] Kompletten Login-Flow auf der echten Domain/IP testen + +--- + +## Phase 10 — Danach (spätere Erweiterungen, nicht jetzt) + +- [ ] Domain final festlegen, Cookie-Settings (`SESSION_COOKIE_DOMAIN`) anpassen +- [ ] Weitere Mini-Apps als neue Blueprints ergänzen (Navigation erweitert sich + automatisch, falls Registry-Pattern aus `CLAUDE.md` umgesetzt wurde) +- [ ] Mehrere E-Mail-Adressen zur Allowlist hinzufügen, falls gewünscht +- [ ] Regelmäßige automatische Backups einrichten (z.B. Cronjob, der die + SQLite-Datei sichert) diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..ad02470 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,85 @@ +"""App-Factory für den Projekt-Hub. + +Erzeugt die Flask-App, lädt die Config, bindet die Extensions und registriert +die Blueprints der einzelnen Mini-App-Module. Jedes neue Modul wird hier per +register_blueprint(...) eingebunden und über die zentrale Navigations-Registry +in der Navigation sichtbar gemacht — Kern bleibt unangetastet. +""" + +import os + +from flask import Flask, redirect, url_for + +from .config import get_config +from .extensions import db, migrate, login_manager, mail, limiter, csrf + + +# --- Zentrale Navigations-Registry --------------------------------------- +# Jedes Modul trägt hier einen Eintrag bei (Label, Endpoint, Icon). base.html +# rendert daraus die gemeinsame Navigation. Neues Modul = neuer Eintrag. +NAV_MODULES = [ + {"label": "Projekte", "endpoint": "projects.index", "icon": "📁"}, +] + + +def create_app(config_name: str | None = None) -> Flask: + app = Flask( + __name__, + instance_relative_config=True, + instance_path=os.path.join(os.path.dirname(os.path.dirname(__file__)), "instance"), + ) + app.config.from_object(get_config(config_name)) + + # instance/-Ordner sicherstellen (SQLite-Datei landet hier). + os.makedirs(app.instance_path, exist_ok=True) + + # Relativen sqlite-Pfad in den instance/-Ordner umbiegen. + uri = app.config.get("SQLALCHEMY_DATABASE_URI", "") + if uri.startswith("sqlite:///") and not uri.startswith("sqlite:////"): + db_name = uri.replace("sqlite:///", "", 1) + abs_path = os.path.join(app.instance_path, db_name) + app.config["SQLALCHEMY_DATABASE_URI"] = f"sqlite:///{abs_path}" + + _init_extensions(app) + _register_blueprints(app) + _register_context(app) + + @app.route("/") + def index(): + return redirect(url_for("projects.index")) + + return app + + +def _init_extensions(app: Flask) -> None: + db.init_app(app) + migrate.init_app(app, db) + login_manager.init_app(app) + mail.init_app(app) + limiter.init_app(app) + csrf.init_app(app) + + # Modelle importieren, damit SQLAlchemy/Alembic sie kennen (Autogenerate). + from . import models # noqa: F401 + from .projects import models as project_models # noqa: F401 + + from .models import User + + @login_manager.user_loader + def load_user(user_id): + return db.session.get(User, int(user_id)) + + +def _register_blueprints(app: Flask) -> None: + from .auth import bp as auth_bp + from .projects import bp as projects_bp + + app.register_blueprint(auth_bp) + app.register_blueprint(projects_bp) + + +def _register_context(app: Flask) -> None: + @app.context_processor + def inject_nav(): + # Navigations-Registry in allen Templates verfügbar machen. + return {"nav_modules": NAV_MODULES} diff --git a/app/auth/__init__.py b/app/auth/__init__.py new file mode 100644 index 0000000..8121a2e --- /dev/null +++ b/app/auth/__init__.py @@ -0,0 +1,11 @@ +"""Auth-Modul (Blueprint): E-Mail-Code-Login. + +Routen und Formulare folgen in Schritt 3. Hier wird vorerst nur der Blueprint +definiert, damit die App-Factory ihn registrieren kann. +""" + +from flask import Blueprint + +bp = Blueprint("auth", __name__, url_prefix="/auth") + +from . import routes # noqa: E402,F401 diff --git a/app/auth/forms.py b/app/auth/forms.py new file mode 100644 index 0000000..fd9afa0 --- /dev/null +++ b/app/auth/forms.py @@ -0,0 +1,24 @@ +"""Auth-Formulare: E-Mail-Eingabe, Code-Eingabe (Flask-WTF, mit CSRF-Schutz).""" + +from flask_wtf import FlaskForm +from wtforms import StringField, SubmitField +from wtforms.validators import DataRequired, Email, Regexp + + +class EmailForm(FlaskForm): + email = StringField( + "E-Mail", + validators=[DataRequired(), Email(message="Bitte eine gültige E-Mail eingeben.")], + ) + submit = SubmitField("Code anfordern") + + +class CodeForm(FlaskForm): + code = StringField( + "Code", + validators=[ + DataRequired(), + Regexp(r"^\d{6}$", message="Bitte den 6-stelligen Code eingeben."), + ], + ) + submit = SubmitField("Einloggen") diff --git a/app/auth/routes.py b/app/auth/routes.py new file mode 100644 index 0000000..bbd3433 --- /dev/null +++ b/app/auth/routes.py @@ -0,0 +1,105 @@ +"""Auth-Routen: /login (Code anfordern), /verify (Code prüfen), /logout.""" + +from urllib.parse import urlparse + +from flask import ( + render_template, + redirect, + url_for, + flash, + session, + request, + current_app, +) +from flask_login import login_user, logout_user, login_required, current_user + +from ..extensions import limiter +from ..models import User +from . import bp +from .forms import EmailForm, CodeForm +from .service import ( + email_allowed, + get_or_create_user, + issue_login_code, + send_login_code, + verify_code, +) + + +def _rate_key() -> str: + """Rate-Limit pro E-Mail (Fallback: IP), damit Code-Spam begrenzt ist.""" + return (request.form.get("email") or request.remote_addr or "").lower() + + +def _is_safe_next(target: str | None) -> bool: + """Open-Redirect verhindern: nur lokale, relative Pfade erlauben.""" + if not target: + return False + parsed = urlparse(target) + return not parsed.netloc and not parsed.scheme and target.startswith("/") + + +@bp.route("/login", methods=["GET", "POST"]) +@limiter.limit( + lambda: current_app.config["LOGIN_CODE_RATE_LIMIT"], + methods=["POST"], + key_func=_rate_key, +) +def login(): + if current_user.is_authenticated: + return redirect(url_for("projects.index")) + + form = EmailForm() + if form.validate_on_submit(): + email = form.email.data.lower().strip() + + if not email_allowed(email): + flash("Diese E-Mail-Adresse ist nicht freigeschaltet.", "error") + return render_template("auth/login.html", form=form) + + user = get_or_create_user(email) + code = issue_login_code(user) + try: + send_login_code(user, code) + except Exception: # noqa: BLE001 + flash("Code konnte nicht gesendet werden. Bitte später erneut versuchen.", "error") + return render_template("auth/login.html", form=form) + + session["auth_email"] = email + flash("Wir haben dir einen Code geschickt. Bitte gib ihn unten ein.", "info") + return redirect(url_for("auth.verify")) + + return render_template("auth/login.html", form=form) + + +@bp.route("/verify", methods=["GET", "POST"]) +def verify(): + if current_user.is_authenticated: + return redirect(url_for("projects.index")) + + email = session.get("auth_email") + if not email: + flash("Bitte zuerst deine E-Mail-Adresse eingeben.", "info") + return redirect(url_for("auth.login")) + + form = CodeForm() + if form.validate_on_submit(): + user = User.query.filter_by(email=email).first() + if user and verify_code(user, form.code.data.strip()): + login_user(user) + session.pop("auth_email", None) + flash("Eingeloggt. Willkommen zurück!", "success") + next_url = request.args.get("next") + return redirect(next_url if _is_safe_next(next_url) else url_for("projects.index")) + flash("Code ungültig oder abgelaufen. Bitte erneut versuchen.", "error") + + return render_template("auth/verify.html", form=form, email=email) + + +@bp.route("/logout") +@login_required +def logout(): + logout_user() + session.pop("auth_email", None) + flash("Du wurdest abgemeldet.", "info") + return redirect(url_for("auth.login")) diff --git a/app/auth/service.py b/app/auth/service.py new file mode 100644 index 0000000..e5a36f2 --- /dev/null +++ b/app/auth/service.py @@ -0,0 +1,95 @@ +"""Auth-Service-Logik: Allowlist, User-Anlage, Code-Erzeugung/-Versand/-Prüfung. + +Trennt die fachliche Logik von den Routen. Codes werden 6-stellig numerisch +erzeugt, nur gehasht gespeichert und per E-Mail versandt. +""" + +import secrets +from datetime import timedelta + +from flask import current_app +from flask_mail import Message + +from ..extensions import db, mail +from ..models import User, LoginCode, utcnow + + +def email_allowed(email: str) -> bool: + """True, wenn die E-Mail freigeschaltet ist. + + Leere Allowlist (z.B. lokale Entwicklung ohne .env) = keine Einschränkung. + In Produktion sollte ALLOWED_EMAILS gesetzt sein (Self-Registration begrenzt). + """ + allow = current_app.config.get("ALLOWED_EMAILS", []) + return (not allow) or (email.lower() in allow) + + +def get_or_create_user(email: str) -> User: + """User per E-Mail holen oder passwortlos anlegen (Self-Registration).""" + email = email.lower().strip() + user = User.query.filter_by(email=email).first() + if user is None: + user = User(email=email) + db.session.add(user) + db.session.commit() + return user + + +def issue_login_code(user: User) -> str: + """Neuen Login-Code erzeugen, alte unbenutzte Codes des Users verwerfen. + + Gibt den Klartext-Code zurück (nur zum sofortigen Versand) — gespeichert + wird ausschließlich der Hash. + """ + LoginCode.query.filter_by(user_id=user.id, used=False).delete() + + code = f"{secrets.randbelow(1_000_000):06d}" + ttl = current_app.config.get("LOGIN_CODE_TTL_MINUTES", 10) + lc = LoginCode(user_id=user.id, expires_at=utcnow() + timedelta(minutes=ttl)) + lc.set_code(code) + db.session.add(lc) + db.session.commit() + return code + + +def send_login_code(user: User, code: str) -> None: + """Code per E-Mail senden (Klartext, kein HTML/Tracking). + + Im Debug-Modus wird der Code zusätzlich geloggt und Versandfehler werden + verschluckt, damit lokal ohne SMTP getestet werden kann. In Produktion + wird eine Versand-Exception weitergereicht. + """ + ttl = current_app.config.get("LOGIN_CODE_TTL_MINUTES", 10) + msg = Message( + subject="Dein Login-Code für den Projekt-Hub", + recipients=[user.email], + body=( + "Hallo,\n\n" + f"dein Login-Code lautet: {code}\n\n" + f"Der Code ist {ttl} Minuten gültig und nur einmal verwendbar.\n" + "Wenn du keinen Login angefordert hast, ignoriere diese Mail.\n" + ), + ) + try: + mail.send(msg) + except Exception as exc: # noqa: BLE001 + current_app.logger.warning("Mailversand fehlgeschlagen: %s", exc) + if not current_app.debug: + raise + + if current_app.debug: + current_app.logger.info("DEV — Login-Code für %s: %s", user.email, code) + + +def verify_code(user: User, code: str) -> bool: + """Neuesten unbenutzten Code des Users prüfen; bei Erfolg als used markieren.""" + lc = ( + LoginCode.query.filter_by(user_id=user.id, used=False) + .order_by(LoginCode.created_at.desc()) + .first() + ) + if lc is None or not lc.is_valid(code): + return False + lc.used = True + db.session.commit() + return True diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..e5e0927 --- /dev/null +++ b/app/config.py @@ -0,0 +1,82 @@ +"""Config-Klassen für die Projekt-Hub-App. + +Liest Werte aus Umgebungsvariablen (.env wird in run.py / wsgi.py via +python-dotenv geladen). Keine Secrets im Code — alles über die Umgebung. +""" + +import os + + +def _as_bool(value: str | None, default: bool = False) -> bool: + if value is None: + return default + return value.strip().lower() in {"1", "true", "yes", "on"} + + +class Config: + """Basis-Config, gemeinsam für alle Umgebungen.""" + + SECRET_KEY = os.environ.get("SECRET_KEY", "dev-insecure-change-me") + + # --- Datenbank --- + # Default landet in der instance/-Datei (Pfad wird in der App-Factory gesetzt, + # falls hier ein relativer sqlite-Name steht). + SQLALCHEMY_DATABASE_URI = os.environ.get("DATABASE_URL", "sqlite:///hub.sqlite") + SQLALCHEMY_TRACK_MODIFICATIONS = False + + # --- Host / URL --- + SERVER_NAME = os.environ.get("SERVER_NAME") or None + PREFERRED_URL_SCHEME = "https" + + # --- Session-Cookie-Sicherheit --- + SESSION_COOKIE_HTTPONLY = True + SESSION_COOKIE_SAMESITE = "Lax" + # In Produktion über HTTPS erzwingen; in Dev (HTTP) abschaltbar. + SESSION_COOKIE_SECURE = _as_bool(os.environ.get("SESSION_COOKIE_SECURE"), default=True) + + # --- Auth --- + ALLOWED_EMAILS = [ + e.strip().lower() + for e in os.environ.get("ALLOWED_EMAILS", "").split(",") + if e.strip() + ] + LOGIN_CODE_TTL_MINUTES = int(os.environ.get("LOGIN_CODE_TTL_MINUTES", "10")) + LOGIN_CODE_RATE_LIMIT = os.environ.get("LOGIN_CODE_RATE_LIMIT", "5 per hour") + + # --- Rate-Limiting (Flask-Limiter) --- + # Speicher-Backend. Default memory:// (pro Prozess, reicht für Dev/Einzelworker). + # In Produktion mit mehreren gunicorn-Workern: gemeinsames Backend nötig, sonst + # zählt jeder Worker eigene Limits. Empfehlung: redis://localhost:6379 + RATELIMIT_STORAGE_URI = os.environ.get("RATELIMIT_STORAGE_URI", "memory://") + # Restzeit-/Limit-Header in Antworten mitschicken. + RATELIMIT_HEADERS_ENABLED = True + + # --- Mail --- + MAIL_SERVER = os.environ.get("MAIL_SERVER", "127.0.0.1") + MAIL_PORT = int(os.environ.get("MAIL_PORT", "1025")) + MAIL_USE_TLS = _as_bool(os.environ.get("MAIL_USE_TLS")) + MAIL_USE_SSL = _as_bool(os.environ.get("MAIL_USE_SSL")) + MAIL_USERNAME = os.environ.get("MAIL_USERNAME") or None + MAIL_PASSWORD = os.environ.get("MAIL_PASSWORD") or None + MAIL_DEFAULT_SENDER = os.environ.get("MAIL_DEFAULT_SENDER", "hub@example.com") + + +class DevelopmentConfig(Config): + DEBUG = True + SESSION_COOKIE_SECURE = _as_bool(os.environ.get("SESSION_COOKIE_SECURE"), default=False) + + +class ProductionConfig(Config): + DEBUG = False + + +config_by_name = { + "development": DevelopmentConfig, + "production": ProductionConfig, +} + + +def get_config(name: str | None = None): + """Wählt die Config-Klasse anhand von FLASK_ENV (Default: development).""" + name = name or os.environ.get("FLASK_ENV", "development") + return config_by_name.get(name, DevelopmentConfig) diff --git a/app/extensions.py b/app/extensions.py new file mode 100644 index 0000000..ca3083a --- /dev/null +++ b/app/extensions.py @@ -0,0 +1,26 @@ +"""Zentrale Instanziierung der Flask-Extensions. + +Hier werden die Extension-Objekte ohne App erzeugt und in der App-Factory +(app/__init__.py) via init_app(app) gebunden. So vermeiden wir zirkuläre +Importe und können Module sauber auf gemeinsame Extensions zugreifen. +""" + +from flask_sqlalchemy import SQLAlchemy +from flask_migrate import Migrate +from flask_login import LoginManager +from flask_mail import Mail +from flask_limiter import Limiter +from flask_limiter.util import get_remote_address +from flask_wtf import CSRFProtect + +db = SQLAlchemy() +migrate = Migrate() +login_manager = LoginManager() +mail = Mail() +limiter = Limiter(key_func=get_remote_address) +csrf = CSRFProtect() + +# Auth-Konfiguration: Wohin umleiten, wenn Login nötig ist. +login_manager.login_view = "auth.login" +login_manager.login_message = "Bitte zuerst einloggen." +login_manager.login_message_category = "info" diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..fe8467f --- /dev/null +++ b/app/models.py @@ -0,0 +1,84 @@ +"""Gemeinsame Modelle: User, LoginCode. + +Modul-spezifische Modelle (Project, Task) liegen in app/projects/models.py. +Alle Zeitstempel werden in UTC gespeichert. +""" + +from datetime import datetime, timezone + +from flask_login import UserMixin +from werkzeug.security import generate_password_hash, check_password_hash + +from .extensions import db + + +def utcnow() -> datetime: + """Zeitzonenbewusster UTC-Zeitstempel (für DB-Defaults).""" + return datetime.now(timezone.utc) + + +def _as_aware(dt: datetime) -> datetime: + """SQLite gibt Datetimes naiv zurück — als UTC interpretieren.""" + return dt.replace(tzinfo=timezone.utc) if dt.tzinfo is None else dt + + +class User(UserMixin, db.Model): + __tablename__ = "users" + + id = db.Column(db.Integer, primary_key=True) + email = db.Column(db.String(255), unique=True, nullable=False, index=True) + created_at = db.Column(db.DateTime, default=utcnow, nullable=False) + + login_codes = db.relationship( + "LoginCode", + back_populates="user", + cascade="all, delete-orphan", + ) + projects = db.relationship( + "Project", + back_populates="user", + cascade="all, delete-orphan", + ) + tasks = db.relationship( + "Task", + back_populates="user", + cascade="all, delete-orphan", + ) + + def __repr__(self) -> str: + return f"" + + +class LoginCode(db.Model): + """Einmaliger, zeitlich begrenzter Login-Code (gehasht gespeichert).""" + + __tablename__ = "login_codes" + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column( + db.Integer, db.ForeignKey("users.id"), nullable=False, index=True + ) + code_hash = db.Column(db.String(255), nullable=False) + created_at = db.Column(db.DateTime, default=utcnow, nullable=False) + expires_at = db.Column(db.DateTime, nullable=False) + used = db.Column(db.Boolean, default=False, nullable=False) + + user = db.relationship("User", back_populates="login_codes") + + def set_code(self, code: str) -> None: + """Klartext-Code hashen und ablegen (nie im Klartext speichern).""" + self.code_hash = generate_password_hash(code) + + def check_code(self, code: str) -> bool: + return check_password_hash(self.code_hash, code) + + @property + def is_expired(self) -> bool: + return utcnow() > _as_aware(self.expires_at) + + def is_valid(self, code: str) -> bool: + """Code ist gültig: nicht benutzt, nicht abgelaufen, korrekt.""" + return (not self.used) and (not self.is_expired) and self.check_code(code) + + def __repr__(self) -> str: + return f"" diff --git a/app/projects/__init__.py b/app/projects/__init__.py new file mode 100644 index 0000000..5b66829 --- /dev/null +++ b/app/projects/__init__.py @@ -0,0 +1,17 @@ +"""Modul 1: Projekte & Aufgaben (Blueprint). + +Routen, Modelle (Project, Task) und Templates folgen in Schritt 5. Templates +liegen unter app/projects/templates/projects/ und werden über den Blueprint +gefunden. +""" + +from flask import Blueprint + +bp = Blueprint( + "projects", + __name__, + url_prefix="/projekte", + template_folder="templates", +) + +from . import routes # noqa: E402,F401 diff --git a/app/projects/fonts/DejaVuSans-Bold.ttf b/app/projects/fonts/DejaVuSans-Bold.ttf new file mode 100644 index 0000000..7d6b762 Binary files /dev/null and b/app/projects/fonts/DejaVuSans-Bold.ttf differ diff --git a/app/projects/fonts/DejaVuSans.ttf b/app/projects/fonts/DejaVuSans.ttf new file mode 100644 index 0000000..0692d93 Binary files /dev/null and b/app/projects/fonts/DejaVuSans.ttf differ diff --git a/app/projects/helpers.py b/app/projects/helpers.py new file mode 100644 index 0000000..d0dc036 --- /dev/null +++ b/app/projects/helpers.py @@ -0,0 +1,70 @@ +"""Hilfsfunktionen des Projekte-Moduls. + +Enthält die feste Farbpalette (zyklisch zugewiesen), die Fälligkeits-Badge-Logik +(portiert aus dem Prototyp `dueBadge`), die Aufgaben-Sortierung und ein +locale-unabhängiges deutsches Datumslabel. +""" + +from datetime import date + +# Feste Palette wie im Prototyp — neue Projekte bekommen zyklisch die nächste Farbe. +PROJECT_COLORS = [ + "#e94560", "#f5a623", "#4ecca3", "#a78bfa", "#60a5fa", + "#f472b6", "#34d399", "#fb923c", "#818cf8", "#38bdf8", +] + +_WEEKDAYS_DE = [ + "Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag", "Sonntag", +] +_MONTHS_DE = [ + "Januar", "Februar", "März", "April", "Mai", "Juni", + "Juli", "August", "September", "Oktober", "November", "Dezember", +] + + +def next_color(existing_count: int) -> str: + """Nächste Farbe aus der Palette (zyklisch nach Anzahl vorhandener Projekte).""" + return PROJECT_COLORS[existing_count % len(PROJECT_COLORS)] + + +def _fmt(d: date) -> str: + """Datum als dd.mm.yy (wie im Prototyp).""" + return f"{d.day:02d}.{d.month:02d}.{str(d.year)[2:]}" + + +def due_badge(due: date | None, done: bool) -> dict: + """Fälligkeits-Badge: CSS-Klasse + Text. Portiert aus dem Prototyp. + + - kein Datum -> 'kein Datum' (neutral) + - erledigt -> Datum neutral + - überfällig -> rot, mit Tagen drüber + - <= 7 Tage -> orange, mit Resttagen + - sonst -> grün + """ + if due is None: + return {"cls": "due-none", "text": "kein Datum"} + + label = _fmt(due) + if done: + return {"cls": "due-none", "text": label} + + diff = (due - date.today()).days + if diff < 0: + return {"cls": "due-overdue", "text": f"⚠ {label} ({abs(diff)}d über)"} + if diff <= 7: + return {"cls": "due-soon", "text": f"⏳ {label} ({diff}d)"} + return {"cls": "due-ok", "text": f"📅 {label}"} + + +def sort_tasks(tasks: list) -> list: + """Sortierung wie im Prototyp: offen vor erledigt, dann nach Datum (ohne zuletzt).""" + return sorted( + tasks, + key=lambda t: (t.done, t.due_date is None, t.due_date or date.max), + ) + + +def today_label() -> str: + """Deutsches Datumslabel ohne Locale-Abhängigkeit (z.B. 'Donnerstag, 11. Juni 2026').""" + d = date.today() + return f"{_WEEKDAYS_DE[d.weekday()]}, {d.day}. {_MONTHS_DE[d.month - 1]} {d.year}" diff --git a/app/projects/models.py b/app/projects/models.py new file mode 100644 index 0000000..2792d96 --- /dev/null +++ b/app/projects/models.py @@ -0,0 +1,50 @@ +"""Modelle des Projekte-Moduls: Project, Task. + +Aufgaben dürfen ohne Projekt existieren (project_id nullable). Beim Löschen +eines Projekts werden seine Aufgaben nicht gelöscht, sondern in der Routen-Logik +(Schritt 5) auf project_id=None gesetzt. +""" + +from ..extensions import db +from ..models import utcnow + + +class Project(db.Model): + __tablename__ = "projects" + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column( + db.Integer, db.ForeignKey("users.id"), nullable=False, index=True + ) + name = db.Column(db.String(120), nullable=False) + # Hex-Farbe aus fester Palette (z.B. "#5b8def"). + color = db.Column(db.String(7), nullable=False, default="#5b8def") + created_at = db.Column(db.DateTime, default=utcnow, nullable=False) + + user = db.relationship("User", back_populates="projects") + tasks = db.relationship("Task", back_populates="project") + + def __repr__(self) -> str: + return f"" + + +class Task(db.Model): + __tablename__ = "tasks" + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column( + db.Integer, db.ForeignKey("users.id"), nullable=False, index=True + ) + project_id = db.Column( + db.Integer, db.ForeignKey("projects.id"), nullable=True, index=True + ) + text = db.Column(db.String(500), nullable=False) + due_date = db.Column(db.Date, nullable=True) + done = db.Column(db.Boolean, default=False, nullable=False) + created_at = db.Column(db.DateTime, default=utcnow, nullable=False) + + user = db.relationship("User", back_populates="tasks") + project = db.relationship("Project", back_populates="tasks") + + def __repr__(self) -> str: + return f"" diff --git a/app/projects/pdf.py b/app/projects/pdf.py new file mode 100644 index 0000000..ddb37ef --- /dev/null +++ b/app/projects/pdf.py @@ -0,0 +1,175 @@ +"""PDF-Export: projektbasierte To-Do-Liste zum Ausdrucken. + +Erzeugt mit fpdf2 (reines Python, keine System-Abhängigkeiten). Die DejaVu- +Schrift ist im Repo mitgeliefert (app/projects/fonts/), damit das Ergebnis auf +dem VPS unabhängig von dort installierten Fonts identisch aussieht und beliebiger +Aufgabentext (Umlaute, Sonderzeichen) korrekt gerendert wird. + +Inhalt laut Festlegung: offene UND erledigte Aufgaben (erledigte abgehakt und +durchgestrichen), Aufgaben ohne Projekt in einem eigenen Abschnitt am Ende. +""" + +import os +from datetime import date + +from fpdf import FPDF +from fpdf.enums import XPos, YPos + +from .helpers import sort_tasks, today_label + +FONT_DIR = os.path.join(os.path.dirname(__file__), "fonts") + +# Farben (RGB) — bewusst auch in S/W-Druck lesbar (Info steht zusätzlich als Text). +COL_TEXT = (25, 25, 25) +COL_MUTED = (130, 130, 130) +COL_OVERDUE = (200, 50, 60) +COL_SOON = (190, 120, 20) +COL_RULE = (210, 210, 210) + + +def _hex_to_rgb(h: str) -> tuple: + h = h.lstrip("#") + return tuple(int(h[i : i + 2], 16) for i in (0, 2, 4)) + + +def _due_text(due: date | None, done: bool): + """Fälligkeit als reiner Text + Farbe (ohne Emoji, druckfreundlich).""" + if due is None: + return None, COL_MUTED + label = f"{due.day:02d}.{due.month:02d}.{str(due.year)[2:]}" + if done: + return label, COL_MUTED + diff = (due - date.today()).days + if diff < 0: + return f"{label} · {abs(diff)} Tag(e) überfällig", COL_OVERDUE + if diff == 0: + return f"{label} · heute fällig", COL_SOON + if diff <= 7: + return f"{label} · in {diff} Tag(en)", COL_SOON + return label, COL_MUTED + + +class TodoPDF(FPDF): + def __init__(self): + super().__init__(format="A4") + self.set_auto_page_break(auto=True, margin=18) + self.set_margins(15, 15, 15) + self.add_font("DejaVu", "", os.path.join(FONT_DIR, "DejaVuSans.ttf")) + self.add_font("DejaVu", "B", os.path.join(FONT_DIR, "DejaVuSans-Bold.ttf")) + + def header(self): + self.set_font("DejaVu", "B", 15) + self.set_text_color(*COL_TEXT) + self.cell(0, 9, "Projekte & Aufgaben", new_x=XPos.LMARGIN, new_y=YPos.NEXT) + self.set_font("DejaVu", "", 9) + self.set_text_color(*COL_MUTED) + self.cell(0, 5, today_label(), new_x=XPos.LMARGIN, new_y=YPos.NEXT) + self.ln(3) + self.set_draw_color(*COL_RULE) + self.set_line_width(0.2) + y = self.get_y() + self.line(self.l_margin, y, self.w - self.r_margin, y) + self.ln(4) + + def footer(self): + self.set_y(-14) + self.set_font("DejaVu", "", 8) + self.set_text_color(*COL_MUTED) + self.cell(0, 5, f"Seite {self.page_no()}", align="C") + + +def _checkbox(pdf: TodoPDF, done: bool, size: float = 3.6) -> None: + """Kästchen als Vektor zeichnen (scharf im Druck); bei done mit Haken.""" + x, y = pdf.get_x(), pdf.get_y() + top = y + 1.1 + pdf.set_draw_color(*COL_TEXT) + pdf.set_line_width(0.3) + pdf.rect(x, top, size, size) + if done: + pdf.line(x + 0.7, top + size * 0.55, x + size * 0.42, top + size - 0.6) + pdf.line(x + size * 0.42, top + size - 0.6, x + size - 0.4, top + 0.6) + pdf.set_x(x + size + 2.5) + + +def _task_line(pdf: TodoPDF, task) -> None: + pdf.set_x(pdf.l_margin + 4) + _checkbox(pdf, task.done) + + text_x = pdf.get_x() + avail = pdf.w - pdf.r_margin - text_x + + pdf.set_font("DejaVu", "", 10.5) + pdf.set_text_color(*(COL_MUTED if task.done else COL_TEXT)) + y_before = pdf.get_y() + pdf.multi_cell(avail, 5.2, task.text, new_x=XPos.LMARGIN, new_y=YPos.NEXT) + + if task.done: + width = min(avail, pdf.get_string_width(task.text)) + pdf.set_draw_color(*COL_MUTED) + pdf.set_line_width(0.3) + pdf.line(text_x, y_before + 2.6, text_x + width, y_before + 2.6) + + due_str, col = _due_text(task.due_date, task.done) + if due_str: + pdf.set_x(text_x) + pdf.set_font("DejaVu", "", 8.5) + pdf.set_text_color(*col) + pdf.cell(0, 4.4, due_str, new_x=XPos.LMARGIN, new_y=YPos.NEXT) + + pdf.ln(1.4) + + +def _section_header(pdf: TodoPDF, name: str, color_hex, open_count: int, total: int) -> None: + pdf.ln(2.5) + if color_hex: + x, y = pdf.get_x(), pdf.get_y() + pdf.set_fill_color(*_hex_to_rgb(color_hex)) + pdf.ellipse(x, y + 1.6, 3, 3, style="F") + pdf.set_x(x + 5) + + pdf.set_font("DejaVu", "B", 12) + pdf.set_text_color(*COL_TEXT) + pdf.cell(pdf.get_string_width(name) + 1, 6, name, new_x=XPos.RIGHT, new_y=YPos.TOP) + + pdf.set_font("DejaVu", "", 9) + pdf.set_text_color(*COL_MUTED) + pdf.cell(0, 6, f" ({open_count} offen · {total} gesamt)", + new_x=XPos.LMARGIN, new_y=YPos.NEXT) + pdf.ln(1) + + +def build_todo_pdf(projects: list, tasks: list) -> bytes: + """Baut die druckbare To-Do-PDF aus Projekten und Aufgaben des Users.""" + by_pid = {} + for t in tasks: + by_pid.setdefault(t.project_id, []).append(t) + + pdf = TodoPDF() + pdf.add_page() + + rendered = False + for p in projects: + ptasks = sort_tasks(by_pid.get(p.id, [])) + if not ptasks: + continue + rendered = True + open_count = sum(1 for t in ptasks if not t.done) + _section_header(pdf, p.name, p.color, open_count, len(ptasks)) + for t in ptasks: + _task_line(pdf, t) + + no_project = sort_tasks(by_pid.get(None, [])) + if no_project: + rendered = True + open_count = sum(1 for t in no_project if not t.done) + _section_header(pdf, "Ohne Projekt", None, open_count, len(no_project)) + for t in no_project: + _task_line(pdf, t) + + if not rendered: + pdf.set_font("DejaVu", "", 11) + pdf.set_text_color(*COL_MUTED) + pdf.cell(0, 8, "Keine Aufgaben vorhanden.", + new_x=XPos.LMARGIN, new_y=YPos.NEXT) + + return bytes(pdf.output()) diff --git a/app/projects/routes.py b/app/projects/routes.py new file mode 100644 index 0000000..f7162ab --- /dev/null +++ b/app/projects/routes.py @@ -0,0 +1,206 @@ +"""Routen des Projekte-Moduls — voller Funktionsumfang aus dem HTML-Prototyp. + +Alles ist login_required und strikt auf current_user gescoped. Schreibende +Aktionen laufen als POST + Redirect (PRG-Muster), damit kein Reload doppelt +ausführt. +""" + +from datetime import date, datetime, timedelta +from urllib.parse import urlparse + +from flask import render_template, request, redirect, url_for, flash, Response +from flask_login import login_required, current_user + +from ..extensions import db +from .models import Project, Task +from .helpers import next_color, due_badge, sort_tasks, today_label +from .pdf import build_todo_pdf +from . import bp + +VALID_FILTERS = {"all", "open", "overdue", "soon", "done"} + + +def _redirect_back(): + """Zur vorigen Ansicht zurück (erhält z.B. den Filter), sonst zur Übersicht.""" + ref = request.referrer + if ref: + parsed = urlparse(ref) + if not parsed.netloc or parsed.netloc == urlparse(request.host_url).netloc: + return redirect(ref) + return redirect(url_for("projects.index")) + + +def _owned_project_or_none(project_id): + """Projekt des aktuellen Users per (string) ID holen — oder None.""" + try: + pid = int(project_id) + except (TypeError, ValueError): + return None + return Project.query.filter_by(id=pid, user_id=current_user.id).first() + + +@bp.route("/") +@login_required +def index(): + flt = request.args.get("filter", "all") + if flt not in VALID_FILTERS: + flt = "all" + + today = date.today() + week = today + timedelta(days=7) + + projects = ( + Project.query.filter_by(user_id=current_user.id) + .order_by(Project.created_at) + .all() + ) + tasks = Task.query.filter_by(user_id=current_user.id).all() + projects_by_id = {p.id: p for p in projects} + + # Statistik-Leiste + stats = { + "open": sum(1 for t in tasks if not t.done), + "done": sum(1 for t in tasks if t.done), + "overdue": sum(1 for t in tasks if not t.done and t.due_date and t.due_date < today), + "projects": len(projects), + } + + # Ansicht "Nach Projekt" + project_views = [] + for p in projects: + ptasks = sort_tasks([t for t in tasks if t.project_id == p.id]) + project_views.append( + { + "project": p, + "tasks": ptasks, + "open": sum(1 for t in ptasks if not t.done), + "total": len(ptasks), + } + ) + proj_open_total = sum(v["open"] for v in project_views) + + # Ansicht "Alle Aufgaben" (gefiltert) + def keep(t): + if flt == "open": + return not t.done + if flt == "done": + return t.done + if flt == "overdue": + return not t.done and t.due_date and t.due_date < today + if flt == "soon": + return not t.done and t.due_date and today <= t.due_date <= week + return True + + all_tasks = sort_tasks([t for t in tasks if keep(t)]) + + return render_template( + "projects/index.html", + projects=projects, + projects_by_id=projects_by_id, + project_views=project_views, + proj_open_total=proj_open_total, + all_tasks=all_tasks, + stats=stats, + active_filter=flt, + today_label=today_label(), + ) + + +# ── Projekte ───────────────────────────────────────────────────────────── +@bp.route("/projects", methods=["POST"]) +@login_required +def create_project(): + name = (request.form.get("name") or "").strip() + if not name: + flash("Projektname fehlt.", "error") + return _redirect_back() + + count = Project.query.filter_by(user_id=current_user.id).count() + project = Project(user_id=current_user.id, name=name[:120], color=next_color(count)) + db.session.add(project) + db.session.commit() + return _redirect_back() + + +@bp.route("/projects//delete", methods=["POST"]) +@login_required +def delete_project(project_id): + project = Project.query.filter_by(id=project_id, user_id=current_user.id).first_or_404() + # Aufgaben bleiben erhalten, verlieren nur die Projektzuordnung. + for t in project.tasks: + t.project_id = None + db.session.delete(project) + db.session.commit() + flash("Projekt gelöscht. Aufgaben bleiben erhalten.", "info") + return _redirect_back() + + +# ── Aufgaben ───────────────────────────────────────────────────────────── +@bp.route("/tasks", methods=["POST"]) +@login_required +def create_task(): + text = (request.form.get("text") or "").strip() + if not text: + flash("Aufgabentext fehlt.", "error") + return _redirect_back() + + project = _owned_project_or_none(request.form.get("project_id")) + due_raw = (request.form.get("due_date") or "").strip() + due = None + if due_raw: + try: + due = datetime.strptime(due_raw, "%Y-%m-%d").date() + except ValueError: + due = None + + task = Task( + user_id=current_user.id, + text=text[:500], + project_id=project.id if project else None, + due_date=due, + ) + db.session.add(task) + db.session.commit() + return _redirect_back() + + +@bp.route("/tasks//toggle", methods=["POST"]) +@login_required +def toggle_task(task_id): + task = Task.query.filter_by(id=task_id, user_id=current_user.id).first_or_404() + task.done = not task.done + db.session.commit() + return _redirect_back() + + +@bp.route("/tasks//delete", methods=["POST"]) +@login_required +def delete_task(task_id): + task = Task.query.filter_by(id=task_id, user_id=current_user.id).first_or_404() + db.session.delete(task) + db.session.commit() + return _redirect_back() + + +# ── PDF-Export (druckbare To-Do-Liste) ────────────────────────────────── +@bp.route("/export.pdf") +@login_required +def export_pdf(): + projects = ( + Project.query.filter_by(user_id=current_user.id) + .order_by(Project.created_at) + .all() + ) + tasks = Task.query.filter_by(user_id=current_user.id).all() + pdf_bytes = build_todo_pdf(projects, tasks) + + filename = f"aufgaben_{date.today().isoformat()}.pdf" + return Response( + pdf_bytes, + mimetype="application/pdf", + headers={"Content-Disposition": f"inline; filename={filename}"}, + ) + + +# Fälligkeits-Badge als Template-Global verfügbar machen. +bp.add_app_template_global(due_badge, "due_badge") diff --git a/app/projects/templates/projects/.gitkeep b/app/projects/templates/projects/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/app/projects/templates/projects/_macros.html b/app/projects/templates/projects/_macros.html new file mode 100644 index 0000000..3059447 --- /dev/null +++ b/app/projects/templates/projects/_macros.html @@ -0,0 +1,21 @@ +{# Wiederverwendbare Aufgabenzeile — genutzt in Projektkarten und der "Alle Aufgaben"-Liste. + show_project: ob das Projekt-Tag angezeigt wird (in der Projektkarte unnötig). #} +{% macro task_row(task, show_project, projects_by_id) %} + {% set badge = due_badge(task.due_date, task.done) %} +
+
+ + +
+
+ {{ task.text }}{% if show_project and task.project_id and projects_by_id.get(task.project_id) %}{% set p = projects_by_id[task.project_id] %}{{ p.name }}{% endif %} +
{{ badge.text }}
+
+
+ + +
+
+{% endmacro %} diff --git a/app/projects/templates/projects/index.html b/app/projects/templates/projects/index.html new file mode 100644 index 0000000..f683f1a --- /dev/null +++ b/app/projects/templates/projects/index.html @@ -0,0 +1,121 @@ +{% extends "base.html" %} +{% from "projects/_macros.html" import task_row %} +{% block title %}Projekte & Aufgaben · Projekt-Hub{% endblock %} +{% block content %} + +
+

Projekte & Aufgaben

+
{{ today_label }}
+
+ + {# ── Statistik-Leiste ── #} +
+
{{ stats.open }}Offen
+
{{ stats.done }}Erledigt
+
{{ stats.overdue }}Überfällig
+
{{ stats.projects }}Projekte
+
+ + {# ── Druckbare PDF-Liste ── #} +
+ Drucken + PDF / Druckliste + Projektbasierte To-Do-Liste als PDF zum Ausdrucken +
+ + {# ── Aufgabe hinzufügen ── #} +
+ +
+ +
+
+ + + +
+
+ + {# ── Projekt anlegen ── #} +
+ + Projekt: + + +
+ + {# ── Ansicht: Nach Projekt ── #} +
+
+ Nach Projekt + {{ proj_open_total }} offen + +
+ +
+ {% for view in project_views %} + {% set p = view.project %} +
+
+ + {{ p.name }} + {{ view.open }} offen · {{ view.total }} gesamt +
+ + +
+
+
+ {% for t in view.tasks %} + {{ task_row(t, false, projects_by_id) }} + {% else %} +
Keine Aufgaben — oben hinzufügen.
+ {% endfor %} +
+
+ {% else %} +
Noch keine Projekte — oben anlegen.
+ {% endfor %} +
+
+ +
+ + {# ── Ansicht: Alle Aufgaben (gefiltert) ── #} +
+
+ Alle Aufgaben + {{ all_tasks|length }} +
+ +
+ Zeigen: + {% set filters = [('all', 'Alle'), ('open', 'Offen'), ('overdue', 'Überfällig'), + ('soon', 'Diese Woche'), ('done', 'Erledigt')] %} + {% for key, label in filters %} + {{ label }} + {% endfor %} +
+ +
+
+ {% for t in all_tasks %} + {{ task_row(t, true, projects_by_id) }} + {% else %} +
Keine Aufgaben in dieser Ansicht.
+ {% endfor %} +
+
+
+ +{% endblock %} diff --git a/app/static/css/main.css b/app/static/css/main.css new file mode 100644 index 0000000..3837ca1 --- /dev/null +++ b/app/static/css/main.css @@ -0,0 +1,485 @@ +/* ========================================================================= + Projekt-Hub — Dark-Theme-Basis + Palette & Komponenten aus dem Prototyp (reference/projekte.html) übernommen + und als gemeinsames Theme für den ganzen Hub aufbereitet. Module nutzen + diese Variablen und Komponentenklassen. + ========================================================================= */ + +* { box-sizing: border-box; margin: 0; padding: 0; } + +:root { + --bg: #1a1a2e; + --surface: #16213e; + --card: #0f3460; + --accent: #e94560; + --accent2: #f5a623; + --text: #eaeaea; + --muted: #8892a4; + --done: #3a4a5c; + --done-text: #566272; + --overdue: #ff6b6b; + --soon: #f5a623; + --ok: #4ecca3; + --border: rgba(255, 255, 255, 0.07); + --font: 'Segoe UI', system-ui, sans-serif; +} + +body { + background: var(--bg); + color: var(--text); + font-family: var(--font); + min-height: 100vh; +} + +a { color: var(--accent); } + +/* ========================================================================= + Hub-Navigation (gemeinsame Leiste über allen Modulen) + ========================================================================= */ +.hub-nav { + display: flex; + align-items: center; + gap: 1.5rem; + padding: 0.85rem 1.5rem; + background: var(--surface); + border-bottom: 1px solid var(--border); +} + +.hub-brand { + font-weight: 700; + font-size: 1.05rem; + letter-spacing: -0.02em; + color: var(--text); + text-decoration: none; +} +.hub-brand span { color: var(--accent); } + +.hub-nav-list { + display: flex; + gap: 0.4rem; + list-style: none; +} +.hub-nav-list a { + color: var(--muted); + text-decoration: none; + font-size: 0.85rem; + padding: 0.35rem 0.7rem; + border-radius: 20px; + transition: background 0.15s, color 0.15s; +} +.hub-nav-list a:hover { color: var(--text); background: var(--card); } + +.hub-nav-auth { margin-left: auto; display: flex; align-items: center; gap: 0.75rem; } +.hub-user { color: var(--muted); font-size: 0.82rem; } +.hub-nav-auth a { + color: var(--muted); + text-decoration: none; + font-size: 0.82rem; + transition: color 0.15s; +} +.hub-nav-auth a:hover { color: var(--accent); } + +.hub-main { + max-width: 760px; + margin: 0 auto; + padding: 2rem 1rem 4rem; +} + +.muted { color: var(--muted); } +.hidden { display: none; } + +/* Lösch-Formulare in Aufgaben/Projekten sollen das Flex-Layout nicht stören. */ +.task form, .project-card-header form { display: inline-flex; } + +/* ========================================================================= + Flash-Meldungen + ========================================================================= */ +.flashes { display: flex; flex-direction: column; gap: 0.5rem; margin-bottom: 1.5rem; } +.flash { + padding: 0.65rem 0.9rem; + border-radius: 8px; + border: 1px solid var(--border); + font-size: 0.9rem; +} +.flash-info { background: var(--surface); color: var(--text); } +.flash-success { background: rgba(78, 204, 163, 0.12); border-color: var(--ok); color: var(--ok); } +.flash-error { background: rgba(255, 107, 107, 0.14); border-color: var(--overdue); color: var(--overdue); } + +/* ========================================================================= + Auth-Karten / Formulare + ========================================================================= */ +.auth-card { + max-width: 380px; + margin: 3rem auto; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 12px; + padding: 1.75rem; +} +.auth-card h1 { margin-top: 0; margin-bottom: 0.4rem; font-size: 1.4rem; } +.auth-card .muted { margin-bottom: 1.2rem; font-size: 0.88rem; line-height: 1.5; } + +.stack { display: flex; flex-direction: column; gap: 0.5rem; } +.stack label { font-size: 0.85rem; color: var(--muted); } +.stack input[type="text"], +.stack input[type="email"] { + width: 100%; + padding: 0.6rem 0.7rem; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 8px; + color: var(--text); + font-size: 1rem; + font-family: var(--font); + outline: none; + transition: border-color 0.15s; +} +.stack input:focus { border-color: var(--accent); } + +.field-error { color: var(--overdue); font-size: 0.83rem; } + +.btn { + margin-top: 0.5rem; + padding: 0.6rem 0.9rem; + background: var(--accent); + border: none; + border-radius: 8px; + color: #fff; + font-size: 1rem; + font-weight: 600; + cursor: pointer; + transition: opacity 0.15s; +} +.btn:hover { opacity: 0.85; } + +/* ========================================================================= + Modul "Projekte" — Komponenten (aus dem Prototyp) + ========================================================================= */ + +/* ── Seitenkopf ── */ +.page-head { + display: flex; + align-items: baseline; + justify-content: space-between; + flex-wrap: wrap; + gap: 0.5rem; + margin-bottom: 2rem; +} +.page-head h1 { + font-size: 1.6rem; + font-weight: 700; + letter-spacing: -0.02em; +} +.page-head h1 span { color: var(--accent); } +.today { font-size: 0.8rem; color: var(--muted); } + +/* ── Stats-Leiste ── */ +.stats { display: flex; gap: 1rem; flex-wrap: wrap; margin-bottom: 2rem; } +.stat { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 8px; + padding: 0.5rem 1rem; + font-size: 0.8rem; + color: var(--muted); +} +.stat strong { color: var(--text); font-size: 1.1rem; display: block; } + +/* ── Aufgaben-Formular ── */ +.add-form { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 12px; + padding: 1.2rem; + display: flex; + flex-direction: column; + gap: 0.75rem; + margin-bottom: 2.5rem; +} +.add-form .row { display: flex; gap: 0.6rem; flex-wrap: wrap; } +.add-form input[type="text"], +.add-form input[type="date"], +.add-form select { + background: var(--bg); + border: 1px solid var(--border); + border-radius: 8px; + color: var(--text); + padding: 0.55rem 0.8rem; + font-size: 0.9rem; + font-family: var(--font); + outline: none; + transition: border-color 0.15s; +} +.add-form input[type="text"] { flex: 1; min-width: 180px; } +.add-form input[type="date"] { width: 145px; color-scheme: dark; } +.add-form select { width: 150px; } +.add-form input:focus, .add-form select:focus { border-color: var(--accent); } + +.btn-add { + background: var(--accent); + color: #fff; + border: none; + border-radius: 8px; + padding: 0.55rem 1.2rem; + font-size: 0.9rem; + font-weight: 600; + cursor: pointer; + white-space: nowrap; + transition: opacity 0.15s; +} +.btn-add:hover { opacity: 0.85; } + +/* ── Projektverwaltung ── */ +.project-mgmt { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; + align-items: center; + margin-bottom: 1rem; +} +.project-mgmt input[type="text"] { + background: var(--bg); + border: 1px solid var(--border); + border-radius: 8px; + color: var(--text); + padding: 0.4rem 0.7rem; + font-size: 0.85rem; + font-family: var(--font); + outline: none; + width: 160px; +} +.project-mgmt input:focus { border-color: var(--accent); } + +.btn-small { + background: var(--surface); + color: var(--text); + border: 1px solid var(--border); + border-radius: 8px; + padding: 0.4rem 0.8rem; + font-size: 0.8rem; + cursor: pointer; + transition: background 0.15s; +} +.btn-small:hover { background: var(--card); } + +/* ── Abschnitte ── */ +.section { margin-bottom: 2rem; } +.section-header { + display: flex; + align-items: center; + gap: 0.6rem; + margin-bottom: 0.8rem; + user-select: none; +} +.section-header.clickable { cursor: pointer; } +.section-title { + font-size: 0.75rem; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--muted); +} +.section-count { + font-size: 0.7rem; + background: var(--card); + color: var(--muted); + border-radius: 20px; + padding: 0.1rem 0.5rem; +} +.collapse-btn { + margin-left: auto; + background: none; + border: none; + color: var(--muted); + cursor: pointer; + font-size: 0.75rem; + padding: 0; +} + +/* ── Projektkarte ── */ +.project-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 12px; + margin-bottom: 1rem; + overflow: hidden; +} +.project-card-header { + display: flex; + align-items: center; + gap: 0.7rem; + padding: 0.85rem 1rem; + cursor: pointer; + border-bottom: 1px solid var(--border); +} +.project-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; } +.project-name { font-weight: 600; font-size: 0.95rem; flex: 1; } +.project-meta { font-size: 0.75rem; color: var(--muted); } +.project-delete { + background: none; + border: none; + color: var(--muted); + cursor: pointer; + font-size: 0.85rem; + padding: 0.2rem 0.4rem; + border-radius: 4px; + transition: color 0.15s; +} +.project-delete:hover { color: var(--accent); } + +/* ── Aufgaben-Eintrag ── */ +.task-list { padding: 0.4rem 0; } +.task { + display: flex; + align-items: flex-start; + gap: 0.7rem; + padding: 0.55rem 1rem; + border-bottom: 1px solid var(--border); + transition: background 0.1s; +} +.task:last-child { border-bottom: none; } +.task:hover { background: rgba(255, 255, 255, 0.03); } +.task.done { opacity: 0.5; } +.task input[type="checkbox"] { + margin-top: 0.15rem; + width: 16px; + height: 16px; + accent-color: var(--accent); + cursor: pointer; + flex-shrink: 0; +} +.task-body { flex: 1; min-width: 0; } +.task-text { font-size: 0.9rem; line-height: 1.4; word-break: break-word; } +.task.done .task-text { text-decoration: line-through; color: var(--done-text); } + +.task-due { + display: inline-block; + margin-top: 0.25rem; + font-size: 0.72rem; + font-weight: 600; + padding: 0.1rem 0.5rem; + border-radius: 20px; + letter-spacing: 0.03em; +} +.due-overdue { background: rgba(255, 107, 107, 0.18); color: var(--overdue); } +.due-soon { background: rgba(245, 166, 35, 0.18); color: var(--soon); } +.due-ok { background: rgba(78, 204, 163, 0.12); color: var(--ok); } +.due-none { background: var(--card); color: var(--muted); } + +.task-project-tag { + font-size: 0.68rem; + padding: 0.1rem 0.45rem; + border-radius: 20px; + margin-left: 0.3rem; + font-weight: 600; +} + +.task-delete { + background: none; + border: none; + color: var(--muted); + cursor: pointer; + font-size: 0.8rem; + padding: 0.1rem 0.3rem; + border-radius: 4px; + opacity: 0; + transition: opacity 0.15s, color 0.15s; + flex-shrink: 0; +} +.task:hover .task-delete { opacity: 1; } +.task-delete:hover { color: var(--accent); } + +.empty-hint { + text-align: center; + color: var(--muted); + font-size: 0.85rem; + padding: 1.5rem; +} + +/* ── Filter-Leiste ── */ +.filter-bar { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; + align-items: center; + margin-bottom: 0.75rem; +} +.filter-btn { + background: var(--surface); + border: 1px solid var(--border); + color: var(--muted); + border-radius: 20px; + padding: 0.3rem 0.8rem; + font-size: 0.78rem; + cursor: pointer; + transition: all 0.15s; +} +.filter-btn.active { background: var(--accent); border-color: var(--accent); color: #fff; } +.filter-btn:hover:not(.active) { border-color: var(--muted); color: var(--text); } +.filter-label { font-size: 0.75rem; color: var(--muted); margin-right: 0.2rem; } + +/* ── Trenner ── */ +.divider { margin-bottom: 2rem; border: none; border-top: 1px solid var(--border); } + +/* ── Backup/Sync-Leiste ── */ +.sync-bar { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 12px; + padding: 0.9rem 1.2rem; + display: flex; + align-items: center; + gap: 0.75rem; + flex-wrap: wrap; + margin-bottom: 2.5rem; +} +.sync-label { + font-size: 0.75rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--muted); + margin-right: 0.25rem; +} +.btn-sync { + background: var(--card); + color: var(--text); + border: 1px solid var(--border); + border-radius: 8px; + padding: 0.4rem 0.9rem; + font-size: 0.82rem; + font-weight: 600; + cursor: pointer; + transition: background 0.15s, border-color 0.15s; + display: inline-flex; + align-items: center; + gap: 0.4rem; + text-decoration: none; +} +.btn-sync:hover { background: #1a3a5c; border-color: var(--accent); } +.btn-sync.export { border-color: var(--ok); color: var(--ok); } +.btn-sync.export:hover { background: rgba(78, 204, 163, 0.1); } +.btn-sync.import { border-color: var(--accent2); color: var(--accent2); } +.btn-sync.import:hover { background: rgba(245, 166, 35, 0.1); } +.sync-hint { font-size: 0.72rem; color: var(--muted); margin-left: auto; } + +/* ── Toast ── */ +.sync-toast { + position: fixed; + bottom: 1.5rem; + left: 50%; + transform: translateX(-50%) translateY(80px); + background: var(--card); + color: var(--text); + border: 1px solid var(--border); + border-radius: 8px; + padding: 0.6rem 1.2rem; + font-size: 0.85rem; + font-weight: 500; + pointer-events: none; + transition: transform 0.25s ease, opacity 0.25s ease; + opacity: 0; + z-index: 100; + white-space: nowrap; +} +.sync-toast.show { transform: translateX(-50%) translateY(0); opacity: 1; } diff --git a/app/static/js/app.js b/app/static/js/app.js new file mode 100644 index 0000000..da43d26 --- /dev/null +++ b/app/static/js/app.js @@ -0,0 +1,26 @@ +// Minimal-JS für den Projekt-Hub — Progressive Enhancement, keine Abhängigkeiten. + +// 1) Bestätigungsdialog für Formulare mit data-confirm (z.B. Löschen). +document.addEventListener("submit", function (e) { + var form = e.target; + if (form.dataset && form.dataset.confirm) { + if (!window.confirm(form.dataset.confirm)) { + e.preventDefault(); + } + } +}); + +// 2) Klappbare Bereiche: Klick auf .js-collapse blendet das Ziel (data-target) aus. +document.addEventListener("click", function (e) { + var head = e.target.closest(".js-collapse"); + if (!head) return; + // Klicks auf interaktive Elemente (z.B. Lösch-Button) nicht als Klapp-Klick werten. + if (e.target.closest("button, a, input, form") && !e.target.closest(".collapse-btn")) { + return; + } + var target = document.getElementById(head.dataset.target); + if (!target) return; + var collapsed = target.classList.toggle("hidden"); + var btn = head.querySelector(".collapse-btn"); + if (btn) btn.textContent = collapsed ? "▼" : "▲"; +}); diff --git a/app/templates/auth/login.html b/app/templates/auth/login.html new file mode 100644 index 0000000..45663ec --- /dev/null +++ b/app/templates/auth/login.html @@ -0,0 +1,20 @@ +{% extends "base.html" %} +{% block title %}Anmelden · Projekt-Hub{% endblock %} +{% block content %} +
+

Anmelden

+

Gib deine E-Mail-Adresse ein — wir schicken dir einen + 6-stelligen Code zum Einloggen.

+ +
+ {{ form.hidden_tag() }} + + {{ form.email(id="email", type="email", autocomplete="email", autofocus=true, + placeholder="du@beispiel.de") }} + {% for err in form.email.errors %} + {{ err }} + {% endfor %} + {{ form.submit(class="btn") }} +
+
+{% endblock %} diff --git a/app/templates/auth/verify.html b/app/templates/auth/verify.html new file mode 100644 index 0000000..045d708 --- /dev/null +++ b/app/templates/auth/verify.html @@ -0,0 +1,22 @@ +{% extends "base.html" %} +{% block title %}Code eingeben · Projekt-Hub{% endblock %} +{% block content %} +
+

Code eingeben

+

Wir haben einen Code an {{ email }} geschickt. + Er ist nur kurz gültig.

+ +
+ {{ form.hidden_tag() }} + + {{ form.code(id="code", inputmode="numeric", autocomplete="one-time-code", + autofocus=true, maxlength="6", placeholder="123456") }} + {% for err in form.code.errors %} + {{ err }} + {% endfor %} + {{ form.submit(class="btn") }} +
+ +

Andere E-Mail verwenden

+
+{% endblock %} diff --git a/app/templates/base.html b/app/templates/base.html new file mode 100644 index 0000000..1f75f8f --- /dev/null +++ b/app/templates/base.html @@ -0,0 +1,41 @@ + + + + + + {% block title %}Projekt-Hub{% endblock %} + + + + + +
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
+ {% for category, message in messages %} +
{{ message }}
+ {% endfor %} +
+ {% endif %} + {% endwith %} + + {% block content %}{% endblock %} +
+ + + + diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..026582a --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,32 @@ +# deploy/ — Deployment-Artefakte + +Versionierte Vorlagen für den VPS-Betrieb. Die ausführliche Schritt-für-Schritt- +Anleitung steht in [`../DEPLOYMENT.md`](../DEPLOYMENT.md); hier liegen die Dateien, +die dort referenziert werden. + +| Datei | Zweck | Ziel auf dem VPS | +|-------|-------|------------------| +| `../gunicorn.conf.py` | Gunicorn-Konfiguration (Worker, Socket, Logging) | bleibt im Projektordner, via `-c` geladen | +| `projekt-hub.service` | systemd-Unit (Autostart, Neustart bei Absturz) | `/etc/systemd/system/projekt-hub.service` | +| `nginx/projekt-hub.conf` | nginx-Reverse-Proxy (Socket-Proxy + Static) | `/etc/nginx/sites-available/projekt-hub` | + +## Vor dem Kopieren anpassen + +Alle Dateien gehen von diesen Annahmen aus — bei Abweichung anpassen: + +- Nutzer: `projekthub`, Gruppe `www-data` +- Projektpfad: `/home/projekthub/projekt-hub` +- venv: `.venv` im Projektordner +- Socket: `projekt-hub.sock` im Projektordner + +## Rate-Limiting-Backend + +Mit mehreren gunicorn-Workern braucht Flask-Limiter ein gemeinsames Backend, +sonst zählt jeder Worker eigene Limits. In der `.env`: + +```ini +RATELIMIT_STORAGE_URI=redis://localhost:6379 +``` + +Redis installieren: `sudo apt install -y redis-server` (siehe `DEPLOYMENT.md`). +Ohne Redis bleibt `memory://` möglich — dann am besten nur **ein** Worker. diff --git a/deploy/nginx/projekt-hub.conf b/deploy/nginx/projekt-hub.conf new file mode 100644 index 0000000..2f38b3a --- /dev/null +++ b/deploy/nginx/projekt-hub.conf @@ -0,0 +1,36 @@ +# nginx-Reverse-Proxy für den Projekt-Hub. +# +# Installation: +# sudo cp deploy/nginx/projekt-hub.conf /etc/nginx/sites-available/projekt-hub +# sudo ln -s /etc/nginx/sites-available/projekt-hub /etc/nginx/sites-enabled/ +# sudo nginx -t && sudo systemctl reload nginx +# +# HTTPS wird hier NICHT manuell konfiguriert — certbot --nginx ergänzt die +# TLS-Direktiven automatisch, sobald eine Domain feststeht (siehe DEPLOYMENT.md §10). + +server { + listen 80; + listen [::]:80; + + # Zunächst Server-IP; später auf die echte Domain ändern und certbot laufen lassen. + server_name DEINE_SERVER_IP; + + # Statische Dateien direkt von nginx ausliefern (schneller, entlastet gunicorn). + location /static/ { + alias /home/projekthub/projekt-hub/app/static/; + expires 30d; + access_log off; + } + + location / { + proxy_pass http://unix:/home/projekthub/projekt-hub/projekt-hub.sock; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + # Wichtig: App nimmt HTTPS an (PREFERRED_URL_SCHEME=https, Secure-Cookies). + proxy_set_header X-Forwarded-Proto $scheme; + } + + # PDF-Export und sonstige Antworten dürfen ruhig etwas größer sein. + client_max_body_size 4m; +} diff --git a/deploy/projekt-hub.service b/deploy/projekt-hub.service new file mode 100644 index 0000000..5b74322 --- /dev/null +++ b/deploy/projekt-hub.service @@ -0,0 +1,34 @@ +# systemd-Service für den Projekt-Hub (Gunicorn). +# +# Installation: +# sudo cp deploy/projekt-hub.service /etc/systemd/system/projekt-hub.service +# # Pfade/Nutzer unten ggf. anpassen, dann: +# sudo systemctl daemon-reload +# sudo systemctl enable --now projekt-hub +# sudo systemctl status projekt-hub +# +# Annahme: Code unter /home/projekthub/projekt-hub, venv unter .venv. + +[Unit] +Description=Projekt-Hub Flask App (Gunicorn) +After=network.target +# Falls Rate-Limiting über lokales Redis läuft, Redis vorher starten: +# After=network.target redis-server.service +# Wants=redis-server.service + +[Service] +User=projekthub +Group=www-data +WorkingDirectory=/home/projekthub/projekt-hub +Environment="PATH=/home/projekthub/projekt-hub/.venv/bin" +ExecStart=/home/projekthub/projekt-hub/.venv/bin/gunicorn -c gunicorn.conf.py wsgi:app +Restart=always +RestartSec=3 + +# Etwas Härtung (optional, aber empfohlen): +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=full + +[Install] +WantedBy=multi-user.target diff --git a/gunicorn.conf.py b/gunicorn.conf.py new file mode 100644 index 0000000..c7f3272 --- /dev/null +++ b/gunicorn.conf.py @@ -0,0 +1,31 @@ +"""Gunicorn-Konfiguration für den Projekt-Hub. + +Aufruf (im aktivierten venv, WorkingDirectory = Projektordner): + gunicorn -c gunicorn.conf.py wsgi:app + +Wird vom systemd-Service (deploy/projekt-hub.service) genutzt. Werte sind über +Umgebungsvariablen überschreibbar, sinnvolle Defaults für einen kleinen VPS. +""" + +import multiprocessing +import os + +# Bind-Adresse: standardmäßig ein Unix-Socket relativ zum WorkingDirectory. +# nginx spricht denselben Socket per absoluter Pfadangabe an +# (z.B. /home/projekthub/projekt-hub/projekt-hub.sock). +bind = os.environ.get("GUNICORN_BIND", "unix:projekt-hub.sock") + +# Worker-Anzahl: Faustregel (2 × CPU-Kerne) + 1, per env begrenzbar. +workers = int(os.environ.get("GUNICORN_WORKERS", str(multiprocessing.cpu_count() * 2 + 1))) +worker_class = os.environ.get("GUNICORN_WORKER_CLASS", "sync") +timeout = int(os.environ.get("GUNICORN_TIMEOUT", "30")) + +# Socket-Dateirechte, damit nginx (Gruppe www-data) zugreifen kann. +umask = 0o007 + +# Logs auf stdout/stderr → landen via systemd im journal (journalctl -u projekt-hub). +accesslog = "-" +errorlog = "-" +loglevel = os.environ.get("GUNICORN_LOGLEVEL", "info") + +proc_name = "projekt-hub" diff --git a/instance/.gitkeep b/instance/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/migrations/README b/migrations/README new file mode 100644 index 0000000..0e04844 --- /dev/null +++ b/migrations/README @@ -0,0 +1 @@ +Single-database configuration for Flask. diff --git a/migrations/alembic.ini b/migrations/alembic.ini new file mode 100644 index 0000000..ec9d45c --- /dev/null +++ b/migrations/alembic.ini @@ -0,0 +1,50 @@ +# A generic, single database configuration. + +[alembic] +# template used to generate migration files +# file_template = %%(rev)s_%%(slug)s + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic,flask_migrate + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[logger_flask_migrate] +level = INFO +handlers = +qualname = flask_migrate + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/migrations/env.py b/migrations/env.py new file mode 100644 index 0000000..4c97092 --- /dev/null +++ b/migrations/env.py @@ -0,0 +1,113 @@ +import logging +from logging.config import fileConfig + +from flask import current_app + +from alembic import context + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +fileConfig(config.config_file_name) +logger = logging.getLogger('alembic.env') + + +def get_engine(): + try: + # this works with Flask-SQLAlchemy<3 and Alchemical + return current_app.extensions['migrate'].db.get_engine() + except (TypeError, AttributeError): + # this works with Flask-SQLAlchemy>=3 + return current_app.extensions['migrate'].db.engine + + +def get_engine_url(): + try: + return get_engine().url.render_as_string(hide_password=False).replace( + '%', '%%') + except AttributeError: + return str(get_engine().url).replace('%', '%%') + + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +config.set_main_option('sqlalchemy.url', get_engine_url()) +target_db = current_app.extensions['migrate'].db + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def get_metadata(): + if hasattr(target_db, 'metadatas'): + return target_db.metadatas[None] + return target_db.metadata + + +def run_migrations_offline(): + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, target_metadata=get_metadata(), literal_binds=True + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online(): + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + + # this callback is used to prevent an auto-migration from being generated + # when there are no changes to the schema + # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html + def process_revision_directives(context, revision, directives): + if getattr(config.cmd_opts, 'autogenerate', False): + script = directives[0] + if script.upgrade_ops.is_empty(): + directives[:] = [] + logger.info('No changes in schema detected.') + + conf_args = current_app.extensions['migrate'].configure_args + if conf_args.get("process_revision_directives") is None: + conf_args["process_revision_directives"] = process_revision_directives + + connectable = get_engine() + + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=get_metadata(), + **conf_args + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/migrations/script.py.mako b/migrations/script.py.mako new file mode 100644 index 0000000..2c01563 --- /dev/null +++ b/migrations/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/migrations/versions/cc4f09c8a12f_initial_schema_users_login_codes_.py b/migrations/versions/cc4f09c8a12f_initial_schema_users_login_codes_.py new file mode 100644 index 0000000..97507ad --- /dev/null +++ b/migrations/versions/cc4f09c8a12f_initial_schema_users_login_codes_.py @@ -0,0 +1,93 @@ +"""initial schema: users, login_codes, projects, tasks + +Revision ID: cc4f09c8a12f +Revises: +Create Date: 2026-06-11 02:25:05.478589 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'cc4f09c8a12f' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('users', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('email', sa.String(length=255), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_users_email'), ['email'], unique=True) + + op.create_table('login_codes', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('code_hash', sa.String(length=255), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('expires_at', sa.DateTime(), nullable=False), + sa.Column('used', sa.Boolean(), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + with op.batch_alter_table('login_codes', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_login_codes_user_id'), ['user_id'], unique=False) + + op.create_table('projects', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=120), nullable=False), + sa.Column('color', sa.String(length=7), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + with op.batch_alter_table('projects', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_projects_user_id'), ['user_id'], unique=False) + + op.create_table('tasks', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('project_id', sa.Integer(), nullable=True), + sa.Column('text', sa.String(length=500), nullable=False), + sa.Column('due_date', sa.Date(), nullable=True), + sa.Column('done', sa.Boolean(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['project_id'], ['projects.id'], ), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + with op.batch_alter_table('tasks', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_tasks_project_id'), ['project_id'], unique=False) + batch_op.create_index(batch_op.f('ix_tasks_user_id'), ['user_id'], unique=False) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('tasks', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_tasks_user_id')) + batch_op.drop_index(batch_op.f('ix_tasks_project_id')) + + op.drop_table('tasks') + with op.batch_alter_table('projects', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_projects_user_id')) + + op.drop_table('projects') + with op.batch_alter_table('login_codes', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_login_codes_user_id')) + + op.drop_table('login_codes') + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_users_email')) + + op.drop_table('users') + # ### end Alembic commands ### diff --git a/reference/projekte.html b/reference/projekte.html new file mode 100644 index 0000000..98e4906 --- /dev/null +++ b/reference/projekte.html @@ -0,0 +1,873 @@ + + + + + +Projekte + + + + +
+

Projekte & Aufgaben

+
+
+ + +
+ + +
+ ↕ Sync + + + + Exportieren → auf Proton Drive laden → auf anderem Gerät importieren +
+ +
+ + +
+
+ +
+
+ + + +
+
+ + +
+ Projekt: + + +
+ + +
+
+ Nach Projekt + 0 + +
+
+
+ +
+ + +
+
+ Alle Aufgaben + 0 +
+ +
+ Zeigen: + + + + + +
+ +
+
+
+
+ + + + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..5b0eac1 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,13 @@ +Flask>=3.0 +Flask-SQLAlchemy>=3.1 +Flask-Migrate>=4.0 +Flask-Login>=0.6 +Flask-Mail>=0.9 +Flask-Limiter>=3.5 +Flask-WTF>=1.2 +email-validator>=2.1 +fpdf2>=2.7 +python-dotenv>=1.0 +gunicorn>=21.2 +# Optional: nur genutzt, wenn RATELIMIT_STORAGE_URI auf redis:// zeigt (Produktion). +redis>=5.0 diff --git a/run.py b/run.py new file mode 100644 index 0000000..f0eb785 --- /dev/null +++ b/run.py @@ -0,0 +1,16 @@ +"""Lokaler Dev-Server. + +Lädt .env, erzeugt die App via Factory und startet den Flask-Dev-Server. +Aufruf: python run.py +""" + +from dotenv import load_dotenv + +load_dotenv() + +from app import create_app # noqa: E402 + +app = create_app() + +if __name__ == "__main__": + app.run(host="127.0.0.1", port=5000, debug=True) diff --git a/wsgi.py b/wsgi.py new file mode 100644 index 0000000..1f84035 --- /dev/null +++ b/wsgi.py @@ -0,0 +1,12 @@ +"""WSGI-Einstiegspunkt für Produktion (gunicorn). + +Aufruf z.B.: gunicorn --bind 127.0.0.1:8000 wsgi:app +""" + +from dotenv import load_dotenv + +load_dotenv() + +from app import create_app # noqa: E402 + +app = create_app("production")