Initial commit
This commit is contained in:
1002
skills/markdown-editor-integrator/SKILL.md
Normal file
1002
skills/markdown-editor-integrator/SKILL.md
Normal file
File diff suppressed because it is too large
Load Diff
160
skills/markdown-editor-integrator/assets/MarkdownEditor.tsx
Normal file
160
skills/markdown-editor-integrator/assets/MarkdownEditor.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
'use client'
|
||||
|
||||
import { useTheme } from 'next-themes'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { ComponentProps } from 'react'
|
||||
import rehypeSanitize from 'rehype-sanitize'
|
||||
import '@uiw/react-md-editor/markdown-editor.css'
|
||||
import '@uiw/react-markdown-preview/markdown.css'
|
||||
|
||||
const MDEditor = dynamic(
|
||||
() => import('@uiw/react-md-editor'),
|
||||
{ ssr: false }
|
||||
)
|
||||
|
||||
type MDEditorProps = ComponentProps<typeof MDEditor>
|
||||
|
||||
interface MarkdownEditorProps extends Omit<MDEditorProps, 'data-color-mode'> {
|
||||
value: string
|
||||
onChange?: (value?: string) => void
|
||||
height?: number | string
|
||||
hideToolbar?: boolean
|
||||
enablePreview?: boolean
|
||||
preview?: 'edit' | 'live' | 'preview'
|
||||
}
|
||||
|
||||
/**
|
||||
* Markdown Editor component with theme integration and sanitization
|
||||
*
|
||||
* Features:
|
||||
* - Automatic theme switching (light/dark)
|
||||
* - XSS protection with rehype-sanitize
|
||||
* - Controlled component for forms
|
||||
* - Customizable toolbar and preview
|
||||
* - Next.js SSR compatible
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const [content, setContent] = useState('')
|
||||
*
|
||||
* <MarkdownEditor
|
||||
* value={content}
|
||||
* onChange={setContent}
|
||||
* height={400}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export function MarkdownEditor({
|
||||
value,
|
||||
onChange,
|
||||
height = 400,
|
||||
hideToolbar = false,
|
||||
enablePreview = true,
|
||||
preview = 'live',
|
||||
...props
|
||||
}: MarkdownEditorProps) {
|
||||
const { theme } = useTheme()
|
||||
|
||||
return (
|
||||
<div
|
||||
data-color-mode={theme === 'dark' ? 'dark' : 'light'}
|
||||
className="markdown-editor-wrapper"
|
||||
>
|
||||
<MDEditor
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
height={height}
|
||||
hideToolbar={hideToolbar}
|
||||
enablePreview={enablePreview}
|
||||
preview={preview}
|
||||
previewOptions={{
|
||||
rehypePlugins: [[rehypeSanitize]],
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// CSS for theme integration (add to globals.css)
|
||||
const editorStyles = `
|
||||
/* Markdown Editor Theme Integration */
|
||||
|
||||
.markdown-editor-wrapper {
|
||||
@apply rounded-md border border-input;
|
||||
}
|
||||
|
||||
.w-md-editor {
|
||||
@apply bg-background text-foreground shadow-none;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.w-md-editor-toolbar {
|
||||
@apply border-b border-border bg-muted/30;
|
||||
}
|
||||
|
||||
.w-md-editor-toolbar button,
|
||||
.w-md-editor-toolbar li button {
|
||||
@apply text-foreground hover:bg-accent hover:text-accent-foreground;
|
||||
}
|
||||
|
||||
.w-md-editor-toolbar li.active button {
|
||||
@apply bg-accent text-accent-foreground;
|
||||
}
|
||||
|
||||
.w-md-editor-content {
|
||||
@apply text-foreground;
|
||||
}
|
||||
|
||||
.w-md-editor-preview {
|
||||
@apply prose prose-sm dark:prose-invert max-w-none p-4;
|
||||
}
|
||||
|
||||
.w-md-editor-text-pre,
|
||||
.w-md-editor-text-input {
|
||||
@apply text-foreground;
|
||||
}
|
||||
|
||||
.w-md-editor-text-pre > code,
|
||||
.w-md-editor-text-input > code {
|
||||
@apply text-foreground;
|
||||
}
|
||||
|
||||
/* Code blocks in preview */
|
||||
.wmde-markdown {
|
||||
@apply bg-transparent text-foreground;
|
||||
}
|
||||
|
||||
.wmde-markdown pre {
|
||||
@apply bg-muted;
|
||||
}
|
||||
|
||||
.wmde-markdown code {
|
||||
@apply text-primary;
|
||||
}
|
||||
|
||||
.wmde-markdown blockquote {
|
||||
@apply border-l-primary/50;
|
||||
}
|
||||
|
||||
.wmde-markdown a {
|
||||
@apply text-primary hover:underline;
|
||||
}
|
||||
|
||||
/* Tables in preview */
|
||||
.wmde-markdown table {
|
||||
@apply border-border;
|
||||
}
|
||||
|
||||
.wmde-markdown th,
|
||||
.wmde-markdown td {
|
||||
@apply border-border;
|
||||
}
|
||||
|
||||
.wmde-markdown th {
|
||||
@apply bg-muted;
|
||||
}
|
||||
`
|
||||
|
||||
// Export styles constant for documentation
|
||||
export { editorStyles }
|
||||
151
skills/markdown-editor-integrator/assets/MarkdownPreview.tsx
Normal file
151
skills/markdown-editor-integrator/assets/MarkdownPreview.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
'use client'
|
||||
|
||||
import { useTheme } from 'next-themes'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { ComponentProps } from 'react'
|
||||
import rehypeSanitize from 'rehype-sanitize'
|
||||
import '@uiw/react-markdown-preview/markdown.css'
|
||||
|
||||
const MDPreview = dynamic(
|
||||
() => import('@uiw/react-markdown-preview'),
|
||||
{ ssr: false }
|
||||
)
|
||||
|
||||
type MDPreviewProps = ComponentProps<typeof MDPreview>
|
||||
|
||||
interface MarkdownPreviewProps extends Omit<MDPreviewProps, 'source' | 'data-color-mode'> {
|
||||
content: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Markdown Preview component for displaying formatted markdown
|
||||
*
|
||||
* Features:
|
||||
* - Automatic theme switching
|
||||
* - XSS protection with rehype-sanitize
|
||||
* - GitHub-flavored markdown support
|
||||
* - Syntax highlighting for code blocks
|
||||
* - Next.js SSR compatible
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <MarkdownPreview
|
||||
* content={character.biography}
|
||||
* className="max-w-3xl"
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export function MarkdownPreview({
|
||||
content,
|
||||
className = '',
|
||||
...props
|
||||
}: MarkdownPreviewProps) {
|
||||
const { theme } = useTheme()
|
||||
|
||||
return (
|
||||
<div
|
||||
data-color-mode={theme === 'dark' ? 'dark' : 'light'}
|
||||
className={`markdown-preview-wrapper ${className}`}
|
||||
>
|
||||
<MDPreview
|
||||
source={content}
|
||||
rehypePlugins={[[rehypeSanitize]]}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// CSS for preview theme integration (add to globals.css)
|
||||
const previewStyles = `
|
||||
/* Markdown Preview Theme Integration */
|
||||
|
||||
.markdown-preview-wrapper {
|
||||
@apply rounded-md;
|
||||
}
|
||||
|
||||
.markdown-preview-wrapper .wmde-markdown {
|
||||
@apply bg-transparent text-foreground;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
/* Typography */
|
||||
.markdown-preview-wrapper .wmde-markdown h1,
|
||||
.markdown-preview-wrapper .wmde-markdown h2,
|
||||
.markdown-preview-wrapper .wmde-markdown h3,
|
||||
.markdown-preview-wrapper .wmde-markdown h4,
|
||||
.markdown-preview-wrapper .wmde-markdown h5,
|
||||
.markdown-preview-wrapper .wmde-markdown h6 {
|
||||
@apply text-foreground font-semibold;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.markdown-preview-wrapper .wmde-markdown p {
|
||||
@apply text-foreground leading-7;
|
||||
}
|
||||
|
||||
/* Links */
|
||||
.markdown-preview-wrapper .wmde-markdown a {
|
||||
@apply text-primary hover:underline;
|
||||
}
|
||||
|
||||
/* Lists */
|
||||
.markdown-preview-wrapper .wmde-markdown ul,
|
||||
.markdown-preview-wrapper .wmde-markdown ol {
|
||||
@apply text-foreground;
|
||||
}
|
||||
|
||||
/* Blockquotes */
|
||||
.markdown-preview-wrapper .wmde-markdown blockquote {
|
||||
@apply border-l-4 border-primary/50 bg-muted/50 text-muted-foreground italic;
|
||||
}
|
||||
|
||||
/* Code */
|
||||
.markdown-preview-wrapper .wmde-markdown code {
|
||||
@apply bg-muted text-primary px-1.5 py-0.5 rounded text-sm;
|
||||
}
|
||||
|
||||
.markdown-preview-wrapper .wmde-markdown pre {
|
||||
@apply bg-muted rounded-md p-4 overflow-x-auto;
|
||||
}
|
||||
|
||||
.markdown-preview-wrapper .wmde-markdown pre code {
|
||||
@apply bg-transparent p-0;
|
||||
}
|
||||
|
||||
/* Tables */
|
||||
.markdown-preview-wrapper .wmde-markdown table {
|
||||
@apply border-collapse w-full border border-border;
|
||||
}
|
||||
|
||||
.markdown-preview-wrapper .wmde-markdown th {
|
||||
@apply bg-muted font-semibold text-left p-2 border border-border;
|
||||
}
|
||||
|
||||
.markdown-preview-wrapper .wmde-markdown td {
|
||||
@apply p-2 border border-border;
|
||||
}
|
||||
|
||||
.markdown-preview-wrapper .wmde-markdown tr:nth-child(even) {
|
||||
@apply bg-muted/30;
|
||||
}
|
||||
|
||||
/* Horizontal Rule */
|
||||
.markdown-preview-wrapper .wmde-markdown hr {
|
||||
@apply border-border my-4;
|
||||
}
|
||||
|
||||
/* Images */
|
||||
.markdown-preview-wrapper .wmde-markdown img {
|
||||
@apply rounded-md max-w-full h-auto;
|
||||
}
|
||||
|
||||
/* Task Lists */
|
||||
.markdown-preview-wrapper .wmde-markdown input[type="checkbox"] {
|
||||
@apply mr-2;
|
||||
}
|
||||
`
|
||||
|
||||
// Export styles constant for documentation
|
||||
export { previewStyles }
|
||||
573
skills/markdown-editor-integrator/references/sanitization.md
Normal file
573
skills/markdown-editor-integrator/references/sanitization.md
Normal file
@@ -0,0 +1,573 @@
|
||||
# Markdown Sanitization Security Guide
|
||||
|
||||
## Why Sanitization is Critical
|
||||
|
||||
Markdown editors can be exploited for XSS attacks through:
|
||||
- Malicious JavaScript in HTML tags
|
||||
- Script injection via event handlers
|
||||
- Data exfiltration through image sources
|
||||
- Link-based phishing attacks
|
||||
- Iframe injection
|
||||
|
||||
## Client-Side Sanitization
|
||||
|
||||
### Using rehype-sanitize
|
||||
|
||||
```bash
|
||||
npm install rehype-sanitize
|
||||
```
|
||||
|
||||
```tsx
|
||||
import MDEditor from '@uiw/react-md-editor'
|
||||
import rehypeSanitize from 'rehype-sanitize'
|
||||
|
||||
<MDEditor
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
previewOptions={{
|
||||
rehypePlugins: [[rehypeSanitize]],
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
### Custom Sanitization Schema
|
||||
|
||||
```tsx
|
||||
import rehypeSanitize, { defaultSchema } from 'rehype-sanitize'
|
||||
import { deepmerge } from 'deepmerge-ts'
|
||||
|
||||
const customSchema = deepmerge(defaultSchema, {
|
||||
attributes: {
|
||||
'*': ['className'], // Allow className on all elements
|
||||
a: ['href', 'title', 'target', 'rel'],
|
||||
img: ['src', 'alt', 'title', 'width', 'height'],
|
||||
code: ['className'], // For syntax highlighting
|
||||
},
|
||||
tagNames: [
|
||||
// Standard markdown
|
||||
'p', 'br', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
||||
'strong', 'em', 'u', 's', 'del', 'ins',
|
||||
'ul', 'ol', 'li',
|
||||
'blockquote', 'code', 'pre',
|
||||
'a', 'img',
|
||||
'table', 'thead', 'tbody', 'tr', 'th', 'td',
|
||||
'hr', 'div', 'span',
|
||||
// Additional if needed
|
||||
'kbd', 'mark', 'abbr',
|
||||
],
|
||||
protocols: {
|
||||
href: ['http', 'https', 'mailto'],
|
||||
src: ['http', 'https'],
|
||||
},
|
||||
})
|
||||
|
||||
<MDEditor
|
||||
previewOptions={{
|
||||
rehypePlugins: [[rehypeSanitize, customSchema]],
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
### Strict Sanitization (No HTML)
|
||||
|
||||
```tsx
|
||||
import rehypeSanitize from 'rehype-sanitize'
|
||||
|
||||
const strictSchema = {
|
||||
tagNames: [
|
||||
'p', 'br', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
||||
'strong', 'em', 'code', 'pre',
|
||||
'ul', 'ol', 'li',
|
||||
'blockquote',
|
||||
'a',
|
||||
'hr',
|
||||
],
|
||||
attributes: {
|
||||
a: ['href'],
|
||||
code: ['className'],
|
||||
},
|
||||
protocols: {
|
||||
href: ['https'], // Only HTTPS links
|
||||
},
|
||||
}
|
||||
|
||||
<MDEditor
|
||||
previewOptions={{
|
||||
rehypePlugins: [[rehypeSanitize, strictSchema]],
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
## Server-Side Sanitization
|
||||
|
||||
### Using DOMPurify
|
||||
|
||||
```bash
|
||||
npm install isomorphic-dompurify
|
||||
```
|
||||
|
||||
```typescript
|
||||
// lib/sanitize-markdown.ts
|
||||
import DOMPurify from 'isomorphic-dompurify'
|
||||
|
||||
export function sanitizeMarkdown(markdown: string): string {
|
||||
return DOMPurify.sanitize(markdown, {
|
||||
ALLOWED_TAGS: [
|
||||
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
||||
'p', 'br', 'strong', 'em', 'u', 's',
|
||||
'ul', 'ol', 'li',
|
||||
'blockquote', 'code', 'pre',
|
||||
'a', 'img',
|
||||
'table', 'thead', 'tbody', 'tr', 'th', 'td',
|
||||
'hr', 'div', 'span',
|
||||
],
|
||||
ALLOWED_ATTR: [
|
||||
'href', 'src', 'alt', 'title',
|
||||
'class', 'id',
|
||||
'width', 'height',
|
||||
],
|
||||
ALLOWED_URI_REGEXP: /^(?:https?:|mailto:)/i,
|
||||
KEEP_CONTENT: true,
|
||||
RETURN_DOM: false,
|
||||
RETURN_DOM_FRAGMENT: false,
|
||||
RETURN_DOM_IMPORT: false,
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### Server Action with Sanitization
|
||||
|
||||
```typescript
|
||||
'use server'
|
||||
|
||||
import { sanitizeMarkdown } from '@/lib/sanitize-markdown'
|
||||
import { db } from '@/lib/db'
|
||||
import { auth } from '@/lib/auth'
|
||||
|
||||
export async function saveCharacterBio(
|
||||
characterId: string,
|
||||
biography: string
|
||||
) {
|
||||
const session = await auth()
|
||||
|
||||
if (!session?.user) {
|
||||
return { success: false, message: 'Unauthorized' }
|
||||
}
|
||||
|
||||
// Sanitize before saving
|
||||
const sanitizedBio = sanitizeMarkdown(biography)
|
||||
|
||||
try {
|
||||
await db.character.update({
|
||||
where: { id: characterId },
|
||||
data: { biography: sanitizedBio },
|
||||
})
|
||||
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
console.error('Failed to save biography:', error)
|
||||
return { success: false, message: 'Failed to save' }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### API Route with Sanitization
|
||||
|
||||
```typescript
|
||||
// app/api/entities/[id]/description/route.ts
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { sanitizeMarkdown } from '@/lib/sanitize-markdown'
|
||||
import { db } from '@/lib/db'
|
||||
import { getServerSession } from 'next-auth'
|
||||
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
const session = await getServerSession()
|
||||
|
||||
if (!session?.user) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const { description } = await request.json()
|
||||
|
||||
// Validate input
|
||||
if (!description || typeof description !== 'string') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid description' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Sanitize
|
||||
const sanitized = sanitizeMarkdown(description)
|
||||
|
||||
// Save to database
|
||||
await db.entity.update({
|
||||
where: { id: params.id },
|
||||
data: { description: sanitized },
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
```
|
||||
|
||||
## Advanced Sanitization Patterns
|
||||
|
||||
### Markdown-to-HTML with Sanitization
|
||||
|
||||
```typescript
|
||||
import { remark } from 'remark'
|
||||
import remarkHtml from 'remark-html'
|
||||
import DOMPurify from 'isomorphic-dompurify'
|
||||
|
||||
export async function markdownToSafeHtml(markdown: string): Promise<string> {
|
||||
// Convert markdown to HTML
|
||||
const result = await remark()
|
||||
.use(remarkHtml, { sanitize: false }) // Don't double-sanitize
|
||||
.process(markdown)
|
||||
|
||||
const html = result.toString()
|
||||
|
||||
// Sanitize HTML
|
||||
const sanitized = DOMPurify.sanitize(html, {
|
||||
ALLOWED_TAGS: [
|
||||
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
||||
'p', 'br', 'strong', 'em', 'code', 'pre',
|
||||
'ul', 'ol', 'li',
|
||||
'blockquote', 'a', 'img',
|
||||
'table', 'thead', 'tbody', 'tr', 'th', 'td',
|
||||
'hr',
|
||||
],
|
||||
ALLOWED_ATTR: ['href', 'src', 'alt', 'title'],
|
||||
})
|
||||
|
||||
return sanitized
|
||||
}
|
||||
|
||||
// Usage in server action
|
||||
export async function saveDescription(id: string, markdown: string) {
|
||||
const html = await markdownToSafeHtml(markdown)
|
||||
|
||||
await db.entity.update({
|
||||
where: { id },
|
||||
data: {
|
||||
descriptionMarkdown: markdown, // Store original
|
||||
descriptionHtml: html, // Store sanitized HTML
|
||||
},
|
||||
})
|
||||
|
||||
return { success: true }
|
||||
}
|
||||
```
|
||||
|
||||
### Link Validation
|
||||
|
||||
```typescript
|
||||
import DOMPurify from 'isomorphic-dompurify'
|
||||
|
||||
export function sanitizeWithLinkValidation(markdown: string): string {
|
||||
return DOMPurify.sanitize(markdown, {
|
||||
ALLOWED_TAGS: ['a', 'p', 'h1', 'h2', 'h3', 'strong', 'em', 'code'],
|
||||
ALLOWED_ATTR: ['href'],
|
||||
ALLOWED_URI_REGEXP: /^https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)$/,
|
||||
// Block certain domains
|
||||
FORBID_ATTR: [],
|
||||
HOOKS: {
|
||||
afterSanitizeAttributes: (node) => {
|
||||
if (node.tagName === 'A') {
|
||||
const href = node.getAttribute('href')
|
||||
if (href) {
|
||||
// Block suspicious links
|
||||
const suspiciousDomains = ['malicious.com', 'phishing.com']
|
||||
const url = new URL(href)
|
||||
if (suspiciousDomains.some(domain => url.hostname.includes(domain))) {
|
||||
node.removeAttribute('href')
|
||||
}
|
||||
// Force external links to open in new tab
|
||||
node.setAttribute('target', '_blank')
|
||||
node.setAttribute('rel', 'noopener noreferrer')
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### Image Source Validation
|
||||
|
||||
```typescript
|
||||
import DOMPurify from 'isomorphic-dompurify'
|
||||
|
||||
export function sanitizeWithImageValidation(markdown: string): string {
|
||||
return DOMPurify.sanitize(markdown, {
|
||||
ALLOWED_TAGS: ['img', 'p', 'h1', 'h2', 'strong', 'em'],
|
||||
ALLOWED_ATTR: ['src', 'alt', 'title', 'width', 'height'],
|
||||
HOOKS: {
|
||||
afterSanitizeAttributes: (node) => {
|
||||
if (node.tagName === 'IMG') {
|
||||
const src = node.getAttribute('src')
|
||||
if (src) {
|
||||
try {
|
||||
const url = new URL(src)
|
||||
// Only allow images from trusted domains
|
||||
const trustedDomains = [
|
||||
'images.example.com',
|
||||
'cdn.example.com',
|
||||
'storage.googleapis.com',
|
||||
]
|
||||
if (!trustedDomains.some(domain => url.hostname === domain)) {
|
||||
node.removeAttribute('src')
|
||||
node.setAttribute('alt', 'Image blocked: untrusted source')
|
||||
}
|
||||
} catch (error) {
|
||||
// Invalid URL, remove src
|
||||
node.removeAttribute('src')
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
### 1. Always Sanitize on Server
|
||||
|
||||
Never trust client-side sanitization alone:
|
||||
|
||||
```typescript
|
||||
// [ERROR] BAD: Only client-side sanitization
|
||||
function onSubmit(data: FormValues) {
|
||||
await saveDescription(data.description) // Raw user input
|
||||
}
|
||||
|
||||
// [OK] GOOD: Server-side sanitization
|
||||
'use server'
|
||||
export async function saveDescription(description: string) {
|
||||
const sanitized = sanitizeMarkdown(description)
|
||||
await db.save(sanitized)
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Validate Input Length
|
||||
|
||||
```typescript
|
||||
const MAX_MARKDOWN_LENGTH = 50000 // 50KB
|
||||
|
||||
export async function saveDescription(description: string) {
|
||||
if (description.length > MAX_MARKDOWN_LENGTH) {
|
||||
throw new Error('Description too long')
|
||||
}
|
||||
|
||||
const sanitized = sanitizeMarkdown(description)
|
||||
await db.save(sanitized)
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Store Both Raw and Sanitized
|
||||
|
||||
```typescript
|
||||
export async function saveDescription(
|
||||
entityId: string,
|
||||
description: string
|
||||
) {
|
||||
const sanitized = sanitizeMarkdown(description)
|
||||
|
||||
await db.entity.update({
|
||||
where: { id: entityId },
|
||||
data: {
|
||||
descriptionRaw: description, // Original for editing
|
||||
description: sanitized, // Sanitized for display
|
||||
descriptionUpdatedAt: new Date(),
|
||||
},
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Implement Rate Limiting
|
||||
|
||||
```typescript
|
||||
import { Ratelimit } from '@upstash/ratelimit'
|
||||
import { Redis } from '@upstash/redis'
|
||||
|
||||
const ratelimit = new Ratelimit({
|
||||
redis: Redis.fromEnv(),
|
||||
limiter: Ratelimit.slidingWindow(10, '1 m'), // 10 requests per minute
|
||||
})
|
||||
|
||||
export async function saveDescription(
|
||||
userId: string,
|
||||
description: string
|
||||
) {
|
||||
const { success } = await ratelimit.limit(userId)
|
||||
|
||||
if (!success) {
|
||||
throw new Error('Rate limit exceeded')
|
||||
}
|
||||
|
||||
const sanitized = sanitizeMarkdown(description)
|
||||
await db.save(sanitized)
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Log Suspicious Content
|
||||
|
||||
```typescript
|
||||
export function sanitizeAndLog(
|
||||
markdown: string,
|
||||
userId: string
|
||||
): string {
|
||||
const original = markdown
|
||||
const sanitized = DOMPurify.sanitize(markdown)
|
||||
|
||||
// If content was modified, it contained potentially malicious code
|
||||
if (original !== sanitized) {
|
||||
console.warn('Suspicious content detected:', {
|
||||
userId,
|
||||
timestamp: new Date(),
|
||||
removed: original.length - sanitized.length,
|
||||
})
|
||||
|
||||
// Optionally store for security review
|
||||
logSecurityEvent({
|
||||
type: 'SUSPICIOUS_CONTENT',
|
||||
userId,
|
||||
original: original.substring(0, 1000), // First 1KB
|
||||
sanitized: sanitized.substring(0, 1000),
|
||||
})
|
||||
}
|
||||
|
||||
return sanitized
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Sanitization
|
||||
|
||||
### Unit Tests
|
||||
|
||||
```typescript
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { sanitizeMarkdown } from './sanitize-markdown'
|
||||
|
||||
describe('sanitizeMarkdown', () => {
|
||||
it('removes script tags', () => {
|
||||
const input = '<script>alert("XSS")</script>Hello'
|
||||
const output = sanitizeMarkdown(input)
|
||||
expect(output).not.toContain('<script>')
|
||||
expect(output).toContain('Hello')
|
||||
})
|
||||
|
||||
it('removes event handlers', () => {
|
||||
const input = '<img src="x" onerror="alert(1)">'
|
||||
const output = sanitizeMarkdown(input)
|
||||
expect(output).not.toContain('onerror')
|
||||
})
|
||||
|
||||
it('allows safe HTML', () => {
|
||||
const input = '<p><strong>Bold</strong> and <em>italic</em></p>'
|
||||
const output = sanitizeMarkdown(input)
|
||||
expect(output).toContain('<strong>')
|
||||
expect(output).toContain('<em>')
|
||||
})
|
||||
|
||||
it('sanitizes links', () => {
|
||||
const input = '<a href="javascript:alert(1)">Click</a>'
|
||||
const output = sanitizeMarkdown(input)
|
||||
expect(output).not.toContain('javascript:')
|
||||
})
|
||||
|
||||
it('handles nested tags', () => {
|
||||
const input = '<div><script>alert(1)</script><p>Safe</p></div>'
|
||||
const output = sanitizeMarkdown(input)
|
||||
expect(output).not.toContain('<script>')
|
||||
expect(output).toContain('Safe')
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
### Integration Tests
|
||||
|
||||
```typescript
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.describe('Markdown Editor Security', () => {
|
||||
test('prevents XSS through markdown', async ({ page }) => {
|
||||
await page.goto('/editor')
|
||||
|
||||
const xssPayload = '<script>window.xssExecuted = true</script>Hello'
|
||||
|
||||
await page.fill('[role="textbox"]', xssPayload)
|
||||
await page.click('button:has-text("Save")')
|
||||
|
||||
await page.waitForTimeout(1000)
|
||||
|
||||
// Check that script did not execute
|
||||
const xssExecuted = await page.evaluate(() => {
|
||||
return (window as any).xssExecuted
|
||||
})
|
||||
expect(xssExecuted).toBeUndefined()
|
||||
|
||||
// Check that content was sanitized
|
||||
const preview = await page.locator('.preview').textContent()
|
||||
expect(preview).toContain('Hello')
|
||||
expect(preview).not.toContain('<script>')
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
## Common Attack Vectors
|
||||
|
||||
### Script Injection
|
||||
```html
|
||||
<!-- Attack -->
|
||||
<script>alert('XSS')</script>
|
||||
<img src=x onerror="alert('XSS')">
|
||||
<svg onload="alert('XSS')">
|
||||
|
||||
<!-- Sanitized -->
|
||||
<!-- Scripts and event handlers removed -->
|
||||
```
|
||||
|
||||
### Link-based Attacks
|
||||
```html
|
||||
<!-- Attack -->
|
||||
<a href="javascript:alert('XSS')">Click</a>
|
||||
<a href="data:text/html,<script>alert('XSS')</script>">Click</a>
|
||||
|
||||
<!-- Sanitized -->
|
||||
<a>Click</a> <!-- href removed -->
|
||||
```
|
||||
|
||||
### Iframe Injection
|
||||
```html
|
||||
<!-- Attack -->
|
||||
<iframe src="https://malicious.com"></iframe>
|
||||
|
||||
<!-- Sanitized -->
|
||||
<!-- iframe tag removed completely -->
|
||||
```
|
||||
|
||||
## Sanitization Checklist
|
||||
|
||||
- [ ] Client-side sanitization with rehype-sanitize configured
|
||||
- [ ] Server-side sanitization with DOMPurify
|
||||
- [ ] Whitelist approach (allowed tags/attributes)
|
||||
- [ ] Protocol restrictions (https only)
|
||||
- [ ] Input length validation
|
||||
- [ ] Rate limiting on save endpoints
|
||||
- [ ] Logging of sanitized content
|
||||
- [ ] Both raw and sanitized versions stored
|
||||
- [ ] External links open in new tab with rel="noopener noreferrer"
|
||||
- [ ] Image sources validated against trusted domains
|
||||
- [ ] Unit tests for XSS prevention
|
||||
- [ ] Integration tests with malicious payloads
|
||||
- [ ] Security headers configured (CSP)
|
||||
- [ ] Regular dependency updates for security patches
|
||||
@@ -0,0 +1,432 @@
|
||||
# Markdown Editor Theme Integration Guide
|
||||
|
||||
## Overview
|
||||
|
||||
Integrate @uiw/react-md-editor with shadcn/ui theming system for consistent light/dark mode support.
|
||||
|
||||
## Theme Switching
|
||||
|
||||
### Using next-themes
|
||||
|
||||
```tsx
|
||||
'use client'
|
||||
|
||||
import { useTheme } from 'next-themes'
|
||||
import MDEditor from '@uiw/react-md-editor'
|
||||
|
||||
export function ThemedMarkdownEditor({ value, onChange }) {
|
||||
const { theme } = useTheme()
|
||||
|
||||
return (
|
||||
<div data-color-mode={theme === 'dark' ? 'dark' : 'light'}>
|
||||
<MDEditor
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
height={400}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### data-color-mode Attribute
|
||||
|
||||
The `data-color-mode` attribute controls the editor's theme:
|
||||
- `light` - Light mode
|
||||
- `dark` - Dark mode
|
||||
- `auto` - System preference (default)
|
||||
|
||||
## CSS Variable Mapping
|
||||
|
||||
Map shadcn/ui CSS variables to editor styles:
|
||||
|
||||
```css
|
||||
/* globals.css */
|
||||
|
||||
/* Editor Container */
|
||||
.w-md-editor {
|
||||
--md-editor-bg: hsl(var(--background));
|
||||
--md-editor-color: hsl(var(--foreground));
|
||||
--md-editor-border: hsl(var(--border));
|
||||
}
|
||||
|
||||
/* Toolbar */
|
||||
.w-md-editor-toolbar {
|
||||
background-color: hsl(var(--muted) / 0.3);
|
||||
border-bottom-color: hsl(var(--border));
|
||||
}
|
||||
|
||||
.w-md-editor-toolbar button {
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.w-md-editor-toolbar button:hover {
|
||||
background-color: hsl(var(--accent));
|
||||
color: hsl(var(--accent-foreground));
|
||||
}
|
||||
|
||||
.w-md-editor-toolbar li.active button {
|
||||
background-color: hsl(var(--accent));
|
||||
color: hsl(var(--accent-foreground));
|
||||
}
|
||||
|
||||
/* Editor Content */
|
||||
.w-md-editor-content {
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.w-md-editor-text-pre,
|
||||
.w-md-editor-text-input {
|
||||
color: hsl(var(--foreground));
|
||||
caret-color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
/* Preview */
|
||||
.w-md-editor-preview {
|
||||
background-color: hsl(var(--background));
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.wmde-markdown {
|
||||
background-color: transparent;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
/* Code Blocks */
|
||||
.wmde-markdown pre {
|
||||
background-color: hsl(var(--muted));
|
||||
}
|
||||
|
||||
.wmde-markdown code {
|
||||
background-color: hsl(var(--muted));
|
||||
color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
/* Inline Code */
|
||||
.wmde-markdown :not(pre) > code {
|
||||
background-color: hsl(var(--muted));
|
||||
color: hsl(var(--primary));
|
||||
padding: 0.2em 0.4em;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.875em;
|
||||
}
|
||||
|
||||
/* Blockquotes */
|
||||
.wmde-markdown blockquote {
|
||||
border-left-color: hsl(var(--primary) / 0.5);
|
||||
background-color: hsl(var(--muted) / 0.5);
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
/* Links */
|
||||
.wmde-markdown a {
|
||||
color: hsl(var(--primary));
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.wmde-markdown a:hover {
|
||||
color: hsl(var(--primary) / 0.8);
|
||||
}
|
||||
|
||||
/* Tables */
|
||||
.wmde-markdown table {
|
||||
border-color: hsl(var(--border));
|
||||
}
|
||||
|
||||
.wmde-markdown th,
|
||||
.wmde-markdown td {
|
||||
border-color: hsl(var(--border));
|
||||
}
|
||||
|
||||
.wmde-markdown th {
|
||||
background-color: hsl(var(--muted));
|
||||
}
|
||||
|
||||
.wmde-markdown tr:nth-child(even) {
|
||||
background-color: hsl(var(--muted) / 0.3);
|
||||
}
|
||||
|
||||
/* Horizontal Rule */
|
||||
.wmde-markdown hr {
|
||||
border-color: hsl(var(--border));
|
||||
}
|
||||
|
||||
/* Headings */
|
||||
.wmde-markdown h1,
|
||||
.wmde-markdown h2,
|
||||
.wmde-markdown h3,
|
||||
.wmde-markdown h4,
|
||||
.wmde-markdown h5,
|
||||
.wmde-markdown h6 {
|
||||
color: hsl(var(--foreground));
|
||||
border-bottom: none;
|
||||
}
|
||||
```
|
||||
|
||||
## Tailwind Class Approach
|
||||
|
||||
Use Tailwind utility classes for theming:
|
||||
|
||||
```tsx
|
||||
'use client'
|
||||
|
||||
import { useTheme } from 'next-themes'
|
||||
import MDEditor from '@uiw/react-md-editor'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function MarkdownEditor({ value, onChange, className }) {
|
||||
const { theme } = useTheme()
|
||||
|
||||
return (
|
||||
<div
|
||||
data-color-mode={theme === 'dark' ? 'dark' : 'light'}
|
||||
className={cn(
|
||||
'rounded-md border border-input overflow-hidden',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<MDEditor
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
height={400}
|
||||
className="shadow-none"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Typography Integration
|
||||
|
||||
Integrate with Tailwind Typography (prose):
|
||||
|
||||
```css
|
||||
/* Preview with prose styling */
|
||||
.w-md-editor-preview {
|
||||
@apply prose prose-sm dark:prose-invert max-w-none p-4;
|
||||
}
|
||||
|
||||
/* Override prose defaults with theme colors */
|
||||
.w-md-editor-preview.prose {
|
||||
--tw-prose-body: hsl(var(--foreground));
|
||||
--tw-prose-headings: hsl(var(--foreground));
|
||||
--tw-prose-links: hsl(var(--primary));
|
||||
--tw-prose-bold: hsl(var(--foreground));
|
||||
--tw-prose-code: hsl(var(--primary));
|
||||
--tw-prose-pre-bg: hsl(var(--muted));
|
||||
--tw-prose-quotes: hsl(var(--muted-foreground));
|
||||
--tw-prose-quote-borders: hsl(var(--primary) / 0.5);
|
||||
}
|
||||
|
||||
.w-md-editor-preview.prose.dark {
|
||||
--tw-prose-body: hsl(var(--foreground));
|
||||
--tw-prose-headings: hsl(var(--foreground));
|
||||
--tw-prose-links: hsl(var(--primary));
|
||||
--tw-prose-bold: hsl(var(--foreground));
|
||||
--tw-prose-code: hsl(var(--primary));
|
||||
--tw-prose-pre-bg: hsl(var(--muted));
|
||||
--tw-prose-quotes: hsl(var(--muted-foreground));
|
||||
--tw-prose-quote-borders: hsl(var(--primary) / 0.5);
|
||||
}
|
||||
```
|
||||
|
||||
## Complete Component with Theme
|
||||
|
||||
```tsx
|
||||
'use client'
|
||||
|
||||
import { useTheme } from 'next-themes'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { ComponentProps, useEffect, useState } from 'react'
|
||||
import rehypeSanitize from 'rehype-sanitize'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const MDEditor = dynamic(
|
||||
() => import('@uiw/react-md-editor'),
|
||||
{ ssr: false }
|
||||
)
|
||||
|
||||
type MDEditorProps = ComponentProps<typeof MDEditor>
|
||||
|
||||
interface MarkdownEditorProps extends Omit<MDEditorProps, 'data-color-mode'> {
|
||||
value: string
|
||||
onChange?: (value?: string) => void
|
||||
height?: number | string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function MarkdownEditor({
|
||||
value,
|
||||
onChange,
|
||||
height = 400,
|
||||
className,
|
||||
...props
|
||||
}: MarkdownEditorProps) {
|
||||
const { theme, resolvedTheme } = useTheme()
|
||||
const [mounted, setMounted] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
if (!mounted) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-md border border-input bg-background',
|
||||
className
|
||||
)}
|
||||
style={{ height }}
|
||||
>
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||
Loading editor...
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const colorMode = (resolvedTheme || theme) === 'dark' ? 'dark' : 'light'
|
||||
|
||||
return (
|
||||
<div
|
||||
data-color-mode={colorMode}
|
||||
className={cn('markdown-editor-wrapper', className)}
|
||||
>
|
||||
<MDEditor
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
height={height}
|
||||
previewOptions={{
|
||||
rehypePlugins: [[rehypeSanitize]],
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Dark Mode Specifics
|
||||
|
||||
### Handle System Preference
|
||||
|
||||
```tsx
|
||||
import { useTheme } from 'next-themes'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
export function useEditorTheme() {
|
||||
const { theme, systemTheme } = useTheme()
|
||||
const [colorMode, setColorMode] = useState<'light' | 'dark'>('light')
|
||||
|
||||
useEffect(() => {
|
||||
const effectiveTheme = theme === 'system' ? systemTheme : theme
|
||||
setColorMode(effectiveTheme === 'dark' ? 'dark' : 'light')
|
||||
}, [theme, systemTheme])
|
||||
|
||||
return colorMode
|
||||
}
|
||||
|
||||
// Usage
|
||||
const colorMode = useEditorTheme()
|
||||
|
||||
<div data-color-mode={colorMode}>
|
||||
<MDEditor {...props} />
|
||||
</div>
|
||||
```
|
||||
|
||||
### Handle Theme Transitions
|
||||
|
||||
```css
|
||||
/* Smooth theme transitions */
|
||||
.w-md-editor,
|
||||
.w-md-editor-toolbar,
|
||||
.w-md-editor-content,
|
||||
.w-md-editor-preview,
|
||||
.wmde-markdown,
|
||||
.wmde-markdown * {
|
||||
transition: background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
```
|
||||
|
||||
## Custom Syntax Highlighting
|
||||
|
||||
Integrate with shadcn/ui color scheme:
|
||||
|
||||
```tsx
|
||||
import { useTheme } from 'next-themes'
|
||||
import MDEditor from '@uiw/react-md-editor'
|
||||
|
||||
export function MarkdownEditor({ value, onChange }) {
|
||||
const { theme } = useTheme()
|
||||
|
||||
return (
|
||||
<div data-color-mode={theme === 'dark' ? 'dark' : 'light'}>
|
||||
<MDEditor
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
previewOptions={{
|
||||
components: {
|
||||
code: ({ node, inline, className, children, ...props }) => {
|
||||
return inline ? (
|
||||
<code className="bg-muted text-primary px-1.5 py-0.5 rounded text-sm" {...props}>
|
||||
{children}
|
||||
</code>
|
||||
) : (
|
||||
<code className={className} {...props}>
|
||||
{children}
|
||||
</code>
|
||||
)
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Theme Integration
|
||||
|
||||
```tsx
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { ThemeProvider } from 'next-themes'
|
||||
import { MarkdownEditor } from './MarkdownEditor'
|
||||
|
||||
describe('MarkdownEditor Theme', () => {
|
||||
it('applies light theme', () => {
|
||||
render(
|
||||
<ThemeProvider defaultTheme="light">
|
||||
<MarkdownEditor value="Test" onChange={() => {}} />
|
||||
</ThemeProvider>
|
||||
)
|
||||
|
||||
const wrapper = screen.getByRole('textbox').closest('[data-color-mode]')
|
||||
expect(wrapper).toHaveAttribute('data-color-mode', 'light')
|
||||
})
|
||||
|
||||
it('applies dark theme', () => {
|
||||
render(
|
||||
<ThemeProvider defaultTheme="dark">
|
||||
<MarkdownEditor value="Test" onChange={() => {}} />
|
||||
</ThemeProvider>
|
||||
)
|
||||
|
||||
const wrapper = screen.getByRole('textbox').closest('[data-color-mode]')
|
||||
expect(wrapper).toHaveAttribute('data-color-mode', 'dark')
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Issue:** Theme not applying immediately
|
||||
**Solution:** Ensure editor is mounted after theme is resolved
|
||||
|
||||
**Issue:** Flash of wrong theme
|
||||
**Solution:** Add loading state while theme resolves
|
||||
|
||||
**Issue:** CSS variables not working
|
||||
**Solution:** Verify CSS is imported in correct order (globals.css before editor CSS)
|
||||
|
||||
**Issue:** Dark mode colors not matching shadcn/ui
|
||||
**Solution:** Use HSL values from CSS variables, not hardcoded colors
|
||||
Reference in New Issue
Block a user