From 5c0eb129f437fe84e900391b02f1d4ff7557d26d Mon Sep 17 00:00:00 2001 From: Fringg Date: Mon, 23 Mar 2026 12:43:47 +0300 Subject: [PATCH] fix: register DOMPurify hooks once, abort featured upload, fix double drop - Register DOMPurify hooks at module init (not per sanitizeHtml call) - Always set iframe allow attribute (not conditional on existing) - Add AbortController to featured image upload for unmount cleanup - Check defaultPrevented in React onDrop to prevent double upload with TipTap - Remove explicit Content-Type header on upload (axios auto-sets boundary) --- src/api/news.ts | 2 +- src/pages/AdminNewsCreate.tsx | 11 ++- src/pages/NewsArticle.tsx | 124 +++++++++++++++++----------------- 3 files changed, 71 insertions(+), 66 deletions(-) diff --git a/src/api/news.ts b/src/api/news.ts index 996d5c0..5dbcd15 100644 --- a/src/api/news.ts +++ b/src/api/news.ts @@ -64,7 +64,7 @@ export const newsApi = { const response = await apiClient.post( '/cabinet/admin/news/media/upload', formData, - { headers: { 'Content-Type': 'multipart/form-data' }, signal }, + { signal }, ); return response.data; }, diff --git a/src/pages/AdminNewsCreate.tsx b/src/pages/AdminNewsCreate.tsx index 553ae57..b57c32c 100644 --- a/src/pages/AdminNewsCreate.tsx +++ b/src/pages/AdminNewsCreate.tsx @@ -382,16 +382,21 @@ export default function AdminNewsCreate() { return; } + const controller = new AbortController(); + activeUploadsRef.current.add(controller); setIsFeaturedImageUploading(true); try { - const result = await newsApi.uploadMedia(file); + const result = await newsApi.uploadMedia(file, controller.signal); + if (controller.signal.aborted) return; setFeaturedImageUrl(result.url); haptic.success(); } catch { + if (controller.signal.aborted) return; haptic.error(); setSaveError(t('news.admin.uploadError')); } finally { + activeUploadsRef.current.delete(controller); setIsFeaturedImageUploading(false); } }, @@ -410,8 +415,10 @@ export default function AdminNewsCreate() { const handleEditorDrop = useCallback( (e: React.DragEvent) => { - e.preventDefault(); setIsDragging(false); + // TipTap's handleDrop already handled media files dropped on the editor content + if (e.defaultPrevented) return; + e.preventDefault(); const file = e.dataTransfer.files[0]; if (file) handleMediaUpload(file); }, diff --git a/src/pages/NewsArticle.tsx b/src/pages/NewsArticle.tsx index 79a1efb..48db1f8 100644 --- a/src/pages/NewsArticle.tsx +++ b/src/pages/NewsArticle.tsx @@ -170,70 +170,68 @@ const SANITIZE_CONFIG = { */ const articlePurify = DOMPurify(window); +// Register sanitization hooks once at module init. +// All hooks are stateless and idempotent — no need to add/remove per call. + +// Hook: strip iframes with disallowed src +articlePurify.addHook('afterSanitizeAttributes', (node) => { + if (node.tagName === 'IFRAME') { + const src = node.getAttribute('src') ?? ''; + if (!isAllowedIframeSrc(src)) { + node.remove(); + return; + } + // Force sandbox — allow-scripts + allow-same-origin needed for YouTube/Vimeo + // (cross-origin, so sandbox escape via frameElement is not possible) + node.setAttribute('sandbox', 'allow-scripts allow-same-origin allow-presentation'); + // Always set allow — restricts permissions even if original had none + node.setAttribute('allow', 'autoplay; encrypted-media; picture-in-picture'); + } +}); + +// Hook: validate