mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-10-11 18:47:13 +00:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a0270230d | ||
|
|
39a2cd5d20 | ||
|
|
c60e5a6ce7 | ||
|
|
5a3fd7ef96 | ||
|
|
50730ddc6c | ||
|
|
27ba1b3110 | ||
|
|
937bf2fa7a | ||
|
|
2f2381578c | ||
|
|
e606dbffcd |
No files matched your search
@@ -26,7 +26,7 @@ module.exports = {
|
|||||||
'abstract-paperless-service',
|
'abstract-paperless-service',
|
||||||
],
|
],
|
||||||
transformIgnorePatterns: [
|
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: {
|
moduleNameMapper: {
|
||||||
...esmPreset.moduleNameMapper,
|
...esmPreset.moduleNameMapper,
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
"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",
|
||||||
|
|||||||
Generated
+10
@@ -53,6 +53,9 @@ 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
|
||||||
@@ -3226,6 +3229,11 @@ 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==}
|
||||||
|
|
||||||
@@ -7409,6 +7417,8 @@ 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: {}
|
||||||
|
|||||||
@@ -5,14 +5,16 @@
|
|||||||
</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 font-monospace small">
|
<div class="chat-messages small">
|
||||||
@for (message of messages(); track message) {
|
@for (message of messages(); track message) {
|
||||||
<div class="message d-flex flex-row small" [class.justify-content-end]="message.role === 'user'">
|
<div class="message d-flex flex-row small" [class.justify-content-end]="message.role === 'user'">
|
||||||
<div class="p-2 m-2" [class.bg-body]="message.role === 'user'">
|
<div class="p-2 m-2" [class.bg-body]="message.role === 'user'">
|
||||||
<span class="text-break">
|
@if (message.role === 'assistant') {
|
||||||
{{ message.content }}
|
<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> }
|
||||||
</span>
|
} @else {
|
||||||
|
<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">
|
||||||
@for (reference of message.references; track reference.id) {
|
@for (reference of message.references; track reference.id) {
|
||||||
|
|||||||
@@ -8,10 +8,6 @@
|
|||||||
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;
|
||||||
}
|
}
|
||||||
@@ -40,3 +36,19 @@
|
|||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -207,4 +207,18 @@ 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,6 +10,7 @@ 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 { MarkdownPipe } from 'src/app/pipes/markdown.pipe'
|
||||||
import { filter, map } from 'rxjs'
|
import { filter, map } from 'rxjs'
|
||||||
import {
|
import {
|
||||||
ChatMessage,
|
ChatMessage,
|
||||||
@@ -25,6 +26,7 @@ import {
|
|||||||
RouterModule,
|
RouterModule,
|
||||||
NgxBootstrapIconsModule,
|
NgxBootstrapIconsModule,
|
||||||
NgbDropdownModule,
|
NgbDropdownModule,
|
||||||
|
MarkdownPipe,
|
||||||
],
|
],
|
||||||
templateUrl: './chat.component.html',
|
templateUrl: './chat.component.html',
|
||||||
styleUrl: './chat.component.scss',
|
styleUrl: './chat.component.scss',
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { MarkdownPipe } from './markdown.pipe'
|
||||||
|
|
||||||
|
describe('MarkdownPipe', () => {
|
||||||
|
const pipe = new MarkdownPipe()
|
||||||
|
|
||||||
|
it('should return empty string for empty input', () => {
|
||||||
|
expect(pipe.transform(null)).toEqual('')
|
||||||
|
expect(pipe.transform(undefined)).toEqual('')
|
||||||
|
expect(pipe.transform('')).toEqual('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render basic markdown', () => {
|
||||||
|
const html = pipe.transform(
|
||||||
|
'**bold** _em_ `code`\n\n- one\n- two\n\n| a | b |\n|---|---|\n| 1 | 2 |'
|
||||||
|
)
|
||||||
|
expect(html).toContain('<strong>bold</strong>')
|
||||||
|
expect(html).toContain('<em>em</em>')
|
||||||
|
expect(html).toContain('<code>code</code>')
|
||||||
|
expect(html).toContain('<li>one</li>')
|
||||||
|
expect(html).toContain('<table>')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should escape raw html', () => {
|
||||||
|
const html = pipe.transform(
|
||||||
|
'hi <img src=x onerror="alert(1)"> <b>x</b>\n\n<script>alert(1)</script>'
|
||||||
|
)
|
||||||
|
expect(html).not.toContain('<img')
|
||||||
|
expect(html).not.toContain('<script')
|
||||||
|
expect(html).not.toContain('<b>')
|
||||||
|
expect(html).toContain('<script>')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not render images', () => {
|
||||||
|
const html = pipe.transform('')
|
||||||
|
expect(html).not.toContain('<img')
|
||||||
|
expect(html).not.toContain('evil.example')
|
||||||
|
expect(html).toContain('secret')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render safe links with target and rel', () => {
|
||||||
|
const html = pipe.transform('[docs](https://docs.paperless-ngx.com "Docs")')
|
||||||
|
expect(html).toContain(
|
||||||
|
'<a href="https://docs.paperless-ngx.com/" title="Docs" target="_blank" rel="noopener noreferrer nofollow">docs</a>'
|
||||||
|
)
|
||||||
|
expect(pipe.transform('[mail](mailto:a@b.c)')).toContain(
|
||||||
|
'href="mailto:a@b.c"'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should drop unsafe or relative links but keep their text', () => {
|
||||||
|
for (const href of [
|
||||||
|
'javascript:alert(1)',
|
||||||
|
'JaVaScRiPt:alert(1)',
|
||||||
|
'data:text/html,<script>alert(1)</script>',
|
||||||
|
'vbscript:msgbox',
|
||||||
|
'/api/documents/',
|
||||||
|
]) {
|
||||||
|
const html = pipe.transform(`[click](${href})`)
|
||||||
|
expect(html).not.toContain('<a')
|
||||||
|
expect(html).toContain('click')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should escape link titles', () => {
|
||||||
|
const html = pipe.transform(
|
||||||
|
'[x](https://a.example "a\\" onmouseover=\\"1")'
|
||||||
|
)
|
||||||
|
expect(html).not.toMatch(/"\s*onmouseover=/)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { Pipe, PipeTransform } from '@angular/core'
|
||||||
|
import { Marked, Renderer, Tokens } from 'marked'
|
||||||
|
|
||||||
|
const ALLOWED_LINK_PROTOCOLS = ['http:', 'https:', 'mailto:']
|
||||||
|
|
||||||
|
function escapeHtml(text: string): string {
|
||||||
|
return text
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''')
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeHref(href: string): string | null {
|
||||||
|
try {
|
||||||
|
const url = new URL(href)
|
||||||
|
return ALLOWED_LINK_PROTOCOLS.includes(url.protocol) ? url.href : null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Treat chat content as untrusted: no raw HTML, no images, and only
|
||||||
|
// absolute links. Angular sanitizer runs on top of this as well.
|
||||||
|
const renderer: Partial<Renderer> = {
|
||||||
|
html({ text }: Tokens.HTML | Tokens.Tag): string {
|
||||||
|
return escapeHtml(text)
|
||||||
|
},
|
||||||
|
image({ text }: Tokens.Image): string {
|
||||||
|
return escapeHtml(text)
|
||||||
|
},
|
||||||
|
link(this: Renderer, { href, title, tokens }: Tokens.Link): string {
|
||||||
|
const text = this.parser.parseInline(tokens)
|
||||||
|
const url = safeHref(href)
|
||||||
|
if (!url) return text
|
||||||
|
const titleAttr = title ? ` title="${escapeHtml(title)}"` : ''
|
||||||
|
return `<a href="${escapeHtml(url)}"${titleAttr} target="_blank" rel="noopener noreferrer nofollow">${text}</a>`
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const markdown = new Marked({
|
||||||
|
async: false,
|
||||||
|
gfm: true,
|
||||||
|
breaks: true,
|
||||||
|
renderer,
|
||||||
|
})
|
||||||
|
|
||||||
|
@Pipe({
|
||||||
|
name: 'markdown',
|
||||||
|
})
|
||||||
|
export class MarkdownPipe implements PipeTransform {
|
||||||
|
transform(value: string | null | undefined): string {
|
||||||
|
if (!value) return ''
|
||||||
|
return markdown.parse(value) as string
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,9 @@ from django.contrib.auth.models import Permission
|
|||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.db.models import Case
|
from django.db.models import Case
|
||||||
|
from django.db.models import CharField
|
||||||
from django.db.models import Count
|
from django.db.models import Count
|
||||||
|
from django.db.models import F
|
||||||
from django.db.models import IntegerField
|
from django.db.models import IntegerField
|
||||||
from django.db.models import Model
|
from django.db.models import Model
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
@@ -349,6 +351,7 @@ def permitted_object_ids(
|
|||||||
perm: str,
|
perm: str,
|
||||||
*,
|
*,
|
||||||
include_deleted: bool = False,
|
include_deleted: bool = False,
|
||||||
|
parent_field: str | None = None,
|
||||||
) -> QuerySet[int]:
|
) -> QuerySet[int]:
|
||||||
"""
|
"""
|
||||||
Generic version of ``permitted_document_ids`` for any model with an
|
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
|
soft-delete pattern (currently only ``Document``); for every other model
|
||||||
it is accepted but has no effect, since those models have no soft-delete
|
it is accepted but has no effect, since those models have no soft-delete
|
||||||
concept.
|
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")
|
has_soft_delete = hasattr(model, "global_objects")
|
||||||
manager = (
|
manager = (
|
||||||
@@ -364,8 +385,21 @@ def permitted_object_ids(
|
|||||||
)
|
)
|
||||||
base_qs = manager.all().only("id", "owner")
|
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):
|
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
|
# Deactivated users get nothing, deactivated superusers included, so this
|
||||||
# has to come before the superuser shortcut. guardian's
|
# has to come before the superuser shortcut. guardian's
|
||||||
@@ -389,21 +423,26 @@ def permitted_object_ids(
|
|||||||
"permission__content_type": content_type,
|
"permission__content_type": content_type,
|
||||||
}
|
}
|
||||||
|
|
||||||
user_perm_ids = (
|
# Both grant sets are compared to the row key as strings, exactly as
|
||||||
UserObjectPermission.objects.filter(user=user, **perm_filter)
|
# guardian stores them, and are uncorrelated, so each engine can build the
|
||||||
.annotate(object_pk_int=Cast("object_pk", IntegerField()))
|
# set once instead of probing per row.
|
||||||
.values_list("object_pk_int", flat=True)
|
user_keys = UserObjectPermission.objects.filter(
|
||||||
)
|
user=user,
|
||||||
group_perm_ids = (
|
**perm_filter,
|
||||||
GroupObjectPermission.objects.filter(group__user=user, **perm_filter)
|
).values_list("object_pk", flat=True)
|
||||||
.annotate(object_pk_int=Cast("object_pk", IntegerField()))
|
group_keys = GroupObjectPermission.objects.filter(
|
||||||
.values_list("object_pk_int", flat=True)
|
group_id__in=user.groups.values("id"),
|
||||||
)
|
**perm_filter,
|
||||||
permitted_ids = user_perm_ids.union(group_perm_ids)
|
).values_list("object_pk", flat=True)
|
||||||
|
permitted_keys = user_keys.union(group_keys, all=True)
|
||||||
|
|
||||||
return base_qs.filter(
|
return (
|
||||||
Q(owner=user) | Q(owner__isnull=True) | Q(id__in=permitted_ids),
|
base_qs.annotate(permitted_key=Cast(key_field, CharField(max_length=64)))
|
||||||
).values_list("id", flat=True)
|
.filter(
|
||||||
|
Q(**{owner_field: user.pk}) | unowned | Q(permitted_key__in=permitted_keys),
|
||||||
|
)
|
||||||
|
.values_list("id", flat=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
ModelT = TypeVar("ModelT", bound=Model)
|
ModelT = TypeVar("ModelT", bound=Model)
|
||||||
@@ -471,30 +510,16 @@ def permitted_document_ids(
|
|||||||
``include_deleted=True`` for callers that need to check permission on
|
``include_deleted=True`` for callers that need to check permission on
|
||||||
soft-deleted documents (e.g. trash restore). This intentionally avoids
|
soft-deleted documents (e.g. trash restore). This intentionally avoids
|
||||||
``get_objects_for_user`` to keep the subquery small and index-friendly.
|
``get_objects_for_user`` to keep the subquery small and index-friendly.
|
||||||
"""
|
|
||||||
return permitted_object_ids(user, Document, perm, include_deleted=include_deleted)
|
|
||||||
|
|
||||||
|
A version is authorized by its root document, so a version's own owner and
|
||||||
def documents_without_permitted_root(
|
grants never matter.
|
||||||
documents: QuerySet[Document],
|
|
||||||
user: User | None,
|
|
||||||
*,
|
|
||||||
perm: str = "view_document",
|
|
||||||
include_deleted: bool = False,
|
|
||||||
) -> QuerySet[Document]:
|
|
||||||
"""
|
"""
|
||||||
The documents the user lacks ``perm`` on. Versions are authorized by their
|
return permitted_object_ids(
|
||||||
root document, so a version's own owner is ignored. A single query, without
|
user,
|
||||||
loading the documents or joining the root.
|
Document,
|
||||||
"""
|
perm,
|
||||||
return documents.annotate(
|
include_deleted=include_deleted,
|
||||||
root_id=Coalesce("root_document_id", "id"),
|
parent_field="root_document",
|
||||||
).exclude(
|
|
||||||
root_id__in=permitted_document_ids(
|
|
||||||
user,
|
|
||||||
perm=perm,
|
|
||||||
include_deleted=include_deleted,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ from paperless_testing.permissions import grant_global
|
|||||||
from paperless_testing.permissions import grant_object
|
from paperless_testing.permissions import grant_object
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
from django.contrib.auth.models import User
|
||||||
|
|
||||||
from paperless_testing.dirs import PaperlessDirs
|
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
|
@pytest.mark.django_db
|
||||||
class TestAiChatAllDocumentsPermissionBoundary:
|
class TestAiChatAllDocumentsPermissionBoundary:
|
||||||
"""
|
"""
|
||||||
|
|||||||
+17
-26
@@ -174,7 +174,6 @@ from documents.permissions import TrashPermissions
|
|||||||
from documents.permissions import ViewDocumentsPermissions
|
from documents.permissions import ViewDocumentsPermissions
|
||||||
from documents.permissions import annotate_document_count_by_ids
|
from documents.permissions import annotate_document_count_by_ids
|
||||||
from documents.permissions import annotate_document_count_for_related_queryset
|
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_document_count_filter_for_user
|
||||||
from documents.permissions import get_objects_for_user_owner_aware
|
from documents.permissions import get_objects_for_user_owner_aware
|
||||||
from documents.permissions import has_global_statistics_permission
|
from documents.permissions import has_global_statistics_permission
|
||||||
@@ -2117,7 +2116,7 @@ class DocumentViewSet(
|
|||||||
documents = Document.objects.filter(pk__in=document_ids)
|
documents = Document.objects.filter(pk__in=document_ids)
|
||||||
if (
|
if (
|
||||||
request.user is not None
|
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")
|
return HttpResponseForbidden("Insufficient permissions")
|
||||||
|
|
||||||
@@ -3009,12 +3008,8 @@ class DocumentOperationPermissionMixin(PassUserMixin, DocumentSelectionMixin):
|
|||||||
user.has_perm(
|
user.has_perm(
|
||||||
"documents.change_document",
|
"documents.change_document",
|
||||||
)
|
)
|
||||||
and not Document.global_objects.filter(
|
and not Document.global_objects.filter(pk__in=documents)
|
||||||
pk__in=[doc.pk for doc in root_docs],
|
.exclude(pk__in=permitted_document_ids(user, perm="change_document"))
|
||||||
)
|
|
||||||
.exclude(
|
|
||||||
pk__in=permitted_document_ids(user, perm="change_document"),
|
|
||||||
)
|
|
||||||
.exists()
|
.exists()
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -3627,7 +3622,7 @@ class SelectionDataView(DocumentSelectionMixin, GenericAPIView[Any]):
|
|||||||
documents = Document.objects.filter(pk__in=ids)
|
documents = Document.objects.filter(pk__in=ids)
|
||||||
if (
|
if (
|
||||||
documents.count() != len(ids)
|
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")
|
return HttpResponseForbidden("Insufficient permissions")
|
||||||
|
|
||||||
@@ -4124,21 +4119,16 @@ class BulkDownloadView(DocumentSelectionMixin, GenericAPIView[Any]):
|
|||||||
validated_data=serializer.validated_data,
|
validated_data=serializer.validated_data,
|
||||||
)
|
)
|
||||||
documents = Document.objects.filter(pk__in=ids)
|
documents = Document.objects.filter(pk__in=ids)
|
||||||
versioned_documents = []
|
|
||||||
compression = serializer.validated_data.get("compression")
|
compression = serializer.validated_data.get("compression")
|
||||||
content = serializer.validated_data.get("content")
|
content = serializer.validated_data.get("content")
|
||||||
follow_filename_format = serializer.validated_data.get("follow_formatting")
|
follow_filename_format = serializer.validated_data.get("follow_formatting")
|
||||||
|
|
||||||
permitted_ids = set(permitted_document_ids(request.user))
|
if documents.exclude(id__in=permitted_document_ids(request.user)).exists():
|
||||||
for document in documents:
|
return HttpResponseForbidden("Insufficient permissions")
|
||||||
root_doc = get_root_document(document)
|
versioned_documents = [
|
||||||
if root_doc.pk not in permitted_ids:
|
get_latest_version_for_root(get_root_document(document))
|
||||||
return HttpResponseForbidden("Insufficient permissions")
|
for document in documents
|
||||||
versioned_documents.append(
|
]
|
||||||
get_latest_version_for_root(
|
|
||||||
root_doc,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
if content == "both":
|
if content == "both":
|
||||||
strategy_class = OriginalAndArchiveStrategy
|
strategy_class = OriginalAndArchiveStrategy
|
||||||
@@ -4811,7 +4801,7 @@ class ShareLinkBundleViewSet(PassUserMixin, ModelViewSet[ShareLinkBundle]):
|
|||||||
)
|
)
|
||||||
|
|
||||||
denied_id = (
|
denied_id = (
|
||||||
documents_without_permitted_root(documents_qs, request.user)
|
documents_qs.exclude(id__in=permitted_document_ids(request.user))
|
||||||
.order_by("pk")
|
.order_by("pk")
|
||||||
.values_list("pk", flat=True)
|
.values_list("pk", flat=True)
|
||||||
.first()
|
.first()
|
||||||
@@ -5657,11 +5647,12 @@ class TrashView(ListModelMixin, PassUserMixin):
|
|||||||
if doc_ids is not None
|
if doc_ids is not None
|
||||||
else self.filter_queryset(self.get_queryset()).all()
|
else self.filter_queryset(self.get_queryset()).all()
|
||||||
)
|
)
|
||||||
if documents_without_permitted_root(
|
if docs.exclude(
|
||||||
docs,
|
id__in=permitted_document_ids(
|
||||||
request.user,
|
request.user,
|
||||||
perm="delete_document",
|
perm="delete_document",
|
||||||
include_deleted=True,
|
include_deleted=True,
|
||||||
|
),
|
||||||
).exists():
|
).exists():
|
||||||
return HttpResponseForbidden("Insufficient permissions")
|
return HttpResponseForbidden("Insufficient permissions")
|
||||||
action = serializer.validated_data.get("action")
|
action = serializer.validated_data.get("action")
|
||||||
|
|||||||
@@ -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-08 18:39+0000\n"
|
"POT-Creation-Date: 2026-10-10 22:04+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:344 documents/views.py:2751
|
#: documents/serialisers.py:2903 documents/views.py:343 documents/views.py:2750
|
||||||
#: 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:4807
|
#: documents/serialisers.py:2989 documents/views.py:4797
|
||||||
#, 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:337 documents/views.py:2748
|
#: documents/views.py:336 documents/views.py:2747
|
||||||
msgid "Invalid more_like_id"
|
msgid "Invalid more_like_id"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:1683
|
#: documents/views.py:1682
|
||||||
msgid "Invalid AI configuration."
|
msgid "Invalid AI configuration."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:1694
|
#: documents/views.py:1693
|
||||||
msgid "AI backend request timed out."
|
msgid "AI backend request timed out."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:1706
|
#: documents/views.py:1705
|
||||||
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: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."
|
msgid "Specify only one of text, title_search, query, or more_like_id."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:4823
|
#: documents/views.py:4813
|
||||||
#, 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:4870
|
#: documents/views.py:4860
|
||||||
msgid "Bundle is already being processed."
|
msgid "Bundle is already being processed."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:4934
|
#: documents/views.py:4924
|
||||||
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:4948
|
#: documents/views.py:4938
|
||||||
msgid "The share link bundle is unavailable."
|
msgid "The share link bundle is unavailable."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
|||||||
+10
-11
@@ -272,27 +272,26 @@ 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: dict[str, str] = {
|
deprecated_vars = (
|
||||||
"PAPERLESS_DB_TIMEOUT": "timeout",
|
"PAPERLESS_DB_TIMEOUT",
|
||||||
"PAPERLESS_DB_POOLSIZE": "pool.min_size / pool.max_size",
|
"PAPERLESS_DB_POOLSIZE",
|
||||||
"PAPERLESS_DBSSLMODE": "sslmode",
|
"PAPERLESS_DBSSLMODE",
|
||||||
"PAPERLESS_DBSSLROOTCERT": "sslrootcert",
|
"PAPERLESS_DBSSLROOTCERT",
|
||||||
"PAPERLESS_DBSSLCERT": "sslcert",
|
"PAPERLESS_DBSSLCERT",
|
||||||
"PAPERLESS_DBSSLKEY": "sslkey",
|
"PAPERLESS_DBSSLKEY",
|
||||||
}
|
)
|
||||||
|
|
||||||
warnings: list[Warning] = []
|
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):
|
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 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"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",
|
||||||
|
|||||||
@@ -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: dict[str, str] = {
|
DEPRECATED_VARS = (
|
||||||
"PAPERLESS_DB_TIMEOUT": "timeout",
|
"PAPERLESS_DB_TIMEOUT",
|
||||||
"PAPERLESS_DB_POOLSIZE": "pool.min_size / pool.max_size",
|
"PAPERLESS_DB_POOLSIZE",
|
||||||
"PAPERLESS_DBSSLMODE": "sslmode",
|
"PAPERLESS_DBSSLMODE",
|
||||||
"PAPERLESS_DBSSLROOTCERT": "sslrootcert",
|
"PAPERLESS_DBSSLROOTCERT",
|
||||||
"PAPERLESS_DBSSLCERT": "sslcert",
|
"PAPERLESS_DBSSLCERT",
|
||||||
"PAPERLESS_DBSSLKEY": "sslkey",
|
"PAPERLESS_DBSSLKEY",
|
||||||
}
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestDeprecatedDbSettings:
|
class TestDeprecatedDbSettings:
|
||||||
@@ -234,26 +234,11 @@ class TestDeprecatedDbSettings:
|
|||||||
result = check_deprecated_db_settings(None)
|
result = check_deprecated_db_settings(None)
|
||||||
assert result == []
|
assert result == []
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize("env_var", DEPRECATED_VARS)
|
||||||
("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)
|
||||||
@@ -264,7 +249,8 @@ 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 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(
|
def test_multiple_deprecated_vars_produce_one_warning_each(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -4,8 +4,7 @@ from django.conf import settings
|
|||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
|
|
||||||
from documents.models import Document
|
from documents.models import Document
|
||||||
from documents.permissions import permitted_object_ids
|
from documents.permissions import permitted_document_ids
|
||||||
from documents.permissions import restrict_queryset_to_visible
|
|
||||||
from documents.permissions import user_is_unrestricted
|
from documents.permissions import user_is_unrestricted
|
||||||
from paperless.config import AIConfig
|
from paperless.config import AIConfig
|
||||||
from paperless_ai.base_model import ClassificationSuggestions
|
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``
|
active superuser - see user_is_unrestricted) is normalized to ``None``
|
||||||
before calling, since the backend's permission filter has no superuser
|
before calling, since the backend's permission filter has no superuser
|
||||||
short-circuit of its own. Results are re-checked with
|
short-circuit of its own. Results are re-checked with
|
||||||
restrict_queryset_to_visible() since Tantivy's indexed permission fields
|
permitted_document_ids() since Tantivy's indexed permission fields lag the
|
||||||
lag the DB via async reindexing.
|
DB via async reindexing and judge a version by its own owner.
|
||||||
"""
|
"""
|
||||||
from documents.search import get_backend
|
from documents.search import get_backend
|
||||||
|
|
||||||
@@ -69,10 +68,9 @@ def _fulltext_similar_documents(
|
|||||||
)
|
)
|
||||||
if not unrestricted:
|
if not unrestricted:
|
||||||
allowed_ids = set(
|
allowed_ids = set(
|
||||||
restrict_queryset_to_visible(
|
Document.objects.filter(
|
||||||
Document.objects.filter(pk__in=similar_ids),
|
pk__in=similar_ids,
|
||||||
user,
|
id__in=permitted_document_ids(user),
|
||||||
"view_document",
|
|
||||||
).values_list("pk", flat=True),
|
).values_list("pk", flat=True),
|
||||||
)
|
)
|
||||||
similar_ids = [doc_id for doc_id in similar_ids if doc_id in allowed_ids]
|
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
|
# quadratic scan in the vector store at best, and past ~32,763
|
||||||
# documents a hard sqlite3.OperationalError (SQLite's
|
# documents a hard sqlite3.OperationalError (SQLite's
|
||||||
# bound-parameter limit) at worst.
|
# 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
|
# return every Document's id anyway, so this changes nothing about
|
||||||
# which documents are considered -- only how we get there.
|
# which documents are considered -- only how we get there.
|
||||||
visible_document_ids = (
|
visible_document_ids = (
|
||||||
None
|
None
|
||||||
if user_is_unrestricted(user)
|
if user_is_unrestricted(user)
|
||||||
else list(permitted_object_ids(user, Document, "view_document"))
|
else list(permitted_document_ids(user))
|
||||||
)
|
)
|
||||||
nodes = retrieve_similar_nodes(
|
nodes = retrieve_similar_nodes(
|
||||||
document,
|
document,
|
||||||
|
|||||||
@@ -552,7 +552,7 @@ class TestGetTaxonomyContextVisibility:
|
|||||||
return_value=[],
|
return_value=[],
|
||||||
)
|
)
|
||||||
mock_permitted = mocker.patch(
|
mock_permitted = mocker.patch(
|
||||||
"paperless_ai.ai_classifier.permitted_object_ids",
|
"paperless_ai.ai_classifier.permitted_document_ids",
|
||||||
)
|
)
|
||||||
user = UserFactory.create(is_superuser=True)
|
user = UserFactory.create(is_superuser=True)
|
||||||
|
|
||||||
@@ -582,7 +582,7 @@ class TestGetTaxonomyContextVisibility:
|
|||||||
return_value=[],
|
return_value=[],
|
||||||
)
|
)
|
||||||
mock_permitted = mocker.patch(
|
mock_permitted = mocker.patch(
|
||||||
"paperless_ai.ai_classifier.permitted_object_ids",
|
"paperless_ai.ai_classifier.permitted_document_ids",
|
||||||
)
|
)
|
||||||
|
|
||||||
get_taxonomy_context(document, None)
|
get_taxonomy_context(document, None)
|
||||||
@@ -611,16 +611,55 @@ class TestGetTaxonomyContextVisibility:
|
|||||||
return_value=[],
|
return_value=[],
|
||||||
)
|
)
|
||||||
mock_permitted = mocker.patch(
|
mock_permitted = mocker.patch(
|
||||||
"paperless_ai.ai_classifier.permitted_object_ids",
|
"paperless_ai.ai_classifier.permitted_document_ids",
|
||||||
return_value=[1, 2, 3],
|
return_value=[1, 2, 3],
|
||||||
)
|
)
|
||||||
user = UserFactory.create(is_superuser=False)
|
user = UserFactory.create(is_superuser=False)
|
||||||
|
|
||||||
get_taxonomy_context(document, user)
|
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]
|
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
|
@pytest.mark.django_db
|
||||||
class TestFulltextSimilarDocuments:
|
class TestFulltextSimilarDocuments:
|
||||||
@@ -803,7 +842,7 @@ class TestFulltextSimilarDocuments:
|
|||||||
- _fulltext_similar_documents() is called with that user
|
- _fulltext_similar_documents() is called with that user
|
||||||
THEN:
|
THEN:
|
||||||
- Only the still-permitted document is returned - the DB
|
- 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
|
document Tantivy's stale index still thinks is visible
|
||||||
"""
|
"""
|
||||||
owner = UserFactory.create()
|
owner = UserFactory.create()
|
||||||
@@ -834,6 +873,45 @@ class TestFulltextSimilarDocuments:
|
|||||||
|
|
||||||
assert [s["document_id"] for s in result] == [permitted.pk]
|
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
|
@pytest.mark.django_db
|
||||||
@override_settings(LLM_EMBEDDING_BACKEND="huggingface")
|
@override_settings(LLM_EMBEDDING_BACKEND="huggingface")
|
||||||
|
|||||||
Reference in new issue
Block a user