mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-10-12 02:57:12 +00:00
markdown pipe
This commit is contained in:
2 files changed
+127
No files matched your search
@@ -0,0 +1,70 @@
|
||||
import { MarkdownPipe } from './markdown.pipe'
|
||||
|
||||
describe('MarkdownPipe', () => {
|
||||
const pipe = new MarkdownPipe()
|
||||
|
||||
it('should return empty string for empty input', () => {
|
||||
expect(pipe.transform(null)).toEqual('')
|
||||
expect(pipe.transform(undefined)).toEqual('')
|
||||
expect(pipe.transform('')).toEqual('')
|
||||
})
|
||||
|
||||
it('should render basic markdown', () => {
|
||||
const html = pipe.transform(
|
||||
'**bold** _em_ `code`\n\n- one\n- two\n\n| a | b |\n|---|---|\n| 1 | 2 |'
|
||||
)
|
||||
expect(html).toContain('<strong>bold</strong>')
|
||||
expect(html).toContain('<em>em</em>')
|
||||
expect(html).toContain('<code>code</code>')
|
||||
expect(html).toContain('<li>one</li>')
|
||||
expect(html).toContain('<table>')
|
||||
})
|
||||
|
||||
it('should escape raw html', () => {
|
||||
const html = pipe.transform(
|
||||
'hi <img src=x onerror="alert(1)"> <b>x</b>\n\n<script>alert(1)</script>'
|
||||
)
|
||||
expect(html).not.toContain('<img')
|
||||
expect(html).not.toContain('<script')
|
||||
expect(html).not.toContain('<b>')
|
||||
expect(html).toContain('<script>')
|
||||
})
|
||||
|
||||
it('should not render images', () => {
|
||||
const html = pipe.transform('')
|
||||
expect(html).not.toContain('<img')
|
||||
expect(html).not.toContain('evil.example')
|
||||
expect(html).toContain('secret')
|
||||
})
|
||||
|
||||
it('should render safe links with target and rel', () => {
|
||||
const html = pipe.transform('[docs](https://docs.paperless-ngx.com "Docs")')
|
||||
expect(html).toContain(
|
||||
'<a href="https://docs.paperless-ngx.com/" title="Docs" target="_blank" rel="noopener noreferrer nofollow">docs</a>'
|
||||
)
|
||||
expect(pipe.transform('[mail](mailto:a@b.c)')).toContain(
|
||||
'href="mailto:a@b.c"'
|
||||
)
|
||||
})
|
||||
|
||||
it('should drop unsafe or relative links but keep their text', () => {
|
||||
for (const href of [
|
||||
'javascript:alert(1)',
|
||||
'JaVaScRiPt:alert(1)',
|
||||
'data:text/html,<script>alert(1)</script>',
|
||||
'vbscript:msgbox',
|
||||
'/api/documents/',
|
||||
]) {
|
||||
const html = pipe.transform(`[click](${href})`)
|
||||
expect(html).not.toContain('<a')
|
||||
expect(html).toContain('click')
|
||||
}
|
||||
})
|
||||
|
||||
it('should escape link titles', () => {
|
||||
const html = pipe.transform(
|
||||
'[x](https://a.example "a\\" onmouseover=\\"1")'
|
||||
)
|
||||
expect(html).not.toMatch(/"\s*onmouseover=/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Pipe, PipeTransform } from '@angular/core'
|
||||
import { Marked, Renderer, Tokens } from 'marked'
|
||||
|
||||
const ALLOWED_LINK_PROTOCOLS = ['http:', 'https:', 'mailto:']
|
||||
|
||||
function escapeHtml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
function safeHref(href: string): string | null {
|
||||
try {
|
||||
const url = new URL(href)
|
||||
return ALLOWED_LINK_PROTOCOLS.includes(url.protocol) ? url.href : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Treat chat content as untrusted: no raw HTML, no images, and only
|
||||
// absolute links. Angular sanitizer runs on top of this as well.
|
||||
const renderer: Partial<Renderer> = {
|
||||
html({ text }: Tokens.HTML | Tokens.Tag): string {
|
||||
return escapeHtml(text)
|
||||
},
|
||||
image({ text }: Tokens.Image): string {
|
||||
return escapeHtml(text)
|
||||
},
|
||||
link(this: Renderer, { href, title, tokens }: Tokens.Link): string {
|
||||
const text = this.parser.parseInline(tokens)
|
||||
const url = safeHref(href)
|
||||
if (!url) return text
|
||||
const titleAttr = title ? ` title="${escapeHtml(title)}"` : ''
|
||||
return `<a href="${escapeHtml(url)}"${titleAttr} target="_blank" rel="noopener noreferrer nofollow">${text}</a>`
|
||||
},
|
||||
}
|
||||
|
||||
const markdown = new Marked({
|
||||
async: false,
|
||||
gfm: true,
|
||||
breaks: true,
|
||||
renderer,
|
||||
})
|
||||
|
||||
@Pipe({
|
||||
name: 'markdown',
|
||||
})
|
||||
export class MarkdownPipe implements PipeTransform {
|
||||
transform(value: string | null | undefined): string {
|
||||
if (!value) return ''
|
||||
return markdown.parse(value) as string
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user