diff --git a/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html b/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html index 5af53e79d..7f086ec63 100644 --- a/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html +++ b/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html @@ -430,6 +430,24 @@ } + @case (WorkflowActionType.PasswordRemoval) { +
+
+

+ One password per line. The workflow will try them in order until one succeeds. +

+ +
+
+ } } diff --git a/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.spec.ts b/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.spec.ts index ac8a5d2c7..070e5124f 100644 --- a/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.spec.ts +++ b/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.spec.ts @@ -3,6 +3,7 @@ import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http' import { provideHttpClientTesting } from '@angular/common/http/testing' import { ComponentFixture, TestBed } from '@angular/core/testing' import { + FormArray, FormControl, FormGroup, FormsModule, @@ -994,4 +995,32 @@ describe('WorkflowEditDialogComponent', () => { component.removeSelectedCustomField(3, formGroup) expect(formGroup.get('assign_custom_fields').value).toEqual([]) }) + + it('should handle parsing of passwords from array to string and back on save', () => { + const passwordAction: WorkflowAction = { + id: 1, + type: WorkflowActionType.PasswordRemoval, + passwords: ['pass1', 'pass2'], + } + component.object = { + name: 'Workflow with Passwords', + id: 1, + order: 1, + enabled: true, + triggers: [], + actions: [passwordAction], + } + component.ngOnInit() + + const formActions = component.objectForm.get('actions') as FormArray + expect(formActions.value[0].passwords).toBe('pass1\npass2') + formActions.at(0).get('passwords').setValue('pass1\npass2\npass3') + component.save() + + expect(component.objectForm.get('actions').value[0].passwords).toEqual([ + 'pass1', + 'pass2', + 'pass3', + ]) + }) }) diff --git a/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts b/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts index 94d8318e0..37d8bef0d 100644 --- a/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts +++ b/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts @@ -139,6 +139,10 @@ export const WORKFLOW_ACTION_OPTIONS = [ id: WorkflowActionType.Webhook, name: $localize`Webhook`, }, + { + id: WorkflowActionType.PasswordRemoval, + name: $localize`Password removal`, + }, ] export enum TriggerFilterType { @@ -1202,11 +1206,25 @@ export class WorkflowEditDialogComponent headers: new FormControl(action.webhook?.headers), include_document: new FormControl(!!action.webhook?.include_document), }), + passwords: new FormControl( + this.formatPasswords(action.passwords ?? []) + ), }), { emitEvent } ) } + private formatPasswords(passwords: string[] = []): string { + return passwords.join('\n') + } + + private parsePasswords(value: string = ''): string[] { + return value + .split(/[\n,]+/) + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0) + } + private updateAllTriggerActionFields(emitEvent: boolean = false) { this.triggerFields.clear({ emitEvent: false }) this.object?.triggers.forEach((trigger) => { @@ -1331,6 +1349,7 @@ export class WorkflowEditDialogComponent headers: null, include_document: false, }, + passwords: [], } this.object.actions.push(action) this.createActionField(action) @@ -1367,6 +1386,7 @@ export class WorkflowEditDialogComponent if (action.type !== WorkflowActionType.Email) { action.email = null } + action.passwords = this.parsePasswords(action.passwords as any) }) super.save() } diff --git a/src-ui/src/app/data/workflow-action.ts b/src-ui/src/app/data/workflow-action.ts index 06c46806e..ff1509693 100644 --- a/src-ui/src/app/data/workflow-action.ts +++ b/src-ui/src/app/data/workflow-action.ts @@ -5,6 +5,7 @@ export enum WorkflowActionType { Removal = 2, Email = 3, Webhook = 4, + PasswordRemoval = 5, } export interface WorkflowActionEmail extends ObjectWithId { @@ -97,4 +98,6 @@ export interface WorkflowAction extends ObjectWithId { email?: WorkflowActionEmail webhook?: WorkflowActionWebhook + + passwords?: string[] } diff --git a/src/documents/migrations/0009_workflowaction_passwords_alter_workflowaction_type.py b/src/documents/migrations/0009_workflowaction_passwords_alter_workflowaction_type.py new file mode 100644 index 000000000..ae3fef79f --- /dev/null +++ b/src/documents/migrations/0009_workflowaction_passwords_alter_workflowaction_type.py @@ -0,0 +1,38 @@ +# Generated by Django 5.2.7 on 2025-12-29 03:56 + +from django.db import migrations +from django.db import models + + +class Migration(migrations.Migration): + dependencies = [ + ("documents", "0008_sharelinkbundle"), + ] + + operations = [ + migrations.AddField( + model_name="workflowaction", + name="passwords", + field=models.JSONField( + blank=True, + help_text="Passwords to try when removing PDF protection. Separate with commas or new lines.", + null=True, + verbose_name="passwords", + ), + ), + migrations.AlterField( + model_name="workflowaction", + name="type", + field=models.PositiveIntegerField( + choices=[ + (1, "Assignment"), + (2, "Removal"), + (3, "Email"), + (4, "Webhook"), + (5, "Password removal"), + ], + default=1, + verbose_name="Workflow Action Type", + ), + ), + ] diff --git a/src/documents/models.py b/src/documents/models.py index 2e187e98c..5a813f9b5 100644 --- a/src/documents/models.py +++ b/src/documents/models.py @@ -1405,6 +1405,10 @@ class WorkflowAction(models.Model): 4, _("Webhook"), ) + PASSWORD_REMOVAL = ( + 5, + _("Password removal"), + ) type = models.PositiveIntegerField( _("Workflow Action Type"), @@ -1634,6 +1638,15 @@ class WorkflowAction(models.Model): verbose_name=_("webhook"), ) + passwords = models.JSONField( + _("passwords"), + null=True, + blank=True, + help_text=_( + "Passwords to try when removing PDF protection. Separate with commas or new lines.", + ), + ) + class Meta: verbose_name = _("workflow action") verbose_name_plural = _("workflow actions") diff --git a/src/documents/serialisers.py b/src/documents/serialisers.py index cfd2ad3cf..5fd159772 100644 --- a/src/documents/serialisers.py +++ b/src/documents/serialisers.py @@ -2627,6 +2627,7 @@ class WorkflowActionSerializer(serializers.ModelSerializer): "remove_change_groups", "email", "webhook", + "passwords", ] def validate(self, attrs): @@ -2683,6 +2684,23 @@ class WorkflowActionSerializer(serializers.ModelSerializer): "Webhook data is required for webhook actions", ) + if ( + "type" in attrs + and attrs["type"] == WorkflowAction.WorkflowActionType.PASSWORD_REMOVAL + ): + passwords = attrs.get("passwords") + # ensure passwords is a non-empty list of non-empty strings + if ( + passwords is None + or not isinstance(passwords, list) + or len(passwords) == 0 + or any(not isinstance(pw, str) for pw in passwords) + or any(len(pw.strip()) == 0 for pw in passwords) + ): + raise serializers.ValidationError( + "Passwords are required for password removal actions", + ) + return attrs diff --git a/src/documents/signals/handlers.py b/src/documents/signals/handlers.py index 8ef5cad04..47ebab6f5 100644 --- a/src/documents/signals/handlers.py +++ b/src/documents/signals/handlers.py @@ -48,6 +48,7 @@ from documents.permissions import get_objects_for_user_owner_aware from documents.templating.utils import convert_format_str_to_template_format from documents.workflows.actions import build_workflow_action_context from documents.workflows.actions import execute_email_action +from documents.workflows.actions import execute_password_removal_action from documents.workflows.actions import execute_webhook_action from documents.workflows.mutations import apply_assignment_to_document from documents.workflows.mutations import apply_assignment_to_overrides @@ -831,6 +832,8 @@ def run_workflows( logging_group, original_file, ) + elif action.type == WorkflowAction.WorkflowActionType.PASSWORD_REMOVAL: + execute_password_removal_action(action, document, logging_group) if not use_overrides: # limit title to 128 characters diff --git a/src/documents/tests/test_api_workflows.py b/src/documents/tests/test_api_workflows.py index a11cb490a..f07b2b60c 100644 --- a/src/documents/tests/test_api_workflows.py +++ b/src/documents/tests/test_api_workflows.py @@ -838,3 +838,61 @@ class TestApiWorkflows(DirectoriesMixin, APITestCase): self.assertEqual(response.status_code, status.HTTP_200_OK) self.action.refresh_from_db() self.assertEqual(self.action.assign_title, "Patched Title") + + def test_password_action_passwords_field(self): + """ + GIVEN: + - Nothing + WHEN: + - A workflow password removal action is created with passwords set + THEN: + - The passwords field is correctly stored and retrieved + """ + passwords = ["password1", "password2", "password3"] + response = self.client.post( + "/api/workflow_actions/", + json.dumps( + { + "type": WorkflowAction.WorkflowActionType.PASSWORD_REMOVAL, + "passwords": passwords, + }, + ), + content_type="application/json", + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(response.data["passwords"], passwords) + + def test_password_action_invalid_passwords_field(self): + """ + GIVEN: + - Nothing + WHEN: + - A workflow password removal action is created with invalid passwords field + THEN: + - The required validation error is raised + """ + for payload in [ + {"type": WorkflowAction.WorkflowActionType.PASSWORD_REMOVAL}, + { + "type": WorkflowAction.WorkflowActionType.PASSWORD_REMOVAL, + "passwords": "", + }, + { + "type": WorkflowAction.WorkflowActionType.PASSWORD_REMOVAL, + "passwords": [], + }, + { + "type": WorkflowAction.WorkflowActionType.PASSWORD_REMOVAL, + "passwords": ["", "password2"], + }, + ]: + response = self.client.post( + "/api/workflow_actions/", + json.dumps(payload), + content_type="application/json", + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn( + "Passwords are required", + str(response.data["non_field_errors"][0]), + ) diff --git a/src/documents/tests/test_workflows.py b/src/documents/tests/test_workflows.py index 964d7eef6..1cd0a9826 100644 --- a/src/documents/tests/test_workflows.py +++ b/src/documents/tests/test_workflows.py @@ -2,6 +2,7 @@ import datetime import json import shutil import socket +import tempfile from datetime import timedelta from pathlib import Path from typing import TYPE_CHECKING @@ -60,6 +61,7 @@ from documents.tests.utils import DirectoriesMixin from documents.tests.utils import DummyProgressManager from documents.tests.utils import FileSystemAssertsMixin from documents.tests.utils import SampleDirMixin +from documents.workflows.actions import execute_password_removal_action from paperless_mail.models import MailAccount from paperless_mail.models import MailRule @@ -3722,6 +3724,196 @@ class TestWorkflows( mock_post.assert_called_once() + @mock.patch("documents.bulk_edit.remove_password") + def test_password_removal_action_attempts_multiple_passwords( + self, + mock_remove_password, + ): + """ + GIVEN: + - Workflow password removal action + - Multiple passwords provided + WHEN: + - Document updated triggering the workflow + THEN: + - Password removal is attempted until one succeeds + """ + doc = Document.objects.create( + title="Protected", + checksum="pw-checksum", + ) + trigger = WorkflowTrigger.objects.create( + type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED, + ) + action = WorkflowAction.objects.create( + type=WorkflowAction.WorkflowActionType.PASSWORD_REMOVAL, + passwords="wrong, right\n extra ", + ) + workflow = Workflow.objects.create(name="Password workflow") + workflow.triggers.add(trigger) + workflow.actions.add(action) + + mock_remove_password.side_effect = [ + ValueError("wrong password"), + "OK", + ] + + run_workflows(trigger.type, doc) + + assert mock_remove_password.call_count == 2 + mock_remove_password.assert_has_calls( + [ + mock.call( + [doc.id], + password="wrong", + update_document=True, + user=doc.owner, + ), + mock.call( + [doc.id], + password="right", + update_document=True, + user=doc.owner, + ), + ], + ) + + @mock.patch("documents.bulk_edit.remove_password") + def test_password_removal_action_fails_without_correct_password( + self, + mock_remove_password, + ): + """ + GIVEN: + - Workflow password removal action + - No correct password provided + WHEN: + - Document updated triggering the workflow + THEN: + - Password removal is attempted for all passwords and fails + """ + doc = Document.objects.create( + title="Protected", + checksum="pw-checksum-2", + ) + trigger = WorkflowTrigger.objects.create( + type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED, + ) + action = WorkflowAction.objects.create( + type=WorkflowAction.WorkflowActionType.PASSWORD_REMOVAL, + passwords=" \n , ", + ) + workflow = Workflow.objects.create(name="Password workflow missing passwords") + workflow.triggers.add(trigger) + workflow.actions.add(action) + + run_workflows(trigger.type, doc) + + mock_remove_password.assert_not_called() + + @mock.patch("documents.bulk_edit.remove_password") + def test_password_removal_action_skips_without_passwords( + self, + mock_remove_password, + ): + """ + GIVEN: + - Workflow password removal action with no passwords + WHEN: + - Workflow is run + THEN: + - Password removal is not attempted + """ + doc = Document.objects.create( + title="Protected", + checksum="pw-checksum-2", + ) + trigger = WorkflowTrigger.objects.create( + type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED, + ) + action = WorkflowAction.objects.create( + type=WorkflowAction.WorkflowActionType.PASSWORD_REMOVAL, + passwords="", + ) + workflow = Workflow.objects.create(name="Password workflow missing passwords") + workflow.triggers.add(trigger) + workflow.actions.add(action) + + run_workflows(trigger.type, doc) + + mock_remove_password.assert_not_called() + + @mock.patch("documents.bulk_edit.remove_password") + def test_password_removal_consumable_document_deferred( + self, + mock_remove_password, + ): + """ + GIVEN: + - Workflow password removal action + - Simulated consumption trigger (a ConsumableDocument is used) + WHEN: + - Document consumption is finished + THEN: + - Password removal is attempted + """ + action = WorkflowAction.objects.create( + type=WorkflowAction.WorkflowActionType.PASSWORD_REMOVAL, + passwords="first, second", + ) + + temp_dir = Path(tempfile.mkdtemp()) + original_file = temp_dir / "file.pdf" + original_file.write_bytes(b"pdf content") + consumable = ConsumableDocument( + source=DocumentSource.ApiUpload, + original_file=original_file, + ) + + execute_password_removal_action(action, consumable, logging_group=None) + + mock_remove_password.assert_not_called() + + mock_remove_password.side_effect = [ + ValueError("bad password"), + "OK", + ] + + doc = Document.objects.create( + checksum="pw-checksum-consumed", + title="Protected", + ) + + document_consumption_finished.send( + sender=self.__class__, + document=doc, + ) + + assert mock_remove_password.call_count == 2 + mock_remove_password.assert_has_calls( + [ + mock.call( + [doc.id], + password="first", + update_document=True, + user=doc.owner, + ), + mock.call( + [doc.id], + password="second", + update_document=True, + user=doc.owner, + ), + ], + ) + + # ensure handler disconnected after first run + document_consumption_finished.send( + sender=self.__class__, + document=doc, + ) + assert mock_remove_password.call_count == 2 + class TestWebhookSend: def test_send_webhook_data_or_json( diff --git a/src/documents/workflows/actions.py b/src/documents/workflows/actions.py index a61b9930e..442bc0abe 100644 --- a/src/documents/workflows/actions.py +++ b/src/documents/workflows/actions.py @@ -1,4 +1,5 @@ import logging +import re from pathlib import Path from django.conf import settings @@ -14,6 +15,7 @@ from documents.models import Document from documents.models import DocumentType from documents.models import WorkflowAction from documents.models import WorkflowTrigger +from documents.signals import document_consumption_finished from documents.templating.workflows import parse_w_workflow_placeholders from documents.workflows.webhooks import send_webhook @@ -265,3 +267,74 @@ def execute_webhook_action( f"Error occurred sending webhook: {e}", extra={"group": logging_group}, ) + + +def execute_password_removal_action( + action: WorkflowAction, + document: Document | ConsumableDocument, + logging_group, +) -> None: + """ + Try to remove a password from a document using the configured list. + """ + passwords = action.passwords + if not passwords: + logger.warning( + "Password removal action %s has no passwords configured", + action.pk, + extra={"group": logging_group}, + ) + return + + passwords = [ + password.strip() + for password in re.split(r"[,\n]", passwords) + if password.strip() + ] + + if isinstance(document, ConsumableDocument): + # hook the consumption-finished signal to attempt password removal later + def handler(sender, **kwargs): + consumed_document: Document = kwargs.get("document") + if consumed_document is not None: + execute_password_removal_action( + action, + consumed_document, + logging_group, + ) + document_consumption_finished.disconnect(handler) + + document_consumption_finished.connect(handler, weak=False) + return + + # import here to avoid circular dependency + from documents.bulk_edit import remove_password + + for password in passwords: + try: + remove_password( + [document.id], + password=password, + update_document=True, + user=document.owner, + ) + logger.info( + "Removed password from document %s using workflow action %s", + document.pk, + action.pk, + extra={"group": logging_group}, + ) + return + except ValueError as e: + logger.warning( + "Password removal failed for document %s with supplied password: %s", + document.pk, + e, + extra={"group": logging_group}, + ) + + logger.error( + "Password removal failed for document %s after trying all provided passwords", + document.pk, + extra={"group": logging_group}, + )