Commits vergleichen

..

16 Commits

Autor SHA1 Nachricht Datum
99a6b7437b fix Coding/UI 2026-01-11 17:37:57 +00:00
671aaadc26 Datei Upload und Download fix 2026-01-10 20:54:24 +00:00
5b1f8b1cfe Logo für Webseiten-Tab implementiert 2026-01-10 16:47:02 +00:00
ef153789cc UI-Anpassungen 2026-01-10 10:32:52 +00:00
7d67557be4 Kontakt-Modul 2026-01-06 21:49:26 +00:00
623bbdf5dd Gitea-Repo fix 2026-01-04 21:21:11 +00:00
c21be47428 Datenbank bereinigt / Gitea-Integration gefixt 2026-01-04 00:24:11 +00:00
HG
395598c2b0 Implementierung Wissensmanagement 2025-12-30 22:49:56 +00:00
HG
9bf298c26b Statuskarten via Drag&Drop verschiebbar
Unteraufgaben lassen sich verschieben und bearbeiten
2025-12-30 19:55:39 +00:00
HG
15627cce99 Gitea-Fix 2025-12-30 19:17:07 +00:00
HG
c8707d6cf4 Gitea:
Push für Serveranwendung in Gitea implementiert
2025-12-30 17:25:14 +00:00
87c391d2e6 Server-Deployment: Sicherheit und Linux-Kompatibilität
- .env aus Repository entfernt (Sicherheit: Secrets nicht im Repo)
- .env.example ohne echte Secrets hinzugefügt
- .gitignore erstellt (ignoriert .env, data/, logs/, backups/, uploads/)
- docker-compose.yml für Linux angepasst:
  - Port 3001 (3000 belegt durch Gitea)
  - Windows-Mounts entfernt
  - Nur localhost-Binding (Zugriff über Nginx)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-29 21:14:24 +00:00
HG
ad7432c833 Admin-Panel Korrektur
Vorbereitung Serverimplementation
2025-12-29 20:30:43 +00:00
HG
50da44aabc Commit-Ausblendung (für UI) implementiert 2025-12-29 19:52:35 +00:00
HG
dad07c7879 Änderung Autor 2025-12-29 19:20:26 +00:00
627beb1353 Update Gitea-Sektion:
Branch-Auswahl
2025-12-29 19:02:44 +00:00
175 geänderte Dateien mit 102105 neuen und 2042 gelöschten Zeilen

Datei anzeigen

@ -23,7 +23,46 @@
"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:*)",
"Bash(docker-compose up:*)",
"Bash(chmod:*)",
"Bash(./update-css.sh)",
"Bash(./check-resize.sh)",
"Bash(grep -n \"orange\\|#f97316\\|#ea580c\" /home/claude-dev/TaskMate/frontend/css/reminders.css)",
"Bash(grep -A10 -B5 \"reminder-modal-title\\|modal-title\\|modal-header\" /home/claude-dev/TaskMate/frontend/css/reminders.css)",
"Bash(grep -A10 -B5 \"modal-header\\|modal-title\" /home/claude-dev/TaskMate/frontend/css/modal.css)",
"Bash(grep -A10 -B5 \"calendar-reminder\\|reminder.*calendar\" /home/claude-dev/TaskMate/frontend/css/reminders.css)",
"Bash(grep -A15 -B5 \"calendar.*task\\|task.*calendar\" /home/claude-dev/TaskMate/frontend/js/calendar.js)",
"WebFetch(domain:admin-panel-undso.aegis-sight.de)",
"Bash(mkdir:*)",
"Bash(for:*)",
"Bash(do )",
"Bash(done)"
]
}
}

Datei anzeigen

@ -1,16 +1,23 @@
# =============================================================================
# TASKMATE - Umgebungsvariablen
# TASKMATE - Umgebungsvariablen (Beispiel)
# =============================================================================
# Kopieren Sie diese Datei nach .env und passen Sie die Werte an:
# cp .env.example .env
#
# WICHTIG: Generieren Sie sichere Passwörter mit:
# openssl rand -base64 32
# =============================================================================
# -----------------------------------------------------------------------------
# SERVER
# -----------------------------------------------------------------------------
PORT=3000
PORT=3001
# -----------------------------------------------------------------------------
# SICHERHEIT
# -----------------------------------------------------------------------------
JWT_SECRET=c0c02ffaa6209fac125d90c3c69025abb0bc08b8d8d333b71ccf1c6e875a004e
# JWT Secret - MUSS geändert werden! Generieren mit: openssl rand -hex 32
JWT_SECRET=HIER_SICHEREN_WERT_GENERIEREN
# Session-Timeout in Minuten (automatischer Logout bei Inaktivität)
SESSION_TIMEOUT=30
@ -36,18 +43,19 @@ BACKUP_INTERVAL_HOURS=24
# -----------------------------------------------------------------------------
# BENUTZER (werden beim ersten Start angelegt)
# -----------------------------------------------------------------------------
USER1_USERNAME=HG
USER1_PASSWORD=Hzfne313!fdEF34
USER1_DISPLAYNAME=User 1
USER1_USERNAME=user1
USER1_PASSWORD=SICHERES_PASSWORT_HIER
USER1_DISPLAYNAME=Benutzer 1
USER1_COLOR=#00D4FF
USER2_USERNAME=MH
USER2_USERNAME=user2
USER2_PASSWORD=SICHERES_PASSWORT_HIER
USER2_DISPLAYNAME=User 2
USER2_DISPLAYNAME=Benutzer 2
USER2_COLOR=#FF9500
# -----------------------------------------------------------------------------
# GITEA-INTEGRATION
# GITEA-INTEGRATION (optional)
# -----------------------------------------------------------------------------
GITEA_URL=https://gitea-undso.aegis-sight.de
GITEA_TOKEN=8d76ec66b3e1f02e9b1e4848d8b15e0cdffe48df
GITEA_TOKEN=GITEA_ACCESS_TOKEN_HIER
GITEA_ORG=AegisSight

30
.gitignore vendored Normale Datei
Datei anzeigen

@ -0,0 +1,30 @@
# Umgebungsvariablen (enthält Secrets)
.env
# Datenbank und Daten
data/
*.sqlite
*.db
# Logs
logs/
*.log
# Backups
backups/
# Uploads
uploads/
# Node.js
node_modules/
# OS-spezifisch
.DS_Store
Thumbs.db
# IDE
.vscode/
.idea/
*.swp
*.swo

Datei anzeigen

@ -15,11 +15,14 @@ INHALTSVERZEICHNIS
3. Aufgaben verwalten
4. Projekte und Spalten
5. Kalenderansicht
6. Genehmigung (Vorschläge)
7. Archiv-Funktion
8. Einstellungen
9. Benutzerverwaltung (Admin)
10. Tipps und Tricks
6. Listenansicht
7. Genehmigung (Vorschläge)
8. Gitea-Integration
9. Benachrichtigungen (Inbox)
10. Archiv-Funktion
11. Einstellungen
12. Benutzerverwaltung (Admin)
13. Tipps und Tricks
================================================================================
1. ERSTE SCHRITTE
@ -49,7 +52,7 @@ ADMINISTRATOREN
Standard-Admin-Zugangsdaten:
- Benutzername: admin
- Passwort: !1Data123
- Passwort: [Vom Administrator gesetzt]
Nach der Anmeldung als regulärer Benutzer sehen Sie das Kanban-Board.
@ -58,9 +61,9 @@ DIE OBERFLAECHE
---------------
Die Oberflaeche besteht aus:
+-------------------------------------------------------------------------+
| Logo | Projekt-Auswahl | Board | Kalender | Genehmigung | Suche | User |
+-------------------------------------------------------------------------+
+--------------------------------------------------------------------------------------+
| Logo | Projekt-Auswahl | Board | Liste | Kalender | Genehmigung | Gitea | Suche | User |
+--------------------------------------------------------------------------------------+
| Filterleiste: Benutzer | Prioritaet | Labels | Faelligkeit |
+------------------------------------------------------------------+
| Statistik: Offen | In Arbeit | Erledigt | Ueberfaellig |
@ -296,7 +299,45 @@ FILTER
================================================================================
6. GENEHMIGUNG (VORSCHLÄGE)
6. LISTENANSICHT
================================================================================
Die Listenansicht bietet eine tabellarische Übersicht aller Aufgaben.
ANSICHT WECHSELN
----------------
Klicken Sie auf "Liste" in der Navigationsleiste.
ANSICHTSMODI
------------
- Gruppierte Ansicht: Aufgaben nach Status gruppiert (einklappbar)
- Flache Liste: Alle Aufgaben in einer Tabelle
SPALTEN
-------
- Aufgabe: Titel mit Farbpunkt der Spalte
- Status: Aktueller Status (per Dropdown änderbar)
- Priorität: Hoch/Mittel/Niedrig mit Sternen (per Dropdown änderbar)
- Fälligkeitsdatum: Datum (per Datepicker änderbar)
- Zugewiesen: Benutzer-Avatar (per Dropdown änderbar)
INLINE-BEARBEITUNG
------------------
Alle Felder können direkt in der Liste bearbeitet werden:
- Status: Dropdown öffnen und ändern
- Priorität: Dropdown öffnen und ändern
- Datum: Datepicker öffnen und ändern
- Zuweisung: Dropdown öffnen und ändern
- Titel: Doppelklick zum Bearbeiten
SORTIERUNG
----------
Klicken Sie auf eine Spaltenüberschrift, um nach dieser Spalte zu sortieren.
Erneutes Klicken kehrt die Sortierrichtung um.
================================================================================
7. GENEHMIGUNG (VORSCHLÄGE)
================================================================================
Der Genehmigung-Bereich ermöglicht es Teammitgliedern, Vorschläge einzureichen,
@ -343,7 +384,128 @@ VORSCHLAG LÖSCHEN
================================================================================
7. ARCHIV-FUNKTION
8. GITEA-INTEGRATION
================================================================================
Die Gitea-Integration ermöglicht die Verknüpfung von TaskMate-Projekten mit
Git-Repositories auf einem Gitea-Server.
VORAUSSETZUNGEN
---------------
- Gitea-Server muss konfiguriert sein (GITEA_URL, GITEA_TOKEN in .env)
- Docker-Container muss Zugriff auf lokale Laufwerke haben
GITEA-TAB ÖFFNEN
----------------
Klicken Sie auf "Gitea" in der Navigationsleiste.
REPOSITORY VERKNÜPFEN
---------------------
1. Verbindungsstatus prüfen (grün = verbunden)
2. Repository aus Dropdown wählen ODER "Neues Repository erstellen"
3. Lokalen Pfad eingeben (z.B. C:\Projekte\MeinProjekt)
4. Standard-Branch festlegen (z.B. "main")
5. "Speichern" klicken
NEUES REPOSITORY ERSTELLEN
--------------------------
1. "Neues Repository erstellen" klicken
2. Name eingeben (wird automatisch formatiert)
3. Optional: Beschreibung hinzufügen
4. Sichtbarkeit wählen (Privat/Öffentlich)
5. "Erstellen" klicken
GIT-STATUS
----------
Nach der Verknüpfung zeigt das Status-Panel:
- Aktueller Branch (per Dropdown wechselbar)
- Status: Clean (keine Änderungen) oder Dirty (Änderungen vorhanden)
- Anzahl der geänderten Dateien
GIT-OPERATIONEN
---------------
- Fetch: Änderungen vom Server abrufen (ohne lokale Dateien zu ändern)
- Pull: Änderungen vom Server herunterladen und einbinden
- Push: Lokale Änderungen auf den Server hochladen
- Commit: Änderungen mit Nachricht speichern
PUSH MIT BRANCH-AUSWAHL
-----------------------
Beim Push können Sie den Ziel-Branch wählen:
- "Gleicher Name wie lokal": Push auf gleichnamigen Remote-Branch
- "main", "master", "develop": Push auf spezifischen Branch
- Force-Push: Überschreibt Remote-Historie (bei Konflikten)
BRANCH UMBENENNEN
-----------------
1. Auf das Stift-Symbol neben dem Branch-Dropdown klicken
2. Neuen Branch-Namen eingeben
3. "Umbenennen" klicken
ÄNDERUNGEN ANZEIGEN
-------------------
Die Änderungsliste zeigt alle modifizierten Dateien:
- M = Modified (geändert)
- A = Added (neu hinzugefügt)
- D = Deleted (gelöscht)
- ? = Untracked (nicht versioniert)
COMMIT-HISTORIE
---------------
Die letzten Commits werden mit Hash, Nachricht, Autor und Datum angezeigt.
- X-Button: Commit aus der Anzeige ausblenden
- "Alle ausblenden": Alle Commits aus der Anzeige entfernen
KONFIGURATION ENTFERNEN
-----------------------
1. Auf das Papierkorb-Symbol im Repository-Header klicken
2. Bestätigen
Die Verknüpfung wird entfernt, das Repository bleibt erhalten.
================================================================================
9. BENACHRICHTIGUNGEN (INBOX)
================================================================================
Das Benachrichtigungssystem informiert Sie über wichtige Ereignisse in Echtzeit.
GLOCKEN-SYMBOL
--------------
- Position: Oben rechts in der Kopfzeile, neben dem Benutzer-Avatar
- Grau: Keine ungelesenen Nachrichten
- Farbig mit Badge: Anzahl ungelesener Nachrichten
INBOX ÖFFNEN
------------
Klicken Sie auf das Glocken-Symbol, um das Dropdown-Menü zu öffnen.
BENACHRICHTIGUNGSTYPEN
----------------------
- Aufgabe zugewiesen: Eine Aufgabe wurde Ihnen zugewiesen
- Zuweisung entfernt: Sie wurden von einer Aufgabe entfernt
- Bald fällig: Eine Ihrer Aufgaben ist morgen fällig
- Aufgabe erledigt: Eine Aufgabe wurde abgeschlossen
- Fälligkeit geändert: Das Fälligkeitsdatum wurde geändert
- Priorität erhöht: Priorität wurde auf "Hoch" gesetzt
- Neuer Kommentar: Kommentar zu Ihrer Aufgabe
- @Erwähnung: Sie wurden in einem Kommentar erwähnt
- Genehmigung ausstehend: Neue Genehmigung wartet (persistent)
- Genehmigung erteilt: Vorschlag wurde genehmigt
PERSISTENTE BENACHRICHTIGUNGEN
------------------------------
Genehmigungsanfragen sind "persistent" - sie können nicht gelöscht werden
und bleiben bis zur Genehmigung/Ablehnung bestehen.
Sie sind mit einem gelben Rand markiert.
AKTIONEN
--------
- Klick auf Nachricht: Navigiert zur Aufgabe oder Genehmigung
- "Alle als gelesen markieren": Markiert alle Nachrichten als gelesen
================================================================================
10. ARCHIV-FUNKTION
================================================================================
Das Archiv speichert Aufgaben, die Sie nicht mehr aktiv benoetigen,
@ -367,7 +529,7 @@ AUFGABE ARCHIVIEREN
================================================================================
8. EINSTELLUNGEN
11. EINSTELLUNGEN
================================================================================
Oeffnen Sie die Einstellungen ueber das Benutzer-Menu (oben rechts).
@ -392,7 +554,7 @@ PASSWORT AENDERN
================================================================================
9. BENUTZERVERWALTUNG (ADMIN)
12. BENUTZERVERWALTUNG (ADMIN)
================================================================================
Die Benutzerverwaltung ist nur für Administratoren zugänglich und ermöglicht
@ -458,7 +620,7 @@ HINWEIS: Der eigene Account kann nicht gelöscht werden.
================================================================================
10. TIPPS UND TRICKS
13. TIPPS UND TRICKS
================================================================================
SUCHE

Datei-Diff unterdrückt, da er zu groß ist Diff laden

814
CLAUDE.md
Datei anzeigen

