Compare commits

..
Author SHA1 Message Date
stumpylog 7bdc407ed9 Fix: authorize document versions by their root in the single-object permission check
has_perms_owner_aware judged a document by its own owner and grants, so each
endpoint that fetches a document itself had to remember to map a version to
its root document first, and one that forgot, like the more-like-this search
filter, authorized by a stale version owner.

The check now maps a Document to its root before looking at the owner and the
guardian grants, matching what permitted_document_ids does for id sets. The
eight call sites that mapped the document themselves pass it straight through.
The DRF object permission class needs no change because the document viewset
only ever serves root documents.
2026-10-09 14:43:34 -07:00
Trenton HandClaude Sonnet 5.5 01e1e76f91 Fix: judge versions by their root in the AI similarity permission checks
The AI classifier asked permitted_object_ids for the Document model directly,
which judges a version by its own owner and grants. A version whose owner had
drifted from its private root's could be offered as similar-document context
to a user who cannot see the root. Use permitted_document_ids in both the
vector and full-text paths, which authorizes a version by its root.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-09 13:43:36 -07:00
Trenton HandClaude Sonnet 5.5 a41e06f196 Fix: authorize document versions by their root and speed up permission id sets
permitted_document_ids judged a version by its own owner and grants, so a
version whose owner had drifted from its root's was visible to the wrong
people and hidden from the right ones. Callers patched this individually by
mapping each document to its root first. The query itself was also slow on
MariaDB: the guardian grants were a UNION cast to integers and tested with
IN inside an OR with the owner checks, which MariaDB cannot materialize, so it
re-scans the user's grants for every document. At 20k documents that took
seconds for a user with a couple of hundred grants.

permitted_object_ids now casts the row key to a string, as guardian stores
object_pk, and tests it against a single uncorrelated UNION ALL of the user's
and groups' grants. No integer cast is needed and the user's groups are
matched with an IN subquery rather than a join through the membership table.
Postgres and SQLite build the grant set once, and MariaDB probes guardian's
unique indexes per row, which is cheap. A correlated EXISTS per grant also
fixed MariaDB but was up to 3x slower than before on Postgres and SQLite.

permitted_object_ids also takes an optional parent_field naming a
self-referencing foreign key whose target authorizes the row, and
permitted_document_ids passes root_document, so a version is visible exactly
when its root is. The helper that mapped documents to their roots at the call
sites is no longer needed, so the email, selection data, share link bundle,
trash, bulk download and bulk edit checks use the id set directly.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-09 13:43:36 -07:00
16 changed files with 204 additions and 401 deletions

No files matched your search

