mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-10-12 19:17:13 +00:00
Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dfaf270f83 | ||
|
|
706504cac0 | ||
|
|
8878f7bf1f | ||
|
|
5d58193a5d | ||
|
|
d479d07e05 | ||
|
|
ce45104cd5 | ||
|
|
ac5d7de9cc | ||
|
|
560d1e53ed | ||
|
|
37b3d7240c | ||
|
|
4f6907ba6f | ||
|
|
c55f37e980 | ||
|
|
90d6bbb4a9 | ||
|
|
5f7dc04657 | ||
|
|
ebae55f158 | ||
|
|
1c64328b71 | ||
|
|
962b26879d | ||
|
|
6d6cddca16 | ||
|
|
ef5237620b | ||
|
|
82cfe868b5 | ||
|
|
e557854d8c | ||
|
|
973af89f61 | ||
|
|
937bf2fa7a | ||
|
|
2f2381578c | ||
|
|
e606dbffcd |
No files matched your search
@@ -794,17 +794,6 @@ system. See the corresponding
|
||||
|
||||
Defaults to None
|
||||
|
||||
#### [`PAPERLESS_SOCIAL_ACCOUNT_MFA_TRUSTED_AMR=<comma-separated-list>`](#PAPERLESS_SOCIAL_ACCOUNT_MFA_TRUSTED_AMR) {#PAPERLESS_SOCIAL_ACCOUNT_MFA_TRUSTED_AMR}
|
||||
|
||||
: A list of authentication method values which, if present in the `amr` claim of the ID token sent by an OpenID Connect provider, cause Paperless-ngx to skip its own two-factor authentication prompt for that login. This avoids users having to enter a second factor twice when the identity provider already performed multi-factor authentication. Logins with a username and password, or via a provider which does not send a matching `amr` value, still require the Paperless-ngx code.
|
||||
|
||||
Which values to trust depends entirely on your identity provider, e.g. Authelia sends `mfa` and Pocket ID sends `phr` for passkey logins (but `otp` for single-factor email codes, which should _not_ be trusted). Some providers, such as Keycloak, do not send an `amr` claim by default and need to be configured to do so. You can see what your provider sends from the Django admin as a superuser by checking `id_token` → `amr` in the "Extra data" of the social account.
|
||||
|
||||
!!! warning
|
||||
Only list values that mean a second factor was actually used for the login.
|
||||
|
||||
Defaults to `mfa`, the standard value for multi-factor authentication ([RFC 8176](https://www.rfc-editor.org/rfc/rfc8176.html)).
|
||||
|
||||
#### [`PAPERLESS_SOCIAL_ACCOUNT_DEFAULT_GROUPS=<comma-separated-list>`](#PAPERLESS_SOCIAL_ACCOUNT_DEFAULT_GROUPS) {#PAPERLESS_SOCIAL_ACCOUNT_DEFAULT_GROUPS}
|
||||
|
||||
: A list of group names that users who signup via social accounts will be added to upon signup. Groups listed here must already exist.
|
||||
|
||||
@@ -473,8 +473,6 @@ Users can enable two-factor authentication (2FA) for their accounts from the 'My
|
||||
|
||||
Should a user lose access to their 2FA device and all recovery codes, a superuser can disable 2FA for the user from the 'Users & Groups' management screen.
|
||||
|
||||
If users log in via an OpenID Connect provider which already enforces multi-factor authentication, see [`PAPERLESS_SOCIAL_ACCOUNT_MFA_TRUSTED_AMR`](configuration.md#PAPERLESS_SOCIAL_ACCOUNT_MFA_TRUSTED_AMR) to skip the Paperless-ngx prompt for those logins.
|
||||
|
||||
## Workflows
|
||||
|
||||
!!! note
|
||||
|
||||
@@ -26,7 +26,7 @@ module.exports = {
|
||||
'abstract-paperless-service',
|
||||
],
|
||||
transformIgnorePatterns: [
|
||||
'node_modules/(?!.*(\\.mjs$|tslib|lodash-es|normalize-diacritics|@angular/common/locales/.*\\.js$))',
|
||||
'node_modules/(?!.*(\\.mjs$|tslib|lodash-es|normalize-diacritics|marked|@angular/common/locales/.*\\.js$))',
|
||||
],
|
||||
moduleNameMapper: {
|
||||
...esmPreset.moduleNameMapper,
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
"bootstrap": "^5.3.8",
|
||||
"file-saver": "^2.0.5",
|
||||
"lodash-es": "^4.18.1",
|
||||
"marked": "~18.0.14",
|
||||
"mime-names": "^1.0.0",
|
||||
"ngx-bootstrap-icons": "^1.9.3",
|
||||
"ngx-color": "^10.1.0",
|
||||
|
||||
Generated
+10
@@ -53,6 +53,9 @@ importers:
|
||||
lodash-es:
|
||||
specifier: ^4.18.1
|
||||
version: 4.18.1
|
||||
marked:
|
||||
specifier: ~18.0.14
|
||||
version: 18.0.14
|
||||
mime-names:
|
||||
specifier: ^1.0.0
|
||||
version: 1.0.0
|
||||
@@ -3226,6 +3229,11 @@ packages:
|
||||
make-error@1.3.6:
|
||||
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:
|
||||
resolution: {integrity: sha512-6qE4B9deFBIa9YSpOc9O0Sgc43zTeVYbgDT5veRKSlB2+ZuHNoVVxA1L/ckMUayV9Ay9y7Z/SZCLcGteW9i7bg==}
|
||||
|
||||
@@ -7409,6 +7417,8 @@ snapshots:
|
||||
|
||||
make-error@1.3.6: {}
|
||||
|
||||
marked@18.0.14: {}
|
||||
|
||||
material-colors@1.2.6: {}
|
||||
|
||||
merge-stream@2.0.0: {}
|
||||
|
||||
@@ -1,49 +1,85 @@
|
||||
|
||||
<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>
|
||||
<i-bs width="1.3em" height="1.3em" name="chatSquareDots"></i-bs>
|
||||
</button>
|
||||
<div ngbDropdownMenu class="dropdown-menu-end shadow p-3" aria-labelledby="chatDropdown">
|
||||
<div class="chat-container bg-light p-2">
|
||||
<div class="chat-messages font-monospace small">
|
||||
@for (message of messages(); track message) {
|
||||
<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'">
|
||||
<span class="text-break">
|
||||
{{ message.content }}
|
||||
@if (message.isStreaming) { <span class="blinking-cursor">|</span> }
|
||||
</span>
|
||||
@if (message.role === 'assistant' && message.references?.length) {
|
||||
<div class="chat-references list-group mt-3">
|
||||
@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]">
|
||||
<i-bs width="0.9em" height="0.9em" name="file-text" class="me-1"></i-bs><span>{{ reference.title }}</span>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div ngbDropdownMenu class="dropdown-menu-end shadow p-0 overflow-hidden" aria-labelledby="chatDropdown">
|
||||
<div class="d-flex align-items-center border-bottom px-3 py-2">
|
||||
<i-bs width="1em" height="1em" name="stars" class="text-primary me-2"></i-bs>
|
||||
<span class="fw-semibold small">
|
||||
@if (documentId()) {
|
||||
<ng-container i18n>Ask about this document</ng-container>
|
||||
} @else {
|
||||
<ng-container i18n>Ask about your documents</ng-container>
|
||||
}
|
||||
<div #scrollAnchor></div>
|
||||
</div>
|
||||
</span>
|
||||
@if (messages().length) {
|
||||
<button class="btn btn-sm btn-link text-muted ms-auto p-0" type="button" (click)="clearChat()" title="Clear chat" i18n-title>
|
||||
<i-bs width="1em" height="1em" name="arrow-counterclockwise"></i-bs>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<form class="chat-input">
|
||||
<label class="visually-hidden" for="chatInput" i18n>Message</label>
|
||||
<div class="input-group">
|
||||
<input
|
||||
#chatInput
|
||||
id="chatInput"
|
||||
class="form-control form-control-sm" name="chatInput" type="text"
|
||||
<div class="chat-messages small px-2">
|
||||
@if (!messages().length) {
|
||||
<p class="text-muted text-center py-4 px-3 mb-0" i18n>Answers come from your documents, with links to their sources.</p>
|
||||
}
|
||||
@for (message of messages(); track message) {
|
||||
<div class="message d-flex flex-row" [class.justify-content-end]="message.role === 'user'" [class.mt-3]="message.role === 'user' && !$first">
|
||||
<div class="p-2 m-2" [class.bg-body-secondary]="message.role === 'user'" [class.rounded-3]="message.role === 'user'" [class.flex-grow-1]="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" [class.streaming]="message.isStreaming" [innerHTML]="message.content | markdown"></div>
|
||||
} @else {
|
||||
<span class="text-break">{{ message.content }}</span>
|
||||
}
|
||||
@if (message.role === 'assistant' && !message.isStreaming && message.references?.length) {
|
||||
<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) {
|
||||
<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-2 flex-shrink-0"></i-bs><span class="text-truncate">{{ reference.title }}</span>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<div #scrollAnchor></div>
|
||||
</div>
|
||||
|
||||
<form class="chat-input border-top p-2">
|
||||
<label class="visually-hidden" for="chatInput" i18n>Message</label>
|
||||
<div class="input-group">
|
||||
<input
|
||||
#chatInput
|
||||
id="chatInput"
|
||||
class="form-control" name="chatInput" type="text"
|
||||
[placeholder]="placeholder"
|
||||
[disabled]="loading()"
|
||||
[ngModel]="input()"
|
||||
(ngModelChange)="input.set($event)"
|
||||
(keydown)="searchInputKeyDown($event)"
|
||||
/>
|
||||
<button class="btn btn-sm btn-secondary" type="button" (click)="sendMessage()" [disabled]="loading()">Send</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
/>
|
||||
@if (loading()) {
|
||||
<button class="btn btn-outline-secondary" type="button" (click)="stopStreaming()" title="Stop" i18n-title aria-label="Stop" i18n-aria-label>
|
||||
<i-bs width="1em" height="1em" name="x-lg"></i-bs>
|
||||
</button>
|
||||
} @else {
|
||||
<button class="btn btn-primary" type="button" (click)="sendMessage()" title="Send" i18n-title aria-label="Send" i18n-aria-label>
|
||||
<i-bs width="1em" height="1em" name="arrow-return-left"></i-bs>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</li>
|
||||
@@ -1,17 +1,13 @@
|
||||
.dropdown-menu {
|
||||
width: var(--pngx-toast-max-width);
|
||||
width: min(36rem, calc(100vw - 20px));
|
||||
}
|
||||
|
||||
.chat-messages {
|
||||
max-height: 350px;
|
||||
max-height: min(60dvh, 36rem);
|
||||
overflow-y: auto;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.chat-references {
|
||||
font-family: var(--bs-font-sans-serif);
|
||||
}
|
||||
|
||||
.dropdown-toggle::after {
|
||||
display: none;
|
||||
}
|
||||
@@ -20,23 +16,44 @@
|
||||
white-space: initial;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 400px) {
|
||||
:host ::ng-deep .dropdown-menu-end {
|
||||
right: -3rem;
|
||||
@media screen and (max-width: 575.98px) {
|
||||
.chat-messages {
|
||||
max-height: calc(100dvh - 12rem);
|
||||
}
|
||||
}
|
||||
|
||||
.blinking-cursor {
|
||||
font-weight: bold;
|
||||
font-size: 1.2em;
|
||||
animation: blink 1s step-end infinite;
|
||||
.chat-markdown.streaming ::ng-deep {
|
||||
> :last-child:not(ul, ol, table)::after,
|
||||
> :is(ul, ol):last-child > li:last-child::after {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 2px;
|
||||
height: 1.05em;
|
||||
margin-left: 2px;
|
||||
background-color: currentColor;
|
||||
vertical-align: -0.15em;
|
||||
animation: blink 1s step-end infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
from, to {
|
||||
50% {
|
||||
opacity: 0;
|
||||
}
|
||||
50% {
|
||||
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,8 +80,16 @@ describe('ChatComponent', () => {
|
||||
expect(component.messages()[0].content).toBe('Hello')
|
||||
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')
|
||||
expect(component.messages()[1].content).toBe('H')
|
||||
fixture.detectChanges()
|
||||
expect(messagesEl.querySelector('.spinner-border')).toBeNull()
|
||||
expect(messagesEl.querySelector('.chat-markdown.streaming')).not.toBeNull()
|
||||
mockStream$.next('Hi there')
|
||||
// advance time to process the typewriter effect
|
||||
await jest.runAllTimersAsync()
|
||||
@@ -96,6 +104,8 @@ describe('ChatComponent', () => {
|
||||
await fixture.whenStable()
|
||||
expect(component.loading()).toBe(false)
|
||||
expect(component.messages()[1].isStreaming).toBe(false)
|
||||
fixture.detectChanges()
|
||||
expect(messagesEl.querySelector('.chat-markdown.streaming')).toBeNull()
|
||||
expect(fixture.nativeElement.querySelector('#chatInput').disabled).toBe(
|
||||
false
|
||||
)
|
||||
@@ -123,6 +133,10 @@ describe('ChatComponent', () => {
|
||||
mockStream$.next(
|
||||
`Hi there${CHAT_METADATA_DELIMITER}{"references":[{"id":42,"title":"Bread Recipe"}]}`
|
||||
)
|
||||
fixture.detectChanges()
|
||||
expect(fixture.nativeElement.querySelector('.chat-references')).toBeNull()
|
||||
|
||||
mockStream$.complete()
|
||||
jest.advanceTimersByTime(1000)
|
||||
fixture.detectChanges()
|
||||
|
||||
@@ -156,13 +170,96 @@ describe('ChatComponent', () => {
|
||||
component.input.set('Hello')
|
||||
component.sendMessage()
|
||||
|
||||
mockStream$.next('Partial')
|
||||
mockStream$.error('Error')
|
||||
expect(component.messages()[1].content).toContain(
|
||||
'⚠️ Error receiving response.'
|
||||
expect(component.messages()[1].content).toBe(
|
||||
'Partial\n\n⚠️ Error receiving response.'
|
||||
)
|
||||
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.getAttribute('aria-label')).toBe('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 clear the conversation', () => {
|
||||
component.input.set('Hello')
|
||||
component.sendMessage()
|
||||
mockStream$.next('Hi there')
|
||||
fixture.detectChanges()
|
||||
|
||||
fixture.nativeElement.querySelector('button[title="Clear chat"]').click()
|
||||
expect(component.messages()).toHaveLength(0)
|
||||
expect(component.loading()).toBe(false)
|
||||
expect(mockStream$.observed).toBe(false)
|
||||
|
||||
fixture.detectChanges()
|
||||
jest.advanceTimersByTime(10)
|
||||
expect(document.activeElement).toBe(
|
||||
fixture.nativeElement.querySelector('#chatInput')
|
||||
)
|
||||
expect(fixture.nativeElement.textContent).toContain(
|
||||
'Answers come from your documents'
|
||||
)
|
||||
})
|
||||
|
||||
it('should enqueue typewriter chunks correctly', () => {
|
||||
const message = { content: '', role: 'assistant', isStreaming: true }
|
||||
component.enqueueTypewriter(null, message as any) // coverage for null
|
||||
@@ -207,4 +304,33 @@ describe('ChatComponent', () => {
|
||||
component.searchInputKeyDown(event)
|
||||
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**')
|
||||
})
|
||||
|
||||
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,12 +10,16 @@ import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import { NavigationEnd, Router, RouterModule } from '@angular/router'
|
||||
import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
import { filter, map } from 'rxjs'
|
||||
import { filter, map, Subscription } from 'rxjs'
|
||||
import { MarkdownPipe } from 'src/app/pipes/markdown.pipe'
|
||||
import {
|
||||
ChatMessage,
|
||||
ChatService,
|
||||
parseChatResponse,
|
||||
} from 'src/app/services/chat.service'
|
||||
import { pngxPopperOptions } from 'src/app/utils/popper-options'
|
||||
|
||||
const SLOW_RESPONSE_DELAY_MS = 8000
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-chat',
|
||||
@@ -25,6 +29,7 @@ import {
|
||||
RouterModule,
|
||||
NgxBootstrapIconsModule,
|
||||
NgbDropdownModule,
|
||||
MarkdownPipe,
|
||||
],
|
||||
templateUrl: './chat.component.html',
|
||||
styleUrl: './chat.component.scss',
|
||||
@@ -34,6 +39,8 @@ export class ChatComponent implements OnInit {
|
||||
readonly loading = signal(false)
|
||||
readonly input = signal('')
|
||||
readonly documentId = signal<number>(undefined)
|
||||
readonly slowResponse = signal(false)
|
||||
public popperOptions = pngxPopperOptions
|
||||
|
||||
private chatService: ChatService = inject(ChatService)
|
||||
private router: Router = inject(Router)
|
||||
@@ -43,6 +50,11 @@ export class ChatComponent implements OnInit {
|
||||
|
||||
private typewriterBuffer: string[] = []
|
||||
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 {
|
||||
return this.documentId()
|
||||
@@ -81,47 +93,96 @@ export class ChatComponent implements OnInit {
|
||||
}
|
||||
this.messages.update((messages) => [...messages, assistantMessage])
|
||||
this.loading.set(true)
|
||||
this.streamingMessage = assistantMessage
|
||||
this.streamFinished = false
|
||||
this.slowResponseTimeout = setTimeout(
|
||||
() => this.slowResponse.set(true),
|
||||
SLOW_RESPONSE_DELAY_MS
|
||||
)
|
||||
|
||||
let lastVisibleContent = ''
|
||||
|
||||
this.chatService.streamChat(this.documentId(), this.input()).subscribe({
|
||||
next: (chunk) => {
|
||||
const nextResponse = parseChatResponse(chunk)
|
||||
this.streamSubscription = this.chatService
|
||||
.streamChat(this.documentId(), this.input())
|
||||
.subscribe({
|
||||
next: (chunk) => {
|
||||
this.clearSlowResponse()
|
||||
const nextResponse = parseChatResponse(chunk)
|
||||
|
||||
if (nextResponse.content.length < lastVisibleContent.length) {
|
||||
this.resetTypewriter(assistantMessage, nextResponse.content)
|
||||
lastVisibleContent = nextResponse.content
|
||||
} else {
|
||||
const visibleDelta = nextResponse.content.substring(
|
||||
lastVisibleContent.length
|
||||
)
|
||||
lastVisibleContent = nextResponse.content
|
||||
this.enqueueTypewriter(visibleDelta, assistantMessage)
|
||||
}
|
||||
if (nextResponse.content.length < lastVisibleContent.length) {
|
||||
this.resetTypewriter(assistantMessage, nextResponse.content)
|
||||
lastVisibleContent = nextResponse.content
|
||||
} else {
|
||||
const visibleDelta = nextResponse.content.substring(
|
||||
lastVisibleContent.length
|
||||
)
|
||||
lastVisibleContent = nextResponse.content
|
||||
this.enqueueTypewriter(visibleDelta, assistantMessage)
|
||||
}
|
||||
|
||||
assistantMessage.references = nextResponse.references
|
||||
this.notifyMessagesChanged()
|
||||
},
|
||||
error: () => {
|
||||
assistantMessage.content += '\n\n⚠️ Error receiving response.'
|
||||
assistantMessage.isStreaming = false
|
||||
this.notifyMessagesChanged()
|
||||
this.loading.set(false)
|
||||
},
|
||||
complete: () => {
|
||||
assistantMessage.isStreaming = false
|
||||
this.notifyMessagesChanged()
|
||||
this.loading.set(false)
|
||||
this.scrollToBottom()
|
||||
},
|
||||
})
|
||||
assistantMessage.references = nextResponse.references
|
||||
this.notifyMessagesChanged()
|
||||
},
|
||||
error: () => {
|
||||
this.flushTypewriter(assistantMessage)
|
||||
assistantMessage.content += '\n\n⚠️ Error receiving response.'
|
||||
this.finishStreaming(assistantMessage)
|
||||
},
|
||||
complete: () => {
|
||||
// let the typewriter catch up before hiding the cursor
|
||||
this.streamFinished = true
|
||||
if (!this.typewriterActive) this.finishStreaming(assistantMessage)
|
||||
},
|
||||
})
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
clearChat(): void {
|
||||
this.stopStreaming()
|
||||
this.messages.set([])
|
||||
setTimeout(() => this.chatInput?.nativeElement.focus())
|
||||
}
|
||||
|
||||
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.typewriterActive = false
|
||||
}
|
||||
|
||||
private resetTypewriter(message: ChatMessage, content: string): void {
|
||||
this.cancelTypewriter()
|
||||
message.content = content
|
||||
this.notifyMessagesChanged()
|
||||
this.scrollToBottom()
|
||||
@@ -141,6 +202,7 @@ export class ChatComponent implements OnInit {
|
||||
playTypewriter(message: ChatMessage): void {
|
||||
if (this.typewriterBuffer.length === 0) {
|
||||
this.typewriterActive = false
|
||||
if (this.streamFinished) this.finishStreaming(message)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -149,7 +211,8 @@ export class ChatComponent implements OnInit {
|
||||
this.notifyMessagesChanged()
|
||||
this.scrollToBottom()
|
||||
|
||||
setTimeout(() => this.playTypewriter(message), 10) // 10ms per character
|
||||
// 10ms per character
|
||||
this.typewriterTimeout = setTimeout(() => this.playTypewriter(message), 10)
|
||||
}
|
||||
|
||||
private notifyMessagesChanged(): void {
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
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('otherdomain.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 target="_blank" rel="noopener noreferrer nofollow" href="https://docs.paperless-ngx.com" title="Docs">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 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', () => {
|
||||
const html = pipe.transform(
|
||||
'[x](https://a.example "a\\" onmouseover=\\"1")'
|
||||
)
|
||||
expect(html).not.toMatch(/"\s*onmouseover=/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Pipe, PipeTransform } from '@angular/core'
|
||||
import { Marked, Renderer, Tokens } from 'marked'
|
||||
|
||||
const ALLOWED_LINK_PROTOCOLS = new Set(['http:', 'https:', 'mailto:'])
|
||||
|
||||
function allowedUrl(href: string): URL | null {
|
||||
try {
|
||||
const url = new URL(href)
|
||||
return ALLOWED_LINK_PROTOCOLS.has(url.protocol) ? url : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Treat chat content as untrusted: no raw HTML, no images, and only
|
||||
// absolute links. All escaping is done by marked's default renderer, and
|
||||
// Angular's sanitizer runs on top of this as well.
|
||||
const renderer: Partial<Renderer> = {
|
||||
html(this: Renderer, { raw, text }: Tokens.HTML | Tokens.Tag): string {
|
||||
return this.text({ type: 'text', raw, text })
|
||||
},
|
||||
image(this: Renderer, { raw, text }: Tokens.Image): string {
|
||||
return this.text({ type: 'text', raw, text })
|
||||
},
|
||||
link(this: Renderer, token: Tokens.Link): string {
|
||||
const url = allowedUrl(token.href)
|
||||
if (!url) return this.parser.parseInline(token.tokens)
|
||||
const anchor = Renderer.prototype.link
|
||||
.call(this, token)
|
||||
.replace('<a ', '<a target="_blank" rel="noopener noreferrer nofollow" ')
|
||||
if (!url.hostname) return anchor
|
||||
const hostname = this.text({ type: 'text', raw: '', text: url.hostname })
|
||||
return `${anchor} <span class="text-muted small">(${hostname})</span>`
|
||||
},
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
arrowDownUp,
|
||||
arrowLeft,
|
||||
arrowRepeat,
|
||||
arrowReturnLeft,
|
||||
arrowRight,
|
||||
arrowRightShort,
|
||||
arrowUpRight,
|
||||
@@ -283,6 +284,7 @@ const icons = {
|
||||
arrowDownUp,
|
||||
arrowLeft,
|
||||
arrowRepeat,
|
||||
arrowReturnLeft,
|
||||
arrowRight,
|
||||
arrowRightShort,
|
||||
arrowUpRight,
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"target": "ES2022",
|
||||
"module": "es2020",
|
||||
"lib": [
|
||||
"es2020",
|
||||
"es2022",
|
||||
"dom"
|
||||
],
|
||||
"useDefineForClassFields": false
|
||||
|
||||
@@ -6,7 +6,9 @@ from django.contrib.auth.models import Permission
|
||||
from django.contrib.auth.models import User
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db.models import Case
|
||||
from django.db.models import CharField
|
||||
from django.db.models import Count
|
||||
from django.db.models import F
|
||||
from django.db.models import IntegerField
|
||||
from django.db.models import Model
|
||||
from django.db.models import Q
|
||||
@@ -349,6 +351,7 @@ def permitted_object_ids(
|
||||
perm: str,
|
||||
*,
|
||||
include_deleted: bool = False,
|
||||
parent_field: str | None = None,
|
||||
) -> QuerySet[int]:
|
||||
"""
|
||||
Generic version of ``permitted_document_ids`` for any model with an
|
||||
@@ -357,6 +360,24 @@ def permitted_object_ids(
|
||||
soft-delete pattern (currently only ``Document``); for every other model
|
||||
it is accepted but has no effect, since those models have no soft-delete
|
||||
concept.
|
||||
|
||||
``parent_field`` names a self-referencing foreign key whose target
|
||||
authorizes the row (``Document.root_document``). A row with a parent is
|
||||
visible exactly when its parent is, judged by the parent's owner and
|
||||
grants, so the row's own owner and grants are ignored.
|
||||
|
||||
Guardian stores ``object_pk`` as a string, so the row key is cast to a
|
||||
string and tested against the user's and groups' grants with a single
|
||||
uncorrelated ``IN``. Postgres and SQLite build that set once. MariaDB
|
||||
evaluates it as an index probe per row, which is cheap because the
|
||||
lookups use guardian's unique indexes. Casting every ``object_pk`` to an
|
||||
integer instead cannot use an index, and MariaDB cannot materialize it
|
||||
inside the owner ``OR``, so it re-scans the user's grants for every row.
|
||||
A correlated ``EXISTS`` per grant fixes MariaDB too, but Postgres and
|
||||
SQLite re-run it for every row and end up slower than the original. The
|
||||
user's groups are matched with an ``IN`` subquery rather than a join
|
||||
through the membership table, which SQLite plans badly once the grant
|
||||
tables grow.
|
||||
"""
|
||||
has_soft_delete = hasattr(model, "global_objects")
|
||||
manager = (
|
||||
@@ -364,8 +385,21 @@ def permitted_object_ids(
|
||||
)
|
||||
base_qs = manager.all().only("id", "owner")
|
||||
|
||||
owner_field, key_field = "owner", "pk"
|
||||
if parent_field is not None:
|
||||
owner_field, key_field = "authorizing_owner", "authorizing_id"
|
||||
base_qs = base_qs.annotate(
|
||||
authorizing_id=Coalesce(f"{parent_field}_id", "id"),
|
||||
authorizing_owner=Case(
|
||||
When(**{f"{parent_field}_id__isnull": True}, then=F("owner_id")),
|
||||
default=F(f"{parent_field}__owner_id"),
|
||||
output_field=IntegerField(),
|
||||
),
|
||||
)
|
||||
unowned = Q(**{f"{owner_field}__isnull": True})
|
||||
|
||||
if user is None or not getattr(user, "is_authenticated", False):
|
||||
return base_qs.filter(owner__isnull=True).values_list("id", flat=True)
|
||||
return base_qs.filter(unowned).values_list("id", flat=True)
|
||||
|
||||
# Deactivated users get nothing, deactivated superusers included, so this
|
||||
# has to come before the superuser shortcut. guardian's
|
||||
@@ -389,21 +423,26 @@ def permitted_object_ids(
|
||||
"permission__content_type": content_type,
|
||||
}
|
||||
|
||||
user_perm_ids = (
|
||||
UserObjectPermission.objects.filter(user=user, **perm_filter)
|
||||
.annotate(object_pk_int=Cast("object_pk", IntegerField()))
|
||||
.values_list("object_pk_int", flat=True)
|
||||
)
|
||||
group_perm_ids = (
|
||||
GroupObjectPermission.objects.filter(group__user=user, **perm_filter)
|
||||
.annotate(object_pk_int=Cast("object_pk", IntegerField()))
|
||||
.values_list("object_pk_int", flat=True)
|
||||
)
|
||||
permitted_ids = user_perm_ids.union(group_perm_ids)
|
||||
# Both grant sets are compared to the row key as strings, exactly as
|
||||
# guardian stores them, and are uncorrelated, so each engine can build the
|
||||
# set once instead of probing per row.
|
||||
user_keys = UserObjectPermission.objects.filter(
|
||||
user=user,
|
||||
**perm_filter,
|
||||
).values_list("object_pk", flat=True)
|
||||
group_keys = GroupObjectPermission.objects.filter(
|
||||
group_id__in=user.groups.values("id"),
|
||||
**perm_filter,
|
||||
).values_list("object_pk", flat=True)
|
||||
permitted_keys = user_keys.union(group_keys, all=True)
|
||||
|
||||
return base_qs.filter(
|
||||
Q(owner=user) | Q(owner__isnull=True) | Q(id__in=permitted_ids),
|
||||
).values_list("id", flat=True)
|
||||
return (
|
||||
base_qs.annotate(permitted_key=Cast(key_field, CharField(max_length=64)))
|
||||
.filter(
|
||||
Q(**{owner_field: user.pk}) | unowned | Q(permitted_key__in=permitted_keys),
|
||||
)
|
||||
.values_list("id", flat=True)
|
||||
)
|
||||
|
||||
|
||||
ModelT = TypeVar("ModelT", bound=Model)
|
||||
@@ -471,30 +510,16 @@ def permitted_document_ids(
|
||||
``include_deleted=True`` for callers that need to check permission on
|
||||
soft-deleted documents (e.g. trash restore). This intentionally avoids
|
||||
``get_objects_for_user`` to keep the subquery small and index-friendly.
|
||||
"""
|
||||
return permitted_object_ids(user, Document, perm, include_deleted=include_deleted)
|
||||
|
||||
|
||||
def documents_without_permitted_root(
|
||||
documents: QuerySet[Document],
|
||||
user: User | None,
|
||||
*,
|
||||
perm: str = "view_document",
|
||||
include_deleted: bool = False,
|
||||
) -> QuerySet[Document]:
|
||||
A version is authorized by its root document, so a version's own owner and
|
||||
grants never matter.
|
||||
"""
|
||||
The documents the user lacks ``perm`` on. Versions are authorized by their
|
||||
root document, so a version's own owner is ignored. A single query, without
|
||||
loading the documents or joining the root.
|
||||
"""
|
||||
return documents.annotate(
|
||||
root_id=Coalesce("root_document_id", "id"),
|
||||
).exclude(
|
||||
root_id__in=permitted_document_ids(
|
||||
user,
|
||||
perm=perm,
|
||||
include_deleted=include_deleted,
|
||||
),
|
||||
return permitted_object_ids(
|
||||
user,
|
||||
Document,
|
||||
perm,
|
||||
include_deleted=include_deleted,
|
||||
parent_field="root_document",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -32,6 +32,8 @@ from paperless_testing.permissions import grant_global
|
||||
from paperless_testing.permissions import grant_object
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from django.contrib.auth.models import User
|
||||
|
||||
from paperless_testing.dirs import PaperlessDirs
|
||||
|
||||
|
||||
@@ -178,6 +180,296 @@ class TestPermittedDocumentIdsIncludeDeleted:
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestPermittedDocumentIdsVersions:
|
||||
"""
|
||||
A version is authorized by its root document: the version's own owner and
|
||||
grants never matter.
|
||||
"""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("root_owner", "version_owner", "expected_visible"),
|
||||
[
|
||||
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("user", "nobody", True, id="unowned-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_visible: bool,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A root document and a version with differing owners
|
||||
WHEN:
|
||||
- The permitted document ids are resolved for the user
|
||||
THEN:
|
||||
- The version is visible 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])
|
||||
|
||||
visible = set(permitted_document_ids(user))
|
||||
|
||||
assert (version.pk in visible) is expected_visible
|
||||
assert (root.pk in visible) is expected_visible
|
||||
|
||||
@staticmethod
|
||||
def grantee(user: User, kind: str) -> User | Group:
|
||||
"""The user itself, or a new group the user belongs to."""
|
||||
if kind == "user":
|
||||
return user
|
||||
group = Group.objects.create(name="shared")
|
||||
user.groups.add(group)
|
||||
return group
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"grantee_kind",
|
||||
[pytest.param("user", id="user"), pytest.param("group", id="group")],
|
||||
)
|
||||
def test_grant_on_root_applies_to_version(self, grantee_kind: str) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A private root document shared with a user or one of their groups
|
||||
- A version of it owned by someone else
|
||||
WHEN:
|
||||
- The permitted document ids are resolved for the user
|
||||
THEN:
|
||||
- Both the root and the version are visible
|
||||
- A user without the grant sees neither
|
||||
"""
|
||||
user = UserFactory()
|
||||
stranger = UserFactory()
|
||||
root = DocumentFactory(owner=UserFactory())
|
||||
version = DocumentFactory(root_document=root, owner=UserFactory())
|
||||
grant_object(self.grantee(user, grantee_kind), root, "view_document")
|
||||
|
||||
assert_visible_document_ids(
|
||||
permitted_document_ids(user),
|
||||
expected_visible=[root.pk, version.pk],
|
||||
expected_hidden=[],
|
||||
)
|
||||
assert_visible_document_ids(
|
||||
permitted_document_ids(stranger),
|
||||
expected_visible=[],
|
||||
expected_hidden=[root.pk, version.pk],
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"grantee_kind",
|
||||
[pytest.param("user", id="user"), pytest.param("group", id="group")],
|
||||
)
|
||||
def test_grant_on_version_is_ignored(self, grantee_kind: str) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A private root document
|
||||
- A version with an explicit grant for the user or one of their groups
|
||||
WHEN:
|
||||
- The permitted document ids are resolved for the user
|
||||
THEN:
|
||||
- Neither the root nor the version is visible
|
||||
"""
|
||||
user = UserFactory()
|
||||
root = DocumentFactory(owner=UserFactory())
|
||||
version = DocumentFactory(root_document=root, owner=UserFactory())
|
||||
grant_object(self.grantee(user, grantee_kind), version, "view_document")
|
||||
|
||||
assert_visible_document_ids(
|
||||
permitted_document_ids(user),
|
||||
expected_visible=[],
|
||||
expected_hidden=[root.pk, version.pk],
|
||||
)
|
||||
|
||||
def test_grant_on_one_root_does_not_reach_another_roots_version(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- Two private roots, each with a version
|
||||
- The user may view only the first root
|
||||
WHEN:
|
||||
- The permitted document ids are resolved for the user
|
||||
THEN:
|
||||
- Only the first root and its version are visible
|
||||
"""
|
||||
user = UserFactory()
|
||||
first = DocumentFactory(owner=UserFactory())
|
||||
first_version = DocumentFactory(root_document=first, owner=UserFactory())
|
||||
second = DocumentFactory(owner=UserFactory())
|
||||
second_version = DocumentFactory(root_document=second, owner=user)
|
||||
grant_object(user, first, "view_document")
|
||||
|
||||
assert_visible_document_ids(
|
||||
permitted_document_ids(user),
|
||||
expected_visible=[first.pk, first_version.pk],
|
||||
expected_hidden=[second.pk, second_version.pk],
|
||||
)
|
||||
|
||||
def test_user_in_several_groups(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A user in two groups
|
||||
- Two private roots shared with one group each, and a third shared with nobody
|
||||
- A version of each root
|
||||
WHEN:
|
||||
- The permitted document ids are resolved for the user
|
||||
THEN:
|
||||
- The two shared roots and their versions are visible
|
||||
- The third root and its version are not
|
||||
"""
|
||||
user = UserFactory()
|
||||
groups = [Group.objects.create(name=f"group{i}") for i in range(2)]
|
||||
user.groups.add(*groups)
|
||||
shared = [DocumentFactory(owner=UserFactory()) for _ in groups]
|
||||
for root, group in zip(shared, groups, strict=True):
|
||||
grant_object(group, root, "view_document")
|
||||
unshared = DocumentFactory(owner=UserFactory())
|
||||
shared_versions = [
|
||||
DocumentFactory(root_document=root, owner=UserFactory()) for root in shared
|
||||
]
|
||||
unshared_version = DocumentFactory(root_document=unshared, owner=None)
|
||||
|
||||
assert_visible_document_ids(
|
||||
permitted_document_ids(user),
|
||||
expected_visible=[
|
||||
*(root.pk for root in shared),
|
||||
*(version.pk for version in shared_versions),
|
||||
],
|
||||
expected_hidden=[unshared.pk, unshared_version.pk],
|
||||
)
|
||||
|
||||
def test_permission_is_resolved_through_the_root(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A private root document where the user may view and change
|
||||
WHEN:
|
||||
- The permitted ids are resolved for view, change and delete
|
||||
THEN:
|
||||
- The version is visible for view and change only
|
||||
"""
|
||||
user = UserFactory()
|
||||
root = DocumentFactory(owner=UserFactory())
|
||||
version = DocumentFactory(root_document=root, owner=UserFactory())
|
||||
grant_object(user, root, "view_document", "change_document")
|
||||
|
||||
assert version.pk in set(permitted_document_ids(user))
|
||||
assert version.pk in set(permitted_document_ids(user, perm="change_document"))
|
||||
assert version.pk in set(
|
||||
permitted_document_ids(user, perm="documents.change_document"),
|
||||
)
|
||||
assert version.pk not in set(
|
||||
permitted_document_ids(user, perm="delete_document"),
|
||||
)
|
||||
|
||||
def test_anonymous_sees_versions_of_unowned_roots_only(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A version owned by nobody under a private root
|
||||
- A version owned by someone under an unowned root
|
||||
WHEN:
|
||||
- The permitted document ids are resolved for an anonymous user
|
||||
THEN:
|
||||
- Only the version of the unowned root is visible
|
||||
"""
|
||||
private_root = DocumentFactory(owner=UserFactory())
|
||||
private_version = DocumentFactory(root_document=private_root, owner=None)
|
||||
open_root = DocumentFactory(owner=None)
|
||||
open_version = DocumentFactory(root_document=open_root, owner=UserFactory())
|
||||
|
||||
assert_visible_document_ids(
|
||||
permitted_document_ids(AnonymousUser()),
|
||||
expected_visible=[open_root.pk, open_version.pk],
|
||||
expected_hidden=[private_root.pk, private_version.pk],
|
||||
)
|
||||
|
||||
def test_deleted_versions_follow_their_deleted_root(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A soft-deleted root document and its version, which deleting the
|
||||
root soft-deletes too; the version is owned by someone else
|
||||
WHEN:
|
||||
- The permitted document ids are resolved with and without deleted
|
||||
documents
|
||||
THEN:
|
||||
- Nothing is visible by default
|
||||
- With deleted documents included, the version is visible to the
|
||||
root's owner and not to the version's own owner
|
||||
"""
|
||||
owner = UserFactory()
|
||||
version_owner = UserFactory()
|
||||
root = DocumentFactory(owner=owner)
|
||||
version = DocumentFactory(root_document=root, owner=version_owner)
|
||||
root.delete()
|
||||
|
||||
assert not {root.pk, version.pk} & set(permitted_document_ids(owner))
|
||||
assert_visible_document_ids(
|
||||
permitted_document_ids(owner, include_deleted=True),
|
||||
expected_visible=[root.pk, version.pk],
|
||||
expected_hidden=[],
|
||||
)
|
||||
assert_visible_document_ids(
|
||||
permitted_document_ids(version_owner, include_deleted=True),
|
||||
expected_visible=[],
|
||||
expected_hidden=[root.pk, version.pk],
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"is_superuser",
|
||||
[
|
||||
pytest.param(False, id="regular-user"),
|
||||
pytest.param(True, id="superuser"),
|
||||
],
|
||||
)
|
||||
def test_inactive_user_sees_no_versions(self, *, is_superuser: bool) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- An inactive user, possibly a superuser, who owns a root and its version
|
||||
WHEN:
|
||||
- The permitted document ids are resolved for them
|
||||
THEN:
|
||||
- Nothing is visible
|
||||
"""
|
||||
user = UserFactory(is_active=False, is_superuser=is_superuser)
|
||||
root = DocumentFactory(owner=user)
|
||||
version = DocumentFactory(root_document=root, owner=user)
|
||||
|
||||
assert_visible_document_ids(
|
||||
permitted_document_ids(user),
|
||||
expected_visible=[],
|
||||
expected_hidden=[root.pk, version.pk],
|
||||
)
|
||||
|
||||
def test_superuser_sees_all_versions(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A private root owned by someone else, with a version
|
||||
WHEN:
|
||||
- The permitted document ids are resolved for a superuser
|
||||
THEN:
|
||||
- Both the root and the version are visible
|
||||
"""
|
||||
superuser = UserFactory(superuser=True)
|
||||
root = DocumentFactory(owner=UserFactory())
|
||||
version = DocumentFactory(root_document=root, owner=UserFactory())
|
||||
|
||||
assert_visible_document_ids(
|
||||
permitted_document_ids(superuser),
|
||||
expected_visible=[root.pk, version.pk],
|
||||
expected_hidden=[],
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestAiChatAllDocumentsPermissionBoundary:
|
||||
"""
|
||||
|
||||
+17
-26
@@ -174,7 +174,6 @@ from documents.permissions import TrashPermissions
|
||||
from documents.permissions import ViewDocumentsPermissions
|
||||
from documents.permissions import annotate_document_count_by_ids
|
||||
from documents.permissions import annotate_document_count_for_related_queryset
|
||||
from documents.permissions import documents_without_permitted_root
|
||||
from documents.permissions import get_document_count_filter_for_user
|
||||
from documents.permissions import get_objects_for_user_owner_aware
|
||||
from documents.permissions import has_global_statistics_permission
|
||||
@@ -2117,7 +2116,7 @@ class DocumentViewSet(
|
||||
documents = Document.objects.filter(pk__in=document_ids)
|
||||
if (
|
||||
request.user is not None
|
||||
and documents_without_permitted_root(documents, request.user).exists()
|
||||
and documents.exclude(id__in=permitted_document_ids(request.user)).exists()
|
||||
):
|
||||
return HttpResponseForbidden("Insufficient permissions")
|
||||
|
||||
@@ -3009,12 +3008,8 @@ class DocumentOperationPermissionMixin(PassUserMixin, DocumentSelectionMixin):
|
||||
user.has_perm(
|
||||
"documents.change_document",
|
||||
)
|
||||
and not Document.global_objects.filter(
|
||||
pk__in=[doc.pk for doc in root_docs],
|
||||
)
|
||||
.exclude(
|
||||
pk__in=permitted_document_ids(user, perm="change_document"),
|
||||
)
|
||||
and not Document.global_objects.filter(pk__in=documents)
|
||||
.exclude(pk__in=permitted_document_ids(user, perm="change_document"))
|
||||
.exists()
|
||||
)
|
||||
|
||||
@@ -3627,7 +3622,7 @@ class SelectionDataView(DocumentSelectionMixin, GenericAPIView[Any]):
|
||||
documents = Document.objects.filter(pk__in=ids)
|
||||
if (
|
||||
documents.count() != len(ids)
|
||||
or documents_without_permitted_root(documents, request.user).exists()
|
||||
or documents.exclude(id__in=permitted_document_ids(request.user)).exists()
|
||||
):
|
||||
return HttpResponseForbidden("Insufficient permissions")
|
||||
|
||||
@@ -4124,21 +4119,16 @@ class BulkDownloadView(DocumentSelectionMixin, GenericAPIView[Any]):
|
||||
validated_data=serializer.validated_data,
|
||||
)
|
||||
documents = Document.objects.filter(pk__in=ids)
|
||||
versioned_documents = []
|
||||
compression = serializer.validated_data.get("compression")
|
||||
content = serializer.validated_data.get("content")
|
||||
follow_filename_format = serializer.validated_data.get("follow_formatting")
|
||||
|
||||
permitted_ids = set(permitted_document_ids(request.user))
|
||||
for document in documents:
|
||||
root_doc = get_root_document(document)
|
||||
if root_doc.pk not in permitted_ids:
|
||||
return HttpResponseForbidden("Insufficient permissions")
|
||||
versioned_documents.append(
|
||||
get_latest_version_for_root(
|
||||
root_doc,
|
||||
),
|
||||
)
|
||||
if documents.exclude(id__in=permitted_document_ids(request.user)).exists():
|
||||
return HttpResponseForbidden("Insufficient permissions")
|
||||
versioned_documents = [
|
||||
get_latest_version_for_root(get_root_document(document))
|
||||
for document in documents
|
||||
]
|
||||
|
||||
if content == "both":
|
||||
strategy_class = OriginalAndArchiveStrategy
|
||||
@@ -4811,7 +4801,7 @@ class ShareLinkBundleViewSet(PassUserMixin, ModelViewSet[ShareLinkBundle]):
|
||||
)
|
||||
|
||||
denied_id = (
|
||||
documents_without_permitted_root(documents_qs, request.user)
|
||||
documents_qs.exclude(id__in=permitted_document_ids(request.user))
|
||||
.order_by("pk")
|
||||
.values_list("pk", flat=True)
|
||||
.first()
|
||||
@@ -5657,11 +5647,12 @@ class TrashView(ListModelMixin, PassUserMixin):
|
||||
if doc_ids is not None
|
||||
else self.filter_queryset(self.get_queryset()).all()
|
||||
)
|
||||
if documents_without_permitted_root(
|
||||
docs,
|
||||
request.user,
|
||||
perm="delete_document",
|
||||
include_deleted=True,
|
||||
if docs.exclude(
|
||||
id__in=permitted_document_ids(
|
||||
request.user,
|
||||
perm="delete_document",
|
||||
include_deleted=True,
|
||||
),
|
||||
).exists():
|
||||
return HttpResponseForbidden("Insufficient permissions")
|
||||
action = serializer.validated_data.get("action")
|
||||
|
||||
@@ -2,7 +2,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: paperless-ngx\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-10-08 18:39+0000\n"
|
||||
"POT-Creation-Date: 2026-10-10 22:04+0000\n"
|
||||
"PO-Revision-Date: 2022-02-17 04:17\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: English\n"
|
||||
@@ -1653,7 +1653,7 @@ msgid "workflow runs"
|
||||
msgstr ""
|
||||
|
||||
#: documents/serialisers.py:516 documents/serialisers.py:873
|
||||
#: documents/serialisers.py:2903 documents/views.py:344 documents/views.py:2751
|
||||
#: documents/serialisers.py:2903 documents/views.py:343 documents/views.py:2750
|
||||
#: paperless_mail/serialisers.py:156
|
||||
msgid "Insufficient permissions."
|
||||
msgstr ""
|
||||
@@ -1694,7 +1694,7 @@ msgstr ""
|
||||
msgid "Duplicate document identifiers are not allowed."
|
||||
msgstr ""
|
||||
|
||||
#: documents/serialisers.py:2989 documents/views.py:4807
|
||||
#: documents/serialisers.py:2989 documents/views.py:4797
|
||||
#, python-format
|
||||
msgid "Documents not found: %(ids)s"
|
||||
msgstr ""
|
||||
@@ -1945,40 +1945,40 @@ msgstr ""
|
||||
msgid ", "
|
||||
msgstr ""
|
||||
|
||||
#: documents/views.py:337 documents/views.py:2748
|
||||
#: documents/views.py:336 documents/views.py:2747
|
||||
msgid "Invalid more_like_id"
|
||||
msgstr ""
|
||||
|
||||
#: documents/views.py:1683
|
||||
#: documents/views.py:1682
|
||||
msgid "Invalid AI configuration."
|
||||
msgstr ""
|
||||
|
||||
#: documents/views.py:1694
|
||||
#: documents/views.py:1693
|
||||
msgid "AI backend request timed out."
|
||||
msgstr ""
|
||||
|
||||
#: documents/views.py:1706
|
||||
#: documents/views.py:1705
|
||||
msgid "AI backend rejected the request. Check logs for details."
|
||||
msgstr ""
|
||||
|
||||
#: documents/views.py:2573 documents/views.py:2889
|
||||
#: documents/views.py:2572 documents/views.py:2888
|
||||
msgid "Specify only one of text, title_search, query, or more_like_id."
|
||||
msgstr ""
|
||||
|
||||
#: documents/views.py:4823
|
||||
#: documents/views.py:4813
|
||||
#, python-format
|
||||
msgid "Insufficient permissions to share document %(id)s."
|
||||
msgstr ""
|
||||
|
||||
#: documents/views.py:4870
|
||||
#: documents/views.py:4860
|
||||
msgid "Bundle is already being processed."
|
||||
msgstr ""
|
||||
|
||||
#: documents/views.py:4934
|
||||
#: documents/views.py:4924
|
||||
msgid "The share link bundle is still being prepared. Please try again later."
|
||||
msgstr ""
|
||||
|
||||
#: documents/views.py:4948
|
||||
#: documents/views.py:4938
|
||||
msgid "The share link bundle is unavailable."
|
||||
msgstr ""
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ from urllib.parse import quote
|
||||
from allauth.account.adapter import DefaultAccountAdapter
|
||||
from allauth.core import context
|
||||
from allauth.headless.tokens.strategies.sessions import SessionTokenStrategy
|
||||
from allauth.mfa.stages import AuthenticateStage
|
||||
from allauth.socialaccount.adapter import DefaultSocialAccountAdapter
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import Group
|
||||
@@ -20,34 +19,7 @@ from paperless.signals import handle_social_account_updated
|
||||
logger = logging.getLogger("paperless.auth")
|
||||
|
||||
|
||||
class SocialAccountAuthenticateStage(AuthenticateStage):
|
||||
"""
|
||||
Skips the 2FA prompt for a social login if the ID token's `amr` claim shows the
|
||||
identity provider already used one of the SOCIAL_ACCOUNT_MFA_TRUSTED_AMR methods.
|
||||
"""
|
||||
|
||||
def _should_handle(self, request: HttpRequest) -> bool:
|
||||
sociallogin = (self.login.signal_kwargs or {}).get("sociallogin")
|
||||
if sociallogin is not None and settings.SOCIAL_ACCOUNT_MFA_TRUSTED_AMR:
|
||||
id_token = (sociallogin.account.extra_data or {}).get("id_token") or {}
|
||||
amr = id_token.get("amr") or []
|
||||
if set(amr) & set(settings.SOCIAL_ACCOUNT_MFA_TRUSTED_AMR):
|
||||
logger.debug(
|
||||
f"Skipping 2FA for `{self.login.user}`, provider `{sociallogin.account.provider}` reported amr {amr}",
|
||||
)
|
||||
return False
|
||||
return super()._should_handle(request)
|
||||
|
||||
|
||||
class CustomAccountAdapter(DefaultAccountAdapter):
|
||||
def get_login_stages(self) -> list[str]:
|
||||
return [
|
||||
"paperless.adapter.SocialAccountAuthenticateStage"
|
||||
if stage == "allauth.mfa.stages.AuthenticateStage"
|
||||
else stage
|
||||
for stage in super().get_login_stages()
|
||||
]
|
||||
|
||||
def is_open_for_signup(self, request):
|
||||
"""
|
||||
Check whether the site is open for signups, which can be
|
||||
|
||||
+10
-11
@@ -272,27 +272,26 @@ def check_deprecated_db_settings(
|
||||
Detects legacy advanced options that should be migrated to
|
||||
PAPERLESS_DB_OPTIONS. Returns one Warning per deprecated variable found.
|
||||
"""
|
||||
deprecated_vars: dict[str, str] = {
|
||||
"PAPERLESS_DB_TIMEOUT": "timeout",
|
||||
"PAPERLESS_DB_POOLSIZE": "pool.min_size / pool.max_size",
|
||||
"PAPERLESS_DBSSLMODE": "sslmode",
|
||||
"PAPERLESS_DBSSLROOTCERT": "sslrootcert",
|
||||
"PAPERLESS_DBSSLCERT": "sslcert",
|
||||
"PAPERLESS_DBSSLKEY": "sslkey",
|
||||
}
|
||||
deprecated_vars = (
|
||||
"PAPERLESS_DB_TIMEOUT",
|
||||
"PAPERLESS_DB_POOLSIZE",
|
||||
"PAPERLESS_DBSSLMODE",
|
||||
"PAPERLESS_DBSSLROOTCERT",
|
||||
"PAPERLESS_DBSSLCERT",
|
||||
"PAPERLESS_DBSSLKEY",
|
||||
)
|
||||
|
||||
warnings: list[Warning] = []
|
||||
|
||||
for var_name, db_option_key in deprecated_vars.items():
|
||||
for var_name in deprecated_vars:
|
||||
if not os.getenv(var_name):
|
||||
continue
|
||||
warnings.append(
|
||||
Warning(
|
||||
f"Deprecated environment variable: {var_name}",
|
||||
hint=(
|
||||
f"{var_name} is no longer supported and will be removed in v3.2. "
|
||||
f"{var_name} is deprecated. "
|
||||
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."
|
||||
),
|
||||
id="paperless.W001",
|
||||
|
||||
@@ -348,11 +348,6 @@ SOCIAL_ACCOUNT_SYNC_SUPERUSER_GROUP: Final[str | None] = os.getenv(
|
||||
SOCIAL_ACCOUNT_SYNC_STAFF_GROUP: Final[str | None] = os.getenv(
|
||||
"PAPERLESS_SOCIAL_ACCOUNT_SYNC_STAFF_GROUP",
|
||||
)
|
||||
SOCIAL_ACCOUNT_MFA_TRUSTED_AMR = [
|
||||
value.strip()
|
||||
for value in os.getenv("PAPERLESS_SOCIAL_ACCOUNT_MFA_TRUSTED_AMR", "mfa").split(",")
|
||||
if value.strip()
|
||||
]
|
||||
|
||||
HEADLESS_TOKEN_STRATEGY = "paperless.adapter.DrfTokenStrategy"
|
||||
|
||||
|
||||
@@ -2,26 +2,19 @@ import logging
|
||||
|
||||
import pytest
|
||||
from allauth.account.adapter import get_adapter
|
||||
from allauth.account.models import Login
|
||||
from allauth.core import context
|
||||
from allauth.mfa.totp.internal.auth import TOTP
|
||||
from allauth.mfa.totp.internal.auth import generate_totp_secret
|
||||
from allauth.socialaccount.adapter import get_adapter as get_social_adapter
|
||||
from allauth.socialaccount.models import SocialAccount
|
||||
from allauth.socialaccount.models import SocialLogin
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.contrib.auth.models import Group
|
||||
from django.contrib.auth.models import User
|
||||
from django.forms import ValidationError
|
||||
from django.http import HttpRequest
|
||||
from django.test import RequestFactory
|
||||
from django.urls import reverse
|
||||
from pytest_django.fixtures import Settings
|
||||
from pytest_mock import MockerFixture
|
||||
from rest_framework.authtoken.models import Token
|
||||
|
||||
from paperless.adapter import DrfTokenStrategy
|
||||
from paperless.adapter import SocialAccountAuthenticateStage
|
||||
from paperless_testing.factories import UserFactory
|
||||
|
||||
|
||||
@@ -136,89 +129,6 @@ class TestCustomAccountAdapter:
|
||||
assert not user2.is_superuser
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestSocialAccountAuthenticateStage:
|
||||
@staticmethod
|
||||
def _should_handle(user: User, extra_data: dict | None) -> bool:
|
||||
signal_kwargs = None
|
||||
if extra_data is not None:
|
||||
account = SocialAccount(
|
||||
provider="openid_connect",
|
||||
uid="uid",
|
||||
extra_data=extra_data,
|
||||
)
|
||||
signal_kwargs = {"sociallogin": SocialLogin(user=user, account=account)}
|
||||
request = RequestFactory().get("/")
|
||||
request.session = {}
|
||||
stage = SocialAccountAuthenticateStage(
|
||||
None,
|
||||
request,
|
||||
Login(user=user, signal_kwargs=signal_kwargs),
|
||||
)
|
||||
return stage._should_handle(request)
|
||||
|
||||
def test_stage_replaces_allauth_stage(self) -> None:
|
||||
stages = get_adapter().get_login_stages()
|
||||
|
||||
assert "paperless.adapter.SocialAccountAuthenticateStage" in stages
|
||||
assert "allauth.mfa.stages.AuthenticateStage" not in stages
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("trusted_amr", "extra_data", "expected"),
|
||||
[
|
||||
pytest.param(
|
||||
["mfa"],
|
||||
{"id_token": {"amr": ["pwd", "mfa"]}},
|
||||
False,
|
||||
id="trusted-amr",
|
||||
),
|
||||
pytest.param(
|
||||
["phr"],
|
||||
{"id_token": {"amr": ["otp"]}},
|
||||
True,
|
||||
id="untrusted-amr",
|
||||
),
|
||||
pytest.param([], {"id_token": {"amr": ["mfa"]}}, True, id="setting-unset"),
|
||||
pytest.param(["mfa"], {"id_token": {}}, True, id="no-amr-claim"),
|
||||
pytest.param(
|
||||
["mfa"],
|
||||
{"userinfo": {"amr": ["mfa"]}},
|
||||
True,
|
||||
id="userinfo-only",
|
||||
),
|
||||
pytest.param(["mfa"], None, True, id="regular-login"),
|
||||
],
|
||||
)
|
||||
def test_mfa_skipped_only_for_trusted_amr(
|
||||
self,
|
||||
settings: Settings,
|
||||
trusted_amr: list[str],
|
||||
extra_data: dict | None,
|
||||
*,
|
||||
expected: bool,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A user with TOTP enabled
|
||||
WHEN:
|
||||
- The user logs in, via a social account or not
|
||||
THEN:
|
||||
- The 2FA prompt is only skipped if the ID token's amr claim contains
|
||||
one of SOCIAL_ACCOUNT_MFA_TRUSTED_AMR
|
||||
"""
|
||||
settings.SOCIAL_ACCOUNT_MFA_TRUSTED_AMR = trusted_amr
|
||||
user = UserFactory(username="testuser")
|
||||
TOTP.activate(user, generate_totp_secret())
|
||||
|
||||
assert self._should_handle(user, extra_data) is expected
|
||||
|
||||
def test_no_mfa_for_user_without_authenticator(self, settings: Settings) -> None:
|
||||
settings.SOCIAL_ACCOUNT_MFA_TRUSTED_AMR = ["mfa"]
|
||||
user = UserFactory(username="testuser")
|
||||
|
||||
assert not self._should_handle(user, {"id_token": {"amr": ["pwd"]}})
|
||||
|
||||
|
||||
class TestCustomSocialAccountAdapter:
|
||||
@pytest.mark.django_db
|
||||
def test_is_open_for_signup(self, settings: Settings) -> None:
|
||||
|
||||
@@ -211,14 +211,14 @@ class TestAuditLogChecks:
|
||||
assert "auditlog table was found but audit log is disabled." in msgs[0].msg
|
||||
|
||||
|
||||
DEPRECATED_VARS: dict[str, str] = {
|
||||
"PAPERLESS_DB_TIMEOUT": "timeout",
|
||||
"PAPERLESS_DB_POOLSIZE": "pool.min_size / pool.max_size",
|
||||
"PAPERLESS_DBSSLMODE": "sslmode",
|
||||
"PAPERLESS_DBSSLROOTCERT": "sslrootcert",
|
||||
"PAPERLESS_DBSSLCERT": "sslcert",
|
||||
"PAPERLESS_DBSSLKEY": "sslkey",
|
||||
}
|
||||
DEPRECATED_VARS = (
|
||||
"PAPERLESS_DB_TIMEOUT",
|
||||
"PAPERLESS_DB_POOLSIZE",
|
||||
"PAPERLESS_DBSSLMODE",
|
||||
"PAPERLESS_DBSSLROOTCERT",
|
||||
"PAPERLESS_DBSSLCERT",
|
||||
"PAPERLESS_DBSSLKEY",
|
||||
)
|
||||
|
||||
|
||||
class TestDeprecatedDbSettings:
|
||||
@@ -234,26 +234,11 @@ class TestDeprecatedDbSettings:
|
||||
result = check_deprecated_db_settings(None)
|
||||
assert result == []
|
||||
|
||||
@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"),
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize("env_var", DEPRECATED_VARS)
|
||||
def test_single_deprecated_var_produces_one_warning(
|
||||
self,
|
||||
mocker: MockerFixture,
|
||||
env_var: str,
|
||||
db_option_key: str,
|
||||
) -> None:
|
||||
"""Each deprecated var in isolation produces exactly one warning."""
|
||||
mocker.patch.dict(os.environ, {env_var: "some_value"}, clear=True)
|
||||
@@ -264,7 +249,8 @@ class TestDeprecatedDbSettings:
|
||||
assert isinstance(warning, Warning)
|
||||
assert warning.id == "paperless.W001"
|
||||
assert env_var in warning.hint
|
||||
assert db_option_key in warning.hint
|
||||
assert "PAPERLESS_DB_OPTIONS" in warning.hint
|
||||
assert "https://docs.paperless-ngx.com/migration-v3/" in warning.hint
|
||||
|
||||
def test_multiple_deprecated_vars_produce_one_warning_each(
|
||||
self,
|
||||
|
||||
@@ -4,8 +4,7 @@ from django.conf import settings
|
||||
from django.contrib.auth.models import User
|
||||
|
||||
from documents.models import Document
|
||||
from documents.permissions import permitted_object_ids
|
||||
from documents.permissions import restrict_queryset_to_visible
|
||||
from documents.permissions import permitted_document_ids
|
||||
from documents.permissions import user_is_unrestricted
|
||||
from paperless.config import AIConfig
|
||||
from paperless_ai.base_model import ClassificationSuggestions
|
||||
@@ -54,8 +53,8 @@ def _fulltext_similar_documents(
|
||||
active superuser - see user_is_unrestricted) is normalized to ``None``
|
||||
before calling, since the backend's permission filter has no superuser
|
||||
short-circuit of its own. Results are re-checked with
|
||||
restrict_queryset_to_visible() since Tantivy's indexed permission fields
|
||||
lag the DB via async reindexing.
|
||||
permitted_document_ids() since Tantivy's indexed permission fields lag the
|
||||
DB via async reindexing and judge a version by its own owner.
|
||||
"""
|
||||
from documents.search import get_backend
|
||||
|
||||
@@ -69,10 +68,9 @@ def _fulltext_similar_documents(
|
||||
)
|
||||
if not unrestricted:
|
||||
allowed_ids = set(
|
||||
restrict_queryset_to_visible(
|
||||
Document.objects.filter(pk__in=similar_ids),
|
||||
user,
|
||||
"view_document",
|
||||
Document.objects.filter(
|
||||
pk__in=similar_ids,
|
||||
id__in=permitted_document_ids(user),
|
||||
).values_list("pk", flat=True),
|
||||
)
|
||||
similar_ids = [doc_id for doc_id in similar_ids if doc_id in allowed_ids]
|
||||
@@ -200,13 +198,13 @@ def get_taxonomy_context(
|
||||
# quadratic scan in the vector store at best, and past ~32,763
|
||||
# documents a hard sqlite3.OperationalError (SQLite's
|
||||
# bound-parameter limit) at worst.
|
||||
# permitted_object_ids() has its own superuser shortcut that would
|
||||
# permitted_document_ids() has its own superuser shortcut that would
|
||||
# return every Document's id anyway, so this changes nothing about
|
||||
# which documents are considered -- only how we get there.
|
||||
visible_document_ids = (
|
||||
None
|
||||
if user_is_unrestricted(user)
|
||||
else list(permitted_object_ids(user, Document, "view_document"))
|
||||
else list(permitted_document_ids(user))
|
||||
)
|
||||
nodes = retrieve_similar_nodes(
|
||||
document,
|
||||
|
||||
@@ -552,7 +552,7 @@ class TestGetTaxonomyContextVisibility:
|
||||
return_value=[],
|
||||
)
|
||||
mock_permitted = mocker.patch(
|
||||
"paperless_ai.ai_classifier.permitted_object_ids",
|
||||
"paperless_ai.ai_classifier.permitted_document_ids",
|
||||
)
|
||||
user = UserFactory.create(is_superuser=True)
|
||||
|
||||
@@ -582,7 +582,7 @@ class TestGetTaxonomyContextVisibility:
|
||||
return_value=[],
|
||||
)
|
||||
mock_permitted = mocker.patch(
|
||||
"paperless_ai.ai_classifier.permitted_object_ids",
|
||||
"paperless_ai.ai_classifier.permitted_document_ids",
|
||||
)
|
||||
|
||||
get_taxonomy_context(document, None)
|
||||
@@ -611,16 +611,55 @@ class TestGetTaxonomyContextVisibility:
|
||||
return_value=[],
|
||||
)
|
||||
mock_permitted = mocker.patch(
|
||||
"paperless_ai.ai_classifier.permitted_object_ids",
|
||||
"paperless_ai.ai_classifier.permitted_document_ids",
|
||||
return_value=[1, 2, 3],
|
||||
)
|
||||
user = UserFactory.create(is_superuser=False)
|
||||
|
||||
get_taxonomy_context(document, user)
|
||||
|
||||
mock_permitted.assert_called_once_with(user, Document, "view_document")
|
||||
mock_permitted.assert_called_once_with(user)
|
||||
assert mock_retrieve.call_args.kwargs["document_ids"] == [1, 2, 3]
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(LLM_EMBEDDING_BACKEND="huggingface")
|
||||
def test_version_of_private_root_is_not_visible(
|
||||
self,
|
||||
mocker: pytest_mock.MockerFixture,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A private root document owned by someone else
|
||||
- A version of it whose own owner is unset, as when the root
|
||||
changed hands after the version was created
|
||||
WHEN:
|
||||
- get_taxonomy_context() is called for a non-superuser
|
||||
THEN:
|
||||
- Neither the root nor the version is in the visible ids passed to
|
||||
retrieve_similar_nodes(), since a version follows its root
|
||||
"""
|
||||
owner = UserFactory.create()
|
||||
viewer = UserFactory.create(is_superuser=False)
|
||||
root = DocumentFactory.create(content="private", owner=owner)
|
||||
version = DocumentFactory.create(
|
||||
content="private",
|
||||
owner=None,
|
||||
root_document=root,
|
||||
version_index=1,
|
||||
)
|
||||
source = DocumentFactory.create(content="Some content", owner=viewer)
|
||||
mock_retrieve = mocker.patch(
|
||||
"paperless_ai.ai_classifier.retrieve_similar_nodes",
|
||||
return_value=[],
|
||||
)
|
||||
|
||||
get_taxonomy_context(source, viewer)
|
||||
|
||||
visible = mock_retrieve.call_args.kwargs["document_ids"]
|
||||
assert source.pk in visible
|
||||
assert root.pk not in visible
|
||||
assert version.pk not in visible
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestFulltextSimilarDocuments:
|
||||
@@ -803,7 +842,7 @@ class TestFulltextSimilarDocuments:
|
||||
- _fulltext_similar_documents() is called with that user
|
||||
THEN:
|
||||
- Only the still-permitted document is returned - the DB
|
||||
re-check via restrict_queryset_to_visible() must catch the
|
||||
re-check via permitted_document_ids() must catch the
|
||||
document Tantivy's stale index still thinks is visible
|
||||
"""
|
||||
owner = UserFactory.create()
|
||||
@@ -834,6 +873,45 @@ class TestFulltextSimilarDocuments:
|
||||
|
||||
assert [s["document_id"] for s in result] == [permitted.pk]
|
||||
|
||||
def test_excludes_version_of_private_root_for_regular_user(
|
||||
self,
|
||||
fulltext_backend: TantivyBackend,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A regular user and a private root owned by someone else
|
||||
- A version of that root with no owner of its own, which the
|
||||
Tantivy index therefore treats as visible to everyone
|
||||
WHEN:
|
||||
- _fulltext_similar_documents() is called with that user
|
||||
THEN:
|
||||
- The version is not returned, since the DB re-check judges it by
|
||||
its root
|
||||
"""
|
||||
owner = UserFactory.create()
|
||||
viewer = UserFactory.create(is_superuser=False)
|
||||
source = DocumentFactory.create(
|
||||
content="shared content phrase",
|
||||
owner=viewer,
|
||||
)
|
||||
root = DocumentFactory.create(
|
||||
content="shared content phrase",
|
||||
owner=owner,
|
||||
)
|
||||
version = DocumentFactory.create(
|
||||
content="shared content phrase",
|
||||
owner=None,
|
||||
root_document=root,
|
||||
version_index=1,
|
||||
)
|
||||
fulltext_backend.add_or_update(source)
|
||||
fulltext_backend.add_or_update(root)
|
||||
fulltext_backend.add_or_update(version)
|
||||
|
||||
result = _fulltext_similar_documents(source, user=viewer, top_k=5)
|
||||
|
||||
assert version.pk not in [s["document_id"] for s in result]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(LLM_EMBEDDING_BACKEND="huggingface")
|
||||
|
||||
Reference in new issue
Block a user