@ -1,55 +1,777 @@
# TaskMate - Projektanweisungen
# TaskMate - Entwicklerdokumentation
## Allgemein
- Sprache: Deutsch fuer Benutzer-Kommunikation
- Aenderungen immer in CHANGELOG.txt dokumentieren nach bisher bekannten Schema in der Datei
- Beim Start ANWENDUNGSBESCHREIBUNG.txt lesen
- Cache-Version in frontend/sw.js erhoehen nach Aenderungen
- Ich bin kein Mensch mit Fachwissen im Bereich Coding, daher musst du sämtliche Aufgaben in der Regel übernehmen.
## ⚠️ 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
## Technologie
- Frontend: Vanilla JavaScript (kein Framework)
- Backend: Node.js mit Express
- Datenbank: SQLite
### Kommunikations-Regeln
**RICHTIG**: "Ich werde jetzt die Benutzeroberfläche anpassen, damit..."
**FALSCH**: "Kannst du mir den Code aus Zeile 42 zeigen?"
## Konventionen
- CSS-Variablen in frontend/css/variables.css
- Deutsche Umlaute (ä, ö, ü) in Texten verwenden
**RICHTIG**: "Ich starte jetzt den Server neu. Das dauert etwa 30 Sekunden."
**FALSCH**: "Führe bitte folgenden Befehl aus: docker restart..."
## 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}`;
## 🚀 Quick Start
// Falsch: UTC-Konvertierung
const dateStr = date.toISOString().split('T')[0]; // NICHT VERWENDEN!
```
### Wichtigste Befehle
```bash
# Docker Container neu starten (nach Backend-Änderungen)
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?
# Container neu bauen (bei Dependency-Änderungen)
docker build -t taskmate . && docker restart taskmate
## Berechtigungen/Aktionen
- Du sollst den Dockercontainer eigenständig - sofern erforderlich - neu starten/neu bauen, dass Änderungen wirksam werden
- Nach Änderungen immer nach dem Neubau des Dockercontainers oder allgemein ohne Neustart des Dockercontainers, den Browser im Inkognito-Modus starten mit localhost:3000, um die Änderungen sichtbar zu machen.
# Logs anzeigen
docker logs taskmate -f
# 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
**⚠️ NUTZERDATEN-SCHUTZ - ABSOLUTES VERBOT**:
- **KEINE Änderungen an Nutzerdaten oder Kennwörtern**
- **Geschützte Benutzer (NICHT modifizieren)**:
- admin
- Hendrik (hendrik_gebhardt@gmx.de)
- Monami (momohomma@googlemail.com)
- **Diese Benutzer sind produktiv im Einsatz**
- **Keine Passwort-Resets oder Änderungen an diesen Accounts**
- **Bei Anmeldeproblemen: Nur Debugging, keine Datenänderung**
### 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
<script type="module" src="js/myview.js"></script>
// 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
```
### KRITISCHES PROBLEM: Frontend-Änderungen werden nicht sichtbar
**Problem**: Frontend-Dateien (HTML, CSS, JS) werden beim Docker Build nach `/app/public/` kopiert und sind NICHT live gemountet. Änderungen in `/home/claude-dev/TaskMate/frontend/` werden daher nicht automatisch übernommen.
**Symptome**:
- CSS/JS-Änderungen funktionieren nicht trotz Browser-Cache-Löschung
- Service Worker Cache-Version Erhöhung hilft nicht
- Änderungen werden sporadisch nach längerer Zeit sichtbar
**Ursache**:
1. **Dockerfile kopiert Frontend-Dateien**: `COPY frontend/ ./public/`
2. **Express.js cached statische Dateien** mit ETags und Last-Modified Headers
3. **Mehrschichtiges Caching**: Service Worker + Browser + Express.js
**LÖSUNG A - Sofortige Änderungen (Development)**:
```bash
# Einzelne Datei kopieren
docker cp frontend/css/style.css taskmate:/app/public/css/style.css
# Mehrere Dateien kopieren
docker cp frontend/js/app.js taskmate:/app/public/js/app.js
docker cp frontend/index.html taskmate:/app/public/index.html
# CSS + JS zusammen kopieren
docker cp frontend/css/ taskmate:/app/public/css/
docker cp frontend/js/ taskmate:/app/public/js/
```
**LÖSUNG B - Vollständige Aktualisierung (Production)**:
```bash
# Docker Image neu bauen und Container ersetzen
docker build -t taskmate . && docker restart taskmate
```
**Express.js Caching deaktiviert**:
```javascript
// In server.js - statische Dateien ohne Caching
app.use(express.static(path.join(__dirname, 'public'), {
etag: false,
lastModified: false,
cacheControl: false,
setHeaders: (res, path) => {
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate');
}
}));
```
**Debugging-Workflow**:
1. Prüfe Datei-Timestamps im Container: `docker exec taskmate ls -la /app/public/css/`
2. Vergleiche mit lokalen Dateien: `ls -la frontend/css/`
3. Bei Diskrepanz: Files mit `docker cp` aktualisieren
4. Bei JavaScript-Problemen: Browser-Console auf Fehler prüfen
**Warum passiert das**:
- Container-Pfad `/app/public/` = Static Files (nicht live)
- Container-Pfad `/app/taskmate-source/` = Git-Operationen (live gemountet)
- Frontend wird NUR beim Build-Time kopiert, nicht zur Laufzeit
## 🚨 KRITISCHE LEKTIONEN AUS PROBLEMEN
### ⚠️ Kontakte-Modul Implementation Probleme (07.01.2025)
**FEHLER 1: Backend API-Route nicht gefunden (404)**
- **Problem**: GET /api/contacts gibt 404 - Endpoint nicht gefunden
- **Ursache**: Backend-Dateien nicht im Docker-Container, Container nicht neu gestartet
- **Lösung**: Backend-Dateien kopieren und Container neu starten
- **Prävention**:
```bash
# Backend-Änderungen: Alle Dateien kopieren
docker cp backend/routes/contacts.js taskmate:/app/routes/
docker cp backend/middleware/validation.js taskmate:/app/middleware/
docker cp backend/server.js taskmate:/app/server.js
docker restart taskmate # IMMER nach Backend-Änderungen
```
**FEHLER 2: Database Table existiert nicht (500 Internal Server Error)**
- **Problem**: "no such table: contacts" - Tabelle wurde nicht erstellt
- **Ursache**: database.js Änderungen nicht übernommen, bestehende DB erweitert sich nicht automatisch
- **Lösung**: database.js kopieren + Tabelle manuell erstellen
- **Pattern für neue Tabellen**:
```bash
docker cp backend/database.js taskmate:/app/database.js
docker exec taskmate node -e "
const Database = require('better-sqlite3');
const db = new Database('/app/data/taskmate.db');
db.exec('CREATE TABLE IF NOT EXISTS new_table (...);');
console.log('Table created successfully');
"
```
**FEHLER 3: store.showMessage ist undefined**
- **Problem**: `store.showMessage()` Funktion existiert nicht
- **Ursache**: Falsche API für Toast-Nachrichten
- **Lösung**: Verwende `window.dispatchEvent` mit `toast:show`
- **Pattern für Toast-Messages**:
```javascript
// FALSCH: store.showMessage('Text', 'success')
// RICHTIG:
window.dispatchEvent(new CustomEvent('toast:show', {
detail: { message: 'Text', type: 'success' }
}));
```
**FEHLER 4: Event-Handler nicht gebunden**
- **Problem**: Button-Clicks funktionieren nicht trotz Event-Listener
- **Ursache**: Timing-Problem - DOM noch nicht bereit, Modal-Overlay fehlt
- **Lösung**: Korrekte Modal-Struktur + Overlay-Management
- **Debugging-Pattern**:
```javascript
console.log('[Module] Element check:', this.buttonElement);
if (this.buttonElement) {
console.log('[Module] Binding event');
this.buttonElement.addEventListener('click', () => {
console.log('[Module] Button clicked!');
});
}
```
**FEHLER 5: Modal Design inkonsistent**
- **Problem**: Custom Modal-Styles passen nicht zum App-Design
- **Ursache**: Eigene CSS-Klassen statt Standard-Modal-Pattern
- **Lösung**: Standard Modal-Struktur verwenden
- **Standard Modal-Pattern**:
```html
<div class="modal modal-medium hidden">
<div class="modal-header">
<h2>Title</h2>
<button class="modal-close" data-close-modal>&times;</button>
</div>
<div class="modal-body"><!-- Content --></div>
<div class="modal-footer"><!-- Buttons --></div>
</div>
```
### ⚠️ SVG Icon-Rendering Probleme (07.01.2025)
**FEHLER: SVG Icons werden nicht angezeigt**
- **Problem**: SVG-Icons verschwinden oder zeigen nicht korrekt an
- **Ursache**: `createElement()` unterstützt kein SVG-Namespace
- **Lösung**: SVG mit `innerHTML` oder als String-Template einfügen
- **Pattern**:
```javascript
// FALSCH - SVG wird nicht gerendert
const icon = createElement('svg', { viewBox: '0 0 24 24' });
// RICHTIG - SVG als HTML-String
element.innerHTML = `<svg viewBox="0 0 24 24">...</svg>`;
```
- **Prävention**: Bei dynamischen SVGs immer innerHTML oder DOMParser nutzen
### ⚠️ API Field Name Mismatches (07.01.2025)
**FEHLER: Frontend/Backend Feldnamen-Diskrepanz**
- **Problem**: Daten werden mit 0 Bytes oder leer angezeigt
- **Ursache**: Backend sendet camelCase, Frontend erwartet snake_case
- **Beispiel**: `originalName` vs `original_name`, `sizeBytes` vs `size_bytes`
- **Lösung**: Fallback-Pattern für beide Schreibweisen
- **Pattern**:
```javascript
// Robuste Feldabfrage mit Fallback
const fileName = data.originalName || data.original_name || '';
const fileSize = data.sizeBytes || data.size_bytes || 0;
```
- **Prävention**: API-Dokumentation prüfen, einheitliche Naming-Convention
### ⚠️ File Upload Field Names (07.01.2025)
**FEHLER: Multer "Unexpected field" Error**
- **Problem**: 500 Error bei File-Upload
- **Ursache**: Frontend sendet 'files' (plural), Backend erwartet 'file' (singular)
- **Lösung**: Backend-Konsistenz herstellen
- **Pattern**:
```javascript
// Backend - Konsistent 'files' verwenden
upload.single('files') // NICHT 'file'
// Frontend - FormData immer mit 'files'
formData.append('files', file);
```
- **Prävention**: Einheitliche Field-Names über alle Upload-Endpoints
### ⚠️ Erinnerung-Implementation Probleme (06.01.2026)
**FEHLER 1: Syntax-Fehler in JavaScript blockierte Login**
- **Problem**: Missing closing brace in calendar.js verhinderte Login komplett
- **Ursache**: Unvollständige Code-Blöcke beim Multi-Edit
- **Lösung**: IMMER Syntax-Check nach JavaScript-Änderungen
- **Prävention**:
```bash
# Nach JS-Änderungen prüfen:
node -c frontend/js/calendar.js
docker logs taskmate --tail 20 # Auf Syntax-Fehler prüfen
```
**FEHLER 2: "Verschwundene" Projekte durch 401-Fehler**
- **Problem**: User dachte AegisSight-Projekt sei gelöscht
- **Ursache**: Authentifizierungs-Token abgelaufen, API gibt 401 zurück
- **Diagnose**: `docker logs taskmate` zeigt 401-Fehler
- **Lösung**: Einfach neu anmelden, Daten sind intakt
- **Prävention**: Bei "verschwundenen" Daten IMMER zuerst Auth prüfen
**FEHLER 3: Checkbox-Styling funktioniert nicht**
- **Problem**: CSS-Selektoren greifen nicht, komplexe Pseudo-Element-Struktur
- **Ursache**: Browser-CSS-Konflikte, CSS-Variable-Probleme
- **Lösung**: Direktes Styling nativer Checkboxes mit `appearance: none`
- **Lesson**: Bei CSS-Problemen: **Einfachster Ansatz zuerst**
```css
/* FALSCH: Komplexe Pseudo-Struktur */
input:checked + span::after { content: '✓'; }
/* RICHTIG: Direktes Styling */
input[type="checkbox"] {
appearance: none;
background: #3B82F6 when :checked;
}
```
**FEHLER 4: Event-Handler Konflikte bei Modal-Updates**
- **Problem**: Dropdown-Handler werden überschrieben
- **Ursache**: Mehrfache Event-Binding ohne Cleanup
- **Lösung**: Element-Kloning für saubere Event-Handler
- **Pattern**:
```javascript
// Event-Handler cleanup durch Klonen
const newElement = element.cloneNode(true);
element.parentNode.replaceChild(newElement, element);
// Dann neue Handler binden
```
**FEHLER 5: Visuelle Darstellung unterbricht Funktionalität**
- **Problem**: Erinnerungen unterbrachen Aufgaben-Balken
- **Ursache**: Falsche Render-Reihenfolge (Erinnerungen vor Aufgaben)
- **Lösung**: Aufgaben zuerst, dann Erinnerungen
- **Lesson**: UI-Reihenfolge muss Funktionalität folgen, nicht umgekehrt
### ⚠️ Dropdown-Transparenz-Probleme (09.01.2026)
**FEHLER: Dropdown-Menüs haben unerwünschte Transparenz**
- **Problem**: Dropdown-Menüs zeigen durchscheinenden Hintergrund, besonders bei dunklen Themes
- **Symptome**:
- Text schwer lesbar durch transparenten Hintergrund
- Inhalte dahinter scheinen durch
- Besonders auffällig bei Kontakte-Modul und anderen Dropdown-Menüs
- **Ursache**: CSS-Variablen für Hintergründe verwenden `rgba()` mit Transparenz
- **Lösung**: Explizite, nicht-transparente Hintergrundfarben für Dropdowns setzen
- **Pattern**:
```css
/* FALSCH - Transparente Hintergründe */
.dropdown {
background: var(--bg-secondary); /* rgba mit 0.95 opacity */
}
/* RICHTIG - Solide Hintergründe für Dropdowns */
.dropdown {
background: #ffffff; /* Hell-Theme */
background: #1a1a1a; /* Dunkel-Theme */
}
```
- **Prävention**:
- Dropdown-Komponenten immer mit soliden Hintergründen
- Keine CSS-Variablen mit Transparenz für interaktive Elemente
- Bei Theme-Support: Explizite Farben ohne Alpha-Kanal
### 🔧 TROUBLESHOOTING-WORKFLOW
**Bei JavaScript-Fehlern:**
1. `docker logs taskmate --tail 50` prüfen
2. Browser-Console auf Syntax-Fehler prüfen
3. Node.js Syntax-Check: `node -c datei.js`
**Bei "verschwundenen" Daten:**
1. **NIEMALS** sofort Backup/Restore - erst debuggen!
2. API-Logs prüfen auf 401/403 Fehler
3. Auth-Status prüfen: `localStorage.getItem('token')`
4. Datenbank direkt prüfen: `sqlite3 data/taskmate.db "SELECT COUNT(*) FROM projects"`
**Bei CSS-Problemen:**
1. Simplest approach first - keine komplexen Selektoren
2. `!important` nur als letzter Ausweg
3. Browser-DevTools: Computed Styles prüfen
4. Cache leeren: `CACHE_VERSION++` in sw.js
**Bei neuen Modulen mit globaler Suche:**
1. Module in app.js setupSearch() registrieren:
```javascript
} else if (currentView === 'mymodule') {
import('./mymodule.js').then(module => {
if (module.myManager) {
module.myManager.searchQuery = value;
module.myManager.filterData();
}
});
```
2. Manager-Instanz exportieren: `export { myManager }`
3. clearSearch() Funktion ebenfalls erweitern
4. Lokale Suchfelder entfernen - nur Header-Suche nutzen
**Bei File-Upload Problemen:**
1. Prüfe ob Entry/Task bereits gespeichert ist (ID vorhanden)
2. Bei neuen Einträgen: Erst speichern, dann Upload
3. Field-Name Konsistenz prüfen: 'files' (plural) überall
4. `docker logs taskmate` für Multer-Errors checken
## 🐛 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.

Datei anzeigen

@ -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++

179
GITEA_INTEGRATION_STATUS.md Normale Datei
Datei anzeigen

@ -0,0 +1,179 @@
# Gitea-Integration Status - TaskMate
**Datum:** 29.12.2024
**Status:** Vollständig implementiert - Bugfix angewendet
---
## Übersicht
Die Gitea-Integration für TaskMate ist vollständig implementiert. Der kritische Branch-Mismatch Bug wurde behoben - nach einem Push wird der Default-Branch in Gitea automatisch auf den gepushten Branch aktualisiert.
---
## Was wurde bereits implementiert
### Backend (vollständig)
1. **`backend/server.js`** - Routes registriert:
```javascript
const gitRoutes = require('./routes/git');
const applicationsRoutes = require('./routes/applications');
const giteaRoutes = require('./routes/gitea');
app.use('/api/git', authenticateToken, csrfProtection, gitRoutes);
app.use('/api/applications', authenticateToken, csrfProtection, applicationsRoutes);
app.use('/api/gitea', authenticateToken, csrfProtection, giteaRoutes);
```
2. **`backend/routes/gitea.js`** - NEU erstellt mit Endpoints:
- `GET /test` - Verbindung testen
- `GET /repositories` - Alle Repos auflisten
- `POST /repositories` - Neues Repo erstellen
- `GET /repositories/:owner/:repo` - Repo-Details
- `GET /repositories/:owner/:repo/branches` - Branches
- `GET /repositories/:owner/:repo/commits` - Commits
3. **`backend/routes/git.js`** - Erweitert mit:
- `POST /set-remote` - Remote setzen
- `POST /prepare-for-gitea` - Repository für Gitea vorbereiten
4. **`backend/services/gitService.js`** - Erweitert mit:
- `setRemote()` - Remote-URL setzen
- `prepareForGitea()` - Repository für Gitea vorbereiten (remote + initial commit)
- `pushWithUpstream()` - Push mit automatischer Branch-Erkennung
5. **`backend/services/giteaService.js`** - Vorhanden mit:
- `listRepositories()` - Organisation-Repositories auflisten
- `getRepository()` - Einzelnes Repo abrufen
- `createRepository()` - Neues Repo erstellen
- `updateRepository()` - Repo-Einstellungen aktualisieren (inkl. default_branch)
- `deleteRepository()` - Repo löschen
- `testConnection()` - Verbindung testen
- `getAuthenticatedCloneUrl()` - Clone-URL mit Token
6. **`Dockerfile`** - Git-Unterstützung hinzugefügt:
```dockerfile
RUN apk add --no-cache git
RUN git config --system --add safe.directory '*'
RUN git config --system user.email "taskmate@local" && \
git config --system user.name "TaskMate" && \
git config --system init.defaultBranch main
```
### Frontend (vollständig)
1. **`frontend/js/api.js`** - Erweitert mit allen Gitea/Git API-Methoden
2. **`frontend/js/gitea.js`** - NEU erstellt:
- Kompletter GiteaManager mit allen Funktionen
- Konfigurationsansicht
- Status-Anzeige
- Git-Operationen (Fetch, Pull, Push, Commit)
- Commit-Historie
- Branch-Wechsel
3. **`frontend/css/gitea.css`** - NEU erstellt:
- Alle Styles für Gitea-Tab
4. **`frontend/index.html`** - Erweitert:
- Gitea-Tab in Navigation
- Gitea-View Container
- Commit-Modal
- Create-Repo-Modal
5. **`frontend/js/app.js`** - Integriert:
- GiteaManager Import
- View-Switching für Gitea-Tab
6. **`frontend/sw.js`** - Cache-Version erhöht
---
## Der gelöste Bug: Branch-Mismatch
### Problem (GELÖST)
- 146 Dateien wurden erfolgreich nach Gitea gepusht
- Die Dateien landeten im Branch `master`
- Gitea zeigte aber `main` als Standard-Branch an
- Benutzer sahen nur die auto-generierte README statt ihrer Dateien
### Ursache
1. Gitea erstellt Repositories mit `default_branch: 'main'` (Zeile 179 in giteaService.js)
2. Der lokale Git-Branch heißt aber `master`
3. Die Dateien wurden nach `master` gepusht
4. Gitea zeigte `main` an (leer bis auf README)
### Implementierte Lösung
1. **`updateRepository()` Funktion** zu `backend/services/giteaService.js` hinzugefügt
- Ändert den Default-Branch über Gitea PATCH API
- Unterstützt auch Beschreibung und Private-Status
2. **`pushWithUpstream()` Funktion** in `backend/services/gitService.js` erweitert
- Gibt jetzt den Branch-Namen im Ergebnis zurück
3. **`init-push` Endpoint** in `backend/routes/git.js` erweitert
- Nach erfolgreichem Push wird automatisch der Default-Branch in Gitea aktualisiert
- Owner/Repo wird aus der Repository-URL extrahiert
4. **Cache-Version** auf 119 erhöht in `frontend/sw.js`
---
## Gelöste Probleme (zur Referenz)
1. **"git: not found"** → Git in Dockerfile installiert
2. **"dubious ownership in repository"** → `safe.directory '*'` konfiguriert
3. **"No configured push destination"** → `setRemote()` und `prepareForGitea()` hinzugefügt
4. **"src refspec main does not match any"** → Branch-Erkennung in `pushWithUpstream()` implementiert
---
## Wichtige Dateien
| Datei | Status |
|-------|--------|
| `backend/server.js` | ✅ Fertig |
| `backend/routes/gitea.js` | ✅ Fertig |
| `backend/routes/git.js` | ✅ Fertig (inkl. auto Default-Branch Update) |
| `backend/services/gitService.js` | ✅ Fertig (inkl. Branch-Name Return) |
| `backend/services/giteaService.js` | ✅ Fertig (inkl. updateRepository) |
| `Dockerfile` | ✅ Fertig |
| `frontend/js/api.js` | ✅ Fertig |
| `frontend/js/gitea.js` | ✅ Fertig |
| `frontend/css/gitea.css` | ✅ Fertig |
| `frontend/index.html` | ✅ Fertig |
| `frontend/js/app.js` | ✅ Fertig |
| `frontend/sw.js` | ✅ Fertig (v119) |
---
## Nächste Schritte
1. ✅ `updateRepository()` zu `backend/services/giteaService.js` hinzugefügt
2. ✅ Funktion exportiert
3. ✅ Push-Flow erweitert, um Default-Branch nach Push zu aktualisieren
4. ✅ Docker-Container neu gebaut
5. ⏳ Testen: Projekt mit Gitea verknüpfen → Push → Dateien in Gitea sichtbar
---
## Plan-Datei
Der vollständige Implementierungsplan befindet sich in:
`C:\Users\hendr\.claude\plans\rosy-stargazing-scroll.md`
---
## Befehle zum Testen
```bash
# Container neu bauen
docker compose down
docker compose build --no-cache
docker compose up -d
# Browser öffnen
start chrome --incognito http://localhost:3000
```

325
PWA_TO_APK_ANLEITUNG.md Normale Datei
Datei anzeigen

@ -0,0 +1,325 @@
# TaskMate PWA zu APK - Vollständige Anleitung
## Überblick
Diese Anleitung zeigt, wie Sie aus der TaskMate PWA eine Android APK erstellen, die im Google Play Store veröffentlicht werden kann.
## Voraussetzungen
- TaskMate PWA ist online verfügbar (https://taskmate.aegis-sight.de)
- Web App Manifest und Service Worker sind implementiert
- PWA Icons in allen erforderlichen Größen vorhanden
## Methode 1: PWABuilder (Einfachste Methode)
### Schritt 1: PWA Score prüfen
1. Öffnen Sie https://www.pwabuilder.com
2. Geben Sie Ihre PWA URL ein: `https://taskmate.aegis-sight.de`
3. Klicken Sie auf "Start"
4. PWABuilder analysiert Ihre App und zeigt den Score
### Schritt 2: Package für Android generieren
1. Klicken Sie auf "Package for stores"
2. Wählen Sie "Android"
3. Konfigurieren Sie die Einstellungen:
- **Package ID**: `de.aegissight.taskmate`
- **App Name**: TaskMate
- **Display Mode**: Standalone
- **Orientation**: Any
- **Fallback URL**: `https://taskmate.aegis-sight.de`
### Schritt 3: Erweiterte Optionen
- **Signing Key**:
- "None" für Test-APK
- "New" für Play Store (speichern Sie den Key sicher!)
- **Maskable Icon**: Upload des 512x512 Icons
- **Splash Screen**: Automatisch generiert
- **Settings**:
- Enable notifications: Yes
- Location permissions: No
- WebView settings: Standard
### Schritt 4: Download und Test
1. Klicken Sie auf "Download"
2. Extrahieren Sie die ZIP-Datei
3. Die APK befindet sich im Ordner
4. Installieren Sie zum Testen auf einem Android-Gerät
## Methode 2: Bubblewrap (Für Entwickler)
### Installation
```bash
# Node.js und npm müssen installiert sein
npm install -g @bubblewrap/cli
```
### Projekt initialisieren
```bash
# Neues Verzeichnis erstellen
mkdir taskmate-android
cd taskmate-android
# Bubblewrap init
bubblewrap init --manifest https://taskmate.aegis-sight.de/manifest.json
```
### Konfiguration (twa-manifest.json)
```json
{
"packageId": "de.aegissight.taskmate",
"host": "taskmate.aegis-sight.de",
"name": "TaskMate",
"launcherName": "TaskMate",
"display": "standalone",
"themeColor": "#000000",
"navigationColor": "#000000",
"backgroundColor": "#000000",
"enableNotifications": true,
"startUrl": "/",
"iconUrl": "https://taskmate.aegis-sight.de/assets/icons/icon-512x512.png",
"maskableIconUrl": "https://taskmate.aegis-sight.de/assets/icons/icon-512x512.png",
"appVersion": "1.0.0",
"signingKey": {
"alias": "taskmate-key",
"path": "./taskmate-key.keystore"
},
"splashScreenFadeOutDuration": 300,
"enableSiteSettingsShortcut": false,
"shortcuts": [
{
"name": "Neue Aufgabe",
"shortName": "Neue Aufgabe",
"url": "/?action=new-task",
"chosenIconUrl": "https://taskmate.aegis-sight.de/assets/icons/add-task-96x96.png"
}
],
"fallbackType": "customtabs",
"webManifestUrl": "https://taskmate.aegis-sight.de/manifest.json"
}
```
### Build-Prozess
```bash
# Keystore erstellen (nur einmal)
bubblewrap build --skipPwaValidation
# APK erstellen
bubblewrap build
# Signed APK für Play Store
bubblewrap build --skipPwaValidation
```
## Methode 3: Android Studio mit TWA
### Projekt Setup
1. Erstellen Sie ein neues Android-Projekt
2. Minimum SDK: API 19 (Android 4.4)
3. Wählen Sie "No Activity"
### Dependencies (app/build.gradle)
```gradle
dependencies {
implementation 'com.google.androidbrowserhelper:androidbrowserhelper:2.5.0'
}
```
### AndroidManifest.xml
```xml
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="de.aegissight.taskmate">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:theme="@style/Theme.AppCompat.Light.NoActionBar">
<activity
android:name="com.google.androidbrowserhelper.trusted.LauncherActivity"
android:exported="true"
android:label="@string/app_name">
<meta-data
android:name="android.support.customtabs.trusted.DEFAULT_URL"
android:value="https://taskmate.aegis-sight.de" />
<meta-data
android:name="android.support.customtabs.trusted.STATUS_BAR_COLOR"
android:resource="@color/colorPrimary" />
<meta-data
android:name="android.support.customtabs.trusted.NAVIGATION_BAR_COLOR"
android:resource="@color/colorPrimary" />
<meta-data
android:name="android.support.customtabs.trusted.SPLASH_IMAGE_DRAWABLE"
android:resource="@drawable/splash" />
<meta-data
android:name="android.support.customtabs.trusted.SPLASH_SCREEN_FADE_OUT_DURATION"
android:value="300" />
<meta-data
android:name="android.support.customtabs.trusted.DISPLAY_MODE"
android:value="standalone" />
<meta-data
android:name="android.support.customtabs.trusted.SCREEN_ORIENTATION"
android:value="unspecified" />
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data
android:scheme="https"
android:host="taskmate.aegis-sight.de"/>
</intent-filter>
</activity>
<service
android:name="com.google.androidbrowserhelper.trusted.DelegationService"
android:enabled="true"
android:exported="true">
<intent-filter>
<action android:name="android.support.customtabs.trusted.TRUSTED_WEB_ACTIVITY_SERVICE"/>
<category android:name="android.intent.category.DEFAULT"/>
</intent-filter>
</service>
</application>
</manifest>
```
### Digital Asset Links
Erstellen Sie auf Ihrem Server die Datei:
`https://taskmate.aegis-sight.de/.well-known/assetlinks.json`
```json
[{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "de.aegissight.taskmate",
"sha256_cert_fingerprints": [
"YOUR_APP_SIGNING_KEY_FINGERPRINT"
]
}
}]
```
## Icon-Generierung
Falls Icons fehlen, nutzen Sie diese Tools:
1. **Android Asset Studio**: https://romannurik.github.io/AndroidAssetStudio/
2. **Maskable.app**: https://maskable.app/editor
Benötigte Größen:
- 48x48, 72x72, 96x96, 144x144, 192x192, 512x512
- Adaptive Icons für Android 8+
## Testing
### Lokaler Test
1. Aktivieren Sie "Unbekannte Quellen" in Android-Einstellungen
2. Übertragen Sie die APK aufs Gerät
3. Installieren und testen
### Play Console Test Track
1. Laden Sie die APK in die Google Play Console
2. Erstellen Sie eine interne Testversion
3. Fügen Sie Tester hinzu
4. Testen Sie Installation und Updates
## Veröffentlichung im Play Store
### Vorbereitung
1. Google Play Developer Account ($25 einmalig)
2. App-Beschreibung und Screenshots
3. Datenschutzerklärung
4. Altersfreigabe-Fragebogen
### Store Listing
- **Titel**: TaskMate - Aufgabenverwaltung
- **Kurzbeschreibung**: Einfache und effiziente Aufgabenverwaltung mit Kanban-Board
- **Kategorie**: Produktivität
- **Screenshots**: Mindestens 2, idealerweise 8
### APK Upload
1. Signierte APK erstellen
2. In Play Console hochladen
3. Rollout starten (gestaffelt empfohlen)
## Wartung und Updates
### Version Updates
1. Erhöhen Sie `versionCode` und `versionName`
2. Erstellen Sie neue APK
3. Laden Sie in Play Console hoch
4. Beschreiben Sie Änderungen
### PWA Updates
- Service Worker Updates werden automatisch geladen
- Manifest-Änderungen erfordern App-Update
- Cache-Versionierung beachten
## Häufige Probleme
### "Not a valid PWA"
- Prüfen Sie HTTPS
- Validieren Sie manifest.json
- Service Worker muss registriert sein
### "Digital Asset Links validation failed"
- assetlinks.json muss öffentlich erreichbar sein
- SHA256 Fingerprint muss stimmen
- Content-Type: application/json
### Icon-Probleme
- Alle Größen müssen vorhanden sein
- PNG-Format erforderlich
- Transparenz vermeiden für adaptive Icons
## Empfehlungen
1. **Verwenden Sie PWABuilder** für den Anfang
2. **Bubblewrap** für mehr Kontrolle
3. **Android Studio** nur bei speziellen Anforderungen
4. Testen Sie auf verschiedenen Geräten
5. Behalten Sie die Keystore-Datei sicher auf!
## Nächste Schritte
1. Icons generieren (falls noch nicht vorhanden)
2. PWABuilder Test durchführen
3. Test-APK erstellen und installieren
4. Bei Erfolg: Play Store Konto einrichten
5. Produktions-APK mit Signing Key erstellen
6. Im Play Store veröffentlichen
Bei Fragen oder Problemen können Sie die offizielle Dokumentation konsultieren:
- [PWABuilder Docs](https://docs.pwabuilder.com/)
- [Bubblewrap GitHub](https://github.com/GoogleChromeLabs/bubblewrap)
- [TWA Documentation](https://developers.google.com/web/android/trusted-web-activity)

105
SSH_CLAUDE_ANLEITUNG.txt Normale Datei
Datei anzeigen

@ -0,0 +1,105 @@
# Anleitung: Claude über SSH aus TaskMate starten
## Voraussetzungen
- SSH-Client auf dem eigenen Computer
- Zugang zu TaskMate
## Schritt 1: SSH-Schlüsselpaar erstellen
### Windows (PowerShell/Terminal):
```
ssh-keygen -t rsa -b 4096 -f %USERPROFILE%\.ssh\taskmate_claude
```
### Mac/Linux:
```
ssh-keygen -t rsa -b 4096 -f ~/.ssh/taskmate_claude
```
**Wichtig:**
- Bei der Frage nach einem Passwort ein sicheres Passwort vergeben und merken
- Es werden zwei Dateien erstellt:
- `taskmate_claude` (privater Schlüssel - GEHEIM HALTEN!)
- `taskmate_claude.pub` (öffentlicher Schlüssel)
## Schritt 2: Öffentlichen Schlüssel übermitteln
Den Inhalt der Datei `taskmate_claude.pub` an den Administrator senden:
### Windows:
```
type %USERPROFILE%\.ssh\taskmate_claude.pub
```
### Mac/Linux:
```
cat ~/.ssh/taskmate_claude.pub
```
Der Administrator muss diesen öffentlichen Schlüssel auf dem Server hinterlegen.
## Schritt 3: SSH-Konfiguration einrichten
Eine Konfigurationsdatei erstellen für einfacheren Zugriff:
### Windows:
Datei `%USERPROFILE%\.ssh\config` bearbeiten
### Mac/Linux:
Datei `~/.ssh/config` bearbeiten
Folgenden Inhalt hinzufügen:
```
Host taskmate-claude
HostName [SERVER-IP oder DOMAIN]
User [BENUTZERNAME]
Port [SSH-PORT, standard 22]
IdentityFile ~/.ssh/taskmate_claude
```
**Hinweis:** Die Werte in eckigen Klammern müssen vom Administrator bereitgestellt werden.
## Schritt 4: Verbindung testen
```
ssh taskmate-claude
```
Bei der ersten Verbindung:
1. Die Fingerprint-Warnung mit "yes" bestätigen
2. Das beim Erstellen des Schlüssels vergebene Passwort eingeben
## Schritt 5: Integration in TaskMate
Nach erfolgreicher SSH-Konfiguration:
1. In TaskMate einloggen
2. Zur Kachel "TaskMate -> Claude starten" navigieren
3. Die Funktion sollte nun verfügbar sein
## Sicherheitshinweise
- **Privaten Schlüssel niemals weitergeben!** (Datei ohne .pub Endung)
- Das Passwort für den SSH-Schlüssel sicher aufbewahren
- Bei Verlust des privaten Schlüssels muss ein neues Schlüsselpaar erstellt werden
## Fehlerbehebung
### "Permission denied"
- Prüfen ob der richtige Schlüssel verwendet wird
- Prüfen ob das Passwort korrekt ist
### "Connection refused"
- Server-IP/Domain und Port prüfen
- Firewall-Einstellungen prüfen
### "Host key verification failed"
- Die Datei `~/.ssh/known_hosts` prüfen
- Ggf. alten Eintrag für den Server entfernen
## Benötigte Informationen vom Administrator
Der Administrator muss folgende Daten bereitstellen:
1. Server-IP oder Domain
2. SSH-Port (falls nicht Standard 22)
3. Benutzername für SSH-Zugang
4. Bestätigung dass der öffentliche Schlüssel hinterlegt wurde

Datei anzeigen

@ -170,6 +170,63 @@ function createTables() {
logger.info('Migration: repositories_base_path Spalte zu users hinzugefuegt');
}
// Migration: Add custom_initials column to users
const hasCustomInitials = userColumns.some(col => col.name === 'custom_initials');
if (!hasCustomInitials) {
db.exec("ALTER TABLE users ADD COLUMN custom_initials TEXT");
logger.info('Migration: custom_initials Spalte zu users hinzugefuegt');
}
// Migration: Add initials column and prepare email
const hasInitials = userColumns.some(col => col.name === 'initials');
if (!hasInitials && userColumns.some(col => col.name === 'username')) {
logger.info('Migration: Füge initials Spalte hinzu und bereite E-Mail vor');
// Zuerst Daten vorbereiten
const users = db.prepare('SELECT id, username, email, custom_initials FROM users').all();
for (const user of users) {
// Stelle sicher dass jeder Benutzer eine E-Mail hat
if (!user.email || user.email === '') {
if (user.username === 'admin') {
// Admin bekommt eine spezielle E-Mail
db.prepare('UPDATE users SET email = ? WHERE id = ?').run('admin@taskmate.local', user.id);
} else if (user.username.includes('@')) {
// Username enthält bereits E-Mail (wie bei bestehenden Benutzern)
db.prepare('UPDATE users SET email = ? WHERE id = ?').run(user.username, user.id);
}
}
// Initialen setzen (aus custom_initials oder generieren)
if (!user.custom_initials || user.custom_initials === '') {
let initials = 'XX';
if (user.username === 'admin') {
initials = 'AD';
} else if (user.email || user.username.includes('@')) {
// Generiere Initialen aus E-Mail
const emailPart = (user.email || user.username).split('@')[0];
if (emailPart.includes('_')) {
const parts = emailPart.split('_');
initials = (parts[0][0] + parts[1][0]).toUpperCase();
} else if (emailPart.includes('.')) {
const parts = emailPart.split('.');
initials = (parts[0][0] + parts[1][0]).toUpperCase();
} else {
initials = emailPart.substring(0, 2).toUpperCase();
}
}
db.prepare('UPDATE users SET custom_initials = ? WHERE id = ?').run(initials, user.id);
}
}
// Neue initials Spalte hinzufügen
db.exec("ALTER TABLE users ADD COLUMN initials TEXT");
// Daten von custom_initials nach initials kopieren
db.exec("UPDATE users SET initials = custom_initials");
logger.info('Migration: initials Spalte hinzugefügt und E-Mail-Daten vorbereitet');
}
// Proposals (Vorschlaege)
db.exec(`
CREATE TABLE IF NOT EXISTS proposals (
@ -368,6 +425,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 (
@ -409,6 +485,159 @@ function createTables() {
)
`);
// Erinnerungen
db.exec(`
CREATE TABLE IF NOT EXISTS reminders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER NOT NULL,
title TEXT NOT NULL,
description TEXT,
reminder_date DATE NOT NULL,
reminder_time TIME DEFAULT '09:00',
color TEXT DEFAULT '#F59E0B',
advance_days TEXT DEFAULT '1',
repeat_type TEXT DEFAULT 'none',
repeat_interval INTEGER DEFAULT 1,
is_active INTEGER DEFAULT 1,
created_by INTEGER NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE,
FOREIGN KEY (created_by) REFERENCES users(id)
)
`);
// Erinnerungs-Benachrichtigungen (für Tracking welche bereits gesendet wurden)
db.exec(`
CREATE TABLE IF NOT EXISTS reminder_notifications (
id INTEGER PRIMARY KEY AUTOINCREMENT,
reminder_id INTEGER NOT NULL,
notification_date DATE NOT NULL,
sent INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (reminder_id) REFERENCES reminders(id) ON DELETE CASCADE,
UNIQUE(reminder_id, notification_date)
)
`);
// Wissensmanagement - Kategorien
db.exec(`
CREATE TABLE IF NOT EXISTS knowledge_categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
description TEXT,
color TEXT DEFAULT '#3B82F6',
icon TEXT,
position INTEGER NOT NULL DEFAULT 0,
created_by INTEGER,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (created_by) REFERENCES users(id)
)
`);
// Wissensmanagement - Einträge
db.exec(`
CREATE TABLE IF NOT EXISTS knowledge_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
category_id INTEGER NOT NULL,
title TEXT NOT NULL,
url TEXT,
notes TEXT,
position INTEGER NOT NULL DEFAULT 0,
created_by INTEGER,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (category_id) REFERENCES knowledge_categories(id) ON DELETE CASCADE,
FOREIGN KEY (created_by) REFERENCES users(id)
)
`);
// Wissensmanagement - Anhänge
db.exec(`
CREATE TABLE IF NOT EXISTS knowledge_attachments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
entry_id INTEGER NOT NULL,
filename TEXT NOT NULL,
original_name TEXT NOT NULL,
mime_type TEXT NOT NULL,
size_bytes INTEGER NOT NULL,
uploaded_by INTEGER,
uploaded_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (entry_id) REFERENCES knowledge_entries(id) ON DELETE CASCADE,
FOREIGN KEY (uploaded_by) REFERENCES users(id)
)
`);
// 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');
}
// Coding Verbrauchsdaten
db.exec(`
CREATE TABLE IF NOT EXISTS coding_usage (
id INTEGER PRIMARY KEY AUTOINCREMENT,
directory_id INTEGER NOT NULL,
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
cpu_percent REAL,
memory_mb REAL,
disk_read_mb REAL,
disk_write_mb REAL,
network_recv_mb REAL,
network_sent_mb REAL,
process_count INTEGER,
FOREIGN KEY (directory_id) REFERENCES coding_directories(id) ON DELETE CASCADE
)
`);
// Kontakte
db.exec(`
CREATE TABLE IF NOT EXISTS contacts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
first_name TEXT,
last_name TEXT,
company TEXT,
position TEXT,
email TEXT,
phone TEXT,
mobile TEXT,
address TEXT,
postal_code TEXT,
city TEXT,
country TEXT,
website TEXT,
notes TEXT,
tags TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
created_by INTEGER,
FOREIGN KEY (created_by) REFERENCES users(id)
)
`);
// Indizes für Performance
db.exec(`
CREATE INDEX IF NOT EXISTS idx_tasks_project ON tasks(project_id);
@ -426,17 +655,42 @@ function createTables() {
CREATE INDEX IF NOT EXISTS idx_notifications_user_read ON notifications(user_id, is_read);
CREATE INDEX IF NOT EXISTS idx_notifications_created ON notifications(created_at);
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);
CREATE INDEX IF NOT EXISTS idx_coding_usage_directory ON coding_usage(directory_id);
CREATE INDEX IF NOT EXISTS idx_coding_usage_timestamp ON coding_usage(timestamp);
CREATE INDEX IF NOT EXISTS idx_contacts_company ON contacts(company);
CREATE INDEX IF NOT EXISTS idx_contacts_tags ON contacts(tags);
`);
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 email = ? AND role = ?').get('admin@taskmate.local', '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 = {
@ -460,10 +714,10 @@ async function createDefaultUsers() {
// Admin-Benutzer
const adminUser = {
username: 'admin',
password: '!1Data123',
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

Datei anzeigen

@ -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
*/
@ -45,6 +77,10 @@ function verifyToken(token) {
try {
return jwt.verify(token, JWT_SECRET);
} catch (error) {
// Nur bei unerwarteten Fehlern loggen (nicht bei normalen Ablauf/Ungültig-Fällen)
if (error.name !== 'TokenExpiredError' && error.name !== 'JsonWebTokenError') {
logger.error(`[AUTH] Unerwarteter Token-Fehler: ${error.name} - ${error.message}`);
}
return null;
}
}
@ -175,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,

Datei anzeigen

@ -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
};

Datei anzeigen

@ -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 = {
'&amp;': '&',
'&lt;': '<',
'&gt;': '>',
'&quot;': '"',
'&#039;': "'",
'&#x27;': "'",
'&apos;': "'"
};
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 &amp;, 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 {
@ -239,6 +305,46 @@ function sanitizeMiddleware(req, res, next) {
next();
}
/**
* Kontakt-Validierung Middleware
*/
validators.contact = function(req, res, next) {
const errors = [];
const { firstName, lastName, company, email, phone, mobile, website } = req.body;
// Mindestens ein Name oder Firma muss vorhanden sein
if (!firstName && !lastName && !company) {
errors.push('Mindestens Vorname, Nachname oder Firma muss angegeben werden');
}
// Email validieren
if (email) {
const emailError = validators.email(email, 'E-Mail');
if (emailError) errors.push(emailError);
}
// Website URL validieren
if (website) {
const urlError = validators.url(website, 'Website');
if (urlError) errors.push(urlError);
}
// Telefonnummer Format (optional)
if (phone && !/^[\d\s\-\+\(\)]+$/.test(phone)) {
errors.push('Telefonnummer enthält ungültige Zeichen');
}
if (mobile && !/^[\d\s\-\+\(\)]+$/.test(mobile)) {
errors.push('Mobilnummer enthält ungültige Zeichen');
}
if (errors.length > 0) {
return res.status(400).json({ errors });
}
next();
};
module.exports = {
stripHtml,
sanitizeMarkdown,

Datei anzeigen

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

87
backend/query_users.js Normale Datei
Datei anzeigen

@ -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);
}

Datei anzeigen

@ -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
@ -62,8 +31,8 @@ router.get('/users', (req, res) => {
try {
const db = getDb();
const users = db.prepare(`
SELECT id, username, display_name, color, role, permissions, email,
created_at, last_login, failed_attempts, locked_until
SELECT id, email, initials, display_name, color, role, permissions,
created_at, last_login, failed_attempts, locked_until, custom_initials
FROM users
ORDER BY id
`).all();
@ -86,10 +55,10 @@ router.get('/users', (req, res) => {
*/
router.post('/users', async (req, res) => {
try {
const { username, password, displayName, email, role, permissions } = req.body;
const { initials, password, displayName, email, role, permissions } = req.body;
// Validierung
if (!username || !password || !displayName || !email) {
if (!initials || !password || !displayName || !email) {
return res.status(400).json({ error: 'Kürzel, Passwort, Anzeigename und E-Mail erforderlich' });
}
@ -100,8 +69,8 @@ router.post('/users', async (req, res) => {
}
// Kürzel muss genau 2 Buchstaben sein
const usernameUpper = username.toUpperCase();
if (!/^[A-Z]{2}$/.test(usernameUpper)) {
const initialsUpper = initials.toUpperCase();
if (!/^[A-Z]{2}$/.test(initialsUpper)) {
return res.status(400).json({ error: 'Kürzel muss genau 2 Buchstaben sein (z.B. HG)' });
}
@ -111,18 +80,18 @@ router.post('/users', async (req, res) => {
const db = getDb();
// Prüfen ob Kürzel bereits existiert
const existing = db.prepare('SELECT id FROM users WHERE username = ?').get(usernameUpper);
if (existing) {
return res.status(400).json({ error: 'Kürzel bereits vergeben' });
}
// Prüfen ob E-Mail bereits existiert
const existingEmail = db.prepare('SELECT id FROM users WHERE email = ?').get(email.toLowerCase());
if (existingEmail) {
return res.status(400).json({ error: 'E-Mail bereits vergeben' });
}
// Prüfen ob Kürzel bereits existiert
const existingInitials = db.prepare('SELECT id FROM users WHERE initials = ?').get(initialsUpper);
if (existingInitials) {
return res.status(400).json({ error: 'Kürzel bereits vergeben' });
}
// Passwort hashen
const passwordHash = await bcrypt.hash(password, 12);
@ -131,23 +100,24 @@ router.post('/users', async (req, res) => {
// Benutzer erstellen
const result = db.prepare(`
INSERT INTO users (username, password_hash, display_name, color, role, permissions, email)
VALUES (?, ?, ?, ?, ?, ?, ?)
INSERT INTO users (email, initials, password_hash, display_name, color, role, permissions, custom_initials)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).run(
usernameUpper,
email.toLowerCase(),
initialsUpper,
passwordHash,
displayName,
defaultColor,
role || 'user',
JSON.stringify(permissions || []),
email.toLowerCase()
initialsUpper
);
logger.info(`Admin ${req.user.username} hat Benutzer ${usernameUpper} erstellt`);
logger.info(`Admin ${req.user.email} hat Benutzer ${initialsUpper} erstellt`);
res.status(201).json({
id: result.lastInsertRowid,
username: usernameUpper,
initials: initialsUpper,
displayName,
email: email.toLowerCase(),
color: defaultColor,
@ -166,7 +136,7 @@ router.post('/users', async (req, res) => {
router.put('/users/:id', async (req, res) => {
try {
const userId = parseInt(req.params.id);
const { displayName, color, role, permissions, password, unlockAccount, email } = req.body;
const { displayName, color, role, permissions, password, unlockAccount, email, initials } = req.body;
const db = getDb();
@ -237,6 +207,24 @@ router.put('/users/:id', async (req, res) => {
params.push(email.toLowerCase());
}
if (initials !== undefined) {
// Validierung: Genau 2 Buchstaben
const initialsUpper = initials.toUpperCase();
if (!/^[A-Z]{2}$/.test(initialsUpper)) {
return res.status(400).json({ error: 'Kürzel muss genau 2 Buchstaben sein (z.B. HG)' });
}
// Prüfen ob Kürzel bereits von anderem Benutzer verwendet wird
const existingInitials = db.prepare('SELECT id FROM users WHERE initials = ? AND id != ?').get(initialsUpper, userId);
if (existingInitials) {
return res.status(400).json({ error: 'Kürzel bereits vergeben' });
}
updates.push('initials = ?');
params.push(initialsUpper);
// Auch custom_initials aktualisieren für Kompatibilität
updates.push('custom_initials = ?');
params.push(initialsUpper);
}
if (updates.length === 0) {
return res.status(400).json({ error: 'Keine Änderungen angegeben' });
}
@ -249,7 +237,7 @@ router.put('/users/:id', async (req, res) => {
// Aktualisierten Benutzer zurueckgeben
const updatedUser = db.prepare(`
SELECT id, username, display_name, color, role, permissions, email,
created_at, last_login, failed_attempts, locked_until
created_at, last_login, failed_attempts, locked_until, custom_initials
FROM users WHERE id = ?
`).get(userId);
@ -290,35 +278,44 @@ router.delete('/users/:id', (req, res) => {
return res.status(400).json({ error: 'Sie können sich nicht selbst löschen' });
}
// Alle Referenzen auf den Benutzer auf NULL setzen oder löschen
// Tasks
// Alle Referenzen auf den Benutzer behandeln
// Tasks - assigned_to und created_by auf NULL setzen (erlaubt NULL)
db.prepare('UPDATE tasks SET assigned_to = NULL WHERE assigned_to = ?').run(userId);
db.prepare('UPDATE tasks SET created_by = NULL WHERE created_by = ?').run(userId);
// Kommentare
db.prepare('UPDATE comments SET user_id = NULL WHERE user_id = ?').run(userId);
// Task-Assignees löschen (Mehrfachzuweisung)
db.prepare('DELETE FROM task_assignees WHERE user_id = ?').run(userId);
// Historie
db.prepare('UPDATE history SET user_id = NULL WHERE user_id = ?').run(userId);
// Kommentare löschen (user_id NOT NULL)
db.prepare('DELETE FROM comments WHERE user_id = ?').run(userId);
// Vorschläge
db.prepare('UPDATE proposals SET created_by = NULL WHERE created_by = ?').run(userId);
// Historie löschen (user_id NOT NULL)
db.prepare('DELETE FROM history WHERE user_id = ?').run(userId);
// Vorschläge: Votes zuerst löschen, dann Vorschläge des Benutzers
db.prepare('DELETE FROM proposal_votes WHERE user_id = ?').run(userId);
db.prepare('DELETE FROM proposals WHERE created_by = ?').run(userId);
// approved_by kann NULL sein
db.prepare('UPDATE proposals SET approved_by = NULL WHERE approved_by = ?').run(userId);
// Projekte
// Projekte - created_by auf NULL setzen (erlaubt NULL)
db.prepare('UPDATE projects SET created_by = NULL WHERE created_by = ?').run(userId);
// Anhänge
// Anhänge - uploaded_by auf NULL setzen (erlaubt NULL)
db.prepare('UPDATE attachments SET uploaded_by = NULL WHERE uploaded_by = ?').run(userId);
// Links
// Links - created_by auf NULL setzen (erlaubt NULL)
db.prepare('UPDATE links SET created_by = NULL WHERE created_by = ?').run(userId);
// Login-Audit (kann gelöscht werden)
// Applications - created_by auf NULL setzen (erlaubt NULL)
db.prepare('UPDATE applications SET created_by = NULL WHERE created_by = ?').run(userId);
// Login-Audit löschen
db.prepare('DELETE FROM login_audit WHERE user_id = ?').run(userId);
// Votes des Benutzers löschen
db.prepare('DELETE FROM proposal_votes WHERE user_id = ?').run(userId);
// Benachrichtigungen löschen (wird auch durch CASCADE gelöscht, aber sicherheitshalber)
db.prepare('DELETE FROM notifications WHERE user_id = ?').run(userId);
db.prepare('UPDATE notifications SET actor_id = NULL WHERE actor_id = ?').run(userId);
// Benutzer löschen
db.prepare('DELETE FROM users WHERE id = ?').run(userId);
@ -342,6 +339,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
@ -360,24 +368,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) {
@ -395,7 +415,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) {
@ -404,6 +429,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;

Datei anzeigen

@ -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');
@ -35,13 +35,13 @@ router.post('/login', async (req, res) => {
const db = getDb();
// Benutzer suchen: Zuerst nach Username "admin", dann nach E-Mail
// Benutzer suchen: Admin kann mit "admin" einloggen, alle anderen mit E-Mail
let user;
if (username.toLowerCase() === 'admin') {
// Admin-User per Username suchen
user = db.prepare('SELECT * FROM users WHERE username = ?').get('admin');
// Admin-Login über spezielle E-Mail
user = db.prepare('SELECT * FROM users WHERE email = ? AND role = ?').get('admin@taskmate.local', 'admin');
} else {
// Normale User per E-Mail suchen
// Normale User loggen sich mit E-Mail ein
user = db.prepare('SELECT * FROM users WHERE email = ?').get(username);
}
@ -111,8 +111,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,11 +131,13 @@ router.post('/login', async (req, res) => {
}
res.json({
token,
token: accessToken,
refreshToken,
csrfToken,
user: {
id: user.id,
username: user.username,
email: user.email,
initials: user.initials,
displayName: user.display_name,
color: user.color,
role: user.role || 'user',
@ -147,13 +152,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 +211,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);
const { refreshToken } = req.body;
const ip = req.ip || req.connection.remoteAddress;
const userAgent = req.headers['user-agent'];
if (!user) {
return res.status(404).json({ error: 'Benutzer nicht gefunden' });
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();
res.json({ token, csrfToken });
// User-Daten für CSRF-Token abrufen
const decoded = require('jsonwebtoken').decode(accessToken);
const csrfToken = getTokenForUser(decoded.id);
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
@ -299,14 +351,15 @@ router.get('/users', authenticateToken, (req, res) => {
const db = getDb();
// Nur regulaere Benutzer (nicht Admins) fuer Aufgaben-Zuweisung
const users = db.prepare(`
SELECT id, username, display_name, color
SELECT id, email, initials, display_name, color
FROM users
WHERE role != 'admin' OR role IS NULL
`).all();
res.json(users.map(u => ({
id: u.id,
username: u.username,
email: u.email,
initials: u.initials,
displayName: u.display_name,
color: u.color
})));

714
backend/routes/coding.js Normale Datei
Datei anzeigen

@ -0,0 +1,714 @@
/**
* 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' });
}
});
/**
* GET /api/coding/directories/:id/usage
* Aktuelle Verbrauchsdaten abrufen (simuliert)
*/
router.get('/directories/:id/usage', (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' });
}
// Simulierte Verbrauchsdaten generieren
const usage = {
cpu_percent: Math.random() * 100,
memory_mb: Math.floor(Math.random() * 4096),
disk_read_mb: Math.random() * 100,
disk_write_mb: Math.random() * 50,
network_recv_mb: Math.random() * 10,
network_sent_mb: Math.random() * 10,
process_count: Math.floor(Math.random() * 20) + 1,
timestamp: new Date()
};
// Speichere in Datenbank für Historie
db.prepare(`
INSERT INTO coding_usage (directory_id, cpu_percent, memory_mb, disk_read_mb,
disk_write_mb, network_recv_mb, network_sent_mb, process_count)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).run(id, usage.cpu_percent, usage.memory_mb, usage.disk_read_mb,
usage.disk_write_mb, usage.network_recv_mb, usage.network_sent_mb, usage.process_count);
res.json({ usage });
} catch (error) {
logger.error('Fehler beim Abrufen der Verbrauchsdaten:', error);
res.status(500).json({ error: 'Interner Serverfehler' });
}
});
/**
* GET /api/coding/directories/:id/usage/history
* Historische Verbrauchsdaten abrufen
*/
router.get('/directories/:id/usage/history', (req, res) => {
try {
const { id } = req.params;
const hours = parseInt(req.query.hours) || 24;
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 history = db.prepare(`
SELECT * FROM coding_usage
WHERE directory_id = ?
AND timestamp > datetime('now', '-${hours} hours')
ORDER BY timestamp DESC
LIMIT 100
`).all(id);
res.json({ history });
} catch (error) {
logger.error('Fehler beim Abrufen der Verbrauchshistorie:', error);
res.status(500).json({ error: 'Interner Serverfehler' });
}
});
module.exports = router;

439
backend/routes/contacts.js Normale Datei
Datei anzeigen

@ -0,0 +1,439 @@
/**
* TASKMATE - Contact Routes
* =========================
* CRUD für Kontakte
*/
const express = require('express');
const router = express.Router();
const { getDb } = require('../database');
const logger = require('../utils/logger');
const { validators } = require('../middleware/validation');
/**
* GET /api/contacts
* Alle Kontakte abrufen mit optionalem Filter
*/
router.get('/', (req, res) => {
try {
const db = getDb();
const { search, tag, sortBy = 'created_at', sortOrder = 'desc' } = req.query;
let query = `
SELECT c.*, u.display_name as creator_name
FROM contacts c
LEFT JOIN users u ON c.created_by = u.id
WHERE 1=1
`;
const params = [];
// Suchfilter
if (search) {
query += ` AND (
c.first_name LIKE ? OR
c.last_name LIKE ? OR
c.company LIKE ? OR
c.email LIKE ? OR
c.phone LIKE ? OR
c.mobile LIKE ?
)`;
const searchParam = `%${search}%`;
params.push(searchParam, searchParam, searchParam, searchParam, searchParam, searchParam);
}
// Tag-Filter
if (tag) {
query += ` AND c.tags LIKE ?`;
params.push(`%${tag}%`);
}
// Sortierung
const validSortFields = ['first_name', 'last_name', 'company', 'created_at', 'updated_at'];
const sortField = validSortFields.includes(sortBy) ? sortBy : 'created_at';
const order = sortOrder.toLowerCase() === 'asc' ? 'ASC' : 'DESC';
query += ` ORDER BY c.${sortField} ${order}`;
const contacts = db.prepare(query).all(params);
res.json(contacts.map(c => ({
id: c.id,
firstName: c.first_name,
lastName: c.last_name,
company: c.company,
position: c.position,
email: c.email,
phone: c.phone,
mobile: c.mobile,
address: c.address,
postalCode: c.postal_code,
city: c.city,
country: c.country,
website: c.website,
notes: c.notes,
tags: c.tags ? c.tags.split(',').map(t => t.trim()) : [],
createdAt: c.created_at,
updatedAt: c.updated_at,
createdBy: c.created_by,
creatorName: c.creator_name
})));
} catch (error) {
logger.error('Fehler beim Abrufen der Kontakte:', { error: error.message });
res.status(500).json({ error: 'Interner Serverfehler' });
}
});
/**
* GET /api/contacts/:id
* Einzelnen Kontakt abrufen
*/
router.get('/:id', (req, res) => {
try {
const db = getDb();
const contactId = req.params.id;
const contact = db.prepare(`
SELECT c.*, u.display_name as creator_name
FROM contacts c
LEFT JOIN users u ON c.created_by = u.id
WHERE c.id = ?
`).get(contactId);
if (!contact) {
return res.status(404).json({ error: 'Kontakt nicht gefunden' });
}
res.json({
id: contact.id,
firstName: contact.first_name,
lastName: contact.last_name,
company: contact.company,
position: contact.position,
email: contact.email,
phone: contact.phone,
mobile: contact.mobile,
address: contact.address,
postalCode: contact.postal_code,
city: contact.city,
country: contact.country,
website: contact.website,
notes: contact.notes,
tags: contact.tags ? contact.tags.split(',').map(t => t.trim()) : [],
createdAt: contact.created_at,
updatedAt: contact.updated_at,
createdBy: contact.created_by,
creatorName: contact.creator_name
});
} catch (error) {
logger.error('Fehler beim Abrufen des Kontakts:', { error: error.message, contactId: req.params.id });
res.status(500).json({ error: 'Interner Serverfehler' });
}
});
/**
* POST /api/contacts
* Neuen Kontakt erstellen
*/
router.post('/', validators.contact, (req, res) => {
try {
const db = getDb();
const userId = req.user.id;
const {
firstName,
lastName,
company,
position,
email,
phone,
mobile,
address,
postalCode,
city,
country,
website,
notes,
tags
} = req.body;
const result = db.prepare(`
INSERT INTO contacts (
first_name, last_name, company, position,
email, phone, mobile, address, postal_code,
city, country, website, notes, tags,
created_by
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
firstName || null,
lastName || null,
company || null,
position || null,
email || null,
phone || null,
mobile || null,
address || null,
postalCode || null,
city || null,
country || null,
website || null,
notes || null,
Array.isArray(tags) ? tags.join(', ') : null,
userId
);
const newContact = db.prepare(`
SELECT c.*, u.display_name as creator_name
FROM contacts c
LEFT JOIN users u ON c.created_by = u.id
WHERE c.id = ?
`).get(result.lastInsertRowid);
// Socket.io Event
const io = req.app.get('io');
io.emit('contact:created', {
contact: {
id: newContact.id,
firstName: newContact.first_name,
lastName: newContact.last_name,
company: newContact.company,
position: newContact.position,
email: newContact.email,
phone: newContact.phone,
mobile: newContact.mobile,
address: newContact.address,
postalCode: newContact.postal_code,
city: newContact.city,
country: newContact.country,
website: newContact.website,
notes: newContact.notes,
tags: newContact.tags ? newContact.tags.split(',').map(t => t.trim()) : [],
createdAt: newContact.created_at,
updatedAt: newContact.updated_at,
createdBy: newContact.created_by,
creatorName: newContact.creator_name
},
userId
});
res.status(201).json({
id: newContact.id,
firstName: newContact.first_name,
lastName: newContact.last_name,
company: newContact.company,
position: newContact.position,
email: newContact.email,
phone: newContact.phone,
mobile: newContact.mobile,
address: newContact.address,
postalCode: newContact.postal_code,
city: newContact.city,
country: newContact.country,
website: newContact.website,
notes: newContact.notes,
tags: newContact.tags ? newContact.tags.split(',').map(t => t.trim()) : [],
createdAt: newContact.created_at,
updatedAt: newContact.updated_at,
createdBy: newContact.created_by,
creatorName: newContact.creator_name
});
logger.info('Kontakt erstellt', { contactId: newContact.id, userId });
} catch (error) {
logger.error('Fehler beim Erstellen des Kontakts:', { error: error.message, body: req.body });
res.status(500).json({ error: 'Interner Serverfehler' });
}
});
/**
* PUT /api/contacts/:id
* Kontakt aktualisieren
*/
router.put('/:id', validators.contact, (req, res) => {
try {
const db = getDb();
const contactId = req.params.id;
const userId = req.user.id;
const {
firstName,
lastName,
company,
position,
email,
phone,
mobile,
address,
postalCode,
city,
country,
website,
notes,
tags
} = req.body;
// Prüfen ob Kontakt existiert
const existing = db.prepare('SELECT id FROM contacts WHERE id = ?').get(contactId);
if (!existing) {
return res.status(404).json({ error: 'Kontakt nicht gefunden' });
}
// Update
db.prepare(`
UPDATE contacts SET
first_name = ?,
last_name = ?,
company = ?,
position = ?,
email = ?,
phone = ?,
mobile = ?,
address = ?,
postal_code = ?,
city = ?,
country = ?,
website = ?,
notes = ?,
tags = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`).run(
firstName || null,
lastName || null,
company || null,
position || null,
email || null,
phone || null,
mobile || null,
address || null,
postalCode || null,
city || null,
country || null,
website || null,
notes || null,
Array.isArray(tags) ? tags.join(', ') : null,
contactId
);
const updatedContact = db.prepare(`
SELECT c.*, u.display_name as creator_name
FROM contacts c
LEFT JOIN users u ON c.created_by = u.id
WHERE c.id = ?
`).get(contactId);
// Socket.io Event
const io = req.app.get('io');
io.emit('contact:updated', {
contact: {
id: updatedContact.id,
firstName: updatedContact.first_name,
lastName: updatedContact.last_name,
company: updatedContact.company,
position: updatedContact.position,
email: updatedContact.email,
phone: updatedContact.phone,
mobile: updatedContact.mobile,
address: updatedContact.address,
postalCode: updatedContact.postal_code,
city: updatedContact.city,
country: updatedContact.country,
website: updatedContact.website,
notes: updatedContact.notes,
tags: updatedContact.tags ? updatedContact.tags.split(',').map(t => t.trim()) : [],
createdAt: updatedContact.created_at,
updatedAt: updatedContact.updated_at,
createdBy: updatedContact.created_by,
creatorName: updatedContact.creator_name
},
userId
});
res.json({
id: updatedContact.id,
firstName: updatedContact.first_name,
lastName: updatedContact.last_name,
company: updatedContact.company,
position: updatedContact.position,
email: updatedContact.email,
phone: updatedContact.phone,
mobile: updatedContact.mobile,
address: updatedContact.address,
postalCode: updatedContact.postal_code,
city: updatedContact.city,
country: updatedContact.country,
website: updatedContact.website,
notes: updatedContact.notes,
tags: updatedContact.tags ? updatedContact.tags.split(',').map(t => t.trim()) : [],
createdAt: updatedContact.created_at,
updatedAt: updatedContact.updated_at,
createdBy: updatedContact.created_by,
creatorName: updatedContact.creator_name
});
logger.info('Kontakt aktualisiert', { contactId, userId });
} catch (error) {
logger.error('Fehler beim Aktualisieren des Kontakts:', { error: error.message, contactId: req.params.id });
res.status(500).json({ error: 'Interner Serverfehler' });
}
});
/**
* DELETE /api/contacts/:id
* Kontakt löschen
*/
router.delete('/:id', (req, res) => {
try {
const db = getDb();
const contactId = req.params.id;
const userId = req.user.id;
// Prüfen ob Kontakt existiert
const existing = db.prepare('SELECT id FROM contacts WHERE id = ?').get(contactId);
if (!existing) {
return res.status(404).json({ error: 'Kontakt nicht gefunden' });
}
// Löschen
db.prepare('DELETE FROM contacts WHERE id = ?').run(contactId);
// Socket.io Event
const io = req.app.get('io');
io.emit('contact:deleted', { contactId, userId });
res.json({ success: true });
logger.info('Kontakt gelöscht', { contactId, userId });
} catch (error) {
logger.error('Fehler beim Löschen des Kontakts:', { error: error.message, contactId: req.params.id });
res.status(500).json({ error: 'Interner Serverfehler' });
}
});
/**
* GET /api/contacts/tags
* Alle verwendeten Tags abrufen
*/
router.get('/tags/all', (req, res) => {
try {
const db = getDb();
const contacts = db.prepare('SELECT DISTINCT tags FROM contacts WHERE tags IS NOT NULL').all();
// Alle Tags sammeln und deduplizieren
const allTags = new Set();
contacts.forEach(contact => {
if (contact.tags) {
contact.tags.split(',').forEach(tag => {
const trimmedTag = tag.trim();
if (trimmedTag) {
allTags.add(trimmedTag);
}
});
}
});
res.json(Array.from(allTags).sort());
} catch (error) {
logger.error('Fehler beim Abrufen der Tags:', { error: error.message });
res.status(500).json({ error: 'Interner Serverfehler' });
}
});
module.exports = router;

Datei anzeigen

@ -6,10 +6,48 @@
const express = require('express');
const router = express.Router();
const path = require('path');
const fs = require('fs');
const os = require('os');
const { getDb } = require('../database');
const logger = require('../utils/logger');
const gitService = require('../services/gitService');
const giteaService = require('../services/giteaService');
const multer = require('multer');
// Fester Pfad für Server-Modus (TaskMate Source-Code)
const SERVER_SOURCE_PATH = '/app/taskmate-source';
// Temporäres Verzeichnis für Browser-Uploads
const TEMP_UPLOAD_DIR = path.join(os.tmpdir(), 'taskmate-git-uploads');
// Multer-Konfiguration für Git-Uploads (beliebige Dateitypen)
const gitUploadStorage = multer.diskStorage({
destination: (req, file, cb) => {
// Erstelle eindeutiges temporäres Verzeichnis pro Upload-Session
const sessionId = req.body.sessionId || Date.now().toString();
const sessionDir = path.join(TEMP_UPLOAD_DIR, sessionId);
// Relativen Pfad aus dem Dateinamen extrahieren (wird vom Frontend gesendet)
const relativePath = file.originalname;
const fileDir = path.join(sessionDir, path.dirname(relativePath));
fs.mkdirSync(fileDir, { recursive: true });
cb(null, fileDir);
},
filename: (req, file, cb) => {
// Nur den Dateinamen ohne Pfad
cb(null, path.basename(file.originalname));
}
});
const gitUpload = multer({
storage: gitUploadStorage,
limits: {
fileSize: 50 * 1024 * 1024, // 50MB pro Datei
files: 500 // Maximal 500 Dateien
}
});
/**
* Hilfsfunktion: Anwendung für Projekt abrufen
@ -175,8 +213,14 @@ router.post('/commit/:projectId', (req, res) => {
}
}
// Commit erstellen
const result = gitService.commit(application.local_path, message);
// Autor aus eingeloggtem Benutzer
const author = req.user ? {
name: req.user.display_name || req.user.username,
email: req.user.email || `${req.user.username.toLowerCase()}@taskmate.local`
} : null;
// Commit erstellen mit Autor
const result = gitService.commit(application.local_path, message, author);
res.json(result);
} catch (error) {
logger.error('Fehler beim Commit:', error);
@ -414,24 +458,58 @@ router.post('/set-remote/:projectId', (req, res) => {
/**
* POST /api/git/init-push/:projectId
* Initialen Push mit Upstream-Tracking
* Body: { targetBranch?: string, force?: boolean }
* - targetBranch: Optional - Ziel-Branch auf Remote (z.B. "main" auch wenn lokal "master")
* - force: Optional - Force-Push um Remote zu überschreiben
*/
router.post('/init-push/:projectId', (req, res) => {
router.post('/init-push/:projectId', async (req, res) => {
try {
const { projectId } = req.params;
const { branch } = req.body;
const { targetBranch, force } = req.body;
const application = getApplicationForProject(projectId);
if (!application) {
return res.status(404).json({ error: 'Keine Anwendung für dieses Projekt konfiguriert' });
}
const currentBranch = branch || 'main';
const result = gitService.pushWithUpstream(application.local_path, currentBranch);
// targetBranch kann null sein - dann wird der lokale Branch-Name verwendet
// force: boolean - überschreibt Remote-Branch bei Konflikten
const result = gitService.pushWithUpstream(application.local_path, targetBranch || null, 'origin', force === true);
if (result.success) {
// Sync-Zeitpunkt aktualisieren
const db = getDb();
db.prepare('UPDATE applications SET last_sync = CURRENT_TIMESTAMP WHERE project_id = ?').run(projectId);
// Default-Branch in Gitea aktualisieren wenn der gepushte Branch abweicht
if (application.gitea_repo_url && result.branch) {
try {
// Owner/Repo aus URL extrahieren (z.B. https://gitea.../AegisSight/TaskMate.git)
const urlMatch = application.gitea_repo_url.match(/\/([^\/]+)\/([^\/]+?)(?:\.git)?$/);
if (urlMatch) {
const owner = urlMatch[1];
const repoName = urlMatch[2];
const actualBranch = result.branch;
// Default-Branch in Gitea setzen
const updateResult = await giteaService.updateRepository(owner, repoName, {
defaultBranch: actualBranch
});
if (updateResult.success) {
logger.info(`Default-Branch in Gitea auf '${actualBranch}' gesetzt für ${owner}/${repoName}`);
result.giteaUpdated = true;
result.defaultBranch = actualBranch;
} else {
logger.warn(`Konnte Default-Branch in Gitea nicht aktualisieren: ${updateResult.error}`);
result.giteaUpdated = false;
}
}
} catch (giteaError) {
logger.warn('Fehler beim Aktualisieren des Default-Branch in Gitea:', giteaError);
result.giteaUpdated = false;
}
}
}
res.json(result);
@ -441,4 +519,451 @@ router.post('/init-push/:projectId', (req, res) => {
}
});
/**
* POST /api/git/rename-branch/:projectId
* Branch umbenennen
* Body: { oldName: string, newName: string }
*/
router.post('/rename-branch/:projectId', (req, res) => {
try {
const { projectId } = req.params;
const { oldName, newName } = req.body;
const application = getApplicationForProject(projectId);
if (!application) {
return res.status(404).json({ error: 'Keine Anwendung für dieses Projekt konfiguriert' });
}
if (!oldName || !newName) {
return res.status(400).json({ error: 'oldName und newName sind erforderlich' });
}
const result = gitService.renameBranch(application.local_path, oldName, newName);
res.json(result);
} catch (error) {
logger.error('Fehler beim Umbenennen des Branches:', error);
res.status(500).json({ error: 'Serverfehler' });
}
});
// ============================================
// SERVER-MODUS ENDPOINTS
// Für direkte Git-Operationen auf dem TaskMate Source-Code
// ============================================
/**
* GET /api/git/server/status
* Git-Status für Server-Dateien abrufen
*/
router.get('/server/status', (req, res) => {
try {
// Prüfe ob der Pfad existiert und ein Git-Repo ist
if (!gitService.isPathAccessible(SERVER_SOURCE_PATH)) {
return res.status(500).json({
success: false,
error: 'Server-Verzeichnis nicht erreichbar'
});
}
if (!gitService.isGitRepository(SERVER_SOURCE_PATH)) {
return res.status(500).json({
success: false,
error: 'Server-Verzeichnis ist kein Git-Repository'
});
}
const result = gitService.getStatus(SERVER_SOURCE_PATH);
res.json(result);
} catch (error) {
logger.error('[Server-Git] Fehler beim Status:', error);
res.status(500).json({ error: 'Serverfehler', details: error.message });
}
});
/**
* GET /api/git/server/branches
* Branches für Server-Dateien abrufen
*/
router.get('/server/branches', (req, res) => {
try {
const result = gitService.getBranches(SERVER_SOURCE_PATH);
res.json(result);
} catch (error) {
logger.error('[Server-Git] Fehler beim Abrufen der Branches:', error);
res.status(500).json({ error: 'Serverfehler' });
}
});
/**
* GET /api/git/server/commits
* Commit-Historie für Server-Dateien abrufen
*/
router.get('/server/commits', (req, res) => {
try {
const limit = parseInt(req.query.limit) || 20;
const result = gitService.getCommitHistory(SERVER_SOURCE_PATH, limit);
res.json(result);
} catch (error) {
logger.error('[Server-Git] Fehler beim Abrufen der Commits:', error);
res.status(500).json({ error: 'Serverfehler' });
}
});
/**
* GET /api/git/server/remote
* Remote-URL für Server-Dateien abrufen
*/
router.get('/server/remote', (req, res) => {
try {
const result = gitService.getRemoteUrl(SERVER_SOURCE_PATH);
res.json(result);
} catch (error) {
logger.error('[Server-Git] Fehler beim Abrufen der Remote-URL:', error);
res.status(500).json({ error: 'Serverfehler' });
}
});
/**
* POST /api/git/server/stage
* Alle Änderungen für Server-Dateien stagen
*/
router.post('/server/stage', (req, res) => {
try {
const result = gitService.stageAll(SERVER_SOURCE_PATH);
res.json(result);
} catch (error) {
logger.error('[Server-Git] Fehler beim Stagen:', error);
res.status(500).json({ error: 'Serverfehler' });
}
});
/**
* POST /api/git/server/commit
* Commit für Server-Dateien erstellen
*/
router.post('/server/commit', (req, res) => {
try {
const { message, stageAll } = req.body;
if (!message) {
return res.status(400).json({ error: 'Commit-Nachricht ist erforderlich' });
}
// Optional: Alle Änderungen stagen
if (stageAll !== false) {
const stageResult = gitService.stageAll(SERVER_SOURCE_PATH);
if (!stageResult.success) {
return res.json(stageResult);
}
}
// Autor aus eingeloggtem Benutzer
const author = req.user ? {
name: req.user.display_name || req.user.username,
email: req.user.email || `${req.user.username.toLowerCase()}@taskmate.local`
} : null;
const result = gitService.commit(SERVER_SOURCE_PATH, message, author);
res.json(result);
} catch (error) {
logger.error('[Server-Git] Fehler beim Commit:', error);
res.status(500).json({ error: 'Serverfehler' });
}
});
/**
* POST /api/git/server/push
* Push für Server-Dateien ausführen
*/
router.post('/server/push', (req, res) => {
try {
const { branch, force } = req.body;
// Prüfe ob Remote existiert
if (!gitService.hasRemote(SERVER_SOURCE_PATH)) {
return res.json({
success: false,
error: 'Kein Remote konfiguriert'
});
}
let result;
if (force) {
// Force Push
result = gitService.pushWithUpstream(SERVER_SOURCE_PATH, branch || null, 'origin', true);
} else {
// Normaler Push
result = gitService.pushChanges(SERVER_SOURCE_PATH, { branch });
// Falls Push wegen fehlendem Upstream fehlschlägt, versuche mit -u
if (!result.success && result.error && result.error.includes('no upstream')) {
result = gitService.pushWithUpstream(SERVER_SOURCE_PATH, branch || null);
}
}
res.json(result);
} catch (error) {
logger.error('[Server-Git] Fehler beim Push:', error);
res.status(500).json({ error: 'Serverfehler' });
}
});
/**
* POST /api/git/server/pull
* Pull für Server-Dateien ausführen
*/
router.post('/server/pull', (req, res) => {
try {
const { branch } = req.body;
// Fetch zuerst
gitService.fetchRemote(SERVER_SOURCE_PATH);
// Dann Pull
const result = gitService.pullChanges(SERVER_SOURCE_PATH, { branch });
res.json(result);
} catch (error) {
logger.error('[Server-Git] Fehler beim Pull:', error);
res.status(500).json({ error: 'Serverfehler' });
}
});
/**
* POST /api/git/server/fetch
* Fetch für Server-Dateien ausführen
*/
router.post('/server/fetch', (req, res) => {
try {
const result = gitService.fetchRemote(SERVER_SOURCE_PATH);
res.json(result);
} catch (error) {
logger.error('[Server-Git] Fehler beim Fetch:', error);
res.status(500).json({ error: 'Serverfehler' });
}
});
/**
* POST /api/git/server/checkout
* Branch für Server-Dateien wechseln
*/
router.post('/server/checkout', (req, res) => {
try {
const { branch } = req.body;
if (!branch) {
return res.status(400).json({ error: 'Branch ist erforderlich' });
}
const result = gitService.checkoutBranch(SERVER_SOURCE_PATH, branch);
res.json(result);
} catch (error) {
logger.error('[Server-Git] Fehler beim Branch-Wechsel:', error);
res.status(500).json({ error: 'Serverfehler' });
}
});
/**
* GET /api/git/server/info
* Grundlegende Infos über Server-Repository
*/
router.get('/server/info', (req, res) => {
try {
const isAccessible = gitService.isPathAccessible(SERVER_SOURCE_PATH);
const isRepo = isAccessible ? gitService.isGitRepository(SERVER_SOURCE_PATH) : false;
const hasRemote = isRepo ? gitService.hasRemote(SERVER_SOURCE_PATH) : false;
let remoteUrl = null;
if (hasRemote) {
const remoteResult = gitService.getRemoteUrl(SERVER_SOURCE_PATH);
remoteUrl = remoteResult.url || null;
}
res.json({
path: SERVER_SOURCE_PATH,
hostPath: '/home/claude-dev/TaskMate',
accessible: isAccessible,
isRepository: isRepo,
hasRemote: hasRemote,
remoteUrl: remoteUrl
});
} catch (error) {
logger.error('[Server-Git] Fehler beim Info-Abruf:', error);
res.status(500).json({ error: 'Serverfehler' });
}
});
// ============================================
// BROWSER-UPLOAD ENDPOINTS
// Für lokale Verzeichnis-Uploads vom Browser
// ============================================
/**
* Hilfsfunktion: Verzeichnis rekursiv löschen
*/
function deleteFolderRecursive(dirPath) {
if (fs.existsSync(dirPath)) {
fs.readdirSync(dirPath).forEach((file) => {
const curPath = path.join(dirPath, file);
if (fs.lstatSync(curPath).isDirectory()) {
deleteFolderRecursive(curPath);
} else {
fs.unlinkSync(curPath);
}
});
fs.rmdirSync(dirPath);
}
}
/**
* POST /api/git/browser-upload
* Empfängt Dateien vom Browser und pusht sie ins Gitea
*
* Body (multipart/form-data):
* - files: Die hochgeladenen Dateien (originalname enthält relativen Pfad)
* - repoUrl: Die Gitea-Repository-URL
* - branch: Der Ziel-Branch (default: main)
* - commitMessage: Die Commit-Nachricht
* - sessionId: Eindeutige Session-ID für den Upload
*/
router.post('/browser-upload', gitUpload.array('files', 500), async (req, res) => {
const sessionId = req.body.sessionId || Date.now().toString();
const sessionDir = path.join(TEMP_UPLOAD_DIR, sessionId);
try {
const { repoUrl, branch = 'main', commitMessage } = req.body;
const files = req.files;
// Validierung
if (!repoUrl) {
return res.status(400).json({ error: 'Repository-URL ist erforderlich' });
}
if (!commitMessage) {
return res.status(400).json({ error: 'Commit-Nachricht ist erforderlich' });
}
if (!files || files.length === 0) {
return res.status(400).json({ error: 'Keine Dateien hochgeladen' });
}
logger.info(`[Browser-Upload] ${files.length} Dateien empfangen für ${repoUrl}`);
// Git-Repository initialisieren
const initResult = gitService.initRepository(sessionDir);
if (!initResult.success) {
throw new Error('Git-Initialisierung fehlgeschlagen: ' + initResult.error);
}
// Remote hinzufügen
const remoteResult = gitService.addRemote(sessionDir, repoUrl, 'origin');
if (!remoteResult.success) {
throw new Error('Remote hinzufügen fehlgeschlagen: ' + remoteResult.error);
}
// Autor aus eingeloggtem Benutzer
const author = req.user ? {
name: req.user.display_name || req.user.username,
email: req.user.email || `${req.user.username.toLowerCase()}@taskmate.local`
} : null;
// Alle Dateien stagen
const stageResult = gitService.stageAll(sessionDir);
if (!stageResult.success) {
throw new Error('Staging fehlgeschlagen: ' + stageResult.error);
}
// Commit erstellen
const commitResult = gitService.commit(sessionDir, commitMessage, author);
if (!commitResult.success) {
throw new Error('Commit fehlgeschlagen: ' + commitResult.error);
}
// Push mit Upstream
const pushResult = gitService.pushWithUpstream(sessionDir, branch, 'origin', false);
if (!pushResult.success) {
// Bei Fehler: Versuche Force-Push falls Branch existiert
if (pushResult.error && pushResult.error.includes('rejected')) {
logger.warn('[Browser-Upload] Normaler Push abgelehnt, versuche mit Force...');
const forcePushResult = gitService.pushWithUpstream(sessionDir, branch, 'origin', true);
if (!forcePushResult.success) {
throw new Error('Push fehlgeschlagen: ' + forcePushResult.error);
}
} else {
throw new Error('Push fehlgeschlagen: ' + pushResult.error);
}
}
logger.info(`[Browser-Upload] Erfolgreich nach ${repoUrl}/${branch} gepusht`);
// Erfolgreich - Aufräumen
deleteFolderRecursive(sessionDir);
res.json({
success: true,
message: `${files.length} Dateien erfolgreich nach ${branch} gepusht`,
filesCount: files.length,
branch: branch,
commit: commitResult.hash || null
});
} catch (error) {
logger.error('[Browser-Upload] Fehler:', error);
// Bei Fehler aufräumen
try {
deleteFolderRecursive(sessionDir);
} catch (cleanupError) {
logger.warn('[Browser-Upload] Aufräumen fehlgeschlagen:', cleanupError);
}
res.status(500).json({
success: false,
error: error.message || 'Upload fehlgeschlagen'
});
}
});
/**
* POST /api/git/browser-upload-prepare
* Bereitet einen Upload vor und gibt eine Session-ID zurück
*/
router.post('/browser-upload-prepare', (req, res) => {
const sessionId = `upload_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
const sessionDir = path.join(TEMP_UPLOAD_DIR, sessionId);
try {
fs.mkdirSync(sessionDir, { recursive: true });
res.json({
success: true,
sessionId: sessionId,
maxFileSize: 50 * 1024 * 1024, // 50MB
maxFiles: 500
});
} catch (error) {
logger.error('[Browser-Upload] Prepare fehlgeschlagen:', error);
res.status(500).json({ error: 'Vorbereitung fehlgeschlagen' });
}
});
/**
* DELETE /api/git/browser-upload/:sessionId
* Löscht eine Upload-Session (für Abbruch)
*/
router.delete('/browser-upload/:sessionId', (req, res) => {
const { sessionId } = req.params;
const sessionDir = path.join(TEMP_UPLOAD_DIR, sessionId);
try {
if (fs.existsSync(sessionDir)) {
deleteFolderRecursive(sessionDir);
}
res.json({ success: true });
} catch (error) {
logger.error('[Browser-Upload] Löschen fehlgeschlagen:', error);
res.status(500).json({ error: 'Löschen fehlgeschlagen' });
}
});
module.exports = router;

993
backend/routes/knowledge.js Normale Datei
Datei anzeigen

@ -0,0 +1,993 @@
/**
* TASKMATE - Knowledge Management Routes
* ======================================
* CRUD für Wissensmanagement: Kategorien, Einträge, Anhänge
*/
const express = require('express');
const router = express.Router();
const path = require('path');
const fs = require('fs');
const multer = require('multer');
const { getDb } = require('../database');
const logger = require('../utils/logger');
const { validators, stripHtml } = require('../middleware/validation');
const notificationService = require('../services/notificationService');
// Upload-Konfiguration für Knowledge-Anhänge
const UPLOAD_DIR = path.join(__dirname, '..', 'uploads', 'knowledge');
// Sicherstellen, dass das Upload-Verzeichnis existiert
if (!fs.existsSync(UPLOAD_DIR)) {
fs.mkdirSync(UPLOAD_DIR, { recursive: true });
}
const storage = multer.diskStorage({
destination: (req, file, cb) => {
cb(null, UPLOAD_DIR);
},
filename: (req, file, cb) => {
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
const ext = path.extname(file.originalname);
cb(null, `knowledge-${uniqueSuffix}${ext}`);
}
});
const upload = multer({
storage,
limits: {
fileSize: 50 * 1024 * 1024 // 50MB max
}
});
// =====================
// KATEGORIEN
// =====================
/**
* GET /api/knowledge/categories
* Alle Kategorien abrufen
*/
router.get('/categories', (req, res) => {
try {
const db = getDb();
const categories = db.prepare(`
SELECT kc.*,
(SELECT COUNT(*) FROM knowledge_entries WHERE category_id = kc.id) as entry_count,
u.display_name as creator_name
FROM knowledge_categories kc
LEFT JOIN users u ON kc.created_by = u.id
ORDER BY kc.position, kc.created_at
`).all();
res.json(categories.map(c => ({
id: c.id,
name: c.name,
description: c.description,
color: c.color,
icon: c.icon,
position: c.position,
entryCount: c.entry_count,
createdBy: c.created_by,
creatorName: c.creator_name,
createdAt: c.created_at
})));
} catch (error) {
logger.error('Fehler beim Abrufen der Knowledge-Kategorien:', { error: error.message });
res.status(500).json({ error: 'Interner Serverfehler' });
}
});
/**
* POST /api/knowledge/categories
* Neue Kategorie erstellen
*/
router.post('/categories', (req, res) => {
try {
const { name, description, color, icon } = req.body;
// Validierung
const nameError = validators.required(name, 'Name') ||
validators.maxLength(name, 50, 'Name');
if (nameError) {
return res.status(400).json({ error: nameError });
}
if (color) {
const colorError = validators.hexColor(color, 'Farbe');
if (colorError) {
return res.status(400).json({ error: colorError });
}
}
const db = getDb();
// Duplikat-Prüfung
const existing = db.prepare(
'SELECT id FROM knowledge_categories WHERE LOWER(name) = LOWER(?)'
).get(name);
if (existing) {
return res.status(400).json({ error: 'Eine Kategorie mit diesem Namen existiert bereits' });
}
// Alle bestehenden Kategorien um 1 nach unten verschieben
db.prepare(`
UPDATE knowledge_categories
SET position = position + 1
`).run();
// Neue Kategorie an Position 0 (ganz oben) einfügen
const result = db.prepare(`
INSERT INTO knowledge_categories (name, description, color, icon, position, created_by)
VALUES (?, ?, ?, ?, ?, ?)
`).run(
stripHtml(name),
description ? stripHtml(description) : null,
color || '#3B82F6',
icon || null,
0, // Neue Kategorien immer an Position 0 (oben)
req.user.id
);
const category = db.prepare('SELECT * FROM knowledge_categories WHERE id = ?')
.get(result.lastInsertRowid);
logger.info(`Knowledge-Kategorie erstellt: ${name}`);
res.status(201).json({
id: category.id,
name: category.name,
description: category.description,
color: category.color,
icon: category.icon,
position: category.position,
entryCount: 0,
createdBy: category.created_by,
createdAt: category.created_at
});
} catch (error) {
logger.error('Fehler beim Erstellen der Knowledge-Kategorie:', { error: error.message });
res.status(500).json({ error: 'Interner Serverfehler' });
}
});
/**
* PUT /api/knowledge/categories/:id
* Kategorie aktualisieren
*/
router.put('/categories/:id', (req, res) => {
try {
const categoryId = req.params.id;
const { name, description, color, icon } = req.body;
const db = getDb();
const existing = db.prepare('SELECT * FROM knowledge_categories WHERE id = ?').get(categoryId);
if (!existing) {
return res.status(404).json({ error: 'Kategorie nicht gefunden' });
}
// Validierung
if (name) {
const nameError = validators.maxLength(name, 50, 'Name');
if (nameError) {
return res.status(400).json({ error: nameError });
}
// Duplikat-Prüfung (ausser eigene)
const duplicate = db.prepare(
'SELECT id FROM knowledge_categories WHERE LOWER(name) = LOWER(?) AND id != ?'
).get(name, categoryId);
if (duplicate) {
return res.status(400).json({ error: 'Eine Kategorie mit diesem Namen existiert bereits' });
}
}
if (color) {
const colorError = validators.hexColor(color, 'Farbe');
if (colorError) {
return res.status(400).json({ error: colorError });
}
}
db.prepare(`
UPDATE knowledge_categories SET
name = COALESCE(?, name),
description = COALESCE(?, description),
color = COALESCE(?, color),
icon = COALESCE(?, icon)
WHERE id = ?
`).run(
name ? stripHtml(name) : null,
description !== undefined ? (description ? stripHtml(description) : '') : null,
color || null,
icon !== undefined ? icon : null,
categoryId
);
const category = db.prepare('SELECT * FROM knowledge_categories WHERE id = ?').get(categoryId);
logger.info(`Knowledge-Kategorie aktualisiert: ${category.name}`);
res.json({
id: category.id,
name: category.name,
description: category.description,
color: category.color,
icon: category.icon,
position: category.position,
createdBy: category.created_by,
createdAt: category.created_at
});
} catch (error) {
logger.error('Fehler beim Aktualisieren der Knowledge-Kategorie:', { error: error.message });
res.status(500).json({ error: 'Interner Serverfehler' });
}
});
/**
* PUT /api/knowledge/categories/:id/position
* Kategorie-Position ändern
*/
router.put('/categories/:id/position', (req, res) => {
try {
const categoryId = req.params.id;
const { newPosition } = req.body;
const db = getDb();
const category = db.prepare('SELECT * FROM knowledge_categories WHERE id = ?').get(categoryId);
if (!category) {
return res.status(404).json({ error: 'Kategorie nicht gefunden' });
}
const oldPosition = category.position;
if (newPosition > oldPosition) {
db.prepare(`
UPDATE knowledge_categories SET position = position - 1
WHERE position > ? AND position <= ?
`).run(oldPosition, newPosition);
} else if (newPosition < oldPosition) {
db.prepare(`
UPDATE knowledge_categories SET position = position + 1
WHERE position >= ? AND position < ?
`).run(newPosition, oldPosition);
}
db.prepare('UPDATE knowledge_categories SET position = ? WHERE id = ?').run(newPosition, categoryId);
const categories = db.prepare(
'SELECT * FROM knowledge_categories ORDER BY position'
).all();
res.json({
categories: categories.map(c => ({
id: c.id,
name: c.name,
position: c.position
}))
});
} catch (error) {
logger.error('Fehler beim Verschieben der Knowledge-Kategorie:', { error: error.message });
res.status(500).json({ error: 'Interner Serverfehler' });
}
});
/**
* DELETE /api/knowledge/categories/:id
* Kategorie löschen (inkl. aller Einträge und Anhänge)
*/
router.delete('/categories/:id', (req, res) => {
try {
const categoryId = req.params.id;
const db = getDb();
const category = db.prepare('SELECT * FROM knowledge_categories WHERE id = ?').get(categoryId);
if (!category) {
return res.status(404).json({ error: 'Kategorie nicht gefunden' });
}
// Anhänge von allen Einträgen dieser Kategorie löschen
const entries = db.prepare('SELECT id FROM knowledge_entries WHERE category_id = ?').all(categoryId);
for (const entry of entries) {
const attachments = db.prepare('SELECT * FROM knowledge_attachments WHERE entry_id = ?').all(entry.id);
for (const attachment of attachments) {
const filePath = path.join(UPLOAD_DIR, attachment.filename);
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
}
}
// Kategorie löschen (CASCADE löscht Einträge und Anhänge aus DB)
db.prepare('DELETE FROM knowledge_categories WHERE id = ?').run(categoryId);
// Positionen neu nummerieren
const remaining = db.prepare(
'SELECT id FROM knowledge_categories ORDER BY position'
).all();
remaining.forEach((c, idx) => {
db.prepare('UPDATE knowledge_categories SET position = ? WHERE id = ?').run(idx, c.id);
});
logger.info(`Knowledge-Kategorie gelöscht: ${category.name}`);
res.json({ message: 'Kategorie gelöscht' });
} catch (error) {
logger.error('Fehler beim Löschen der Knowledge-Kategorie:', { error: error.message });
res.status(500).json({ error: 'Interner Serverfehler' });
}
});
// =====================
// EINTRÄGE
// =====================
/**
* GET /api/knowledge/entries
* Einträge abrufen (optional gefiltert nach Kategorie)
*/
router.get('/entries', (req, res) => {
try {
const { categoryId } = req.query;
const db = getDb();
let query = `
SELECT ke.*,
kc.name as category_name,
kc.color as category_color,
u.display_name as creator_name,
(SELECT COUNT(*) FROM knowledge_attachments WHERE entry_id = ke.id) as attachment_count
FROM knowledge_entries ke
LEFT JOIN knowledge_categories kc ON ke.category_id = kc.id
LEFT JOIN users u ON ke.created_by = u.id
`;
const params = [];
if (categoryId) {
query += ' WHERE ke.category_id = ?';
params.push(categoryId);
}
query += ' ORDER BY ke.position, ke.created_at DESC';
const entries = db.prepare(query).all(...params);
res.json(entries.map(e => ({
id: e.id,
categoryId: e.category_id,
categoryName: e.category_name,
categoryColor: e.category_color,
title: e.title,
url: e.url,
notes: e.notes,
position: e.position,
attachmentCount: e.attachment_count,
createdBy: e.created_by,
creatorName: e.creator_name,
createdAt: e.created_at,
updatedAt: e.updated_at
})));
} catch (error) {
logger.error('Fehler beim Abrufen der Knowledge-Einträge:', { error: error.message });
res.status(500).json({ error: 'Interner Serverfehler' });
}
});
/**
* GET /api/knowledge/entries/:id
* Einzelnen Eintrag abrufen (mit Anhängen)
*/
router.get('/entries/:id', (req, res) => {
try {
const entryId = req.params.id;
const db = getDb();
const entry = db.prepare(`
SELECT ke.*,
kc.name as category_name,
kc.color as category_color,
u.display_name as creator_name
FROM knowledge_entries ke
LEFT JOIN knowledge_categories kc ON ke.category_id = kc.id
LEFT JOIN users u ON ke.created_by = u.id
WHERE ke.id = ?
`).get(entryId);
if (!entry) {
return res.status(404).json({ error: 'Eintrag nicht gefunden' });
}
const attachments = db.prepare(`
SELECT ka.*, u.display_name as uploader_name
FROM knowledge_attachments ka
LEFT JOIN users u ON ka.uploaded_by = u.id
WHERE ka.entry_id = ?
ORDER BY ka.uploaded_at DESC
`).all(entryId);
res.json({
id: entry.id,
categoryId: entry.category_id,
categoryName: entry.category_name,
categoryColor: entry.category_color,
title: entry.title,
url: entry.url,
notes: entry.notes,
position: entry.position,
createdBy: entry.created_by,
creatorName: entry.creator_name,
createdAt: entry.created_at,
updatedAt: entry.updated_at,
attachments: attachments.map(a => ({
id: a.id,
filename: a.filename,
originalName: a.original_name,
mimeType: a.mime_type,
sizeBytes: a.size_bytes,
uploadedBy: a.uploaded_by,
uploaderName: a.uploader_name,
uploadedAt: a.uploaded_at
}))
});
} catch (error) {
logger.error('Fehler beim Abrufen des Knowledge-Eintrags:', { error: error.message });
res.status(500).json({ error: 'Interner Serverfehler' });
}
});
/**
* POST /api/knowledge/entries
* Neuen Eintrag erstellen
*/
router.post('/entries', (req, res) => {
try {
const { categoryId, title, url, notes } = req.body;
// Validierung
const categoryError = validators.required(categoryId, 'Kategorie');
if (categoryError) {
return res.status(400).json({ error: categoryError });
}
const titleError = validators.required(title, 'Titel') ||
validators.maxLength(title, 200, 'Titel');
if (titleError) {
return res.status(400).json({ error: titleError });
}
if (url) {
const urlError = validators.url(url, 'URL');
if (urlError) {
return res.status(400).json({ error: urlError });
}
}
const db = getDb();
// Kategorie prüfen
const category = db.prepare('SELECT * FROM knowledge_categories WHERE id = ?').get(categoryId);
if (!category) {
return res.status(404).json({ error: 'Kategorie nicht gefunden' });
}
// Alle bestehenden Einträge um 1 nach unten verschieben
db.prepare(`
UPDATE knowledge_entries
SET position = position + 1
WHERE category_id = ?
`).run(categoryId);
// Neuen Eintrag an Position 0 (ganz oben) einfügen
const result = db.prepare(`
INSERT INTO knowledge_entries (category_id, title, url, notes, position, created_by)
VALUES (?, ?, ?, ?, ?, ?)
`).run(
categoryId,
stripHtml(title),
url || null,
notes || null,
0, // Neue Einträge immer an Position 0 (oben)
req.user.id
);
const entry = db.prepare('SELECT * FROM knowledge_entries WHERE id = ?')
.get(result.lastInsertRowid);
logger.info(`Knowledge-Eintrag erstellt: ${title}`);
// Benachrichtigung an alle Nutzer senden
const io = req.app.get('io');
if (io) {
// Alle Nutzer abrufen (außer dem Ersteller)
const users = db.prepare(`
SELECT id FROM users
WHERE id != ?
`).all(req.user.id);
// Benachrichtigung für jeden Nutzer erstellen
users.forEach(user => {
notificationService.create(
user.id,
'knowledge:new_entry',
{
entryId: entry.id,
entryTitle: entry.title,
categoryName: category.name,
categoryId: category.id,
projectId: null,
actorId: req.user.id
},
io,
false // nicht persistent
);
});
// Socket.io Event für Echtzeit-Update senden
io.emit('knowledge:created', {
entry: {
id: entry.id,
categoryId: entry.category_id,
categoryName: category.name,
categoryColor: category.color,
title: entry.title,
url: entry.url,
notes: entry.notes,
position: entry.position,
attachmentCount: 0,
createdBy: entry.created_by,
creatorName: req.user.display_name,
createdAt: entry.created_at,
updatedAt: entry.updated_at
}
});
}
res.status(201).json({
id: entry.id,
categoryId: entry.category_id,
title: entry.title,
url: entry.url,
notes: entry.notes,
position: entry.position,
attachmentCount: 0,
createdBy: entry.created_by,
createdAt: entry.created_at,
updatedAt: entry.updated_at
});
} catch (error) {
logger.error('Fehler beim Erstellen des Knowledge-Eintrags:', { error: error.message });
res.status(500).json({ error: 'Interner Serverfehler' });
}
});
/**
* PUT /api/knowledge/entries/:id
* Eintrag aktualisieren
*/
router.put('/entries/:id', (req, res) => {
try {
const entryId = req.params.id;
const { categoryId, title, url, notes } = req.body;
const db = getDb();
const existing = db.prepare('SELECT * FROM knowledge_entries WHERE id = ?').get(entryId);
if (!existing) {
return res.status(404).json({ error: 'Eintrag nicht gefunden' });
}
// Validierung
if (title) {
const titleError = validators.maxLength(title, 200, 'Titel');
if (titleError) {
return res.status(400).json({ error: titleError });
}
}
if (url) {
const urlError = validators.url(url, 'URL');
if (urlError) {
return res.status(400).json({ error: urlError });
}
}
if (categoryId) {
const category = db.prepare('SELECT * FROM knowledge_categories WHERE id = ?').get(categoryId);
if (!category) {
return res.status(404).json({ error: 'Kategorie nicht gefunden' });
}
}
db.prepare(`
UPDATE knowledge_entries SET
category_id = COALESCE(?, category_id),
title = COALESCE(?, title),
url = ?,
notes = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`).run(
categoryId || null,
title ? stripHtml(title) : null,
url !== undefined ? url : existing.url,
notes !== undefined ? notes : existing.notes,
entryId
);
const entry = db.prepare(`
SELECT ke.*, kc.name as category_name, kc.color as category_color
FROM knowledge_entries ke
LEFT JOIN knowledge_categories kc ON ke.category_id = kc.id
WHERE ke.id = ?
`).get(entryId);
logger.info(`Knowledge-Eintrag aktualisiert: ${entry.title}`);
res.json({
id: entry.id,
categoryId: entry.category_id,
categoryName: entry.category_name,
categoryColor: entry.category_color,
title: entry.title,
url: entry.url,
notes: entry.notes,
position: entry.position,
createdBy: entry.created_by,
createdAt: entry.created_at,
updatedAt: entry.updated_at
});
} catch (error) {
logger.error('Fehler beim Aktualisieren des Knowledge-Eintrags:', { error: error.message });
res.status(500).json({ error: 'Interner Serverfehler' });
}
});
/**
* PUT /api/knowledge/entries/:id/position
* Eintrag-Position ändern (oder in andere Kategorie verschieben)
*/
router.put('/entries/:id/position', (req, res) => {
try {
const entryId = req.params.id;
const { newPosition, newCategoryId } = req.body;
const db = getDb();
const entry = db.prepare('SELECT * FROM knowledge_entries WHERE id = ?').get(entryId);
if (!entry) {
return res.status(404).json({ error: 'Eintrag nicht gefunden' });
}
const oldPosition = entry.position;
const oldCategoryId = entry.category_id;
const targetCategoryId = newCategoryId || oldCategoryId;
// Wenn Kategorie wechselt
if (targetCategoryId !== oldCategoryId) {
// Alte Kategorie: Positionen nach dem entfernten Eintrag reduzieren
db.prepare(`
UPDATE knowledge_entries SET position = position - 1
WHERE category_id = ? AND position > ?
`).run(oldCategoryId, oldPosition);
// Neue Kategorie: Platz für neuen Eintrag schaffen
db.prepare(`
UPDATE knowledge_entries SET position = position + 1
WHERE category_id = ? AND position >= ?
`).run(targetCategoryId, newPosition);
// Eintrag verschieben
db.prepare(`
UPDATE knowledge_entries SET category_id = ?, position = ?
WHERE id = ?
`).run(targetCategoryId, newPosition, entryId);
} else {
// Innerhalb der gleichen Kategorie
if (newPosition > oldPosition) {
db.prepare(`
UPDATE knowledge_entries SET position = position - 1
WHERE category_id = ? AND position > ? AND position <= ?
`).run(oldCategoryId, oldPosition, newPosition);
} else if (newPosition < oldPosition) {
db.prepare(`
UPDATE knowledge_entries SET position = position + 1
WHERE category_id = ? AND position >= ? AND position < ?
`).run(oldCategoryId, newPosition, oldPosition);
}
db.prepare('UPDATE knowledge_entries SET position = ? WHERE id = ?').run(newPosition, entryId);
}
res.json({ message: 'Position aktualisiert' });
} catch (error) {
logger.error('Fehler beim Verschieben des Knowledge-Eintrags:', { error: error.message });
res.status(500).json({ error: 'Interner Serverfehler' });
}
});
/**
* DELETE /api/knowledge/entries/:id
* Eintrag löschen
*/
router.delete('/entries/:id', (req, res) => {
try {
const entryId = req.params.id;
const db = getDb();
const entry = db.prepare('SELECT * FROM knowledge_entries WHERE id = ?').get(entryId);
if (!entry) {
return res.status(404).json({ error: 'Eintrag nicht gefunden' });
}
// Anhänge vom Dateisystem löschen
const attachments = db.prepare('SELECT * FROM knowledge_attachments WHERE entry_id = ?').all(entryId);
for (const attachment of attachments) {
const filePath = path.join(UPLOAD_DIR, attachment.filename);
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
}
const categoryId = entry.category_id;
// Eintrag löschen
db.prepare('DELETE FROM knowledge_entries WHERE id = ?').run(entryId);
// Positionen neu nummerieren
const remaining = db.prepare(
'SELECT id FROM knowledge_entries WHERE category_id = ? ORDER BY position'
).all(categoryId);
remaining.forEach((e, idx) => {
db.prepare('UPDATE knowledge_entries SET position = ? WHERE id = ?').run(idx, e.id);
});
logger.info(`Knowledge-Eintrag gelöscht: ${entry.title}`);
res.json({ message: 'Eintrag gelöscht' });
} catch (error) {
logger.error('Fehler beim Löschen des Knowledge-Eintrags:', { error: error.message });
res.status(500).json({ error: 'Interner Serverfehler' });
}
});
// =====================
// ANHÄNGE
// =====================
/**
* GET /api/knowledge/attachments/:entryId
* Anhänge eines Eintrags abrufen
*/
router.get('/attachments/:entryId', (req, res) => {
try {
const entryId = req.params.entryId;
const db = getDb();
const entry = db.prepare('SELECT * FROM knowledge_entries WHERE id = ?').get(entryId);
if (!entry) {
return res.status(404).json({ error: 'Eintrag nicht gefunden' });
}
const attachments = db.prepare(`
SELECT ka.*, u.display_name as uploader_name
FROM knowledge_attachments ka
LEFT JOIN users u ON ka.uploaded_by = u.id
WHERE ka.entry_id = ?
ORDER BY ka.uploaded_at DESC
`).all(entryId);
res.json(attachments.map(a => ({
id: a.id,
entryId: a.entry_id,
filename: a.filename,
originalName: a.original_name,
mimeType: a.mime_type,
sizeBytes: a.size_bytes,
uploadedBy: a.uploaded_by,
uploaderName: a.uploader_name,
uploadedAt: a.uploaded_at
})));
} catch (error) {
logger.error('Fehler beim Abrufen der Knowledge-Anhänge:', { error: error.message });
res.status(500).json({ error: 'Interner Serverfehler' });
}
});
/**
* POST /api/knowledge/attachments/:entryId
* Anhang hochladen
*/
router.post('/attachments/:entryId', upload.single('files'), (req, res) => {
try {
const entryId = req.params.entryId;
const db = getDb();
const entry = db.prepare('SELECT * FROM knowledge_entries WHERE id = ?').get(entryId);
if (!entry) {
// Hochgeladene Datei löschen
if (req.file) {
fs.unlinkSync(req.file.path);
}
return res.status(404).json({ error: 'Eintrag nicht gefunden' });
}
if (!req.file) {
return res.status(400).json({ error: 'Keine Datei hochgeladen' });
}
const result = db.prepare(`
INSERT INTO knowledge_attachments (entry_id, filename, original_name, mime_type, size_bytes, uploaded_by)
VALUES (?, ?, ?, ?, ?, ?)
`).run(
entryId,
req.file.filename,
req.file.originalname,
req.file.mimetype,
req.file.size,
req.user.id
);
// Eintrag updated_at aktualisieren
db.prepare('UPDATE knowledge_entries SET updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(entryId);
const attachment = db.prepare('SELECT * FROM knowledge_attachments WHERE id = ?')
.get(result.lastInsertRowid);
logger.info(`Knowledge-Anhang hochgeladen: ${req.file.originalname} für Eintrag ${entry.title}`);
res.status(201).json({
id: attachment.id,
entryId: attachment.entry_id,
filename: attachment.filename,
originalName: attachment.original_name,
mimeType: attachment.mime_type,
sizeBytes: attachment.size_bytes,
uploadedBy: attachment.uploaded_by,
uploadedAt: attachment.uploaded_at
});
} catch (error) {
logger.error('Fehler beim Hochladen des Knowledge-Anhangs:', { error: error.message });
res.status(500).json({ error: 'Interner Serverfehler' });
}
});
/**
* GET /api/knowledge/attachments/download/:id
* Anhang herunterladen
*/
router.get('/attachments/download/:id', (req, res) => {
try {
const attachmentId = req.params.id;
const db = getDb();
const attachment = db.prepare('SELECT * FROM knowledge_attachments WHERE id = ?').get(attachmentId);
if (!attachment) {
return res.status(404).json({ error: 'Anhang nicht gefunden' });
}
const filePath = path.join(UPLOAD_DIR, attachment.filename);
if (!fs.existsSync(filePath)) {
return res.status(404).json({ error: 'Datei nicht gefunden' });
}
res.download(filePath, attachment.original_name);
} catch (error) {
logger.error('Fehler beim Herunterladen des Knowledge-Anhangs:', { error: error.message });
res.status(500).json({ error: 'Interner Serverfehler' });
}
});
/**
* DELETE /api/knowledge/attachments/:id
* Anhang löschen
*/
router.delete('/attachments/:id', (req, res) => {
try {
const attachmentId = req.params.id;
const db = getDb();
const attachment = db.prepare('SELECT * FROM knowledge_attachments WHERE id = ?').get(attachmentId);
if (!attachment) {
return res.status(404).json({ error: 'Anhang nicht gefunden' });
}
// Datei vom Dateisystem löschen
const filePath = path.join(UPLOAD_DIR, attachment.filename);
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
// Aus Datenbank löschen
db.prepare('DELETE FROM knowledge_attachments WHERE id = ?').run(attachmentId);
// Eintrag updated_at aktualisieren
db.prepare('UPDATE knowledge_entries SET updated_at = CURRENT_TIMESTAMP WHERE id = ?')
.run(attachment.entry_id);
logger.info(`Knowledge-Anhang gelöscht: ${attachment.original_name}`);
res.json({ message: 'Anhang gelöscht' });
} catch (error) {
logger.error('Fehler beim Löschen des Knowledge-Anhangs:', { error: error.message });
res.status(500).json({ error: 'Interner Serverfehler' });
}
});
// =====================
// SUCHE
// =====================
/**
* GET /api/knowledge/search
* Wissensmanagement durchsuchen
*/
router.get('/search', (req, res) => {
try {
const { q } = req.query;
if (!q || q.trim().length < 2) {
return res.json({ categories: [], entries: [] });
}
const searchTerm = `%${q.toLowerCase()}%`;
const db = getDb();
// Kategorien durchsuchen
const categories = db.prepare(`
SELECT kc.*,
(SELECT COUNT(*) FROM knowledge_entries WHERE category_id = kc.id) as entry_count
FROM knowledge_categories kc
WHERE LOWER(kc.name) LIKE ? OR LOWER(kc.description) LIKE ?
ORDER BY kc.position
`).all(searchTerm, searchTerm);
// Einträge durchsuchen
const entries = db.prepare(`
SELECT ke.*,
kc.name as category_name,
kc.color as category_color,
(SELECT COUNT(*) FROM knowledge_attachments WHERE entry_id = ke.id) as attachment_count
FROM knowledge_entries ke
LEFT JOIN knowledge_categories kc ON ke.category_id = kc.id
WHERE LOWER(ke.title) LIKE ? OR LOWER(ke.notes) LIKE ? OR LOWER(ke.url) LIKE ?
ORDER BY
CASE
WHEN LOWER(ke.title) LIKE ? THEN 1
ELSE 2
END,
ke.created_at DESC
LIMIT 50
`).all(searchTerm, searchTerm, searchTerm, searchTerm);
res.json({
categories: categories.map(c => ({
id: c.id,
name: c.name,
description: c.description,
color: c.color,
icon: c.icon,
entryCount: c.entry_count
})),
entries: entries.map(e => ({
id: e.id,
categoryId: e.category_id,
categoryName: e.category_name,
categoryColor: e.category_color,
title: e.title,
url: e.url,
notes: e.notes,
attachmentCount: e.attachment_count,
createdAt: e.created_at
}))
});
} catch (error) {
logger.error('Fehler bei der Knowledge-Suche:', { error: error.message });
res.status(500).json({ error: 'Interner Serverfehler' });
}
});
module.exports = router;

360
backend/routes/reminders.js Normale Datei
Datei anzeigen

@ -0,0 +1,360 @@
/**
* TASKMATE - Reminders API
* ========================
* API endpoints für Erinnerungen
*/
const express = require('express');
const router = express.Router();
const { getDb } = require('../database');
const reminderService = require('../services/reminderService');
// GET /api/reminders - Alle Erinnerungen für ein Projekt abrufen
router.get('/', (req, res) => {
try {
const { project_id } = req.query;
const db = getDb();
if (!project_id) {
return res.status(400).json({
success: false,
error: 'project_id ist erforderlich'
});
}
const reminders = db.prepare(`
SELECT r.*, u.display_name as creator_name
FROM reminders r
LEFT JOIN users u ON r.created_by = u.id
WHERE r.project_id = ? AND r.is_active = 1
ORDER BY r.reminder_date ASC, r.reminder_time ASC
`).all(project_id);
// Advance days von String zu Array konvertieren
reminders.forEach(reminder => {
reminder.advance_days = reminder.advance_days ? reminder.advance_days.split(',') : ['1'];
});
res.json({
success: true,
data: reminders
});
} catch (error) {
console.error('Error fetching reminders:', error);
res.status(500).json({
success: false,
error: 'Interner Server-Fehler'
});
}
});
// GET /api/reminders/:id - Einzelne Erinnerung abrufen
router.get('/:id', (req, res) => {
try {
const db = getDb();
const reminder = db.prepare(`
SELECT r.*, u.display_name as creator_name
FROM reminders r
LEFT JOIN users u ON r.created_by = u.id
WHERE r.id = ?
`).get(req.params.id);
if (!reminder) {
return res.status(404).json({
success: false,
error: 'Erinnerung nicht gefunden'
});
}
// Advance days von String zu Array konvertieren
reminder.advance_days = reminder.advance_days ? reminder.advance_days.split(',') : ['1'];
res.json({
success: true,
data: reminder
});
} catch (error) {
console.error('Error fetching reminder:', error);
res.status(500).json({
success: false,
error: 'Interner Server-Fehler'
});
}
});
// POST /api/reminders - Neue Erinnerung erstellen
router.post('/', (req, res) => {
try {
const {
project_id,
title,
description,
reminder_date,
reminder_time,
color,
advance_days,
repeat_type,
repeat_interval
} = req.body;
if (!project_id || !title || !reminder_date) {
return res.status(400).json({
success: false,
error: 'project_id, title und reminder_date sind erforderlich'
});
}
const db = getDb();
// Advance days Array zu String konvertieren
const advanceDaysStr = Array.isArray(advance_days) ? advance_days.join(',') : '1';
const result = db.prepare(`
INSERT INTO reminders (
project_id, title, description, reminder_date, reminder_time,
color, advance_days, repeat_type, repeat_interval, created_by
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
project_id,
title,
description || null,
reminder_date,
reminder_time || '09:00',
color || '#F59E0B',
advanceDaysStr,
repeat_type || 'none',
repeat_interval || 1,
req.user.id
);
// Benachrichtigungs-Termine mit ReminderService erstellen
if (advance_days && Array.isArray(advance_days)) {
const serviceInstance = reminderService.getInstance();
serviceInstance.createNotificationSchedule(result.lastInsertRowid, reminder_date, advance_days);
}
// Neue Erinnerung abrufen
const newReminder = db.prepare(`
SELECT r.*, u.display_name as creator_name
FROM reminders r
LEFT JOIN users u ON r.created_by = u.id
WHERE r.id = ?
`).get(result.lastInsertRowid);
newReminder.advance_days = newReminder.advance_days ? newReminder.advance_days.split(',') : ['1'];
res.status(201).json({
success: true,
data: newReminder
});
} catch (error) {
console.error('Error creating reminder:', error);
res.status(500).json({
success: false,
error: 'Interner Server-Fehler'
});
}
});
// PUT /api/reminders/:id - Erinnerung bearbeiten
router.put('/:id', (req, res) => {
try {
const {
title,
description,
reminder_date,
reminder_time,
color,
advance_days,
repeat_type,
repeat_interval,
is_active
} = req.body;
const db = getDb();
// Prüfen ob Erinnerung existiert
const existing = db.prepare('SELECT * FROM reminders WHERE id = ?').get(req.params.id);
if (!existing) {
return res.status(404).json({
success: false,
error: 'Erinnerung nicht gefunden'
});
}
// Advance days Array zu String konvertieren
const advanceDaysStr = Array.isArray(advance_days) ? advance_days.join(',') : existing.advance_days;
db.prepare(`
UPDATE reminders SET
title = ?,
description = ?,
reminder_date = ?,
reminder_time = ?,
color = ?,
advance_days = ?,
repeat_type = ?,
repeat_interval = ?,
is_active = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`).run(
title || existing.title,
description !== undefined ? description : existing.description,
reminder_date || existing.reminder_date,
reminder_time || existing.reminder_time,
color || existing.color,
advanceDaysStr,
repeat_type || existing.repeat_type,
repeat_interval || existing.repeat_interval,
is_active !== undefined ? is_active : existing.is_active,
req.params.id
);
// Benachrichtigungs-Termine neu berechnen wenn sich das Datum geändert hat
if (reminder_date || advance_days) {
const finalAdvanceDays = advance_days || existing.advance_days.split(',');
const finalReminderDate = reminder_date || existing.reminder_date;
const serviceInstance = reminderService.getInstance();
serviceInstance.createNotificationSchedule(req.params.id, finalReminderDate, finalAdvanceDays);
}
// Aktualisierte Erinnerung abrufen
const updatedReminder = db.prepare(`
SELECT r.*, u.display_name as creator_name
FROM reminders r
LEFT JOIN users u ON r.created_by = u.id
WHERE r.id = ?
`).get(req.params.id);
updatedReminder.advance_days = updatedReminder.advance_days ? updatedReminder.advance_days.split(',') : ['1'];
res.json({
success: true,
data: updatedReminder
});
} catch (error) {
console.error('Error updating reminder:', error);
res.status(500).json({
success: false,
error: 'Interner Server-Fehler'
});
}
});
// DELETE /api/reminders/:id - Erinnerung löschen
router.delete('/:id', (req, res) => {
try {
const db = getDb();
const result = db.prepare('DELETE FROM reminders WHERE id = ?').run(req.params.id);
if (result.changes === 0) {
return res.status(404).json({
success: false,
error: 'Erinnerung nicht gefunden'
});
}
res.json({
success: true,
message: 'Erinnerung erfolgreich gelöscht'
});
} catch (error) {
console.error('Error deleting reminder:', error);
res.status(500).json({
success: false,
error: 'Interner Server-Fehler'
});
}
});
// GET /api/reminders/due/check - Fällige Erinnerungen prüfen (für Cron-Job)
router.get('/due/check', (req, res) => {
try {
const db = getDb();
const today = new Date().toISOString().split('T')[0];
// Fällige Benachrichtigungen finden
const dueNotifications = db.prepare(`
SELECT rn.*, r.title, r.description, r.project_id, r.created_by, r.reminder_date, r.color
FROM reminder_notifications rn
JOIN reminders r ON rn.reminder_id = r.id
WHERE rn.notification_date <= ? AND rn.sent = 0 AND r.is_active = 1
ORDER BY rn.notification_date ASC
`).all(today);
res.json({
success: true,
data: dueNotifications
});
} catch (error) {
console.error('Error checking due reminders:', error);
res.status(500).json({
success: false,
error: 'Interner Server-Fehler'
});
}
});
// POST /api/reminders/due/mark-sent - Benachrichtigung als gesendet markieren
router.post('/due/mark-sent', (req, res) => {
try {
const { notification_id } = req.body;
const db = getDb();
db.prepare('UPDATE reminder_notifications SET sent = 1 WHERE id = ?').run(notification_id);
res.json({
success: true,
message: 'Benachrichtigung als gesendet markiert'
});
} catch (error) {
console.error('Error marking notification as sent:', error);
res.status(500).json({
success: false,
error: 'Interner Server-Fehler'
});
}
});
// GET /api/reminders/stats - Debug-Statistiken für Reminder Service
router.get('/stats', (req, res) => {
try {
const serviceInstance = reminderService.getInstance();
const stats = serviceInstance.getStats();
res.json({
success: true,
data: stats
});
} catch (error) {
console.error('Error getting reminder stats:', error);
res.status(500).json({
success: false,
error: 'Interner Server-Fehler'
});
}
});
// POST /api/reminders/check-now - Manuelle Prüfung fälliger Erinnerungen
router.post('/check-now', async (req, res) => {
try {
const serviceInstance = reminderService.getInstance();
await serviceInstance.manualCheck();
res.json({
success: true,
message: 'Manuelle Reminder-Prüfung durchgeführt'
});
} catch (error) {
console.error('Error during manual reminder check:', error);
res.status(500).json({
success: false,
error: 'Interner Server-Fehler'
});
}
});
module.exports = router;

Datei anzeigen

@ -57,16 +57,16 @@ router.post('/', (req, res) => {
return res.status(404).json({ error: 'Aufgabe nicht gefunden' });
}
// Höchste Position ermitteln
const maxPos = db.prepare(
'SELECT COALESCE(MAX(position), -1) as max FROM subtasks WHERE task_id = ?'
).get(taskId).max;
// Alle bestehenden Subtasks um eine Position nach unten verschieben
db.prepare(`
UPDATE subtasks SET position = position + 1 WHERE task_id = ?
`).run(taskId);
// Subtask erstellen
// Neue Subtask an Position 0 erstellen (immer an erster Stelle)
const result = db.prepare(`
INSERT INTO subtasks (task_id, title, position)
VALUES (?, ?, ?)
`).run(taskId, title, maxPos + 1);
VALUES (?, ?, 0)
`).run(taskId, title);
// Task updated_at aktualisieren
db.prepare('UPDATE tasks SET updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(taskId);

Datei anzeigen

@ -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');
@ -38,9 +41,13 @@ const adminRoutes = require('./routes/admin');
const proposalRoutes = require('./routes/proposals');
const notificationRoutes = require('./routes/notifications');
const notificationService = require('./services/notificationService');
const reminderService = require('./services/reminderService');
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');
const reminderRoutes = require('./routes/reminders');
// Express App erstellen
const app = express();
@ -58,17 +65,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"
}
}));
@ -85,6 +93,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();
@ -96,8 +108,17 @@ app.use((req, res, next) => {
next();
});
// Statische Dateien (Frontend)
app.use(express.static(path.join(__dirname, 'public')));
// Statische Dateien (Frontend) - ohne Caching für Development
app.use(express.static(path.join(__dirname, 'public'), {
etag: false,
lastModified: false,
cacheControl: false,
setHeaders: (res, path) => {
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate');
res.setHeader('Pragma', 'no-cache');
res.setHeader('Expires', '0');
}
}));
// Uploads-Ordner
app.use('/uploads', authenticateToken, express.static(process.env.UPLOAD_DIR || path.join(__dirname, 'uploads')));
@ -144,6 +165,18 @@ app.use('/api/applications', authenticateToken, csrfProtection, applicationsRout
// Gitea-Routes (Gitea API Integration)
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);
// Reminder-Routes (Erinnerungen)
app.use('/api/reminders', authenticateToken, csrfProtection, reminderRoutes);
// Contacts-Routes (Kontakte)
app.use('/api/contacts', authenticateToken, csrfProtection, require('./routes/contacts'));
// =============================================================================
// SOCKET.IO
// =============================================================================
@ -280,6 +313,10 @@ database.initialize()
notificationService.checkDueTasks(io);
logger.info('Fälligkeits-Check für Benachrichtigungen gestartet');
}, 60 * 1000);
// Reminder Service starten
const reminderServiceInstance = reminderService.getInstance(io);
reminderServiceInstance.start();
});
})
.catch((err) => {
@ -290,6 +327,11 @@ database.initialize()
// Graceful Shutdown
process.on('SIGTERM', () => {
logger.info('SIGTERM empfangen, fahre herunter...');
// Reminder Service stoppen
const reminderServiceInstance = reminderService.getInstance();
reminderServiceInstance.stop();
server.close(() => {
database.close();
logger.info('Server beendet');
@ -299,6 +341,11 @@ process.on('SIGTERM', () => {
process.on('SIGINT', () => {
logger.info('SIGINT empfangen, fahre herunter...');
// Reminder Service stoppen
const reminderServiceInstance = reminderService.getInstance();
reminderServiceInstance.stop();
server.close(() => {
database.close();
logger.info('Server beendet');

Datei anzeigen

@ -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;
}
}
@ -253,8 +262,11 @@ function stageAll(localPath) {
/**
* Commit erstellen
* @param {string} localPath - Pfad zum Repository
* @param {string} message - Commit-Nachricht
* @param {object} author - Autor-Informationen { name, email }
*/
function commit(localPath, message) {
function commit(localPath, message, author = null) {
if (!isGitRepository(localPath)) {
return { success: false, error: 'Kein Git-Repository' };
}
@ -265,7 +277,17 @@ function commit(localPath, message) {
// Escape für Shell
const escapedMessage = message.replace(/"/g, '\\"');
return execGitCommand(`git commit -m "${escapedMessage}"`, localPath);
// Commit-Befehl mit optionalem Autor
let commitCmd = `git commit -m "${escapedMessage}"`;
if (author && author.name) {
const authorName = author.name.replace(/"/g, '\\"');
const authorEmail = author.email || `${author.name.toLowerCase().replace(/\s+/g, '.')}@taskmate.local`;
commitCmd = `git commit --author="${authorName} <${authorEmail}>" -m "${escapedMessage}"`;
logger.info(`Commit mit Autor: ${authorName} <${authorEmail}>`);
}
return execGitCommand(commitCmd, localPath);
}
/**
@ -443,7 +465,7 @@ function hasRemote(localPath, remoteName = 'origin') {
/**
* Initialen Push mit Upstream-Tracking
*/
function pushWithUpstream(localPath, branch = null, remoteName = 'origin') {
function pushWithUpstream(localPath, targetBranch = null, remoteName = 'origin', force = false) {
if (!isGitRepository(localPath)) {
return { success: false, error: 'Kein Git-Repository' };
}
@ -477,11 +499,12 @@ function pushWithUpstream(localPath, branch = null, remoteName = 'origin') {
}
}
// Aktuellen Branch ermitteln NACH dem Commit
// Aktuellen (lokalen) Branch ermitteln NACH dem Commit
let localBranch = null;
const branchResult = execGitCommand('git branch --show-current', localPath);
if (branchResult.success && branchResult.output) {
branch = branchResult.output;
logger.info(`Aktueller Branch: ${branch}`);
localBranch = branchResult.output;
logger.info(`Lokaler Branch: ${localBranch}`);
} else {
// Fallback: Branch aus git branch auslesen
const branchListResult = execGitCommand('git branch', localPath);
@ -491,29 +514,46 @@ function pushWithUpstream(localPath, branch = null, remoteName = 'origin') {
const lines = branchListResult.output.split('\n');
for (const line of lines) {
if (line.includes('*')) {
branch = line.replace('*', '').trim();
localBranch = line.replace('*', '').trim();
break;
}
}
}
if (!branch) {
if (!localBranch) {
// Letzter Fallback: Versuche den Branch zu erstellen
logger.info('Kein Branch gefunden, erstelle main');
execGitCommand('git checkout -b main', localPath);
branch = 'main';
localBranch = 'main';
}
}
logger.info(`Push auf Branch: ${branch}`);
// Wenn kein Ziel-Branch angegeben, verwende lokalen Branch-Namen
const remoteBranch = targetBranch || localBranch;
logger.info(`Push: lokaler Branch '${localBranch}' → Remote Branch '${remoteBranch}'${force ? ' (FORCE)' : ''}`);
// Push mit -u für Upstream-Tracking
const pushResult = execGitCommand(`git push -u ${remoteName} ${branch}`, localPath, { timeout: 120000 });
// Format: git push -u origin localBranch:remoteBranch
const forceFlag = force ? ' --force' : '';
let pushCommand;
if (localBranch === remoteBranch) {
pushCommand = `git push -u${forceFlag} ${remoteName} ${localBranch}`;
} else {
// Branch-Mapping: lokalen Branch auf anderen Remote-Branch pushen
pushCommand = `git push -u${forceFlag} ${remoteName} ${localBranch}:${remoteBranch}`;
}
const pushResult = execGitCommand(pushCommand, localPath, { timeout: 120000 });
if (!pushResult.success) {
logger.error(`Push fehlgeschlagen: ${pushResult.error}`);
}
// Branch-Namen im Ergebnis inkludieren für Default-Branch Aktualisierung
pushResult.localBranch = localBranch;
pushResult.branch = remoteBranch; // Remote-Branch für Gitea Default-Branch
return pushResult;
}
@ -561,6 +601,39 @@ function prepareForGitea(localPath, remoteUrl, options = {}) {
return { success: true, message: 'Repository für Gitea vorbereitet' };
}
/**
* Branch umbenennen
*/
function renameBranch(localPath, oldName, newName) {
if (!isGitRepository(localPath)) {
return { success: false, error: 'Kein Git-Repository' };
}
// Validiere neuen Branch-Namen
if (!newName || !/^[a-zA-Z0-9_\-]+$/.test(newName)) {
return { success: false, error: 'Ungültiger Branch-Name. Nur Buchstaben, Zahlen, Unterstriche und Bindestriche erlaubt.' };
}
// Prüfe ob wir auf dem zu umbenennenden Branch sind
const branchResult = execGitCommand('git branch --show-current', localPath);
const currentBranch = branchResult.success ? branchResult.output : null;
if (currentBranch !== oldName) {
return { success: false, error: `Bitte wechsle zuerst zum Branch "${oldName}" bevor du ihn umbenennst.` };
}
// Branch umbenennen
const renameResult = execGitCommand(`git branch -m ${oldName} ${newName}`, localPath);
if (!renameResult.success) {
logger.error(`Branch umbenennen fehlgeschlagen: ${renameResult.error}`);
return renameResult;
}
logger.info(`Branch umbenannt: ${oldName}${newName}`);
return { success: true, oldName, newName, message: `Branch "${oldName}" zu "${newName}" umbenannt` };
}
module.exports = {
windowsToContainerPath,
containerToWindowsPath,
@ -581,5 +654,6 @@ module.exports = {
setRemote,
hasRemote,
pushWithUpstream,
prepareForGitea
prepareForGitea,
renameBranch
};

Datei anzeigen

@ -284,12 +284,44 @@ function getSafeCloneUrl(cloneUrl) {
return cloneUrl.replace(/https:\/\/[^@]+@/, 'https://');
}
/**
* Repository-Einstellungen aktualisieren (z.B. default_branch ändern)
*/
async function updateRepository(owner, repo, options = {}) {
try {
const updateData = {};
if (options.defaultBranch) updateData.default_branch = options.defaultBranch;
if (options.description !== undefined) updateData.description = options.description;
if (options.private !== undefined) updateData.private = options.private;
const repoData = await giteaFetch(`/repos/${owner}/${repo}`, {
method: 'PATCH',
body: JSON.stringify(updateData)
});
logger.info(`Repository ${owner}/${repo} aktualisiert (default_branch: ${repoData.default_branch})`);
return {
success: true,
repository: {
id: repoData.id,
name: repoData.name,
fullName: repoData.full_name,
defaultBranch: repoData.default_branch
}
};
} catch (error) {
logger.error(`Fehler beim Aktualisieren des Repositories ${owner}/${repo}:`, error);
return { success: false, error: error.message };
}
}
module.exports = {
listRepositories,
getRepository,
getRepositoryBranches,
getRepositoryCommits,
createRepository,
updateRepository,
deleteRepository,
getCurrentUser,
testConnection,

Datei anzeigen

@ -47,6 +47,10 @@ const NOTIFICATION_TYPES = {
title: (data) => 'Genehmigung erforderlich',
message: (data) => `Neue Genehmigung: "${data.proposalTitle}"`
},
'reminder:due': {
title: (data) => 'Erinnerung',
message: (data) => `${data.reminderTitle} - ${data.daysAdvance === '0' ? 'Heute' : `in ${data.daysAdvance} Tag${data.daysAdvance > 1 ? 'en' : ''}`}`
},
'approval:granted': {
title: (data) => 'Genehmigung erteilt',
message: (data) => `"${data.proposalTitle}" wurde genehmigt`
@ -54,6 +58,10 @@ const NOTIFICATION_TYPES = {
'approval:rejected': {
title: (data) => 'Genehmigung abgelehnt',
message: (data) => `"${data.proposalTitle}" wurde abgelehnt`
},
'knowledge:new_entry': {
title: (data) => 'Neuer Wissenseintrag',
message: (data) => `Neuer Eintrag: "${data.entryTitle}" in ${data.categoryName}`
}
};
@ -80,8 +88,8 @@ const notificationService = {
const message = typeConfig.message(data);
const result = db.prepare(`
INSERT INTO notifications (user_id, type, title, message, task_id, project_id, proposal_id, actor_id, is_persistent)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO notifications (user_id, type, title, message, task_id, project_id, proposal_id, actor_id, is_persistent, data)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
userId,
type,
@ -91,7 +99,8 @@ const notificationService = {
data.projectId || null,
data.proposalId || null,
data.actorId || null,
persistent ? 1 : 0
persistent ? 1 : 0,
JSON.stringify(data) // Zusätzliche Daten als JSON speichern
);
const notification = db.prepare(`
@ -256,6 +265,16 @@ const notificationService = {
* Benachrichtigung formatieren für Frontend
*/
formatNotification(notification) {
// Parse zusätzliche Daten wenn vorhanden
let additionalData = {};
if (notification.data) {
try {
additionalData = JSON.parse(notification.data);
} catch (e) {
// Ignore parse errors
}
}
return {
id: notification.id,
userId: notification.user_id,
@ -270,7 +289,10 @@ const notificationService = {
actorColor: notification.actor_color,
isRead: notification.is_read === 1,
isPersistent: notification.is_persistent === 1,
createdAt: notification.created_at
createdAt: notification.created_at,
// Zusätzliche Daten für Knowledge-Einträge
entryId: additionalData.entryId || null,
categoryId: additionalData.categoryId || null
};
},
@ -284,6 +306,24 @@ const notificationService = {
if (result) results.push(result);
});
return results;
},
/**
* Reminder-Benachrichtigung erstellen
*/
createReminderNotification(reminder, daysAdvance, io) {
return this.create(
reminder.created_by,
'reminder:due',
{
reminderTitle: reminder.title,
daysAdvance: daysAdvance.toString(),
projectId: reminder.project_id,
reminderId: reminder.id
},
io,
false
);
}
};

Datei anzeigen

@ -0,0 +1,242 @@
/**
* TASKMATE - Reminder Service
* ===========================
* Service für Erinnerungsbenachrichtigungen und Scheduling
*/
const { getDb } = require('../database');
const notificationService = require('./notificationService');
const logger = require('../utils/logger');
class ReminderService {
constructor(io = null) {
this.io = io;
this.intervalId = null;
this.isRunning = false;
}
/**
* Startet den Reminder-Check-Service
* Läuft alle 5 Minuten (kann für Produktion auf 1 Stunde erhöht werden)
*/
start() {
if (this.isRunning) {
logger.warn('Reminder Service ist bereits gestartet');
return;
}
this.isRunning = true;
// Sofort prüfen
this.checkDueReminders();
// Dann alle 5 Minuten (300000 ms)
// In Produktion könnte das auf 1 Stunde (3600000 ms) erhöht werden
this.intervalId = setInterval(() => {
this.checkDueReminders();
}, 300000); // 5 Minuten
logger.info('Reminder Service gestartet - prüft alle 5 Minuten');
}
/**
* Stoppt den Reminder-Check-Service
*/
stop() {
if (!this.isRunning) {
return;
}
if (this.intervalId) {
clearInterval(this.intervalId);
this.intervalId = null;
}
this.isRunning = false;
logger.info('Reminder Service gestoppt');
}
/**
* Prüft fällige Erinnerungen und sendet Benachrichtigungen
*/
async checkDueReminders() {
try {
const db = getDb();
const today = new Date().toISOString().split('T')[0];
// Finde alle fälligen Benachrichtigungen die noch nicht gesendet wurden
const dueNotifications = db.prepare(`
SELECT
rn.*,
r.id as reminder_id,
r.title as reminder_title,
r.description as reminder_description,
r.reminder_date,
r.reminder_time,
r.color,
r.project_id,
r.created_by,
r.advance_days
FROM reminder_notifications rn
JOIN reminders r ON rn.reminder_id = r.id
WHERE rn.notification_date <= ?
AND rn.sent = 0
AND r.is_active = 1
ORDER BY rn.notification_date ASC, r.reminder_time ASC
`).all(today);
if (dueNotifications.length === 0) {
logger.debug('Keine fälligen Erinnerungen gefunden');
return;
}
logger.info(`${dueNotifications.length} fällige Erinnerung(en) gefunden`);
// Verarbeite jede fällige Benachrichtigung
for (const notification of dueNotifications) {
await this.processReminderNotification(notification);
}
} catch (error) {
logger.error('Fehler beim Prüfen fälliger Erinnerungen:', error);
}
}
/**
* Verarbeitet eine einzelne fällige Erinnerungs-Benachrichtigung
*/
async processReminderNotification(notification) {
try {
const db = getDb();
// Berechne wie viele Tage im Voraus diese Benachrichtigung ist
const reminderDate = new Date(notification.reminder_date);
const notificationDate = new Date(notification.notification_date);
const daysDiff = Math.ceil((reminderDate - notificationDate) / (1000 * 60 * 60 * 24));
const reminder = {
id: notification.reminder_id,
title: notification.reminder_title,
description: notification.reminder_description,
project_id: notification.project_id,
created_by: notification.created_by,
color: notification.color
};
// Erstelle Benachrichtigung
const createdNotification = notificationService.createReminderNotification(
reminder,
daysDiff,
this.io
);
if (createdNotification) {
// Markiere als gesendet
db.prepare(`
UPDATE reminder_notifications
SET sent = 1
WHERE id = ?
`).run(notification.id);
logger.info(`Reminder-Benachrichtigung gesendet: "${notification.reminder_title}" (${daysDiff} Tage vorher)`);
}
} catch (error) {
logger.error(`Fehler beim Verarbeiten der Reminder-Benachrichtigung ${notification.id}:`, error);
}
}
/**
* Erstellt Benachrichtigungstermine für eine neue Erinnerung
*/
createNotificationSchedule(reminderId, reminderDate, advanceDays) {
try {
const db = getDb();
const baseDate = new Date(reminderDate);
// Lösche alte Termine falls vorhanden
db.prepare('DELETE FROM reminder_notifications WHERE reminder_id = ?').run(reminderId);
// Erstelle neue Termine für jeden advance day
advanceDays.forEach(days => {
const notificationDate = new Date(baseDate);
notificationDate.setDate(notificationDate.getDate() - parseInt(days));
const notificationDateStr = notificationDate.toISOString().split('T')[0];
// Nur zukünftige Termine erstellen
const today = new Date().toISOString().split('T')[0];
if (notificationDateStr >= today) {
db.prepare(`
INSERT OR IGNORE INTO reminder_notifications (reminder_id, notification_date)
VALUES (?, ?)
`).run(reminderId, notificationDateStr);
}
});
logger.debug(`Benachrichtigungstermine erstellt für Reminder ${reminderId}`);
} catch (error) {
logger.error(`Fehler beim Erstellen der Benachrichtigungstermine für Reminder ${reminderId}:`, error);
}
}
/**
* Manuelle Prüfung für API-Endpoint
*/
async manualCheck() {
logger.info('Manuelle Reminder-Prüfung ausgelöst');
return await this.checkDueReminders();
}
/**
* Statistiken für Debugging
*/
getStats() {
try {
const db = getDb();
const stats = {
isRunning: this.isRunning,
activeReminders: db.prepare('SELECT COUNT(*) as count FROM reminders WHERE is_active = 1').get().count,
pendingNotifications: db.prepare('SELECT COUNT(*) as count FROM reminder_notifications WHERE sent = 0').get().count,
nextDueDate: db.prepare(`
SELECT MIN(notification_date) as next_date
FROM reminder_notifications
WHERE sent = 0 AND notification_date >= date('now')
`).get().next_date
};
return stats;
} catch (error) {
logger.error('Fehler beim Abrufen der Reminder-Statistiken:', error);
return { error: error.message };
}
}
/**
* Socket.io Instanz setzen/aktualisieren
*/
setSocketIO(io) {
this.io = io;
logger.debug('Socket.IO Instanz für Reminder Service aktualisiert');
}
}
// Singleton Export
let instance = null;
module.exports = {
getInstance(io = null) {
if (!instance) {
instance = new ReminderService(io);
} else if (io) {
instance.setSocketIO(io);
}
return instance;
},
// Für Tests und Debugging
createInstance(io = null) {
return new ReminderService(io);
}
};

Datei anzeigen

@ -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);

237
backend/utils/encryption.js Normale Datei
Datei anzeigen

@ -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
};

Binäre Datei nicht angezeigt.

Binäre Datei nicht angezeigt.

Binäre Datei nicht angezeigt.

Binäre Datei nicht angezeigt.

Binäre Datei nicht angezeigt.

Binäre Datei nicht angezeigt.

Binäre Datei nicht angezeigt.

Binäre Datei nicht angezeigt.

Binäre Datei nicht angezeigt.

Binäre Datei nicht angezeigt.

Binäre Datei nicht angezeigt.

Binäre Datei nicht angezeigt.

Binäre Datei nicht angezeigt.

Binäre Datei nicht angezeigt.

Binäre Datei nicht angezeigt.

Binäre Datei nicht angezeigt.

Binäre Datei nicht angezeigt.

Binäre Datei nicht angezeigt.

Binäre Datei nicht angezeigt.

Binäre Datei nicht angezeigt.

Binäre Datei nicht angezeigt.

Binäre Datei nicht angezeigt.

Binäre Datei nicht angezeigt.

Binäre Datei nicht angezeigt.

Binäre Datei nicht angezeigt.

Binäre Datei nicht angezeigt.

Binäre Datei nicht angezeigt.

Binäre Datei nicht angezeigt.

Binäre Datei nicht angezeigt.

Binäre Datei nicht angezeigt.

51
create-placeholder-icons.sh Ausführbare Datei
Datei anzeigen

@ -0,0 +1,51 @@
#!/bin/bash
# Erstellt Platzhalter-Icons für TaskMate PWA
cd /home/claude-dev/TaskMate/frontend/assets/icons/
# Funktion zum Erstellen eines PNG aus SVG mit ImageMagick
create_icon() {
size=$1
echo "Erstelle icon-${size}x${size}.png..."
# Mit rsvg-convert (falls verfügbar)
if command -v rsvg-convert &> /dev/null; then
rsvg-convert -w $size -h $size taskmate-logo.svg -o icon-${size}x${size}.png
rsvg-convert -w $size -h $size taskmate-logo.svg -o icon-maskable-${size}x${size}.png
# Mit convert/ImageMagick (falls verfügbar)
elif command -v convert &> /dev/null; then
convert -background transparent -resize ${size}x${size} taskmate-logo.svg icon-${size}x${size}.png
convert -background transparent -resize ${size}x${size} taskmate-logo.svg icon-maskable-${size}x${size}.png
else
echo "Weder rsvg-convert noch ImageMagick gefunden!"
echo "Installieren Sie eines davon mit:"
echo " sudo apt-get install librsvg2-bin"
echo " oder"
echo " sudo apt-get install imagemagick"
return 1
fi
}
# Alle benötigten Größen
sizes=(48 72 96 128 144 152 192 384 512)
# Icons erstellen
for size in "${sizes[@]}"; do
create_icon $size
done
# Zusätzliche Icons
if command -v rsvg-convert &> /dev/null; then
echo "Erstelle Shortcut-Icons..."
rsvg-convert -w 96 -h 96 taskmate-logo.svg -o add-task-96x96.png
rsvg-convert -w 96 -h 96 taskmate-logo.svg -o calendar-96x96.png
elif command -v convert &> /dev/null; then
echo "Erstelle Shortcut-Icons..."
convert -background transparent -resize 96x96 taskmate-logo.svg add-task-96x96.png
convert -background transparent -resize 96x96 taskmate-logo.svg calendar-96x96.png
fi
echo "Fertig! Icons wurden erstellt."
echo ""
echo "Nächster Schritt: Icons in Docker Container kopieren:"
echo "docker cp *.png taskmate:/app/public/assets/icons/"

Binäre Datei nicht angezeigt.

Binäre Datei nicht angezeigt.

Binäre Datei nicht angezeigt.

Datei anzeigen

@ -4,16 +4,13 @@ services:
container_name: taskmate
restart: unless-stopped
ports:
- "${PORT:-3000}:3000"
- "127.0.0.1:${PORT:-3001}:3000" # Nur localhost, Zugriff über Nginx
volumes:
- ./data:/app/data
- ./backups:/app/backups
- ./logs:/app/logs
- ./uploads:/app/uploads
# Laufwerke für Git-Repositories (bei Bedarf weitere hinzufügen)
- C:/:/mnt/c
# - D:/:/mnt/d # Deaktiviert - Laufwerk nicht verfügbar
# - E:/:/mnt/e # Deaktiviert - Laufwerk nicht verfügbar
- .:/app/taskmate-source
environment:
- NODE_ENV=production
- JWT_SECRET=${JWT_SECRET}
@ -34,6 +31,7 @@ services:
- USER2_PASSWORD=${USER2_PASSWORD:-changeme456}
- USER2_DISPLAYNAME=${USER2_DISPLAYNAME:-Benutzer 2}
- USER2_COLOR=${USER2_COLOR:-#FF9500}
- ENCRYPTION_KEY=${ENCRYPTION_KEY}
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/api/health"]
interval: 30s

43
fix_passwords.js Normale Datei
Datei anzeigen

@ -0,0 +1,43 @@
/**
* Password Fix Script
* Setzt die Passwort-Hashes für alle Benutzer zurück
*/
const bcrypt = require('bcrypt');
const Database = require('better-sqlite3');
const path = require('path');
async function fixPasswords() {
const db = new Database(path.join(__dirname, 'data/taskmate.db'));
console.log('Setze Passwort-Hashes zurück...');
// Standard-Passwörter
const passwords = {
'admin': 'admin123',
'hendrik_gebhardt@gmx.de': 'Hzfne313!fdEF34',
'momohomma@googlemail.com': 'Hzfne313!fdEF34'
};
for (const [username, password] of Object.entries(passwords)) {
const hash = await bcrypt.hash(password, 12);
// Update basierend auf E-Mail oder Username
const result = db.prepare(`
UPDATE users
SET password_hash = ?, failed_attempts = 0, locked_until = NULL
WHERE email = ? OR username = ?
`).run(hash, username, username);
if (result.changes > 0) {
console.log(`✅ Passwort für ${username} aktualisiert`);
} else {
console.log(`❌ Benutzer ${username} nicht gefunden`);
}
}
db.close();
console.log('Passwort-Fix abgeschlossen!');
}
fixPasswords().catch(console.error);

Datei anzeigen

@ -0,0 +1,10 @@
[{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "de.aegissight.taskmate",
"sha256_cert_fingerprints": [
"TO_BE_REPLACED_WITH_YOUR_APP_SIGNING_KEY_FINGERPRINT"
]
}
}]

Binäre Datei nicht angezeigt.

Nachher

Breite:  |  Höhe:  |  Größe: 3.9 KiB

Binäre Datei nicht angezeigt.

Nachher

Breite:  |  Höhe:  |  Größe: 5.7 KiB

Einige Dateien werden nicht angezeigt, da zu viele Dateien in diesem Diff geändert wurden Mehr anzeigen