From 6dfb18bc0bad8368354fca06b95b9dcd59350fb7 Mon Sep 17 00:00:00 2001
From: shamoon <4887959+shamoon@users.noreply.github.com>
Date: Fri, 31 Oct 2025 21:09:17 -0700
Subject: [PATCH] Basic remove password bulk edit action
---
docs/api.md | 3 +
.../document-detail.component.html | 6 ++
.../document-detail.component.ts | 31 ++++++++
src-ui/src/main.ts | 2 +
src/documents/bulk_edit.py | 71 +++++++++++++++++++
src/documents/serialisers.py | 11 +++
src/documents/views.py | 5 +-
7 files changed, 128 insertions(+), 1 deletion(-)
diff --git a/docs/api.md b/docs/api.md
index f7e12bf67..4e4941ca3 100644
--- a/docs/api.md
+++ b/docs/api.md
@@ -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.
diff --git a/src-ui/src/app/components/document-detail/document-detail.component.html b/src-ui/src/app/components/document-detail/document-detail.component.html
index d8cd2d756..204119ddf 100644
--- a/src-ui/src/app/components/document-detail/document-detail.component.html
+++ b/src-ui/src/app/components/document-detail/document-detail.component.html
@@ -65,6 +65,12 @@
+
+ @if (requiresPassword || password) {
+
+ }
diff --git a/src-ui/src/app/components/document-detail/document-detail.component.ts b/src-ui/src/app/components/document-detail/document-detail.component.ts
index 9c0c84592..f8aec81b6 100644
--- a/src-ui/src/app/components/document-detail/document-detail.component.ts
+++ b/src-ui/src/app/components/document-detail/document-detail.component.ts
@@ -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,
diff --git a/src-ui/src/main.ts b/src-ui/src/main.ts
index 7e57edcea..76a9e9232 100644
--- a/src-ui/src/main.ts
+++ b/src-ui/src/main.ts
@@ -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,
diff --git a/src/documents/bulk_edit.py b/src/documents/bulk_edit.py
index 73cc47990..0fb6afb63 100644
--- a/src/documents/bulk_edit.py
+++ b/src/documents/bulk_edit.py
@@ -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,
diff --git a/src/documents/serialisers.py b/src/documents/serialisers.py
index f04bb70da..09d82f9e2 100644
--- a/src/documents/serialisers.py
+++ b/src/documents/serialisers.py
@@ -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
diff --git a/src/documents/views.py b/src/documents/views.py
index 822647fdb..113f164af 100644
--- a/src/documents/views.py
+++ b/src/documents/views.py
@@ -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"]
)
)