Compare commits

..
Author SHA1 Message Date
shamoon 90d6bbb4a9 Drop the custom replace, let marked escape it 2026-10-11 15:43:27 -07:00
shamoon 5f7dc04657 Add hostname label 2026-10-11 15:40:09 -07:00
shamoon ebae55f158 Sonar 2026-10-11 15:40:08 -07:00
shamoon 1c64328b71 some chat component visual improvements 2026-10-11 15:40:08 -07:00
shamoon 962b26879d Ditch monospace 2026-10-11 15:40:08 -07:00
shamoon 6d6cddca16 Update chat.component.html 2026-10-11 15:40:08 -07:00
shamoon ef5237620b Use the markdown pipe 2026-10-11 15:40:08 -07:00
shamoon 82cfe868b5 markdown pipe 2026-10-11 15:40:07 -07:00
shamoon e557854d8c Add marked 2026-10-11 15:40:07 -07:00
shamoon 973af89f61 Chore: update frontend TS libs to ES2022 2026-10-11 15:39:56 -07:00
GitHub Actions 937bf2fa7a Auto translate strings 2026-10-10 22:05:15 +00:00
Trenton HandClaude Sonnet 5.5 2f2381578c Fix: authorize document versions by their root and speed up permission id sets (#14396)
* Fix: authorize document versions by their root and speed up permission id sets

permitted_document_ids judged a version by its own owner and grants, so a
version whose owner had drifted from its root's was visible to the wrong
people and hidden from the right ones. Callers patched this individually by
mapping each document to its root first. The query itself was also slow on
MariaDB: the guardian grants were a UNION cast to integers and tested with
IN inside an OR with the owner checks, which MariaDB cannot materialize, so it
re-scans the user's grants for every document. At 20k documents that took
seconds for a user with a couple of hundred grants.

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

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

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-10 15:03:52 -07:00
shamoon e606dbffcd Chore: update the db options warning (#14398) 2026-10-10 07:17:54 -07:00
17 changed files with 250 additions and 161 deletions

No files matched your search

+1 -1
View File
@@ -26,7 +26,7 @@ module.exports = {
'abstract-paperless-service', 'abstract-paperless-service',
], ],
transformIgnorePatterns: [ transformIgnorePatterns: [
'node_modules/(?!.*(\\.mjs$|tslib|lodash-es|normalize-diacritics|@angular/common/locales/.*\\.js$))', 'node_modules/(?!.*(\\.mjs$|tslib|lodash-es|normalize-diacritics|marked|@angular/common/locales/.*\\.js$))',
], ],
moduleNameMapper: { moduleNameMapper: {
...esmPreset.moduleNameMapper, ...esmPreset.moduleNameMapper,
+1
View File
@@ -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",
+10
View File
@@ -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,33 @@ 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**')
})
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,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,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('&lt;script&gt;')
})
it('should not render images', () => {
const html = pipe.transform(
'![secret](https://otherdomain.example/x.png?d=1)'
)
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=/)
})
})
+52
View File
@@ -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
}
}
+1 -1
View File
@@ -15,7 +15,7 @@
"target": "ES2022", "target": "ES2022",
"module": "es2020", "module": "es2020",
"lib": [ "lib": [
"es2020", "es2022",
"dom" "dom"
], ],
"useDefineForClassFields": false "useDefineForClassFields": false
-8
View File
@@ -28,7 +28,6 @@ from rest_framework.permissions import BasePermission
from rest_framework.permissions import DjangoObjectPermissions from rest_framework.permissions import DjangoObjectPermissions
from documents.models import Document from documents.models import Document
from documents.versioning import get_root_document
class PaperlessObjectPermissions(DjangoObjectPermissions): class PaperlessObjectPermissions(DjangoObjectPermissions):
@@ -680,14 +679,7 @@ def has_perms_owner_aware(user, perms, obj):
single-object check still has many production callers. Several callers single-object check still has many production callers. Several callers
remain across ``documents/``, ``paperless_mail/``, and ``paperless_ai/`` remain across ``documents/``, ``paperless_mail/``, and ``paperless_ai/``
-- grep for this function name before removing it. -- grep for this function name before removing it.
A document version is authorized by its root document, like in
``permitted_document_ids``, so a version's own owner and grants never
matter. Fetch the root with ``select_related("root_document__owner")`` to
avoid extra queries.
""" """
if isinstance(obj, Document):
obj = get_root_document(obj)
checker = ObjectPermissionChecker(user) checker = ObjectPermissionChecker(user)
return obj.owner is None or obj.owner == user or checker.has_perm(perms, obj) return obj.owner is None or obj.owner == user or checker.has_perm(perms, obj)
+2 -1
View File
@@ -90,6 +90,7 @@ from documents.templating.utils import convert_format_str_to_template_format
from documents.templating.workflows import validate_workflow_template from documents.templating.workflows import validate_workflow_template
from documents.validators import uri_validator from documents.validators import uri_validator
from documents.validators import url_validator from documents.validators import url_validator
from documents.versioning import get_root_document
from documents.versioning import has_prefetched_effective_content from documents.versioning import has_prefetched_effective_content
from documents.versioning import sort_versions_newest_first from documents.versioning import sort_versions_newest_first
@@ -2894,7 +2895,7 @@ class ShareLinkSerializer(OwnedObjectSerializer):
and has_perms_owner_aware( and has_perms_owner_aware(
self.user, self.user,
"view_document", "view_document",
document, get_root_document(document),
) )
): ):
return document return document
@@ -18,7 +18,6 @@ from documents.models import Correspondent
from documents.models import DocumentType from documents.models import DocumentType
from documents.models import StoragePath from documents.models import StoragePath
from documents.models import Tag from documents.models import Tag
from documents.permissions import has_perms_owner_aware
from documents.permissions import permitted_document_ids from documents.permissions import permitted_document_ids
from documents.permissions import permitted_object_ids from documents.permissions import permitted_object_ids
from documents.permissions import restrict_queryset_to_visible from documents.permissions import restrict_queryset_to_visible
@@ -471,92 +470,6 @@ class TestPermittedDocumentIdsVersions:
) )
@pytest.mark.django_db
class TestHasPermsOwnerAwareVersions:
"""
The single-object check agrees with permitted_document_ids: a version is
authorized by its root document.
"""
@pytest.mark.parametrize(
("root_owner", "version_owner", "expected"),
[
pytest.param(
"other",
"nobody",
False,
id="unowned-version-of-private-root",
),
pytest.param("other", "user", False, id="own-version-of-private-root"),
pytest.param("user", "other", True, id="foreign-version-of-own-root"),
pytest.param("nobody", "other", True, id="private-version-of-unowned-root"),
],
)
def test_version_follows_root_owner(
self,
root_owner: str,
version_owner: str,
*,
expected: bool,
) -> None:
"""
GIVEN:
- A root document and a version with differing owners
WHEN:
- The single-object check runs for the version
THEN:
- The version is allowed exactly when its root is
"""
user = UserFactory()
owners = {"user": user, "other": UserFactory(), "nobody": None}
root = DocumentFactory(owner=owners[root_owner])
version = DocumentFactory(root_document=root, owner=owners[version_owner])
assert has_perms_owner_aware(user, "view_document", version) is expected
assert has_perms_owner_aware(user, "view_document", root) is expected
def test_grant_on_root_applies_and_grant_on_version_does_not(self) -> None:
"""
GIVEN:
- A private root with a version, and a second private root with a version
- The user may change only the first root, and was granted the second
root's version directly
WHEN:
- The single-object check runs for each version
THEN:
- Only the first root's version is allowed
"""
user = UserFactory()
shared_root = DocumentFactory(owner=UserFactory())
shared_version = DocumentFactory(root_document=shared_root, owner=UserFactory())
private_root = DocumentFactory(owner=UserFactory())
private_version = DocumentFactory(
root_document=private_root,
owner=UserFactory(),
)
grant_object(user, shared_root, "change_document")
grant_object(user, private_version, "change_document")
assert has_perms_owner_aware(user, "change_document", shared_version)
assert not has_perms_owner_aware(user, "change_document", private_version)
def test_other_models_use_their_own_owner(self) -> None:
"""
GIVEN:
- A tag owned by someone else, and one owned by the user
WHEN:
- The single-object check runs for each
THEN:
- Only the user's own tag is allowed without a grant
"""
user = UserFactory()
mine = TagFactory(owner=user)
theirs = TagFactory(owner=UserFactory())
assert has_perms_owner_aware(user, "view_tag", mine)
assert not has_perms_owner_aware(user, "view_tag", theirs)
@pytest.mark.django_db @pytest.mark.django_db
class TestAiChatAllDocumentsPermissionBoundary: class TestAiChatAllDocumentsPermissionBoundary:
""" """
+7 -7
View File
@@ -1559,7 +1559,7 @@ class DocumentViewSet(
if request.user is not None and not has_perms_owner_aware( if request.user is not None and not has_perms_owner_aware(
request.user, request.user,
"change_document", "change_document",
doc, get_root_document(doc),
): ):
return HttpResponseForbidden("Insufficient permissions") return HttpResponseForbidden("Insufficient permissions")
@@ -1622,7 +1622,7 @@ class DocumentViewSet(
if request.user is not None and not has_perms_owner_aware( if request.user is not None and not has_perms_owner_aware(
request.user, request.user,
"change_document", "change_document",
doc, get_root_document(doc),
): ):
return HttpResponseForbidden("Insufficient permissions") return HttpResponseForbidden("Insufficient permissions")
@@ -1875,7 +1875,7 @@ class DocumentViewSet(
if currentUser is not None and not has_perms_owner_aware( if currentUser is not None and not has_perms_owner_aware(
currentUser, currentUser,
"view_document", "view_document",
doc, get_root_document(doc),
): ):
return HttpResponseForbidden("Insufficient permissions to view notes") return HttpResponseForbidden("Insufficient permissions to view notes")
except Document.DoesNotExist: except Document.DoesNotExist:
@@ -1897,7 +1897,7 @@ class DocumentViewSet(
if currentUser is not None and not has_perms_owner_aware( if currentUser is not None and not has_perms_owner_aware(
currentUser, currentUser,
"change_document", "change_document",
doc, get_root_document(doc),
): ):
return HttpResponseForbidden( return HttpResponseForbidden(
"Insufficient permissions to create notes", "Insufficient permissions to create notes",
@@ -1940,7 +1940,7 @@ class DocumentViewSet(
if currentUser is not None and not has_perms_owner_aware( if currentUser is not None and not has_perms_owner_aware(
currentUser, currentUser,
"change_document", "change_document",
doc, get_root_document(doc),
): ):
return HttpResponseForbidden("Insufficient permissions to delete notes") return HttpResponseForbidden("Insufficient permissions to delete notes")
@@ -1990,7 +1990,7 @@ class DocumentViewSet(
if currentUser is not None and not has_perms_owner_aware( if currentUser is not None and not has_perms_owner_aware(
currentUser, currentUser,
"change_document", "change_document",
doc, get_root_document(doc),
): ):
return HttpResponseForbidden( return HttpResponseForbidden(
"Insufficient permissions to add share link", "Insufficient permissions to add share link",
@@ -2451,7 +2451,7 @@ class ChatStreamingView(GenericAPIView[Any]):
if not has_perms_owner_aware( if not has_perms_owner_aware(
request.user, request.user,
"view_document", "view_document",
document, get_root_document(document),
): ):
return HttpResponseForbidden("Insufficient permissions") return HttpResponseForbidden("Insufficient permissions")
+12 -12
View File
@@ -2,7 +2,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: paperless-ngx\n" "Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-10-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
View File
@@ -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",
+11 -25
View File
@@ -211,14 +211,14 @@ class TestAuditLogChecks:
assert "auditlog table was found but audit log is disabled." in msgs[0].msg assert "auditlog table was found but audit log is disabled." in msgs[0].msg
DEPRECATED_VARS: 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,