Files
simplenote-web/public/js/views/document.js
Hiro 461a17bc45 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
2026-03-28 12:06:16 +00:00

163 lines
5.6 KiB
JavaScript

// Document View
import { api } from '../api.js';
export async function renderDocument(app) {
const { id } = app.state.params;
let doc;
try {
doc = await api.getDocument(id);
} catch (e) {
app.showToast('Failed to load document', 'error');
app.navigate('dashboard');
return;
}
const appEl = document.getElementById('app');
function render() {
const priorityEmoji = { high: '🔴', medium: '🟡', low: '🟢' };
const priority = doc.priority || 'medium';
const renderedContent = renderMarkdown(doc.content || '');
appEl.innerHTML = `
<header class="app-header">
<button class="btn btn-ghost" onclick="window.app.navigate('dashboard')">← Back</button>
<div class="header-actions">
<button class="btn btn-ghost" onclick="window.app.navigate('editor', {id: '${doc.id}'})">✏️ Edit</button>
<button class="btn btn-ghost" onclick="exportDoc()">📥 Export</button>
<button class="btn btn-ghost danger" onclick="deleteDoc()">🗑️ Delete</button>
</div>
</header>
<main class="main-content">
<div class="content-body">
<div class="doc-viewer">
<div class="doc-content">
<div class="doc-viewer-header">
<span class="doc-id">${doc.id}</span>
<span class="type-badge ${doc.type || 'general'}">${doc.type || 'general'}</span>
</div>
<div class="prose">${renderedContent}</div>
</div>
<aside class="doc-sidebar">
<div class="meta-section">
<div class="meta-header">Details</div>
<div class="meta-body">
<div class="meta-row">
<span class="meta-label">Status</span>
<span class="status-badge ${doc.status || 'draft'}">${doc.status || 'draft'}</span>
</div>
<div class="meta-row">
<span class="meta-label">Priority</span>
<span class="meta-value">${priorityEmoji[priority]} ${priority}</span>
</div>
<div class="meta-row">
<span class="meta-label">Author</span>
<span class="meta-value">${escapeHtml(doc.author || 'unknown')}</span>
</div>
<div class="meta-row">
<span class="meta-label">Created</span>
<span class="meta-value">${formatDate(doc.createdAt)}</span>
</div>
<div class="meta-row">
<span class="meta-label">Updated</span>
<span class="meta-value">${formatDate(doc.updatedAt)}</span>
</div>
</div>
</div>
${doc.tags && doc.tags.length ? `
<div class="meta-section">
<div class="meta-header">Tags</div>
<div class="meta-body doc-tags">
${doc.tags.map(t => `<span class="tag-pill" onclick="filterByTag('${escapeHtml(t)}')">${escapeHtml(t)}</span>`).join('')}
</div>
</div>
` : ''}
</aside>
</div>
</div>
</main>
`;
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');
};
}
render();
async function exportDoc() {
try {
const markdown = await api.exportDocument(id);
const blob = new Blob([markdown], { type: 'text/markdown' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
// 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();
URL.revokeObjectURL(url);
app.showToast('Document exported', 'success');
} catch (e) {
app.showToast('Failed to export', 'error');
}
}
async function deleteDoc() {
const confirmed = await app.confirmDelete(`Delete "${doc.title}"? This cannot be undone.`);
if (confirmed) {
try {
await api.deleteDocument(id);
app.showToast('Document deleted', 'success');
app.navigate('dashboard');
} catch (e) {
app.showToast('Failed to delete', 'error');
}
}
}
}
function escapeHtml(str) {
if (!str) return '';
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
function formatDate(dateStr) {
if (!dateStr) return '';
const date = new Date(dateStr);
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
}
function renderMarkdown(content) {
// Simple markdown rendering using marked library if available
if (typeof marked !== 'undefined') {
return marked.parse(content);
}
// Fallback simple rendering
return content
.replace(/^### (.+)$/gm, '<h3>$1</h3>')
.replace(/^## (.+)$/gm, '<h2>$1</h2>')
.replace(/^# (.+)$/gm, '<h1>$1</h1>')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.+?)\*/g, '<em>$1</em>')
.replace(/`(.+?)`/g, '<code>$1</code>')
.replace(/^- (.+)$/gm, '<li>$1</li>')
.replace(/(<li>.*<\/li>)/s, '<ul>$1</ul>')
.replace(/\n\n/g, '</p><p>')
.replace(/^(.+)$/gm, (match) => {
if (match.startsWith('<')) return match;
return `<p>${match}</p>`;
});
}