- WebSocket Shell/RDP: Rollenprüfung statt nur JWT-Gültigkeit (war: jeder eingeloggte User konnte fremde Agents per Shell/RDP übernehmen) - JWT_SECRET: Server bricht ab statt mit unsicherem Default weiterzulaufen - Auth: Token läuft jetzt über httpOnly-Cookie statt localStorage (XSS-Schutz gegen Session-Diebstahl) - WS-Auth: Token nicht mehr als URL-Query-Param (landete in nginx-Logs), sondern als erste Message bzw. automatisch via Cookie - Frontend: toter Rollen-Check (isSuperAdmin/isAdmin ohne Funktionsaufruf) in AgentDetailPage gefixt - XSS: DOMPurify-Sanitizing für alle marked.parse()-Renderstellen (KI-Antworten, Kommentare, Knowledge Base) - E-Mail: HTML-Escaping für alle ticket-gesteuerten Felder (auch über öffentliche Ticket-Route erreichbar) - SSRF-Schutz beim Knowledge-Base-URL-Import (blockt private/Loopback-Adressen) - TV-Dashboard: Shared-Key statt komplett offenem Endpoint - Striktes Rate-Limit auf /login, must_change_password serverseitig erzwungen - Agent (C#) v2.7.0: RDP-Consent/Disconnect/Capture verlangen jetzt ein Pro-Session-Secret (war: jeder lokale Prozess konnte Consent vortäuschen), DataDir-ACL für agent.log/status.json - FIDO-PINs AES-256-GCM-verschlüsselt statt Klartext, Retention-Job für alte patch_commands/audit_log Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
368 lines
15 KiB
JavaScript
368 lines
15 KiB
JavaScript
const { asyncHandler, AppError } = require('../middleware/errorHandler');
|
|
const AiService = require('../services/ai.service');
|
|
const { getDatabase } = require('../config/database');
|
|
const path = require('path');
|
|
const fs = require('fs');
|
|
|
|
class AiController {
|
|
|
|
/**
|
|
* GET /api/ai/status
|
|
* Prüft ob AI konfiguriert ist
|
|
*/
|
|
static getStatus = asyncHandler(async (req, res) => {
|
|
res.json({
|
|
status: 'success',
|
|
data: { configured: AiService.isConfigured() }
|
|
});
|
|
});
|
|
|
|
/**
|
|
* POST /api/ai/chat
|
|
* Body: { messages: [{role, content}] }
|
|
*/
|
|
static chat = asyncHandler(async (req, res) => {
|
|
if (!AiService.isConfigured()) {
|
|
throw new AppError('KI-Integration ist nicht konfiguriert (ANTHROPIC_API_KEY fehlt)', 503);
|
|
}
|
|
|
|
const { messages, userMode } = req.body;
|
|
if (!messages || !Array.isArray(messages) || messages.length === 0) {
|
|
throw new AppError('messages Array ist erforderlich', 400);
|
|
}
|
|
|
|
// Nur erlaubte Rollen: user und assistant
|
|
const cleaned = messages
|
|
.filter(m => ['user', 'assistant'].includes(m.role) && typeof m.content === 'string')
|
|
.map(m => ({ role: m.role, content: m.content.slice(0, 4000) }));
|
|
|
|
if (cleaned.length === 0) {
|
|
throw new AppError('Keine gültigen Nachrichten', 400);
|
|
}
|
|
|
|
const reply = await AiService.chat(cleaned, !!userMode);
|
|
res.json({ status: 'success', data: { reply } });
|
|
});
|
|
|
|
/**
|
|
* GET /api/ai/knowledge-base
|
|
*/
|
|
static getKnowledgeBase = asyncHandler(async (req, res) => {
|
|
const db = getDatabase();
|
|
const entries = db.prepare(`
|
|
SELECT kb.*, u.username as created_by_username
|
|
FROM knowledge_base kb
|
|
LEFT JOIN users u ON kb.created_by_user_id = u.id
|
|
ORDER BY kb.created_at DESC
|
|
`).all();
|
|
res.json({ status: 'success', data: entries });
|
|
});
|
|
|
|
/**
|
|
* POST /api/ai/knowledge-base
|
|
* Body: { problem, solution, category, tags }
|
|
*/
|
|
static addKnowledgeEntry = asyncHandler(async (req, res) => {
|
|
const { problem, solution, category, tags } = req.body;
|
|
if (!problem?.trim() || !solution?.trim()) {
|
|
throw new AppError('problem und solution sind erforderlich', 400);
|
|
}
|
|
|
|
const db = getDatabase();
|
|
const result = db.prepare(`
|
|
INSERT INTO knowledge_base (problem, solution, category, tags, created_by_user_id)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
`).run(
|
|
problem.trim(),
|
|
solution.trim(),
|
|
category || 'Allgemein',
|
|
tags?.trim() || null,
|
|
req.user.id
|
|
);
|
|
|
|
const entry = db.prepare('SELECT * FROM knowledge_base WHERE id = ?').get(result.lastInsertRowid);
|
|
res.status(201).json({ status: 'success', data: entry });
|
|
});
|
|
|
|
/**
|
|
* PUT /api/ai/knowledge-base/:id
|
|
* Body: { problem, solution, category, tags }
|
|
*/
|
|
static updateKnowledgeEntry = asyncHandler(async (req, res) => {
|
|
const db = getDatabase();
|
|
const entry = db.prepare('SELECT id FROM knowledge_base WHERE id = ?').get(parseInt(req.params.id));
|
|
if (!entry) throw new AppError('Eintrag nicht gefunden', 404);
|
|
|
|
const { problem, solution, category, tags } = req.body;
|
|
if (!problem?.trim() || !solution?.trim()) {
|
|
throw new AppError('problem und solution sind erforderlich', 400);
|
|
}
|
|
|
|
db.prepare(`
|
|
UPDATE knowledge_base SET problem = ?, solution = ?, category = ?, tags = ? WHERE id = ?
|
|
`).run(problem.trim(), solution.trim(), category || 'Allgemein', tags?.trim() || null, parseInt(req.params.id));
|
|
|
|
const updated = db.prepare(`
|
|
SELECT kb.*, u.username as created_by_username
|
|
FROM knowledge_base kb LEFT JOIN users u ON kb.created_by_user_id = u.id
|
|
WHERE kb.id = ?
|
|
`).get(parseInt(req.params.id));
|
|
res.json({ status: 'success', data: updated });
|
|
});
|
|
|
|
/**
|
|
* POST /api/ai/knowledge-base/import-text
|
|
* Body: { text } OR { file: "<base64>", filename: "doc.pdf" }
|
|
*/
|
|
static importTextToKb = asyncHandler(async (req, res) => {
|
|
const AiService = require('../services/ai.service');
|
|
let text = req.body.text;
|
|
|
|
// Handle file upload (base64 encoded)
|
|
if (!text && req.body.file && req.body.filename) {
|
|
const { file, filename } = req.body;
|
|
const ext = filename.split('.').pop().toLowerCase();
|
|
const match = file.match(/^data:[^;]+;base64,(.+)$/);
|
|
const buf = Buffer.from(match ? match[1] : file, 'base64');
|
|
|
|
if (ext === 'pdf') {
|
|
const pdfParse = require('pdf-parse');
|
|
const parsed = await pdfParse(buf);
|
|
text = parsed.text;
|
|
} else if (ext === 'docx' || ext === 'doc') {
|
|
const mammoth = require('mammoth');
|
|
const result = await mammoth.extractRawText({ buffer: buf });
|
|
text = result.value;
|
|
} else if (ext === 'eml' || ext === 'msg') {
|
|
const { simpleParser } = require('mailparser');
|
|
const parsed = await simpleParser(buf);
|
|
text = `Betreff: ${parsed.subject || ''}\nVon: ${parsed.from?.text || ''}\n\n${parsed.text || parsed.html || ''}`;
|
|
} else if (ext === 'txt') {
|
|
text = buf.toString('utf-8');
|
|
} else {
|
|
throw new AppError('Unterstützte Formate: PDF, DOCX, EML, TXT', 400);
|
|
}
|
|
}
|
|
|
|
if (!text?.trim()) throw new AppError('text oder Datei ist erforderlich', 400);
|
|
|
|
const ids = await AiService.importTextToKb(text, req.user.id);
|
|
res.json({ status: 'success', data: { created: ids.length, ids } });
|
|
});
|
|
|
|
/**
|
|
* POST /api/ai/knowledge-base/import-url
|
|
* Body: { url }
|
|
* Fetches a web page, strips HTML, extracts text, and imports it via AI
|
|
*/
|
|
static importUrlToKb = asyncHandler(async (req, res) => {
|
|
const AiService = require('../services/ai.service');
|
|
const { url } = req.body;
|
|
if (!url?.trim()) throw new AppError('URL ist erforderlich', 400);
|
|
|
|
const { assertPublicUrl } = require('../utils/ssrfGuard');
|
|
try {
|
|
await assertPublicUrl(url);
|
|
} catch (e) {
|
|
throw new AppError(e.message, 400);
|
|
}
|
|
|
|
let html;
|
|
try {
|
|
const response = await fetch(url, {
|
|
headers: { 'User-Agent': 'Mozilla/5.0 IT-Nexus KnowledgeBase Importer' },
|
|
signal: AbortSignal.timeout(15000),
|
|
});
|
|
if (!response.ok) throw new AppError(`Seite nicht erreichbar: HTTP ${response.status}`, 400);
|
|
html = await response.text();
|
|
} catch (err) {
|
|
if (err instanceof AppError) throw err;
|
|
throw new AppError(`Fehler beim Abrufen der URL: ${err.message}`, 400);
|
|
}
|
|
|
|
// Strip scripts, styles, nav, header, footer then all HTML tags
|
|
const text = html
|
|
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
|
|
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
|
|
.replace(/<nav[^>]*>[\s\S]*?<\/nav>/gi, '')
|
|
.replace(/<header[^>]*>[\s\S]*?<\/header>/gi, '')
|
|
.replace(/<footer[^>]*>[\s\S]*?<\/footer>/gi, '')
|
|
.replace(/<[^>]+>/g, ' ')
|
|
.replace(/ /g, ' ').replace(/&/g, '&').replace(/</g, '<')
|
|
.replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'")
|
|
.replace(/\s+/g, ' ')
|
|
.trim();
|
|
|
|
if (text.length < 100) throw new AppError('Zu wenig Inhalt auf der Seite gefunden', 400);
|
|
|
|
const ids = await AiService.importTextToKb(text, req.user.id);
|
|
res.json({ status: 'success', data: { created: ids.length, ids } });
|
|
});
|
|
|
|
/**
|
|
* POST /api/ai/knowledge-base/import-crawl
|
|
* Body: { url, maxPages }
|
|
* Crawls a website (BFS within same domain+path) and imports all pages via AI
|
|
*/
|
|
static importCrawlToKb = asyncHandler(async (req, res) => {
|
|
const AiService = require('../services/ai.service');
|
|
const { url, maxPages = 20 } = req.body;
|
|
if (!url?.trim()) throw new AppError('URL ist erforderlich', 400);
|
|
const { assertPublicUrl } = require('../utils/ssrfGuard');
|
|
try {
|
|
await assertPublicUrl(url);
|
|
} catch (e) {
|
|
throw new AppError(e.message, 400);
|
|
}
|
|
|
|
const limit = Math.min(Math.max(1, parseInt(maxPages) || 20), 100);
|
|
const baseUrl = new URL(url);
|
|
const baseDomain = baseUrl.hostname;
|
|
// basePath: bei index-Seiten oder explizit deaktiviertem Pfad-Filter nur nach Hostname filtern
|
|
let basePath = baseUrl.pathname.substring(0, baseUrl.pathname.lastIndexOf('/') + 1);
|
|
if (/\/index\.(html?|htm)$/i.test(baseUrl.pathname)) {
|
|
basePath = '/'; // Gesamte Domain crawlen wenn Index-Seite
|
|
}
|
|
|
|
const stripHtml = (html) => html
|
|
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
|
|
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
|
|
.replace(/<nav[^>]*>[\s\S]*?<\/nav>/gi, '')
|
|
.replace(/<header[^>]*>[\s\S]*?<\/header>/gi, '')
|
|
.replace(/<footer[^>]*>[\s\S]*?<\/footer>/gi, '')
|
|
.replace(/<[^>]+>/g, ' ')
|
|
.replace(/ /g, ' ').replace(/&/g, '&').replace(/</g, '<')
|
|
.replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'")
|
|
.replace(/\s+/g, ' ').trim();
|
|
|
|
const visited = new Set();
|
|
const queue = [url];
|
|
const texts = [];
|
|
|
|
while (queue.length > 0 && visited.size < limit) {
|
|
const currentUrl = queue.shift();
|
|
if (visited.has(currentUrl)) continue;
|
|
visited.add(currentUrl);
|
|
|
|
try {
|
|
try { await assertPublicUrl(currentUrl); } catch { continue; } // DNS-Rebinding-Schutz
|
|
const response = await fetch(currentUrl, {
|
|
headers: { 'User-Agent': 'Mozilla/5.0 IT-Nexus KnowledgeBase Importer' },
|
|
signal: AbortSignal.timeout(10000),
|
|
});
|
|
if (!response.ok) continue;
|
|
const html = await response.text();
|
|
|
|
const text = stripHtml(html);
|
|
if (text.length > 100) texts.push(text);
|
|
|
|
// Extract links within same domain + path prefix
|
|
const linkRegex = /href="([^"#?]+)"/gi;
|
|
let match;
|
|
while ((match = linkRegex.exec(html)) !== null) {
|
|
try {
|
|
const linkUrl = new URL(match[1], currentUrl);
|
|
if (
|
|
linkUrl.hostname === baseDomain &&
|
|
linkUrl.pathname.startsWith(basePath) &&
|
|
!visited.has(linkUrl.href) &&
|
|
!queue.includes(linkUrl.href)
|
|
) {
|
|
queue.push(linkUrl.href);
|
|
}
|
|
} catch { /* ignore invalid URLs */ }
|
|
}
|
|
} catch { /* ignore fetch errors for individual pages */ }
|
|
}
|
|
|
|
if (texts.length === 0) throw new AppError('Keine Inhalte gefunden', 400);
|
|
|
|
// Process in chunks of 40k chars to stay within AI context limits
|
|
const CHUNK = 40000;
|
|
const combined = texts.join('\n\n---\n\n');
|
|
let allIds = [];
|
|
for (let i = 0; i < combined.length; i += CHUNK) {
|
|
const chunk = combined.slice(i, i + CHUNK);
|
|
if (chunk.trim().length > 100) {
|
|
const ids = await AiService.importTextToKb(chunk, req.user.id);
|
|
allIds = allIds.concat(ids);
|
|
}
|
|
}
|
|
|
|
res.json({ status: 'success', data: { created: allIds.length, pages: visited.size, ids: allIds } });
|
|
});
|
|
|
|
/**
|
|
* POST /api/ai/knowledge-base/:id/images
|
|
* Body: { image: "<base64>", filename: "foto.png" }
|
|
*/
|
|
static uploadKbImage = asyncHandler(async (req, res) => {
|
|
const db = getDatabase();
|
|
const id = parseInt(req.params.id);
|
|
const entry = db.prepare('SELECT id, images FROM knowledge_base WHERE id = ?').get(id);
|
|
if (!entry) throw new AppError('Eintrag nicht gefunden', 404);
|
|
|
|
const { image, filename } = req.body;
|
|
if (!image || !filename) throw new AppError('image und filename erforderlich', 400);
|
|
|
|
// Validate base64 image
|
|
const match = image.match(/^data:(image\/(png|jpe?g|gif|webp));base64,(.+)$/);
|
|
if (!match) throw new AppError('Ungültiges Bildformat (PNG, JPG, GIF, WEBP erlaubt)', 400);
|
|
|
|
const ext = match[2].replace('jpeg', 'jpg');
|
|
const safeName = `kb-${id}-${Date.now()}.${ext}`;
|
|
const uploadDir = path.join(__dirname, '../../uploads/kb');
|
|
fs.mkdirSync(uploadDir, { recursive: true });
|
|
fs.writeFileSync(path.join(uploadDir, safeName), Buffer.from(match[3], 'base64'));
|
|
|
|
const images = JSON.parse(entry.images || '[]');
|
|
images.push(safeName);
|
|
db.prepare('UPDATE knowledge_base SET images = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?')
|
|
.run(JSON.stringify(images), id);
|
|
|
|
res.json({ status: 'success', data: { filename: safeName, url: `/uploads/kb/${safeName}` } });
|
|
});
|
|
|
|
/**
|
|
* DELETE /api/ai/knowledge-base/:id/images/:filename
|
|
*/
|
|
static deleteKbImage = asyncHandler(async (req, res) => {
|
|
const db = getDatabase();
|
|
const id = parseInt(req.params.id);
|
|
const { filename } = req.params;
|
|
const entry = db.prepare('SELECT id, images FROM knowledge_base WHERE id = ?').get(id);
|
|
if (!entry) throw new AppError('Eintrag nicht gefunden', 404);
|
|
|
|
const images = JSON.parse(entry.images || '[]').filter(f => f !== filename);
|
|
db.prepare('UPDATE knowledge_base SET images = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?')
|
|
.run(JSON.stringify(images), id);
|
|
|
|
// Delete file
|
|
const filePath = path.join(__dirname, '../../uploads/kb', filename);
|
|
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
|
|
|
|
res.json({ status: 'success', message: 'Bild gelöscht' });
|
|
});
|
|
|
|
/**
|
|
* DELETE /api/ai/knowledge-base/:id
|
|
*/
|
|
static deleteKnowledgeEntry = asyncHandler(async (req, res) => {
|
|
const db = getDatabase();
|
|
const entry = db.prepare('SELECT id, images FROM knowledge_base WHERE id = ?').get(parseInt(req.params.id));
|
|
if (!entry) throw new AppError('Eintrag nicht gefunden', 404);
|
|
|
|
// Delete all associated images
|
|
const images = JSON.parse(entry.images || '[]');
|
|
for (const f of images) {
|
|
const fp = path.join(__dirname, '../../uploads/kb', f);
|
|
if (fs.existsSync(fp)) fs.unlinkSync(fp);
|
|
}
|
|
|
|
db.prepare('DELETE FROM knowledge_base WHERE id = ?').run(parseInt(req.params.id));
|
|
res.json({ status: 'success', message: 'Eintrag gelöscht' });
|
|
});
|
|
}
|
|
|
|
module.exports = AiController;
|