diff --git a/CHANGELOG.txt b/CHANGELOG.txt index 8324595..9e3c040 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -1,6 +1,16 @@ TASKMATE - CHANGELOG ==================== +================================================================================ +19.03.2026 - v392 - Wissensdatenbank: Markdown, Volltextsuche, Sanitizing + +WISSENSDATENBANK: +- Markdown-Rendering fuer Notizen (fett, kursiv, Ueberschriften, Listen, Links, Code-Bloecke) +- HTML-Sanitizing im Frontend und Backend (script/iframe/on*-Attribute entfernt) +- FTS5 Volltextindex fuer schnellere Suche mit Fallback auf LIKE +- Kategorie-Loeschung zeigt jetzt Eintragsanzahl im Bestaetigungsdialog +- CSS-Styles fuer gerenderte Markdown-Elemente in Notizen + ================================================================================ 19.03.2026 - v391 - Sicherheitshärtung und toter Code bereinigt diff --git a/backend/database.js b/backend/database.js index 82ac33b..61e37ea 100644 --- a/backend/database.js +++ b/backend/database.js @@ -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 ( diff --git a/backend/routes/knowledge.js b/backend/routes/knowledge.js index 1dd0f86..7362380 100644 --- a/backend/routes/knowledge.js +++ b/backend/routes/knowledge.js @@ -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 => ({ diff --git a/frontend/css/knowledge.css b/frontend/css/knowledge.css index 8d5e1ea..f672f7f 100644 --- a/frontend/css/knowledge.css +++ b/frontend/css/knowledge.css @@ -441,6 +441,84 @@ white-space: pre-wrap; } +/* Markdown Styles in Notizen */ +.knowledge-entry-notes h1 { + font-size: 1.2rem; + font-weight: 700; + color: var(--text-primary); + margin: 0.5rem 0 0.25rem 0; +} + +.knowledge-entry-notes h2 { + font-size: 1.1rem; + font-weight: 600; + color: var(--text-primary); + margin: 0.5rem 0 0.25rem 0; +} + +.knowledge-entry-notes h3 { + font-size: 1rem; + font-weight: 600; + color: var(--text-primary); + margin: 0.4rem 0 0.2rem 0; +} + +.knowledge-entry-notes code { + background: var(--bg-tertiary); + padding: 2px 6px; + border-radius: var(--radius-sm); + font-family: 'Courier New', monospace; + font-size: 0.85em; +} + +.knowledge-entry-notes pre { + background: var(--bg-tertiary); + padding: var(--spacing-sm) var(--spacing-md); + border-radius: var(--radius-md); + overflow-x: auto; + margin: 0.5rem 0; +} + +.knowledge-entry-notes pre code { + background: transparent; + padding: 0; + border-radius: 0; + font-size: 0.85rem; + line-height: 1.5; +} + +.knowledge-entry-notes ul, +.knowledge-entry-notes ol { + padding-left: 1.5rem; + margin: 0.25rem 0; +} + +.knowledge-entry-notes li { + margin: 0.15rem 0; +} + +.knowledge-entry-notes a { + color: var(--primary); + text-decoration: underline; +} + +.knowledge-entry-notes a:hover { + opacity: 0.8; +} + +.knowledge-entry-notes strong { + font-weight: 700; + color: var(--text-primary); +} + +.knowledge-entry-notes em { + font-style: italic; +} + +.knowledge-entry-notes p { + margin: 0.25rem 0; +} + .knowledge-entry-attachments-info { display: flex; align-items: center; diff --git a/frontend/js/knowledge.js b/frontend/js/knowledge.js index d860b0e..2ea23c4 100644 --- a/frontend/js/knowledge.js +++ b/frontend/js/knowledge.js @@ -348,7 +348,7 @@ class KnowledgeManager { ` : ''} ${hasNotes ? ` -
${this.escapeHtml(entry.notes)}
+
${this.renderMarkdown(this.sanitizeHtml(entry.notes))}
` : ''} ${hasAttachments ? `
@@ -937,7 +937,11 @@ class KnowledgeManager { const category = this.categories.find(c => c.id === categoryId); if (!category) return; - const confirmDelete = confirm(`Kategorie "${category.name}" und alle Einträge wirklich löschen?`); + const count = category.entryCount || 0; + const msg = count > 0 + ? `Kategorie "${category.name}" mit ${count} Einträgen löschen? Dies kann nicht rückgängig gemacht werden.` + : `Kategorie "${category.name}" löschen? Dies kann nicht rückgängig gemacht werden.`; + const confirmDelete = confirm(msg); if (!confirmDelete) return; try { @@ -1239,6 +1243,94 @@ class KnowledgeManager { } } + // ========================================== + // MARKDOWN RENDERING + // ========================================== + + /** + * Sanitize HTML - entfernt gefaehrliche Tags und Attribute + * Muss VOR renderMarkdown aufgerufen werden + */ + sanitizeHtml(html) { + if (!html) return ''; + // Entferne script, iframe, object, embed, form, style Tags + let clean = html.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 (onclick, onerror, onload etc.) + 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; + } + + /** + * Einfacher Markdown-Renderer + * Unterstuetzt: fett, kursiv, Ueberschriften, Listen, Links, Code, Zeilenumbrueche + */ + renderMarkdown(text) { + if (!text) return ''; + + // Zuerst HTML-Entities escapen fuer Sicherheit + let html = text + .replace(/&/g, '&') + .replace(//g, '>'); + + // Code-Bloecke (dreifache Backticks) - vor anderen Regeln + html = html.replace(/```(\w*)\n?([\s\S]*?)```/g, (match, lang, code) => { + return `
${code.trim()}
`; + }); + + // Inline-Code (einfache Backticks) + html = html.replace(/`([^`\n]+)`/g, '$1'); + + // Ueberschriften (# bis ###) + html = html.replace(/^### (.+)$/gm, '

$1

'); + html = html.replace(/^## (.+)$/gm, '

$1

'); + html = html.replace(/^# (.+)$/gm, '

$1

'); + + // Fett (**text** oder __text__) + html = html.replace(/\*\*(.+?)\*\*/g, '$1'); + html = html.replace(/__(.+?)__/g, '$1'); + + // Kursiv (*text* oder _text_) + html = html.replace(/(?$1'); + html = html.replace(/(?$1'); + + // Links [text](url) + html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1'); + + // Ungeordnete Listen (- oder * am Zeilenanfang) + html = html.replace(/^(?:- |\* )(.+)$/gm, '
  • $1
  • '); + html = html.replace(/((?:
  • .*<\/li>\n?)+)/g, ''); + + // Geordnete Listen (1. am Zeilenanfang) + html = html.replace(/^\d+\. (.+)$/gm, '$1'); + html = html.replace(/((?:.*<\/oli>\n?)+)/g, (match) => { + return '
      ' + match.replace(/<\/?oli>/g, (tag) => tag.replace('oli', 'li')) + '
    '; + }); + + // Leere Zeilen als Absatz-Trennung + html = html.replace(/\n\n+/g, '

    '); + + // Einfache Zeilenumbrueche (ausser in pre/code und nach Block-Elementen) + html = html.replace(/(?|<\/li>|<\/ul>|<\/ol>|<\/pre>|<\/p>|

    )\n(?!|

      |
        |
        |<\/p>|

        )/g, '
        '); + + // In Absatz einwickeln + html = '

        ' + html + '

        '; + + // Leere Absaetze entfernen + html = html.replace(/

        \s*<\/p>/g, ''); + + // Block-Elemente nicht in

        einwickeln + html = html.replace(/

        \s*(<(?:h[123]|ul|ol|pre|blockquote)[\s>])/g, '$1'); + html = html.replace(/(<\/(?:h[123]|ul|ol|pre|blockquote)>)\s*<\/p>/g, '$1'); + + return html; + } + // ========================================== // UTILITIES // ========================================== diff --git a/frontend/sw.js b/frontend/sw.js index bd08881..b2bf429 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -4,7 +4,7 @@ * Offline support and caching */ -const CACHE_VERSION = '391'; +const CACHE_VERSION = '392'; const CACHE_NAME = 'taskmate-v' + CACHE_VERSION; const STATIC_CACHE_NAME = 'taskmate-static-v' + CACHE_VERSION; const DYNAMIC_CACHE_NAME = 'taskmate-dynamic-v' + CACHE_VERSION;