Compare commits

...
Author SHA1 Message Date
Trenton Holmes 34f2385df4 Fix: resolve silent duplication from rebase onto fix/bulk_edit
Rebasing onto origin/fix/bulk_edit (PR #14083, which independently
bounds the edit_pdf doc index via manual checks) applied cleanly with
no reported conflicts, but left two copies of the same "doc index is
out of bounds" check back to back in both EditPdfDocumentsSerializer
.validate and BulkEditSerializer._validate_parameters_edit_pdf --
#14083's own `< 0 or >= len(operations)` check is now fully redundant
here since PdfEditOperationSerializer.doc already has min_value=0.
Removed the redundant second check in both methods.

Also split the merged test_edit_pdf_invalid_params subtest: #14083's
loop asserted the same "doc index is out of bounds" message for both
doc=-1 and doc=2**32, but with min_value=0 in place, -1 is now
rejected earlier by the field itself with a different message.
2026-09-13 15:06:52 -07:00
Trenton HolmesandClaude Sonnet 5 1ff18656b7 Fix: type edit_pdf operations via a nested serializer
operations was a plain ListField(required=True) with no child=, so
each element was untyped, and both EditPdfDocumentsSerializer.validate
and BulkEditSerializer._validate_parameters_edit_pdf hand-checked
page/rotate/doc with isinstance(). This let a negative doc index
through silently (used as a wrapping Python list index instead of
being rejected), an empty operations list crashed
`max(op.get("doc", 0) for op in operations)` with update_document=True,
and an out-of-range doc index could drive pikepdf.new() to allocate an
unbounded number of objects.

