fixing rolodex and search

This commit is contained in:
HotSwapp
2025-08-11 21:58:25 -05:00
parent 278eb7c5d4
commit c76b68d009
25 changed files with 1651 additions and 915 deletions

View File

@@ -0,0 +1,58 @@
/** @jest-environment jsdom */
const path = require('path');
// Load sanitizer utility first so alerts can delegate to it
require(path.join(__dirname, '..', 'sanitizer.js'));
// Load the alerts module (IIFE attaches itself to window)
require(path.join(__dirname, '..', 'alerts.js'));
describe('alerts._sanitize', () => {
const sanitize = window.alerts && window.alerts._sanitize;
it('should be a function', () => {
expect(typeof sanitize).toBe('function');
});
it('removes <script> tags and event-handler attributes', () => {
const dirty = '<img src="x" onerror="alert(1)"><script>alert("x")</script><p>Hello</p>';
const clean = sanitize(dirty);
expect(clean).toContain('<img src="x">');
expect(clean).toContain('<p>Hello</p>');
expect(clean).not.toMatch(/<script/i);
expect(clean).not.toMatch(/onerror/i);
});
it('uses DOMPurify after it is lazily loaded', async () => {
// Ensure DOMPurify is not present initially
delete window.DOMPurify;
const mockPurify = {
sanitize: jest.fn((html) => `CLEAN:${html}`)
};
// Spy on the shared sanitizer loader and inject DOMPurify once called
const loaderSpy = jest
.spyOn(window.htmlSanitizer, 'ensureDOMPurifyLoaded')
.mockImplementation(() => {
window.DOMPurify = mockPurify;
return Promise.resolve(mockPurify);
});
const dirty = '<span onclick="evil()">Hi</span>';
// First call: fallback sanitizer, DOMPurify not used yet
const first = sanitize(dirty);
expect(mockPurify.sanitize).not.toHaveBeenCalled();
expect(loaderSpy).toHaveBeenCalledTimes(1);
// Wait for loader promise to resolve
await loaderSpy.mock.results[0].value;
// Second call: should use DOMPurify
const second = sanitize(dirty);
expect(mockPurify.sanitize).toHaveBeenCalledTimes(1);
expect(second).toBe(`CLEAN:${dirty}`);
loaderSpy.mockRestore();
});
});

View File

@@ -0,0 +1,56 @@
/** @jest-environment jsdom */
require('../sanitizer.js');
describe('htmlSanitizer', () => {
it('escape() encodes special HTML chars', () => {
const { escape } = window.htmlSanitizer;
expect(escape('<div>')).toBe('&lt;div&gt;');
expect(escape('Tom & Jerry')).toBe('Tom &amp; Jerry');
expect(escape('"quotes" and \'apostrophes\'')).toContain('&quot;');
});
it('sanitize() returns safe HTML and does not double-escape plain text', () => {
const { sanitize, escape } = window.htmlSanitizer;
const dirty = '<img src=x onerror=alert(1)><p>Hello</p>';
const clean = sanitize(dirty);
expect(clean).toContain('<img');
expect(clean).toContain('<p>Hello</p>');
expect(clean).not.toMatch(/onerror/i);
const text = '<b>bold</b>';
const escaped = escape(text);
const sanitizedEscaped = sanitize(escaped);
expect(sanitizedEscaped).toBe(escaped);
});
it('setSafeHTML sets sanitized HTML on the element', () => {
const el = document.createElement('div');
const dirty = '<img src=x onerror=alert(1)><p>Hello</p>';
window.setSafeHTML(el, dirty);
expect(el.innerHTML).toContain('<img');
expect(el.innerHTML).toContain('<p>Hello</p>');
expect(el.innerHTML).not.toMatch(/onerror/i);
});
it('setSafeHTML uses DOMPurify when it becomes available after first call', () => {
// Ensure not present initially
delete window.DOMPurify;
const el = document.createElement('div');
const html = '<em>hello</em>';
// First call: fallback sanitizer (no DOMPurify)
window.setSafeHTML(el, html);
// Now make DOMPurify available
const mockPurify = { sanitize: jest.fn((h) => `CLEAN:${h}`) };
window.DOMPurify = mockPurify;
// Second call should use DOMPurify
window.setSafeHTML(el, html);
expect(mockPurify.sanitize).toHaveBeenCalledTimes(1);
expect(el.innerHTML).toBe(`CLEAN:${html}`);
});
});

