From 1aaf128bcb970251f536c46ec9017e6e3f70f40c Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Sun, 28 Dec 2025 20:05:46 -0800 Subject: [PATCH] Enhancement: password removal workflow action --- .../workflow-edit-dialog.component.html | 16 +++++ .../workflow-edit-dialog.component.ts | 5 ++ src-ui/src/app/data/workflow-action.ts | 3 + ...ion_passwords_alter_workflowaction_type.py | 38 ++++++++++ src/documents/models.py | 13 ++++ src/documents/serialisers.py | 15 ++++ src/documents/signals/handlers.py | 9 +++ src/documents/tests/test_workflows.py | 69 +++++++++++++++++++ src/documents/workflows/actions.py | 65 +++++++++++++++++ 9 files changed, 233 insertions(+) create mode 100644 src/documents/migrations/1075_workflowaction_passwords_alter_workflowaction_type.py 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..36ceace2e 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,22 @@ } + @case (WorkflowActionType.PasswordRemoval) { +
+
+

+ One or more passwords separated by commas or new lines. 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.ts b/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts index f6d9e60f5..1fcc84c5d 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 { @@ -1133,6 +1137,7 @@ export class WorkflowEditDialogComponent headers: new FormControl(action.webhook?.headers), include_document: new FormControl(!!action.webhook?.include_document), }), + passwords: new FormControl(action.passwords), }), { emitEvent } ) diff --git a/src-ui/src/app/data/workflow-action.ts b/src-ui/src/app/data/workflow-action.ts index 06c46806e..fcbb7454b 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/1075_workflowaction_passwords_alter_workflowaction_type.py b/src/documents/migrations/1075_workflowaction_passwords_alter_workflowaction_type.py new file mode 100644 index 000000000..e712fd7cd --- /dev/null +++ b/src/documents/migrations/1075_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", "1074_workflowrun_deleted_at_workflowrun_restored_at_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="workflowaction", + name="passwords", + field=models.TextField( + 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 12dab2b6d..a6f62ae55 100644 --- a/src/documents/models.py +++ b/src/documents/models.py @@ -1287,6 +1287,10 @@ class WorkflowAction(models.Model): 4, _("Webhook"), ) + PASSWORD_REMOVAL = ( + 5, + _("Password removal"), + ) type = models.PositiveIntegerField( _("Workflow Action Type"), @@ -1514,6 +1518,15 @@ class WorkflowAction(models.Model): verbose_name=_("webhook"), ) + passwords = models.TextField( + _("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 9c8fb4e4e..8a773ab4a 100644 --- a/src/documents/serialisers.py +++ b/src/documents/serialisers.py @@ -2440,6 +2440,7 @@ class WorkflowActionSerializer(serializers.ModelSerializer): "remove_change_groups", "email", "webhook", + "passwords", ] def validate(self, attrs): @@ -2496,6 +2497,20 @@ 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") + if passwords is None or not isinstance(passwords, str): + raise serializers.ValidationError( + "Passwords are required for password removal actions", + ) + if not passwords.strip(): + 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 5f2c8b4b2..f246f32e5 100644 --- a/src/documents/signals/handlers.py +++ b/src/documents/signals/handlers.py @@ -46,6 +46,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 @@ -792,6 +793,14 @@ def run_workflows( logging_group, original_file, ) + elif action.type == WorkflowAction.WorkflowActionType.PASSWORD_REMOVAL: + if use_overrides: + logger.info( + "Skipping password removal action during consumption workflow", + extra={"group": logging_group}, + ) + continue + execute_password_removal_action(action, document, logging_group) if not use_overrides: # limit title to 128 characters diff --git a/src/documents/tests/test_workflows.py b/src/documents/tests/test_workflows.py index 249183b6e..01443798d 100644 --- a/src/documents/tests/test_workflows.py +++ b/src/documents/tests/test_workflows.py @@ -3548,6 +3548,75 @@ 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, + ): + 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_skips_without_passwords( + self, + mock_remove_password, + ): + 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() + class TestWebhookSend: def test_send_webhook_data_or_json( diff --git a/src/documents/workflows/actions.py b/src/documents/workflows/actions.py index 040cbc127..37853ba8c 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 @@ -259,3 +260,67 @@ def execute_webhook_action( f"Error occurred sending webhook: {e}", extra={"group": logging_group}, ) + + +def parse_passwords(raw_passwords: str | None) -> list[str]: + """ + Convert a comma/newline separated string of passwords into a clean list. + """ + if not raw_passwords: + return [] + + return [ + password.strip() + for password in re.split(r"[,\n]", raw_passwords) + if password.strip() + ] + + +def execute_password_removal_action( + action: WorkflowAction, + document: Document, + logging_group, +) -> None: + """ + Try to remove a password from a document using the configured list. + """ + passwords = parse_passwords(action.passwords) + if not passwords: + logger.warning( + "Password removal action %s has no passwords configured", + action.pk, + extra={"group": logging_group}, + ) + 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}, + )