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