diff --git a/docs/api.md b/docs/api.md index f7e12bf67..1ac634162 100644 --- a/docs/api.md +++ b/docs/api.md @@ -294,6 +294,13 @@ 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. + - Optional `parameters`: + - `"update_document": true` to replace the existing document with the password-less PDF. + - `"delete_original": true` to delete the original document after editing. + - `"include_metadata": true` to copy metadata from the original document to the new password-less document. - `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/common/confirm-dialog/password-removal-confirm-dialog/password-removal-confirm-dialog.component.html b/src-ui/src/app/components/common/confirm-dialog/password-removal-confirm-dialog/password-removal-confirm-dialog.component.html new file mode 100644 index 000000000..fc866fe40 --- /dev/null +++ b/src-ui/src/app/components/common/confirm-dialog/password-removal-confirm-dialog/password-removal-confirm-dialog.component.html @@ -0,0 +1,75 @@ + + + diff --git a/src-ui/src/app/components/common/confirm-dialog/password-removal-confirm-dialog/password-removal-confirm-dialog.component.scss b/src-ui/src/app/components/common/confirm-dialog/password-removal-confirm-dialog/password-removal-confirm-dialog.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/src-ui/src/app/components/common/confirm-dialog/password-removal-confirm-dialog/password-removal-confirm-dialog.component.spec.ts b/src-ui/src/app/components/common/confirm-dialog/password-removal-confirm-dialog/password-removal-confirm-dialog.component.spec.ts new file mode 100644 index 000000000..a1449511b --- /dev/null +++ b/src-ui/src/app/components/common/confirm-dialog/password-removal-confirm-dialog/password-removal-confirm-dialog.component.spec.ts @@ -0,0 +1,53 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing' +import { By } from '@angular/platform-browser' +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' +import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' +import { PasswordRemovalConfirmDialogComponent } from './password-removal-confirm-dialog.component' + +describe('PasswordRemovalConfirmDialogComponent', () => { + let component: PasswordRemovalConfirmDialogComponent + let fixture: ComponentFixture + + beforeEach(async () => { + await TestBed.configureTestingModule({ + providers: [NgbActiveModal], + imports: [ + NgxBootstrapIconsModule.pick(allIcons), + PasswordRemovalConfirmDialogComponent, + ], + }).compileComponents() + + fixture = TestBed.createComponent(PasswordRemovalConfirmDialogComponent) + component = fixture.componentInstance + fixture.detectChanges() + }) + + it('should default to replacing the document', () => { + expect(component.updateDocument).toBe(true) + expect( + fixture.debugElement.query(By.css('#removeReplace')).nativeElement.checked + ).toBe(true) + }) + + it('should allow creating a new document with metadata and delete toggle', () => { + component.onUpdateDocumentChange(false) + fixture.detectChanges() + + expect(component.updateDocument).toBe(false) + expect(fixture.debugElement.query(By.css('#copyMetaRemove'))).not.toBeNull() + + component.includeMetadata = false + component.deleteOriginal = true + component.onUpdateDocumentChange(true) + expect(component.updateDocument).toBe(true) + expect(component.includeMetadata).toBe(true) + expect(component.deleteOriginal).toBe(false) + }) + + it('should emit confirm when confirmed', () => { + let confirmed = false + component.confirmClicked.subscribe(() => (confirmed = true)) + component.confirm() + expect(confirmed).toBe(true) + }) +}) diff --git a/src-ui/src/app/components/common/confirm-dialog/password-removal-confirm-dialog/password-removal-confirm-dialog.component.ts b/src-ui/src/app/components/common/confirm-dialog/password-removal-confirm-dialog/password-removal-confirm-dialog.component.ts new file mode 100644 index 000000000..82444ad13 --- /dev/null +++ b/src-ui/src/app/components/common/confirm-dialog/password-removal-confirm-dialog/password-removal-confirm-dialog.component.ts @@ -0,0 +1,38 @@ +import { Component, Input } from '@angular/core' +import { FormsModule } from '@angular/forms' +import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' +import { ConfirmDialogComponent } from '../confirm-dialog.component' + +@Component({ + selector: 'pngx-password-removal-confirm-dialog', + templateUrl: './password-removal-confirm-dialog.component.html', + styleUrls: ['./password-removal-confirm-dialog.component.scss'], + imports: [FormsModule, NgxBootstrapIconsModule], +}) +export class PasswordRemovalConfirmDialogComponent extends ConfirmDialogComponent { + updateDocument: boolean = true + includeMetadata: boolean = true + deleteOriginal: boolean = false + + @Input() + override title = $localize`Remove password protection` + + @Input() + override message = + $localize`Create an unprotected copy or replace the existing file.` + + @Input() + override btnCaption = $localize`Start` + + constructor() { + super() + } + + onUpdateDocumentChange(updateDocument: boolean) { + this.updateDocument = updateDocument + if (this.updateDocument) { + this.deleteOriginal = false + this.includeMetadata = true + } + } +} 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 c3dbc4805..f8a942ba3 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 (userIsOwner && (requiresPassword || password)) { + + } diff --git a/src-ui/src/app/components/document-detail/document-detail.component.spec.ts b/src-ui/src/app/components/document-detail/document-detail.component.spec.ts index dada60074..b1b3650c6 100644 --- a/src-ui/src/app/components/document-detail/document-detail.component.spec.ts +++ b/src-ui/src/app/components/document-detail/document-detail.component.spec.ts @@ -66,6 +66,7 @@ import { SettingsService } from 'src/app/services/settings.service' import { ToastService } from 'src/app/services/toast.service' import { environment } from 'src/environments/environment' import { ConfirmDialogComponent } from '../common/confirm-dialog/confirm-dialog.component' +import { PasswordRemovalConfirmDialogComponent } from '../common/confirm-dialog/password-removal-confirm-dialog/password-removal-confirm-dialog.component' import { CustomFieldsDropdownComponent } from '../common/custom-fields-dropdown/custom-fields-dropdown.component' import { DocumentDetailComponent, @@ -1209,6 +1210,88 @@ describe('DocumentDetailComponent', () => { expect(closeSpy).toHaveBeenCalled() }) + it('should support removing password protection from pdfs', () => { + let modal: NgbModalRef + modalService.activeInstances.subscribe((m) => (modal = m[0])) + initNormally() + component.password = 'secret' + component.removePassword() + const dialog = + modal.componentInstance as PasswordRemovalConfirmDialogComponent + dialog.updateDocument = false + dialog.includeMetadata = false + dialog.deleteOriginal = true + dialog.confirm() + const req = httpTestingController.expectOne( + `${environment.apiBaseUrl}documents/bulk_edit/` + ) + expect(req.request.body).toEqual({ + documents: [doc.id], + method: 'remove_password', + parameters: { + password: 'secret', + update_document: false, + include_metadata: false, + delete_original: true, + }, + }) + req.flush(true) + }) + + it('should require the current password before removing it', () => { + initNormally() + const errorSpy = jest.spyOn(toastService, 'showError') + component.requiresPassword = true + component.password = '' + + component.removePassword() + + expect(errorSpy).toHaveBeenCalled() + httpTestingController.expectNone( + `${environment.apiBaseUrl}documents/bulk_edit/` + ) + }) + + it('should handle failures when removing password protection', () => { + let modal: NgbModalRef + modalService.activeInstances.subscribe((m) => (modal = m[0])) + initNormally() + const errorSpy = jest.spyOn(toastService, 'showError') + component.password = 'secret' + + component.removePassword() + const dialog = + modal.componentInstance as PasswordRemovalConfirmDialogComponent + dialog.confirm() + const req = httpTestingController.expectOne( + `${environment.apiBaseUrl}documents/bulk_edit/` + ) + req.error(new ErrorEvent('failed')) + + expect(errorSpy).toHaveBeenCalled() + expect(component.networkActive).toBe(false) + expect(dialog.buttonsEnabled).toBe(true) + }) + + it('should refresh the document when removing password in update mode', () => { + let modal: NgbModalRef + modalService.activeInstances.subscribe((m) => (modal = m[0])) + const refreshSpy = jest.spyOn(openDocumentsService, 'refreshDocument') + initNormally() + component.password = 'secret' + + component.removePassword() + const dialog = + modal.componentInstance as PasswordRemovalConfirmDialogComponent + dialog.confirm() + const req = httpTestingController.expectOne( + `${environment.apiBaseUrl}documents/bulk_edit/` + ) + req.flush(true) + + expect(refreshSpy).toHaveBeenCalledWith(doc.id) + }) + it('should support keyboard shortcuts', () => { initNormally() 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..165cf0cef 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 @@ -83,6 +83,7 @@ import { getFilenameFromContentDisposition } from 'src/app/utils/http' import { ISODateAdapter } from 'src/app/utils/ngb-iso-date-adapter' import * as UTIF from 'utif' import { ConfirmDialogComponent } from '../common/confirm-dialog/confirm-dialog.component' +import { PasswordRemovalConfirmDialogComponent } from '../common/confirm-dialog/password-removal-confirm-dialog/password-removal-confirm-dialog.component' import { CustomFieldsDropdownComponent } from '../common/custom-fields-dropdown/custom-fields-dropdown.component' import { CorrespondentEditDialogComponent } from '../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component' import { DocumentTypeEditDialogComponent } from '../common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component' @@ -175,6 +176,7 @@ export enum ZoomSetting { NgxBootstrapIconsModule, PdfViewerModule, TextAreaComponent, + PasswordRemovalConfirmDialogComponent, ], }) export class DocumentDetailComponent @@ -1428,6 +1430,63 @@ export class DocumentDetailComponent }) } + removePassword() { + if (this.requiresPassword || !this.password) { + this.toastService.showError( + $localize`Please enter the current password before attempting to remove it.` + ) + return + } + const modal = this.modalService.open( + PasswordRemovalConfirmDialogComponent, + { + backdrop: 'static', + } + ) + modal.componentInstance.title = $localize`Remove password protection` + modal.componentInstance.message = $localize`Create an unprotected copy or replace the existing file.` + modal.componentInstance.btnCaption = $localize`Start` + + modal.componentInstance.confirmClicked + .pipe(takeUntil(this.unsubscribeNotifier)) + .subscribe(() => { + const dialog = + modal.componentInstance as PasswordRemovalConfirmDialogComponent + dialog.buttonsEnabled = false + this.networkActive = true + this.documentsService + .bulkEdit([this.document.id], 'remove_password', { + password: this.password, + update_document: dialog.updateDocument, + include_metadata: dialog.includeMetadata, + delete_original: dialog.deleteOriginal, + }) + .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 + modal.close() + if (!dialog.updateDocument && dialog.deleteOriginal) { + this.openDocumentService.closeDocument(this.document) + } else if (dialog.updateDocument) { + this.openDocumentService.refreshDocument(this.documentId) + } + }, + error: (error) => { + dialog.buttonsEnabled = true + 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 b55faf227..f140536ec 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, @@ -348,6 +349,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 219947d09..43cb13261 100644 --- a/src/documents/bulk_edit.py +++ b/src/documents/bulk_edit.py @@ -646,6 +646,77 @@ def edit_pdf( return "OK" +def remove_password( + doc_ids: list[int], + password: str, + *, + update_document: bool = False, + delete_original: 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 5c71de9a9..6e2307c2e 100644 --- a/src/documents/serialisers.py +++ b/src/documents/serialisers.py @@ -1430,6 +1430,7 @@ class BulkEditSerializer( "split", "delete_pages", "edit_pdf", + "remove_password", ], label="Method", write_only=True, @@ -1505,6 +1506,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.") @@ -1701,6 +1704,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"] @@ -1741,6 +1750,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/tests/test_api_bulk_edit.py b/src/documents/tests/test_api_bulk_edit.py index 945f06b67..2ba9f1af6 100644 --- a/src/documents/tests/test_api_bulk_edit.py +++ b/src/documents/tests/test_api_bulk_edit.py @@ -1582,6 +1582,58 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase): self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertIn(b"out of bounds", response.content) + @mock.patch("documents.serialisers.bulk_edit.remove_password") + def test_remove_password(self, m): + self.setup_mock(m, "remove_password") + response = self.client.post( + "/api/documents/bulk_edit/", + json.dumps( + { + "documents": [self.doc2.id], + "method": "remove_password", + "parameters": {"password": "secret", "update_document": True}, + }, + ), + content_type="application/json", + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + m.assert_called_once() + args, kwargs = m.call_args + self.assertCountEqual(args[0], [self.doc2.id]) + self.assertEqual(kwargs["password"], "secret") + self.assertTrue(kwargs["update_document"]) + self.assertEqual(kwargs["user"], self.user) + + def test_remove_password_invalid_params(self): + response = self.client.post( + "/api/documents/bulk_edit/", + json.dumps( + { + "documents": [self.doc2.id], + "method": "remove_password", + "parameters": {}, + }, + ), + content_type="application/json", + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn(b"password not specified", response.content) + + response = self.client.post( + "/api/documents/bulk_edit/", + json.dumps( + { + "documents": [self.doc2.id], + "method": "remove_password", + "parameters": {"password": 123}, + }, + ), + content_type="application/json", + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn(b"password must be a string", response.content) + @override_settings(AUDIT_LOG_ENABLED=True) def test_bulk_edit_audit_log_enabled_simple_field(self): """ diff --git a/src/documents/tests/test_bulk_edit.py b/src/documents/tests/test_bulk_edit.py index c220c1e9b..bf5033bdc 100644 --- a/src/documents/tests/test_bulk_edit.py +++ b/src/documents/tests/test_bulk_edit.py @@ -1,3 +1,4 @@ +import hashlib import shutil from datetime import date from pathlib import Path @@ -1066,3 +1067,147 @@ class TestPDFActions(DirectoriesMixin, TestCase): bulk_edit.edit_pdf(doc_ids, operations, update_document=True) mock_group.assert_not_called() mock_consume_file.assert_not_called() + + @mock.patch("documents.bulk_edit.update_document_content_maybe_archive_file.delay") + @mock.patch("pikepdf.open") + def test_remove_password_update_document(self, mock_open, mock_update_document): + doc = self.doc1 + original_checksum = doc.checksum + + fake_pdf = mock.MagicMock() + fake_pdf.pages = [mock.Mock(), mock.Mock(), mock.Mock()] + + def save_side_effect(target_path): + Path(target_path).write_bytes(b"new pdf content") + + fake_pdf.save.side_effect = save_side_effect + mock_open.return_value.__enter__.return_value = fake_pdf + + result = bulk_edit.remove_password( + [doc.id], + password="secret", + update_document=True, + ) + + self.assertEqual(result, "OK") + mock_open.assert_called_once_with(doc.source_path, password="secret") + fake_pdf.remove_unreferenced_resources.assert_called_once() + doc.refresh_from_db() + self.assertNotEqual(doc.checksum, original_checksum) + expected_checksum = hashlib.md5(doc.source_path.read_bytes()).hexdigest() + self.assertEqual(doc.checksum, expected_checksum) + self.assertEqual(doc.page_count, len(fake_pdf.pages)) + mock_update_document.assert_called_once_with(document_id=doc.id) + + @mock.patch("documents.bulk_edit.chord") + @mock.patch("documents.bulk_edit.group") + @mock.patch("documents.tasks.consume_file.s") + @mock.patch("documents.bulk_edit.tempfile.mkdtemp") + @mock.patch("pikepdf.open") + def test_remove_password_creates_consumable_document( + self, + mock_open, + mock_mkdtemp, + mock_consume_file, + mock_group, + mock_chord, + ): + doc = self.doc2 + temp_dir = self.dirs.scratch_dir / "remove-password" + temp_dir.mkdir(parents=True, exist_ok=True) + mock_mkdtemp.return_value = str(temp_dir) + + fake_pdf = mock.MagicMock() + fake_pdf.pages = [mock.Mock(), mock.Mock()] + + def save_side_effect(target_path): + Path(target_path).write_bytes(b"password removed") + + fake_pdf.save.side_effect = save_side_effect + mock_open.return_value.__enter__.return_value = fake_pdf + mock_group.return_value.delay.return_value = None + + user = User.objects.create(username="owner") + + result = bulk_edit.remove_password( + [doc.id], + password="secret", + include_metadata=False, + update_document=False, + delete_original=False, + user=user, + ) + + self.assertEqual(result, "OK") + mock_open.assert_called_once_with(doc.source_path, password="secret") + mock_consume_file.assert_called_once() + consume_args, _ = mock_consume_file.call_args + consumable_document = consume_args[0] + overrides = consume_args[1] + expected_path = temp_dir / f"{doc.id}_unprotected.pdf" + self.assertTrue(expected_path.exists()) + self.assertEqual( + Path(consumable_document.original_file).resolve(), + expected_path.resolve(), + ) + self.assertEqual(overrides.owner_id, user.id) + mock_group.assert_called_once_with([mock_consume_file.return_value]) + mock_group.return_value.delay.assert_called_once() + mock_chord.assert_not_called() + + @mock.patch("documents.bulk_edit.delete") + @mock.patch("documents.bulk_edit.chord") + @mock.patch("documents.bulk_edit.group") + @mock.patch("documents.tasks.consume_file.s") + @mock.patch("documents.bulk_edit.tempfile.mkdtemp") + @mock.patch("pikepdf.open") + def test_remove_password_deletes_original( + self, + mock_open, + mock_mkdtemp, + mock_consume_file, + mock_group, + mock_chord, + mock_delete, + ): + doc = self.doc2 + temp_dir = self.dirs.scratch_dir / "remove-password-delete" + temp_dir.mkdir(parents=True, exist_ok=True) + mock_mkdtemp.return_value = str(temp_dir) + + fake_pdf = mock.MagicMock() + fake_pdf.pages = [mock.Mock(), mock.Mock()] + + def save_side_effect(target_path): + Path(target_path).write_bytes(b"password removed") + + fake_pdf.save.side_effect = save_side_effect + mock_open.return_value.__enter__.return_value = fake_pdf + mock_chord.return_value.delay.return_value = None + + result = bulk_edit.remove_password( + [doc.id], + password="secret", + include_metadata=False, + update_document=False, + delete_original=True, + ) + + self.assertEqual(result, "OK") + mock_open.assert_called_once_with(doc.source_path, password="secret") + mock_consume_file.assert_called_once() + mock_group.assert_not_called() + mock_chord.assert_called_once() + mock_chord.return_value.delay.assert_called_once() + mock_delete.si.assert_called_once_with([doc.id]) + + @mock.patch("pikepdf.open") + def test_remove_password_open_failure(self, mock_open): + mock_open.side_effect = RuntimeError("wrong password") + + with self.assertLogs("paperless.bulk_edit", level="ERROR") as cm: + with self.assertRaises(ValueError) as exc: + bulk_edit.remove_password([self.doc1.id], password="secret") + + self.assertIn("wrong password", str(exc.exception)) + self.assertIn("Error removing password from document", cm.output[0]) diff --git a/src/documents/views.py b/src/documents/views.py index d5910497f..680600c4b 100644 --- a/src/documents/views.py +++ b/src/documents/views.py @@ -1504,6 +1504,7 @@ class BulkEditView(PassUserMixin): "merge": None, "edit_pdf": "checksum", "reprocess": "checksum", + "remove_password": "checksum", } permission_classes = (IsAuthenticated,) @@ -1522,6 +1523,7 @@ class BulkEditView(PassUserMixin): bulk_edit.split, bulk_edit.merge, bulk_edit.edit_pdf, + bulk_edit.remove_password, ]: parameters["user"] = user @@ -1550,6 +1552,7 @@ class BulkEditView(PassUserMixin): bulk_edit.rotate, bulk_edit.delete_pages, bulk_edit.edit_pdf, + bulk_edit.remove_password, ] ) or ( @@ -1566,7 +1569,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"] ) )