Browse Source

bug-fixes

master
Silberengel 1 month ago
parent
commit
8382af8af3
  1. 444
      src/lib/components/content/MarkdownRenderer.svelte
  2. 31
      src/lib/components/content/mount-component-action.ts
  3. 25
      src/lib/components/layout/ProfileBadge.svelte
  4. 91
      src/lib/services/nostr/nip21-parser.ts
  5. 19
      src/lib/services/security/sanitizer.ts

444
src/lib/components/content/MarkdownRenderer.svelte

@ -1,304 +1,268 @@
<script lang="ts"> <script lang="ts">
import * as marked from 'marked'; import { marked } from 'marked';
import { sanitizeMarkdown } from '../../services/security/sanitizer.js'; import { sanitizeMarkdown } from '../../services/security/sanitizer.js';
import { findNIP21Links } from '../../services/nostr/nip21-parser.js'; import { findNIP21Links, parseNIP21 } from '../../services/nostr/nip21-parser.js';
import { nip19 } from 'nostr-tools'; import { nip19 } from 'nostr-tools';
import { tick } from 'svelte';
import ProfileBadge from '../layout/ProfileBadge.svelte'; import ProfileBadge from '../layout/ProfileBadge.svelte';
import EmbeddedEvent from './EmbeddedEvent.svelte';
import { mountComponent } from './mount-component-action.js'; import { mountComponent } from './mount-component-action.js';
interface Props { interface Props {
content?: string; content: string;
} }
let { content = '' }: Props = $props(); let { content }: Props = $props();
let containerRef = $state<HTMLElement | null>(null);
let rendered = $state(''); // Extract pubkey from npub or nprofile
let containerElement: HTMLDivElement | null = $state(null); function getPubkeyFromNIP21(parsed: ReturnType<typeof parseNIP21>): string | null {
let mountedElements = $state<Set<HTMLElement>>(new Set()); if (!parsed) return null;
// Process content and render markdown if (parsed.type === 'npub') {
$effect(() => { // npub decodes directly to pubkey
if (!content) { try {
rendered = ''; const decoded = nip19.decode(parsed.data);
return; if (decoded.type === 'npub') {
return String(decoded.data);
}
} catch {
return null;
}
} else if (parsed.type === 'nprofile') {
// nprofile decodes to object with pubkey property
try {
const decoded = nip19.decode(parsed.data);
if (decoded.type === 'nprofile' && decoded.data && typeof decoded.data === 'object' && 'pubkey' in decoded.data) {
return String(decoded.data.pubkey);
}
} catch {
return null;
}
} }
// Find NIP-21 links return null;
const links = findNIP21Links(content); }
// Escape HTML to prevent XSS
function escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
// Convert plain image URLs to img tags
function convertImageUrls(text: string): string {
// Match image URLs (http/https URLs ending in image extensions)
// Pattern: http(s)://... followed by image extension, optionally with query params
// Don't match URLs that are already in markdown ![alt](url) or HTML <img> tags
const imageExtensions = /\.(png|jpg|jpeg|gif|webp|svg|bmp|ico)(\?[^\s<>"']*)?$/i;
const urlPattern = /https?:\/\/[^\s<>"']+/g;
// Replace links with placeholders before markdown parsing let result = text;
let processed = content; const matches: Array<{ url: string; index: number; endIndex: number }> = [];
const placeholders: Map<string, { uri: string; parsed: any }> = new Map();
let offset = 0;
// Process in reverse order to maintain indices // Find all URLs
const sortedLinks = [...links].sort((a, b) => b.start - a.start); let match;
while ((match = urlPattern.exec(text)) !== null) {
const url = match[0];
if (imageExtensions.test(url)) {
const index = match.index;
const endIndex = index + url.length;
// Check if this URL is already in markdown or HTML
const before = text.substring(Math.max(0, index - 10), index);
const after = text.substring(endIndex, Math.min(text.length, endIndex + 10));
// Skip if it's in markdown image syntax ![alt](url) or already in <img> tag
if (!before.includes('![') && !before.includes('<img') && !after.startsWith('](') && !after.startsWith('</img>')) {
matches.push({ url, index, endIndex });
}
}
}
for (const link of sortedLinks) { // Replace from end to start to preserve indices
const placeholder = `\`NIP21PLACEHOLDER${offset}\``; for (let i = matches.length - 1; i >= 0; i--) {
const before = processed.slice(0, link.start); const { url, index, endIndex } = matches[i];
const after = processed.slice(link.end); const escapedUrl = escapeHtml(url);
processed = before + placeholder + after; result = result.substring(0, index) + `<img src="${escapedUrl}" alt="" loading="lazy" />` + result.substring(endIndex);
placeholders.set(placeholder, { uri: link.uri, parsed: link.parsed });
offset++;
} }
return result;
}
// Parse markdown // Process content: replace nostr URIs with HTML span elements and convert image URLs
const parseResult = marked.parse(processed); function processContent(text: string): string {
// First, convert plain image URLs to img tags
let processed = convertImageUrls(text);
const processHtml = (html: string) => { // Find all NIP-21 links (nostr:npub, nostr:nprofile, etc.)
let finalHtml = sanitizeMarkdown(html); const links = findNIP21Links(processed);
// Process media elements // Only process npub and nprofile for profile badges
finalHtml = finalHtml.replace(/<img([^>]*)>/gi, (match, attrs) => { const profileLinks = links.filter(link =>
if (/loading\s*=/i.test(attrs)) return match; link.parsed.type === 'npub' || link.parsed.type === 'nprofile'
return `<img${attrs} loading="lazy">`; );
});
finalHtml = finalHtml.replace(/<video([^>]*)>/gi, (match, attrs) => {
let newAttrs = attrs.replace(/\s*autoplay\s*/gi, ' ');
if (!/preload\s*=/i.test(newAttrs)) {
newAttrs += ' preload="none"';
} else {
newAttrs = newAttrs.replace(/preload\s*=\s*["']?[^"'\s>]+["']?/gi, 'preload="none"');
}
if (!/autoplay\s*=/i.test(newAttrs)) {
newAttrs += ' autoplay="false"';
}
return `<video${newAttrs}>`;
});
finalHtml = finalHtml.replace(/<audio([^>]*)>/gi, (match, attrs) => {
let newAttrs = attrs.replace(/\s*autoplay\s*/gi, ' ');
if (!/preload\s*=/i.test(newAttrs)) {
newAttrs += ' preload="none"';
} else {
newAttrs = newAttrs.replace(/preload\s*=\s*["']?[^"'\s>]+["']?/gi, 'preload="none"');
}
if (!/autoplay\s*=/i.test(newAttrs)) {
newAttrs += ' autoplay="false"';
}
return `<audio${newAttrs}>`;
});
// Replace placeholders with component placeholders
for (const [placeholder, { uri, parsed }] of placeholders.entries()) {
let replacement = '';
try {
if (parsed.type === 'hexID') {
const eventId = parsed.data;
replacement = `<span class="nostr-embedded-event-placeholder inline-block" data-event-id="${eventId}"></span>`;
} else {
const decoded: any = nip19.decode(parsed.data);
if (decoded.type === 'npub' || decoded.type === 'nprofile') {
const pubkey = decoded.type === 'npub'
? String(decoded.data)
: (decoded.data?.pubkey ? String(decoded.data.pubkey) : null);
if (pubkey) {
replacement = `<span class="nostr-profile-badge-placeholder inline-block" data-pubkey="${pubkey}"></span>`;
} else {
replacement = `<span class="nostr-link nostr-${parsed.type}">${uri}</span>`;
}
} else if (decoded.type === 'note' || decoded.type === 'nevent') {
const eventId = decoded.type === 'note'
? String(decoded.data)
: (decoded.data?.id ? String(decoded.data.id) : null);
if (eventId) {
replacement = `<span class="nostr-embedded-event-placeholder inline-block" data-event-id="${eventId}"></span>`;
} else {
replacement = `<span class="nostr-link nostr-${parsed.type}">${uri}</span>`;
}
} else if (decoded.type === 'naddr') {
// For naddr, store the bech32 string
replacement = `<span class="nostr-embedded-event-placeholder inline-block" data-event-id="${parsed.data}"></span>`;
} else {
replacement = `<span class="nostr-link nostr-${parsed.type}">${uri}</span>`;
}
}
} catch {
replacement = `<span class="nostr-link nostr-${parsed.type || 'unknown'}">${uri}</span>`;
}
// Replace placeholder in HTML
const placeholderText = placeholder.replace(/`/g, '');
const codePlaceholder = `<code>${placeholderText}</code>`;
const escapedCodePlaceholder = codePlaceholder.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
finalHtml = finalHtml.replace(new RegExp(escapedCodePlaceholder, 'g'), replacement);
const escapedPlaceholder = placeholderText.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
finalHtml = finalHtml.replace(new RegExp(escapedPlaceholder, 'g'), replacement);
}
// Clean up any remaining placeholders
finalHtml = finalHtml.replace(/<code>NIP21PLACEHOLDER\d+<\/code>/g, '');
finalHtml = finalHtml.replace(/NIP21PLACEHOLDER\d+/g, '');
return finalHtml;
};
if (parseResult instanceof Promise) { // Replace profile links with HTML span elements that have data attributes
parseResult.then(processHtml).then(html => { // Process from end to start to preserve indices
rendered = html; for (let i = profileLinks.length - 1; i >= 0; i--) {
// Clear mounted elements when content changes const link = profileLinks[i];
mountedElements.clear(); const pubkey = getPubkeyFromNIP21(link.parsed);
// Mount components after a delay if (pubkey) {
tick().then(() => tick()).then(() => { // Escape pubkey to prevent XSS (though pubkeys should be safe hex strings)
mountComponents(); const escapedPubkey = escapeHtml(pubkey);
}); // Create a span element that will survive markdown rendering
}); // Markdown won't process HTML tags, so this will remain as-is
} else { const span = `<span data-nostr-profile data-pubkey="${escapedPubkey}"></span>`;
rendered = processHtml(parseResult); processed =
// Clear mounted elements when content changes processed.slice(0, link.start) +
mountedElements.clear(); span +
// Mount components after a delay processed.slice(link.end);
tick().then(() => tick()).then(() => { }
mountComponents();
});
} }
return processed;
}
// Configure marked once - ensure images are rendered and HTML is preserved
marked.setOptions({
breaks: true, // Convert line breaks to <br>
gfm: true, // GitHub Flavored Markdown
silent: false // Don't suppress errors
// HTML tags (like <img>) pass through by default in marked
}); });
// Mount components into placeholder elements // Render markdown to HTML
function mountComponents() { function renderMarkdown(text: string): string {
if (!containerElement) return; if (!content) return '';
// Mount profile badges const processed = processContent(content);
const badgeElements = containerElement.querySelectorAll('.nostr-profile-badge-placeholder:not([data-mounted])');
badgeElements.forEach((el) => { // Render markdown - this should convert ![alt](url) to <img src="url" alt="alt">
if (mountedElements.has(el as HTMLElement)) return; let html: string;
const pubkey = el.getAttribute('data-pubkey'); try {
if (pubkey) { html = marked.parse(processed) as string;
mountComponent(el as HTMLElement, ProfileBadge as any, { pubkey }); } catch (error) {
el.setAttribute('data-mounted', 'true'); console.error('Marked parsing error:', error);
mountedElements.add(el as HTMLElement); return processed; // Fallback to raw text if parsing fails
} }
});
// Mount embedded events // Sanitize HTML (but preserve our data attributes and image src)
const eventElements = containerElement.querySelectorAll('.nostr-embedded-event-placeholder:not([data-mounted])'); const sanitized = sanitizeMarkdown(html);
eventElements.forEach((el) => {
if (mountedElements.has(el as HTMLElement)) return; return sanitized;
const eventId = el.getAttribute('data-event-id');
if (eventId) {
mountComponent(el as HTMLElement, EmbeddedEvent as any, { eventId });
el.setAttribute('data-mounted', 'true');
mountedElements.add(el as HTMLElement);
}
});
} }
// Mount components when container is available and rendered changes const renderedHtml = $derived(renderMarkdown(content));
// Mount ProfileBadge components after rendering
$effect(() => { $effect(() => {
if (containerElement && rendered) { if (!containerRef || !renderedHtml) return;
tick().then(() => tick()).then(() => {
mountComponents(); // Use a small delay to ensure DOM is updated
const timeoutId = setTimeout(() => {
if (!containerRef) return;
// Find all profile placeholders and mount ProfileBadge components
const placeholders = containerRef.querySelectorAll('[data-nostr-profile]');
placeholders.forEach((placeholder) => {
const pubkey = placeholder.getAttribute('data-pubkey');
if (pubkey && !placeholder.hasAttribute('data-mounted')) {
// Mark as mounted to avoid double-mounting
placeholder.setAttribute('data-mounted', 'true');
mountComponent(placeholder as HTMLElement, ProfileBadge as any, { pubkey });
}
}); });
} }, 0);
return () => clearTimeout(timeoutId);
}); });
</script> </script>
<div class="markdown-content anon-content" bind:this={containerElement}> <div
{@html rendered} bind:this={containerRef}
class="markdown-content prose prose-sm dark:prose-invert max-w-none"
>
{@html renderedHtml}
</div> </div>
<style> <style>
.markdown-content { :global(.markdown-content) {
line-height: var(--line-height); word-wrap: break-word;
} overflow-wrap: break-word;
.markdown-content :global(p) {
margin: 0.5em 0;
} }
.markdown-content :global(a) { :global(.markdown-content img) {
color: #64748b; max-width: 100%;
text-decoration: underline; height: auto;
} border-radius: 0.25rem;
margin: 0.5rem 0;
:global(.dark) .markdown-content :global(a) { display: block;
color: #94a3b8; visibility: visible !important;
opacity: 1 !important;
} }
.markdown-content :global(a:hover) { :global(.markdown-content video) {
color: #475569; max-width: 100%;
height: auto;
border-radius: 0.25rem;
margin: 0.5rem 0;
} }
:global(.dark) .markdown-content :global(a:hover) { :global(.markdown-content audio) {
color: #cbd5e1; width: 100%;
margin: 0.5rem 0;
} }
.markdown-content :global(.nostr-link) { :global(.markdown-content a) {
color: var(--fog-accent, #64748b); color: var(--fog-accent, #64748b);
text-decoration: underline; text-decoration: underline;
cursor: pointer;
}
:global(.dark) .markdown-content :global(.nostr-link) {
color: var(--fog-dark-accent, #64748b);
} }
.markdown-content :global(.nostr-link:hover) { :global(.markdown-content a:hover) {
color: var(--fog-text, #475569); text-decoration: none;
} }
:global(.dark) .markdown-content :global(.nostr-link:hover) { :global(.markdown-content code) {
color: var(--fog-dark-text, #cbd5e1); background: var(--fog-border, #e5e7eb);
padding: 0.125rem 0.25rem;
border-radius: 0.25rem;
font-size: 0.875em;
} }
.markdown-content :global(code) { :global(.dark .markdown-content code) {
background: #e2e8f0; background: var(--fog-dark-border, #374151);
padding: 0.2em 0.4em;
border-radius: 3px;
font-family: monospace;
color: #475569;
} }
:global(.dark) .markdown-content :global(code) { :global(.markdown-content pre) {
background: #475569; background: var(--fog-border, #e5e7eb);
color: #cbd5e1; padding: 1rem;
} border-radius: 0.5rem;
.markdown-content :global(pre) {
background: #e2e8f0;
padding: 1em;
border-radius: 5px;
overflow-x: auto; overflow-x: auto;
border: 1px solid #cbd5e1; margin: 0.5rem 0;
}
:global(.dark) .markdown-content :global(pre) {
background: #475569;
border-color: #64748b;
}
.markdown-content :global(img) {
max-width: 100%;
height: auto;
display: block;
margin: 0.5em 0;
filter: grayscale(100%) sepia(15%) hue-rotate(200deg) saturate(60%) brightness(0.95);
} }
:global(.dark) .markdown-content :global(img) { :global(.dark .markdown-content pre) {
filter: grayscale(100%) sepia(20%) hue-rotate(200deg) saturate(70%) brightness(0.9); background: var(--fog-dark-border, #374151);
} }
.markdown-content :global(.nostr-profile-badge-placeholder), :global(.markdown-content pre code) {
.markdown-content :global(.nostr-embedded-event-placeholder) { background: transparent;
display: inline-block; padding: 0;
vertical-align: middle;
} }
.markdown-content :global(span[role="img"]), :global(.markdown-content blockquote) {
.markdown-content :global(.emoji) { border-left: 4px solid var(--fog-border, #e5e7eb);
filter: grayscale(100%) sepia(15%) hue-rotate(200deg) saturate(60%) brightness(0.95); padding-left: 1rem;
display: inline-block; margin: 0.5rem 0;
color: var(--fog-text-light, #6b7280);
} }
:global(.dark) .markdown-content :global(span[role="img"]), :global(.dark .markdown-content blockquote) {
:global(.dark) .markdown-content :global(.emoji) { border-color: var(--fog-dark-border, #374151);
filter: grayscale(100%) sepia(20%) hue-rotate(200deg) saturate(70%) brightness(0.9); color: var(--fog-dark-text-light, #9ca3af);
} }
</style> </style>

31
src/lib/components/content/mount-component-action.ts

@ -14,26 +14,49 @@ export function mountComponent(
if (component && typeof component === 'function') { if (component && typeof component === 'function') {
// Using Svelte 4 component API (enabled via compatibility mode in svelte.config.js) // Using Svelte 4 component API (enabled via compatibility mode in svelte.config.js)
try { try {
// Clear the node first to ensure clean mounting
node.innerHTML = '';
// Create a new instance // Create a new instance
// In Svelte 5 with compatibility mode, components are instantiated with the Svelte 4 API
instance = new (component as any)({ instance = new (component as any)({
target: node, target: node,
props props,
// Ensure the component is hydrated and rendered
hydrate: false,
intro: false
}); });
// Verify the component was mounted
if (!instance) {
console.warn('[mountComponent] Component instance not created', { component, props });
}
} catch (e) { } catch (e) {
console.error('Failed to mount component:', e); console.error('[mountComponent] Failed to mount component:', e, { component, props, node });
} }
} else {
console.warn('[mountComponent] Invalid component provided', { component, props });
} }
return { return {
update(newProps: Record<string, any>) { update(newProps: Record<string, any>) {
if (instance && typeof instance.$set === 'function') { if (instance && typeof instance.$set === 'function') {
instance.$set(newProps); try {
instance.$set(newProps);
} catch (e) {
console.error('[mountComponent] Failed to update component:', e);
}
} }
}, },
destroy() { destroy() {
if (instance && typeof instance.$destroy === 'function') { if (instance && typeof instance.$destroy === 'function') {
instance.$destroy(); try {
instance.$destroy();
} catch (e) {
console.error('[mountComponent] Failed to destroy component:', e);
}
} }
instance = null;
} }
}; };
} }

25
src/lib/components/layout/ProfileBadge.svelte

@ -15,30 +15,13 @@
let activityMessage = $state<string | null>(null); let activityMessage = $state<string | null>(null);
let imageError = $state(false); let imageError = $state(false);
// Debounce requests to allow batching from parent components
let loadTimeout: ReturnType<typeof setTimeout> | null = null;
$effect(() => { $effect(() => {
if (pubkey) { if (pubkey) {
imageError = false; // Reset image error when pubkey changes imageError = false; // Reset image error when pubkey changes
// Load immediately - no debounce
// Clear any pending timeout loadProfile();
if (loadTimeout) { loadStatus();
clearTimeout(loadTimeout); updateActivityStatus();
}
// Debounce requests by 200ms to allow parent components to batch fetch
loadTimeout = setTimeout(() => {
loadProfile();
loadStatus();
updateActivityStatus();
}, 200);
return () => {
if (loadTimeout) {
clearTimeout(loadTimeout);
}
};
} }
}); });

91
src/lib/services/nostr/nip21-parser.ts

@ -60,27 +60,108 @@ export function parseNIP21(uri: string): ParsedNIP21 | null {
/** /**
* Find all NIP-21 URIs in text * Find all NIP-21 URIs in text
* Also finds plain bech32 mentions (npub1..., note1..., etc.) without nostr: prefix
*/ */
export function findNIP21Links(text: string): Array<{ uri: string; start: number; end: number; parsed: ParsedNIP21 }> { export function findNIP21Links(text: string): Array<{ uri: string; start: number; end: number; parsed: ParsedNIP21 }> {
const links: Array<{ uri: string; start: number; end: number; parsed: ParsedNIP21 }> = []; const links: Array<{ uri: string; start: number; end: number; parsed: ParsedNIP21 }> = [];
const seenPositions = new Set<string>(); // Track positions to avoid duplicates
const seenEntities = new Map<string, { start: number; end: number }>(); // Track entities to prefer nostr: versions
// Match nostr: URIs (case-insensitive) // First, match nostr: URIs (case-insensitive) - these take priority
// Also match hex event IDs (64 hex characters) as nostr:hexID // Also match hex event IDs (64 hex characters) as nostr:hexID
const regex = /nostr:((npub|note|nevent|naddr|nprofile)1[a-z0-9]+|[0-9a-f]{64})/gi; const nostrUriRegex = /nostr:((npub|note|nevent|naddr|nprofile)1[a-z0-9]+|[0-9a-f]{64})/gi;
let match; let match;
while ((match = regex.exec(text)) !== null) { while ((match = nostrUriRegex.exec(text)) !== null) {
const uri = match[0]; const uri = match[0];
const parsed = parseNIP21(uri); const parsed = parseNIP21(uri);
if (parsed) {
const key = `${match.index}-${match.index + uri.length}`;
if (!seenPositions.has(key)) {
seenPositions.add(key);
// Extract the entity identifier (without nostr: prefix)
const entityId = uri.slice(6); // Remove 'nostr:' prefix
seenEntities.set(entityId.toLowerCase(), { start: match.index, end: match.index + uri.length });
links.push({
uri,
start: match.index,
end: match.index + uri.length,
parsed
});
}
}
}
// Also match plain bech32 mentions (npub1..., note1..., nevent1..., naddr1..., nprofile1...)
// and hex event IDs (64 hex characters) without nostr: prefix
// Use word boundaries to avoid matching partial strings
// BUT skip if we already found a nostr: version of the same entity
const bech32Regex = /\b((npub|note|nevent|naddr|nprofile)1[a-z0-9]{58,})\b/gi;
while ((match = bech32Regex.exec(text)) !== null) {
const bech32String = match[1];
const key = `${match.index}-${match.index + bech32String.length}`;
// Skip if this position overlaps with a nostr: URI we already found
if (seenPositions.has(key)) continue;
// Skip if we already found a nostr: version of this entity
const existing = seenEntities.get(bech32String.toLowerCase());
if (existing) {
// Check if positions overlap
if (!(match.index >= existing.end || match.index + bech32String.length <= existing.start)) {
continue; // Overlaps with nostr: version, skip
}
}
seenPositions.add(key);
// Create a nostr: URI for parsing
const uri = `nostr:${bech32String}`;
const parsed = parseNIP21(uri);
if (parsed) { if (parsed) {
links.push({ links.push({
uri, uri: bech32String, // Store without nostr: prefix for display
start: match.index, start: match.index,
end: match.index + uri.length, end: match.index + bech32String.length,
parsed parsed
}); });
} }
} }
// Match hex event IDs (64 hex characters) without nostr: prefix
// BUT skip if we already found a nostr: version
const hexIdRegex = /\b([0-9a-f]{64})\b/gi;
while ((match = hexIdRegex.exec(text)) !== null) {
const hexId = match[1];
const key = `${match.index}-${match.index + hexId.length}`;
// Skip if this position overlaps with a nostr: URI we already found
if (seenPositions.has(key)) continue;
// Skip if we already found a nostr: version of this hex ID
const existing = seenEntities.get(hexId.toLowerCase());
if (existing) {
// Check if positions overlap
if (!(match.index >= existing.end || match.index + hexId.length <= existing.start)) {
continue; // Overlaps with nostr: version, skip
}
}
seenPositions.add(key);
// Create a nostr: URI for parsing
const uri = `nostr:${hexId}`;
const parsed = parseNIP21(uri);
if (parsed) {
links.push({
uri: hexId, // Store without nostr: prefix
start: match.index,
end: match.index + hexId.length,
parsed
});
}
}
// Sort by start position
links.sort((a, b) => a.start - b.start);
return links; return links;
} }

19
src/lib/services/security/sanitizer.ts

@ -3,12 +3,18 @@
*/ */
import DOMPurify from 'dompurify'; import DOMPurify from 'dompurify';
import { browser } from '$app/environment';
/** /**
* Sanitize HTML content * Sanitize HTML content
*/ */
export function sanitizeHtml(dirty: string): string { export function sanitizeHtml(dirty: string): string {
return DOMPurify.sanitize(dirty, { // Only sanitize in browser - during SSR, return as-is (will be sanitized on client)
if (!browser) {
return dirty;
}
const config = {
ALLOWED_TAGS: [ ALLOWED_TAGS: [
'p', 'p',
'br', 'br',
@ -35,10 +41,15 @@ export function sanitizeHtml(dirty: string): string {
'div', 'div',
'span' 'span'
], ],
ALLOWED_ATTR: ['href', 'src', 'alt', 'title', 'class', 'controls', 'preload', 'loading', 'autoplay', 'data-pubkey', 'data-event-id', 'data-placeholder'], ALLOWED_ATTR: ['href', 'src', 'alt', 'title', 'class', 'controls', 'preload', 'loading', 'autoplay', 'width', 'height', 'data-pubkey', 'data-event-id', 'data-placeholder', 'data-nostr-profile', 'data-mounted'],
ALLOW_DATA_ATTR: true, ALLOW_DATA_ATTR: true,
KEEP_CONTENT: true KEEP_CONTENT: true,
}); // Ensure images are preserved
FORBID_TAGS: [],
FORBID_ATTR: []
};
return DOMPurify.sanitize(dirty, config);
} }
/** /**

Loading…
Cancel
Save