View File

@@ -12,20 +12,20 @@
const TYPE_CLASSES = {
success: {
container: 'border-success-200 dark:border-success-800',
icon: 'fa-solid fa-circle-check text-success-600 dark:text-success-400'
container: 'border-green-300 dark:border-green-500 bg-green-50 dark:bg-green-800',
icon: 'fa-solid fa-circle-check text-green-600 dark:text-green-300'
},
danger: {
container: 'border-danger-200 dark:border-danger-800',
icon: 'fa-solid fa-triangle-exclamation text-danger-600 dark:text-danger-400'
container: 'border-red-300 dark:border-red-500 bg-red-50 dark:bg-red-800',
icon: 'fa-solid fa-triangle-exclamation text-red-600 dark:text-red-300'
},
warning: {
container: 'border-warning-200 dark:border-warning-800',
icon: 'fa-solid fa-triangle-exclamation text-warning-600 dark:text-warning-400'
container: 'border-yellow-300 dark:border-yellow-500 bg-yellow-50 dark:bg-yellow-800',
icon: 'fa-solid fa-triangle-exclamation text-yellow-600 dark:text-yellow-300'
},
info: {
container: 'border-info-200 dark:border-info-800',
icon: 'fa-solid fa-circle-info text-info-600 dark:text-info-400'
container: 'border-blue-300 dark:border-blue-500 bg-blue-50 dark:bg-blue-800',
icon: 'fa-solid fa-circle-info text-blue-600 dark:text-blue-300'
}
};
@@ -34,6 +34,30 @@
return TYPE_ALIASES[key] || 'info';
}
// ---- DOMPurify Lazy Loader ------------------------------------------------
// Delegated sanitizer: uses shared htmlSanitizer if available, else performs a minimal fallback
function sanitizeHTML(dirty) {
if (window.htmlSanitizer && typeof window.htmlSanitizer.sanitize === 'function') {
return window.htmlSanitizer.sanitize(dirty);
}
// Minimal inline fallback to guarantee some protection until sanitizer.js loads
const temp = document.createElement('div');
temp.innerHTML = dirty;
temp.querySelectorAll('script, style').forEach((el) => el.remove());
temp.querySelectorAll('*').forEach((el) => {
Array.from(el.attributes).forEach((attr) => {
const name = attr.name;
const value = attr.value;
if (/^on/i.test(name)) el.removeAttribute(name);
if ((name === 'href' || name === 'src') && value && value.trim().toLowerCase().startsWith('javascript:')) {
el.removeAttribute(name);
}
});
});
return temp.innerHTML;
}
function getOrCreateContainer(containerId = 'notification-container') {
let container = document.getElementById(containerId);
if (!container) {
@@ -63,7 +87,7 @@
const container = getOrCreateContainer(containerId);
const wrapper = document.createElement('div');
wrapper.className = `alert-notification max-w-sm w-[22rem] bg-white dark:bg-neutral-800 border rounded-lg shadow-lg p-4 transition-all duration-300 translate-x-4 opacity-0 ${
wrapper.className = `alert-notification max-w-sm w-[22rem] border-2 rounded-lg shadow-xl p-4 transition-all duration-300 translate-x-4 opacity-0 ${
(TYPE_CLASSES[tone] || TYPE_CLASSES.info).container
}`;
wrapper.setAttribute('role', role);
@@ -84,17 +108,17 @@
if (title) {
const titleEl = document.createElement('p');
titleEl.className = 'text-sm font-semibold text-neutral-900 dark:text-neutral-100';
titleEl.className = 'text-sm font-bold text-neutral-900 dark:text-white';
titleEl.textContent = String(title);
content.appendChild(titleEl);
}
const text = document.createElement('div');
text.className = 'text-xs mt-1 text-neutral-800 dark:text-neutral-200';
text.className = 'text-sm mt-1 font-semibold text-neutral-900 dark:text-white';
if (message instanceof Node) {
text.appendChild(message);
} else if (html) {
text.innerHTML = String(message || '');
text.innerHTML = sanitizeHTML(String(message || ''));
} else {
text.textContent = String(message || '');
}
@@ -177,7 +201,10 @@
error: (message, options = {}) => show(message, 'danger', options),
warning: (message, options = {}) => show(message, 'warning', options),
info: (message, options = {}) => show(message, 'info', options),
getOrCreateContainer
getOrCreateContainer,
// Internal: exposed for unit testing only (non-enumerable by default prototype iteration)
_sanitize: sanitizeHTML,
_ensureDOMPurifyLoaded: () => window.htmlSanitizer ? window.htmlSanitizer.ensureDOMPurifyLoaded() : Promise.resolve(null)
};
// Expose globally

File diff suppressed because it is too large Load Diff

View File

@@ -9,6 +9,8 @@
window.app = window.app || {};
const CORRELATION_HEADER = 'X-Correlation-ID';
let warnedTokenStorage = false;
let warnedDeprecatedPatch = false;
function generateCorrelationId() {
try {
@@ -34,6 +36,8 @@
async function wrappedFetch(resource, options = {}) {
const url = typeof resource === 'string' ? resource : (resource && resource.url) || '';
const headers = normalizeHeaders(options.headers);
const method = (options.method || 'GET').toUpperCase();
const body = options.body;
// Inject correlation id if not present
let outgoingCid = headers.get(CORRELATION_HEADER);
@@ -48,10 +52,30 @@
if (storedToken && !headers.has('Authorization')) {
headers.set('Authorization', `Bearer ${storedToken}`);
}
// One-time security note if we detect token in localStorage
if (!warnedTokenStorage && typeof localStorage !== 'undefined' && localStorage.getItem('auth_token')) {
warnedTokenStorage = true;
// eslint-disable-next-line no-console
console.warn('Security note: auth tokens are read from localStorage. If this app is exposed to the internet, migrate to HttpOnly cookies.');
}
} catch (_) {
// Ignore storage access errors (e.g., privacy mode)
}
// Inject Content-Type: application/json for JSON string bodies when missing
try {
const hasContentType = headers.has('Content-Type');
const methodAllowsBody = method !== 'GET' && method !== 'HEAD';
if (methodAllowsBody && body != null && !hasContentType) {
// Only auto-set for stringified JSON bodies to avoid interfering with FormData or other types
if (typeof body === 'string') {
headers.set('Content-Type', 'application/json');
}
}
} catch (_) {
// Best-effort only; ignore header normalization errors
}
const requestInit = { ...options, headers };
const response = await originalFetch(resource, requestInit);
@@ -132,10 +156,18 @@
parseErrorEnvelope,
toError,
formatAlert,
wrappedFetch,
};
// Install wrapper
window.fetch = wrappedFetch;
// Install wrapper (deprecated). Keep for backward compatibility, but nudge callers to use window.http.wrappedFetch
window.fetch = async function(...args) {
if (!warnedDeprecatedPatch) {
warnedDeprecatedPatch = true;
// eslint-disable-next-line no-console
console.warn('Deprecated: global fetch() is wrapped. Prefer window.http.wrappedFetch for clarity and testability.');
}
return wrappedFetch(...args);
};
})();

View File

@@ -54,12 +54,8 @@ async function saveThemePreference(theme) {
const token = localStorage.getItem('auth_token');
if (!token || isLoginPage()) return;
try {
await fetch('/api/auth/theme-preference', {
await window.http.wrappedFetch('/api/auth/theme-preference', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ theme_preference: theme })
});
} catch (error) {
@@ -88,9 +84,7 @@ async function loadUserThemePreference() {
const token = localStorage.getItem('auth_token');
if (!token || isLoginPage()) return;
try {
const response = await fetch('/api/auth/me', {
headers: { 'Authorization': `Bearer ${token}` }
});
const response = await window.http.wrappedFetch('/api/auth/me');
if (response.ok) {
const user = await response.json();
if (user.theme_preference) {
@@ -162,13 +156,8 @@ function validateField(field) {
function setupAPIHelpers() {
// Set up default headers for all API calls
window.apiHeaders = {
'Content-Type': 'application/json',
'Accept': 'application/json'
};
if (app.token) {
window.apiHeaders['Authorization'] = `Bearer ${app.token}`;
}
// Start proactive refresh scheduling when a token is present
if (app.token) {
@@ -197,7 +186,7 @@ async function apiCall(url, options = {}) {
};
try {
let response = await fetch(url, config);
let response = await window.http.wrappedFetch(url, config);
const updateCorrelationFromResponse = (resp) => {
try {
const cid = resp && resp.headers ? resp.headers.get('X-Correlation-ID') : null;
@@ -215,7 +204,7 @@ async function apiCall(url, options = {}) {
headers: { ...window.apiHeaders, ...options.headers },
...options
};
response = await fetch(url, retryConfig);
response = await window.http.wrappedFetch(url, retryConfig);
lastCorrelationId = updateCorrelationFromResponse(response);
} catch (_) {
// fall through to logout below
@@ -269,7 +258,6 @@ function setAuthTokens(accessToken, newRefreshToken = null) {
if (accessToken) {
app.token = accessToken;
localStorage.setItem('auth_token', accessToken);
window.apiHeaders['Authorization'] = `Bearer ${accessToken}`;
}
if (newRefreshToken) {
app.refreshToken = newRefreshToken;
@@ -297,9 +285,7 @@ async function checkTokenValidity() {
const token = localStorage.getItem('auth_token');
if (!token) return false;
try {
const response = await fetch('/api/auth/me', {
headers: { 'Authorization': `Bearer ${token}` }
});
const response = await window.http.wrappedFetch('/api/auth/me');
if (!response.ok) {
// Invalid token
return false;
@@ -334,9 +320,7 @@ async function checkUserPermissions() {
const token = localStorage.getItem('auth_token');
if (!token || isLoginPage()) return;
try {
const response = await fetch('/api/auth/me', {
headers: { 'Authorization': `Bearer ${token}` }
});
const response = await window.http.wrappedFetch('/api/auth/me');
if (response.ok) {
const user = await response.json();
app.user = user;
@@ -361,9 +345,7 @@ async function getInactivityWarningMinutes() {
const token = localStorage.getItem('auth_token');
if (!token) return 240;
try {
const resp = await fetch('/api/settings/inactivity_warning_minutes', {
headers: { 'Authorization': `Bearer ${token}` }
});
const resp = await window.http.wrappedFetch('/api/settings/inactivity_warning_minutes');
if (!resp.ok) return 240;
const data = await resp.json();
if (typeof data.minutes === 'number') return data.minutes;
@@ -512,9 +494,8 @@ async function logout(reason = null) {
const rtoken = localStorage.getItem('refresh_token');
try {
if (rtoken) {
await fetch('/api/auth/logout', {
await window.http.wrappedFetch('/api/auth/logout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refresh_token: rtoken })
});
}
@@ -531,7 +512,7 @@ async function logout(reason = null) {
app.user = null;
localStorage.removeItem('auth_token');
localStorage.removeItem('refresh_token');
delete window.apiHeaders['Authorization'];
// Authorization header is injected by fetch wrapper; nothing to clean here
if (reason) {
try { sessionStorage.setItem('logout_reason', reason); } catch (_) {}
@@ -702,7 +683,7 @@ function displaySearchResults(container, results) {
return;
}
const resultsHtml = results.map(result => `
const resultsHtmlRaw = results.map(result => `
<div class="search-result p-2 border-bottom">
<div class="flex justify-between">
<div>
@@ -713,8 +694,11 @@ function displaySearchResults(container, results) {
</div>
</div>
`).join('');
container.innerHTML = resultsHtml;
if (window.setSafeHTML) {
window.setSafeHTML(container, resultsHtmlRaw);
} else {
container.innerHTML = resultsHtmlRaw;
}
}
// Utility functions
@@ -814,13 +798,9 @@ function setupGlobalErrorHandlers() {
async function postClientError(payload) {
try {
const headers = { 'Content-Type': 'application/json', 'Accept': 'application/json' };
const token = (window.app && window.app.token) || localStorage.getItem('auth_token');
if (token) headers['Authorization'] = `Bearer ${token}`;
// Fire-and-forget; do not block UI
fetch('/api/documents/client-error', {
window.http.wrappedFetch('/api/documents/client-error', {
method: 'POST',
headers,
body: JSON.stringify(payload)
}).catch(() => {});
} catch (_) {
@@ -875,11 +855,10 @@ async function refreshToken() {
if (app.refreshInProgress) return; // Avoid parallel refreshes
app.refreshInProgress = true;
try {
const response = await fetch('/api/auth/refresh', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
body: JSON.stringify({ refresh_token: app.refreshToken })
});
const response = await window.http.wrappedFetch('/api/auth/refresh', {
method: 'POST',
body: JSON.stringify({ refresh_token: app.refreshToken })
});
if (!response.ok) {
throw new Error('Refresh failed');
}

91
static/js/sanitizer.js Normal file
View File

@@ -0,0 +1,91 @@
(function () {
const DOMPURIFY_CDN = 'https://cdn.jsdelivr.net/npm/dompurify@3.0.4/dist/purify.min.js';
let _domPurifyPromise = null;
function ensureDOMPurifyLoaded() {
if (typeof window === 'undefined') {
return Promise.resolve(null);
}
if (window.DOMPurify && typeof window.DOMPurify.sanitize === 'function') {
return Promise.resolve(window.DOMPurify);
}
if (_domPurifyPromise) return _domPurifyPromise;
_domPurifyPromise = new Promise((resolve, reject) => {
try {
const script = document.createElement('script');
script.src = DOMPURIFY_CDN;
script.async = true;
script.onload = () => {
if (window.DOMPurify && window.DOMPurify.sanitize) {
resolve(window.DOMPurify);
} else {
reject(new Error('DOMPurify failed to load'));
}
};
script.onerror = () => reject(new Error('Failed to load DOMPurify'));
document.head.appendChild(script);
} catch (err) {
reject(err);
}
});
return _domPurifyPromise;
}
// Basic fallback sanitizer when DOMPurify is not available yet.
function fallbackSanitize(dirty) {
const temp = document.createElement('div');
temp.innerHTML = dirty;
// Remove script and style tags
temp.querySelectorAll('script, style').forEach((el) => el.remove());
// Remove dangerous attributes
temp.querySelectorAll('*').forEach((el) => {
Array.from(el.attributes).forEach((attr) => {
const name = attr.name;
const value = attr.value;
if (/^on/i.test(name)) {
el.removeAttribute(name);
return;
}
if ((name === 'href' || name === 'src') && value && value.trim().toLowerCase().startsWith('javascript:')) {
el.removeAttribute(name);
}
});
});
return temp.innerHTML;
}
function sanitizeHTML(dirty) {
if (typeof window !== 'undefined' && window.DOMPurify && window.DOMPurify.sanitize) {
return window.DOMPurify.sanitize(dirty);
}
// Trigger async load so the next call benefits
ensureDOMPurifyLoaded().catch(() => {});
return fallbackSanitize(dirty);
}
function escapeHtml(text) {
const span = document.createElement('span');
span.textContent = String(text == null ? '' : text);
return span.innerHTML;
}
function setSafeHTML(element, html) {
if (!element) return;
const sanitized = sanitizeHTML(String(html == null ? '' : html));
element.innerHTML = sanitized;
}
// Expose globally
window.htmlSanitizer = {
sanitize: sanitizeHTML,
ensureDOMPurifyLoaded,
escape: escapeHtml,
setHTML: setSafeHTML
};
window.setSafeHTML = setSafeHTML;
})();