Wissensdatenbank: Markdown, FTS5-Suche, Sanitizing, UX

- Markdown-Rendering fuer Notizen (fett, kursiv, Ueberschriften, Listen, Code, Links)
- HTML-Sanitizing im Frontend und Backend (XSS-Schutz)
- FTS5 Volltextindex fuer schnelle Suche mit Ranking
- Kategorie-Loeschung zeigt Anzahl betroffener Eintraege

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Dieser Commit ist enthalten in:
Server Deploy
2026-03-19 19:38:43 +01:00
Ursprung 5c87254e97
Commit 48c917eb28
6 geänderte Dateien mit 277 neuen und 23 gelöschten Zeilen

Datei anzeigen

@@ -552,6 +552,43 @@ function createTables() {
)
`);
// Wissensmanagement - FTS5 Volltextsuche
try {
db.exec(`
CREATE VIRTUAL TABLE IF NOT EXISTS knowledge_fts USING fts5(
title, notes, content=knowledge_entries, content_rowid=id
)
`);
// Trigger: INSERT synchronisieren
db.exec(`
CREATE TRIGGER IF NOT EXISTS knowledge_fts_insert AFTER INSERT ON knowledge_entries BEGIN
INSERT INTO knowledge_fts(rowid, title, notes) VALUES (NEW.id, NEW.title, NEW.notes);
END
`);
// Trigger: DELETE synchronisieren
db.exec(`
CREATE TRIGGER IF NOT EXISTS knowledge_fts_delete AFTER DELETE ON knowledge_entries BEGIN
INSERT INTO knowledge_fts(knowledge_fts, rowid, title, notes) VALUES('delete', OLD.id, OLD.title, OLD.notes);
END
`);
// Trigger: UPDATE synchronisieren
db.exec(`
CREATE TRIGGER IF NOT EXISTS knowledge_fts_update AFTER UPDATE ON knowledge_entries BEGIN
INSERT INTO knowledge_fts(knowledge_fts, rowid, title, notes) VALUES('delete', OLD.id, OLD.title, OLD.notes);
INSERT INTO knowledge_fts(rowid, title, notes) VALUES (NEW.id, NEW.title, NEW.notes);
END
`);
// Initiales Befuellen der FTS-Tabelle
db.exec(`INSERT INTO knowledge_fts(knowledge_fts) VALUES('rebuild')`);
logger.info('Knowledge FTS5 Volltextindex erstellt/aktualisiert');
} catch (ftsError) {
logger.warn('FTS5 konnte nicht erstellt werden (evtl. nicht unterstuetzt):', ftsError.message);
}
// Wissensmanagement - Anhänge
db.exec(`
CREATE TABLE IF NOT EXISTS knowledge_attachments (

Datei anzeigen

@@ -14,6 +14,23 @@ const logger = require('../utils/logger');
const { validators, stripHtml } = require('../middleware/validation');
const notificationService = require('../services/notificationService');
/**
* Sanitize Notes - entfernt gefaehrliche Tags/Attribute, laesst Markdown durch
*/
function sanitizeNotes(text) {
if (!text) return text;
// Entferne script, iframe, object, embed, form, style Tags inkl. Inhalt
let clean = text.replace(/<\s*(script|iframe|object|embed|form|style|link|meta|base)\b[^>]*>[\s\S]*?<\s*\/\s*\1\s*>/gi, '');
// Entferne selbstschliessende gefaehrliche Tags
clean = clean.replace(/<\s*(script|iframe|object|embed|form|style|link|meta|base)\b[^>]*\/?>/gi, '');
// Entferne on*-Event-Attribute
clean = clean.replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, '');
// Entferne javascript: URLs
clean = clean.replace(/href\s*=\s*(?:"javascript:[^"]*"|'javascript:[^']*')/gi, 'href="#"');
clean = clean.replace(/src\s*=\s*(?:"javascript:[^"]*"|'javascript:[^']*')/gi, 'src=""');
return clean;
}
// Upload-Konfiguration für Knowledge-Anhänge
const UPLOAD_DIR = path.join(__dirname, '..', 'uploads', 'knowledge');
@@ -489,7 +506,7 @@ router.post('/entries', (req, res) => {
categoryId,
stripHtml(title),
url || null,
notes || null,
notes ? sanitizeNotes(notes) : null,
0, // Neue Einträge immer an Position 0 (oben)
req.user.id
);
@@ -614,7 +631,7 @@ router.put('/entries/:id', (req, res) => {
categoryId || null,
title ? stripHtml(title) : null,
url !== undefined ? url : existing.url,
notes !== undefined ? notes : existing.notes,
notes !== undefined ? (notes ? sanitizeNotes(notes) : notes) : existing.notes,
entryId
);
@@ -936,7 +953,7 @@ router.get('/search', (req, res) => {
const searchTerm = `%${q.toLowerCase()}%`;
const db = getDb();
// Kategorien durchsuchen
// Kategorien durchsuchen (kein FTS noetig - wenige Datensaetze)
const categories = db.prepare(`
SELECT kc.*,
(SELECT COUNT(*) FROM knowledge_entries WHERE category_id = kc.id) as entry_count
@@ -945,23 +962,43 @@ router.get('/search', (req, res) => {
ORDER BY kc.position
`).all(searchTerm, searchTerm);
// Einträge durchsuchen
const entries = db.prepare(`
SELECT ke.*,
kc.name as category_name,
kc.color as category_color,
(SELECT COUNT(*) FROM knowledge_attachments WHERE entry_id = ke.id) as attachment_count
FROM knowledge_entries ke
LEFT JOIN knowledge_categories kc ON ke.category_id = kc.id
WHERE LOWER(ke.title) LIKE ? OR LOWER(ke.notes) LIKE ? OR LOWER(ke.url) LIKE ?
ORDER BY
CASE
WHEN LOWER(ke.title) LIKE ? THEN 1
ELSE 2
END,
ke.created_at DESC
LIMIT 50
`).all(searchTerm, searchTerm, searchTerm, searchTerm);
// Eintraege durchsuchen - FTS5 mit Fallback auf LIKE
let entries;
try {
// FTS5 Suche: Sonderzeichen escapen und Prefix-Suche
const ftsQuery = q.trim().replace(/['"*()]/g, '').split(/\s+/).map(t => `"${t}"*`).join(' ');
entries = db.prepare(`
SELECT ke.*,
kc.name as category_name,
kc.color as category_color,
(SELECT COUNT(*) FROM knowledge_attachments WHERE entry_id = ke.id) as attachment_count
FROM knowledge_fts fts
JOIN knowledge_entries ke ON ke.id = fts.rowid
LEFT JOIN knowledge_categories kc ON ke.category_id = kc.id
WHERE knowledge_fts MATCH ?
ORDER BY rank
LIMIT 50
`).all(ftsQuery);
} catch (ftsError) {
// Fallback auf LIKE wenn FTS nicht verfuegbar
logger.warn('FTS5-Suche fehlgeschlagen, Fallback auf LIKE:', ftsError.message);
entries = db.prepare(`
SELECT ke.*,
kc.name as category_name,
kc.color as category_color,
(SELECT COUNT(*) FROM knowledge_attachments WHERE entry_id = ke.id) as attachment_count
FROM knowledge_entries ke
LEFT JOIN knowledge_categories kc ON ke.category_id = kc.id
WHERE LOWER(ke.title) LIKE ? OR LOWER(ke.notes) LIKE ? OR LOWER(ke.url) LIKE ?
ORDER BY
CASE
WHEN LOWER(ke.title) LIKE ? THEN 1
ELSE 2
END,
ke.created_at DESC
LIMIT 50
`).all(searchTerm, searchTerm, searchTerm, searchTerm);
}
res.json({
categories: categories.map(c => ({