]> git.ipfire.org Git - thirdparty/paperless-ngx.git/commitdiff
Basic remove password bulk edit action
authorshamoon <4887959+shamoon@users.noreply.github.com>
Sat, 1 Nov 2025 04:09:17 +0000 (21:09 -0700)
committershamoon <4887959+shamoon@users.noreply.github.com>
Tue, 4 Nov 2025 01:34:09 +0000 (17:34 -0800)
docs/api.md
src-ui/src/app/components/document-detail/document-detail.component.html
src-ui/src/app/components/document-detail/document-detail.component.ts
src-ui/src/main.ts
src/documents/bulk_edit.py
src/documents/serialisers.py
src/documents/views.py

index f7e12bf67a1274534564c6ee426a5bc934dff63c..4e4941ca320bfc40e98b33d0301e76577efe6c4c 100644 (file)
@@ -294,6 +294,9 @@ The following methods are supported:
         -   `"delete_original": true` to delete the original documents after editing.
         -   `"update_document": true` to update the existing document with the edited PDF.
         -   `"include_metadata": true` to copy metadata from the original document to the edited document.
+-   `remove_password`
+    -   Requires `parameters`:
+        -   `"password": "PASSWORD_STRING"` The password to remove from the PDF documents.
 -   `merge`
     -   No additional `parameters` required.
     -   The ordering of the merged document is determined by the list of IDs.
index d8cd2d756ac927a6902bdfb8e418ca8fe330dc82..204119ddf70f21d694faf883074d21a70cdcd3ab 100644 (file)
       <button ngbDropdownItem (click)="editPdf()" [disabled]="!userIsOwner || !userCanEdit || originalContentRenderType !== ContentRenderType.PDF">
         <i-bs name="pencil"></i-bs>&nbsp;<ng-container i18n>PDF Editor</ng-container>
       </button>
+
+      @if (requiresPassword || password) {
+        <button ngbDropdownItem (click)="removePassword()" [disabled]="!userIsOwner || !password">
+          <i-bs name="unlock"></i-bs>&nbsp;<ng-container i18n>Remove Password</ng-container>
+        </button>
+      }
     </div>
   </div>
 
index 9c0c845929d85e4362c33754eeb00901c7289d52..f8aec81b6d15d1138d8df9e2ad27994c4ffc1104 100644 (file)
@@ -1428,6 +1428,37 @@ export class DocumentDetailComponent
       })
   }
 
