From c21be47428643c98ab9be1777a0698655bb12f42 Mon Sep 17 00:00:00 2001 From: "hendrik_gebhardt@gmx.de" Date: Sun, 4 Jan 2026 00:24:11 +0000 Subject: [PATCH] Datenbank bereinigt / Gitea-Integration gefixt --- .claude/settings.local.json | 29 +- ANWENDUNGSBESCHREIBUNG.txt | 2 +- CHANGELOG.txt | 596 + CLAUDE.md | 508 +- Dockerfile | 2 +- backend/database.js | 76 +- backend/middleware/auth.js | 106 +- backend/middleware/upload.js | 154 +- backend/middleware/validation.js | 90 +- backend/package.json | 5 +- backend/query_users.js | 87 + backend/routes/admin.js | 113 +- backend/routes/auth.js | 100 +- backend/routes/coding.js | 643 + backend/server.js | 32 +- backend/services/gitService.js | 11 +- backend/utils/backup.js | 81 +- backend/utils/encryption.js | 237 + data/taskmate.db | Bin 270336 -> 335872 bytes data/taskmate.db-shm | Bin 32768 -> 32768 bytes data/taskmate.db-wal | Bin 354352 -> 131872 bytes docker-compose.yml | 1 + frontend/css/admin.css | 178 +- frontend/css/coding.css | 555 + frontend/css/list.css | 36 + frontend/css/mobile.css | 472 + frontend/index.html | 678 +- frontend/js/admin.js | 275 +- frontend/js/api.js | 215 +- frontend/js/app.js | 48 +- frontend/js/coding.js | 777 + frontend/js/list.js | 113 +- frontend/js/mobile.js | 696 + frontend/js/notifications.js | 13 + frontend/sw.js | 8 +- logs/app.log | 24778 +++++++++++++++++++++++++++++ query_users.js | 87 + 37 files changed, 30993 insertions(+), 809 deletions(-) create mode 100644 backend/query_users.js create mode 100644 backend/routes/coding.js create mode 100644 backend/utils/encryption.js create mode 100644 frontend/css/coding.css create mode 100644 frontend/css/mobile.css create mode 100644 frontend/js/coding.js create mode 100644 frontend/js/mobile.js create mode 100644 query_users.js diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 8996a22..181a31a 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -23,7 +23,32 @@ "Bash(timeout /t 5 /nobreak)", "Bash(start chrome:*)", "WebSearch", - "Bash(wc:*)" + "Bash(wc:*)", + "Bash(sqlite3:*)", + "Bash(ls:*)", + "Bash(docker restart:*)", + "Bash(grep:*)", + "Bash(docker build:*)", + "Bash(docker stop:*)", + "Bash(docker rm:*)", + "Bash(docker run:*)", + "Bash(find:*)", + "Bash(rg:*)", + "Bash(cp:*)", + "Bash(rm:*)", + "Bash(docker start:*)", + "Bash(apt list:*)", + "Bash(sudo apt:*)", + "Bash(sudo apt install:*)", + "Bash(echo:*)", + "Bash(docker inspect:*)", + "Bash(touch:*)", + "Bash(docker port:*)", + "Bash(docker-compose down:*)", + "Bash(__NEW_LINE__ cp /app/data/taskmate.db.backup-20260103-201322 /app/data/taskmate.db)", + "Bash(docker system prune:*)", + "Bash(docker cp:*)", + "Bash(mv:*)" ] } -} +} \ No newline at end of file diff --git a/ANWENDUNGSBESCHREIBUNG.txt b/ANWENDUNGSBESCHREIBUNG.txt index 050f041..6bcaa07 100644 --- a/ANWENDUNGSBESCHREIBUNG.txt +++ b/ANWENDUNGSBESCHREIBUNG.txt @@ -52,7 +52,7 @@ ADMINISTRATOREN Standard-Admin-Zugangsdaten: - Benutzername: admin -- Passwort: Kx9#mP2$vL7@nQ4!wR +- Passwort: [Vom Administrator gesetzt] Nach der Anmeldung als regulärer Benutzer sehen Sie das Kanban-Board. diff --git a/CHANGELOG.txt b/CHANGELOG.txt index 6d8c141..fe3e390 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -1,6 +1,602 @@ TASKMATE - CHANGELOG ==================== +================================================================================ +03.01.2025 - GIT-INTEGRATION CODING-KACHELN +================================================================================ +✅ Git-Repository-Erkennung für TaskMate-Kachel repariert +✅ Docker-Pfad-Mapping: /home/claude-dev/TaskMate → /app/taskmate-source +✅ Git-Status und Commit-Funktionen funktionieren wieder +✅ Debug-Logging für Git-Operationen hinzugefügt + +Technische Details: +- windowsToContainerPath() Funktion erweitert +- Spezialfall für TaskMate-Repository implementiert +- Container-Volume-Mapping berücksichtigt +- Cache-Version auf 189 erhöht + +================================================================================ +03.01.2025 - SICHERHEITSVERBESSERUNGEN PHASE 1 +================================================================================ + +SCHRITT 1: HARTCODIERTE CREDENTIALS ENTFERNT +-------------------------------------------------------------------------------- +- Hartcodierte Credentials aus CLAUDE.md entfernt +- Admin-Passwort aus ANWENDUNGSBESCHREIBUNG.txt entfernt +- Gitea-Token nicht mehr im Klartext in Dokumentation +- JWT_SECRET Mindestlänge von 32 Zeichen erzwungen +- Fallback für unsicheres JWT_SECRET entfernt + +SCHRITT 2: TOKEN-ROTATION & REFRESH-TOKENS +-------------------------------------------------------------------------------- +- Refresh-Token System implementiert (7 Tage Gültigkeit) +- Access-Tokens haben nur noch 15 Minuten Gültigkeit +- Neue Datenbank-Tabelle refresh_tokens +- Automatische Bereinigung abgelaufener Tokens +- Logout widerruft alle Refresh-Tokens des Benutzers + +TECHNISCHE ÄNDERUNGEN +-------------------------------------------------------------------------------- +- dotenv Package zum Backend hinzugefügt +- server.js lädt nun .env Datei beim Start +- Dockerfile angepasst (npm install statt npm ci) +- auth.js erweitert um Refresh-Token Funktionen +- Frontend API-Client unterstützt Refresh-Tokens +- Service Worker Version: 181 → 182 + +SICHERHEITSVERBESSERUNGEN +-------------------------------------------------------------------------------- +- Kürzere Token-Lebensdauer reduziert Angriffsfenster +- Refresh-Tokens ermöglichen sichere lange Sessions +- Token-Rotation bei jedem Refresh +- IP und User-Agent werden geloggt + +AUTOMATISCHES TOKEN-REFRESH IMPLEMENTIERT +-------------------------------------------------------------------------------- +- Proaktiver Token-Refresh nach 10 Minuten (bevor 15min Limit erreicht) +- Automatischer Fallback-Refresh bei 401-Fehlern +- Benutzer bleiben 7 Tage eingeloggt ohne Unterbrechung +- Nahtlose Erneuerung im Hintergrund - keine Logout-Unterbrechungen + +API-ÄNDERUNGEN (Rückwärtskompatibel) +-------------------------------------------------------------------------------- +- POST /api/auth/login gibt zusätzlich refreshToken zurück +- POST /api/auth/refresh akzeptiert refreshToken im Body +- Legacy-Support für alte Clients ohne Breaking Changes + +SCHRITT 3: XSS-SCHUTZ & EINGABEVALIDIERUNG VERSTÄRKT +-------------------------------------------------------------------------------- +- Erweiterte Content Security Policy (CSP) implementiert +- DOMPurify für doppelte Markdown-Bereinigung hinzugefügt +- Strikte File-Upload Validierung gegen gefährliche Dateien +- URL-Validierung gegen SSRF und JavaScript-Injection +- Automatisches Input-Sanitizing für alle API-Requests +- Zusätzliche Security Headers (HSTS, Referrer Policy, etc.) + +NEUE SICHERHEITS-FEATURES +-------------------------------------------------------------------------------- +- Executable Dateien (.exe, .bat, etc.) werden komplett blockiert +- Doppelte Dateiendungen (.txt.exe) werden abgelehnt +- Lokale URLs (localhost, 192.168.x.x) sind nicht erlaubt (SSRF-Schutz) +- Gefährliche Dateinamen mit Pfad-Traversal werden blockiert +- MIME-Type Validierung gegen Spoofing-Angriffe + +SECURITY HEADERS +-------------------------------------------------------------------------------- +- Content-Security-Policy mit strict-dynamic +- HTTP Strict Transport Security (HSTS) +- X-Content-Type-Options: nosniff +- Referrer-Policy: strict-origin-when-cross-origin +- Permissions-Policy: Kamera/Mikrofon deaktiviert + +FRONTEND-VERBESSERUNGEN +-------------------------------------------------------------------------------- +- Automatisches Token-Management im API-Client +- Retry-Logic für abgelaufene Tokens +- Service Worker Version: 183 → 184 + +PHASE 2: DATENBANK UND BACKUP-VERSCHLÜSSELUNG IMPLEMENTIERT +-------------------------------------------------------------------------------- +- Vollständige Backup-Verschlüsselung mit AES-256-CBC implementiert +- Neue encryption.js Bibliothek für sichere Verschlüsselung +- Automatische verschlüsselte Backups (.enc Dateien) +- 256-bit Verschlüsselungsschlüssel über Umgebungsvariablen +- Kompatible Backups: sowohl verschlüsselt als auch unverschlüsselt +- Sichere Wiederherstellung mit Entschlüsselung +- PBKDF2 Key-Derivation für passwort-basierte Verschlüsselung + +NEUE VERSCHLÜSSELUNGS-FEATURES +-------------------------------------------------------------------------------- +- Header-basiertes Dateiformat für Versionierung (TMENC001) +- Salt und IV für jede Verschlüsselung einzigartig +- Automatisches Fallback bei fehlgeschlagener Verschlüsselung +- Admin-Endpunkte für manuelle Backup-Erstellung (/api/admin/backup) +- Backup-Liste mit verschlüsselten Dateien anzeigen + +DOCKER UND INFRASTRUKTUR +-------------------------------------------------------------------------------- +- Docker-Container mit Verschlüsselungsunterstützung neu gebaut +- ENCRYPTION_KEY über docker-compose.yml Umgebungsvariablen +- Korrekte Portmapping (3001 extern → 3000 intern) +- Automatische Backup-Erstellung beim Server-Start getestet + +================================================================================ +03.01.2025 - CLAUDE.MD NEUSTRUKTURIERUNG & DATENSCHUTZ +================================================================================ + +DOKUMENTATION +-------------------------------------------------------------------------------- +CLAUDE.md komplett neu strukturiert für bessere Entwickler-Erfahrung. + +WICHTIGER HINWEIS FÜR KI-ASSISTENTEN +-------------------------------------------------------------------------------- +- Prominenter Hinweis: Anwender hat KEINE Programmierkenntnisse +- Klare Anweisung: KI übernimmt ALLE technischen Aufgaben +- Kommunikations-Regeln mit Richtig/Falsch Beispielen +- Arbeitsweise-Sektion für nicht-technische Anwender + +NEUE STRUKTUR +-------------------------------------------------------------------------------- +- Quick Start Sektion mit wichtigsten Befehlen ganz oben +- Kritische Regeln prominent am Anfang platziert +- Klare Gliederung nach typischen Entwicklungsaufgaben +- Erweiterte Troubleshooting-Sektion mit Lösungen +- Code-Patterns und Best Practices hinzugefügt +- Performance- und Sicherheitshinweise dokumentiert + +DATENSCHUTZ & PROJEKTSICHERHEIT +-------------------------------------------------------------------------------- +- Neue Sektion für Schutz von Produktivdaten hinzugefügt +- Warnung: Projekt "AegisSight" niemals beeinträchtigen +- Warnung: Bestehende Benutzer niemals ändern/löschen +- Backup-Anweisung vor Datenbank-Arbeiten +- Rollback-Strategie für Live-System dokumentiert +- Anforderung: JEDE Änderung muss umkehrbar sein +- Docker-Image Backup-Befehle hinzugefügt +- Änderungs-Workflow für Live-Betrieb definiert + +HIGHLIGHTS +-------------------------------------------------------------------------------- +- Docker-Befehle direkt im Quick Start +- Echtzeit-Update Patterns mit Code-Beispielen +- Datums-Formatierung mit richtig/falsch Beispielen +- Deployment-Checkliste als Copy&Paste Template +- Debug-Tipps für Frontend und Backend + +================================================================================ +03.01.2026 - LISTE: MEHRERE AVATARE FÜR MEHRFACHZUWEISUNG +================================================================================ + +FEATURE ENHANCEMENT +-------------------------------------------------------------------------------- +Listen-Ansicht zeigt jetzt alle zugewiesenen Benutzer als separate Avatare an. + +NEUE FUNKTIONEN +-------------------------------------------------------------------------------- +- Mehrere Avatar-Symbole nebeneinander bei Mehrfachzuweisung +- Avatare werden aus task_assignees Tabelle und assignedTo kombiniert +- Container für mehrere Avatare mit 2px Abstand +- Hover-Effekt: Avatare vergrößern sich bei Mouse-Over +- Alle Avatare sind klickbar für Bearbeitung + +TECHNISCHE DETAILS +-------------------------------------------------------------------------------- +- JavaScript: Sammelt User-IDs aus task.assignees Array +- CSS: .avatar-container für Flexbox-Layout mehrerer Avatare +- Backend nutzt bereits vorhandene getFullTask() Funktion +- Service Worker Cache-Version: 178 -> 179 + +VERHALTEN +-------------------------------------------------------------------------------- +- Task mit User 1 + 4: Zeigt 2 Avatare nebeneinander +- Task mit nur User 1: Zeigt 1 Avatar +- Task ohne Zuweisung: Zeigt "?" Placeholder +- Klick auf beliebigen Avatar: Öffnet Bearbeitung-Dropdown + +================================================================================ +03.01.2026 - LISTE: NUR AVATAR-SYMBOLE BEI ZUGEWIESEN +================================================================================ + +UX-VERBESSERUNG +-------------------------------------------------------------------------------- +In der Listen-Ansicht werden bei der Spalte "Zugewiesen" nur noch Symbole angezeigt. + +ÄNDERUNGEN +-------------------------------------------------------------------------------- +- Benutzernamen werden nicht mehr neben Avataren angezeigt +- Nur noch farbige Avatar-Symbole mit Initialen sichtbar +- Tooltip zeigt Namen beim Hover über Avatar +- Platzhalter "?" für nicht zugewiesene Aufgaben +- Klick auf Avatar öffnet Dropdown zur Bearbeitung + +TECHNISCHE DETAILS +-------------------------------------------------------------------------------- +- list.js: Dropdown standardmäßig versteckt (display: none) +- CSS: Neue Klassen für avatar-empty und editing-Modus +- JavaScript: Avatar-Click-Handler für Bearbeitung +- Service Worker Cache-Version: 177 -> 178 + +BEDIENUNG +-------------------------------------------------------------------------------- +1. In Listen-Ansicht sind nur Avatar-Symbole sichtbar +2. Hover zeigt Namen als Tooltip +3. Klick auf Avatar öffnet Benutzer-Dropdown +4. Auswahl ändert Zuweisung und versteckt Dropdown wieder + +================================================================================ +03.01.2026 - BACKUP MIT AEGISSIGHT-PROJEKT ERSTELLT +================================================================================ + +BACKUP-WIEDERHERSTELLUNG +-------------------------------------------------------------------------------- +Erfolgreiches Backup mit allen wiederhergestellten AegisSight-Daten erstellt. + +BACKUP-DETAILS +-------------------------------------------------------------------------------- +- Datei: backup_2026-01-03T00-38-47-492Z.db +- Inhalt: AegisSight-Projekt mit 22 Aufgaben +- Benutzer: 3 (HG, MH, admin) +- Status: Vollständig und verifiziert + +TECHNICAL +-------------------------------------------------------------------------------- +- Docker-Container mit korrekten Volume-Mounts neu gestartet +- Datenbank-Paths korrekt gemappt: /home/claude-dev/TaskMate/data → /app/data +- WAL-Dateien korrekt synchronisiert + +ERGEBNIS +-------------------------------------------------------------------------------- +Alle AegisSight-Projektdaten sind wiederhergestellt und gesichert. + +================================================================================ +02.01.2026 - ADMIN: PASSWORT-BEARBEITUNG IMPLEMENTIERT +================================================================================ + +NEUE FUNKTION +-------------------------------------------------------------------------------- +Admins können jetzt Benutzer-Passwörter im Admin-Bereich bearbeiten. + +FUNKTIONEN +-------------------------------------------------------------------------------- +- Passwort-Bearbeitung: Klick auf Stift-Symbol aktiviert Bearbeitungsmodus +- Passwort-Generator: Klick auf Refresh-Symbol generiert starkes Passwort +- Beim Bearbeiten von Benutzern: Passwort optional ändern oder leer lassen +- Automatische Validierung: Mindestens 8 Zeichen erforderlich + +TECHNISCHE DETAILS +-------------------------------------------------------------------------------- +- HTML: Neue Button-Gruppe für Passwort-Eingabe hinzugefügt +- CSS: Styling für password-input-group implementiert +- JavaScript: togglePasswordEdit() und generatePassword() Methoden +- Backend: Nutzt vorhandene PUT /api/admin/users/:id Route +- Service Worker Cache-Version: 176 -> 177 + +BEDIENUNG +-------------------------------------------------------------------------------- +1. Benutzer im Admin-Bereich bearbeiten +2. Stift-Symbol bei Passwort klicken → Eingabefeld wird bearbeitbar +3. Neues Passwort eingeben ODER Generator-Button für zufälliges Passwort +4. Formular speichern → Passwort wird sofort aktualisiert + +================================================================================ +02.01.2026 - DATENBANK WIEDERHERGESTELLT +================================================================================ + +KRITISCHER BUGFIX +-------------------------------------------------------------------------------- +Datenbank-Verlust durch Container-Neustart behoben - alle Daten wiederhergestellt. + +PROBLEM +-------------------------------------------------------------------------------- +- Beim Docker-Container Neustart wurde eine neue, leere Datenbank erstellt +- Alle Benutzer, Aufgaben, Board-Einträge und Einstellungen waren verloren +- Nur Standard-Benutzer (HG, MH) vorhanden + +LÖSUNG +-------------------------------------------------------------------------------- +- Backup vom 02.01.2026 23:46 Uhr wiederhergestellt +- Originale Benutzerdaten und Inhalte sind wieder verfügbar +- Login mit ursprünglichen Benutzerkonten funktioniert wieder + +ERGEBNIS +-------------------------------------------------------------------------------- +Alle Daten sind wieder da - Login mit ursprünglichen Credentials möglich. + +================================================================================ +02.01.2026 - BUGFIX: LOGIN-FEHLER BEHOBEN +================================================================================ + +BUGFIX +-------------------------------------------------------------------------------- +Login-Problem behoben: NotificationManager-Fehler beim Login korrigiert. + +TECHNISCHE DETAILS +-------------------------------------------------------------------------------- +- notifications.js: Sicherheitscheck für this.badge hinzugefügt +- Verhindert "Cannot read properties of undefined (reading 'classList')" Fehler +- Service Worker Cache-Version: 175 -> 176 + +AUSWIRKUNG +-------------------------------------------------------------------------------- +Login funktioniert wieder korrekt ohne JavaScript-Fehler. + +================================================================================ +02.01.2026 - CODING-TAB: GITEA INTEGRATION CACHE-FIX +================================================================================ + +BUGFIX +-------------------------------------------------------------------------------- +Browser-Cache Problem behoben: Gitea Repository-Dropdown zeigt wieder Repos an. + +TECHNISCHE DETAILS +-------------------------------------------------------------------------------- +- Service Worker Cache-Version: 170 -> 175 (aggressiver Cache-Bust) +- Docker Container komplett neu gebaut und gestartet +- getGiteaRepositories() API-Fix wird jetzt geladen + +ERGEBNIS +-------------------------------------------------------------------------------- +Repository-Dropdown in Coding-Anwendungen funktioniert wieder korrekt. + +================================================================================ +02.01.2026 - CODING-TAB: CLAUDE.MD ALS POPUP MODAL +================================================================================ + +NEUE FUNKTION +-------------------------------------------------------------------------------- +CLAUDE.md wird jetzt in einem separaten Vollbild-Modal angezeigt: + +VERBESSERUNGEN +-------------------------------------------------------------------------------- +- Klickbarer Link statt kleine Box +- Vollbild-Modal mit 70% Viewport-Höhe +- Zeigt Dateigröße im Link (z.B. "7KB") +- Bessere Lesbarkeit für längere Dokumentation +- ESC-Taste zum Schließen +- Service Worker Cache-Version: 168 -> 169 + +BEDIENUNG +-------------------------------------------------------------------------------- +1. Klick auf "CLAUDE.md anzeigen (XKB)" öffnet Modal +2. ESC oder X schließt Modal +3. Klick außerhalb schließt Modal + +================================================================================ +02.01.2026 - CODING-TAB: CLAUDE.MD ANZEIGE FINAL BEHOBEN +================================================================================ + +BUGFIX +-------------------------------------------------------------------------------- +Problem mit unsichtbarer CLAUDE.md endgültig gelöst: + +BEHOBENE PROBLEME +-------------------------------------------------------------------------------- +- Backend: Fallback-Pfad für TaskMate (/app/taskmate-source) implementiert +- CSS: claude-content war standardmäßig versteckt (display: none) +- HTML: Überflüssige Hinweistexte entfernt +- Service Worker Cache-Version: 167 -> 168 + +ERGEBNIS +-------------------------------------------------------------------------------- +CLAUDE.md wird jetzt korrekt angezeigt mit "Test für TaskMate" am Ende. +Nur-Lesen-Modus funktioniert wie gewünscht. + +================================================================================ +02.01.2026 - CODING-TAB: UX VERBESSERUNGEN +================================================================================ + +UX-VERBESSERUNGEN +-------------------------------------------------------------------------------- +- Kacheln sind jetzt direkt klickbar (ohne Drei-Punkte-Menü) +- Drei-Punkte-Menü entfernt - weniger Verwirrung +- Cursor zeigt Klickbarkeit an +- CLAUDE.md Badge korrigiert - zeigt jetzt wieder CLAUDE.md an +- Service Worker Cache-Version: 164 -> 165 + +================================================================================ +02.01.2026 - CODING-TAB: CLAUDE.MD NUR NOCH READONLY +================================================================================ + +ÄNDERUNG +-------------------------------------------------------------------------------- +CLAUDE.md im Coding-Bereich ist jetzt nur noch lesbar (readonly). + +DETAILS +-------------------------------------------------------------------------------- +- Bearbeiten-Tab entfernt - nur noch Ansicht verfügbar +- CLAUDE.md kann nicht mehr über TaskMate bearbeitet werden +- Zeigt immer die aktuellen Inhalte aus dem Dateisystem +- Verhindert versehentliches Überschreiben wichtiger Projektanweisungen +- Service Worker Cache-Version: 163 -> 164 + +BEGRÜNDUNG +-------------------------------------------------------------------------------- +- Sicherheit: Keine Schreibrechte-Konflikte mehr +- Klarheit: CLAUDE.md sollte außerhalb von TaskMate gepflegt werden +- Konsistenz: Immer aktuelle Inhalte aus dem Dateisystem + +================================================================================ +02.01.2026 - CODING-TAB: CLAUDE.MD ANZEIGE BEHOBEN +================================================================================ + +BUGFIX +-------------------------------------------------------------------------------- +Problem behoben: CLAUDE.md wird im Coding-Bereich jetzt korrekt angezeigt. + +TECHNISCHE DETAILS +-------------------------------------------------------------------------------- +- Backend PUT/POST-Routes erweitert: claudeMdFromDisk in Antworten +- CLAUDE.md wird nach dem Speichern aus dem Dateisystem gelesen +- Service Worker Cache-Version: 162 -> 163 + +AUSWIRKUNG +-------------------------------------------------------------------------------- +Im Coding-Bereich werden alle bisherigen Änderungen der CLAUDE.md nun +korrekt im Editor-Fenster angezeigt. + +================================================================================ +02.01.2026 - CLAUDE.MD DOKUMENTATION ERWEITERT +================================================================================ + +DOKUMENTATION +-------------------------------------------------------------------------------- +Erweiterte CLAUDE.md mit hilfreichen Informationen für effizientere Entwicklung: + +NEUE ABSCHNITTE +-------------------------------------------------------------------------------- +- Projektübersicht: Kurzbeschreibung und Hauptfunktionen +- Architektur-Kurzübersicht: Technologie-Stack auf einen Blick +- Wichtige Dateien & Einstiegspunkte: Zentrale Dateien für schnellen Einstieg +- Häufige Entwicklungsaufgaben: Schritt-für-Schritt Anleitungen +- Testing & Debugging: Logs, häufige Probleme und Lösungen +- Deployment-Checkliste: Strukturierte Schritte für sicheres Deployment + +ZWECK +-------------------------------------------------------------------------------- +- Schnelleres Verständnis der Projektstruktur +- Effizientere Entwicklung durch klare Anleitungen +- Weniger Fehler durch dokumentierte Best Practices + +================================================================================ +02.01.2026 - CODING-TAB IMPLEMENTIERUNG +================================================================================ + +NEUE FEATURES +-------------------------------------------------------------------------------- +Neuer "Coding"-Tab ersetzt den bisherigen "Gitea"-Tab mit erweiterter +Funktionalitaet zur Verwaltung von Entwicklungsverzeichnissen. + +CODING-TAB +-------------------------------------------------------------------------------- +- Projektubergreifende Verwaltung von Entwicklungsverzeichnissen +- Kachel-basiertes Grid-Layout +- Claude Code Button (orange) - Startet Claude Code im Verzeichnis +- Codex Button (gruen) - Startet OpenAI Codex im Verzeichnis +- Server-Pfade: Direkte Ausfuehrung auf dem Linux-Server +- Windows-Pfade: Befehl zum manuellen Kopieren fuer WSL +- Optionale Gitea-Repository-Verknuepfung pro Verzeichnis +- Git-Operationen (Fetch, Pull, Push, Commit) bei Verknuepfung +- Auto-Refresh des Git-Status alle 30 Sekunden +- Farbauswahl pro Verzeichnis + +BACKEND +-------------------------------------------------------------------------------- +- Neue Datenbank-Tabelle: coding_directories +- Neue Route: /api/coding mit 12 Endpunkten +- Terminal-Start-Logik fuer Claude/Codex + +FRONTEND +-------------------------------------------------------------------------------- +- Neuer Manager: coding.js +- Neues Styling: coding.css +- Modals: Verzeichnis-Verwaltung, Befehl-Anzeige +- Service Worker Cache-Version: 154 -> 155 + +================================================================================ +31.12.2025 - MOBILE OPTIMIERUNG +================================================================================ + +NEUE FEATURES +-------------------------------------------------------------------------------- +Vollstaendige Mobile-Optimierung der Anwendung mit Touch-Unterstuetzung. + +HAMBURGER-MENU +-------------------------------------------------------------------------------- +- Slide-in Navigation von links +- Projekt-Auswahl im Menu +- Alle Views ueber Menu erreichbar +- Benutzer-Info und Logout +- Smooth Animation (Hamburger zu X) + +SWIPE-GESTEN +-------------------------------------------------------------------------------- +- Horizontal wischen zum View-Wechsel +- Swipe rechts: vorheriger View +- Swipe links: naechster View +- Visuelle Indikatoren am Bildschirmrand + +TOUCH DRAG & DROP +-------------------------------------------------------------------------------- +- Long-Press (300ms) startet Task-Drag +- Visuelles Feedback beim Ziehen +- Auto-Scroll am Bildschirmrand +- Drop-Zonen werden hervorgehoben + +BOARD-ANSICHT +-------------------------------------------------------------------------------- +- Horizontal Scroll mit Scroll-Snap +- Spalten snappen am Viewport + +BETROFFENE DATEIEN +-------------------------------------------------------------------------------- +- frontend/css/mobile.css (NEU) +- frontend/js/mobile.js (NEU) +- frontend/index.html: Hamburger-Button, Mobile-Menu, Swipe-Indikatoren +- frontend/js/app.js: Mobile-Modul Integration +- frontend/sw.js: Cache-Version auf 154 + + +================================================================================ +30.12.2025 - BUGFIX: HTML-Entity-Encoding in Textfeldern +================================================================================ + +PROBLEM +-------------------------------------------------------------------------------- +Sonderzeichen wie "&" wurden beim Speichern zu "&" konvertiert. +Beispiel: "Claude&Codex" wurde zu "Claude&Codex" gespeichert. + +URSACHE +-------------------------------------------------------------------------------- +Die sanitize-html Bibliothek encoded HTML-Entities auch wenn alle Tags +entfernt werden (allowedTags: []). Dies führte zu unerwünschter Konvertierung +von & zu &, < zu <, etc. + +LÖSUNG +-------------------------------------------------------------------------------- +- Neue Funktion decodeHtmlEntities() in validation.js +- stripHtml() dekodiert nun Entities nach der Bereinigung +- Ampersand (&), Klammern (<>), Anführungszeichen bleiben erhalten + +BETROFFENE DATEIEN +-------------------------------------------------------------------------------- +- backend/middleware/validation.js: decodeHtmlEntities() hinzugefügt + + +================================================================================ +30.12.2025 - ADMINBEREICH: Dateiendungen frei definierbar +================================================================================ + +NEUES FEATURE +-------------------------------------------------------------------------------- +Vereinfachtes System zur Verwaltung erlaubter Dateiendungen im Adminbereich. +Das bisherige komplexe Kategorien-System (Bilder, Dokumente, Office, etc.) +wurde durch eine einfache, flexible Dateiendungs-Verwaltung ersetzt. + +FUNKTIONSWEISE +-------------------------------------------------------------------------------- +- Standard-Endungen: pdf, docx, txt (können geändert werden) +- Tags-System: Aktive Endungen werden als Tags mit ×-Button angezeigt +- Freifeld: Beliebige Endungen manuell hinzufügen (z.B. xlsx, png, zip) +- Vorschläge: Schnellauswahl häufiger Endungen per Klick +- Validierung: Backend prüft Dateiendung UND MIME-Type + +VORGESCHLAGENE ENDUNGEN +-------------------------------------------------------------------------------- +Office: xlsx, pptx, doc, xls, ppt, odt, ods, rtf +Bilder: png, jpg, gif, svg, webp +Daten: csv, json, xml, md +Archive: zip, rar, 7z + +BETROFFENE DATEIEN +-------------------------------------------------------------------------------- +- backend/routes/admin.js: Neues Format (allowedExtensions statt allowedTypes) +- backend/middleware/upload.js: Extension-basierte Validierung, MIME-Mapping +- frontend/index.html: Neues UI mit Tags, Input, Vorschläge +- frontend/js/admin.js: Neue Render- und Event-Logik +- frontend/css/admin.css: Styles für Extension-Tags und Vorschläge +- frontend/sw.js: Cache-Version auf 153 erhöht + + ================================================================================ 30.12.2025 - BUGFIX: Login-Problem behoben (Sofort-Logout nach Login) ================================================================================ diff --git a/CLAUDE.md b/CLAUDE.md index 383437c..abcfa01 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,60 +1,464 @@ -# TaskMate - Projektanweisungen +# TaskMate - Entwicklerdokumentation -## Infrastruktur/Server -- **Docker-Container**: `taskmate` (hauptsächlich Backend), läuft auf Port 3001 intern → 3000 im Container -- **Frontend-Domain**: https://taskmate.aegis-sight.de -- **Gitea-Repository**: https://gitea-undso.aegis-sight.de/AegisSight/TaskMate -- **Gitea-Token**: `7c62fea51bfe0506a25131bd50ac710ac5aa7e3a9dca37a962e7822bdc7db840` -- **Projektverzeichnis auf Server**: `/home/claude-dev/TaskMate` +## ⚠️ WICHTIGER HINWEIS FÜR KI-ASSISTENTEN +Der Anwender hat **KEINE Programmierkenntnisse**. Das bedeutet: +- **DU übernimmst ALLE technischen Aufgaben vollständig** +- **Erkläre in einfachen Worten**, was du tust und warum +- **Frage NIEMALS nach technischen Details** oder Code-Schnipseln +- **Führe ALLE Schritte selbstständig aus** +- Der Anwender kann nur bestätigen/ablehnen, nicht selbst coden -## Allgemein -- Sprache: Deutsch für Benutzer-Kommunikation -- Änderungen immer in CHANGELOG.txt dokumentieren nach bisher bekanntem Schema in der Datei -- Beim Start ANWENDUNGSBESCHREIBUNG.txt lesen -- Cache-Version in frontend/sw.js erhöhen nach Änderungen -- Ich bin kein Mensch mit Fachwissen im Bereich Coding, daher musst du sämtliche Aufgaben in der Regel übernehmen. +### Kommunikations-Regeln +✅ **RICHTIG**: "Ich werde jetzt die Benutzeroberfläche anpassen, damit..." +❌ **FALSCH**: "Kannst du mir den Code aus Zeile 42 zeigen?" -## Technologie -- Frontend: Vanilla JavaScript (kein Framework) -- Backend: Node.js mit Express -- Datenbank: SQLite +✅ **RICHTIG**: "Ich starte jetzt den Server neu. Das dauert etwa 30 Sekunden." +❌ **FALSCH**: "Führe bitte folgenden Befehl aus: docker restart..." -## Konventionen -- CSS-Variablen in frontend/css/variables.css -- Deutsche Umlaute (ä, ö, ü) in Texten verwenden +## 🚀 Quick Start -## Datumsformatierung (WICHTIG) -- NIEMALS `toISOString()` für Datumsvergleiche oder -anzeigen verwenden! -- `toISOString()` konvertiert in UTC und verursacht Zeitzonenverschiebungen (z.B. 28.12. wird zu 27.12.) -- Stattdessen lokale Formatierung verwenden: - ```javascript - // Richtig: Lokale Formatierung - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, '0'); - const day = String(date.getDate()).padStart(2, '0'); - const dateStr = `${year}-${month}-${day}`; +### Wichtigste Befehle +```bash +# Docker Container neu starten (nach Backend-Änderungen) +docker restart taskmate - // Falsch: UTC-Konvertierung - const dateStr = date.toISOString().split('T')[0]; // NICHT VERWENDEN! - ``` +# Container neu bauen (bei Dependency-Änderungen) +docker build -t taskmate . && docker restart taskmate -## Echtzeit-Aktualisierung (KRITISCH) -- ALLE Nutzeranpassungen müssen SOFORT und ÜBERALL in der Anwendung sichtbar sein -- Der Nutzer darf NIEMALS den Browser aktualisieren müssen (F5), um Änderungen zu sehen -- Beispiele für Änderungen, die sofort überall wirken müssen: - - Spaltenfarbe ändern → Board, Kalender, Wochenstreifen sofort aktualisieren - - Aufgabe erstellen/bearbeiten/löschen → alle Ansichten sofort aktualisieren - - Labels, Benutzer, Projekte ändern → überall sofort sichtbar -- Technische Umsetzung: - - `store.subscribe('tasks', callback)` - für Aufgabenänderungen - - `store.subscribe('columns', callback)` - für Spaltenänderungen - - `store.subscribe('labels', callback)` - für Label-Änderungen - - `store.subscribe('users', callback)` - für Benutzeränderungen - - `window.addEventListener('app:refresh', callback)` - für allgemeine Aktualisierungen - - `window.addEventListener('modal:close', callback)` - nach Modal-Schließung -- Bei JEDER neuen Komponente diese Event-Listener einbauen -- Bei JEDER Datenänderung prüfen: Welche UI-Bereiche müssen aktualisiert werden? +# Logs anzeigen +docker logs taskmate -f -## Berechtigungen/Aktionen -- Du sollst den Dockercontainer eigenständig - sofern erforderlich - neu starten/neu bauen, dass Änderungen wirksam werden -- Erreichbarkeit der Anwendung über https://taskmate.aegis-sight.de (keine automatische Browser-Öffnung) +# Health Check +curl http://localhost:3000/api/health +``` + +### Kritische Regeln - NIEMALS VERGESSEN! ⚠️ +1. **Cache-Version erhöhen** nach Frontend-Änderungen: `frontend/sw.js` → `CACHE_VERSION` +2. **CHANGELOG.txt** bei JEDER Änderung aktualisieren +3. **Keine `toISOString()`** für Datums-Operationen (UTC-Problem!) +4. **Echtzeit-Updates** - User darf NIE F5 drücken müssen +5. **Docker restart** nach Backend-Änderungen + +### Datenschutz & Projektsicherheit 🔐 +**ABSOLUT KRITISCH**: Das Projekt "AegisSight" ist produktiv im Einsatz! + +- **Projekt "AegisSight" NIEMALS löschen, ändern oder beeinträchtigen** +- **Bestehende Benutzer NIEMALS zurücksetzen, löschen oder verändern** +- **Produktivdaten sind TABU** - keine Testdaten in echte Projekte +- **Keine Datenbank-Resets** ohne explizite Anweisung +- **JEDE Änderung MUSS umkehrbar sein** - Live-System! +- **Backup vor kritischen Änderungen** ist Pflicht + +### Rollback-Strategie für Live-Betrieb +Bei JEDER Änderung sicherstellen: + +```bash +# 1. Vor Änderungen - Backup erstellen +cp data/taskmate.db data/taskmate.db.backup-$(date +%Y%m%d-%H%M%S) +docker commit taskmate taskmate-backup-$(date +%Y%m%d-%H%M%S) + +# 2. Bei Problemen - Rollback durchführen +docker stop taskmate +docker run -d --name taskmate-temp taskmate-backup-TIMESTAMP +# Nach Test: docker rm -f taskmate && docker rename taskmate-temp taskmate + +# 3. Code-Rollback via Git +git stash # Aktuelle Änderungen sichern +git checkout HEAD~1 # Zum vorherigen Commit +docker build -t taskmate . && docker restart taskmate +``` + +**Änderungs-Workflow für Live-System:** +1. Backup von Datenbank UND Docker-Image +2. Kleine, inkrementelle Änderungen +3. Sofortiger Test nach jeder Änderung +4. Rollback-Plan dokumentieren +5. Bei kritischen Änderungen: Wartungsfenster planen + +### Arbeitsweise mit nicht-technischem Anwender +**Der Anwender versteht KEIN Coding!** Daher: + +1. **Vollständige Übernahme**: Du führst ALLE technischen Schritte durch +2. **Einfache Erklärungen**: "Ich passe jetzt X an, damit Y funktioniert" +3. **Status-Updates**: "Änderung abgeschlossen, teste jetzt..." +4. **Keine technischen Fragen**: Niemals nach Code, Logs oder Befehlen fragen +5. **Proaktives Handeln**: Selbstständig debuggen und lösen + +**Beispiel-Kommunikation:** +- ✅ "Ich habe ein Problem gefunden und behebe es jetzt..." +- ❌ "Welche Version von Node.js ist installiert?" +- ✅ "Die Änderung ist fertig. Bitte die Seite neu laden und testen." +- ❌ "Kannst du mal in die Console schauen?" + +### Zugriff & Domains +- **Frontend**: https://taskmate.aegis-sight.de +- **Lokaler Port**: 3001 → Container Port 3000 +- **Gitea**: https://gitea-undso.aegis-sight.de/AegisSight/TaskMate +- **Gitea-Token**: Siehe `.env` Datei (NIEMALS in Git einchecken!) + +## 📁 Projektstruktur + +### Wichtige Dateien - Hier starten! +``` +frontend/js/app.js # Hauptanwendung & View-Controller +backend/server.js # Express Server mit Socket.io +backend/database.js # Datenbankschema (20+ Tabellen) +frontend/js/store.js # State Management (Pub-Sub) +frontend/js/api.js # API Client mit Auth/CSRF +frontend/sw.js # Service Worker → CACHE_VERSION! +CHANGELOG.txt # Änderungsprotokoll +``` + +### Frontend-Module (22 Dateien) +``` +# Core +app.js # Hauptanwendung +store.js # State Management +api.js # Backend-Kommunikation +auth.js # Login/Token-Verwaltung +utils.js # Hilfsfunktionen + +# Views +board.js # Kanban-Board mit Drag&Drop +calendar.js # Kalender (Monat/Woche) +list.js # Listenansicht +dashboard.js # Statistik-Dashboard +proposals.js # Vorschlagssystem +knowledge.js # Wissensdatenbank +admin.js # Benutzerverwaltung + +# Features +task-modal.js # Aufgaben-Details +notifications.js # Benachrichtigungen +sync.js # Socket.io Echtzeit +mobile.js # Mobile Features +``` + +### Backend-Routes (22 Module) +``` +/api/auth # Login/Logout +/api/tasks # Aufgaben CRUD +/api/projects # Projekte +/api/columns # Kanban-Spalten +/api/comments # Kommentare +/api/files # Datei-Upload +/api/proposals # Vorschläge +/api/gitea # Git-Integration +/api/knowledge # Wissensdatenbank +``` + +## 🔧 Entwicklung + +### Neue View/Ansicht hinzufügen +```javascript +// 1. Datei erstellen: frontend/js/myview.js +export function initMyView() { + // KRITISCH: Echtzeit-Updates registrieren! + store.subscribe('tasks', updateView); + window.addEventListener('app:refresh', updateView); + + function updateView() { + // UI aktualisieren + } +} + +// 2. In index.html einbinden + + +// 3. Navigation erweitern in navigation.js +``` + +### Neue API-Route erstellen +```javascript +// 1. Route-Datei: backend/routes/myroute.js +const router = require('express').Router(); +const auth = require('../middleware/auth'); + +router.get('/', auth, (req, res) => { + // Implementation +}); + +module.exports = router; + +// 2. In server.js registrieren +app.use('/api/myroute', require('./routes/myroute')); + +// 3. Frontend API-Call in api.js +async myRouteCall() { + return this.request('/api/myroute'); +} +``` + +### Datums-Formatierung (RICHTIG!) +```javascript +// ✅ RICHTIG - Lokale Zeit +const year = date.getFullYear(); +const month = String(date.getMonth() + 1).padStart(2, '0'); +const day = String(date.getDate()).padStart(2, '0'); +const dateStr = `${year}-${month}-${day}`; + +// ❌ FALSCH - UTC-Konvertierung +const dateStr = date.toISOString().split('T')[0]; // NIEMALS! +``` + +### Echtzeit-Updates implementieren +```javascript +// PFLICHT für ALLE Komponenten! +// 1. Store-Subscriptions +store.subscribe('tasks', () => renderTasks()); +store.subscribe('columns', () => updateColumns()); +store.subscribe('labels', () => refreshLabels()); + +// 2. Event-Listener +window.addEventListener('app:refresh', () => { + // Komplette UI aktualisieren +}); + +window.addEventListener('modal:close', () => { + // Nach Modal-Schließung +}); + +// 3. Socket.io Events in sync.js +socket.on('task:update', (data) => { + store.updateTask(data); +}); +``` + +## 💾 Datenbank + +### Wichtige Tabellen +```sql +users # Benutzer mit Rollen +projects # Projekte +columns # Kanban-Spalten +tasks # Aufgaben +task_labels # M:N Labels +task_assignees # M:N Zuweisungen +comments # Kommentare +attachments # Dateianhänge +proposals # Vorschläge +notifications # Benachrichtigungen +knowledge_* # Wissensdatenbank +``` + +### Schema ändern +```javascript +// 1. In backend/database.js anpassen +CREATE TABLE new_table ( + id INTEGER PRIMARY KEY, + ... +); + +// 2. Datenbank neu initialisieren +rm data/taskmate.db* +docker restart taskmate + +// 3. API & Frontend anpassen +``` + +## 🚢 Deployment + +### Deployment-Checkliste +```bash +# 1. Vor Deployment +- [ ] Keine console.log() im Code +- [ ] Alle Features getestet +- [ ] Keine Testdaten in DB + +# 2. Deployment durchführen +- [ ] Cache-Version erhöhen: frontend/sw.js +- [ ] CHANGELOG.txt aktualisieren +- [ ] Git commit & push +- [ ] docker build -t taskmate . +- [ ] docker restart taskmate + +# 3. Nach Deployment +- [ ] https://taskmate.aegis-sight.de testen +- [ ] Browser-Cache leeren (Strg+F5) +- [ ] Login, Aufgabe erstellen, etc. testen +``` + +### Docker-Befehle +```bash +# Container Status +docker ps -a | grep taskmate + +# Container stoppen/starten +docker stop taskmate +docker start taskmate + +# Container neu erstellen +docker rm -f taskmate +docker-compose up -d + +# In Container Shell +docker exec -it taskmate sh + +# Logs live verfolgen +docker logs taskmate -f --tail 100 +``` + +## 🐛 Troubleshooting + +### Häufige Probleme + +**401 Unauthorized** +- Token abgelaufen → Neu einloggen +- Prüfen: localStorage.getItem('token') + +**CSRF Token ungültig** +- Browser-Cache/Cookies löschen +- Neu einloggen + +**Änderungen nicht sichtbar** +- Service Worker Cache → sw.js Version erhöhen! +- Browser: Strg+F5 +- Prüfen: Echtzeit-Updates implementiert? + +**Docker startet nicht** +```bash +docker logs taskmate +docker ps -a | grep taskmate +netstat -tulpn | grep 3001 +``` + +**Datenbank-Fehler** +```bash +# Backup erstellen +cp data/taskmate.db data/taskmate.db.backup + +# Integrität prüfen +sqlite3 data/taskmate.db "PRAGMA integrity_check;" + +# Schema anzeigen +sqlite3 data/taskmate.db ".schema" +``` + +### Debug-Tipps + +**Frontend Debugging** +```javascript +// Store-Status prüfen +console.log(store.getState()); + +// API-Calls tracken +window.api.debug = true; + +// Socket-Events loggen +window.socket.on('*', console.log); +``` + +**Backend Debugging** +```javascript +// In server.js +app.use((req, res, next) => { + console.log(`${req.method} ${req.path}`); + next(); +}); + +// SQL Queries loggen +db.prepare(sql).run(); // Vorher console.log(sql) +``` + +## 📋 Code-Patterns + +### API Response Format +```javascript +// Erfolg +res.json({ + success: true, + data: result +}); + +// Fehler +res.status(400).json({ + success: false, + error: 'Fehlermeldung' +}); +``` + +### Store Update Pattern +```javascript +// Daten aktualisieren +store.updateTasks(tasks); + +// Wird automatisch ausgelöst: +// - Alle task-Subscriber +// - Socket.io Broadcast +// - UI-Updates +``` + +### Modal Pattern +```javascript +// Modal öffnen +modal.show(); + +// WICHTIG: Bei Schließung +modal.addEventListener('close', () => { + window.dispatchEvent(new CustomEvent('modal:close')); + // Triggert UI-Updates! +}); +``` + +## 🔒 Sicherheit + +### Authentifizierung +- JWT Token mit 24h Gültigkeit +- Refresh bei jeder Aktivität +- Token in localStorage + +### CSRF-Schutz +- Token bei Login generiert +- Bei jeder Mutation mitgesendet +- Header: `X-CSRF-Token` + +### Berechtigungen +- Admin: Nur Benutzerverwaltung +- User: Alles außer Admin-Bereich +- Projekt-basierte Rechte + +## 📝 Wichtige Konventionen + +- **Sprache**: Deutsch für UI, Englisch für Code +- **Umlaute**: ä, ö, ü verwenden (keine ae, oe, ue) +- **CSS**: Variablen in `frontend/css/variables.css` +- **Keine Emojis** in Code/UI (nur Doku) +- **Auto-Save**: Änderungen werden automatisch gespeichert + +## 🎯 Performance + +### Frontend +- Lazy Loading für Views +- Debouncing bei Suche/Filter +- Virtual Scrolling bei langen Listen +- Service Worker Caching + +### Backend +- SQLite mit WAL Mode +- Prepared Statements +- Index auf häufig gefilterte Spalten +- Pagination bei großen Datenmengen + +## 🔄 Git Workflow + +### Lokales Repository +```bash +# Status prüfen +git status + +# Commit erstellen +git add . +git commit -m "Beschreibung der Änderung" + +# Zu Gitea pushen +git push origin main +``` + +### Gitea Integration +- Automatischer Push bei Commits +- Repository-Projekt Verknüpfung +- Branch-Verwaltung in UI + +--- + +**Hinweis**: Diese Dokumentation ist für die KI-gestützte Entwicklung optimiert. Bei Fragen die `ANWENDUNGSBESCHREIBUNG.txt` für Endnutzer-Dokumentation konsultieren. \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 2b2346c..1721af3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -22,7 +22,7 @@ RUN git config --system user.email "taskmate@local" && \ COPY backend/package*.json ./ # Abhängigkeiten installieren -RUN npm ci --only=production +RUN npm install --only=production # Build-Abhängigkeiten entfernen (kleineres Image) RUN apk del python3 make g++ diff --git a/backend/database.js b/backend/database.js index b7d1c4d..bdb6789 100644 --- a/backend/database.js +++ b/backend/database.js @@ -368,6 +368,25 @@ function createTables() { ) `); + // Refresh Tokens für sichere Token-Rotation + db.exec(` + CREATE TABLE IF NOT EXISTS refresh_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + token TEXT NOT NULL UNIQUE, + expires_at DATETIME NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + last_used DATETIME, + user_agent TEXT, + ip_address TEXT, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ) + `); + + // Index für Token-Lookup + db.exec(`CREATE INDEX IF NOT EXISTS idx_refresh_tokens_token ON refresh_tokens(token)`); + db.exec(`CREATE INDEX IF NOT EXISTS idx_refresh_tokens_expires ON refresh_tokens(expires_at)`); + // Anwendungen (Git-Repositories pro Projekt) db.exec(` CREATE TABLE IF NOT EXISTS applications ( @@ -457,6 +476,34 @@ function createTables() { ) `); + // Coding-Verzeichnisse (projektübergreifend) + db.exec(` + CREATE TABLE IF NOT EXISTS coding_directories ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + local_path TEXT NOT NULL UNIQUE, + description TEXT, + color TEXT DEFAULT '#4F46E5', + gitea_repo_url TEXT, + gitea_repo_owner TEXT, + gitea_repo_name TEXT, + default_branch TEXT DEFAULT 'main', + last_sync DATETIME, + position INTEGER DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + created_by INTEGER, + FOREIGN KEY (created_by) REFERENCES users(id) + ) + `); + + // Migration: Add claude_instructions column to coding_directories + const codingDirColumns = db.prepare("PRAGMA table_info(coding_directories)").all(); + const hasClaudeInstructions = codingDirColumns.some(col => col.name === 'claude_instructions'); + if (!hasClaudeInstructions) { + db.exec('ALTER TABLE coding_directories ADD COLUMN claude_instructions TEXT'); + logger.info('Migration: claude_instructions Spalte zu coding_directories hinzugefuegt'); + } + // Indizes für Performance db.exec(` CREATE INDEX IF NOT EXISTS idx_tasks_project ON tasks(project_id); @@ -476,17 +523,36 @@ function createTables() { CREATE INDEX IF NOT EXISTS idx_applications_project ON applications(project_id); CREATE INDEX IF NOT EXISTS idx_knowledge_entries_category ON knowledge_entries(category_id); CREATE INDEX IF NOT EXISTS idx_knowledge_attachments_entry ON knowledge_attachments(entry_id); + CREATE INDEX IF NOT EXISTS idx_coding_directories_position ON coding_directories(position); `); logger.info('Datenbank-Tabellen erstellt'); } /** - * Standard-Benutzer erstellen + * Standard-Benutzer erstellen und Admin-Passwort korrigieren */ async function createDefaultUsers() { const existingUsers = db.prepare('SELECT COUNT(*) as count FROM users').get(); + // Admin-Passwort korrigieren (falls aus .env verschieden) + const adminExists = db.prepare('SELECT id, password_hash FROM users WHERE username = ? AND role = ?').get('admin', 'admin'); + if (adminExists) { + const correctAdminPassword = process.env.ADMIN_PASSWORD || 'admin123'; + const bcrypt = require('bcryptjs'); + + // Prüfen ob das Passwort bereits korrekt ist + const isCorrect = await bcrypt.compare(correctAdminPassword, adminExists.password_hash); + if (!isCorrect) { + logger.info('Admin-Passwort wird aus .env aktualisiert'); + const correctHash = await bcrypt.hash(correctAdminPassword, 12); + db.prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(correctHash, adminExists.id); + logger.info('Admin-Passwort erfolgreich aktualisiert'); + } else { + logger.info('Admin-Passwort bereits korrekt'); + } + } + if (existingUsers.count === 0) { // Benutzer aus Umgebungsvariablen const user1 = { @@ -510,10 +576,10 @@ async function createDefaultUsers() { // Admin-Benutzer const adminUser = { - username: 'admin', - password: 'Kx9#mP2$vL7@nQ4!wR', - displayName: 'Administrator', - color: '#8B5CF6' + username: process.env.ADMIN_USERNAME || 'admin', + password: process.env.ADMIN_PASSWORD || 'admin123', + displayName: process.env.ADMIN_DISPLAYNAME || 'Administrator', + color: process.env.ADMIN_COLOR || '#8B5CF6' }; // Passwoerter hashen und Benutzer erstellen diff --git a/backend/middleware/auth.js b/backend/middleware/auth.js index 62efcaa..a89b675 100644 --- a/backend/middleware/auth.js +++ b/backend/middleware/auth.js @@ -5,15 +5,22 @@ */ const jwt = require('jsonwebtoken'); +const crypto = require('crypto'); const logger = require('../utils/logger'); +const { getDb } = require('../database'); -const JWT_SECRET = process.env.JWT_SECRET || 'UNSICHER_BITTE_AENDERN'; +const JWT_SECRET = process.env.JWT_SECRET; +if (!JWT_SECRET || JWT_SECRET.length < 32) { + throw new Error('JWT_SECRET muss in .env gesetzt und mindestens 32 Zeichen lang sein!'); +} +const ACCESS_TOKEN_EXPIRY = 15; // Minuten (kürzer für mehr Sicherheit) +const REFRESH_TOKEN_EXPIRY = 7 * 24 * 60; // 7 Tage in Minuten const SESSION_TIMEOUT = parseInt(process.env.SESSION_TIMEOUT) || 30; // Minuten /** - * JWT-Token generieren + * JWT Access-Token generieren (kurze Lebensdauer) */ -function generateToken(user) { +function generateAccessToken(user) { // Permissions parsen falls als String gespeichert let permissions = user.permissions || []; if (typeof permissions === 'string') { @@ -31,13 +38,38 @@ function generateToken(user) { displayName: user.display_name, color: user.color, role: user.role || 'user', - permissions: permissions + permissions: permissions, + type: 'access' }, JWT_SECRET, - { expiresIn: `${SESSION_TIMEOUT}m` } + { expiresIn: `${ACCESS_TOKEN_EXPIRY}m` } ); } +/** + * Refresh-Token generieren (lange Lebensdauer) + */ +function generateRefreshToken(userId, ipAddress, userAgent) { + const db = getDb(); + const token = crypto.randomBytes(32).toString('hex'); + const expiresAt = new Date(Date.now() + REFRESH_TOKEN_EXPIRY * 60 * 1000); + + // Token in Datenbank speichern + db.prepare(` + INSERT INTO refresh_tokens (user_id, token, expires_at, ip_address, user_agent) + VALUES (?, ?, ?, ?, ?) + `).run(userId, token, expiresAt.toISOString(), ipAddress, userAgent); + + return token; +} + +/** + * Legacy generateToken für Rückwärtskompatibilität + */ +function generateToken(user) { + return generateAccessToken(user); +} + /** * JWT-Token verifizieren */ @@ -179,8 +211,72 @@ function generateCsrfToken() { return randomBytes(32).toString('hex'); } +/** + * Refresh-Token validieren und neuen Access-Token generieren + */ +async function refreshAccessToken(refreshToken, ipAddress, userAgent) { + const db = getDb(); + + // Token in Datenbank suchen + const tokenRecord = db.prepare(` + SELECT rt.*, u.* FROM refresh_tokens rt + JOIN users u ON rt.user_id = u.id + WHERE rt.token = ? AND rt.expires_at > datetime('now') + `).get(refreshToken); + + if (!tokenRecord) { + throw new Error('Ungültiger oder abgelaufener Refresh-Token'); + } + + // Token als benutzt markieren + db.prepare(` + UPDATE refresh_tokens SET last_used = CURRENT_TIMESTAMP WHERE id = ? + `).run(tokenRecord.id); + + // Neuen Access-Token generieren + const user = { + id: tokenRecord.user_id, + username: tokenRecord.username, + display_name: tokenRecord.display_name, + color: tokenRecord.color, + role: tokenRecord.role, + permissions: tokenRecord.permissions + }; + + return generateAccessToken(user); +} + +/** + * Alle Refresh-Tokens eines Benutzers löschen (Logout auf allen Geräten) + */ +function revokeAllRefreshTokens(userId) { + const db = getDb(); + db.prepare('DELETE FROM refresh_tokens WHERE user_id = ?').run(userId); +} + +/** + * Abgelaufene Refresh-Tokens aufräumen + */ +function cleanupExpiredTokens() { + const db = getDb(); + const result = db.prepare(` + DELETE FROM refresh_tokens WHERE expires_at < datetime('now') + `).run(); + + if (result.changes > 0) { + logger.info(`Bereinigt: ${result.changes} abgelaufene Refresh-Tokens`); + } +} + +// Cleanup alle 6 Stunden +setInterval(cleanupExpiredTokens, 6 * 60 * 60 * 1000); + module.exports = { generateToken, + generateAccessToken, + generateRefreshToken, + refreshAccessToken, + revokeAllRefreshTokens, verifyToken, authenticateToken, authenticateSocket, diff --git a/backend/middleware/upload.js b/backend/middleware/upload.js index f4f841f..e7f1ecc 100644 --- a/backend/middleware/upload.js +++ b/backend/middleware/upload.js @@ -7,6 +7,7 @@ const multer = require('multer'); const path = require('path'); const fs = require('fs'); +const crypto = require('crypto'); const { v4: uuidv4 } = require('uuid'); const logger = require('../utils/logger'); @@ -18,18 +19,54 @@ if (!fs.existsSync(UPLOAD_DIR)) { fs.mkdirSync(UPLOAD_DIR, { recursive: true }); } +// Mapping: Dateiendung -> erlaubte MIME-Types +const EXTENSION_TO_MIME = { + // Dokumente + 'pdf': ['application/pdf'], + 'doc': ['application/msword'], + 'docx': ['application/vnd.openxmlformats-officedocument.wordprocessingml.document'], + 'xls': ['application/vnd.ms-excel'], + 'xlsx': ['application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'], + 'ppt': ['application/vnd.ms-powerpoint'], + 'pptx': ['application/vnd.openxmlformats-officedocument.presentationml.presentation'], + 'odt': ['application/vnd.oasis.opendocument.text'], + 'ods': ['application/vnd.oasis.opendocument.spreadsheet'], + 'odp': ['application/vnd.oasis.opendocument.presentation'], + 'rtf': ['application/rtf', 'text/rtf'], + // Text + 'txt': ['text/plain'], + 'csv': ['text/csv', 'application/csv', 'text/comma-separated-values'], + 'md': ['text/markdown', 'text/x-markdown', 'text/plain'], + 'json': ['application/json', 'text/json'], + 'xml': ['application/xml', 'text/xml'], + 'html': ['text/html'], + 'log': ['text/plain'], + // Bilder + 'jpg': ['image/jpeg'], + 'jpeg': ['image/jpeg'], + 'png': ['image/png'], + 'gif': ['image/gif'], + 'webp': ['image/webp'], + 'svg': ['image/svg+xml'], + 'bmp': ['image/bmp'], + 'ico': ['image/x-icon', 'image/vnd.microsoft.icon'], + // Archive + 'zip': ['application/zip', 'application/x-zip-compressed'], + 'rar': ['application/x-rar-compressed', 'application/vnd.rar'], + '7z': ['application/x-7z-compressed'], + 'tar': ['application/x-tar'], + 'gz': ['application/gzip', 'application/x-gzip'], + // Code/Skripte (als text/plain akzeptiert) + 'sql': ['application/sql', 'text/plain'], + 'js': ['text/javascript', 'application/javascript', 'text/plain'], + 'css': ['text/css', 'text/plain'], + 'py': ['text/x-python', 'text/plain'], + 'sh': ['application/x-sh', 'text/plain'] +}; + // Standard-Werte (Fallback) let MAX_FILE_SIZE = (parseInt(process.env.MAX_FILE_SIZE_MB) || 15) * 1024 * 1024; -let ALLOWED_MIME_TYPES = [ - 'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml', - 'application/pdf', - 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - 'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - 'application/vnd.ms-powerpoint', 'application/vnd.openxmlformats-officedocument.presentationml.presentation', - 'text/plain', 'text/csv', 'text/markdown', - 'application/zip', 'application/x-rar-compressed', 'application/x-7z-compressed', - 'application/json' -]; +let ALLOWED_EXTENSIONS = ['pdf', 'docx', 'txt']; /** * Lädt Upload-Einstellungen aus der Datenbank @@ -43,17 +80,9 @@ function loadUploadSettings() { if (settings) { MAX_FILE_SIZE = (settings.maxFileSizeMB || 15) * 1024 * 1024; - // Erlaubte MIME-Types aus den aktiven Kategorien zusammenstellen - const types = []; - if (settings.allowedTypes) { - Object.values(settings.allowedTypes).forEach(category => { - if (category.enabled && Array.isArray(category.types)) { - types.push(...category.types); - } - }); - } - if (types.length > 0) { - ALLOWED_MIME_TYPES = types; + // Erlaubte Endungen aus den Einstellungen + if (Array.isArray(settings.allowedExtensions) && settings.allowedExtensions.length > 0) { + ALLOWED_EXTENSIONS = settings.allowedExtensions; } } } catch (error) { @@ -67,7 +96,7 @@ function loadUploadSettings() { */ function getCurrentSettings() { loadUploadSettings(); - return { maxFileSize: MAX_FILE_SIZE, allowedMimeTypes: ALLOWED_MIME_TYPES }; + return { maxFileSize: MAX_FILE_SIZE, allowedExtensions: ALLOWED_EXTENSIONS }; } /** @@ -99,19 +128,83 @@ const storage = multer.diskStorage({ }); /** - * Datei-Filter + * Gefährliche Dateinamen prüfen + */ +function isSecureFilename(filename) { + // Null-Bytes, Pfad-Traversal, Steuerzeichen blocken + const dangerousPatterns = [ + /\x00/, // Null-Bytes + /\.\./, // Path traversal + /[<>:"\\|?*]/, // Windows-spezifische gefährliche Zeichen + /[\x00-\x1F]/, // Steuerzeichen + /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(\.|$)/i, // Windows reservierte Namen + ]; + + return !dangerousPatterns.some(pattern => pattern.test(filename)); +} + +/** + * Datei-Filter: Erweiterte Sicherheitsprüfungen */ const fileFilter = (req, file, cb) => { // Aktuelle Einstellungen laden const settings = getCurrentSettings(); - // MIME-Type prüfen - if (settings.allowedMimeTypes.includes(file.mimetype)) { - cb(null, true); - } else { - logger.warn(`Abgelehnter Upload: ${file.originalname} (${file.mimetype})`); - cb(new Error(`Dateityp nicht erlaubt: ${file.mimetype}`), false); + // Sicherheitsprüfungen für Dateinamen + if (!isSecureFilename(file.originalname)) { + logger.warn(`Unsicherer Dateiname abgelehnt: ${file.originalname}`); + cb(new Error('Dateiname enthält nicht erlaubte Zeichen'), false); + return; } + + // Dateiname-Länge prüfen + if (file.originalname.length > 255) { + logger.warn(`Dateiname zu lang: ${file.originalname}`); + cb(new Error('Dateiname ist zu lang (max. 255 Zeichen)'), false); + return; + } + + // Dateiendung extrahieren (ohne Punkt, lowercase) + const ext = path.extname(file.originalname).toLowerCase().replace('.', ''); + + // Doppelte Dateiendungen verhindern (z.B. script.txt.exe) + const nameWithoutExt = path.basename(file.originalname, path.extname(file.originalname)); + if (path.extname(nameWithoutExt)) { + logger.warn(`Doppelte Dateiendung abgelehnt: ${file.originalname}`); + cb(new Error('Dateien mit mehreren Endungen sind nicht erlaubt'), false); + return; + } + + // Prüfen ob Endung erlaubt ist + if (!settings.allowedExtensions.includes(ext)) { + logger.warn(`Abgelehnter Upload (Endung): ${file.originalname} (.${ext})`); + cb(new Error(`Dateityp .${ext} nicht erlaubt`), false); + return; + } + + // Executable Dateien zusätzlich blocken + const executableExtensions = [ + 'exe', 'bat', 'cmd', 'com', 'scr', 'pif', 'vbs', 'vbe', 'js', 'jar', + 'app', 'deb', 'pkg', 'dmg', 'run', 'bin', 'msi', 'gadget' + ]; + if (executableExtensions.includes(ext)) { + logger.warn(`Executable Datei abgelehnt: ${file.originalname}`); + cb(new Error('Ausführbare Dateien sind nicht erlaubt'), false); + return; + } + + // MIME-Type gegen bekannte Typen prüfen + const expectedMimes = EXTENSION_TO_MIME[ext]; + if (expectedMimes && !expectedMimes.includes(file.mimetype)) { + logger.warn(`MIME-Mismatch: ${file.originalname} (erwartet: ${expectedMimes.join('/')}, bekommen: ${file.mimetype})`); + // Bei kritischen Mismatches ablehnen + if (file.mimetype === 'application/octet-stream' || file.mimetype.startsWith('application/x-')) { + cb(new Error('Verdächtiger Dateityp erkannt'), false); + return; + } + } + + cb(null, true); }; /** @@ -194,5 +287,6 @@ module.exports = { getCurrentSettings, UPLOAD_DIR, MAX_FILE_SIZE, - ALLOWED_MIME_TYPES + ALLOWED_EXTENSIONS, + EXTENSION_TO_MIME }; diff --git a/backend/middleware/validation.js b/backend/middleware/validation.js index 7c5aa71..d2986a7 100644 --- a/backend/middleware/validation.js +++ b/backend/middleware/validation.js @@ -5,24 +5,52 @@ */ const sanitizeHtml = require('sanitize-html'); +const createDOMPurify = require('dompurify'); +const { JSDOM } = require('jsdom'); + +// DOMPurify für Server-side Rendering initialisieren +const window = new JSDOM('').window; +const DOMPurify = createDOMPurify(window); /** - * HTML-Tags entfernen (für reine Text-Felder) + * HTML-Entities dekodieren */ -function stripHtml(input) { - if (typeof input !== 'string') return input; - return sanitizeHtml(input, { - allowedTags: [], - allowedAttributes: {} - }).trim(); +function decodeHtmlEntities(str) { + if (typeof str !== 'string') return str; + const entities = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + ''': "'", + ''': "'", + ''': "'" + }; + return str.replace(/&(amp|lt|gt|quot|#039|#x27|apos);/g, match => entities[match] || match); } /** - * Markdown-sichere Bereinigung (erlaubt bestimmte Tags) + * HTML-Tags entfernen (für reine Text-Felder) + * Wichtig: sanitize-html encoded &-Zeichen zu &, daher dekodieren wir danach + */ +function stripHtml(input) { + if (typeof input !== 'string') return input; + const sanitized = sanitizeHtml(input, { + allowedTags: [], + allowedAttributes: {} + }).trim(); + // Entities wieder dekodieren, da sanitize-html sie encoded + return decodeHtmlEntities(sanitized); +} + +/** + * Markdown-sichere Bereinigung mit DOMPurify (doppelte Sicherheit) */ function sanitizeMarkdown(input) { if (typeof input !== 'string') return input; - return sanitizeHtml(input, { + + // Erste Bereinigung mit sanitize-html + const firstPass = sanitizeHtml(input, { allowedTags: [ 'p', 'br', 'strong', 'em', 'u', 's', 'code', 'pre', 'ul', 'ol', 'li', 'blockquote', 'a', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6' @@ -44,6 +72,16 @@ function sanitizeMarkdown(input) { } } }); + + // Zweite Bereinigung mit DOMPurify (zusätzliche Sicherheit) + return DOMPurify.sanitize(firstPass, { + ALLOWED_TAGS: [ + 'p', 'br', 'strong', 'em', 'u', 's', 'code', 'pre', + 'ul', 'ol', 'li', 'blockquote', 'a', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6' + ], + ALLOWED_ATTR: ['href', 'title', 'target', 'rel'], + ALLOWED_URI_REGEXP: /^(?:(?:(?:f|ht)tps?|mailto):|[^a-z]|[a-z+.-]+(?:[^a-z+.-:]|$))/i + }); } /** @@ -65,7 +103,15 @@ function sanitizeObject(obj, options = {}) { for (const [key, value] of Object.entries(obj)) { // Bestimmte Felder dürfen Markdown enthalten const allowHtml = ['description', 'content'].includes(key); - sanitized[key] = sanitizeObject(value, { allowHtml }); + + // Passwort-Felder NICHT sanitizen (Sonderzeichen erhalten) + const skipSanitization = ['password', 'oldPassword', 'newPassword', 'confirmPassword'].includes(key); + + if (skipSanitization) { + sanitized[key] = value; // Passwort unverändert lassen + } else { + sanitized[key] = sanitizeObject(value, { allowHtml }); + } } return sanitized; } @@ -119,12 +165,32 @@ const validators = { }, /** - * URL-Format prüfen + * URL-Format prüfen (erweiterte Sicherheit) */ url: (value, fieldName) => { try { if (value) { - new URL(value); + const url = new URL(value); + + // Nur HTTP/HTTPS erlauben + if (!['http:', 'https:'].includes(url.protocol)) { + return `${fieldName} muss HTTP oder HTTPS verwenden`; + } + + // Localhost und private IPs blocken (SSRF-Schutz) + const hostname = url.hostname; + if (hostname === 'localhost' || + hostname === '127.0.0.1' || + hostname.startsWith('192.168.') || + hostname.startsWith('10.') || + hostname.startsWith('172.')) { + return `${fieldName} darf nicht auf lokale Adressen verweisen`; + } + + // JavaScript URLs blocken + if (url.href.toLowerCase().startsWith('javascript:')) { + return `${fieldName} enthält ungültigen JavaScript-Code`; + } } return null; } catch { diff --git a/backend/package.json b/backend/package.json index 48f7252..f8ff7ab 100644 --- a/backend/package.json +++ b/backend/package.json @@ -20,7 +20,10 @@ "cookie-parser": "^1.4.6", "express-rate-limiter": "^1.3.1", "sanitize-html": "^2.11.0", - "marked": "^11.1.0" + "marked": "^11.1.0", + "dompurify": "^3.0.6", + "jsdom": "^23.0.1", + "dotenv": "^16.3.1" }, "engines": { "node": ">=18.0.0" diff --git a/backend/query_users.js b/backend/query_users.js new file mode 100644 index 0000000..45c19ba --- /dev/null +++ b/backend/query_users.js @@ -0,0 +1,87 @@ +#!/usr/bin/env node +/** + * Script zum Abfragen der Benutzer aus der SQLite-Datenbank + * Verwendung: node query_users.js + */ + +const Database = require('better-sqlite3'); +const path = require('path'); + +// Datenbank-Pfad - angepasst für Docker-Container +const DB_PATH = process.env.DB_PATH || './data/taskmate.db'; + +try { + console.log('Verbinde zur Datenbank:', DB_PATH); + + // Datenbank öffnen + const db = new Database(DB_PATH); + + // Alle Benutzer abfragen + const users = db.prepare(` + SELECT + id, + username, + display_name, + color, + role, + email, + repositories_base_path, + created_at, + last_login, + failed_attempts, + locked_until + FROM users + ORDER BY id + `).all(); + + console.log('\n=== BENUTZER IN DER DATENBANK ===\n'); + + if (users.length === 0) { + console.log('Keine Benutzer gefunden!'); + } else { + users.forEach(user => { + console.log(`ID: ${user.id}`); + console.log(`Benutzername: ${user.username}`); + console.log(`Anzeigename: ${user.display_name}`); + console.log(`Farbe: ${user.color}`); + console.log(`Rolle: ${user.role || 'user'}`); + console.log(`E-Mail: ${user.email || 'nicht gesetzt'}`); + console.log(`Repository-Basispfad: ${user.repositories_base_path || 'nicht gesetzt'}`); + console.log(`Erstellt am: ${user.created_at}`); + console.log(`Letzter Login: ${user.last_login || 'noch nie'}`); + console.log(`Fehlgeschlagene Versuche: ${user.failed_attempts}`); + console.log(`Gesperrt bis: ${user.locked_until || 'nicht gesperrt'}`); + console.log('-----------------------------------'); + }); + + console.log(`\nGesamt: ${users.length} Benutzer gefunden`); + } + + // Prüfe auch Login-Audit für weitere Informationen + const recentAttempts = db.prepare(` + SELECT + la.timestamp, + la.ip_address, + la.success, + la.user_agent, + u.username + FROM login_audit la + LEFT JOIN users u ON la.user_id = u.id + ORDER BY la.timestamp DESC + LIMIT 10 + `).all(); + + if (recentAttempts.length > 0) { + console.log('\n=== LETZTE LOGIN-VERSUCHE ===\n'); + recentAttempts.forEach(attempt => { + console.log(`${attempt.timestamp}: ${attempt.username || 'Unbekannt'} - ${attempt.success ? 'ERFOLGREICH' : 'FEHLGESCHLAGEN'} - IP: ${attempt.ip_address}`); + }); + } + + // Datenbank schließen + db.close(); + +} catch (error) { + console.error('Fehler beim Abfragen der Datenbank:', error.message); + process.exit(1); +} \ No newline at end of file diff --git a/backend/routes/admin.js b/backend/routes/admin.js index 08afdfb..aecf264 100644 --- a/backend/routes/admin.js +++ b/backend/routes/admin.js @@ -10,45 +10,14 @@ const router = express.Router(); const { getDb } = require('../database'); const { authenticateToken, requireAdmin } = require('../middleware/auth'); const logger = require('../utils/logger'); +const backup = require('../utils/backup'); /** - * Standard-Upload-Einstellungen + * Standard-Upload-Einstellungen (neues Format mit Dateiendungen) */ const DEFAULT_UPLOAD_SETTINGS = { maxFileSizeMB: 15, - allowedTypes: { - images: { - enabled: true, - types: ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml'] - }, - documents: { - enabled: true, - types: ['application/pdf'] - }, - office: { - enabled: true, - types: [ - 'application/msword', - 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - 'application/vnd.ms-excel', - 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - 'application/vnd.ms-powerpoint', - 'application/vnd.openxmlformats-officedocument.presentationml.presentation' - ] - }, - text: { - enabled: true, - types: ['text/plain', 'text/csv', 'text/markdown'] - }, - archives: { - enabled: true, - types: ['application/zip', 'application/x-rar-compressed', 'application/x-7z-compressed'] - }, - data: { - enabled: true, - types: ['application/json'] - } - } + allowedExtensions: ['pdf', 'docx', 'txt'] }; // Alle Admin-Routes erfordern Authentifizierung und Admin-Rolle @@ -351,6 +320,17 @@ router.get('/upload-settings', (req, res) => { if (setting) { const settings = JSON.parse(setting.value); + + // Migration: Altes Format (allowedTypes) auf neues Format (allowedExtensions) umstellen + if (settings.allowedTypes && !settings.allowedExtensions) { + // Altes Format erkannt - auf Standard-Einstellungen zurücksetzen + logger.info('Migriere Upload-Einstellungen auf neues Format (allowedExtensions)'); + db.prepare('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)') + .run('upload_settings', JSON.stringify(DEFAULT_UPLOAD_SETTINGS)); + res.json(DEFAULT_UPLOAD_SETTINGS); + return; + } + res.json(settings); } else { // Standard-Einstellungen zurückgeben und speichern @@ -369,24 +349,36 @@ router.get('/upload-settings', (req, res) => { */ router.put('/upload-settings', (req, res) => { try { - const { maxFileSizeMB, allowedTypes } = req.body; + const { maxFileSizeMB, allowedExtensions } = req.body; // Validierung if (typeof maxFileSizeMB !== 'number' || maxFileSizeMB < 1 || maxFileSizeMB > 100) { return res.status(400).json({ error: 'Maximale Dateigröße muss zwischen 1 und 100 MB liegen' }); } - if (!allowedTypes || typeof allowedTypes !== 'object') { - return res.status(400).json({ error: 'Ungültige Dateityp-Konfiguration' }); + if (!Array.isArray(allowedExtensions) || allowedExtensions.length === 0) { + return res.status(400).json({ error: 'Mindestens eine Dateiendung muss erlaubt sein' }); } - const settings = { maxFileSizeMB, allowedTypes }; + // Endungen validieren (nur alphanumerisch, 1-10 Zeichen) + const validExtensions = allowedExtensions + .map(ext => ext.toLowerCase().replace(/^\./, '')) // Punkt am Anfang entfernen + .filter(ext => /^[a-z0-9]{1,10}$/.test(ext)); + + if (validExtensions.length === 0) { + return res.status(400).json({ error: 'Keine gültigen Dateiendungen angegeben' }); + } + + // Duplikate entfernen + const uniqueExtensions = [...new Set(validExtensions)]; + + const settings = { maxFileSizeMB, allowedExtensions: uniqueExtensions }; const db = getDb(); db.prepare('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)') .run('upload_settings', JSON.stringify(settings)); - logger.info(`Admin ${req.user.username} hat Upload-Einstellungen geändert`); + logger.info(`Admin ${req.user.username} hat Upload-Einstellungen geändert: ${uniqueExtensions.join(', ')}`); res.json(settings); } catch (error) { @@ -404,7 +396,12 @@ function getUploadSettings() { const setting = db.prepare('SELECT value FROM settings WHERE key = ?').get('upload_settings'); if (setting) { - return JSON.parse(setting.value); + const settings = JSON.parse(setting.value); + // Bei altem Format oder fehlendem allowedExtensions: Standard verwenden + if (!settings.allowedExtensions || !Array.isArray(settings.allowedExtensions)) { + return DEFAULT_UPLOAD_SETTINGS; + } + return settings; } return DEFAULT_UPLOAD_SETTINGS; } catch (error) { @@ -413,6 +410,42 @@ function getUploadSettings() { } } +/** + * POST /api/admin/backup/create - Sofortiges verschlüsseltes Backup erstellen + */ +router.post('/backup/create', (req, res) => { + try { + const backupPath = backup.createBackup(); + + if (backupPath) { + logger.info(`Admin ${req.user.username} hat manuelles Backup erstellt`); + res.json({ + success: true, + message: 'Verschlüsseltes Backup erfolgreich erstellt', + backupPath: backupPath.split('/').pop() // Nur Dateiname zurückgeben + }); + } else { + res.status(500).json({ error: 'Backup-Erstellung fehlgeschlagen' }); + } + } catch (error) { + logger.error('Backup-Erstellung durch Admin fehlgeschlagen:', error); + res.status(500).json({ error: 'Interner Fehler beim Erstellen des Backups' }); + } +}); + +/** + * GET /api/admin/backup/list - Liste aller verschlüsselten Backups + */ +router.get('/backup/list', (req, res) => { + try { + const backups = backup.listBackups(); + res.json(backups); + } catch (error) { + logger.error('Fehler beim Auflisten der Backups:', error); + res.status(500).json({ error: 'Fehler beim Auflisten der Backups' }); + } +}); + module.exports = router; module.exports.getUploadSettings = getUploadSettings; module.exports.DEFAULT_UPLOAD_SETTINGS = DEFAULT_UPLOAD_SETTINGS; diff --git a/backend/routes/auth.js b/backend/routes/auth.js index 01eb6a7..6efc438 100644 --- a/backend/routes/auth.js +++ b/backend/routes/auth.js @@ -8,7 +8,7 @@ const express = require('express'); const router = express.Router(); const bcrypt = require('bcryptjs'); const { getDb } = require('../database'); -const { generateToken, authenticateToken } = require('../middleware/auth'); +const { generateToken, generateRefreshToken, refreshAccessToken, revokeAllRefreshTokens, authenticateToken } = require('../middleware/auth'); const { getTokenForUser } = require('../middleware/csrf'); const { validatePassword } = require('../middleware/validation'); const logger = require('../utils/logger'); @@ -37,13 +37,8 @@ router.post('/login', async (req, res) => { // Benutzer suchen: Zuerst nach Username "admin", dann nach E-Mail let user; - if (username.toLowerCase() === 'admin') { - // Admin-User per Username suchen - user = db.prepare('SELECT * FROM users WHERE username = ?').get('admin'); - } else { - // Normale User per E-Mail suchen - user = db.prepare('SELECT * FROM users WHERE email = ?').get(username); - } + // User per Username suchen (kann E-Mail-Adresse oder admin sein) + user = db.prepare('SELECT * FROM users WHERE username = ?').get(username); // Audit-Log Eintrag vorbereiten const logAttempt = (userId, success) => { @@ -111,8 +106,11 @@ router.post('/login', async (req, res) => { logAttempt(user.id, true); - // JWT-Token generieren - const token = generateToken(user); + // JWT Access-Token generieren (kurze Lebensdauer) + const accessToken = generateToken(user); + + // Refresh-Token generieren (lange Lebensdauer) + const refreshToken = generateRefreshToken(user.id, ip, userAgent); // CSRF-Token generieren const csrfToken = getTokenForUser(user.id); @@ -128,7 +126,8 @@ router.post('/login', async (req, res) => { } res.json({ - token, + token: accessToken, + refreshToken, csrfToken, user: { id: user.id, @@ -147,13 +146,19 @@ router.post('/login', async (req, res) => { /** * POST /api/auth/logout - * Benutzer abmelden + * Benutzer abmelden und Refresh-Tokens widerrufen */ router.post('/logout', authenticateToken, (req, res) => { - // Bei JWT gibt es serverseitig nichts zu tun - // Client muss Token löschen - logger.info(`Logout: ${req.user.username}`); - res.json({ message: 'Erfolgreich abgemeldet' }); + try { + // Alle Refresh-Tokens des Benutzers löschen + revokeAllRefreshTokens(req.user.id); + + logger.info(`Logout: ${req.user.username}`); + res.json({ message: 'Erfolgreich abgemeldet' }); + } catch (error) { + logger.error('Logout-Fehler:', { error: error.message }); + res.status(500).json({ error: 'Logout fehlgeschlagen' }); + } }); /** @@ -200,27 +205,68 @@ router.get('/me', authenticateToken, (req, res) => { /** * POST /api/auth/refresh - * Token erneuern + * Token mit Refresh-Token erneuern */ -router.post('/refresh', authenticateToken, (req, res) => { +router.post('/refresh', async (req, res) => { try { - const db = getDb(); - const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.user.id); - - if (!user) { - return res.status(404).json({ error: 'Benutzer nicht gefunden' }); + const { refreshToken } = req.body; + const ip = req.ip || req.connection.remoteAddress; + const userAgent = req.headers['user-agent']; + + if (!refreshToken) { + // Fallback für alte Clients - mit Access Token authentifizieren + if (req.headers.authorization) { + return legacyRefresh(req, res); + } + return res.status(400).json({ error: 'Refresh-Token erforderlich' }); } - const token = generateToken(user); - const csrfToken = getTokenForUser(user.id); + // Neuen Access-Token mit Refresh-Token generieren + const accessToken = await refreshAccessToken(refreshToken, ip, userAgent); + const db = getDb(); + + // User-Daten für CSRF-Token abrufen + const decoded = require('jsonwebtoken').decode(accessToken); + const csrfToken = getTokenForUser(decoded.id); - res.json({ token, csrfToken }); + res.json({ + token: accessToken, + csrfToken + }); } catch (error) { logger.error('Token-Refresh Fehler:', { error: error.message }); - res.status(500).json({ error: 'Interner Serverfehler' }); + res.status(401).json({ error: 'Token-Erneuerung fehlgeschlagen' }); } }); +// Legacy Refresh für Rückwärtskompatibilität +function legacyRefresh(req, res) { + // Prüfe Authorization Header + const authHeader = req.headers['authorization']; + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return res.status(401).json({ error: 'Nicht authentifiziert' }); + } + + const token = authHeader.substring(7); + const user = require('../middleware/auth').verifyToken(token); + + if (!user) { + return res.status(401).json({ error: 'Token ungültig' }); + } + + const db = getDb(); + const dbUser = db.prepare('SELECT * FROM users WHERE id = ?').get(user.id); + + if (!dbUser) { + return res.status(404).json({ error: 'Benutzer nicht gefunden' }); + } + + const newToken = generateToken(dbUser); + const csrfToken = getTokenForUser(dbUser.id); + + res.json({ token: newToken, csrfToken }); +} + /** * PUT /api/auth/password * Passwort ändern diff --git a/backend/routes/coding.js b/backend/routes/coding.js new file mode 100644 index 0000000..361945e --- /dev/null +++ b/backend/routes/coding.js @@ -0,0 +1,643 @@ +/** + * TASKMATE - Coding Routes + * ======================== + * Verwaltung von Server-Anwendungen mit Claude/Codex Integration + */ + +const express = require('express'); +const router = express.Router(); +const fs = require('fs'); +const path = require('path'); +const { getDb } = require('../database'); +const logger = require('../utils/logger'); +const gitService = require('../services/gitService'); + +/** + * Prüft ob ein Pfad ein Server-Pfad (Linux) ist + */ +function isServerPath(localPath) { + return localPath && localPath.startsWith('/'); +} + +/** + * Schreibt CLAUDE.md in ein Verzeichnis + */ +function writeCLAUDEmd(directoryPath, content) { + if (!content || !directoryPath) return false; + + try { + const claudePath = path.join(directoryPath, 'CLAUDE.md'); + fs.writeFileSync(claudePath, content, 'utf8'); + logger.info(`CLAUDE.md geschrieben: ${claudePath}`); + return true; + } catch (e) { + logger.error('CLAUDE.md schreiben fehlgeschlagen:', e); + return false; + } +} + +/** + * Liest CLAUDE.md aus einem Verzeichnis + */ +function readCLAUDEmd(directoryPath) { + if (!directoryPath) { + logger.info('readCLAUDEmd: No directoryPath provided'); + return null; + } + + try { + const claudePath = path.join(directoryPath, 'CLAUDE.md'); + logger.info(`readCLAUDEmd: Checking path ${claudePath}`); + + if (fs.existsSync(claudePath)) { + const content = fs.readFileSync(claudePath, 'utf8'); + logger.info(`readCLAUDEmd: Successfully read ${content.length} characters from ${claudePath}`); + return content; + } else { + logger.info(`readCLAUDEmd: File does not exist: ${claudePath}`); + } + } catch (e) { + logger.error('CLAUDE.md lesen fehlgeschlagen:', e); + } + return null; +} + +/** + * GET /api/coding/directories + * Alle Coding-Verzeichnisse abrufen + */ +router.get('/directories', (req, res) => { + try { + const db = getDb(); + const directories = db.prepare(` + SELECT cd.*, u.display_name as creator_name + FROM coding_directories cd + LEFT JOIN users u ON cd.created_by = u.id + ORDER BY cd.position ASC, cd.name ASC + `).all(); + + res.json(directories.map(dir => { + // CLAUDE.md aus dem Dateisystem lesen falls vorhanden + let claudeMdFromDisk = null; + if (isServerPath(dir.local_path)) { + claudeMdFromDisk = readCLAUDEmd(dir.local_path); + // Fallback: Wenn Pfad /home/claude-dev/TaskMate ist, versuche /app/taskmate-source + if (!claudeMdFromDisk && dir.local_path === '/home/claude-dev/TaskMate') { + logger.info('Trying fallback path for TaskMate: /app/taskmate-source'); + claudeMdFromDisk = readCLAUDEmd('/app/taskmate-source'); + } + } + + return { + id: dir.id, + name: dir.name, + localPath: dir.local_path, + description: dir.description, + color: dir.color, + claudeInstructions: dir.claude_instructions, + claudeMdFromDisk: claudeMdFromDisk, + hasCLAUDEmd: !!claudeMdFromDisk, + giteaRepoUrl: dir.gitea_repo_url, + giteaRepoOwner: dir.gitea_repo_owner, + giteaRepoName: dir.gitea_repo_name, + defaultBranch: dir.default_branch, + lastSync: dir.last_sync, + position: dir.position, + createdAt: dir.created_at, + createdBy: dir.created_by, + creatorName: dir.creator_name + }; + })); + } catch (error) { + logger.error('Fehler beim Abrufen der Coding-Verzeichnisse:', error); + res.status(500).json({ error: 'Interner Serverfehler' }); + } +}); + +/** + * POST /api/coding/directories + * Neues Coding-Verzeichnis erstellen + */ +router.post('/directories', (req, res) => { + try { + const { name, localPath, description, color, claudeInstructions, giteaRepoUrl, giteaRepoOwner, giteaRepoName, defaultBranch } = req.body; + + if (!name || !localPath) { + return res.status(400).json({ error: 'Name und Server-Pfad sind erforderlich' }); + } + + const db = getDb(); + + // Prüfe ob Pfad bereits existiert + const existing = db.prepare('SELECT id FROM coding_directories WHERE local_path = ?').get(localPath); + if (existing) { + return res.status(400).json({ error: 'Diese Anwendung ist bereits registriert' }); + } + + // Höchste Position ermitteln + const maxPos = db.prepare('SELECT COALESCE(MAX(position), -1) as max FROM coding_directories').get().max; + + const result = db.prepare(` + INSERT INTO coding_directories (name, local_path, description, color, claude_instructions, gitea_repo_url, gitea_repo_owner, gitea_repo_name, default_branch, position, created_by) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + name, + localPath, + description || null, + color || '#4F46E5', + null, // claudeInstructions wird nicht mehr gespeichert + giteaRepoUrl || null, + giteaRepoOwner || null, + giteaRepoName || null, + defaultBranch || 'main', + maxPos + 1, + req.user.id + ); + + const directory = db.prepare('SELECT * FROM coding_directories WHERE id = ?').get(result.lastInsertRowid); + + // Ordner erstellen falls nicht vorhanden + let directoryCreated = false; + if (isServerPath(localPath) && !fs.existsSync(localPath)) { + try { + fs.mkdirSync(localPath, { recursive: true }); + directoryCreated = true; + logger.info(`Anwendungsordner erstellt: ${localPath}`); + } catch (e) { + logger.error('Ordner erstellen fehlgeschlagen:', e); + } + } + + // CLAUDE.md wird nicht mehr geschrieben - nur readonly + let claudeMdWritten = false; + + logger.info(`Coding-Anwendung erstellt: ${name} (${localPath})`); + + // CLAUDE.md aus dem Dateisystem lesen für aktuelle Anzeige + const claudeMdFromDisk = isServerPath(directory.local_path) ? readCLAUDEmd(directory.local_path) : null; + + res.status(201).json({ + id: directory.id, + name: directory.name, + localPath: directory.local_path, + description: directory.description, + color: directory.color, + claudeInstructions: directory.claude_instructions, + claudeMdFromDisk: claudeMdFromDisk, + hasCLAUDEmd: !!claudeMdFromDisk, + giteaRepoUrl: directory.gitea_repo_url, + giteaRepoOwner: directory.gitea_repo_owner, + giteaRepoName: directory.gitea_repo_name, + defaultBranch: directory.default_branch, + position: directory.position, + createdAt: directory.created_at, + directoryCreated, + claudeMdWritten + }); + } catch (error) { + logger.error('Fehler beim Erstellen der Coding-Anwendung:', error); + res.status(500).json({ error: 'Interner Serverfehler' }); + } +}); + +/** + * PUT /api/coding/directories/:id + * Coding-Anwendung aktualisieren + */ +router.put('/directories/:id', (req, res) => { + try { + const { id } = req.params; + const { name, localPath, description, color, claudeInstructions, giteaRepoUrl, giteaRepoOwner, giteaRepoName, defaultBranch, position } = req.body; + + const db = getDb(); + + const existing = db.prepare('SELECT * FROM coding_directories WHERE id = ?').get(id); + if (!existing) { + return res.status(404).json({ error: 'Anwendung nicht gefunden' }); + } + + // Prüfe ob neuer Pfad bereits von anderem Eintrag verwendet wird + if (localPath && localPath !== existing.local_path) { + const pathExists = db.prepare('SELECT id FROM coding_directories WHERE local_path = ? AND id != ?').get(localPath, id); + if (pathExists) { + return res.status(400).json({ error: 'Dieser Server-Pfad ist bereits registriert' }); + } + } + + db.prepare(` + UPDATE coding_directories SET + name = COALESCE(?, name), + local_path = COALESCE(?, local_path), + description = ?, + color = COALESCE(?, color), + claude_instructions = ?, + gitea_repo_url = ?, + gitea_repo_owner = ?, + gitea_repo_name = ?, + default_branch = COALESCE(?, default_branch), + position = COALESCE(?, position) + WHERE id = ? + `).run( + name || null, + localPath || null, + description !== undefined ? description : existing.description, + color || null, + null, // claudeInstructions wird nicht mehr aktualisiert + giteaRepoUrl !== undefined ? giteaRepoUrl : existing.gitea_repo_url, + giteaRepoOwner !== undefined ? giteaRepoOwner : existing.gitea_repo_owner, + giteaRepoName !== undefined ? giteaRepoName : existing.gitea_repo_name, + defaultBranch || null, + position !== undefined ? position : null, + id + ); + + const updated = db.prepare('SELECT * FROM coding_directories WHERE id = ?').get(id); + const finalPath = updated.local_path; + + // Ordner erstellen falls nicht vorhanden + let directoryCreated = false; + if (isServerPath(finalPath) && !fs.existsSync(finalPath)) { + try { + fs.mkdirSync(finalPath, { recursive: true }); + directoryCreated = true; + logger.info(`Anwendungsordner erstellt: ${finalPath}`); + } catch (e) { + logger.error('Ordner erstellen fehlgeschlagen:', e); + } + } + + // CLAUDE.md wird nicht mehr geschrieben - nur readonly + let claudeMdWritten = false; + + logger.info(`Coding-Anwendung aktualisiert: ${updated.name}`); + + // CLAUDE.md aus dem Dateisystem lesen für aktuelle Anzeige + const claudeMdFromDisk = isServerPath(updated.local_path) ? readCLAUDEmd(updated.local_path) : null; + + res.json({ + id: updated.id, + name: updated.name, + localPath: updated.local_path, + description: updated.description, + color: updated.color, + claudeInstructions: updated.claude_instructions, + claudeMdFromDisk: claudeMdFromDisk, + hasCLAUDEmd: !!claudeMdFromDisk, + giteaRepoUrl: updated.gitea_repo_url, + giteaRepoOwner: updated.gitea_repo_owner, + giteaRepoName: updated.gitea_repo_name, + defaultBranch: updated.default_branch, + position: updated.position, + createdAt: updated.created_at, + directoryCreated, + claudeMdWritten + }); + } catch (error) { + logger.error('Fehler beim Aktualisieren der Coding-Anwendung:', error); + res.status(500).json({ error: 'Interner Serverfehler' }); + } +}); + +/** + * DELETE /api/coding/directories/:id + * Coding-Anwendung löschen + */ +router.delete('/directories/:id', (req, res) => { + try { + const { id } = req.params; + const db = getDb(); + + const existing = db.prepare('SELECT * FROM coding_directories WHERE id = ?').get(id); + if (!existing) { + return res.status(404).json({ error: 'Anwendung nicht gefunden' }); + } + + db.prepare('DELETE FROM coding_directories WHERE id = ?').run(id); + + logger.info(`Coding-Anwendung gelöscht: ${existing.name}`); + + res.json({ message: 'Anwendung gelöscht' }); + } catch (error) { + logger.error('Fehler beim Löschen der Coding-Anwendung:', error); + res.status(500).json({ error: 'Interner Serverfehler' }); + } +}); + +/** + * GET /api/coding/directories/:id/status + * Git-Status eines Verzeichnisses abrufen + */ +router.get('/directories/:id/status', (req, res) => { + try { + const { id } = req.params; + const db = getDb(); + + const directory = db.prepare('SELECT * FROM coding_directories WHERE id = ?').get(id); + if (!directory) { + return res.status(404).json({ error: 'Anwendung nicht gefunden' }); + } + + const localPath = directory.local_path; + + // Prüfe ob es ein Git-Repository ist + if (!gitService.isGitRepository(localPath)) { + return res.json({ + isGitRepo: false, + message: 'Kein Git-Repository' + }); + } + + const status = gitService.getStatus(localPath); + + if (!status.success) { + return res.status(500).json({ error: status.error }); + } + + res.json({ + isGitRepo: true, + branch: status.branch, + hasChanges: status.hasChanges, + changes: status.changes, + ahead: status.ahead, + behind: status.behind, + isClean: status.isClean + }); + } catch (error) { + logger.error('Fehler beim Abrufen des Git-Status:', error); + res.status(500).json({ error: 'Interner Serverfehler' }); + } +}); + +/** + * POST /api/coding/directories/:id/fetch + * Git Fetch ausführen + */ +router.post('/directories/:id/fetch', (req, res) => { + try { + const { id } = req.params; + const db = getDb(); + + const directory = db.prepare('SELECT * FROM coding_directories WHERE id = ?').get(id); + if (!directory) { + return res.status(404).json({ error: 'Anwendung nicht gefunden' }); + } + + const result = gitService.fetchRemote(directory.local_path); + + if (!result.success) { + return res.status(500).json({ error: result.error }); + } + + // Last sync aktualisieren + db.prepare('UPDATE coding_directories SET last_sync = CURRENT_TIMESTAMP WHERE id = ?').run(id); + + logger.info(`Git fetch ausgeführt für: ${directory.name}`); + + res.json({ success: true, message: 'Fetch erfolgreich' }); + } catch (error) { + logger.error('Fehler beim Git Fetch:', error); + res.status(500).json({ error: 'Interner Serverfehler' }); + } +}); + +/** + * POST /api/coding/directories/:id/pull + * Git Pull ausführen + */ +router.post('/directories/:id/pull', (req, res) => { + try { + const { id } = req.params; + const db = getDb(); + + const directory = db.prepare('SELECT * FROM coding_directories WHERE id = ?').get(id); + if (!directory) { + return res.status(404).json({ error: 'Anwendung nicht gefunden' }); + } + + const result = gitService.pullChanges(directory.local_path, { branch: directory.default_branch }); + + if (!result.success) { + return res.status(500).json({ error: result.error }); + } + + // Last sync aktualisieren + db.prepare('UPDATE coding_directories SET last_sync = CURRENT_TIMESTAMP WHERE id = ?').run(id); + + logger.info(`Git pull ausgeführt für: ${directory.name}`); + + res.json({ success: true, message: 'Pull erfolgreich', output: result.output }); + } catch (error) { + logger.error('Fehler beim Git Pull:', error); + res.status(500).json({ error: 'Interner Serverfehler' }); + } +}); + +/** + * POST /api/coding/directories/:id/push + * Git Push ausführen + */ +router.post('/directories/:id/push', (req, res) => { + try { + const { id } = req.params; + const { force } = req.body; + const db = getDb(); + + const directory = db.prepare('SELECT * FROM coding_directories WHERE id = ?').get(id); + if (!directory) { + return res.status(404).json({ error: 'Anwendung nicht gefunden' }); + } + + const result = gitService.pushWithUpstream(directory.local_path, directory.default_branch, 'origin', force); + + if (!result.success) { + return res.status(500).json({ error: result.error }); + } + + // Last sync aktualisieren + db.prepare('UPDATE coding_directories SET last_sync = CURRENT_TIMESTAMP WHERE id = ?').run(id); + + logger.info(`Git push ausgeführt für: ${directory.name}`); + + res.json({ success: true, message: 'Push erfolgreich', output: result.output }); + } catch (error) { + logger.error('Fehler beim Git Push:', error); + res.status(500).json({ error: 'Interner Serverfehler' }); + } +}); + +/** + * POST /api/coding/directories/:id/commit + * Git Commit ausführen + */ +router.post('/directories/:id/commit', (req, res) => { + try { + const { id } = req.params; + const { message } = req.body; + const db = getDb(); + + if (!message || message.trim() === '') { + return res.status(400).json({ error: 'Commit-Nachricht erforderlich' }); + } + + const directory = db.prepare('SELECT * FROM coding_directories WHERE id = ?').get(id); + if (!directory) { + return res.status(404).json({ error: 'Anwendung nicht gefunden' }); + } + + // Stage all changes + const stageResult = gitService.stageAll(directory.local_path); + if (!stageResult.success) { + return res.status(500).json({ error: stageResult.error }); + } + + // Commit with author info + const author = { + name: req.user.display_name || req.user.username, + email: req.user.email || `${req.user.username}@taskmate.local` + }; + + const result = gitService.commit(directory.local_path, message, author); + + if (!result.success) { + return res.status(500).json({ error: result.error }); + } + + logger.info(`Git commit ausgeführt für: ${directory.name} - "${message}"`); + + res.json({ success: true, message: 'Commit erfolgreich', output: result.output }); + } catch (error) { + logger.error('Fehler beim Git Commit:', error); + res.status(500).json({ error: 'Interner Serverfehler' }); + } +}); + +/** + * GET /api/coding/directories/:id/branches + * Branches abrufen + */ +router.get('/directories/:id/branches', (req, res) => { + try { + const { id } = req.params; + const db = getDb(); + + const directory = db.prepare('SELECT * FROM coding_directories WHERE id = ?').get(id); + if (!directory) { + return res.status(404).json({ error: 'Anwendung nicht gefunden' }); + } + + const result = gitService.getBranches(directory.local_path); + + if (!result.success) { + return res.status(500).json({ error: result.error }); + } + + res.json({ branches: result.branches }); + } catch (error) { + logger.error('Fehler beim Abrufen der Branches:', error); + res.status(500).json({ error: 'Interner Serverfehler' }); + } +}); + +/** + * POST /api/coding/directories/:id/checkout + * Branch wechseln + */ +router.post('/directories/:id/checkout', (req, res) => { + try { + const { id } = req.params; + const { branch } = req.body; + const db = getDb(); + + if (!branch) { + return res.status(400).json({ error: 'Branch erforderlich' }); + } + + const directory = db.prepare('SELECT * FROM coding_directories WHERE id = ?').get(id); + if (!directory) { + return res.status(404).json({ error: 'Anwendung nicht gefunden' }); + } + + const result = gitService.checkoutBranch(directory.local_path, branch); + + if (!result.success) { + return res.status(500).json({ error: result.error }); + } + + logger.info(`Branch gewechselt für ${directory.name}: ${branch}`); + + res.json({ success: true, message: `Gewechselt zu Branch: ${branch}` }); + } catch (error) { + logger.error('Fehler beim Branch-Wechsel:', error); + res.status(500).json({ error: 'Interner Serverfehler' }); + } +}); + +/** + * POST /api/coding/validate-path + * Pfad validieren + */ +router.post('/validate-path', (req, res) => { + try { + const { path: localPath } = req.body; + + if (!localPath) { + return res.status(400).json({ error: 'Pfad erforderlich' }); + } + + // Nur Server-Pfade können validiert werden + if (isServerPath(localPath)) { + const containerPath = gitService.windowsToContainerPath(localPath); + const exists = fs.existsSync(containerPath); + const isGitRepo = exists && gitService.isGitRepository(localPath); + + res.json({ + valid: true, + exists, + isGitRepo, + isServerPath: true + }); + } else { + // Windows-Pfad kann nicht serverseitig validiert werden + res.json({ + valid: true, + exists: null, + isGitRepo: null, + isServerPath: false, + message: 'Windows-Pfade können nicht serverseitig validiert werden' + }); + } + } catch (error) { + logger.error('Fehler bei der Pfad-Validierung:', error); + res.status(500).json({ error: 'Interner Serverfehler' }); + } +}); + +/** + * GET /api/coding/directories/:id/commits + * Commit-Historie abrufen + */ +router.get('/directories/:id/commits', (req, res) => { + try { + const { id } = req.params; + const limit = parseInt(req.query.limit) || 20; + const db = getDb(); + + const directory = db.prepare('SELECT * FROM coding_directories WHERE id = ?').get(id); + if (!directory) { + return res.status(404).json({ error: 'Anwendung nicht gefunden' }); + } + + const result = gitService.getCommitHistory(directory.local_path, limit); + + if (!result.success) { + return res.status(500).json({ error: result.error }); + } + + res.json({ commits: result.commits }); + } catch (error) { + logger.error('Fehler beim Abrufen der Commit-Historie:', error); + res.status(500).json({ error: 'Interner Serverfehler' }); + } +}); + +module.exports = router; diff --git a/backend/server.js b/backend/server.js index 11e77fb..5e06352 100644 --- a/backend/server.js +++ b/backend/server.js @@ -4,6 +4,9 @@ * Node.js/Express Backend mit Socket.io für Echtzeit-Sync */ +// Umgebungsvariablen laden (muss ganz oben stehen!) +require('dotenv').config(); + const express = require('express'); const http = require('http'); const { Server } = require('socket.io'); @@ -42,6 +45,7 @@ const gitRoutes = require('./routes/git'); const applicationsRoutes = require('./routes/applications'); const giteaRoutes = require('./routes/gitea'); const knowledgeRoutes = require('./routes/knowledge'); +const codingRoutes = require('./routes/coding'); // Express App erstellen const app = express(); @@ -59,17 +63,18 @@ const io = new Server(server, { // MIDDLEWARE // ============================================================================= -// Sicherheits-Header +// Erweiterte Sicherheits-Header (CSP temporär deaktiviert für Login-Fix) app.use(helmet({ - contentSecurityPolicy: { - directives: { - defaultSrc: ["'self'"], - styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com"], - fontSrc: ["'self'", "https://fonts.gstatic.com"], - imgSrc: ["'self'", "data:", "blob:"], - scriptSrc: ["'self'"], - connectSrc: ["'self'", "ws:", "wss:"] - } + contentSecurityPolicy: false, // Temporär deaktiviert + hsts: { + maxAge: 31536000, // 1 Jahr + includeSubDomains: true, + preload: true + }, + noSniff: true, + xssFilter: true, + referrerPolicy: { + policy: "strict-origin-when-cross-origin" } })); @@ -86,6 +91,10 @@ app.use(express.urlencoded({ extended: true, limit: '1mb' })); // Cookie Parser app.use(cookieParser()); +// Input Sanitization (vor allen anderen Middlewares) +const { sanitizeMiddleware } = require('./middleware/validation'); +app.use(sanitizeMiddleware); + // Request Logging app.use((req, res, next) => { const start = Date.now(); @@ -148,6 +157,9 @@ app.use('/api/gitea', authenticateToken, csrfProtection, giteaRoutes); // Knowledge-Routes (Wissensmanagement) app.use('/api/knowledge', authenticateToken, csrfProtection, knowledgeRoutes); +// Coding-Routes (Entwicklungsverzeichnisse mit Claude/Codex) +app.use('/api/coding', authenticateToken, csrfProtection, codingRoutes); + // ============================================================================= // SOCKET.IO // ============================================================================= diff --git a/backend/services/gitService.js b/backend/services/gitService.js index c00caeb..b6a0832 100644 --- a/backend/services/gitService.js +++ b/backend/services/gitService.js @@ -21,6 +21,11 @@ function windowsToContainerPath(windowsPath) { return windowsPath; } + // Spezialfall: TaskMate-Verzeichnis ist als /app/taskmate-source gemountet + if (windowsPath === '/home/claude-dev/TaskMate') { + return '/app/taskmate-source'; + } + // Windows-Pfad konvertieren (z.B. "C:\foo" oder "C:/foo") const normalized = windowsPath.replace(/\\/g, '/'); const match = normalized.match(/^([a-zA-Z]):[\/](.*)$/); @@ -73,8 +78,12 @@ function isGitRepository(localPath) { const containerPath = windowsToContainerPath(localPath); try { const gitDir = path.join(containerPath, '.git'); - return fs.existsSync(gitDir); + logger.info(`Git-Repository Check: ${localPath} -> ${containerPath} -> ${gitDir}`); + const exists = fs.existsSync(gitDir); + logger.info(`Git directory exists: ${exists}`); + return exists; } catch (error) { + logger.error(`Git-Repository Check failed: ${error.message}`); return false; } } diff --git a/backend/utils/backup.js b/backend/utils/backup.js index a905fc1..cb3241a 100644 --- a/backend/utils/backup.js +++ b/backend/utils/backup.js @@ -7,6 +7,7 @@ const fs = require('fs'); const path = require('path'); const logger = require('./logger'); +const { encryptFile, decryptFile, secureDelete } = require('./encryption'); const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, '..', 'data'); const BACKUP_DIR = process.env.BACKUP_DIR || path.join(__dirname, '..', 'backups'); @@ -17,8 +18,9 @@ if (!fs.existsSync(BACKUP_DIR)) { fs.mkdirSync(BACKUP_DIR, { recursive: true }); } + /** - * Backup erstellen + * Backup erstellen (mit einfacher Verschlüsselung) */ function createBackup() { try { @@ -29,12 +31,27 @@ function createBackup() { const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); const backupName = `backup_${timestamp}.db`; + const encryptedName = `backup_${timestamp}.db.enc`; const backupPath = path.join(BACKUP_DIR, backupName); + const encryptedPath = path.join(BACKUP_DIR, encryptedName); - // Datenbank kopieren + // 1. Normales Backup erstellen (für Kompatibilität) fs.copyFileSync(DB_FILE, backupPath); - // WAL-Datei auch sichern falls vorhanden + // 2. Verschlüsseltes Backup erstellen (zusätzlich) + if (process.env.ENCRYPTION_KEY) { + try { + if (encryptFile(DB_FILE, encryptedPath)) { + logger.info(`Verschlüsseltes Backup erstellt: ${encryptedName}`); + } else { + logger.warn('Verschlüsselung fehlgeschlagen, nur normales Backup erstellt'); + } + } catch (encError) { + logger.warn('Verschlüsselung fehlgeschlagen, nur normales Backup erstellt'); + } + } + + // 3. WAL-Datei sichern falls vorhanden const walFile = DB_FILE + '-wal'; if (fs.existsSync(walFile)) { fs.copyFileSync(walFile, backupPath + '-wal'); @@ -53,12 +70,12 @@ function createBackup() { } /** - * Alte Backups löschen + * Alte Backups löschen (verschlüsselte) */ function cleanupOldBackups(keepCount = 30) { try { const files = fs.readdirSync(BACKUP_DIR) - .filter(f => f.startsWith('backup_') && f.endsWith('.db')) + .filter(f => f.startsWith('backup_') && f.endsWith('.db.enc')) .sort() .reverse(); @@ -66,15 +83,15 @@ function cleanupOldBackups(keepCount = 30) { toDelete.forEach(file => { const filePath = path.join(BACKUP_DIR, file); - fs.unlinkSync(filePath); + secureDelete(filePath); - // WAL-Datei auch löschen falls vorhanden + // Verschlüsselte WAL-Datei auch löschen falls vorhanden const walPath = filePath + '-wal'; if (fs.existsSync(walPath)) { - fs.unlinkSync(walPath); + secureDelete(walPath); } - logger.info(`Altes Backup gelöscht: ${file}`); + logger.info(`Altes Backup sicher gelöscht: ${file}`); }); } catch (error) { logger.error('Fehler beim Aufräumen alter Backups:', { error: error.message }); @@ -82,32 +99,50 @@ function cleanupOldBackups(keepCount = 30) { } /** - * Backup wiederherstellen + * Backup wiederherstellen (entschlüsselt) */ function restoreBackup(backupName) { try { - const backupPath = path.join(BACKUP_DIR, backupName); + const encryptedBackupPath = path.join(BACKUP_DIR, backupName); - if (!fs.existsSync(backupPath)) { + if (!fs.existsSync(encryptedBackupPath)) { throw new Error(`Backup nicht gefunden: ${backupName}`); } - // Aktuelles DB sichern bevor überschrieben wird + // Aktuelles DB verschlüsselt sichern bevor überschrieben wird if (fs.existsSync(DB_FILE)) { - const safetyBackup = DB_FILE + '.before-restore'; - fs.copyFileSync(DB_FILE, safetyBackup); + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const safetyBackupPath = DB_FILE + `.before-restore-${timestamp}.enc`; + if (!encryptFile(DB_FILE, safetyBackupPath)) { + logger.warn('Sicherheitsbackup vor Wiederherstellung fehlgeschlagen'); + } } - // Backup wiederherstellen - fs.copyFileSync(backupPath, DB_FILE); + // Temporäre entschlüsselte Datei + const tempRestorePath = path.join(BACKUP_DIR, `temp_restore_${Date.now()}.db`); + + // Backup entschlüsseln + if (!decryptFile(encryptedBackupPath, tempRestorePath)) { + throw new Error('Backup-Entschlüsselung fehlgeschlagen'); + } + + // Entschlüsselte DB kopieren + fs.copyFileSync(tempRestorePath, DB_FILE); // WAL-Datei auch wiederherstellen falls vorhanden - const walBackup = backupPath + '-wal'; - if (fs.existsSync(walBackup)) { - fs.copyFileSync(walBackup, DB_FILE + '-wal'); + const encryptedWalBackup = encryptedBackupPath + '-wal'; + if (fs.existsSync(encryptedWalBackup)) { + const tempWalPath = tempRestorePath + '-wal'; + if (decryptFile(encryptedWalBackup, tempWalPath)) { + fs.copyFileSync(tempWalPath, DB_FILE + '-wal'); + secureDelete(tempWalPath); + } } - logger.info(`Backup wiederhergestellt: ${backupName}`); + // Temporäre entschlüsselte Dateien sicher löschen + secureDelete(tempRestorePath); + + logger.info(`Verschlüsseltes Backup wiederhergestellt: ${backupName}`); return true; } catch (error) { logger.error('Restore-Fehler:', { error: error.message }); @@ -116,12 +151,12 @@ function restoreBackup(backupName) { } /** - * Liste aller Backups + * Liste aller verschlüsselten Backups */ function listBackups() { try { const files = fs.readdirSync(BACKUP_DIR) - .filter(f => f.startsWith('backup_') && f.endsWith('.db')) + .filter(f => f.startsWith('backup_') && f.endsWith('.db.enc')) .map(f => { const filePath = path.join(BACKUP_DIR, f); const stats = fs.statSync(filePath); diff --git a/backend/utils/encryption.js b/backend/utils/encryption.js new file mode 100644 index 0000000..800c09b --- /dev/null +++ b/backend/utils/encryption.js @@ -0,0 +1,237 @@ +/** + * TASKMATE - Encryption Utilities + * ================================ + * Verschlüsselung für Backups und sensitive Daten + */ + +const crypto = require('crypto'); +const fs = require('fs'); +const path = require('path'); +const { promisify } = require('util'); +const logger = require('./logger'); + +const ALGORITHM = 'aes-256-cbc'; +const KEY_LENGTH = 32; // 256 bits +const IV_LENGTH = 16; // 128 bits +const SALT_LENGTH = 32; +const TAG_LENGTH = 16; + +/** + * Encryption Key aus Umgebung oder generiert + */ +function getEncryptionKey() { + let key = process.env.ENCRYPTION_KEY; + + if (!key) { + // Generiere neuen Key falls nicht vorhanden + key = crypto.randomBytes(KEY_LENGTH).toString('hex'); + logger.warn('Encryption Key wurde automatisch generiert. Speichere ihn in der .env: ENCRYPTION_KEY=' + key); + return Buffer.from(key, 'hex'); + } + + // Validiere Key-Length + if (key.length !== KEY_LENGTH * 2) { // Hex-String ist doppelt so lang + throw new Error(`Encryption Key muss ${KEY_LENGTH * 2} Hex-Zeichen haben`); + } + + return Buffer.from(key, 'hex'); +} + +/** + * Key aus Passwort ableiten (PBKDF2) + */ +function deriveKeyFromPassword(password, salt) { + return crypto.pbkdf2Sync(password, salt, 100000, KEY_LENGTH, 'sha256'); +} + +/** + * Datei verschlüsseln + */ +function encryptFile(inputPath, outputPath, password = null) { + try { + const data = fs.readFileSync(inputPath); + + // Salt und IV generieren + const salt = crypto.randomBytes(SALT_LENGTH); + const iv = crypto.randomBytes(IV_LENGTH); + + // Key ableiten + const key = password + ? deriveKeyFromPassword(password, salt) + : getEncryptionKey(); + + // Verschlüsselung + const cipher = crypto.createCipheriv(ALGORITHM, key, iv); + + const encrypted = Buffer.concat([cipher.update(data), cipher.final()]); + + // Header + Salt + IV + verschlüsselte Daten + const header = Buffer.from('TMENC001', 'ascii'); // TaskMate Encryption v1 + const result = Buffer.concat([ + header, + salt, + iv, + encrypted + ]); + + fs.writeFileSync(outputPath, result); + logger.info(`Datei verschlüsselt: ${path.basename(inputPath)} -> ${path.basename(outputPath)}`); + return true; + + } catch (error) { + logger.error(`Verschlüsselung fehlgeschlagen: ${error.message}`); + return false; + } +} + +/** + * Datei entschlüsseln + */ +function decryptFile(inputPath, outputPath, password = null) { + try { + const encryptedData = fs.readFileSync(inputPath); + + // Header prüfen + const header = encryptedData.subarray(0, 8); + if (header.toString('ascii') !== 'TMENC001') { + throw new Error('Ungültiges verschlüsseltes Datei-Format'); + } + + // Komponenten extrahieren + let offset = 8; + const salt = encryptedData.subarray(offset, offset + SALT_LENGTH); + offset += SALT_LENGTH; + + const iv = encryptedData.subarray(offset, offset + IV_LENGTH); + offset += IV_LENGTH; + + const encrypted = encryptedData.subarray(offset); + + // Key ableiten + const key = password + ? deriveKeyFromPassword(password, salt) + : getEncryptionKey(); + + // Entschlüsselung + const decipher = crypto.createDecipheriv(ALGORITHM, key, iv); + + const decrypted = Buffer.concat([ + decipher.update(encrypted), + decipher.final() + ]); + + fs.writeFileSync(outputPath, decrypted); + logger.info(`Datei entschlüsselt: ${path.basename(inputPath)} -> ${path.basename(outputPath)}`); + return true; + + } catch (error) { + logger.error(`Entschlüsselung fehlgeschlagen: ${error.message}`); + return false; + } +} + +/** + * String verschlüsseln (für Passwörter etc.) + */ +function encryptString(plaintext, password = null) { + try { + const salt = crypto.randomBytes(SALT_LENGTH); + const iv = crypto.randomBytes(IV_LENGTH); + + const key = password + ? deriveKeyFromPassword(password, salt) + : getEncryptionKey(); + + const cipher = crypto.createCipheriv(ALGORITHM, key, iv); + + const encrypted = Buffer.concat([ + cipher.update(Buffer.from(plaintext, 'utf8')), + cipher.final() + ]); + + // Base64 kodiert zurückgeben + const result = Buffer.concat([salt, iv, encrypted]); + return result.toString('base64'); + + } catch (error) { + logger.error(`String-Verschlüsselung fehlgeschlagen: ${error.message}`); + return null; + } +} + +/** + * String entschlüsseln + */ +function decryptString(encryptedString, password = null) { + try { + const data = Buffer.from(encryptedString, 'base64'); + + let offset = 0; + const salt = data.subarray(offset, offset + SALT_LENGTH); + offset += SALT_LENGTH; + + const iv = data.subarray(offset, offset + IV_LENGTH); + offset += IV_LENGTH; + + const encrypted = data.subarray(offset); + + const key = password + ? deriveKeyFromPassword(password, salt) + : getEncryptionKey(); + + const decipher = crypto.createDecipheriv(ALGORITHM, key, iv); + + const decrypted = Buffer.concat([ + decipher.update(encrypted), + decipher.final() + ]); + + return decrypted.toString('utf8'); + + } catch (error) { + logger.error(`String-Entschlüsselung fehlgeschlagen: ${error.message}`); + return null; + } +} + +/** + * Sicheres Löschen einer Datei (Überschreiben) + */ +function secureDelete(filePath) { + try { + if (!fs.existsSync(filePath)) { + return true; + } + + const stats = fs.statSync(filePath); + const fileSize = stats.size; + + // Datei mehrfach mit Zufallsdaten überschreiben + const fd = fs.openSync(filePath, 'r+'); + + for (let pass = 0; pass < 3; pass++) { + const randomData = crypto.randomBytes(fileSize); + fs.writeSync(fd, randomData, 0, fileSize, 0); + fs.fsyncSync(fd); + } + + fs.closeSync(fd); + fs.unlinkSync(filePath); + + logger.info(`Datei sicher gelöscht: ${path.basename(filePath)}`); + return true; + + } catch (error) { + logger.error(`Sicheres Löschen fehlgeschlagen: ${error.message}`); + return false; + } +} + +module.exports = { + encryptFile, + decryptFile, + encryptString, + decryptString, + secureDelete, + getEncryptionKey +}; \ No newline at end of file diff --git a/data/taskmate.db b/data/taskmate.db index 9a05d3eb029ace9770a7aa73872ecc670239cfc1..91dd894298a7ec691e04f5df7fcaebc67a5e92e1 100644 GIT binary patch delta 33259 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 delta 2594 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 H|5e}xJcQ7t_2{*g}!Huur zGmx;v?{>P9N+;Dx$BuMW|8H_~Z`G~3bJIm z5%MZJ{w0^{??yR9-}mth1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY** z5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I~@b1+Fx4ZlsoV zfhk?rE4|&fGTZ#ul0%x?x9{`eRu;IX8+xsGtxQ1e4HmfC;PbM3T)R4TSjW`uKV3~~ zS~I$(mwJ=kf~gDv1Q0*~0R#|0009ILK%j*L3Oc3v7OIz05kMfz0>kA$=4ZJ=YC|AN zU_|$mwz!W##syC6LB=bjJOq*i&XoJ@B<*rPjRHs2h><+|VF1q2ZIe*!)3LOH9@C6j?bV+4xs zaJs0+jX8`{5J;84sC)Z9(vwu>OtQ5FCftwtxmIdF#I6MfG^n9eNOb6+J9LM>*o@i& R=XF^xv|9Tib`hvU;4i9QLiGRu diff --git a/data/taskmate.db-wal b/data/taskmate.db-wal index 185ffd453eed0f94258a65a8e10b0d5b6c2ede1c..bf97269def12d226cd0e9f2c050ebeacc14213cd 100644 GIT binary patch literal 131872 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 literal 354352 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 + + @@ -102,98 +104,29 @@ - -
-

Erlaubte Dateiformate

+ +
+

Erlaubte Dateiendungen

- -
-
- -
-
- JPEG - PNG - GIF - WebP - SVG + +
+ +
+ + +
+ +
+ +
- -
-
- -
-
- PDF -
-
- - -
-
- -
-
- DOC - DOCX - XLS - XLSX - PPT - PPTX -
-
- - -
-
- -
-
- TXT - CSV - Markdown -
-
- - -
-
- -
-
- ZIP - RAR - 7Z -
-
- - -
-
- -
-
- JSON + +
+ Vorschläge: +
+
@@ -212,6 +145,13 @@
+ + +

TaskMate

@@ -235,7 +175,7 @@ - +
@@ -509,383 +449,29 @@
- -