+1 -1
View File
@@ -26,7 +26,7 @@ module.exports = {
'abstract-paperless-service', 'abstract-paperless-service',
], ],
transformIgnorePatterns: [ transformIgnorePatterns: [
'node_modules/(?!.*(\\.mjs$|tslib|lodash-es|normalize-diacritics|marked|@angular/common/locales/.*\\.js$))', 'node_modules/(?!.*(\\.mjs$|tslib|lodash-es|normalize-diacritics|@angular/common/locales/.*\\.js$))',
], ],
moduleNameMapper: { moduleNameMapper: {
...esmPreset.moduleNameMapper, ...esmPreset.moduleNameMapper,
-1
View File
@@ -30,7 +30,6 @@
"bootstrap": "^5.3.8", "bootstrap": "^5.3.8",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"lodash-es": "^4.18.1", "lodash-es": "^4.18.1",
"marked": "~18.0.14",
"mime-names": "^1.0.0", "mime-names": "^1.0.0",
"ngx-bootstrap-icons": "^1.9.3", "ngx-bootstrap-icons": "^1.9.3",
"ngx-color": "^10.1.0", "ngx-color": "^10.1.0",
-10
View File
@@ -53,9 +53,6 @@ importers:
lodash-es: lodash-es:
specifier: ^4.18.1 specifier: ^4.18.1
version: 4.18.1 version: 4.18.1
marked:
specifier: ~18.0.14
version: 18.0.14
mime-names: mime-names:
specifier: ^1.0.0 specifier: ^1.0.0
version: 1.0.0 version: 1.0.0
@@ -3229,11 +3226,6 @@ packages:
make-error@1.3.6: make-error@1.3.6:
resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==}
marked@18.0.14:
resolution: {integrity: sha512-mBHK6FBHuBAlhgRe88w9F0O1AbwwXJUcQibUbC/QcdTbVGAD7aWza+xt3N6oT/jCZx3/OMeS+8rnuiHZcQ9s7A==}
engines: {node: '>= 20'}
hasBin: true
material-colors@1.2.6: material-colors@1.2.6:
resolution: {integrity: sha512-6qE4B9deFBIa9YSpOc9O0Sgc43zTeVYbgDT5veRKSlB2+ZuHNoVVxA1L/ckMUayV9Ay9y7Z/SZCLcGteW9i7bg==} resolution: {integrity: sha512-6qE4B9deFBIa9YSpOc9O0Sgc43zTeVYbgDT5veRKSlB2+ZuHNoVVxA1L/ckMUayV9Ay9y7Z/SZCLcGteW9i7bg==}
@@ -7417,8 +7409,6 @@ snapshots:
make-error@1.3.6: {} make-error@1.3.6: {}
marked@18.0.14: {}
material-colors@1.2.6: {} material-colors@1.2.6: {}
merge-stream@2.0.0: {} merge-stream@2.0.0: {}
@@ -1,35 +1,23 @@
<li ngbDropdown class="nav-item" display="dynamic" placement="bottom-end" [popperOptions]="popperOptions" (openChange)="onOpenChange($event)"> <li ngbDropdown class="nav-item" (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>
<div ngbDropdownMenu class="dropdown-menu-end shadow p-3" aria-labelledby="chatDropdown"> <div ngbDropdownMenu class="dropdown-menu-end shadow p-3" aria-labelledby="chatDropdown">
<div class="chat-container bg-light p-2"> <div class="chat-container bg-light p-2">
<div class="chat-messages small"> <div class="chat-messages font-monospace 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'" [class.flex-grow-1]="message.role === 'assistant'"> <div class="p-2 m-2" [class.bg-body]="message.role === 'user'">
@if (message.isStreaming && !message.content) { <span class="text-break">
<div class="d-flex align-items-center text-muted"> {{ message.content }}
<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>
@if (message.isStreaming) { <span class="blinking-cursor">|</span> } @if (message.isStreaming) { <span class="blinking-cursor">|</span> }
} @else { </span>
<span class="text-break">{{ message.content }}</span>
}
@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 align-items-center text-primary" [routerLink]="['/documents', reference.id]" [title]="reference.title"> <a class="list-group-item list-group-item-action d-flex text-primary text-break" [routerLink]="['/documents', reference.id]">
<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> <i-bs width="0.9em" height="0.9em" name="file-text" class="me-1"></i-bs><span>{{ reference.title }}</span>
</a> </a>
} }
</div> </div>
@@ -53,11 +41,7 @@
(ngModelChange)="input.set($event)" (ngModelChange)="input.set($event)"
(keydown)="searchInputKeyDown($event)" (keydown)="searchInputKeyDown($event)"
/> />
@if (loading()) { <button class="btn btn-sm btn-secondary" type="button" (click)="sendMessage()" [disabled]="loading()">Send</button>
<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,13 +1,17 @@
.dropdown-menu { .dropdown-menu {
width: min(36rem, calc(100vw - 20px)); width: var(--pngx-toast-max-width);
} }
.chat-messages { .chat-messages {
max-height: min(60dvh, 36rem); max-height: 350px;
overflow-y: auto; overflow-y: auto;
white-space: pre-wrap; white-space: pre-wrap;
} }
.chat-references {
font-family: var(--bs-font-sans-serif);
}
.dropdown-toggle::after { .dropdown-toggle::after {
display: none; display: none;
} }
@@ -16,9 +20,9 @@
white-space: initial; white-space: initial;
} }
@media screen and (max-width: 575.98px) { @media screen and (max-width: 400px) {
.chat-messages { :host ::ng-deep .dropdown-menu-end {
max-height: calc(100dvh - 12rem); right: -3rem;
} }
} }
@@ -36,19 +40,3 @@
opacity: 1; opacity: 1;
} }
} }
.chat-markdown ::ng-deep {
h1, h2, h3, h4, h5, h6 {
font-size: 1em;
font-weight: bold;
}
th, td {
padding: 0.15rem 0.4rem;
border: 1px solid var(--bs-border-color);
}
> :last-child {
margin-bottom: 0;
}
}
@@ -80,15 +80,8 @@ 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()
@@ -163,75 +156,13 @@ 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).toBe( expect(component.messages()[1].content).toContain(
'Partial\n\n⚠️ Error receiving response.' '⚠️ 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
@@ -276,18 +207,4 @@ describe('ChatComponent', () => {
component.searchInputKeyDown(event) component.searchInputKeyDown(event)
expect(component.sendMessage).not.toHaveBeenCalled() expect(component.sendMessage).not.toHaveBeenCalled()
}) })
it('should render markdown for assistant messages only', () => {
component.messages.set([
{ role: 'user', content: '**user**' },
{ role: 'assistant', content: '**assistant** <script>x</script>' },
])
fixture.detectChanges()
const el: HTMLElement = fixture.nativeElement
expect(el.querySelector('.chat-markdown strong')?.textContent).toBe(
'assistant'
)
expect(el.querySelector('.chat-markdown script')).toBeNull()
expect(el.textContent).toContain('**user**')
})
}) })
@@ -10,16 +10,12 @@ 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 { filter, map } from 'rxjs'
import { MarkdownPipe } from 'src/app/pipes/markdown.pipe'
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',
@@ -29,7 +25,6 @@ const SLOW_RESPONSE_DELAY_MS = 8000
RouterModule, RouterModule,
NgxBootstrapIconsModule, NgxBootstrapIconsModule,
NgbDropdownModule, NgbDropdownModule,
MarkdownPipe,
], ],
templateUrl: './chat.component.html', templateUrl: './chat.component.html',
styleUrl: './chat.component.scss', styleUrl: './chat.component.scss',
@@ -39,8 +34,6 @@ 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)
@@ -50,11 +43,6 @@ 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()
@@ -93,90 +81,47 @@ 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.streamSubscription = this.chatService this.chatService.streamChat(this.documentId(), this.input()).subscribe({
.streamChat(this.documentId(), this.input()) next: (chunk) => {
.subscribe({ const nextResponse = parseChatResponse(chunk)
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: () => {
this.flushTypewriter(assistantMessage) assistantMessage.content += '\n\n⚠️ Error receiving response.'
assistantMessage.content += '\n\n⚠️ Error receiving response.' assistantMessage.isStreaming = false
this.finishStreaming(assistantMessage) this.notifyMessagesChanged()
}, this.loading.set(false)
complete: () => { },
// let the typewriter catch up before hiding the cursor complete: () => {
this.streamFinished = true assistantMessage.isStreaming = false
if (!this.typewriterActive) this.finishStreaming(assistantMessage) this.notifyMessagesChanged()
}, this.loading.set(false)
}) this.scrollToBottom()
},
})
this.input.set('') this.input.set('')
} }
stopStreaming(): void { private resetTypewriter(message: ChatMessage, content: string): 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()
@@ -196,7 +141,6 @@ 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
} }
@@ -205,8 +149,7 @@ export class ChatComponent implements OnInit {
this.notifyMessagesChanged() this.notifyMessagesChanged()
this.scrollToBottom() this.scrollToBottom()
// 10ms per character setTimeout(() => this.playTypewriter(message), 10) // 10ms per character
this.typewriterTimeout = setTimeout(() => this.playTypewriter(message), 10)
} }
private notifyMessagesChanged(): void { private notifyMessagesChanged(): void {
@@ -1,70 +0,0 @@
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('&lt;script&gt;')
})
it('should not render images', () => {
const html = pipe.transform('![secret](https://evil.example/x.png?d=1)')
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=/)
})
})
-57
View File
@@ -1,57 +0,0 @@
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
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
}
}
+8
View File
@@ -28,6 +28,7 @@ from rest_framework.permissions import BasePermission
from rest_framework.permissions import DjangoObjectPermissions from rest_framework.permissions import DjangoObjectPermissions
from documents.models import Document from documents.models import Document
from documents.versioning import get_root_document
class PaperlessObjectPermissions(DjangoObjectPermissions): class PaperlessObjectPermissions(DjangoObjectPermissions):
@@ -679,7 +680,14 @@ def has_perms_owner_aware(user, perms, obj):
single-object check still has many production callers. Several callers single-object check still has many production callers. Several callers
remain across ``documents/``, ``paperless_mail/``, and ``paperless_ai/`` remain across ``documents/``, ``paperless_mail/``, and ``paperless_ai/``
-- grep for this function name before removing it. -- grep for this function name before removing it.
A document version is authorized by its root document, like in
``permitted_document_ids``, so a version's own owner and grants never
matter. Fetch the root with ``select_related("root_document__owner")`` to
avoid extra queries.
""" """
if isinstance(obj, Document):
obj = get_root_document(obj)
checker = ObjectPermissionChecker(user) checker = ObjectPermissionChecker(user)
return obj.owner is None or obj.owner == user or checker.has_perm(perms, obj) return obj.owner is None or obj.owner == user or checker.has_perm(perms, obj)
+1 -2
View File
@@ -90,7 +90,6 @@ from documents.templating.utils import convert_format_str_to_template_format
from documents.templating.workflows import validate_workflow_template from documents.templating.workflows import validate_workflow_template
from documents.validators import uri_validator from documents.validators import uri_validator
from documents.validators import url_validator from documents.validators import url_validator
from documents.versioning import get_root_document
from documents.versioning import has_prefetched_effective_content from documents.versioning import has_prefetched_effective_content
from documents.versioning import sort_versions_newest_first from documents.versioning import sort_versions_newest_first
@@ -2895,7 +2894,7 @@ class ShareLinkSerializer(OwnedObjectSerializer):
and has_perms_owner_aware( and has_perms_owner_aware(
self.user, self.user,
"view_document", "view_document",
get_root_document(document), document,
) )
): ):
return document return document
@@ -18,6 +18,7 @@ from documents.models import Correspondent
from documents.models import DocumentType from documents.models import DocumentType
from documents.models import StoragePath from documents.models import StoragePath
from documents.models import Tag from documents.models import Tag
from documents.permissions import has_perms_owner_aware
from documents.permissions import permitted_document_ids from documents.permissions import permitted_document_ids
from documents.permissions import permitted_object_ids from documents.permissions import permitted_object_ids
from documents.permissions import restrict_queryset_to_visible from documents.permissions import restrict_queryset_to_visible
@@ -470,6 +471,92 @@ class TestPermittedDocumentIdsVersions:
) )
@pytest.mark.django_db
class TestHasPermsOwnerAwareVersions:
"""
The single-object check agrees with permitted_document_ids: a version is
authorized by its root document.
"""
@pytest.mark.parametrize(
("root_owner", "version_owner", "expected"),
[
pytest.param(
"other",
"nobody",
False,
id="unowned-version-of-private-root",
),
pytest.param("other", "user", False, id="own-version-of-private-root"),
pytest.param("user", "other", True, id="foreign-version-of-own-root"),
pytest.param("nobody", "other", True, id="private-version-of-unowned-root"),
],
)
def test_version_follows_root_owner(
self,
root_owner: str,
version_owner: str,
*,
expected: bool,
) -> None:
"""
GIVEN:
- A root document and a version with differing owners
WHEN:
- The single-object check runs for the version
THEN:
- The version is allowed exactly when its root is
"""
user = UserFactory()
owners = {"user": user, "other": UserFactory(), "nobody": None}
root = DocumentFactory(owner=owners[root_owner])
version = DocumentFactory(root_document=root, owner=owners[version_owner])
assert has_perms_owner_aware(user, "view_document", version) is expected
assert has_perms_owner_aware(user, "view_document", root) is expected
def test_grant_on_root_applies_and_grant_on_version_does_not(self) -> None:
"""
GIVEN:
- A private root with a version, and a second private root with a version
- The user may change only the first root, and was granted the second
root's version directly
WHEN:
- The single-object check runs for each version
THEN:
- Only the first root's version is allowed
"""
user = UserFactory()
shared_root = DocumentFactory(owner=UserFactory())
shared_version = DocumentFactory(root_document=shared_root, owner=UserFactory())
private_root = DocumentFactory(owner=UserFactory())
private_version = DocumentFactory(
root_document=private_root,
owner=UserFactory(),
)
grant_object(user, shared_root, "change_document")
grant_object(user, private_version, "change_document")
assert has_perms_owner_aware(user, "change_document", shared_version)
assert not has_perms_owner_aware(user, "change_document", private_version)
def test_other_models_use_their_own_owner(self) -> None:
"""
GIVEN:
- A tag owned by someone else, and one owned by the user
WHEN:
- The single-object check runs for each
THEN:
- Only the user's own tag is allowed without a grant
"""
user = UserFactory()
mine = TagFactory(owner=user)
theirs = TagFactory(owner=UserFactory())
assert has_perms_owner_aware(user, "view_tag", mine)
assert not has_perms_owner_aware(user, "view_tag", theirs)
@pytest.mark.django_db @pytest.mark.django_db
class TestAiChatAllDocumentsPermissionBoundary: class TestAiChatAllDocumentsPermissionBoundary:
""" """
+7 -7
View File
@@ -1559,7 +1559,7 @@ class DocumentViewSet(
if request.user is not None and not has_perms_owner_aware( if request.user is not None and not has_perms_owner_aware(
request.user, request.user,
"change_document", "change_document",
get_root_document(doc), doc,
): ):
return HttpResponseForbidden("Insufficient permissions") return HttpResponseForbidden("Insufficient permissions")
@@ -1622,7 +1622,7 @@ class DocumentViewSet(
if request.user is not None and not has_perms_owner_aware( if request.user is not None and not has_perms_owner_aware(
request.user, request.user,
"change_document", "change_document",
get_root_document(doc), doc,
): ):
return HttpResponseForbidden("Insufficient permissions") return HttpResponseForbidden("Insufficient permissions")
@@ -1875,7 +1875,7 @@ class DocumentViewSet(
if currentUser is not None and not has_perms_owner_aware( if currentUser is not None and not has_perms_owner_aware(
currentUser, currentUser,
"view_document", "view_document",
get_root_document(doc), doc,
): ):
return HttpResponseForbidden("Insufficient permissions to view notes") return HttpResponseForbidden("Insufficient permissions to view notes")
except Document.DoesNotExist: except Document.DoesNotExist:
@@ -1897,7 +1897,7 @@ class DocumentViewSet(
if currentUser is not None and not has_perms_owner_aware( if currentUser is not None and not has_perms_owner_aware(
currentUser, currentUser,
"change_document", "change_document",
get_root_document(doc), doc,
): ):
return HttpResponseForbidden( return HttpResponseForbidden(
"Insufficient permissions to create notes", "Insufficient permissions to create notes",
@@ -1940,7 +1940,7 @@ class DocumentViewSet(
if currentUser is not None and not has_perms_owner_aware( if currentUser is not None and not has_perms_owner_aware(
currentUser, currentUser,
"change_document", "change_document",
get_root_document(doc), doc,
): ):
return HttpResponseForbidden("Insufficient permissions to delete notes") return HttpResponseForbidden("Insufficient permissions to delete notes")
@@ -1990,7 +1990,7 @@ class DocumentViewSet(
if currentUser is not None and not has_perms_owner_aware( if currentUser is not None and not has_perms_owner_aware(
currentUser, currentUser,
"change_document", "change_document",
get_root_document(doc), doc,
): ):
return HttpResponseForbidden( return HttpResponseForbidden(
"Insufficient permissions to add share link", "Insufficient permissions to add share link",
@@ -2451,7 +2451,7 @@ class ChatStreamingView(GenericAPIView[Any]):
if not has_perms_owner_aware( if not has_perms_owner_aware(
request.user, request.user,
"view_document", "view_document",
get_root_document(document), document,
): ):
return HttpResponseForbidden("Insufficient permissions") return HttpResponseForbidden("Insufficient permissions")
+12 -12
View File
@@ -2,7 +2,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: paperless-ngx\n" "Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-10-10 22:04+0000\n" "POT-Creation-Date: 2026-10-08 18:39+0000\n"
"PO-Revision-Date: 2022-02-17 04:17\n" "PO-Revision-Date: 2022-02-17 04:17\n"
"Last-Translator: \n" "Last-Translator: \n"
"Language-Team: English\n" "Language-Team: English\n"
@@ -1653,7 +1653,7 @@ msgid "workflow runs"
msgstr "" msgstr ""
#: documents/serialisers.py:516 documents/serialisers.py:873 #: documents/serialisers.py:516 documents/serialisers.py:873
#: documents/serialisers.py:2903 documents/views.py:343 documents/views.py:2750 #: documents/serialisers.py:2903 documents/views.py:344 documents/views.py:2751
#: paperless_mail/serialisers.py:156 #: paperless_mail/serialisers.py:156
msgid "Insufficient permissions." msgid "Insufficient permissions."
msgstr "" msgstr ""
@@ -1694,7 +1694,7 @@ msgstr ""
msgid "Duplicate document identifiers are not allowed." msgid "Duplicate document identifiers are not allowed."
msgstr "" msgstr ""
#: documents/serialisers.py:2989 documents/views.py:4797 #: documents/serialisers.py:2989 documents/views.py:4807
#, python-format #, python-format
msgid "Documents not found: %(ids)s" msgid "Documents not found: %(ids)s"
msgstr "" msgstr ""
@@ -1945,40 +1945,40 @@ msgstr ""
msgid ", " msgid ", "
msgstr "" msgstr ""
#: documents/views.py:336 documents/views.py:2747 #: documents/views.py:337 documents/views.py:2748
msgid "Invalid more_like_id" msgid "Invalid more_like_id"
msgstr "" msgstr ""
#: documents/views.py:1682 #: documents/views.py:1683
msgid "Invalid AI configuration." msgid "Invalid AI configuration."
msgstr "" msgstr ""
#: documents/views.py:1693 #: documents/views.py:1694
msgid "AI backend request timed out." msgid "AI backend request timed out."
msgstr "" msgstr ""
#: documents/views.py:1705 #: documents/views.py:1706
msgid "AI backend rejected the request. Check logs for details." msgid "AI backend rejected the request. Check logs for details."
msgstr "" msgstr ""
#: documents/views.py:2572 documents/views.py:2888 #: documents/views.py:2573 documents/views.py:2889
msgid "Specify only one of text, title_search, query, or more_like_id." msgid "Specify only one of text, title_search, query, or more_like_id."
msgstr "" msgstr ""
#: documents/views.py:4813 #: documents/views.py:4823
#, python-format #, python-format
msgid "Insufficient permissions to share document %(id)s." msgid "Insufficient permissions to share document %(id)s."
msgstr "" msgstr ""
#: documents/views.py:4860 #: documents/views.py:4870
msgid "Bundle is already being processed." msgid "Bundle is already being processed."
msgstr "" msgstr ""
#: documents/views.py:4924 #: documents/views.py:4934
msgid "The share link bundle is still being prepared. Please try again later." msgid "The share link bundle is still being prepared. Please try again later."
msgstr "" msgstr ""
#: documents/views.py:4938 #: documents/views.py:4948
msgid "The share link bundle is unavailable." msgid "The share link bundle is unavailable."
msgstr "" msgstr ""
+11 -10
View File
@@ -272,26 +272,27 @@ def check_deprecated_db_settings(
Detects legacy advanced options that should be migrated to Detects legacy advanced options that should be migrated to
PAPERLESS_DB_OPTIONS. Returns one Warning per deprecated variable found. PAPERLESS_DB_OPTIONS. Returns one Warning per deprecated variable found.
""" """
deprecated_vars = ( deprecated_vars: dict[str, str] = {
"PAPERLESS_DB_TIMEOUT", "PAPERLESS_DB_TIMEOUT": "timeout",
"PAPERLESS_DB_POOLSIZE", "PAPERLESS_DB_POOLSIZE": "pool.min_size / pool.max_size",
"PAPERLESS_DBSSLMODE", "PAPERLESS_DBSSLMODE": "sslmode",
"PAPERLESS_DBSSLROOTCERT", "PAPERLESS_DBSSLROOTCERT": "sslrootcert",
"PAPERLESS_DBSSLCERT", "PAPERLESS_DBSSLCERT": "sslcert",
"PAPERLESS_DBSSLKEY", "PAPERLESS_DBSSLKEY": "sslkey",
) }
warnings: list[Warning] = [] warnings: list[Warning] = []
for var_name in deprecated_vars: for var_name, db_option_key in deprecated_vars.items():
if not os.getenv(var_name): if not os.getenv(var_name):
continue continue
warnings.append( warnings.append(
Warning( Warning(
f"Deprecated environment variable: {var_name}", f"Deprecated environment variable: {var_name}",
hint=( hint=(
f"{var_name} is deprecated. " f"{var_name} is no longer supported and will be removed in v3.2. "
f"Set the equivalent option via PAPERLESS_DB_OPTIONS instead. " f"Set the equivalent option via PAPERLESS_DB_OPTIONS instead. "
f'Example: PAPERLESS_DB_OPTIONS=\'{{"{db_option_key}": "<value>"}}\'. '
"See https://docs.paperless-ngx.com/migration-v3/ for the full reference." "See https://docs.paperless-ngx.com/migration-v3/ for the full reference."
), ),
id="paperless.W001", id="paperless.W001",
+25 -11
View File
@@ -211,14 +211,14 @@ class TestAuditLogChecks:
assert "auditlog table was found but audit log is disabled." in msgs[0].msg assert "auditlog table was found but audit log is disabled." in msgs[0].msg
DEPRECATED_VARS = ( DEPRECATED_VARS: dict[str, str] = {
"PAPERLESS_DB_TIMEOUT", "PAPERLESS_DB_TIMEOUT": "timeout",
"PAPERLESS_DB_POOLSIZE", "PAPERLESS_DB_POOLSIZE": "pool.min_size / pool.max_size",
"PAPERLESS_DBSSLMODE", "PAPERLESS_DBSSLMODE": "sslmode",
"PAPERLESS_DBSSLROOTCERT", "PAPERLESS_DBSSLROOTCERT": "sslrootcert",
"PAPERLESS_DBSSLCERT", "PAPERLESS_DBSSLCERT": "sslcert",
"PAPERLESS_DBSSLKEY", "PAPERLESS_DBSSLKEY": "sslkey",
) }
class TestDeprecatedDbSettings: class TestDeprecatedDbSettings:
@@ -234,11 +234,26 @@ class TestDeprecatedDbSettings:
result = check_deprecated_db_settings(None) result = check_deprecated_db_settings(None)
assert result == [] assert result == []
@pytest.mark.parametrize("env_var", DEPRECATED_VARS) @pytest.mark.parametrize(
("env_var", "db_option_key"),
[
pytest.param("PAPERLESS_DB_TIMEOUT", "timeout", id="db-timeout"),
pytest.param(
"PAPERLESS_DB_POOLSIZE",
"pool.min_size / pool.max_size",
id="db-poolsize",
),
pytest.param("PAPERLESS_DBSSLMODE", "sslmode", id="ssl-mode"),
pytest.param("PAPERLESS_DBSSLROOTCERT", "sslrootcert", id="ssl-rootcert"),
pytest.param("PAPERLESS_DBSSLCERT", "sslcert", id="ssl-cert"),
pytest.param("PAPERLESS_DBSSLKEY", "sslkey", id="ssl-key"),
],
)
def test_single_deprecated_var_produces_one_warning( def test_single_deprecated_var_produces_one_warning(
self, self,
mocker: MockerFixture, mocker: MockerFixture,
env_var: str, env_var: str,
db_option_key: str,
) -> None: ) -> None:
"""Each deprecated var in isolation produces exactly one warning.""" """Each deprecated var in isolation produces exactly one warning."""
mocker.patch.dict(os.environ, {env_var: "some_value"}, clear=True) mocker.patch.dict(os.environ, {env_var: "some_value"}, clear=True)
@@ -249,8 +264,7 @@ class TestDeprecatedDbSettings:
assert isinstance(warning, Warning) assert isinstance(warning, Warning)
assert warning.id == "paperless.W001" assert warning.id == "paperless.W001"
assert env_var in warning.hint assert env_var in warning.hint
assert "PAPERLESS_DB_OPTIONS" in warning.hint assert db_option_key in warning.hint
assert "https://docs.paperless-ngx.com/migration-v3/" in warning.hint
def test_multiple_deprecated_vars_produce_one_warning_each( def test_multiple_deprecated_vars_produce_one_warning_each(
self, self,