Files
gh-christianlouis-docuelevate/frontend/templates/search.html
T
copilot-swe-agent[bot] 483a5b71a1 fix(ui): sanitize Meilisearch HTML output, use event delegation, improve error messages
Address code review feedback:
- Sanitize highlighted HTML from Meilisearch to prevent XSS (only allow
  <mark> tags, escape everything else)
- Replace inline onclick handlers with event delegation for pagination
- Improve error message to be more user-friendly with technical detail
  in smaller text

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-02-26 09:34:31 +00:00

267 lines
9.6 KiB
HTML

{% extends "base.html" %}
{% block title %}Search Documents - DocuElevate{% endblock %}
{% block head_extra %}
<script src="/static/js/common.js"></script>
<style>
.search-container { max-width: 800px; margin: 0 auto; }
.search-box {
display: flex; gap: 0.5rem; align-items: center;
margin-bottom: 1.5rem;
}
.search-box input {
flex: 1; padding: 0.75rem 1rem;
border: 1px solid #d1d5db; border-radius: 0.5rem;
font-size: 1rem; outline: none;
}
.search-box input:focus {
border-color: #3b82f6; box-shadow: 0 0 0 3px rgba(59,130,246,0.15);
}
.search-box button {
padding: 0.75rem 1.5rem; background-color: #3b82f6;
color: white; border: none; border-radius: 0.5rem;
font-size: 1rem; cursor: pointer; font-weight: 600;
white-space: nowrap;
}
.search-box button:hover { background-color: #2563eb; }
.search-summary {
font-size: 0.875rem; color: #6b7280;
margin-bottom: 1rem;
}
/* Google-style result cards */
.search-result {
margin-bottom: 1.5rem;
}
.search-result-title a {
font-size: 1.125rem; font-weight: 600;
color: #1a0dab; text-decoration: none;
}
.search-result-title a:hover { text-decoration: underline; }
.search-result-url {
font-size: 0.8rem; color: #006621; margin-top: 0.1rem;
word-break: break-all;
}
.search-result-meta {
display: flex; flex-wrap: wrap; gap: 0.4rem;
margin-top: 0.3rem;
}
.search-result-badge {
display: inline-block; padding: 0.1rem 0.5rem;
border-radius: 9999px; font-size: 0.7rem; font-weight: 500;
}
.badge-type { background: #eff6ff; color: #1d4ed8; }
.badge-tag { background: #f0fdf4; color: #15803d; }
.badge-sender { background: #fef3c7; color: #92400e; }
.search-result-snippet {
font-size: 0.875rem; color: #4d5156;
line-height: 1.5; margin-top: 0.35rem;
word-break: break-word;
}
.search-result-snippet mark {
background: #fef08a; font-weight: 600;
border-radius: 2px; padding: 0 1px;
}
.search-pagination {
display: flex; justify-content: center;
gap: 0.5rem; margin-top: 2rem;
}
.search-pagination button {
padding: 0.4rem 1rem; border: 1px solid #d1d5db;
border-radius: 0.375rem; font-size: 0.875rem;
cursor: pointer; background: white; color: #374151;
}
.search-pagination button:hover { background: #f3f4f6; }
.search-pagination span {
padding: 0.4rem 0.75rem; font-size: 0.875rem; color: #6b7280;
}
.search-empty {
text-align: center; padding: 3rem 1rem; color: #9ca3af;
}
.search-empty i { font-size: 3rem; margin-bottom: 1rem; display: block; }
</style>
{% endblock %}
{% block content %}
<div class="container mx-auto px-4 py-8">
<div class="search-container">
<h1 class="text-3xl font-bold mb-6"><i class="fas fa-search text-blue-500"></i> Document Search</h1>
<!-- Search box -->
<div class="search-box">
<input
type="text"
id="search-input"
placeholder="Search documents by content, sender, tags, type..."
value="{{ query }}"
autofocus
>
<button type="button" id="search-btn">
<i class="fas fa-search"></i> Search
</button>
</div>
<!-- Summary -->
<div id="search-summary" class="search-summary" style="display:none;"></div>
<!-- Results -->
<div id="search-results"></div>
<!-- Pagination -->
<div id="search-pagination" class="search-pagination" style="display:none;"></div>
</div>
</div>
<script>
const searchInput = document.getElementById('search-input');
const searchBtn = document.getElementById('search-btn');
const resultsDiv = document.getElementById('search-results');
const summaryDiv = document.getElementById('search-summary');
const paginationDiv = document.getElementById('search-pagination');
let _debounce = null;
let _currentPage = 1;
const PER_PAGE = 20;
function doSearch(page) {
const q = searchInput.value.trim();
if (!q) {
resultsDiv.innerHTML = '';
summaryDiv.style.display = 'none';
paginationDiv.style.display = 'none';
return;
}
_currentPage = page || 1;
// Update URL without reload
const url = new URL(window.location);
url.searchParams.set('q', q);
window.history.replaceState({}, '', url);
// Loading indicator
resultsDiv.innerHTML = '<div style="text-align:center;padding:2rem;color:#6b7280;"><i class="fas fa-spinner fa-spin fa-2x"></i><p style="margin-top:0.75rem;">Searching…</p></div>';
summaryDiv.style.display = 'none';
paginationDiv.style.display = 'none';
const params = new URLSearchParams({ q, page: _currentPage, per_page: PER_PAGE });
fetch('/api/search?' + params.toString())
.then(r => { if (!r.ok) throw new Error('Search returned ' + r.status); return r.json(); })
.then(data => renderResults(data, q))
.catch(err => {
resultsDiv.innerHTML = '<div style="text-align:center;padding:2rem;color:#dc2626;"><i class="fas fa-exclamation-triangle fa-2x"></i><p style="margin-top:0.5rem;">Search is temporarily unavailable. Please try again in a moment.</p><p style="font-size:0.75rem;color:#9ca3af;margin-top:0.25rem;">' + escapeHtml(err.message) + '</p></div>';
});
}
function escapeHtml(str) {
const d = document.createElement('div');
d.textContent = str;
return d.innerHTML;
}
/**
* Sanitize Meilisearch highlighted HTML: allow only <mark> tags,
* escape everything else to prevent XSS from indexed content.
*/
function sanitizeHighlight(html) {
// Temporarily replace <mark> and </mark> with placeholders
var safe = html
.replace(/<mark>/gi, '\x00MARK_OPEN\x00')
.replace(/<\/mark>/gi, '\x00MARK_CLOSE\x00');
// Escape all remaining HTML
safe = escapeHtml(safe);
// Restore the <mark> tags
safe = safe
.replace(/\x00MARK_OPEN\x00/g, '<mark>')
.replace(/\x00MARK_CLOSE\x00/g, '</mark>');
return safe;
}
function renderResults(data, q) {
const { results, total, page, pages } = data;
// Summary
summaryDiv.textContent = total + ' result' + (total !== 1 ? 's' : '') + ' for "' + q + '"';
summaryDiv.style.display = 'block';
if (!results || results.length === 0) {
resultsDiv.innerHTML = '<div class="search-empty"><i class="fas fa-search"></i><p>No documents found matching your query.</p></div>';
paginationDiv.style.display = 'none';
return;
}
resultsDiv.innerHTML = results.map(function(hit) {
var fmt = hit._formatted || {};
var title = fmt.document_title || hit.document_title || hit.original_filename || '(untitled)';
var filename = hit.original_filename || '';
var snippet = fmt.ocr_text || '';
var tags = Array.isArray(hit.tags) ? hit.tags : (hit.tags ? [hit.tags] : []);
var docType = hit.document_type || '';
var sender = hit.sender || hit.absender || '';
var fileUrl = '/files/' + hit.file_id;
// Build badges
var badges = '';
if (docType) badges += '<span class="search-result-badge badge-type">' + escapeHtml(docType) + '</span>';
if (sender) badges += '<span class="search-result-badge badge-sender"><i class="fas fa-user"></i> ' + escapeHtml(sender) + '</span>';
tags.forEach(function(t) { badges += '<span class="search-result-badge badge-tag">' + escapeHtml(t) + '</span>'; });
// Snippet: use highlighted text, truncate if very long
var snippetHtml = '';
if (snippet) {
var trimmed = snippet.length > 500 ? snippet.substring(0, 500) + '…' : snippet;
snippetHtml = '<div class="search-result-snippet">…' + sanitizeHighlight(trimmed) + '…</div>';
}
// Sanitize title (may contain <mark> highlights from _formatted)
var safeTitle = (fmt.document_title) ? sanitizeHighlight(title) : escapeHtml(title);
return '<div class="search-result">' +
'<div class="search-result-title"><a href="' + escapeHtml(fileUrl) + '">' + safeTitle + '</a></div>' +
'<div class="search-result-url">' + escapeHtml(filename) + '</div>' +
(badges ? '<div class="search-result-meta">' + badges + '</div>' : '') +
snippetHtml +
'</div>';
}).join('');
// Pagination
if (pages > 1) {
var btns = [];
if (page > 1) btns.push('<button data-page="' + (page - 1) + '">« Previous</button>');
btns.push('<span>Page ' + page + ' of ' + pages + '</span>');
if (page < pages) btns.push('<button data-page="' + (page + 1) + '">Next »</button>');
paginationDiv.innerHTML = btns.join('');
paginationDiv.style.display = 'flex';
} else {
paginationDiv.style.display = 'none';
}
}
// Event delegation for pagination buttons
paginationDiv.addEventListener('click', function(e) {
var btn = e.target.closest('button[data-page]');
if (btn) doSearch(parseInt(btn.getAttribute('data-page'), 10));
});
// Event listeners
searchBtn.addEventListener('click', function() { doSearch(1); });
searchInput.addEventListener('keydown', function(e) {
if (e.key === 'Enter') { doSearch(1); }
});
searchInput.addEventListener('input', function() {
clearTimeout(_debounce);
var val = searchInput.value.trim();
if (val.length < 2) {
resultsDiv.innerHTML = '';
summaryDiv.style.display = 'none';
paginationDiv.style.display = 'none';
return;
}
_debounce = setTimeout(function() { doSearch(1); }, 400);
});
// If q was provided via URL, search immediately
if (searchInput.value.trim().length >= 2) {
doSearch(1);
}
</script>
{% endblock %}