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"] ) )