mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-10-12 02:57:12 +00:00
Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
409fc9878f | ||
|
|
1c8aea439e | ||
|
|
269acf925d | ||
|
|
dcf3b533e4 | ||
|
|
4968f3195e | ||
|
|
9ff3960425 | ||
|
|
77b6027aa1 | ||
|
|
5edeb3becd | ||
|
|
5a0270230d | ||
|
|
39a2cd5d20 | ||
|
|
c60e5a6ce7 | ||
|
|
5a3fd7ef96 | ||
|
|
50730ddc6c | ||
|
|
27ba1b3110 |
No files matched your search
@@ -1,5 +1,5 @@
|
|||||||
|
|
||||||
<li ngbDropdown class="nav-item" (openChange)="onOpenChange($event)">
|
<li ngbDropdown class="nav-item" display="dynamic" placement="bottom-end" [popperOptions]="popperOptions" (openChange)="onOpenChange($event)">
|
||||||
<button class="btn navbar-action border-0" id="chatDropdown" ngbDropdownToggle aria-label="Chat" i18n-aria-label>
|
<button class="btn navbar-action border-0" id="chatDropdown" ngbDropdownToggle aria-label="Chat" i18n-aria-label>
|
||||||
<i-bs width="1.3em" height="1.3em" name="chatSquareDots"></i-bs>
|
<i-bs width="1.3em" height="1.3em" name="chatSquareDots"></i-bs>
|
||||||
</button>
|
</button>
|
||||||
@@ -8,8 +8,17 @@
|
|||||||
<div class="chat-messages small">
|
<div class="chat-messages small">
|
||||||
@for (message of messages(); track message) {
|
@for (message of messages(); track message) {
|
||||||
<div class="message d-flex flex-row small" [class.justify-content-end]="message.role === 'user'">
|
<div class="message d-flex flex-row small" [class.justify-content-end]="message.role === 'user'">
|
||||||
<div class="p-2 m-2" [class.bg-body]="message.role === 'user'">
|
<div class="p-2 m-2" [class.bg-body]="message.role === 'user'" [class.flex-grow-1]="message.role === 'assistant'">
|
||||||
@if (message.role === 'assistant') {
|
@if (message.isStreaming && !message.content) {
|
||||||
|
<div class="d-flex align-items-center text-muted">
|
||||||
|
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
|
||||||
|
@if (slowResponse()) {
|
||||||
|
<ng-container i18n>Still thinking...</ng-container>
|
||||||
|
} @else {
|
||||||
|
<ng-container i18n>Thinking...</ng-container>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
} @else if (message.role === 'assistant') {
|
||||||
<div class="chat-markdown text-break text-wrap" [innerHTML]="message.content | markdown"></div>
|
<div class="chat-markdown text-break text-wrap" [innerHTML]="message.content | markdown"></div>
|
||||||
@if (message.isStreaming) { <span class="blinking-cursor">|</span> }
|
@if (message.isStreaming) { <span class="blinking-cursor">|</span> }
|
||||||
} @else {
|
} @else {
|
||||||
@@ -17,9 +26,10 @@
|
|||||||
}
|
}
|
||||||
@if (message.role === 'assistant' && message.references?.length) {
|
@if (message.role === 'assistant' && message.references?.length) {
|
||||||
<div class="chat-references list-group mt-3">
|
<div class="chat-references list-group mt-3">
|
||||||
|
<div class="list-group-item text-muted small py-1" i18n>Sources</div>
|
||||||
@for (reference of message.references; track reference.id) {
|
@for (reference of message.references; track reference.id) {
|
||||||
<a class="list-group-item list-group-item-action d-flex text-primary text-break" [routerLink]="['/documents', reference.id]">
|
<a class="list-group-item list-group-item-action d-flex align-items-center text-primary" [routerLink]="['/documents', reference.id]" [title]="reference.title">
|
||||||
<i-bs width="0.9em" height="0.9em" name="file-text" class="me-1"></i-bs><span>{{ reference.title }}</span>
|
<i-bs width="0.9em" height="0.9em" name="file-text" class="me-2 flex-shrink-0"></i-bs><span class="text-truncate">{{ reference.title }}</span>
|
||||||
</a>
|
</a>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -43,7 +53,11 @@
|
|||||||
(ngModelChange)="input.set($event)"
|
(ngModelChange)="input.set($event)"
|
||||||
(keydown)="searchInputKeyDown($event)"
|
(keydown)="searchInputKeyDown($event)"
|
||||||
/>
|
/>
|
||||||
<button class="btn btn-sm btn-secondary" type="button" (click)="sendMessage()" [disabled]="loading()">Send</button>
|
@if (loading()) {
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" type="button" (click)="stopStreaming()" i18n>Stop</button>
|
||||||
|
} @else {
|
||||||
|
<button class="btn btn-sm btn-secondary" type="button" (click)="sendMessage()">Send</button>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
.dropdown-menu {
|
.dropdown-menu {
|
||||||
width: var(--pngx-toast-max-width);
|
width: min(36rem, calc(100vw - 20px));
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-messages {
|
.chat-messages {
|
||||||
max-height: 350px;
|
max-height: min(60dvh, 36rem);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
@@ -16,9 +16,9 @@
|
|||||||
white-space: initial;
|
white-space: initial;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (max-width: 400px) {
|
@media screen and (max-width: 575.98px) {
|
||||||
:host ::ng-deep .dropdown-menu-end {
|
.chat-messages {
|
||||||
right: -3rem;
|
max-height: calc(100dvh - 12rem);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -80,8 +80,15 @@ describe('ChatComponent', () => {
|
|||||||
expect(component.messages()[0].content).toBe('Hello')
|
expect(component.messages()[0].content).toBe('Hello')
|
||||||
expect(component.loading()).toBe(true)
|
expect(component.loading()).toBe(true)
|
||||||
|
|
||||||
|
fixture.detectChanges()
|
||||||
|
const messagesEl = fixture.nativeElement.querySelector('.chat-messages')
|
||||||
|
expect(messagesEl.querySelector('.spinner-border')).not.toBeNull()
|
||||||
|
expect(messagesEl.textContent).toContain('Thinking...')
|
||||||
|
|
||||||
mockStream$.next('Hi')
|
mockStream$.next('Hi')
|
||||||
expect(component.messages()[1].content).toBe('H')
|
expect(component.messages()[1].content).toBe('H')
|
||||||
|
fixture.detectChanges()
|
||||||
|
expect(messagesEl.querySelector('.spinner-border')).toBeNull()
|
||||||
mockStream$.next('Hi there')
|
mockStream$.next('Hi there')
|
||||||
// advance time to process the typewriter effect
|
// advance time to process the typewriter effect
|
||||||
await jest.runAllTimersAsync()
|
await jest.runAllTimersAsync()
|
||||||
@@ -156,13 +163,75 @@ describe('ChatComponent', () => {
|
|||||||
component.input.set('Hello')
|
component.input.set('Hello')
|
||||||
component.sendMessage()
|
component.sendMessage()
|
||||||
|
|
||||||
|
mockStream$.next('Partial')
|
||||||
mockStream$.error('Error')
|
mockStream$.error('Error')
|
||||||
expect(component.messages()[1].content).toContain(
|
expect(component.messages()[1].content).toBe(
|
||||||
'⚠️ Error receiving response.'
|
'Partial\n\n⚠️ Error receiving response.'
|
||||||
)
|
)
|
||||||
expect(component.loading()).toBe(false)
|
expect(component.loading()).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should keep streaming until the typewriter catches up', () => {
|
||||||
|
component.input.set('Hello')
|
||||||
|
component.sendMessage()
|
||||||
|
|
||||||
|
mockStream$.next('Hi there')
|
||||||
|
mockStream$.complete()
|
||||||
|
expect(component.messages()[1].isStreaming).toBe(true)
|
||||||
|
expect(component.loading()).toBe(true)
|
||||||
|
|
||||||
|
jest.advanceTimersByTime(1000)
|
||||||
|
expect(component.messages()[1].content).toBe('Hi there')
|
||||||
|
expect(component.messages()[1].isStreaming).toBe(false)
|
||||||
|
expect(component.loading()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show a slow response message while waiting', () => {
|
||||||
|
component.input.set('Hello')
|
||||||
|
component.sendMessage()
|
||||||
|
fixture.detectChanges()
|
||||||
|
const messagesEl = fixture.nativeElement.querySelector('.chat-messages')
|
||||||
|
expect(messagesEl.textContent).not.toContain('Still thinking...')
|
||||||
|
|
||||||
|
jest.advanceTimersByTime(8000)
|
||||||
|
fixture.detectChanges()
|
||||||
|
expect(messagesEl.textContent).toContain('Still thinking...')
|
||||||
|
|
||||||
|
mockStream$.next('Hi')
|
||||||
|
expect(component.slowResponse()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should stop streaming and keep the partial response', () => {
|
||||||
|
component.input.set('Hello')
|
||||||
|
component.sendMessage()
|
||||||
|
fixture.detectChanges()
|
||||||
|
const stopButton = fixture.nativeElement.querySelector('.chat-input button')
|
||||||
|
expect(stopButton.textContent).toContain('Stop')
|
||||||
|
|
||||||
|
mockStream$.next('Hi there')
|
||||||
|
jest.advanceTimersByTime(20)
|
||||||
|
stopButton.click()
|
||||||
|
|
||||||
|
expect(mockStream$.observed).toBe(false)
|
||||||
|
expect(component.messages()[1].content).toBe('Hi there')
|
||||||
|
expect(component.messages()[1].isStreaming).toBe(false)
|
||||||
|
expect(component.loading()).toBe(false)
|
||||||
|
|
||||||
|
jest.advanceTimersByTime(1000)
|
||||||
|
expect(component.messages()[1].content).toBe('Hi there')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should drop the empty response when stopped before any content', () => {
|
||||||
|
component.input.set('Hello')
|
||||||
|
component.sendMessage()
|
||||||
|
component.stopStreaming()
|
||||||
|
|
||||||
|
expect(component.messages()).toHaveLength(1)
|
||||||
|
expect(component.loading()).toBe(false)
|
||||||
|
component.stopStreaming() // no-op once stopped
|
||||||
|
expect(component.messages()).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
it('should enqueue typewriter chunks correctly', () => {
|
it('should enqueue typewriter chunks correctly', () => {
|
||||||
const message = { content: '', role: 'assistant', isStreaming: true }
|
const message = { content: '', role: 'assistant', isStreaming: true }
|
||||||
component.enqueueTypewriter(null, message as any) // coverage for null
|
component.enqueueTypewriter(null, message as any) // coverage for null
|
||||||
@@ -221,19 +290,4 @@ describe('ChatComponent', () => {
|
|||||||
expect(el.querySelector('.chat-markdown script')).toBeNull()
|
expect(el.querySelector('.chat-markdown script')).toBeNull()
|
||||||
expect(el.textContent).toContain('**user**')
|
expect(el.textContent).toContain('**user**')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should keep the destination hostname visible after sanitizing chat links', () => {
|
|
||||||
component.messages.set([
|
|
||||||
{
|
|
||||||
role: 'assistant',
|
|
||||||
content:
|
|
||||||
'[View original document](https://otherdomain.example/?data=secret)',
|
|
||||||
},
|
|
||||||
])
|
|
||||||
fixture.detectChanges()
|
|
||||||
const el: HTMLElement = fixture.nativeElement
|
|
||||||
const link = el.querySelector('.chat-markdown a')
|
|
||||||
expect(link?.textContent).toBe('View original document')
|
|
||||||
expect(link?.nextElementSibling?.textContent).toBe('(otherdomain.example)')
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
@@ -10,13 +10,16 @@ import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
|||||||
import { NavigationEnd, Router, RouterModule } from '@angular/router'
|
import { NavigationEnd, Router, RouterModule } from '@angular/router'
|
||||||
import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
|
import { filter, map, Subscription } from 'rxjs'
|
||||||
import { MarkdownPipe } from 'src/app/pipes/markdown.pipe'
|
import { MarkdownPipe } from 'src/app/pipes/markdown.pipe'
|
||||||
import { filter, map } from 'rxjs'
|
|
||||||
import {
|
import {
|
||||||
ChatMessage,
|
ChatMessage,
|
||||||
ChatService,
|
ChatService,
|
||||||
parseChatResponse,
|
parseChatResponse,
|
||||||
} from 'src/app/services/chat.service'
|
} from 'src/app/services/chat.service'
|
||||||
|
import { pngxPopperOptions } from 'src/app/utils/popper-options'
|
||||||
|
|
||||||
|
const SLOW_RESPONSE_DELAY_MS = 8000
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'pngx-chat',
|
selector: 'pngx-chat',
|
||||||
@@ -36,6 +39,8 @@ export class ChatComponent implements OnInit {
|
|||||||
readonly loading = signal(false)
|
readonly loading = signal(false)
|
||||||
readonly input = signal('')
|
readonly input = signal('')
|
||||||
readonly documentId = signal<number>(undefined)
|
readonly documentId = signal<number>(undefined)
|
||||||
|
readonly slowResponse = signal(false)
|
||||||
|
public popperOptions = pngxPopperOptions
|
||||||
|
|
||||||
private chatService: ChatService = inject(ChatService)
|
private chatService: ChatService = inject(ChatService)
|
||||||
private router: Router = inject(Router)
|
private router: Router = inject(Router)
|
||||||
@@ -45,6 +50,11 @@ export class ChatComponent implements OnInit {
|
|||||||
|
|
||||||
private typewriterBuffer: string[] = []
|
private typewriterBuffer: string[] = []
|
||||||
private typewriterActive = false
|
private typewriterActive = false
|
||||||
|
private typewriterTimeout: ReturnType<typeof setTimeout>
|
||||||
|
private streamSubscription: Subscription
|
||||||
|
private streamingMessage: ChatMessage
|
||||||
|
private streamFinished = false
|
||||||
|
private slowResponseTimeout: ReturnType<typeof setTimeout>
|
||||||
|
|
||||||
public get placeholder(): string {
|
public get placeholder(): string {
|
||||||
return this.documentId()
|
return this.documentId()
|
||||||
@@ -83,47 +93,90 @@ export class ChatComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
this.messages.update((messages) => [...messages, assistantMessage])
|
this.messages.update((messages) => [...messages, assistantMessage])
|
||||||
this.loading.set(true)
|
this.loading.set(true)
|
||||||
|
this.streamingMessage = assistantMessage
|
||||||
|
this.streamFinished = false
|
||||||
|
this.slowResponseTimeout = setTimeout(
|
||||||
|
() => this.slowResponse.set(true),
|
||||||
|
SLOW_RESPONSE_DELAY_MS
|
||||||
|
)
|
||||||
|
|
||||||
let lastVisibleContent = ''
|
let lastVisibleContent = ''
|
||||||
|
|
||||||
this.chatService.streamChat(this.documentId(), this.input()).subscribe({
|
this.streamSubscription = this.chatService
|
||||||
next: (chunk) => {
|
.streamChat(this.documentId(), this.input())
|
||||||
const nextResponse = parseChatResponse(chunk)
|
.subscribe({
|
||||||
|
next: (chunk) => {
|
||||||
|
this.clearSlowResponse()
|
||||||
|
const nextResponse = parseChatResponse(chunk)
|
||||||
|
|
||||||
if (nextResponse.content.length < lastVisibleContent.length) {
|
if (nextResponse.content.length < lastVisibleContent.length) {
|
||||||
this.resetTypewriter(assistantMessage, nextResponse.content)
|
this.resetTypewriter(assistantMessage, nextResponse.content)
|
||||||
lastVisibleContent = nextResponse.content
|
lastVisibleContent = nextResponse.content
|
||||||
} else {
|
} else {
|
||||||
const visibleDelta = nextResponse.content.substring(
|
const visibleDelta = nextResponse.content.substring(
|
||||||
lastVisibleContent.length
|
lastVisibleContent.length
|
||||||
)
|
)
|
||||||
lastVisibleContent = nextResponse.content
|
lastVisibleContent = nextResponse.content
|
||||||
this.enqueueTypewriter(visibleDelta, assistantMessage)
|
this.enqueueTypewriter(visibleDelta, assistantMessage)
|
||||||
}
|
}
|
||||||
|
|
||||||
assistantMessage.references = nextResponse.references
|
assistantMessage.references = nextResponse.references
|
||||||
this.notifyMessagesChanged()
|
this.notifyMessagesChanged()
|
||||||
},
|
},
|
||||||
error: () => {
|
error: () => {
|
||||||
assistantMessage.content += '\n\n⚠️ Error receiving response.'
|
this.flushTypewriter(assistantMessage)
|
||||||
assistantMessage.isStreaming = false
|
assistantMessage.content += '\n\n⚠️ Error receiving response.'
|
||||||
this.notifyMessagesChanged()
|
this.finishStreaming(assistantMessage)
|
||||||
this.loading.set(false)
|
},
|
||||||
},
|
complete: () => {
|
||||||
complete: () => {
|
// let the typewriter catch up before hiding the cursor
|
||||||
assistantMessage.isStreaming = false
|
this.streamFinished = true
|
||||||
this.notifyMessagesChanged()
|
if (!this.typewriterActive) this.finishStreaming(assistantMessage)
|
||||||
this.loading.set(false)
|
},
|
||||||
this.scrollToBottom()
|
})
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
this.input.set('')
|
this.input.set('')
|
||||||
}
|
}
|
||||||
|
|
||||||
private resetTypewriter(message: ChatMessage, content: string): void {
|
stopStreaming(): void {
|
||||||
|
const message = this.streamingMessage
|
||||||
|
if (!message?.isStreaming) return
|
||||||
|
|
||||||
|
this.streamSubscription?.unsubscribe()
|
||||||
|
this.flushTypewriter(message)
|
||||||
|
if (!message.content) {
|
||||||
|
this.messages.update((messages) => messages.filter((m) => m !== message))
|
||||||
|
}
|
||||||
|
this.finishStreaming(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
private finishStreaming(message: ChatMessage): void {
|
||||||
|
this.clearSlowResponse()
|
||||||
|
this.streamFinished = false
|
||||||
|
message.isStreaming = false
|
||||||
|
this.notifyMessagesChanged()
|
||||||
|
this.loading.set(false)
|
||||||
|
this.scrollToBottom()
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearSlowResponse(): void {
|
||||||
|
clearTimeout(this.slowResponseTimeout)
|
||||||
|
this.slowResponse.set(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private flushTypewriter(message: ChatMessage): void {
|
||||||
|
message.content += this.typewriterBuffer.join('')
|
||||||
|
this.cancelTypewriter()
|
||||||
|
}
|
||||||
|
|
||||||
|
private cancelTypewriter(): void {
|
||||||
|
clearTimeout(this.typewriterTimeout)
|
||||||
this.typewriterBuffer = []
|
this.typewriterBuffer = []
|
||||||
this.typewriterActive = false
|
this.typewriterActive = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private resetTypewriter(message: ChatMessage, content: string): void {
|
||||||
|
this.cancelTypewriter()
|
||||||
message.content = content
|
message.content = content
|
||||||
this.notifyMessagesChanged()
|
this.notifyMessagesChanged()
|
||||||
this.scrollToBottom()
|
this.scrollToBottom()
|
||||||
@@ -143,6 +196,7 @@ export class ChatComponent implements OnInit {
|
|||||||
playTypewriter(message: ChatMessage): void {
|
playTypewriter(message: ChatMessage): void {
|
||||||
if (this.typewriterBuffer.length === 0) {
|
if (this.typewriterBuffer.length === 0) {
|
||||||
this.typewriterActive = false
|
this.typewriterActive = false
|
||||||
|
if (this.streamFinished) this.finishStreaming(message)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,7 +205,8 @@ export class ChatComponent implements OnInit {
|
|||||||
this.notifyMessagesChanged()
|
this.notifyMessagesChanged()
|
||||||
this.scrollToBottom()
|
this.scrollToBottom()
|
||||||
|
|
||||||
setTimeout(() => this.playTypewriter(message), 10) // 10ms per character
|
// 10ms per character
|
||||||
|
this.typewriterTimeout = setTimeout(() => this.playTypewriter(message), 10)
|
||||||
}
|
}
|
||||||
|
|
||||||
private notifyMessagesChanged(): void {
|
private notifyMessagesChanged(): void {
|
||||||
|
|||||||
@@ -31,18 +31,16 @@ describe('MarkdownPipe', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should not render images', () => {
|
it('should not render images', () => {
|
||||||
const html = pipe.transform(
|
const html = pipe.transform('')
|
||||||
''
|
|
||||||
)
|
|
||||||
expect(html).not.toContain('<img')
|
expect(html).not.toContain('<img')
|
||||||
expect(html).not.toContain('otherdomain.example')
|
expect(html).not.toContain('evil.example')
|
||||||
expect(html).toContain('secret')
|
expect(html).toContain('secret')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should render safe links with target and rel', () => {
|
it('should render safe links with target and rel', () => {
|
||||||
const html = pipe.transform('[docs](https://docs.paperless-ngx.com "Docs")')
|
const html = pipe.transform('[docs](https://docs.paperless-ngx.com "Docs")')
|
||||||
expect(html).toContain(
|
expect(html).toContain(
|
||||||
'<a target="_blank" rel="noopener noreferrer nofollow" href="https://docs.paperless-ngx.com" title="Docs">docs</a>'
|
'<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(
|
expect(pipe.transform('[mail](mailto:a@b.c)')).toContain(
|
||||||
'href="mailto:a@b.c"'
|
'href="mailto:a@b.c"'
|
||||||
@@ -63,24 +61,6 @@ describe('MarkdownPipe', () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should show the actual hostname beside web links with misleading labels', () => {
|
|
||||||
for (const protocol of ['http', 'https']) {
|
|
||||||
const html = pipe.transform(
|
|
||||||
`[docs.paperless-ngx.com](${protocol}://docs.paperless-ngx.com@otherdomain.example/path?secret=1)`
|
|
||||||
)
|
|
||||||
expect(html).toContain(
|
|
||||||
'</a> <span class="text-muted small">(otherdomain.example)</span>'
|
|
||||||
)
|
|
||||||
expect(html).not.toContain('(docs.paperless-ngx.com)')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should omit the hostname label for mailto links', () => {
|
|
||||||
expect(pipe.transform('[mail](mailto:a@b.c)')).toBe(
|
|
||||||
'<p><a target="_blank" rel="noopener noreferrer nofollow" href="mailto:a@b.c">mail</a></p>\n'
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should escape link titles', () => {
|
it('should escape link titles', () => {
|
||||||
const html = pipe.transform(
|
const html = pipe.transform(
|
||||||
'[x](https://a.example "a\\" onmouseover=\\"1")'
|
'[x](https://a.example "a\\" onmouseover=\\"1")'
|
||||||
|
|||||||
@@ -1,36 +1,41 @@
|
|||||||
import { Pipe, PipeTransform } from '@angular/core'
|
import { Pipe, PipeTransform } from '@angular/core'
|
||||||
import { Marked, Renderer, Tokens } from 'marked'
|
import { Marked, Renderer, Tokens } from 'marked'
|
||||||
|
|
||||||
const ALLOWED_LINK_PROTOCOLS = new Set(['http:', 'https:', 'mailto:'])
|
const ALLOWED_LINK_PROTOCOLS = ['http:', 'https:', 'mailto:']
|
||||||
|
|
||||||
function allowedUrl(href: string): URL | null {
|
function escapeHtml(text: string): string {
|
||||||
|
return text
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''')
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeHref(href: string): string | null {
|
||||||
try {
|
try {
|
||||||
const url = new URL(href)
|
const url = new URL(href)
|
||||||
return ALLOWED_LINK_PROTOCOLS.has(url.protocol) ? url : null
|
return ALLOWED_LINK_PROTOCOLS.includes(url.protocol) ? url.href : null
|
||||||
} catch {
|
} catch {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Treat chat content as untrusted: no raw HTML, no images, and only
|
// Treat chat content as untrusted: no raw HTML, no images, and only
|
||||||
// absolute links. All escaping is done by marked's default renderer, and
|
// absolute links. Angular sanitizer runs on top of this as well.
|
||||||
// Angular's sanitizer runs on top of this as well.
|
|
||||||
const renderer: Partial<Renderer> = {
|
const renderer: Partial<Renderer> = {
|
||||||
html(this: Renderer, { raw, text }: Tokens.HTML | Tokens.Tag): string {
|
html({ text }: Tokens.HTML | Tokens.Tag): string {
|
||||||
return this.text({ type: 'text', raw, text })
|
return escapeHtml(text)
|
||||||
},
|
},
|
||||||
image(this: Renderer, { raw, text }: Tokens.Image): string {
|
image({ text }: Tokens.Image): string {
|
||||||
return this.text({ type: 'text', raw, text })
|
return escapeHtml(text)
|
||||||
},
|
},
|
||||||
link(this: Renderer, token: Tokens.Link): string {
|
link(this: Renderer, { href, title, tokens }: Tokens.Link): string {
|
||||||
const url = allowedUrl(token.href)
|
const text = this.parser.parseInline(tokens)
|
||||||
if (!url) return this.parser.parseInline(token.tokens)
|
const url = safeHref(href)
|
||||||
const anchor = Renderer.prototype.link
|
if (!url) return text
|
||||||
.call(this, token)
|
const titleAttr = title ? ` title="${escapeHtml(title)}"` : ''
|
||||||
.replace('<a ', '<a target="_blank" rel="noopener noreferrer nofollow" ')
|
return `<a href="${escapeHtml(url)}"${titleAttr} target="_blank" rel="noopener noreferrer nofollow">${text}</a>`
|
||||||
if (!url.hostname) return anchor
|
|
||||||
const hostname = this.text({ type: 'text', raw: '', text: url.hostname })
|
|
||||||
return `${anchor} <span class="text-muted small">(${hostname})</span>`
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
"target": "ES2022",
|
"target": "ES2022",
|
||||||
"module": "es2020",
|
"module": "es2020",
|
||||||
"lib": [
|
"lib": [
|
||||||
"es2022",
|
"es2020",
|
||||||
"dom"
|
"dom"
|
||||||
],
|
],
|
||||||
"useDefineForClassFields": false
|
"useDefineForClassFields": false
|
||||||
|
|||||||
Reference in new issue
Block a user