Added PdfEditOperationSerializer (page: IntegerField(min_value=1),
rotate/doc: IntegerField, doc: min_value=0) and used it as
ListField(child=..., allow_empty=False) in both places, plus a
doc-index bound of len(operations) shared by both validate() methods.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 15:02:31 -07:00
GitHub Actions 57e0a17571 Auto translate strings 2026-09-13 15:02:31 -07:00
shamoon 3fc85c82e1 Chore: include Apply AI Suggestions in the tasks UI filter dropdown (#14093) 2026-09-13 15:02:31 -07:00
GitHub Actions 54ce5f9f61 Auto translate strings 2026-09-13 15:02:31 -07:00
shamoon f5a7ab062e Fix: update some api global perms inconsistencies (#14086) 2026-09-13 15:02:31 -07:00
GitHub Actions 9a317844c2 Auto translate strings 2026-09-13 15:02:31 -07:00
shamoon fc4107d390 Fix: ignore nested action IDs on WF create (#14084) 2026-09-13 15:02:31 -07:00
shamoon 4acc0bed54 Chore: read-only deleted_at 2026-09-13 15:02:31 -07:00
shamoon 9425f53d5c fix: validate PDF output doc indexes in bulk edit 2026-09-12 15:20:40 -07:00
20 changed files with 631 additions and 95 deletions
+30 -23
View File
@@ -2068,7 +2068,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context> <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
<context context-type="linenumber">215</context> <context context-type="linenumber">219</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="131016739441837046" datatype="html"> <trans-unit id="131016739441837046" datatype="html">
@@ -2079,7 +2079,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context> <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
<context context-type="linenumber">227</context> <context context-type="linenumber">231</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="3643866539136239218" datatype="html"> <trans-unit id="3643866539136239218" datatype="html">
@@ -2415,11 +2415,11 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context> <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
<context context-type="linenumber">317</context> <context context-type="linenumber">321</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context> <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
<context context-type="linenumber">351</context> <context context-type="linenumber">355</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
@@ -2612,11 +2612,18 @@
<context context-type="linenumber">100</context> <context context-type="linenumber">100</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="5672013191898828327" datatype="html">
<source>Apply AI Suggestions</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
<context context-type="linenumber">104</context>
</context-group>
</trans-unit>
<trans-unit id="9172233176401579786" datatype="html"> <trans-unit id="9172233176401579786" datatype="html">
<source>Scheduled</source> <source>Scheduled</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context> <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
<context context-type="linenumber">110</context> <context context-type="linenumber">114</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context> <context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
@@ -2627,7 +2634,7 @@
<source>Web UI</source> <source>Web UI</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context> <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
<context context-type="linenumber">112</context> <context context-type="linenumber">116</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context> <context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
@@ -2638,7 +2645,7 @@
<source>API Upload</source> <source>API Upload</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context> <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
<context context-type="linenumber">115</context> <context context-type="linenumber">119</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context> <context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
@@ -2649,106 +2656,106 @@
<source>Folder Consume</source> <source>Folder Consume</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context> <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
<context context-type="linenumber">119</context> <context context-type="linenumber">123</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="1456901843819961615" datatype="html"> <trans-unit id="1456901843819961615" datatype="html">
<source>Email Consume</source> <source>Email Consume</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context> <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
<context context-type="linenumber">123</context> <context context-type="linenumber">127</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="29832309535656200" datatype="html"> <trans-unit id="29832309535656200" datatype="html">
<source>System</source> <source>System</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context> <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
<context context-type="linenumber">125</context> <context context-type="linenumber">129</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="2233560223291461480" datatype="html"> <trans-unit id="2233560223291461480" datatype="html">
<source>Manual</source> <source>Manual</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context> <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
<context context-type="linenumber">126</context> <context context-type="linenumber">130</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="5404910960991552159" datatype="html"> <trans-unit id="5404910960991552159" datatype="html">
<source>Dismiss selected</source> <source>Dismiss selected</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context> <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
<context context-type="linenumber">239</context> <context context-type="linenumber">243</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="9169677036332103838" datatype="html"> <trans-unit id="9169677036332103838" datatype="html">
<source>Dismiss visible</source> <source>Dismiss visible</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context> <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
<context context-type="linenumber">240</context> <context context-type="linenumber">244</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="3169751690815214293" datatype="html"> <trans-unit id="3169751690815214293" datatype="html">
<source>Confirm Dismiss</source> <source>Confirm Dismiss</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context> <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
<context context-type="linenumber">314</context> <context context-type="linenumber">318</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="5029621907742319073" datatype="html"> <trans-unit id="5029621907742319073" datatype="html">
<source>Dismiss <x id="PH" equiv-text="tasks.size"/> tasks?</source> <source>Dismiss <x id="PH" equiv-text="tasks.size"/> tasks?</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context> <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
<context context-type="linenumber">315</context> <context context-type="linenumber">319</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="3597309129998924778" datatype="html"> <trans-unit id="3597309129998924778" datatype="html">
<source>Error dismissing tasks</source> <source>Error dismissing tasks</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context> <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
<context context-type="linenumber">326</context> <context context-type="linenumber">330</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context> <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
<context context-type="linenumber">360</context> <context context-type="linenumber">364</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="2132179171926568807" datatype="html"> <trans-unit id="2132179171926568807" datatype="html">
<source>Error dismissing task</source> <source>Error dismissing task</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context> <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
<context context-type="linenumber">338</context> <context context-type="linenumber">342</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="1323591410517879795" datatype="html"> <trans-unit id="1323591410517879795" datatype="html">
<source>Confirm Dismiss All</source> <source>Confirm Dismiss All</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context> <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
<context context-type="linenumber">348</context> <context context-type="linenumber">352</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="4157200209636243740" datatype="html"> <trans-unit id="4157200209636243740" datatype="html">
<source>Dismiss all <x id="PH" equiv-text="this.totalTasks()"/> tasks?</source> <source>Dismiss all <x id="PH" equiv-text="this.totalTasks()"/> tasks?</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context> <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
<context context-type="linenumber">349</context> <context context-type="linenumber">353</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="8149502458056418229" datatype="html"> <trans-unit id="8149502458056418229" datatype="html">
<source>Success. New document id <x id="PH" equiv-text="documentId"/> created</source> <source>Success. New document id <x id="PH" equiv-text="documentId"/> created</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context> <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
<context context-type="linenumber">408</context> <context context-type="linenumber">412</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="8760066891202884337" datatype="html"> <trans-unit id="8760066891202884337" datatype="html">
<source>Duplicate of document #<x id="PH" equiv-text="duplicateOf"/></source> <source>Duplicate of document #<x id="PH" equiv-text="duplicateOf"/></source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context> <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
<context context-type="linenumber">418</context> <context context-type="linenumber">422</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context> <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
<context context-type="linenumber">452</context> <context context-type="linenumber">456</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="3418677553313974490" datatype="html"> <trans-unit id="3418677553313974490" datatype="html">
@@ -99,6 +99,10 @@ const TASK_TYPE_OPTIONS: Array<{
value: PaperlessTaskType.BulkDelete, value: PaperlessTaskType.BulkDelete,
label: $localize`Bulk Delete`, label: $localize`Bulk Delete`,
}, },
{
value: PaperlessTaskType.ApplyAiSuggestions,
label: $localize`Apply AI Suggestions`,
},
] ]
const TRIGGER_SOURCE_OPTIONS: Array<{ const TRIGGER_SOURCE_OPTIONS: Array<{
+1
View File
@@ -12,6 +12,7 @@ export enum PaperlessTaskType {
ReprocessDocument = 'reprocess_document', ReprocessDocument = 'reprocess_document',
BuildShareLink = 'build_share_link', BuildShareLink = 'build_share_link',
BulkDelete = 'bulk_delete', BulkDelete = 'bulk_delete',
ApplyAiSuggestions = 'apply_ai_suggestions',
} }
export enum PaperlessTaskTriggerSource { export enum PaperlessTaskTriggerSource {
+14 -5
View File
@@ -899,17 +899,26 @@ def edit_pdf(
pdf_docs: list[pikepdf.Pdf] = [] pdf_docs: list[pikepdf.Pdf] = []
try: try:
with pikepdf.open(pair.source_doc.source_path) as src: if not operations:
# prepare output documents raise ValueError("Output document index is out of bounds")
max_idx = max(op.get("doc", 0) for op in operations)
pdf_docs = [pikepdf.new() for _ in range(max_idx + 1)]
if update_document and len(pdf_docs) > 1: max_idx = max(op.get("doc", 0) for op in operations)
if update_document and max_idx > 0:
logger.error( logger.error(
"Update requested but multiple output documents specified", "Update requested but multiple output documents specified",
) )
raise ValueError("Multiple output documents specified") raise ValueError("Multiple output documents specified")
if any(
op.get("doc", 0) < 0 or op.get("doc", 0) >= len(operations)
for op in operations
):
raise ValueError("Output document index is out of bounds")
with pikepdf.open(pair.source_doc.source_path) as src:
# prepare output documents
pdf_docs = [pikepdf.new() for _ in range(max_idx + 1)]
for op in operations: for op in operations:
dst = pdf_docs[op.get("doc", 0)] dst = pdf_docs[op.get("doc", 0)]
page = src.pages[op["page"] - 1] page = src.pages[op["page"] - 1]
+29 -4
View File
@@ -657,16 +657,41 @@ class ViewDocumentsPermissions(BasePermission):
return request.user.has_perms(self.perms_map.get(request.method, [])) return request.user.has_perms(self.perms_map.get(request.method, []))
class TrashPermissions(BasePermission):
"""Check the global document permission for each trash operation."""
perms_map = {
"OPTIONS": ["documents.view_document"],
"HEAD": ["documents.view_document"],
"GET": ["documents.view_document"],
"POST": ["documents.delete_document"],
}
def has_permission(self, request, view):
if not request.user or not request.user.is_authenticated: # pragma: no cover
return False
return request.user.has_perms(self.perms_map.get(request.method, []))
class PaperlessNotePermissions(BasePermission): class PaperlessNotePermissions(BasePermission):
""" """
Permissions class that checks for model permissions for Notes. Permissions class that checks for model permissions for Notes.
""" """
perms_map = { perms_map = {
"OPTIONS": ["documents.view_note"], "OPTIONS": ["documents.view_note", "documents.view_document"],
"GET": ["documents.view_note"], "GET": ["documents.view_note", "documents.view_document"],
"POST": ["documents.add_note"], "POST": [
"DELETE": ["documents.delete_note"], "documents.add_note",
"documents.view_document",
"documents.change_document",
],
"DELETE": [
"documents.delete_note",
"documents.view_document",
"documents.change_document",
],
} }
def has_permission(self, request, view): def has_permission(self, request, view):
+37 -28
View File
@@ -1335,6 +1335,7 @@ class DocumentSerializer(
"root_document", "root_document",
"versions", "versions",
) )
read_only_fields = ("deleted_at",)
list_serializer_class = OwnedObjectListSerializer list_serializer_class = OwnedObjectListSerializer
@@ -1748,8 +1749,18 @@ class MergeDocumentsAsVersionsSerializer(DocumentListSerializer):
return attrs return attrs
class PdfEditOperationSerializer(serializers.Serializer[dict[str, int]]):
page = serializers.IntegerField(min_value=1)
rotate = serializers.IntegerField(required=False)
doc = serializers.IntegerField(required=False, min_value=0)
class EditPdfDocumentsSerializer(DocumentListSerializer, SourceModeValidationMixin): class EditPdfDocumentsSerializer(DocumentListSerializer, SourceModeValidationMixin):
operations = serializers.ListField(required=True) operations = serializers.ListField(
child=PdfEditOperationSerializer(),
required=True,
allow_empty=False,
)
delete_original = serializers.BooleanField(required=False, default=False) delete_original = serializers.BooleanField(required=False, default=False)
update_document = serializers.BooleanField(required=False, default=False) update_document = serializers.BooleanField(required=False, default=False)
include_metadata = serializers.BooleanField(required=False, default=True) include_metadata = serializers.BooleanField(required=False, default=True)
@@ -1767,18 +1778,9 @@ class EditPdfDocumentsSerializer(DocumentListSerializer, SourceModeValidationMix
) )
operations = attrs["operations"] operations = attrs["operations"]
if not isinstance(operations, list):
raise serializers.ValidationError("operations must be a list")
for op in operations: if any(op.get("doc", 0) >= len(operations) for op in operations):
if not isinstance(op, dict): raise serializers.ValidationError("doc index is out of bounds")
raise serializers.ValidationError("invalid operation entry")
if "page" not in op or not isinstance(op["page"], int):
raise serializers.ValidationError("page must be an integer")
if "rotate" in op and not isinstance(op["rotate"], int):
raise serializers.ValidationError("rotate must be an integer")
if "doc" in op and not isinstance(op["doc"], int):
raise serializers.ValidationError("doc must be an integer")
if attrs["update_document"]: if attrs["update_document"]:
max_idx = max(op.get("doc", 0) for op in operations) max_idx = max(op.get("doc", 0) for op in operations)
@@ -1790,7 +1792,7 @@ class EditPdfDocumentsSerializer(DocumentListSerializer, SourceModeValidationMix
doc = Document.objects.get(id=documents[0]) doc = Document.objects.get(id=documents[0])
if doc.page_count: if doc.page_count:
for op in operations: for op in operations:
if op["page"] < 1 or op["page"] > doc.page_count: if op["page"] > doc.page_count:
raise serializers.ValidationError( raise serializers.ValidationError(
f"Page {op['page']} is out of bounds for document with {doc.page_count} pages.", f"Page {op['page']} is out of bounds for document with {doc.page_count} pages.",
) )
@@ -2121,17 +2123,15 @@ class BulkEditSerializer(
def _validate_parameters_edit_pdf(self, parameters, document_id) -> None: def _validate_parameters_edit_pdf(self, parameters, document_id) -> None:
if "operations" not in parameters: if "operations" not in parameters:
raise serializers.ValidationError("operations not specified") raise serializers.ValidationError("operations not specified")
if not isinstance(parameters["operations"], list): operations_field = serializers.ListField(
raise serializers.ValidationError("operations must be a list") child=PdfEditOperationSerializer(),
for op in parameters["operations"]: allow_empty=False,
if not isinstance(op, dict): )
raise serializers.ValidationError("invalid operation entry") parameters["operations"] = operations_field.run_validation(
if "page" not in op or not isinstance(op["page"], int): parameters["operations"],
raise serializers.ValidationError("page must be an integer") )
if "rotate" in op and not isinstance(op["rotate"], int): operations = parameters["operations"]
raise serializers.ValidationError("rotate must be an integer")
if "doc" in op and not isinstance(op["doc"], int):
raise serializers.ValidationError("doc must be an integer")
if "update_document" in parameters: if "update_document" in parameters:
if not isinstance(parameters["update_document"], bool): if not isinstance(parameters["update_document"], bool):
raise serializers.ValidationError("update_document must be a boolean") raise serializers.ValidationError("update_document must be a boolean")
@@ -2143,8 +2143,11 @@ class BulkEditSerializer(
else: else:
parameters["include_metadata"] = True parameters["include_metadata"] = True
if any(op.get("doc", 0) >= len(operations) for op in operations):
raise serializers.ValidationError("doc index is out of bounds")
if parameters["update_document"]: if parameters["update_document"]:
max_idx = max(op.get("doc", 0) for op in parameters["operations"]) max_idx = max(op.get("doc", 0) for op in operations)
if max_idx > 0: if max_idx > 0:
raise serializers.ValidationError( raise serializers.ValidationError(
"update_document only allowed with a single output document", "update_document only allowed with a single output document",
@@ -2153,8 +2156,8 @@ class BulkEditSerializer(
doc = Document.objects.get(id=document_id) doc = Document.objects.get(id=document_id)
# doc existence is already validated # doc existence is already validated
if doc.page_count: if doc.page_count:
for op in parameters["operations"]: for op in operations:
if op["page"] < 1 or op["page"] > doc.page_count: if op["page"] > doc.page_count:
raise serializers.ValidationError( raise serializers.ValidationError(
f"Page {op['page']} is out of bounds for document with {doc.page_count} pages.", f"Page {op['page']} is out of bounds for document with {doc.page_count} pages.",
) )
@@ -2839,10 +2842,14 @@ class ShareLinkSerializer(OwnedObjectSerializer):
return super().create(validated_data) return super().create(validated_data)
def validate_document(self, document): def validate_document(self, document):
if self.user is not None and has_perms_owner_aware( if (
self.user is not None
and self.user.has_perm("documents.view_document")
and has_perms_owner_aware(
self.user, self.user,
"view_document", "view_document",
document, document,
)
): ):
return document return document
raise PermissionDenied( raise PermissionDenied(
@@ -3603,6 +3610,8 @@ class WorkflowSerializer(serializers.ModelSerializer[Workflow]):
if "actions" in validated_data: if "actions" in validated_data:
actions = validated_data.pop("actions") actions = validated_data.pop("actions")
for action in actions:
action.pop("id", None)
instance = super().create(validated_data) instance = super().create(validated_data)
@@ -4,6 +4,7 @@ import json
import shutil import shutil
import zipfile import zipfile
from django.contrib.auth.models import Permission
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.test import override_settings from django.test import override_settings
from django.utils import timezone from django.utils import timezone
@@ -326,6 +327,9 @@ class TestBulkDownload(DirectoriesMixin, SampleDirMixin, APITestCase):
def test_download_insufficient_permissions(self) -> None: def test_download_insufficient_permissions(self) -> None:
user = User.objects.create_user(username="temp_user") user = User.objects.create_user(username="temp_user")
user.user_permissions.add(
Permission.objects.get(codename="view_document"),
)
self.client.force_authenticate(user=user) self.client.force_authenticate(user=user)
self.doc2.owner = self.user self.doc2.owner = self.user
+179 -6
View File
@@ -1084,6 +1084,8 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
user1 = User.objects.create(username="user1") user1 = User.objects.create(username="user1")
self.client.force_authenticate(user=user1) self.client.force_authenticate(user=user1)
assign_perm("view_document", user1, self.doc2)
response = self.client.post( response = self.client.post(
"/api/documents/selection_data/", "/api/documents/selection_data/",
json.dumps({"documents": [self.doc2.id]}), json.dumps({"documents": [self.doc2.id]}),
@@ -1091,7 +1093,18 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
) )
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
self.assertEqual(response.content, b"Insufficient permissions")
user1.user_permissions.add(
Permission.objects.get(codename="view_document"),
)
user1 = User.objects.get(pk=user1.pk)
self.client.force_authenticate(user=user1)
response = self.client.post(
"/api/documents/selection_data/",
json.dumps({"documents": [self.doc2.id]}),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
@mock.patch("documents.serialisers.bulk_edit.set_permissions") @mock.patch("documents.serialisers.bulk_edit.set_permissions")
def test_set_permissions(self, m) -> None: def test_set_permissions(self, m) -> None:
@@ -1636,6 +1649,24 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
def test_legacy_bulk_edit_rejects_out_of_bounds_pdf_doc_index(self) -> None:
response = self.client.post(
"/api/documents/bulk_edit/",
json.dumps(
{
"documents": [self.doc2.id],
"method": "edit_pdf",
"parameters": {
"operations": [{"page": 1, "doc": 2**32}],
},
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn(b"doc index is out of bounds", response.content)
@mock.patch("documents.views.bulk_edit.edit_pdf") @mock.patch("documents.views.bulk_edit.edit_pdf")
def test_edit_pdf(self, m) -> None: def test_edit_pdf(self, m) -> None:
self.setup_mock(m, "edit_pdf") self.setup_mock(m, "edit_pdf")
@@ -1697,7 +1728,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
content_type="application/json", content_type="application/json",
) )
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn(b"invalid operation entry", response.content) self.assertIn(b"Expected a dictionary", response.content)
response = self.client.post( response = self.client.post(
"/api/documents/edit_pdf/", "/api/documents/edit_pdf/",
@@ -1710,7 +1741,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
content_type="application/json", content_type="application/json",
) )
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn(b"page must be an integer", response.content) self.assertIn(b"valid integer is required", response.content)
response = self.client.post( response = self.client.post(
"/api/documents/edit_pdf/", "/api/documents/edit_pdf/",
@@ -1723,7 +1754,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
content_type="application/json", content_type="application/json",
) )
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn(b"rotate must be an integer", response.content) self.assertIn(b"valid integer is required", response.content)
response = self.client.post( response = self.client.post(
"/api/documents/edit_pdf/", "/api/documents/edit_pdf/",
@@ -1736,7 +1767,29 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
content_type="application/json", content_type="application/json",
) )
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn(b"doc must be an integer", response.content) self.assertIn(b"valid integer is required", response.content)
# A negative doc index is rejected by PdfEditOperationSerializer's
# own min_value=0 field constraint, before the "doc index is out
# of bounds" object-level check (against len(operations)) ever
# runs -- hence the different expected message per case.
for doc_index, expected_message in (
(-1, b"greater than or equal to 0"),
(2**32, b"doc index is out of bounds"),
):
with self.subTest(doc_index=doc_index):
response = self.client.post(
"/api/documents/edit_pdf/",
json.dumps(
{
"documents": [self.doc2.id],
"operations": [{"page": 1, "doc": doc_index}],
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn(expected_message, response.content)
response = self.client.post( response = self.client.post(
"/api/documents/edit_pdf/", "/api/documents/edit_pdf/",
@@ -1744,7 +1797,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
{ {
"documents": [self.doc2.id], "documents": [self.doc2.id],
"update_document": True, "update_document": True,
"operations": [{"page": 1, "doc": 1}, {"page": 2, "doc": 2}], "operations": [{"page": 1, "doc": 0}, {"page": 2, "doc": 1}],
}, },
), ),
content_type="application/json", content_type="application/json",
@@ -1769,6 +1822,86 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn(b"Invalid source_mode", response.content) self.assertIn(b"Invalid source_mode", response.content)
@mock.patch("documents.views.bulk_edit.edit_pdf")
def test_edit_pdf_rejects_empty_operations(self, m) -> None:
"""
An empty operations list previously reached bulk_edit.edit_pdf()
and crashed with `ValueError: max() iterable argument is empty`
(via `max(op.get("doc", 0) for op in operations)`) whenever
update_document was true. Must now be rejected up front.
"""
self.setup_mock(m, "edit_pdf")
response = self.client.post(
"/api/documents/edit_pdf/",
json.dumps(
{
"documents": [self.doc2.id],
"operations": [],
"update_document": True,
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
m.assert_not_called()
@mock.patch("documents.views.bulk_edit.edit_pdf")
def test_edit_pdf_rejects_negative_doc_index(self, m) -> None:
"""
A negative `doc` index was previously silently accepted and used
as a wrapping Python list index instead of being rejected.
"""
self.setup_mock(m, "edit_pdf")
response = self.client.post(
"/api/documents/edit_pdf/",
json.dumps(
{
"documents": [self.doc2.id],
"operations": [{"page": 1, "doc": -1}],
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
m.assert_not_called()
@mock.patch("documents.views.bulk_edit.edit_pdf")
def test_edit_pdf_rejects_out_of_bounds_doc_index(self, m) -> None:
"""
A `doc` index far larger than the number of operations previously
drove `pdf_docs = [pikepdf.new() for _ in range(max_idx + 1)]` to
attempt allocating an enormous number of real objects.
"""
self.setup_mock(m, "edit_pdf")
response = self.client.post(
"/api/documents/edit_pdf/",
json.dumps(
{
"documents": [self.doc2.id],
"operations": [{"page": 1, "doc": 2**33}],
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
m.assert_not_called()
@mock.patch("documents.views.bulk_edit.edit_pdf")
def test_edit_pdf_rejects_non_positive_page(self, m) -> None:
self.setup_mock(m, "edit_pdf")
response = self.client.post(
"/api/documents/edit_pdf/",
json.dumps(
{
"documents": [self.doc2.id],
"operations": [{"page": 0}],
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
m.assert_not_called()
@mock.patch("documents.views.bulk_edit.edit_pdf") @mock.patch("documents.views.bulk_edit.edit_pdf")
def test_edit_pdf_page_out_of_bounds(self, m) -> None: def test_edit_pdf_page_out_of_bounds(self, m) -> None:
self.setup_mock(m, "edit_pdf") self.setup_mock(m, "edit_pdf")
@@ -1786,6 +1919,46 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
self.assertIn(b"out of bounds", response.content) self.assertIn(b"out of bounds", response.content)
m.assert_not_called() m.assert_not_called()
@mock.patch("documents.serialisers.bulk_edit.edit_pdf")
def test_bulk_edit_edit_pdf_rejects_empty_operations(self, m) -> None:
"""
Same validation gap as test_edit_pdf_rejects_empty_operations, but
via the legacy generic /api/documents/bulk_edit/ method="edit_pdf"
path, which hand-parses `parameters["operations"]` independently
in BulkEditSerializer._validate_parameters_edit_pdf.
"""
self.setup_mock(m, "edit_pdf")
response = self.client.post(
"/api/documents/bulk_edit/",
json.dumps(
{
"documents": [self.doc2.id],
"method": "edit_pdf",
"parameters": {"operations": [], "update_document": True},
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
m.assert_not_called()
@mock.patch("documents.serialisers.bulk_edit.edit_pdf")
def test_bulk_edit_edit_pdf_rejects_out_of_bounds_doc_index(self, m) -> None:
self.setup_mock(m, "edit_pdf")
response = self.client.post(
"/api/documents/bulk_edit/",
json.dumps(
{
"documents": [self.doc2.id],
"method": "edit_pdf",
"parameters": {"operations": [{"page": 1, "doc": 2**33}]},
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
m.assert_not_called()
@mock.patch("documents.views.bulk_edit.edit_pdf") @mock.patch("documents.views.bulk_edit.edit_pdf")
def test_edit_pdf_insufficient_permissions(self, m) -> None: def test_edit_pdf_insufficient_permissions(self, m) -> None:
self.doc1.owner = User.objects.get(username="temp_admin") self.doc1.owner = User.objects.get(username="temp_admin")
+64
View File
@@ -3615,6 +3615,55 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
self.assertEqual(response.content, b"Insufficient permissions to delete notes") self.assertEqual(response.content, b"Insufficient permissions to delete notes")
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
def test_notes_require_global_document_permissions(self) -> None:
user = User.objects.create_user(username="note_editor")
user.user_permissions.add(
*Permission.objects.filter(
codename__in=["view_note", "add_note", "delete_note"],
),
)
doc = Document.objects.create(
title="test",
mime_type="application/pdf",
content="notes",
owner=user,
)
note = Note.objects.create(note="Existing", document=doc, user=user)
self.client.force_authenticate(user)
response = self.client.get(f"/api/documents/{doc.pk}/notes/")
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
user.user_permissions.add(
Permission.objects.get(codename="view_document"),
)
user = User.objects.get(pk=user.pk)
self.client.force_authenticate(user)
response = self.client.get(f"/api/documents/{doc.pk}/notes/")
self.assertEqual(response.status_code, status.HTTP_200_OK)
response = self.client.post(
f"/api/documents/{doc.pk}/notes/",
data={"note": "New"},
)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
user.user_permissions.add(
Permission.objects.get(codename="change_document"),
)
user = User.objects.get(pk=user.pk)
self.client.force_authenticate(user)
response = self.client.post(
f"/api/documents/{doc.pk}/notes/",
data={"note": "New"},
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
response = self.client.delete(
f"/api/documents/{doc.pk}/notes/?id={note.pk}",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test_delete_note(self) -> None: def test_delete_note(self) -> None:
""" """
GIVEN: GIVEN:
@@ -3981,6 +4030,21 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
assign_perm("view_document", user1, doc) assign_perm("view_document", user1, doc)
create_resp = self.client.post(
"/api/share_links/",
data={
"document": doc.pk,
"file_version": "original",
},
format="json",
)
self.assertEqual(create_resp.status_code, status.HTTP_403_FORBIDDEN)
user1.user_permissions.add(
Permission.objects.get(codename="view_document"),
)
user1 = User.objects.get(pk=user1.pk)
self.client.force_authenticate(user1)
create_resp = self.client.post( create_resp = self.client.post(
"/api/share_links/", "/api/share_links/",
data={ data={
+32
View File
@@ -457,6 +457,9 @@ class TestApiStoragePaths(DirectoriesMixin, APITestCase):
def test_test_storage_path_requires_document_view_permission(self) -> None: def test_test_storage_path_requires_document_view_permission(self) -> None:
owner = User.objects.create_user(username="owner") owner = User.objects.create_user(username="owner")
unprivileged = User.objects.create_user(username="unprivileged") unprivileged = User.objects.create_user(username="unprivileged")
unprivileged.user_permissions.add(
Permission.objects.get(codename="view_document"),
)
document = Document.objects.create( document = Document.objects.create(
mime_type="application/pdf", mime_type="application/pdf",
owner=owner, owner=owner,
@@ -488,6 +491,23 @@ class TestApiStoragePaths(DirectoriesMixin, APITestCase):
) )
assign_perm("view_document", viewer, document) assign_perm("view_document", viewer, document)
self.client.force_authenticate(user=viewer)
response = self.client.post(
f"{self.ENDPOINT}test/",
json.dumps(
{
"document": document.id,
"path": "path/{{ title }}",
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
viewer.user_permissions.add(
Permission.objects.get(codename="view_document"),
)
viewer = User.objects.get(pk=viewer.pk)
self.client.force_authenticate(user=viewer) self.client.force_authenticate(user=viewer)
response = self.client.post( response = self.client.post(
f"{self.ENDPOINT}test/", f"{self.ENDPOINT}test/",
@@ -530,6 +550,9 @@ class TestApiStoragePaths(DirectoriesMixin, APITestCase):
password="password", password="password",
email="owner@example.com", email="owner@example.com",
) )
owner.user_permissions.add(
Permission.objects.get(codename="view_document"),
)
document = Document.objects.create( document = Document.objects.create(
mime_type="application/pdf", mime_type="application/pdf",
owner=owner, owner=owner,
@@ -605,6 +628,9 @@ class TestApiStoragePaths(DirectoriesMixin, APITestCase):
checksum="123", checksum="123",
) )
assign_perm("view_document", viewer, document) assign_perm("view_document", viewer, document)
viewer.user_permissions.add(
Permission.objects.get(codename="view_document"),
)
self.client.force_authenticate(user=viewer) self.client.force_authenticate(user=viewer)
response = self.client.post( response = self.client.post(
@@ -692,6 +718,9 @@ class TestApiStoragePaths(DirectoriesMixin, APITestCase):
) )
document.tags.add(private_tag) document.tags.add(private_tag)
assign_perm("view_document", viewer, document) assign_perm("view_document", viewer, document)
viewer.user_permissions.add(
Permission.objects.get(codename="view_document"),
)
self.client.force_authenticate(user=viewer) self.client.force_authenticate(user=viewer)
response = self.client.post( response = self.client.post(
@@ -745,6 +774,9 @@ class TestApiStoragePaths(DirectoriesMixin, APITestCase):
value_int=42, value_int=42,
) )
assign_perm("view_document", viewer, document) assign_perm("view_document", viewer, document)
viewer.user_permissions.add(
Permission.objects.get(codename="view_document"),
)
self.client.force_authenticate(user=viewer) self.client.force_authenticate(user=viewer)
response = self.client.post( response = self.client.post(
+10
View File
@@ -69,6 +69,16 @@ class TestTrashAPI(DirectoriesMixin, APITestCase):
self.assertEqual(resp.status_code, status.HTTP_200_OK) self.assertEqual(resp.status_code, status.HTTP_200_OK)
self.assertEqual(Document.global_objects.count(), 0) self.assertEqual(Document.global_objects.count(), 0)
def test_trash_list_requires_global_document_view_permission(self) -> None:
user = User.objects.create_user(username="trash_owner")
document = Document.objects.create(title="Owned", owner=user)
document.delete()
self.client.force_authenticate(user)
response = self.client.get("/api/trash/")
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
def test_trash_api_empty_all(self) -> None: def test_trash_api_empty_all(self) -> None:
""" """
GIVEN: GIVEN:
+42
View File
@@ -194,6 +194,48 @@ class TestApiWorkflows(DirectoriesMixin, APITestCase):
self.assertEqual(response.status_code, status.HTTP_201_CREATED) self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertEqual(Workflow.objects.count(), 2) self.assertEqual(Workflow.objects.count(), 2)
def test_api_create_workflow_ignores_nested_action_id(self) -> None:
"""
GIVEN:
- An existing workflow action
WHEN:
- API request to create a workflow includes that action's ID
THEN:
- A new action is created without changing the existing action
"""
original_title = self.action.assign_title
response = self.client.post(
self.ENDPOINT,
json.dumps(
{
"name": "Workflow 2",
"order": 1,
"triggers": [
{
"sources": [DocumentSource.ApiUpload],
"type": WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
"filter_filename": "*",
},
],
"actions": [
{
"id": self.action.id,
"assign_title": "New Action Title",
},
],
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.action.refresh_from_db()
self.assertEqual(self.action.assign_title, original_title)
new_action = Workflow.objects.get(name="Workflow 2").actions.get()
self.assertNotEqual(new_action.id, self.action.id)
self.assertEqual(new_action.assign_title, "New Action Title")
def test_api_create_workflow_nested(self) -> None: def test_api_create_workflow_nested(self) -> None:
""" """
GIVEN: GIVEN:
+11
View File
@@ -1642,6 +1642,17 @@ class TestPDFActions(DirectoriesMixin, TestCase):
mock_group.assert_not_called() mock_group.assert_not_called()
mock_consume_file.assert_not_called() mock_consume_file.assert_not_called()
@mock.patch("pikepdf.open")
def test_edit_pdf_rejects_out_of_bounds_output_index(self, mock_open) -> None:
with self.assertLogs("paperless.bulk_edit", level="ERROR"):
with self.assertRaisesRegex(ValueError, "index is out of bounds"):
bulk_edit.edit_pdf(
[self.doc2.id],
[{"page": 1, "doc": 2**32}],
)
mock_open.assert_not_called()
@mock.patch("documents.bulk_edit.update_document_content_maybe_archive_file.delay") @mock.patch("documents.bulk_edit.update_document_content_maybe_archive_file.delay")
@mock.patch("documents.tasks.consume_file.apply_async") @mock.patch("documents.tasks.consume_file.apply_async")
@mock.patch("documents.bulk_edit.tempfile.mkdtemp") @mock.patch("documents.bulk_edit.tempfile.mkdtemp")
@@ -309,6 +309,9 @@ class TestEmailDocumentPermissionBoundary:
): ):
owner = User.objects.create_user(username="owner") owner = User.objects.create_user(username="owner")
requester = User.objects.create_user(username="requester") requester = User.objects.create_user(username="requester")
requester.user_permissions.add(
Permission.objects.get(codename="view_document"),
)
rest_api_client.force_authenticate(user=requester) rest_api_client.force_authenticate(user=requester)
hidden = DocumentFactory(owner=owner) hidden = DocumentFactory(owner=owner)
@@ -364,6 +367,27 @@ class TestBulkEditChangePermissionBoundary:
@pytest.mark.django_db @pytest.mark.django_db
class TestBulkDownloadPermissionChecksRootDocument: class TestBulkDownloadPermissionChecksRootDocument:
def test_download_requires_global_view_permission(
self,
rest_api_client,
paperless_dirs,
_media_settings,
):
owner = User.objects.create_user(username="owner")
requester = User.objects.create_user(username="requester")
root = DocumentFactory(owner=owner)
root.source_path.write_bytes(b"%PDF-1.4 test")
assign_perm("view_document", requester, root)
rest_api_client.force_authenticate(user=requester)
response = rest_api_client.post(
"/api/documents/bulk_download/",
{"documents": [root.pk]},
format="json",
)
assert response.status_code == HTTPStatus.FORBIDDEN
def test_permission_checked_on_root_not_on_version( def test_permission_checked_on_root_not_on_version(
self, self,
rest_api_client, rest_api_client,
@@ -372,6 +396,9 @@ class TestBulkDownloadPermissionChecksRootDocument:
): ):
owner = User.objects.create_user(username="owner") owner = User.objects.create_user(username="owner")
requester = User.objects.create_user(username="requester") requester = User.objects.create_user(username="requester")
requester.user_permissions.add(
Permission.objects.get(codename="view_document"),
)
rest_api_client.force_authenticate(user=requester) rest_api_client.force_authenticate(user=requester)
root = DocumentFactory(owner=owner) root = DocumentFactory(owner=owner)
# a version of root that the requester has NOT been individually granted # a version of root that the requester has NOT been individually granted
@@ -396,6 +423,9 @@ class TestBulkDownloadPermissionChecksRootDocument:
# `stranger` case) can't tell the two apart, since they're denied # `stranger` case) can't tell the two apart, since they're denied
# either way. # either way.
version_only_grantee = User.objects.create_user(username="version_only_grantee") version_only_grantee = User.objects.create_user(username="version_only_grantee")
version_only_grantee.user_permissions.add(
Permission.objects.get(codename="view_document"),
)
assign_perm("view_document", version_only_grantee, version) assign_perm("view_document", version_only_grantee, version)
rest_api_client.force_authenticate(user=version_only_grantee) rest_api_client.force_authenticate(user=version_only_grantee)
response = rest_api_client.post( response = rest_api_client.post(
@@ -417,6 +447,9 @@ class TestTrashRestorePermissionBoundary:
): ):
owner = User.objects.create_user(username="owner") owner = User.objects.create_user(username="owner")
requester = User.objects.create_user(username="requester") requester = User.objects.create_user(username="requester")
requester.user_permissions.add(
Permission.objects.get(codename="delete_document"),
)
rest_api_client.force_authenticate(user=requester) rest_api_client.force_authenticate(user=requester)
doc = DocumentFactory(owner=owner) doc = DocumentFactory(owner=owner)
assign_perm("view_document", requester, doc) # view only, NOT delete assign_perm("view_document", requester, doc) # view only, NOT delete
@@ -435,6 +468,9 @@ class TestTrashRestorePermissionBoundary:
): ):
owner = User.objects.create_user(username="owner") owner = User.objects.create_user(username="owner")
requester = User.objects.create_user(username="requester") requester = User.objects.create_user(username="requester")
requester.user_permissions.add(
Permission.objects.get(codename="delete_document"),
)
rest_api_client.force_authenticate(user=requester) rest_api_client.force_authenticate(user=requester)
doc = DocumentFactory(owner=owner) doc = DocumentFactory(owner=owner)
assign_perm("delete_document", requester, doc) assign_perm("delete_document", requester, doc)
@@ -447,6 +483,22 @@ class TestTrashRestorePermissionBoundary:
) )
assert response.status_code == HTTPStatus.OK assert response.status_code == HTTPStatus.OK
def test_restore_requires_global_delete_permission(self, rest_api_client):
owner = User.objects.create_user(username="owner")
requester = User.objects.create_user(username="requester")
rest_api_client.force_authenticate(user=requester)
doc = DocumentFactory(owner=owner)
assign_perm("delete_document", requester, doc)
doc.delete()
response = rest_api_client.post(
"/api/trash/",
{"documents": [doc.pk], "action": "restore"},
format="json",
)
assert response.status_code == HTTPStatus.FORBIDDEN
@pytest.mark.django_db @pytest.mark.django_db
class TestTrashViewExcludesExplicitlyGrantedDocuments: class TestTrashViewExcludesExplicitlyGrantedDocuments:
@@ -463,6 +515,9 @@ class TestTrashViewExcludesExplicitlyGrantedDocuments:
def test_explicit_grant_does_not_leak_trashed_document(self, rest_api_client): def test_explicit_grant_does_not_leak_trashed_document(self, rest_api_client):
owner = User.objects.create_user(username="trash_owner") owner = User.objects.create_user(username="trash_owner")
grantee = User.objects.create_user(username="trash_grantee") grantee = User.objects.create_user(username="trash_grantee")
grantee.user_permissions.add(
Permission.objects.get(codename="view_document"),
)
doc = DocumentFactory(owner=owner) doc = DocumentFactory(owner=owner)
doc.delete() # soft delete doc.delete() # soft delete
assign_perm("view_document", grantee, doc) assign_perm("view_document", grantee, doc)
@@ -6,8 +6,10 @@ from pathlib import Path
from unittest import mock from unittest import mock
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import Permission
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.utils import timezone from django.utils import timezone
from guardian.shortcuts import assign_perm
from rest_framework import serializers from rest_framework import serializers
from rest_framework import status from rest_framework import status
from rest_framework.test import APITestCase from rest_framework.test import APITestCase
@@ -48,6 +50,37 @@ class ShareLinkBundleAPITests(DirectoriesMixin, APITestCase):
delay_mock.assert_called_once() delay_mock.assert_called_once()
self.assertEqual(delay_mock.call_args.kwargs["kwargs"]["bundle_id"], bundle.pk) self.assertEqual(delay_mock.call_args.kwargs["kwargs"]["bundle_id"], bundle.pk)
@mock.patch("documents.views.build_share_link_bundle.apply_async")
def test_create_bundle_requires_global_document_view_permission(
self,
delay_mock,
) -> None:
owner = User.objects.create_user(username="document_owner")
requester = User.objects.create_user(username="bundle_creator")
requester.user_permissions.add(
Permission.objects.get(codename="add_sharelinkbundle"),
)
document = DocumentFactory.create(owner=owner)
assign_perm("view_document", requester, document)
self.client.force_authenticate(requester)
payload = {
"document_ids": [document.pk],
"file_version": ShareLink.FileVersion.ARCHIVE,
"expiration_days": 7,
}
response = self.client.post(self.ENDPOINT, payload, format="json")
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
requester.user_permissions.add(
Permission.objects.get(codename="view_document"),
)
requester = User.objects.get(pk=requester.pk)
self.client.force_authenticate(requester)
response = self.client.post(self.ENDPOINT, payload, format="json")
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
delay_mock.assert_called_once()
def test_create_bundle_rejects_missing_documents(self) -> None: def test_create_bundle_rejects_missing_documents(self) -> None:
payload = { payload = {
"document_ids": [9999], "document_ids": [9999],
+6
View File
@@ -141,6 +141,9 @@ class TestViews(DirectoriesMixin, TestCase):
codename__contains="sharelink", codename__contains="sharelink",
) )
self.user.user_permissions.add(*sharelink_permissions) self.user.user_permissions.add(*sharelink_permissions)
self.user.user_permissions.add(
Permission.objects.get(codename="view_document"),
)
self.user.save() self.user.save()
self.client.force_login(self.user) self.client.force_login(self.user)
@@ -202,6 +205,9 @@ class TestViews(DirectoriesMixin, TestCase):
codename__contains="sharelink", codename__contains="sharelink",
) )
self.user.user_permissions.add(*sharelink_permissions) self.user.user_permissions.add(*sharelink_permissions)
self.user.user_permissions.add(
Permission.objects.get(codename="view_document"),
)
self.client.force_login(self.user) self.client.force_login(self.user)
create_response = self.client.post( create_response = self.client.post(
+11 -4
View File
@@ -170,6 +170,7 @@ from documents.permissions import AcknowledgeTasksPermissions
from documents.permissions import PaperlessAdminPermissions from documents.permissions import PaperlessAdminPermissions
from documents.permissions import PaperlessNotePermissions from documents.permissions import PaperlessNotePermissions
from documents.permissions import PaperlessObjectPermissions from documents.permissions import PaperlessObjectPermissions
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
@@ -3519,7 +3520,7 @@ class PostDocumentView(GenericAPIView[Any]):
), ),
) )
class SelectionDataView(GenericAPIView[Any]): class SelectionDataView(GenericAPIView[Any]):
permission_classes = (IsAuthenticated,) permission_classes = (IsAuthenticated, ViewDocumentsPermissions)
serializer_class = DocumentListSerializer serializer_class = DocumentListSerializer
parser_classes = (parsers.MultiPartParser, parsers.JSONParser) parser_classes = (parsers.MultiPartParser, parsers.JSONParser)
@@ -4010,7 +4011,7 @@ class StatisticsView(GenericAPIView[Any]):
), ),
) )
class BulkDownloadView(DocumentSelectionMixin, GenericAPIView[Any]): class BulkDownloadView(DocumentSelectionMixin, GenericAPIView[Any]):
permission_classes = (IsAuthenticated,) permission_classes = (IsAuthenticated, ViewDocumentsPermissions)
serializer_class = BulkDownloadSerializer serializer_class = BulkDownloadSerializer
parser_classes = (parsers.JSONParser,) parser_classes = (parsers.JSONParser,)
@@ -4109,7 +4110,7 @@ class StoragePathViewSet(PermissionsAwareDocumentCountMixin, ModelViewSet[Storag
def get_permissions(self): def get_permissions(self):
if self.action == "test": if self.action == "test":
# Test action does not require object level permissions # Test action does not require object level permissions
self.permission_classes = (IsAuthenticated,) self.permission_classes = (IsAuthenticated, ViewDocumentsPermissions)
return super().get_permissions() return super().get_permissions()
def destroy(self, request, *args, **kwargs): def destroy(self, request, *args, **kwargs):
@@ -4676,6 +4677,12 @@ class ShareLinkBundleViewSet(PassUserMixin, ModelViewSet[ShareLinkBundle]):
filterset_class = ShareLinkBundleFilterSet filterset_class = ShareLinkBundleFilterSet
ordering_fields = ("created", "expiration", "status") ordering_fields = ("created", "expiration", "status")
def get_permissions(self):
permissions = super().get_permissions()
if self.action == "create":
permissions.append(ViewDocumentsPermissions())
return permissions
def get_queryset(self): def get_queryset(self):
return ( return (
super() super()
@@ -5494,7 +5501,7 @@ class SystemStatusView(PassUserMixin):
class TrashView(ListModelMixin, PassUserMixin): class TrashView(ListModelMixin, PassUserMixin):
permission_classes = (IsAuthenticated,) permission_classes = (IsAuthenticated, TrashPermissions)
serializer_class = TrashSerializer serializer_class = TrashSerializer
class _TrashPermittedObjectsFilter(PermittedObjectsFilter): class _TrashPermittedObjectsFilter(PermittedObjectsFilter):
+19 -19
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-09-11 15:47+0000\n" "POT-Creation-Date: 2026-09-12 23:18+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"
@@ -1632,7 +1632,7 @@ msgid "workflow runs"
msgstr "" msgstr ""
#: documents/serialisers.py:525 documents/serialisers.py:882 #: documents/serialisers.py:525 documents/serialisers.py:882
#: documents/serialisers.py:2849 documents/views.py:318 documents/views.py:2693 #: documents/serialisers.py:2854 documents/views.py:319 documents/views.py:2694
#: paperless_mail/serialisers.py:156 #: paperless_mail/serialisers.py:156
msgid "Insufficient permissions." msgid "Insufficient permissions."
msgstr "" msgstr ""
@@ -1641,39 +1641,39 @@ msgstr ""
msgid "Invalid color." msgid "Invalid color."
msgstr "" msgstr ""
#: documents/serialisers.py:2326 #: documents/serialisers.py:2327
#, python-format #, python-format
msgid "File type %(type)s not supported" msgid "File type %(type)s not supported"
msgstr "" msgstr ""
#: documents/serialisers.py:2370 #: documents/serialisers.py:2371
#, python-format #, python-format
msgid "Custom field id must be an integer: %(id)s" msgid "Custom field id must be an integer: %(id)s"
msgstr "" msgstr ""
#: documents/serialisers.py:2377 #: documents/serialisers.py:2378
#, python-format #, python-format
msgid "Custom field with id %(id)s does not exist" msgid "Custom field with id %(id)s does not exist"
msgstr "" msgstr ""
#: documents/serialisers.py:2394 documents/serialisers.py:2404 #: documents/serialisers.py:2395 documents/serialisers.py:2405
msgid "" msgid ""
"Custom fields must be a list of integers or an object mapping ids to values." "Custom fields must be a list of integers or an object mapping ids to values."
msgstr "" msgstr ""
#: documents/serialisers.py:2399 #: documents/serialisers.py:2400
msgid "Some custom fields don't exist or were specified twice." msgid "Some custom fields don't exist or were specified twice."
msgstr "" msgstr ""
#: documents/serialisers.py:2546 #: documents/serialisers.py:2547
msgid "Invalid variable detected." msgid "Invalid variable detected."
msgstr "" msgstr ""
#: documents/serialisers.py:2905 #: documents/serialisers.py:2910
msgid "Duplicate document identifiers are not allowed." msgid "Duplicate document identifiers are not allowed."
msgstr "" msgstr ""
#: documents/serialisers.py:2935 documents/views.py:4700 #: documents/serialisers.py:2940 documents/views.py:4707
#, python-format #, python-format
msgid "Documents not found: %(ids)s" msgid "Documents not found: %(ids)s"
msgstr "" msgstr ""
@@ -1941,40 +1941,40 @@ msgstr ""
msgid "Unable to parse URI {value}" msgid "Unable to parse URI {value}"
msgstr "" msgstr ""
#: documents/views.py:311 documents/views.py:2690 #: documents/views.py:312 documents/views.py:2691
msgid "Invalid more_like_id" msgid "Invalid more_like_id"
msgstr "" msgstr ""
#: documents/views.py:1645 #: documents/views.py:1646
msgid "Invalid AI configuration." msgid "Invalid AI configuration."
msgstr "" msgstr ""
#: documents/views.py:1656 #: documents/views.py:1657
msgid "AI backend request timed out." msgid "AI backend request timed out."
msgstr "" msgstr ""
#: documents/views.py:1668 #: documents/views.py:1669
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:2515 documents/views.py:2836 #: documents/views.py:2516 documents/views.py:2837
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:4713 #: documents/views.py:4720
#, 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:4759 #: documents/views.py:4766
msgid "Bundle is already being processed." msgid "Bundle is already being processed."
msgstr "" msgstr ""
#: documents/views.py:4823 #: documents/views.py:4830
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:4837 #: documents/views.py:4844
msgid "The share link bundle is unavailable." msgid "The share link bundle is unavailable."
msgstr "" msgstr ""
+30
View File
@@ -854,6 +854,36 @@ class TestAPIProcessedMails(DirectoriesMixin, APITestCase):
) )
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
def test_bulk_delete_requires_global_delete_permission(self) -> None:
owner = User.objects.create_user(username="mail_owner")
requester = User.objects.create_user(username="mail_deleter")
requester.user_permissions.add(
Permission.objects.get(codename="add_processedmail"),
)
mail = ProcessedMailFactory(owner=owner)
assign_perm("delete_processedmail", requester, mail)
self.client.force_authenticate(requester)
response = self.client.post(
f"{self.ENDPOINT}bulk_delete/",
data={"mail_ids": [mail.pk]},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
requester.user_permissions.add(
Permission.objects.get(codename="delete_processedmail"),
)
requester = User.objects.get(pk=requester.pk)
self.client.force_authenticate(requester)
response = self.client.post(
f"{self.ENDPOINT}bulk_delete/",
data={"mail_ids": [mail.pk]},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertFalse(ProcessedMail.objects.filter(pk=mail.pk).exists())
def test_bulk_delete_processed_mails_rejects_mixed_batch_atomically(self) -> None: def test_bulk_delete_processed_mails_rejects_mixed_batch_atomically(self) -> None:
""" """
GIVEN: GIVEN:
+15 -1
View File
@@ -18,6 +18,7 @@ from rest_framework import serializers
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.filters import OrderingFilter from rest_framework.filters import OrderingFilter
from rest_framework.generics import GenericAPIView from rest_framework.generics import GenericAPIView
from rest_framework.permissions import BasePermission
from rest_framework.permissions import IsAuthenticated from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.viewsets import ModelViewSet from rest_framework.viewsets import ModelViewSet
@@ -44,6 +45,15 @@ from paperless_mail.serialisers import ProcessedMailSerializer
from paperless_mail.tasks import process_mail_accounts from paperless_mail.tasks import process_mail_accounts
class DeleteProcessedMailPermissions(BasePermission):
def has_permission(self, request, view):
return bool(
request.user
and request.user.is_authenticated
and request.user.has_perm("paperless_mail.delete_processedmail"),
)
@extend_schema_view( @extend_schema_view(
test=extend_schema( test=extend_schema(
operation_id="mail_account_test", operation_id="mail_account_test",
@@ -206,7 +216,11 @@ class ProcessedMailViewSet(PassUserMixin, ReadOnlyModelViewSet[ProcessedMail]):
queryset = ProcessedMail.objects.all().order_by("-processed") queryset = ProcessedMail.objects.all().order_by("-processed")
@action(methods=["post"], detail=False) @action(
methods=["post"],
detail=False,
permission_classes=[IsAuthenticated, DeleteProcessedMailPermissions],
)
def bulk_delete(self, request): def bulk_delete(self, request):
mail_ids = request.data.get("mail_ids", []) mail_ids = request.data.get("mail_ids", [])
if not isinstance(mail_ids, list) or not all( if not isinstance(mail_ids, list) or not all(