Datenbank bereinigt / Gitea-Integration gefixt
Dieser Commit ist enthalten in:
committet von
Server Deploy
Ursprung
395598c2b0
Commit
c21be47428
@ -368,6 +368,25 @@ function createTables() {
|
||||
)
|
||||
`);
|
||||
|
||||
// Refresh Tokens für sichere Token-Rotation
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
token TEXT NOT NULL UNIQUE,
|
||||
expires_at DATETIME NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
last_used DATETIME,
|
||||
user_agent TEXT,
|
||||
ip_address TEXT,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
|
||||
// Index für Token-Lookup
|
||||
db.exec(`CREATE INDEX IF NOT EXISTS idx_refresh_tokens_token ON refresh_tokens(token)`);
|
||||
db.exec(`CREATE INDEX IF NOT EXISTS idx_refresh_tokens_expires ON refresh_tokens(expires_at)`);
|
||||
|
||||
// Anwendungen (Git-Repositories pro Projekt)
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS applications (
|
||||
@ -457,6 +476,34 @@ function createTables() {
|
||||
)
|
||||
`);
|
||||
|
||||
// Coding-Verzeichnisse (projektübergreifend)
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS coding_directories (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
local_path TEXT NOT NULL UNIQUE,
|
||||
description TEXT,
|
||||
color TEXT DEFAULT '#4F46E5',
|
||||
gitea_repo_url TEXT,
|
||||
gitea_repo_owner TEXT,
|
||||
gitea_repo_name TEXT,
|
||||
default_branch TEXT DEFAULT 'main',
|
||||
last_sync DATETIME,
|
||||
position INTEGER DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by INTEGER,
|
||||
FOREIGN KEY (created_by) REFERENCES users(id)
|
||||
)
|
||||
`);
|
||||
|
||||
// Migration: Add claude_instructions column to coding_directories
|
||||
const codingDirColumns = db.prepare("PRAGMA table_info(coding_directories)").all();
|
||||
const hasClaudeInstructions = codingDirColumns.some(col => col.name === 'claude_instructions');
|
||||
if (!hasClaudeInstructions) {
|
||||
db.exec('ALTER TABLE coding_directories ADD COLUMN claude_instructions TEXT');
|
||||
logger.info('Migration: claude_instructions Spalte zu coding_directories hinzugefuegt');
|
||||
}
|
||||
|
||||
// Indizes für Performance
|
||||
db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_project ON tasks(project_id);
|
||||
@ -476,17 +523,36 @@ function createTables() {
|
||||
CREATE INDEX IF NOT EXISTS idx_applications_project ON applications(project_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_knowledge_entries_category ON knowledge_entries(category_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_knowledge_attachments_entry ON knowledge_attachments(entry_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_coding_directories_position ON coding_directories(position);
|
||||
`);
|
||||
|
||||
logger.info('Datenbank-Tabellen erstellt');
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard-Benutzer erstellen
|
||||
* Standard-Benutzer erstellen und Admin-Passwort korrigieren
|
||||
*/
|
||||
async function createDefaultUsers() {
|
||||
const existingUsers = db.prepare('SELECT COUNT(*) as count FROM users').get();
|
||||
|
||||
// Admin-Passwort korrigieren (falls aus .env verschieden)
|
||||
const adminExists = db.prepare('SELECT id, password_hash FROM users WHERE username = ? AND role = ?').get('admin', 'admin');
|
||||
if (adminExists) {
|
||||
const correctAdminPassword = process.env.ADMIN_PASSWORD || 'admin123';
|
||||
const bcrypt = require('bcryptjs');
|
||||
|
||||
// Prüfen ob das Passwort bereits korrekt ist
|
||||
const isCorrect = await bcrypt.compare(correctAdminPassword, adminExists.password_hash);
|
||||
if (!isCorrect) {
|
||||
logger.info('Admin-Passwort wird aus .env aktualisiert');
|
||||
const correctHash = await bcrypt.hash(correctAdminPassword, 12);
|
||||
db.prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(correctHash, adminExists.id);
|
||||
logger.info('Admin-Passwort erfolgreich aktualisiert');
|
||||
} else {
|
||||
logger.info('Admin-Passwort bereits korrekt');
|
||||
}
|
||||
}
|
||||
|
||||
if (existingUsers.count === 0) {
|
||||
// Benutzer aus Umgebungsvariablen
|
||||
const user1 = {
|
||||
@ -510,10 +576,10 @@ async function createDefaultUsers() {
|
||||
|
||||
// Admin-Benutzer
|
||||
const adminUser = {
|
||||
username: 'admin',
|
||||
password: 'Kx9#mP2$vL7@nQ4!wR',
|
||||
displayName: 'Administrator',
|
||||
color: '#8B5CF6'
|
||||
username: process.env.ADMIN_USERNAME || 'admin',
|
||||
password: process.env.ADMIN_PASSWORD || 'admin123',
|
||||
displayName: process.env.ADMIN_DISPLAYNAME || 'Administrator',
|
||||
color: process.env.ADMIN_COLOR || '#8B5CF6'
|
||||
};
|
||||
|
||||
// Passwoerter hashen und Benutzer erstellen
|
||||
|
||||
@ -5,15 +5,22 @@
|
||||
*/
|
||||
|
||||
const jwt = require('jsonwebtoken');
|
||||
const crypto = require('crypto');
|
||||
const logger = require('../utils/logger');
|
||||
const { getDb } = require('../database');
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'UNSICHER_BITTE_AENDERN';
|
||||
const JWT_SECRET = process.env.JWT_SECRET;
|
||||
if (!JWT_SECRET || JWT_SECRET.length < 32) {
|
||||
throw new Error('JWT_SECRET muss in .env gesetzt und mindestens 32 Zeichen lang sein!');
|
||||
}
|
||||
const ACCESS_TOKEN_EXPIRY = 15; // Minuten (kürzer für mehr Sicherheit)
|
||||
const REFRESH_TOKEN_EXPIRY = 7 * 24 * 60; // 7 Tage in Minuten
|
||||
const SESSION_TIMEOUT = parseInt(process.env.SESSION_TIMEOUT) || 30; // Minuten
|
||||
|
||||
/**
|
||||
* JWT-Token generieren
|
||||
* JWT Access-Token generieren (kurze Lebensdauer)
|
||||
*/
|
||||
function generateToken(user) {
|
||||
function generateAccessToken(user) {
|
||||
// Permissions parsen falls als String gespeichert
|
||||
let permissions = user.permissions || [];
|
||||
if (typeof permissions === 'string') {
|
||||
@ -31,13 +38,38 @@ function generateToken(user) {
|
||||
displayName: user.display_name,
|
||||
color: user.color,
|
||||
role: user.role || 'user',
|
||||
permissions: permissions
|
||||
permissions: permissions,
|
||||
type: 'access'
|
||||
},
|
||||
JWT_SECRET,
|
||||
{ expiresIn: `${SESSION_TIMEOUT}m` }
|
||||
{ expiresIn: `${ACCESS_TOKEN_EXPIRY}m` }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh-Token generieren (lange Lebensdauer)
|
||||
*/
|
||||
function generateRefreshToken(userId, ipAddress, userAgent) {
|
||||
const db = getDb();
|
||||
const token = crypto.randomBytes(32).toString('hex');
|
||||
const expiresAt = new Date(Date.now() + REFRESH_TOKEN_EXPIRY * 60 * 1000);
|
||||
|
||||
// Token in Datenbank speichern
|
||||
db.prepare(`
|
||||
INSERT INTO refresh_tokens (user_id, token, expires_at, ip_address, user_agent)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`).run(userId, token, expiresAt.toISOString(), ipAddress, userAgent);
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy generateToken für Rückwärtskompatibilität
|
||||
*/
|
||||
function generateToken(user) {
|
||||
return generateAccessToken(user);
|
||||
}
|
||||
|
||||
/**
|
||||
* JWT-Token verifizieren
|
||||
*/
|
||||
@ -179,8 +211,72 @@ function generateCsrfToken() {
|
||||
return randomBytes(32).toString('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh-Token validieren und neuen Access-Token generieren
|
||||
*/
|
||||
async function refreshAccessToken(refreshToken, ipAddress, userAgent) {
|
||||
const db = getDb();
|
||||
|
||||
// Token in Datenbank suchen
|
||||
const tokenRecord = db.prepare(`
|
||||
SELECT rt.*, u.* FROM refresh_tokens rt
|
||||
JOIN users u ON rt.user_id = u.id
|
||||
WHERE rt.token = ? AND rt.expires_at > datetime('now')
|
||||
`).get(refreshToken);
|
||||
|
||||
if (!tokenRecord) {
|
||||
throw new Error('Ungültiger oder abgelaufener Refresh-Token');
|
||||
}
|
||||
|
||||
// Token als benutzt markieren
|
||||
db.prepare(`
|
||||
UPDATE refresh_tokens SET last_used = CURRENT_TIMESTAMP WHERE id = ?
|
||||
`).run(tokenRecord.id);
|
||||
|
||||
// Neuen Access-Token generieren
|
||||
const user = {
|
||||
id: tokenRecord.user_id,
|
||||
username: tokenRecord.username,
|
||||
display_name: tokenRecord.display_name,
|
||||
color: tokenRecord.color,
|
||||
role: tokenRecord.role,
|
||||
permissions: tokenRecord.permissions
|
||||
};
|
||||
|
||||
return generateAccessToken(user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Alle Refresh-Tokens eines Benutzers löschen (Logout auf allen Geräten)
|
||||
*/
|
||||
function revokeAllRefreshTokens(userId) {
|
||||
const db = getDb();
|
||||
db.prepare('DELETE FROM refresh_tokens WHERE user_id = ?').run(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Abgelaufene Refresh-Tokens aufräumen
|
||||
*/
|
||||
function cleanupExpiredTokens() {
|
||||
const db = getDb();
|
||||
const result = db.prepare(`
|
||||
DELETE FROM refresh_tokens WHERE expires_at < datetime('now')
|
||||
`).run();
|
||||
|
||||
if (result.changes > 0) {
|
||||
logger.info(`Bereinigt: ${result.changes} abgelaufene Refresh-Tokens`);
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup alle 6 Stunden
|
||||
setInterval(cleanupExpiredTokens, 6 * 60 * 60 * 1000);
|
||||
|
||||
module.exports = {
|
||||
generateToken,
|
||||
generateAccessToken,
|
||||
generateRefreshToken,
|
||||
refreshAccessToken,
|
||||
revokeAllRefreshTokens,
|
||||
verifyToken,
|
||||
authenticateToken,
|
||||
authenticateSocket,
|
||||
|
||||
@ -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
|
||||
};
|
||||
|
||||
@ -5,24 +5,52 @@
|
||||
*/
|
||||
|
||||
const sanitizeHtml = require('sanitize-html');
|
||||
const createDOMPurify = require('dompurify');
|
||||
const { JSDOM } = require('jsdom');
|
||||
|
||||
// DOMPurify für Server-side Rendering initialisieren
|
||||
const window = new JSDOM('').window;
|
||||
const DOMPurify = createDOMPurify(window);
|
||||
|
||||
/**
|
||||
* HTML-Tags entfernen (für reine Text-Felder)
|
||||
* HTML-Entities dekodieren
|
||||
*/
|
||||
function stripHtml(input) {
|
||||
if (typeof input !== 'string') return input;
|
||||
return sanitizeHtml(input, {
|
||||
allowedTags: [],
|
||||
allowedAttributes: {}
|
||||
}).trim();
|
||||
function decodeHtmlEntities(str) {
|
||||
if (typeof str !== 'string') return str;
|
||||
const entities = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
''': "'",
|
||||
''': "'",
|
||||
''': "'"
|
||||
};
|
||||
return str.replace(/&(amp|lt|gt|quot|#039|#x27|apos);/g, match => entities[match] || match);
|
||||
}
|
||||
|
||||
/**
|
||||
* Markdown-sichere Bereinigung (erlaubt bestimmte Tags)
|
||||
* HTML-Tags entfernen (für reine Text-Felder)
|
||||
* Wichtig: sanitize-html encoded &-Zeichen zu &, daher dekodieren wir danach
|
||||
*/
|
||||
function stripHtml(input) {
|
||||
if (typeof input !== 'string') return input;
|
||||
const sanitized = sanitizeHtml(input, {
|
||||
allowedTags: [],
|
||||
allowedAttributes: {}
|
||||
}).trim();
|
||||
// Entities wieder dekodieren, da sanitize-html sie encoded
|
||||
return decodeHtmlEntities(sanitized);
|
||||
}
|
||||
|
||||
/**
|
||||
* Markdown-sichere Bereinigung mit DOMPurify (doppelte Sicherheit)
|
||||
*/
|
||||
function sanitizeMarkdown(input) {
|
||||
if (typeof input !== 'string') return input;
|
||||
return sanitizeHtml(input, {
|
||||
|
||||
// Erste Bereinigung mit sanitize-html
|
||||
const firstPass = sanitizeHtml(input, {
|
||||
allowedTags: [
|
||||
'p', 'br', 'strong', 'em', 'u', 's', 'code', 'pre',
|
||||
'ul', 'ol', 'li', 'blockquote', 'a', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'
|
||||
@ -44,6 +72,16 @@ function sanitizeMarkdown(input) {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Zweite Bereinigung mit DOMPurify (zusätzliche Sicherheit)
|
||||
return DOMPurify.sanitize(firstPass, {
|
||||
ALLOWED_TAGS: [
|
||||
'p', 'br', 'strong', 'em', 'u', 's', 'code', 'pre',
|
||||
'ul', 'ol', 'li', 'blockquote', 'a', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'
|
||||
],
|
||||
ALLOWED_ATTR: ['href', 'title', 'target', 'rel'],
|
||||
ALLOWED_URI_REGEXP: /^(?:(?:(?:f|ht)tps?|mailto):|[^a-z]|[a-z+.-]+(?:[^a-z+.-:]|$))/i
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@ -65,7 +103,15 @@ function sanitizeObject(obj, options = {}) {
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
// Bestimmte Felder dürfen Markdown enthalten
|
||||
const allowHtml = ['description', 'content'].includes(key);
|
||||
sanitized[key] = sanitizeObject(value, { allowHtml });
|
||||
|
||||
// Passwort-Felder NICHT sanitizen (Sonderzeichen erhalten)
|
||||
const skipSanitization = ['password', 'oldPassword', 'newPassword', 'confirmPassword'].includes(key);
|
||||
|
||||
if (skipSanitization) {
|
||||
sanitized[key] = value; // Passwort unverändert lassen
|
||||
} else {
|
||||
sanitized[key] = sanitizeObject(value, { allowHtml });
|
||||
}
|
||||
}
|
||||
return sanitized;
|
||||
}
|
||||
@ -119,12 +165,32 @@ const validators = {
|
||||
},
|
||||
|
||||
/**
|
||||
* URL-Format prüfen
|
||||
* URL-Format prüfen (erweiterte Sicherheit)
|
||||
*/
|
||||
url: (value, fieldName) => {
|
||||
try {
|
||||
if (value) {
|
||||
new URL(value);
|
||||
const url = new URL(value);
|
||||
|
||||
// Nur HTTP/HTTPS erlauben
|
||||
if (!['http:', 'https:'].includes(url.protocol)) {
|
||||
return `${fieldName} muss HTTP oder HTTPS verwenden`;
|
||||
}
|
||||
|
||||
// Localhost und private IPs blocken (SSRF-Schutz)
|
||||
const hostname = url.hostname;
|
||||
if (hostname === 'localhost' ||
|
||||
hostname === '127.0.0.1' ||
|
||||
hostname.startsWith('192.168.') ||
|
||||
hostname.startsWith('10.') ||
|
||||
hostname.startsWith('172.')) {
|
||||
return `${fieldName} darf nicht auf lokale Adressen verweisen`;
|
||||
}
|
||||
|
||||
// JavaScript URLs blocken
|
||||
if (url.href.toLowerCase().startsWith('javascript:')) {
|
||||
return `${fieldName} enthält ungültigen JavaScript-Code`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
|
||||
@ -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
87
backend/query_users.js
Normale Datei
@ -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);
|
||||
}
|
||||
@ -10,45 +10,14 @@ const router = express.Router();
|
||||
const { getDb } = require('../database');
|
||||
const { authenticateToken, requireAdmin } = require('../middleware/auth');
|
||||
const logger = require('../utils/logger');
|
||||
const backup = require('../utils/backup');
|
||||
|
||||
/**
|
||||
* Standard-Upload-Einstellungen
|
||||
* Standard-Upload-Einstellungen (neues Format mit Dateiendungen)
|
||||
*/
|
||||
const DEFAULT_UPLOAD_SETTINGS = {
|
||||
maxFileSizeMB: 15,
|
||||
allowedTypes: {
|
||||
images: {
|
||||
enabled: true,
|
||||
types: ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml']
|
||||
},
|
||||
documents: {
|
||||
enabled: true,
|
||||
types: ['application/pdf']
|
||||
},
|
||||
office: {
|
||||
enabled: true,
|
||||
types: [
|
||||
'application/msword',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'application/vnd.ms-excel',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'application/vnd.ms-powerpoint',
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation'
|
||||
]
|
||||
},
|
||||
text: {
|
||||
enabled: true,
|
||||
types: ['text/plain', 'text/csv', 'text/markdown']
|
||||
},
|
||||
archives: {
|
||||
enabled: true,
|
||||
types: ['application/zip', 'application/x-rar-compressed', 'application/x-7z-compressed']
|
||||
},
|
||||
data: {
|
||||
enabled: true,
|
||||
types: ['application/json']
|
||||
}
|
||||
}
|
||||
allowedExtensions: ['pdf', 'docx', 'txt']
|
||||
};
|
||||
|
||||
// Alle Admin-Routes erfordern Authentifizierung und Admin-Rolle
|
||||
@ -351,6 +320,17 @@ router.get('/upload-settings', (req, res) => {
|
||||
|
||||
if (setting) {
|
||||
const settings = JSON.parse(setting.value);
|
||||
|
||||
// Migration: Altes Format (allowedTypes) auf neues Format (allowedExtensions) umstellen
|
||||
if (settings.allowedTypes && !settings.allowedExtensions) {
|
||||
// Altes Format erkannt - auf Standard-Einstellungen zurücksetzen
|
||||
logger.info('Migriere Upload-Einstellungen auf neues Format (allowedExtensions)');
|
||||
db.prepare('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)')
|
||||
.run('upload_settings', JSON.stringify(DEFAULT_UPLOAD_SETTINGS));
|
||||
res.json(DEFAULT_UPLOAD_SETTINGS);
|
||||
return;
|
||||
}
|
||||
|
||||
res.json(settings);
|
||||
} else {
|
||||
// Standard-Einstellungen zurückgeben und speichern
|
||||
@ -369,24 +349,36 @@ router.get('/upload-settings', (req, res) => {
|
||||
*/
|
||||
router.put('/upload-settings', (req, res) => {
|
||||
try {
|
||||
const { maxFileSizeMB, allowedTypes } = req.body;
|
||||
const { maxFileSizeMB, allowedExtensions } = req.body;
|
||||
|
||||
// Validierung
|
||||
if (typeof maxFileSizeMB !== 'number' || maxFileSizeMB < 1 || maxFileSizeMB > 100) {
|
||||
return res.status(400).json({ error: 'Maximale Dateigröße muss zwischen 1 und 100 MB liegen' });
|
||||
}
|
||||
|
||||
if (!allowedTypes || typeof allowedTypes !== 'object') {
|
||||
return res.status(400).json({ error: 'Ungültige Dateityp-Konfiguration' });
|
||||
if (!Array.isArray(allowedExtensions) || allowedExtensions.length === 0) {
|
||||
return res.status(400).json({ error: 'Mindestens eine Dateiendung muss erlaubt sein' });
|
||||
}
|
||||
|
||||
const settings = { maxFileSizeMB, allowedTypes };
|
||||
// Endungen validieren (nur alphanumerisch, 1-10 Zeichen)
|
||||
const validExtensions = allowedExtensions
|
||||
.map(ext => ext.toLowerCase().replace(/^\./, '')) // Punkt am Anfang entfernen
|
||||
.filter(ext => /^[a-z0-9]{1,10}$/.test(ext));
|
||||
|
||||
if (validExtensions.length === 0) {
|
||||
return res.status(400).json({ error: 'Keine gültigen Dateiendungen angegeben' });
|
||||
}
|
||||
|
||||
// Duplikate entfernen
|
||||
const uniqueExtensions = [...new Set(validExtensions)];
|
||||
|
||||
const settings = { maxFileSizeMB, allowedExtensions: uniqueExtensions };
|
||||
|
||||
const db = getDb();
|
||||
db.prepare('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)')
|
||||
.run('upload_settings', JSON.stringify(settings));
|
||||
|
||||
logger.info(`Admin ${req.user.username} hat Upload-Einstellungen geändert`);
|
||||
logger.info(`Admin ${req.user.username} hat Upload-Einstellungen geändert: ${uniqueExtensions.join(', ')}`);
|
||||
|
||||
res.json(settings);
|
||||
} catch (error) {
|
||||
@ -404,7 +396,12 @@ function getUploadSettings() {
|
||||
const setting = db.prepare('SELECT value FROM settings WHERE key = ?').get('upload_settings');
|
||||
|
||||
if (setting) {
|
||||
return JSON.parse(setting.value);
|
||||
const settings = JSON.parse(setting.value);
|
||||
// Bei altem Format oder fehlendem allowedExtensions: Standard verwenden
|
||||
if (!settings.allowedExtensions || !Array.isArray(settings.allowedExtensions)) {
|
||||
return DEFAULT_UPLOAD_SETTINGS;
|
||||
}
|
||||
return settings;
|
||||
}
|
||||
return DEFAULT_UPLOAD_SETTINGS;
|
||||
} catch (error) {
|
||||
@ -413,6 +410,42 @@ function getUploadSettings() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/admin/backup/create - Sofortiges verschlüsseltes Backup erstellen
|
||||
*/
|
||||
router.post('/backup/create', (req, res) => {
|
||||
try {
|
||||
const backupPath = backup.createBackup();
|
||||
|
||||
if (backupPath) {
|
||||
logger.info(`Admin ${req.user.username} hat manuelles Backup erstellt`);
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Verschlüsseltes Backup erfolgreich erstellt',
|
||||
backupPath: backupPath.split('/').pop() // Nur Dateiname zurückgeben
|
||||
});
|
||||
} else {
|
||||
res.status(500).json({ error: 'Backup-Erstellung fehlgeschlagen' });
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Backup-Erstellung durch Admin fehlgeschlagen:', error);
|
||||
res.status(500).json({ error: 'Interner Fehler beim Erstellen des Backups' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/admin/backup/list - Liste aller verschlüsselten Backups
|
||||
*/
|
||||
router.get('/backup/list', (req, res) => {
|
||||
try {
|
||||
const backups = backup.listBackups();
|
||||
res.json(backups);
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Auflisten der Backups:', error);
|
||||
res.status(500).json({ error: 'Fehler beim Auflisten der Backups' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
module.exports.getUploadSettings = getUploadSettings;
|
||||
module.exports.DEFAULT_UPLOAD_SETTINGS = DEFAULT_UPLOAD_SETTINGS;
|
||||
|
||||
@ -8,7 +8,7 @@ const express = require('express');
|
||||
const router = express.Router();
|
||||
const bcrypt = require('bcryptjs');
|
||||
const { getDb } = require('../database');
|
||||
const { generateToken, authenticateToken } = require('../middleware/auth');
|
||||
const { generateToken, generateRefreshToken, refreshAccessToken, revokeAllRefreshTokens, authenticateToken } = require('../middleware/auth');
|
||||
const { getTokenForUser } = require('../middleware/csrf');
|
||||
const { validatePassword } = require('../middleware/validation');
|
||||
const logger = require('../utils/logger');
|
||||
@ -37,13 +37,8 @@ router.post('/login', async (req, res) => {
|
||||
|
||||
// Benutzer suchen: Zuerst nach Username "admin", dann nach E-Mail
|
||||
let user;
|
||||
if (username.toLowerCase() === 'admin') {
|
||||
// Admin-User per Username suchen
|
||||
user = db.prepare('SELECT * FROM users WHERE username = ?').get('admin');
|
||||
} else {
|
||||
// Normale User per E-Mail suchen
|
||||
user = db.prepare('SELECT * FROM users WHERE email = ?').get(username);
|
||||
}
|
||||
// User per Username suchen (kann E-Mail-Adresse oder admin sein)
|
||||
user = db.prepare('SELECT * FROM users WHERE username = ?').get(username);
|
||||
|
||||
// Audit-Log Eintrag vorbereiten
|
||||
const logAttempt = (userId, success) => {
|
||||
@ -111,8 +106,11 @@ router.post('/login', async (req, res) => {
|
||||
|
||||
logAttempt(user.id, true);
|
||||
|
||||
// JWT-Token generieren
|
||||
const token = generateToken(user);
|
||||
// JWT Access-Token generieren (kurze Lebensdauer)
|
||||
const accessToken = generateToken(user);
|
||||
|
||||
// Refresh-Token generieren (lange Lebensdauer)
|
||||
const refreshToken = generateRefreshToken(user.id, ip, userAgent);
|
||||
|
||||
// CSRF-Token generieren
|
||||
const csrfToken = getTokenForUser(user.id);
|
||||
@ -128,7 +126,8 @@ router.post('/login', async (req, res) => {
|
||||
}
|
||||
|
||||
res.json({
|
||||
token,
|
||||
token: accessToken,
|
||||
refreshToken,
|
||||
csrfToken,
|
||||
user: {
|
||||
id: user.id,
|
||||
@ -147,13 +146,19 @@ router.post('/login', async (req, res) => {
|
||||
|
||||
/**
|
||||
* POST /api/auth/logout
|
||||
* Benutzer abmelden
|
||||
* Benutzer abmelden und Refresh-Tokens widerrufen
|
||||
*/
|
||||
router.post('/logout', authenticateToken, (req, res) => {
|
||||
// Bei JWT gibt es serverseitig nichts zu tun
|
||||
// Client muss Token löschen
|
||||
logger.info(`Logout: ${req.user.username}`);
|
||||
res.json({ message: 'Erfolgreich abgemeldet' });
|
||||
try {
|
||||
// Alle Refresh-Tokens des Benutzers löschen
|
||||
revokeAllRefreshTokens(req.user.id);
|
||||
|
||||
logger.info(`Logout: ${req.user.username}`);
|
||||
res.json({ message: 'Erfolgreich abgemeldet' });
|
||||
} catch (error) {
|
||||
logger.error('Logout-Fehler:', { error: error.message });
|
||||
res.status(500).json({ error: 'Logout fehlgeschlagen' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
@ -200,27 +205,68 @@ router.get('/me', authenticateToken, (req, res) => {
|
||||
|
||||
/**
|
||||
* POST /api/auth/refresh
|
||||
* Token erneuern
|
||||
* Token mit Refresh-Token erneuern
|
||||
*/
|
||||
router.post('/refresh', authenticateToken, (req, res) => {
|
||||
router.post('/refresh', async (req, res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.user.id);
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: 'Benutzer nicht gefunden' });
|
||||
const { refreshToken } = req.body;
|
||||
const ip = req.ip || req.connection.remoteAddress;
|
||||
const userAgent = req.headers['user-agent'];
|
||||
|
||||
if (!refreshToken) {
|
||||
// Fallback für alte Clients - mit Access Token authentifizieren
|
||||
if (req.headers.authorization) {
|
||||
return legacyRefresh(req, res);
|
||||
}
|
||||
return res.status(400).json({ error: 'Refresh-Token erforderlich' });
|
||||
}
|
||||
|
||||
const token = generateToken(user);
|
||||
const csrfToken = getTokenForUser(user.id);
|
||||
// Neuen Access-Token mit Refresh-Token generieren
|
||||
const accessToken = await refreshAccessToken(refreshToken, ip, userAgent);
|
||||
const db = getDb();
|
||||
|
||||
// User-Daten für CSRF-Token abrufen
|
||||
const decoded = require('jsonwebtoken').decode(accessToken);
|
||||
const csrfToken = getTokenForUser(decoded.id);
|
||||
|
||||
res.json({ token, csrfToken });
|
||||
res.json({
|
||||
token: accessToken,
|
||||
csrfToken
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Token-Refresh Fehler:', { error: error.message });
|
||||
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||
res.status(401).json({ error: 'Token-Erneuerung fehlgeschlagen' });
|
||||
}
|
||||
});
|
||||
|
||||
// Legacy Refresh für Rückwärtskompatibilität
|
||||
function legacyRefresh(req, res) {
|
||||
// Prüfe Authorization Header
|
||||
const authHeader = req.headers['authorization'];
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return res.status(401).json({ error: 'Nicht authentifiziert' });
|
||||
}
|
||||
|
||||
const token = authHeader.substring(7);
|
||||
const user = require('../middleware/auth').verifyToken(token);
|
||||
|
||||
if (!user) {
|
||||
return res.status(401).json({ error: 'Token ungültig' });
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const dbUser = db.prepare('SELECT * FROM users WHERE id = ?').get(user.id);
|
||||
|
||||
if (!dbUser) {
|
||||
return res.status(404).json({ error: 'Benutzer nicht gefunden' });
|
||||
}
|
||||
|
||||
const newToken = generateToken(dbUser);
|
||||
const csrfToken = getTokenForUser(dbUser.id);
|
||||
|
||||
res.json({ token: newToken, csrfToken });
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/auth/password
|
||||
* Passwort ändern
|
||||
|
||||
643
backend/routes/coding.js
Normale Datei
643
backend/routes/coding.js
Normale Datei
@ -0,0 +1,643 @@
|
||||
/**
|
||||
* TASKMATE - Coding Routes
|
||||
* ========================
|
||||
* Verwaltung von Server-Anwendungen mit Claude/Codex Integration
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { getDb } = require('../database');
|
||||
const logger = require('../utils/logger');
|
||||
const gitService = require('../services/gitService');
|
||||
|
||||
/**
|
||||
* Prüft ob ein Pfad ein Server-Pfad (Linux) ist
|
||||
*/
|
||||
function isServerPath(localPath) {
|
||||
return localPath && localPath.startsWith('/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Schreibt CLAUDE.md in ein Verzeichnis
|
||||
*/
|
||||
function writeCLAUDEmd(directoryPath, content) {
|
||||
if (!content || !directoryPath) return false;
|
||||
|
||||
try {
|
||||
const claudePath = path.join(directoryPath, 'CLAUDE.md');
|
||||
fs.writeFileSync(claudePath, content, 'utf8');
|
||||
logger.info(`CLAUDE.md geschrieben: ${claudePath}`);
|
||||
return true;
|
||||
} catch (e) {
|
||||
logger.error('CLAUDE.md schreiben fehlgeschlagen:', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Liest CLAUDE.md aus einem Verzeichnis
|
||||
*/
|
||||
function readCLAUDEmd(directoryPath) {
|
||||
if (!directoryPath) {
|
||||
logger.info('readCLAUDEmd: No directoryPath provided');
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const claudePath = path.join(directoryPath, 'CLAUDE.md');
|
||||
logger.info(`readCLAUDEmd: Checking path ${claudePath}`);
|
||||
|
||||
if (fs.existsSync(claudePath)) {
|
||||
const content = fs.readFileSync(claudePath, 'utf8');
|
||||
logger.info(`readCLAUDEmd: Successfully read ${content.length} characters from ${claudePath}`);
|
||||
return content;
|
||||
} else {
|
||||
logger.info(`readCLAUDEmd: File does not exist: ${claudePath}`);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('CLAUDE.md lesen fehlgeschlagen:', e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/coding/directories
|
||||
* Alle Coding-Verzeichnisse abrufen
|
||||
*/
|
||||
router.get('/directories', (req, res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
const directories = db.prepare(`
|
||||
SELECT cd.*, u.display_name as creator_name
|
||||
FROM coding_directories cd
|
||||
LEFT JOIN users u ON cd.created_by = u.id
|
||||
ORDER BY cd.position ASC, cd.name ASC
|
||||
`).all();
|
||||
|
||||
res.json(directories.map(dir => {
|
||||
// CLAUDE.md aus dem Dateisystem lesen falls vorhanden
|
||||
let claudeMdFromDisk = null;
|
||||
if (isServerPath(dir.local_path)) {
|
||||
claudeMdFromDisk = readCLAUDEmd(dir.local_path);
|
||||
// Fallback: Wenn Pfad /home/claude-dev/TaskMate ist, versuche /app/taskmate-source
|
||||
if (!claudeMdFromDisk && dir.local_path === '/home/claude-dev/TaskMate') {
|
||||
logger.info('Trying fallback path for TaskMate: /app/taskmate-source');
|
||||
claudeMdFromDisk = readCLAUDEmd('/app/taskmate-source');
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: dir.id,
|
||||
name: dir.name,
|
||||
localPath: dir.local_path,
|
||||
description: dir.description,
|
||||
color: dir.color,
|
||||
claudeInstructions: dir.claude_instructions,
|
||||
claudeMdFromDisk: claudeMdFromDisk,
|
||||
hasCLAUDEmd: !!claudeMdFromDisk,
|
||||
giteaRepoUrl: dir.gitea_repo_url,
|
||||
giteaRepoOwner: dir.gitea_repo_owner,
|
||||
giteaRepoName: dir.gitea_repo_name,
|
||||
defaultBranch: dir.default_branch,
|
||||
lastSync: dir.last_sync,
|
||||
position: dir.position,
|
||||
createdAt: dir.created_at,
|
||||
createdBy: dir.created_by,
|
||||
creatorName: dir.creator_name
|
||||
};
|
||||
}));
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Abrufen der Coding-Verzeichnisse:', error);
|
||||
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/coding/directories
|
||||
* Neues Coding-Verzeichnis erstellen
|
||||
*/
|
||||
router.post('/directories', (req, res) => {
|
||||
try {
|
||||
const { name, localPath, description, color, claudeInstructions, giteaRepoUrl, giteaRepoOwner, giteaRepoName, defaultBranch } = req.body;
|
||||
|
||||
if (!name || !localPath) {
|
||||
return res.status(400).json({ error: 'Name und Server-Pfad sind erforderlich' });
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
|
||||
// Prüfe ob Pfad bereits existiert
|
||||
const existing = db.prepare('SELECT id FROM coding_directories WHERE local_path = ?').get(localPath);
|
||||
if (existing) {
|
||||
return res.status(400).json({ error: 'Diese Anwendung ist bereits registriert' });
|
||||
}
|
||||
|
||||
// Höchste Position ermitteln
|
||||
const maxPos = db.prepare('SELECT COALESCE(MAX(position), -1) as max FROM coding_directories').get().max;
|
||||
|
||||
const result = db.prepare(`
|
||||
INSERT INTO coding_directories (name, local_path, description, color, claude_instructions, gitea_repo_url, gitea_repo_owner, gitea_repo_name, default_branch, position, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
name,
|
||||
localPath,
|
||||
description || null,
|
||||
color || '#4F46E5',
|
||||
null, // claudeInstructions wird nicht mehr gespeichert
|
||||
giteaRepoUrl || null,
|
||||
giteaRepoOwner || null,
|
||||
giteaRepoName || null,
|
||||
defaultBranch || 'main',
|
||||
maxPos + 1,
|
||||
req.user.id
|
||||
);
|
||||
|
||||
const directory = db.prepare('SELECT * FROM coding_directories WHERE id = ?').get(result.lastInsertRowid);
|
||||
|
||||
// Ordner erstellen falls nicht vorhanden
|
||||
let directoryCreated = false;
|
||||
if (isServerPath(localPath) && !fs.existsSync(localPath)) {
|
||||
try {
|
||||
fs.mkdirSync(localPath, { recursive: true });
|
||||
directoryCreated = true;
|
||||
logger.info(`Anwendungsordner erstellt: ${localPath}`);
|
||||
} catch (e) {
|
||||
logger.error('Ordner erstellen fehlgeschlagen:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// CLAUDE.md wird nicht mehr geschrieben - nur readonly
|
||||
let claudeMdWritten = false;
|
||||
|
||||
logger.info(`Coding-Anwendung erstellt: ${name} (${localPath})`);
|
||||
|
||||
// CLAUDE.md aus dem Dateisystem lesen für aktuelle Anzeige
|
||||
const claudeMdFromDisk = isServerPath(directory.local_path) ? readCLAUDEmd(directory.local_path) : null;
|
||||
|
||||
res.status(201).json({
|
||||
id: directory.id,
|
||||
name: directory.name,
|
||||
localPath: directory.local_path,
|
||||
description: directory.description,
|
||||
color: directory.color,
|
||||
claudeInstructions: directory.claude_instructions,
|
||||
claudeMdFromDisk: claudeMdFromDisk,
|
||||
hasCLAUDEmd: !!claudeMdFromDisk,
|
||||
giteaRepoUrl: directory.gitea_repo_url,
|
||||
giteaRepoOwner: directory.gitea_repo_owner,
|
||||
giteaRepoName: directory.gitea_repo_name,
|
||||
defaultBranch: directory.default_branch,
|
||||
position: directory.position,
|
||||
createdAt: directory.created_at,
|
||||
directoryCreated,
|
||||
claudeMdWritten
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Erstellen der Coding-Anwendung:', error);
|
||||
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/coding/directories/:id
|
||||
* Coding-Anwendung aktualisieren
|
||||
*/
|
||||
router.put('/directories/:id', (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { name, localPath, description, color, claudeInstructions, giteaRepoUrl, giteaRepoOwner, giteaRepoName, defaultBranch, position } = req.body;
|
||||
|
||||
const db = getDb();
|
||||
|
||||
const existing = db.prepare('SELECT * FROM coding_directories WHERE id = ?').get(id);
|
||||
if (!existing) {
|
||||
return res.status(404).json({ error: 'Anwendung nicht gefunden' });
|
||||
}
|
||||
|
||||
// Prüfe ob neuer Pfad bereits von anderem Eintrag verwendet wird
|
||||
if (localPath && localPath !== existing.local_path) {
|
||||
const pathExists = db.prepare('SELECT id FROM coding_directories WHERE local_path = ? AND id != ?').get(localPath, id);
|
||||
if (pathExists) {
|
||||
return res.status(400).json({ error: 'Dieser Server-Pfad ist bereits registriert' });
|
||||
}
|
||||
}
|
||||
|
||||
db.prepare(`
|
||||
UPDATE coding_directories SET
|
||||
name = COALESCE(?, name),
|
||||
local_path = COALESCE(?, local_path),
|
||||
description = ?,
|
||||
color = COALESCE(?, color),
|
||||
claude_instructions = ?,
|
||||
gitea_repo_url = ?,
|
||||
gitea_repo_owner = ?,
|
||||
gitea_repo_name = ?,
|
||||
default_branch = COALESCE(?, default_branch),
|
||||
position = COALESCE(?, position)
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
name || null,
|
||||
localPath || null,
|
||||
description !== undefined ? description : existing.description,
|
||||
color || null,
|
||||
null, // claudeInstructions wird nicht mehr aktualisiert
|
||||
giteaRepoUrl !== undefined ? giteaRepoUrl : existing.gitea_repo_url,
|
||||
giteaRepoOwner !== undefined ? giteaRepoOwner : existing.gitea_repo_owner,
|
||||
giteaRepoName !== undefined ? giteaRepoName : existing.gitea_repo_name,
|
||||
defaultBranch || null,
|
||||
position !== undefined ? position : null,
|
||||
id
|
||||
);
|
||||
|
||||
const updated = db.prepare('SELECT * FROM coding_directories WHERE id = ?').get(id);
|
||||
const finalPath = updated.local_path;
|
||||
|
||||
// Ordner erstellen falls nicht vorhanden
|
||||
let directoryCreated = false;
|
||||
if (isServerPath(finalPath) && !fs.existsSync(finalPath)) {
|
||||
try {
|
||||
fs.mkdirSync(finalPath, { recursive: true });
|
||||
directoryCreated = true;
|
||||
logger.info(`Anwendungsordner erstellt: ${finalPath}`);
|
||||
} catch (e) {
|
||||
logger.error('Ordner erstellen fehlgeschlagen:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// CLAUDE.md wird nicht mehr geschrieben - nur readonly
|
||||
let claudeMdWritten = false;
|
||||
|
||||
logger.info(`Coding-Anwendung aktualisiert: ${updated.name}`);
|
||||
|
||||
// CLAUDE.md aus dem Dateisystem lesen für aktuelle Anzeige
|
||||
const claudeMdFromDisk = isServerPath(updated.local_path) ? readCLAUDEmd(updated.local_path) : null;
|
||||
|
||||
res.json({
|
||||
id: updated.id,
|
||||
name: updated.name,
|
||||
localPath: updated.local_path,
|
||||
description: updated.description,
|
||||
color: updated.color,
|
||||
claudeInstructions: updated.claude_instructions,
|
||||
claudeMdFromDisk: claudeMdFromDisk,
|
||||
hasCLAUDEmd: !!claudeMdFromDisk,
|
||||
giteaRepoUrl: updated.gitea_repo_url,
|
||||
giteaRepoOwner: updated.gitea_repo_owner,
|
||||
giteaRepoName: updated.gitea_repo_name,
|
||||
defaultBranch: updated.default_branch,
|
||||
position: updated.position,
|
||||
createdAt: updated.created_at,
|
||||
directoryCreated,
|
||||
claudeMdWritten
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Aktualisieren der Coding-Anwendung:', error);
|
||||
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/coding/directories/:id
|
||||
* Coding-Anwendung löschen
|
||||
*/
|
||||
router.delete('/directories/:id', (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const db = getDb();
|
||||
|
||||
const existing = db.prepare('SELECT * FROM coding_directories WHERE id = ?').get(id);
|
||||
if (!existing) {
|
||||
return res.status(404).json({ error: 'Anwendung nicht gefunden' });
|
||||
}
|
||||
|
||||
db.prepare('DELETE FROM coding_directories WHERE id = ?').run(id);
|
||||
|
||||
logger.info(`Coding-Anwendung gelöscht: ${existing.name}`);
|
||||
|
||||
res.json({ message: 'Anwendung gelöscht' });
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Löschen der Coding-Anwendung:', error);
|
||||
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/coding/directories/:id/status
|
||||
* Git-Status eines Verzeichnisses abrufen
|
||||
*/
|
||||
router.get('/directories/:id/status', (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const db = getDb();
|
||||
|
||||
const directory = db.prepare('SELECT * FROM coding_directories WHERE id = ?').get(id);
|
||||
if (!directory) {
|
||||
return res.status(404).json({ error: 'Anwendung nicht gefunden' });
|
||||
}
|
||||
|
||||
const localPath = directory.local_path;
|
||||
|
||||
// Prüfe ob es ein Git-Repository ist
|
||||
if (!gitService.isGitRepository(localPath)) {
|
||||
return res.json({
|
||||
isGitRepo: false,
|
||||
message: 'Kein Git-Repository'
|
||||
});
|
||||
}
|
||||
|
||||
const status = gitService.getStatus(localPath);
|
||||
|
||||
if (!status.success) {
|
||||
return res.status(500).json({ error: status.error });
|
||||
}
|
||||
|
||||
res.json({
|
||||
isGitRepo: true,
|
||||
branch: status.branch,
|
||||
hasChanges: status.hasChanges,
|
||||
changes: status.changes,
|
||||
ahead: status.ahead,
|
||||
behind: status.behind,
|
||||
isClean: status.isClean
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Abrufen des Git-Status:', error);
|
||||
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/coding/directories/:id/fetch
|
||||
* Git Fetch ausführen
|
||||
*/
|
||||
router.post('/directories/:id/fetch', (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const db = getDb();
|
||||
|
||||
const directory = db.prepare('SELECT * FROM coding_directories WHERE id = ?').get(id);
|
||||
if (!directory) {
|
||||
return res.status(404).json({ error: 'Anwendung nicht gefunden' });
|
||||
}
|
||||
|
||||
const result = gitService.fetchRemote(directory.local_path);
|
||||
|
||||
if (!result.success) {
|
||||
return res.status(500).json({ error: result.error });
|
||||
}
|
||||
|
||||
// Last sync aktualisieren
|
||||
db.prepare('UPDATE coding_directories SET last_sync = CURRENT_TIMESTAMP WHERE id = ?').run(id);
|
||||
|
||||
logger.info(`Git fetch ausgeführt für: ${directory.name}`);
|
||||
|
||||
res.json({ success: true, message: 'Fetch erfolgreich' });
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Git Fetch:', error);
|
||||
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/coding/directories/:id/pull
|
||||
* Git Pull ausführen
|
||||
*/
|
||||
router.post('/directories/:id/pull', (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const db = getDb();
|
||||
|
||||
const directory = db.prepare('SELECT * FROM coding_directories WHERE id = ?').get(id);
|
||||
if (!directory) {
|
||||
return res.status(404).json({ error: 'Anwendung nicht gefunden' });
|
||||
}
|
||||
|
||||
const result = gitService.pullChanges(directory.local_path, { branch: directory.default_branch });
|
||||
|
||||
if (!result.success) {
|
||||
return res.status(500).json({ error: result.error });
|
||||
}
|
||||
|
||||
// Last sync aktualisieren
|
||||
db.prepare('UPDATE coding_directories SET last_sync = CURRENT_TIMESTAMP WHERE id = ?').run(id);
|
||||
|
||||
logger.info(`Git pull ausgeführt für: ${directory.name}`);
|
||||
|
||||
res.json({ success: true, message: 'Pull erfolgreich', output: result.output });
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Git Pull:', error);
|
||||
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/coding/directories/:id/push
|
||||
* Git Push ausführen
|
||||
*/
|
||||
router.post('/directories/:id/push', (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { force } = req.body;
|
||||
const db = getDb();
|
||||
|
||||
const directory = db.prepare('SELECT * FROM coding_directories WHERE id = ?').get(id);
|
||||
if (!directory) {
|
||||
return res.status(404).json({ error: 'Anwendung nicht gefunden' });
|
||||
}
|
||||
|
||||
const result = gitService.pushWithUpstream(directory.local_path, directory.default_branch, 'origin', force);
|
||||
|
||||
if (!result.success) {
|
||||
return res.status(500).json({ error: result.error });
|
||||
}
|
||||
|
||||
// Last sync aktualisieren
|
||||
db.prepare('UPDATE coding_directories SET last_sync = CURRENT_TIMESTAMP WHERE id = ?').run(id);
|
||||
|
||||
logger.info(`Git push ausgeführt für: ${directory.name}`);
|
||||
|
||||
res.json({ success: true, message: 'Push erfolgreich', output: result.output });
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Git Push:', error);
|
||||
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/coding/directories/:id/commit
|
||||
* Git Commit ausführen
|
||||
*/
|
||||
router.post('/directories/:id/commit', (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { message } = req.body;
|
||||
const db = getDb();
|
||||
|
||||
if (!message || message.trim() === '') {
|
||||
return res.status(400).json({ error: 'Commit-Nachricht erforderlich' });
|
||||
}
|
||||
|
||||
const directory = db.prepare('SELECT * FROM coding_directories WHERE id = ?').get(id);
|
||||
if (!directory) {
|
||||
return res.status(404).json({ error: 'Anwendung nicht gefunden' });
|
||||
}
|
||||
|
||||
// Stage all changes
|
||||
const stageResult = gitService.stageAll(directory.local_path);
|
||||
if (!stageResult.success) {
|
||||
return res.status(500).json({ error: stageResult.error });
|
||||
}
|
||||
|
||||
// Commit with author info
|
||||
const author = {
|
||||
name: req.user.display_name || req.user.username,
|
||||
email: req.user.email || `${req.user.username}@taskmate.local`
|
||||
};
|
||||
|
||||
const result = gitService.commit(directory.local_path, message, author);
|
||||
|
||||
if (!result.success) {
|
||||
return res.status(500).json({ error: result.error });
|
||||
}
|
||||
|
||||
logger.info(`Git commit ausgeführt für: ${directory.name} - "${message}"`);
|
||||
|
||||
res.json({ success: true, message: 'Commit erfolgreich', output: result.output });
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Git Commit:', error);
|
||||
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/coding/directories/:id/branches
|
||||
* Branches abrufen
|
||||
*/
|
||||
router.get('/directories/:id/branches', (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const db = getDb();
|
||||
|
||||
const directory = db.prepare('SELECT * FROM coding_directories WHERE id = ?').get(id);
|
||||
if (!directory) {
|
||||
return res.status(404).json({ error: 'Anwendung nicht gefunden' });
|
||||
}
|
||||
|
||||
const result = gitService.getBranches(directory.local_path);
|
||||
|
||||
if (!result.success) {
|
||||
return res.status(500).json({ error: result.error });
|
||||
}
|
||||
|
||||
res.json({ branches: result.branches });
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Abrufen der Branches:', error);
|
||||
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/coding/directories/:id/checkout
|
||||
* Branch wechseln
|
||||
*/
|
||||
router.post('/directories/:id/checkout', (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { branch } = req.body;
|
||||
const db = getDb();
|
||||
|
||||
if (!branch) {
|
||||
return res.status(400).json({ error: 'Branch erforderlich' });
|
||||
}
|
||||
|
||||
const directory = db.prepare('SELECT * FROM coding_directories WHERE id = ?').get(id);
|
||||
if (!directory) {
|
||||
return res.status(404).json({ error: 'Anwendung nicht gefunden' });
|
||||
}
|
||||
|
||||
const result = gitService.checkoutBranch(directory.local_path, branch);
|
||||
|
||||
if (!result.success) {
|
||||
return res.status(500).json({ error: result.error });
|
||||
}
|
||||
|
||||
logger.info(`Branch gewechselt für ${directory.name}: ${branch}`);
|
||||
|
||||
res.json({ success: true, message: `Gewechselt zu Branch: ${branch}` });
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Branch-Wechsel:', error);
|
||||
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/coding/validate-path
|
||||
* Pfad validieren
|
||||
*/
|
||||
router.post('/validate-path', (req, res) => {
|
||||
try {
|
||||
const { path: localPath } = req.body;
|
||||
|
||||
if (!localPath) {
|
||||
return res.status(400).json({ error: 'Pfad erforderlich' });
|
||||
}
|
||||
|
||||
// Nur Server-Pfade können validiert werden
|
||||
if (isServerPath(localPath)) {
|
||||
const containerPath = gitService.windowsToContainerPath(localPath);
|
||||
const exists = fs.existsSync(containerPath);
|
||||
const isGitRepo = exists && gitService.isGitRepository(localPath);
|
||||
|
||||
res.json({
|
||||
valid: true,
|
||||
exists,
|
||||
isGitRepo,
|
||||
isServerPath: true
|
||||
});
|
||||
} else {
|
||||
// Windows-Pfad kann nicht serverseitig validiert werden
|
||||
res.json({
|
||||
valid: true,
|
||||
exists: null,
|
||||
isGitRepo: null,
|
||||
isServerPath: false,
|
||||
message: 'Windows-Pfade können nicht serverseitig validiert werden'
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Fehler bei der Pfad-Validierung:', error);
|
||||
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/coding/directories/:id/commits
|
||||
* Commit-Historie abrufen
|
||||
*/
|
||||
router.get('/directories/:id/commits', (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const limit = parseInt(req.query.limit) || 20;
|
||||
const db = getDb();
|
||||
|
||||
const directory = db.prepare('SELECT * FROM coding_directories WHERE id = ?').get(id);
|
||||
if (!directory) {
|
||||
return res.status(404).json({ error: 'Anwendung nicht gefunden' });
|
||||
}
|
||||
|
||||
const result = gitService.getCommitHistory(directory.local_path, limit);
|
||||
|
||||
if (!result.success) {
|
||||
return res.status(500).json({ error: result.error });
|
||||
}
|
||||
|
||||
res.json({ commits: result.commits });
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Abrufen der Commit-Historie:', error);
|
||||
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@ -4,6 +4,9 @@
|
||||
* Node.js/Express Backend mit Socket.io für Echtzeit-Sync
|
||||
*/
|
||||
|
||||
// Umgebungsvariablen laden (muss ganz oben stehen!)
|
||||
require('dotenv').config();
|
||||
|
||||
const express = require('express');
|
||||
const http = require('http');
|
||||
const { Server } = require('socket.io');
|
||||
@ -42,6 +45,7 @@ const gitRoutes = require('./routes/git');
|
||||
const applicationsRoutes = require('./routes/applications');
|
||||
const giteaRoutes = require('./routes/gitea');
|
||||
const knowledgeRoutes = require('./routes/knowledge');
|
||||
const codingRoutes = require('./routes/coding');
|
||||
|
||||
// Express App erstellen
|
||||
const app = express();
|
||||
@ -59,17 +63,18 @@ const io = new Server(server, {
|
||||
// MIDDLEWARE
|
||||
// =============================================================================
|
||||
|
||||
// Sicherheits-Header
|
||||
// Erweiterte Sicherheits-Header (CSP temporär deaktiviert für Login-Fix)
|
||||
app.use(helmet({
|
||||
contentSecurityPolicy: {
|
||||
directives: {
|
||||
defaultSrc: ["'self'"],
|
||||
styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com"],
|
||||
fontSrc: ["'self'", "https://fonts.gstatic.com"],
|
||||
imgSrc: ["'self'", "data:", "blob:"],
|
||||
scriptSrc: ["'self'"],
|
||||
connectSrc: ["'self'", "ws:", "wss:"]
|
||||
}
|
||||
contentSecurityPolicy: false, // Temporär deaktiviert
|
||||
hsts: {
|
||||
maxAge: 31536000, // 1 Jahr
|
||||
includeSubDomains: true,
|
||||
preload: true
|
||||
},
|
||||
noSniff: true,
|
||||
xssFilter: true,
|
||||
referrerPolicy: {
|
||||
policy: "strict-origin-when-cross-origin"
|
||||
}
|
||||
}));
|
||||
|
||||
@ -86,6 +91,10 @@ app.use(express.urlencoded({ extended: true, limit: '1mb' }));
|
||||
// Cookie Parser
|
||||
app.use(cookieParser());
|
||||
|
||||
// Input Sanitization (vor allen anderen Middlewares)
|
||||
const { sanitizeMiddleware } = require('./middleware/validation');
|
||||
app.use(sanitizeMiddleware);
|
||||
|
||||
// Request Logging
|
||||
app.use((req, res, next) => {
|
||||
const start = Date.now();
|
||||
@ -148,6 +157,9 @@ app.use('/api/gitea', authenticateToken, csrfProtection, giteaRoutes);
|
||||
// Knowledge-Routes (Wissensmanagement)
|
||||
app.use('/api/knowledge', authenticateToken, csrfProtection, knowledgeRoutes);
|
||||
|
||||
// Coding-Routes (Entwicklungsverzeichnisse mit Claude/Codex)
|
||||
app.use('/api/coding', authenticateToken, csrfProtection, codingRoutes);
|
||||
|
||||
// =============================================================================
|
||||
// SOCKET.IO
|
||||
// =============================================================================
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
237
backend/utils/encryption.js
Normale Datei
@ -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
|
||||
};
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren