הטמעת עורך WYSIWYG לתוכן שנוצר על ידי משתמשים באתר
תארו לעצמכם משתמש שמוסיף ביקורת עם טקסט מודגש, קישור לאתר זדוני וסקריפט מוטמע. ללא סינון, זה פוגע בכל האתר. לאחרונה נתקלנו בפרויקט שבו נרשמו עד 50 התקפות XSS בחודש דרך שדה הביקורות. לאחר הטמעת TipTap עם DOMPurify, ההתקפות נפסקו וזמן המודרציה ירד ב-70%. במאמר זה נראה לכם כיצד להטמיע עורך WYSIWYG עם הגנה מלאה ועיצוב צפוי. תקבלו ארכיטקטורה מוכנה: מבחירת הכלי ועד להצגת HTML שמור.
בחירת עורך: Quill, TipTap או Lexical?
שלושה עורכים עיקריים מתאימים לתוכן שנוצר על ידי משתמשים. ההשוואה ביניהם בטבלה:
| עורך | גודל (gzip) | שימוש עיקרי | יכולת הרחבה | ביצועים |
|---|---|---|---|---|
| Quill | ~20 KB | תגובות, טקסטים קצרים | מוגבלת | גבוהים בנפחים קטנים |
| TipTap (ProseMirror) | ~40 KB | מאמרים, מסמכים, בלוגים | מצוינת (TypeScript) | בינוניים אך יציבים |
| Lexical (Meta) | ~30 KB | תוכן מורכב, RSC | טובה (React) | גבוהים ביותר (מצב מקבילי) |
TipTap הוא הבחירה המאוזנת ביותר לתוכן שנוצר על ידי משתמשים. הוא מהיר פי 2–3 מ-Quill במסמכים ארוכים בזכות ה-DOM הווירטואלי של ProseMirror ותומך ב-React Server Components. Lexical מהיר פי 1.5 מ-TipTap בפעולות הכנסה אך דורש יותר קוד תשתית.
כיצד להגדיר את TipTap לתרחישי משתמש?
התחילו בהתקנת החבילות ויצירת הקומפוננטה. כללו את StarterKit, הגבילו כותרות (h2, h3), השביתו בלוקי קוד. כפו פתיחת קישורים בטאב חדש עם rel="nofollow noopener noreferrer". העלו תמונות באמצעות פונקציית העלאה.
// components/UserEditor.tsx
import { useEditor, EditorContent } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import Link from '@tiptap/extension-link'
import Image from '@tiptap/extension-image'
import Placeholder from '@tiptap/extension-placeholder'
import { useCallback } from 'react'
interface UserEditorProps {
initialContent?: string
onChange: (html: string) => void
maxLength?: number
}
export function UserEditor({ initialContent, onChange, maxLength = 10000 }: UserEditorProps) {
const editor = useEditor({
extensions: [
StarterKit.configure({
heading: { levels: [2, 3] },
codeBlock: false,
horizontalRule: false,
}),
Link.configure({
openOnClick: false,
HTMLAttributes: {
rel: 'nofollow noopener noreferrer',
target: '_blank',
},
validate: href => /^https?:\/\//.test(href),
}),
Image.configure({
allowBase64: false,
HTMLAttributes: { loading: 'lazy' },
}),
Placeholder.configure({
placeholder: 'Напишите что-нибудь...',
}),
],
content: initialContent,
onUpdate: ({ editor }) => {
const html = editor.getHTML()
if (html.length <= maxLength) {
onChange(html)
}
},
})
const addLink = useCallback(() => {
const url = window.prompt('URL:')
if (url) editor?.chain().focus().setLink({ href: url }).run()
}, [editor])
if (!editor) return null
return (
<div className="border rounded-lg overflow-hidden">
<div className="flex gap-1 p-2 border-b bg-gray-50 flex-wrap">
<ToolbarButton active={editor.isActive('bold')} onClick={() => editor.chain().focus().toggleBold().run()} title="Жирный">B</ToolbarButton>
<ToolbarButton active={editor.isActive('italic')} onClick={() => editor.chain().focus().toggleItalic().run()} title="Курсив">I</ToolbarButton>
<ToolbarButton active={editor.isActive('bulletList')} onClick={() => editor.chain().focus().toggleBulletList().run()} title="Список">•</ToolbarButton>
<ToolbarButton active={editor.isActive('link')} onClick={addLink} title="Ссылка">🔗</ToolbarButton>
</div>
<EditorContent editor={editor} className="prose max-w-none p-4 min-h-[150px] focus:outline-none" />
{maxLength && <div className="text-xs text-gray-400 px-4 py-1 border-t text-right">{editor.storage.characterCount?.characters?.() ?? 0} / {maxLength}</div>}
</div>
)
} מדוע סינון HTML הוא חובה?
לעולם אל תשמרו או תציגו HTML של משתמשים ללא ניקוי. גם אם העורך מגביל תגיות בצד הלקוח—שליחת POST ישירה ל-API עוקפת זאת. השתמשו ב-DOMPurify בצד השרת עם רשימת תגיות ומאפיינים מותרים. אסרו // components/UserEditor.tsx import { useEditor, EditorContent } from '@tiptap/react' import StarterKit from '@tiptap/starter-kit' import Link from '@tiptap/extension-link' import Image from '@tiptap/extension-image' import Placeholder from '@tiptap/extension-placeholder' import { useCallback } from 'react' interface UserEditorProps { initialContent?: string onChange: (html: string) => void maxLength?: number } export function UserEditor({ initialContent, onChange, maxLength = 10000 }: UserEditorProps) { const editor = useEditor({ extensions: [ StarterKit.configure({ heading: { levels: [2, 3] }, codeBlock: false, horizontalRule: false, }), Link.configure({ openOnClick: false, HTMLAttributes: { rel: 'nofollow noopener noreferrer', target: '_blank', }, validate: href => /^https?:\/\//.test(href), }), Image.configure({ allowBase64: false, HTMLAttributes: { loading: 'lazy' }, }), Placeholder.configure({ placeholder: 'Напишите что-нибудь...', }), ], content: initialContent, onUpdate: ({ editor }) => { const html = editor.getHTML() if (html.length <= maxLength) { onChange(html) } }, }) const addLink = useCallback(() => { const url = window.prompt('URL:') if (url) editor?.chain().focus().setLink({ href: url }).run() }, [editor]) if (!editor) return null return ( <div className="border rounded-lg overflow-hidden"> <div className="flex gap-1 p-2 border-b bg-gray-50 flex-wrap"> <ToolbarButton active={editor.isActive('bold')} onClick={() => editor.chain().focus().toggleBold().run()} title="Жирный">B</ToolbarButton> <ToolbarButton active={editor.isActive('italic')} onClick={() => editor.chain().focus().toggleItalic().run()} title="Курсив">I</ToolbarButton> <ToolbarButton active={editor.isActive('bulletList')} onClick={() => editor.chain().focus().toggleBulletList().run()} title="Список">•</ToolbarButton> <ToolbarButton active={editor.isActive('link')} onClick={addLink} title="Ссылка">🔗</ToolbarButton> </div> <EditorContent editor={editor} className="prose max-w-none p-4 min-h-[150px] focus:outline-none" /> {maxLength && <div className="text-xs text-gray-400 px-4 py-1 border-t text-right">{editor.storage.characterCount?.characters?.() ?? 0} / {maxLength}</div>} </div> ) } , style, onerror—וקטורי ה-XSS הנפוצים ביותר. כפי שמציינים מומחי OWASP, התקפות XSS נשארות בעשר הפגיעויות המובילות ביישומי אינטרנט. הפחתת הסיכון מגיעה ל-99% עם הגדרה נכונה.
// lib/sanitize.ts
import DOMPurify from 'isomorphic-dompurify'
const ALLOWED_TAGS = ['p', 'br', 'strong', 'em', 'u', 's', 'h2', 'h3', 'ul', 'ol', 'li', 'blockquote', 'a', 'img']
const ALLOWED_ATTR = ['href', 'src', 'alt', 'loading', 'rel', 'target', 'class']
export function sanitizeUserHtml(dirty: string): string {
return DOMPurify.sanitize(dirty, {
ALLOWED_TAGS,
ALLOWED_ATTR,
FORBID_ATTR: ['style', 'onerror', 'onload'],
ADD_ATTR: ['rel'],
FORCE_BODY: false,
})
} רשימה מלאה של תגיות מותרות
- `p`, `br`, `strong`, `em`, `u`, `s` — עיצוב בסיסי - `h2`, `h3` — כותרות - `ul`, `ol`, `li` — רשימות - `blockquote` — ציטוטים - `a` — קישורים (רק `https?`) - `img` — תמונות (עם `loading="lazy"`)כיצד לארגן העלאת תמונות?
משתמשים ירצו להכניס תמונות. צרו נקודת קצה שבודקת את סוג הקובץ (רק JPEG, PNG, WebP), גודל (עד 5 MB), מבצעת אופטימיזציה באמצעות sharp, ומעלה ל-S3 עם מטמון ארוך טווח. חברו את פונקציית ההעלאה בעורך.
// app/api/upload/route.ts
import sharp from 'sharp'
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3'
const s3 = new S3Client({ region: process.env.AWS_REGION })
export async function POST(request: Request) {
const form = await request.formData()
const file = form.get('file') as File
if (!file) return new Response('No file', { status: 400 })
if (file.size > 5 * 1024 * 1024) return new Response('Too large', { status: 413 })
if (!['image/jpeg', 'image/png', 'image/webp'].includes(file.type)) {
return new Response('Invalid type', { status: 415 })
}
const buffer = Buffer.from(await file.arrayBuffer())
const optimized = await sharp(buffer)
.resize(1200, 1200, { fit: 'inside', withoutEnlargement: true })
.webp({ quality: 80 })
.toBuffer()
const key = `user-uploads/${Date.now()}-${crypto.randomUUID()}.webp`
await s3.send(new PutObjectCommand({
Bucket: process.env.S3_BUCKET,
Key: key,
Body: optimized,
ContentType: 'image/webp',
CacheControl: 'public, max-age=31536000, immutable',
}))
return Response.json({ url: `${process.env.CDN_URL}/${key}` })
} תוכנית שלב-אחר-שלב לשילוב עורך WYSIWYG
- בחרו עורך המתאים לסטאק שלכם (React, Vue, Angular, CMS). העריכו את גודל החבילה ואת ההרחבות הנדרשות.
- הגדירו את סרגל הכלים: אפשרו סט מוגבל של אלמנטים (מודגש, נטוי, רשימה, קישור, תמונה).
- סינון בצד השרת: פרסו DOMPurify עם רשימת תגיות ומאפיינים מותרים.
- שלבו העלאת תמונות: צרו נקודת קצה API עם בדיקות סוג וגודל, אופטימיזציה ו-CDN.
- הציגו עם סינון חוזר בפלט. השתמשו באותה רשימת תגיות מותרות.
הצגת HTML שמור
בפלט, בצעו סינון חוזר של ה-HTML—למקרה שהנתונים ישנים או נשמרו ללא ניקוי. השתמשו באותה רשימת תגיות.
// components/UserContent.tsx
import DOMPurify from 'isomorphic-dompurify'
export function UserContent({ html }: { html: string }) {
const clean = DOMPurify.sanitize(html, {
ALLOWED_TAGS,
ALLOWED_ATTR,
})
return (
<div
className="prose prose-sm max-w-none prose-a:text-blue-600 prose-a:no-underline hover:prose-a:underline prose-img:rounded-lg prose-img:mx-auto"
dangerouslySetInnerHTML={{ __html: clean }}
/>
)
} מה כלול בהטמעה סוהר
אנו מציעים מחזור מלא של הטמעת עורך WYSIWYG לתוכן שנוצר על ידי משתמשים:
- בחירת עורך לסטאק שלכם (React, Vue, Angular, CMS).
- הגדרת סרגל כלים: אלמנטים מותרים, קישורים, תמונות.
- סינון בצד השרת עם פריסת DOMPurify.
- שילוב העלאת תמונות עם אופטימיזציה ו-CDN.
- מודרציית תוכן אוטומטית (זיהוי קישורי ספאם, דיווח דרך API).
- הדרכת צוות ותיעוד.
- אחריות אבטחה ותמיכה לאחר ההשקה.
לוח זמנים ועלות
עורך בסיסי עם סינון: בין 2 ל-3 ימים. עם העלאת תמונות ו-CDN: עד 5 ימים. העלות מחושבת באופן אישי—צרו קשר להערכת פרויקט. אנו מספקים פתרון סוהר עם ניסיון של 5+ שנים ו-50+ פרויקטים מוצלחים בתחום זה.
למה לבחור בנו?
- 5+ שנות ניסיון בפיתוח ואבטחת אינטרנט.
- 50+ פרויקטים עם תוכן שנוצר על ידי משתמשים.
- אנו מבטיחים פעולה תקינה בכל הדפדפנים הפופולריים.
- שקיפות מלאה: אתם מקבלים קוד, תיעוד וגישה.
הזמינו הטמעת עורך WYSIWYG סוהר — הבטיחו אבטחה ונוחות למשתמשים שלכם. קבלו ייעוץ לפרויקט שלכם עוד היום.







