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:
@@ -1,6 +1,16 @@
|
|||||||
TASKMATE - CHANGELOG
|
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
|
19.03.2026 - v391 - Sicherheitshärtung und toter Code bereinigt
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
// Wissensmanagement - Anhänge
|
||||||
db.exec(`
|
db.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS knowledge_attachments (
|
CREATE TABLE IF NOT EXISTS knowledge_attachments (
|
||||||
|
|||||||
@@ -14,6 +14,23 @@ const logger = require('../utils/logger');
|
|||||||
const { validators, stripHtml } = require('../middleware/validation');
|
const { validators, stripHtml } = require('../middleware/validation');
|
||||||
const notificationService = require('../services/notificationService');
|
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
|
// Upload-Konfiguration für Knowledge-Anhänge
|
||||||
const UPLOAD_DIR = path.join(__dirname, '..', 'uploads', 'knowledge');
|
const UPLOAD_DIR = path.join(__dirname, '..', 'uploads', 'knowledge');
|
||||||
|
|
||||||
@@ -489,7 +506,7 @@ router.post('/entries', (req, res) => {
|
|||||||
categoryId,
|
categoryId,
|
||||||
stripHtml(title),
|
stripHtml(title),
|
||||||
url || null,
|
url || null,
|
||||||
notes || null,
|
notes ? sanitizeNotes(notes) : null,
|
||||||
0, // Neue Einträge immer an Position 0 (oben)
|
0, // Neue Einträge immer an Position 0 (oben)
|
||||||
req.user.id
|
req.user.id
|
||||||
);
|
);
|
||||||
@@ -614,7 +631,7 @@ router.put('/entries/:id', (req, res) => {
|
|||||||
categoryId || null,
|
categoryId || null,
|
||||||
title ? stripHtml(title) : null,
|
title ? stripHtml(title) : null,
|
||||||
url !== undefined ? url : existing.url,
|
url !== undefined ? url : existing.url,
|
||||||
notes !== undefined ? notes : existing.notes,
|
notes !== undefined ? (notes ? sanitizeNotes(notes) : notes) : existing.notes,
|
||||||
entryId
|
entryId
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -936,7 +953,7 @@ router.get('/search', (req, res) => {
|
|||||||
const searchTerm = `%${q.toLowerCase()}%`;
|
const searchTerm = `%${q.toLowerCase()}%`;
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
|
|
||||||
// Kategorien durchsuchen
|
// Kategorien durchsuchen (kein FTS noetig - wenige Datensaetze)
|
||||||
const categories = db.prepare(`
|
const categories = db.prepare(`
|
||||||
SELECT kc.*,
|
SELECT kc.*,
|
||||||
(SELECT COUNT(*) FROM knowledge_entries WHERE category_id = kc.id) as entry_count
|
(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
|
ORDER BY kc.position
|
||||||
`).all(searchTerm, searchTerm);
|
`).all(searchTerm, searchTerm);
|
||||||
|
|
||||||
// Einträge durchsuchen
|
// Eintraege durchsuchen - FTS5 mit Fallback auf LIKE
|
||||||
const entries = db.prepare(`
|
let entries;
|
||||||
SELECT ke.*,
|
try {
|
||||||
kc.name as category_name,
|
// FTS5 Suche: Sonderzeichen escapen und Prefix-Suche
|
||||||
kc.color as category_color,
|
const ftsQuery = q.trim().replace(/['"*()]/g, '').split(/\s+/).map(t => `"${t}"*`).join(' ');
|
||||||
(SELECT COUNT(*) FROM knowledge_attachments WHERE entry_id = ke.id) as attachment_count
|
entries = db.prepare(`
|
||||||
FROM knowledge_entries ke
|
SELECT ke.*,
|
||||||
LEFT JOIN knowledge_categories kc ON ke.category_id = kc.id
|
kc.name as category_name,
|
||||||
WHERE LOWER(ke.title) LIKE ? OR LOWER(ke.notes) LIKE ? OR LOWER(ke.url) LIKE ?
|
kc.color as category_color,
|
||||||
ORDER BY
|
(SELECT COUNT(*) FROM knowledge_attachments WHERE entry_id = ke.id) as attachment_count
|
||||||
CASE
|
FROM knowledge_fts fts
|
||||||
WHEN LOWER(ke.title) LIKE ? THEN 1
|
JOIN knowledge_entries ke ON ke.id = fts.rowid
|
||||||
ELSE 2
|
LEFT JOIN knowledge_categories kc ON ke.category_id = kc.id
|
||||||
END,
|
WHERE knowledge_fts MATCH ?
|
||||||
ke.created_at DESC
|
ORDER BY rank
|
||||||
LIMIT 50
|
LIMIT 50
|
||||||
`).all(searchTerm, searchTerm, searchTerm, searchTerm);
|
`).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({
|
res.json({
|
||||||
categories: categories.map(c => ({
|
categories: categories.map(c => ({
|
||||||
|
|||||||
@@ -441,6 +441,84 @@
|
|||||||
white-space: pre-wrap;
|
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 {
|
.knowledge-entry-attachments-info {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -348,7 +348,7 @@ class KnowledgeManager {
|
|||||||
</a>
|
</a>
|
||||||
` : ''}
|
` : ''}
|
||||||
${hasNotes ? `
|
${hasNotes ? `
|
||||||
<div class="knowledge-entry-notes">${this.escapeHtml(entry.notes)}</div>
|
<div class="knowledge-entry-notes">${this.renderMarkdown(this.sanitizeHtml(entry.notes))}</div>
|
||||||
` : ''}
|
` : ''}
|
||||||
${hasAttachments ? `
|
${hasAttachments ? `
|
||||||
<div class="knowledge-entry-attachments-info">
|
<div class="knowledge-entry-attachments-info">
|
||||||
@@ -937,7 +937,11 @@ class KnowledgeManager {
|
|||||||
const category = this.categories.find(c => c.id === categoryId);
|
const category = this.categories.find(c => c.id === categoryId);
|
||||||
if (!category) return;
|
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;
|
if (!confirmDelete) return;
|
||||||
|
|
||||||
try {
|
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, '<')
|
||||||
|
.replace(/>/g, '>');
|
||||||
|
|
||||||
|
// Code-Bloecke (dreifache Backticks) - vor anderen Regeln
|
||||||
|
html = html.replace(/```(\w*)\n?([\s\S]*?)```/g, (match, lang, code) => {
|
||||||
|
return `<pre><code>${code.trim()}</code></pre>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Inline-Code (einfache Backticks)
|
||||||
|
html = html.replace(/`([^`\n]+)`/g, '<code>$1</code>');
|
||||||
|
|
||||||
|
// Ueberschriften (# bis ###)
|
||||||
|
html = html.replace(/^### (.+)$/gm, '<h3>$1</h3>');
|
||||||
|
html = html.replace(/^## (.+)$/gm, '<h2>$1</h2>');
|
||||||
|
html = html.replace(/^# (.+)$/gm, '<h1>$1</h1>');
|
||||||
|
|
||||||
|
// Fett (**text** oder __text__)
|
||||||
|
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
|
||||||
|
html = html.replace(/__(.+?)__/g, '<strong>$1</strong>');
|
||||||
|
|
||||||
|
// Kursiv (*text* oder _text_)
|
||||||
|
html = html.replace(/(?<!\w)\*([^*\n]+)\*(?!\w)/g, '<em>$1</em>');
|
||||||
|
html = html.replace(/(?<!\w)_([^_\n]+)_(?!\w)/g, '<em>$1</em>');
|
||||||
|
|
||||||
|
// Links [text](url)
|
||||||
|
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>');
|
||||||
|
|
||||||
|
// Ungeordnete Listen (- oder * am Zeilenanfang)
|
||||||
|
html = html.replace(/^(?:- |\* )(.+)$/gm, '<li>$1</li>');
|
||||||
|
html = html.replace(/((?:<li>.*<\/li>\n?)+)/g, '<ul>$1</ul>');
|
||||||
|
|
||||||
|
// Geordnete Listen (1. am Zeilenanfang)
|
||||||
|
html = html.replace(/^\d+\. (.+)$/gm, '<oli>$1</oli>');
|
||||||
|
html = html.replace(/((?:<oli>.*<\/oli>\n?)+)/g, (match) => {
|
||||||
|
return '<ol>' + match.replace(/<\/?oli>/g, (tag) => tag.replace('oli', 'li')) + '</ol>';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Leere Zeilen als Absatz-Trennung
|
||||||
|
html = html.replace(/\n\n+/g, '</p><p>');
|
||||||
|
|
||||||
|
// Einfache Zeilenumbrueche (ausser in pre/code und nach Block-Elementen)
|
||||||
|
html = html.replace(/(?<!<\/h[123]>|<\/li>|<\/ul>|<\/ol>|<\/pre>|<\/p>|<p>)\n(?!<h[123]|<li>|<ul>|<ol>|<pre>|<\/p>|<p>)/g, '<br>');
|
||||||
|
|
||||||
|
// In Absatz einwickeln
|
||||||
|
html = '<p>' + html + '</p>';
|
||||||
|
|
||||||
|
// Leere Absaetze entfernen
|
||||||
|
html = html.replace(/<p>\s*<\/p>/g, '');
|
||||||
|
|
||||||
|
// Block-Elemente nicht in <p> einwickeln
|
||||||
|
html = html.replace(/<p>\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
|
// UTILITIES
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
* Offline support and caching
|
* Offline support and caching
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const CACHE_VERSION = '391';
|
const CACHE_VERSION = '392';
|
||||||
const CACHE_NAME = 'taskmate-v' + CACHE_VERSION;
|
const CACHE_NAME = 'taskmate-v' + CACHE_VERSION;
|
||||||
const STATIC_CACHE_NAME = 'taskmate-static-v' + CACHE_VERSION;
|
const STATIC_CACHE_NAME = 'taskmate-static-v' + CACHE_VERSION;
|
||||||
const DYNAMIC_CACHE_NAME = 'taskmate-dynamic-v' + CACHE_VERSION;
|
const DYNAMIC_CACHE_NAME = 'taskmate-dynamic-v' + CACHE_VERSION;
|
||||||
|
|||||||
In neuem Issue referenzieren
Einen Benutzer sperren