From 50730ddc6c25fb176d8e017c269fad55be156ff4 Mon Sep 17 00:00:00 2001
From: shamoon <4887959+shamoon@users.noreply.github.com>
Date: Sun, 27 Sep 2026 08:43:26 -0700
Subject: [PATCH] markdown pipe
---
src-ui/src/app/pipes/markdown.pipe.spec.ts | 70 ++++++++++++++++++++++
src-ui/src/app/pipes/markdown.pipe.ts | 57 ++++++++++++++++++
2 files changed, 127 insertions(+)
create mode 100644 src-ui/src/app/pipes/markdown.pipe.spec.ts
create mode 100644 src-ui/src/app/pipes/markdown.pipe.ts
diff --git a/src-ui/src/app/pipes/markdown.pipe.spec.ts b/src-ui/src/app/pipes/markdown.pipe.spec.ts
new file mode 100644
index 000000000..4dbfc719e
--- /dev/null
+++ b/src-ui/src/app/pipes/markdown.pipe.spec.ts
@@ -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('bold')
+ expect(html).toContain('em')
+ expect(html).toContain('code')
+ expect(html).toContain('
one')
+ expect(html).toContain('')
+ })
+
+ it('should escape raw html', () => {
+ const html = pipe.transform(
+ 'hi
x\n\n'
+ )
+ expect(html).not.toContain('
')
+ expect(html).toContain('<script>')
+ })
+
+ it('should not render images', () => {
+ const html = pipe.transform('')
+ expect(html).not.toContain('
{
+ const html = pipe.transform('[docs](https://docs.paperless-ngx.com "Docs")')
+ expect(html).toContain(
+ 'docs'
+ )
+ 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,',
+ 'vbscript:msgbox',
+ '/api/documents/',
+ ]) {
+ const html = pipe.transform(`[click](${href})`)
+ expect(html).not.toContain(' {
+ const html = pipe.transform(
+ '[x](https://a.example "a\\" onmouseover=\\"1")'
+ )
+ expect(html).not.toMatch(/"\s*onmouseover=/)
+ })
+})
diff --git a/src-ui/src/app/pipes/markdown.pipe.ts b/src-ui/src/app/pipes/markdown.pipe.ts
new file mode 100644
index 000000000..492cce77b
--- /dev/null
+++ b/src-ui/src/app/pipes/markdown.pipe.ts
@@ -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, ''')
+}
+
+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 = {
+ 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 `${text}`
+ },
+}
+
+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
+ }
+}