Files
IT-Nexus/backend/src/controllers/ai.controller.js
Simon Grüssing 81b1c326fc
Some checks failed
IT Nexus Deploy / Build Frontend (push) Has been cancelled
IT Nexus Deploy / Deploy to Production (push) Has been cancelled
Security: WS-Rollenprüfung, JWT-Cookie statt localStorage, XSS/SSRF-Fixes, RDP-Consent-Secret
- 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>
2026-06-26 13:27:17 +02:00

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(/&nbsp;/g, ' ').replace(/&amp;/g, '&').replace(/&lt;/g, '<')
.replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&#39;/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(/&nbsp;/g, ' ').replace(/&amp;/g, '&').replace(/&lt;/g, '<')
.replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&#39;/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;