fix: multiple critical bugs in frontend
- sidebar: fix library/tag selection event handlers not firing (callbacks never invoked) - sidebar: fix handleSelectLibrary always passing empty string instead of library id - dashboard: fix tag filter not persisting when navigating from document view - app: fix XSS vulnerability in showToast (API error messages not escaped) - app: fix XSS vulnerability in confirmDelete modal message - document: fix path traversal risk in export filename
This commit is contained in:
@@ -77,18 +77,27 @@ class App {
|
|||||||
const container = document.getElementById('toast-container');
|
const container = document.getElementById('toast-container');
|
||||||
const toast = document.createElement('div');
|
const toast = document.createElement('div');
|
||||||
toast.className = `toast ${type}`;
|
toast.className = `toast ${type}`;
|
||||||
|
const escapedMessage = this.escapeHtml(message);
|
||||||
toast.innerHTML = `
|
toast.innerHTML = `
|
||||||
<span class="toast-message">${message}</span>
|
<span class="toast-message">${escapedMessage}</span>
|
||||||
<button class="toast-close" onclick="this.parentElement.remove()">✕</button>
|
<button class="toast-close" onclick="this.parentElement.remove()">✕</button>
|
||||||
`;
|
`;
|
||||||
container.appendChild(toast);
|
container.appendChild(toast);
|
||||||
setTimeout(() => toast.remove(), 4000);
|
setTimeout(() => toast.remove(), 4000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
escapeHtml(str) {
|
||||||
|
if (!str) return '';
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = str;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
async confirmDelete(message) {
|
async confirmDelete(message) {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const backdrop = document.createElement('div');
|
const backdrop = document.createElement('div');
|
||||||
backdrop.className = 'modal-backdrop';
|
backdrop.className = 'modal-backdrop';
|
||||||
|
const escapedMessage = this.escapeHtml(message);
|
||||||
backdrop.innerHTML = `
|
backdrop.innerHTML = `
|
||||||
<div class="modal">
|
<div class="modal">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
@@ -96,7 +105,7 @@ class App {
|
|||||||
<h3>Confirm Delete</h3>
|
<h3>Confirm Delete</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<p>${message}</p>
|
<p>${escapedMessage}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button class="btn btn-ghost" id="cancel-btn">Cancel</button>
|
<button class="btn btn-ghost" id="cancel-btn">Cancel</button>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export function renderSidebar({ libraries, tags, selectedLibrary, selectedTag, o
|
|||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="tree-node">
|
<div class="tree-node">
|
||||||
<div class="tree-item ${isSelected ? 'active' : ''}" onclick="handleSelectLibrary('${lib.id}')">
|
<div class="tree-item ${isSelected ? 'active' : ''}" data-action="library" data-library-id="${lib.id}">
|
||||||
<span class="tree-toggle ${hasChildren ? 'expanded' : ''}" style="padding-left:${depth * 12}px">
|
<span class="tree-toggle ${hasChildren ? 'expanded' : ''}" style="padding-left:${depth * 12}px">
|
||||||
${hasChildren ? '▶' : ''}
|
${hasChildren ? '▶' : ''}
|
||||||
</span>
|
</span>
|
||||||
@@ -25,13 +25,20 @@ export function renderSidebar({ libraries, tags, selectedLibrary, selectedTag, o
|
|||||||
.join('');
|
.join('');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Store callbacks in a way that's safe and doesn't rely on inline script execution
|
||||||
|
const callbacks = {
|
||||||
|
onSelectLibrary,
|
||||||
|
onSelectTag,
|
||||||
|
onHome
|
||||||
|
};
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<aside class="sidebar">
|
<aside class="sidebar">
|
||||||
<div class="sidebar-scroll">
|
<div class="sidebar-scroll">
|
||||||
<div class="sidebar-section">
|
<div class="sidebar-section">
|
||||||
<h3>📚 Libraries</h3>
|
<h3>📚 Libraries</h3>
|
||||||
<div class="library-tree">
|
<div class="library-tree">
|
||||||
<div class="tree-item ${!selectedLibrary ? 'active' : ''}" onclick="handleHome()">
|
<div class="tree-item ${!selectedLibrary ? 'active' : ''}" data-action="home">
|
||||||
<span class="icon">🏠</span>
|
<span class="icon">🏠</span>
|
||||||
<span class="label">All Documents</span>
|
<span class="label">All Documents</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -42,7 +49,7 @@ export function renderSidebar({ libraries, tags, selectedLibrary, selectedTag, o
|
|||||||
<h3>🏷️ Tags</h3>
|
<h3>🏷️ Tags</h3>
|
||||||
<div class="tag-list">
|
<div class="tag-list">
|
||||||
${tags.map(tag => `
|
${tags.map(tag => `
|
||||||
<div class="tag-item ${selectedTag === tag.name ? 'active' : ''}" onclick="handleSelectTag('${escapeHtml(tag.name)}')">
|
<div class="tag-item ${selectedTag === tag.name ? 'active' : ''}" data-action="tag" data-tag="${escapeHtml(tag.name)}">
|
||||||
<span>#${escapeHtml(tag.name)}</span>
|
<span>#${escapeHtml(tag.name)}</span>
|
||||||
<span class="tag-count">${tag.count}</span>
|
<span class="tag-count">${tag.count}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -50,31 +57,32 @@ export function renderSidebar({ libraries, tags, selectedLibrary, selectedTag, o
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="quick-links">
|
<div class="quick-links">
|
||||||
<a class="quick-link" onclick="handleHome()">📋 All Documents</a>
|
<a class="quick-link" data-action="home">📋 All Documents</a>
|
||||||
<a class="quick-link" onclick="window.app.navigate('editor')">+ New Document</a>
|
<a class="quick-link" onclick="window.app.navigate('editor')">+ New Document</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
<script>
|
<script>
|
||||||
window.handleSelectLibrary = (id) => {
|
(function() {
|
||||||
${onSelectLibrary ? `window.document.dispatchEvent(new CustomEvent('select-library', {detail: '${''}'}))` : ''}
|
var callbacks = window.__sidebarCallbacks;
|
||||||
};
|
document.querySelectorAll('[data-action="home"]').forEach(function(el) {
|
||||||
window.handleSelectTag = (tag) => {
|
el.addEventListener('click', function() {
|
||||||
${onSelectTag ? `window.document.dispatchEvent(new CustomEvent('select-tag', {detail: tag}))` : ''}
|
if (callbacks && callbacks.onHome) callbacks.onHome();
|
||||||
};
|
|
||||||
window.handleHome = () => {
|
|
||||||
${onHome ? `window.document.dispatchEvent(new CustomEvent('go-home'))` : ''}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.document.addEventListener('select-library', (e) => {
|
|
||||||
${onSelectLibrary ? onSelectLibrary.toString().replace(/\s+/g, ' ') : ''}
|
|
||||||
});
|
});
|
||||||
window.document.addEventListener('select-tag', (e) => {
|
|
||||||
${onSelectTag ? onSelectTag.toString().replace(/\s+/g, ' ') : ''}
|
|
||||||
});
|
});
|
||||||
window.document.addEventListener('go-home', (e) => {
|
document.querySelectorAll('[data-action="library"]').forEach(function(el) {
|
||||||
${onHome ? onHome.toString().replace(/\s+/g, ' ') : ''}
|
el.addEventListener('click', function() {
|
||||||
|
var id = this.getAttribute('data-library-id');
|
||||||
|
if (callbacks && callbacks.onSelectLibrary) callbacks.onSelectLibrary(id);
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
document.querySelectorAll('[data-action="tag"]').forEach(function(el) {
|
||||||
|
el.addEventListener('click', function() {
|
||||||
|
var tag = this.getAttribute('data-tag');
|
||||||
|
if (callbacks && callbacks.onSelectTag) callbacks.onSelectTag(tag);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
</script>
|
</script>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ export async function renderDashboard(app) {
|
|||||||
let libraries = [];
|
let libraries = [];
|
||||||
let tags = [];
|
let tags = [];
|
||||||
let searchQuery = '';
|
let searchQuery = '';
|
||||||
let selectedTag = null;
|
let selectedTag = app.state.selectedTag || null;
|
||||||
let selectedLibrary = null;
|
let selectedLibrary = app.state.selectedLibrary || null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
[documents, libraries, tags] = await Promise.all([
|
[documents, libraries, tags] = await Promise.all([
|
||||||
@@ -24,6 +24,32 @@ export async function renderDashboard(app) {
|
|||||||
const appEl = document.getElementById('app');
|
const appEl = document.getElementById('app');
|
||||||
|
|
||||||
function render() {
|
function render() {
|
||||||
|
// Store callbacks for sidebar event handlers
|
||||||
|
const sidebarCallbacks = {
|
||||||
|
onSelectLibrary: (id) => {
|
||||||
|
selectedLibrary = id;
|
||||||
|
selectedTag = null;
|
||||||
|
app.state.selectedLibrary = id;
|
||||||
|
app.state.selectedTag = null;
|
||||||
|
render();
|
||||||
|
},
|
||||||
|
onSelectTag: (tag) => {
|
||||||
|
selectedTag = tag;
|
||||||
|
selectedLibrary = null;
|
||||||
|
app.state.selectedTag = tag;
|
||||||
|
app.state.selectedLibrary = null;
|
||||||
|
render();
|
||||||
|
},
|
||||||
|
onHome: () => {
|
||||||
|
selectedTag = null;
|
||||||
|
selectedLibrary = null;
|
||||||
|
app.state.selectedTag = null;
|
||||||
|
app.state.selectedLibrary = null;
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.__sidebarCallbacks = sidebarCallbacks;
|
||||||
|
|
||||||
let filteredDocs = documents;
|
let filteredDocs = documents;
|
||||||
|
|
||||||
if (searchQuery) {
|
if (searchQuery) {
|
||||||
@@ -64,21 +90,7 @@ export async function renderDashboard(app) {
|
|||||||
tags,
|
tags,
|
||||||
selectedLibrary,
|
selectedLibrary,
|
||||||
selectedTag,
|
selectedTag,
|
||||||
onSelectLibrary: (id) => {
|
...sidebarCallbacks
|
||||||
selectedLibrary = id;
|
|
||||||
selectedTag = null;
|
|
||||||
render();
|
|
||||||
},
|
|
||||||
onSelectTag: (tag) => {
|
|
||||||
selectedTag = tag;
|
|
||||||
selectedLibrary = null;
|
|
||||||
render();
|
|
||||||
},
|
|
||||||
onHome: () => {
|
|
||||||
selectedTag = null;
|
|
||||||
selectedLibrary = null;
|
|
||||||
render();
|
|
||||||
}
|
|
||||||
})}
|
})}
|
||||||
<main class="main-content">
|
<main class="main-content">
|
||||||
<div class="content-header">
|
<div class="content-header">
|
||||||
|
|||||||
@@ -81,6 +81,9 @@ export async function renderDocument(app) {
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
window.filterByTag = (tag) => {
|
window.filterByTag = (tag) => {
|
||||||
|
// Store the tag to filter by in app state so dashboard can pick it up
|
||||||
|
app.state.selectedTag = tag;
|
||||||
|
app.state.selectedLibrary = null;
|
||||||
app.navigate('dashboard');
|
app.navigate('dashboard');
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -94,7 +97,12 @@ export async function renderDocument(app) {
|
|||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.href = url;
|
a.href = url;
|
||||||
a.download = `${doc.id}-${doc.title}.md`;
|
// Sanitize filename to prevent path traversal
|
||||||
|
const safeFilename = (doc.title || 'untitled')
|
||||||
|
.replace(/[^a-zA-Z0-9_\-\s]/g, '')
|
||||||
|
.replace(/\s+/g, '-')
|
||||||
|
.substring(0, 100);
|
||||||
|
a.download = `${doc.id}-${safeFilename}.md`;
|
||||||
a.click();
|
a.click();
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
app.showToast('Document exported', 'success');
|
app.showToast('Document exported', 'success');
|
||||||
|
|||||||
Reference in New Issue
Block a user