+  removePassword() {
+    if (this.requiresPassword || !this.password) {
+      this.toastService.showError(
+        $localize`Please enter the current password before attempting to remove it.`
+      )
+      return
+    }
+    this.networkActive = true
+    this.documentsService
+      .bulkEdit([this.document.id], 'remove_password', {
+        password: this.password,
+      })
+      .pipe(first(), takeUntil(this.unsubscribeNotifier))
+      .subscribe({
+        next: () => {
+          this.toastService.showInfo(
+            $localize`Password removal operation for "${this.document.title}" will begin in the background.`
+          )
+          this.networkActive = false
+          this.openDocumentService.refreshDocument(this.documentId)
+        },
+        error: (error) => {
+          this.networkActive = false
+          this.toastService.showError(
+            $localize`Error executing password removal operation`,
+            error
+          )
+        },
+      })
+  }
+
   printDocument() {
     const printUrl = this.documentsService.getDownloadUrl(
       this.document.id,
index 7e57edcea13f906eab04870d7b917db9ab5025f7..76a9e923290d109061694a26268d281f2e4ede83 100644 (file)
@@ -132,6 +132,7 @@ import {
   threeDotsVertical,
   trash,
   uiRadios,
+  unlock,
   upcScan,
   windowStack,
   x,
@@ -346,6 +347,7 @@ const icons = {
   threeDotsVertical,
   trash,
   uiRadios,
+  unlock,
   upcScan,
   windowStack,
   x,
index 73cc4799074a220d2941b4c58f92580f53087a93..0fb6afb63bd20e533571bd8814fa63a4e935017e 100644 (file)
@@ -644,6 +644,77 @@ def edit_pdf(
     return "OK"
 
 
+def remove_password(
+    doc_ids: list[int],
+    password: str,
+    *,
+    delete_original: bool = False,
+    update_document: bool = False,
+    include_metadata: bool = True,
+    user: User | None = None,
+) -> Literal["OK"]:
+    """
+    Remove password protection from PDF documents.
+    """
+    import pikepdf
+
+    for doc_id in doc_ids:
+        doc = Document.objects.get(id=doc_id)
+        try:
+            logger.info(
+                f"Attempting password removal from document {doc_ids[0]}",
+            )
+            with pikepdf.open(doc.source_path, password=password) as pdf:
+                temp_path = doc.source_path.with_suffix(".tmp.pdf")
+                pdf.remove_unreferenced_resources()
+                pdf.save(temp_path)
+
+                if update_document:
+                    # replace the original document with the unprotected one
+                    temp_path.replace(doc.source_path)
+                    doc.checksum = hashlib.md5(doc.source_path.read_bytes()).hexdigest()
+                    doc.page_count = len(pdf.pages)
+                    doc.save()
+                    update_document_content_maybe_archive_file.delay(document_id=doc.id)
+                else:
+                    consume_tasks = []
+                    overrides = (
+                        DocumentMetadataOverrides().from_document(doc)
+                        if include_metadata
+                        else DocumentMetadataOverrides()
+                    )
+                    if user is not None:
+                        overrides.owner_id = user.id
+
+                    filepath: Path = (
+                        Path(tempfile.mkdtemp(dir=settings.SCRATCH_DIR))
+                        / f"{doc.id}_unprotected.pdf"
+                    )
+                    temp_path.replace(filepath)
+                    consume_tasks.append(
+                        consume_file.s(
+                            ConsumableDocument(
+                                source=DocumentSource.ConsumeFolder,
+                                original_file=filepath,
+                            ),
+                            overrides,
+                        ),
+                    )
+
+                    if delete_original:
+                        chord(header=consume_tasks, body=delete.si([doc.id])).delay()
+                    else:
+                        group(consume_tasks).delay()
+
+        except Exception as e:
+            logger.exception(f"Error removing password from document {doc.id}: {e}")
+            raise ValueError(
+                f"An error occurred while removing the password: {e}",
+            ) from e
+
+    return "OK"
+
+
 def reflect_doclinks(
     document: Document,
     field: CustomField,
index f04bb70daa7be4981217f0d9b313148a107c9008..09d82f9e2282462a7cc0d42de2186be334884a26 100644 (file)
@@ -1400,6 +1400,7 @@ class BulkEditSerializer(
             "split",
             "delete_pages",
             "edit_pdf",
+            "remove_password",
         ],
         label="Method",
         write_only=True,
@@ -1475,6 +1476,8 @@ class BulkEditSerializer(
             return bulk_edit.delete_pages
         elif method == "edit_pdf":
             return bulk_edit.edit_pdf
+        elif method == "remove_password":
+            return bulk_edit.remove_password
         else:  # pragma: no cover
             # This will never happen as it is handled by the ChoiceField
             raise serializers.ValidationError("Unsupported method.")
@@ -1671,6 +1674,12 @@ class BulkEditSerializer(
                         f"Page {op['page']} is out of bounds for document with {doc.page_count} pages.",
                     )
 
+    def validate_parameters_remove_password(self, parameters):
+        if "password" not in parameters:
+            raise serializers.ValidationError("password not specified")
+        if not isinstance(parameters["password"], str):
+            raise serializers.ValidationError("password must be a string")
+
     def validate(self, attrs):
         method = attrs["method"]
         parameters = attrs["parameters"]
@@ -1711,6 +1720,8 @@ class BulkEditSerializer(
                     "Edit PDF method only supports one document",
                 )
             self._validate_parameters_edit_pdf(parameters, attrs["documents"][0])
+        elif method == bulk_edit.remove_password:
+            self.validate_parameters_remove_password(parameters)
 
         return attrs
 
index ec347a553274567147290bc62b3cb5a4b8a08cdc..01f4365f656c1f77163afee92078d32910d50d1b 100644 (file)
@@ -1486,6 +1486,7 @@ class BulkEditView(PassUserMixin):
         "merge": None,
         "edit_pdf": "checksum",
         "reprocess": "checksum",
+        "remove_password": "checksum",
     }
 
     permission_classes = (IsAuthenticated,)
@@ -1504,6 +1505,7 @@ class BulkEditView(PassUserMixin):
             bulk_edit.split,
             bulk_edit.merge,
             bulk_edit.edit_pdf,
+            bulk_edit.remove_password,
         ]:
             parameters["user"] = user
 
@@ -1532,6 +1534,7 @@ class BulkEditView(PassUserMixin):
                         bulk_edit.rotate,
                         bulk_edit.delete_pages,
                         bulk_edit.edit_pdf,
+                        bulk_edit.remove_password,
                     ]
                 )
                 or (
@@ -1548,7 +1551,7 @@ class BulkEditView(PassUserMixin):
                 and (
                     method in [bulk_edit.split, bulk_edit.merge]
                     or (
-                        method == bulk_edit.edit_pdf
+                        method in [bulk_edit.edit_pdf, bulk_edit.remove_password]
                         and not parameters["update_document"]
                     )
                 )