mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2025-11-23 23:49:08 -06:00
Compare commits
5 Commits
v2.20.0
...
feature-pw
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1cf7b1164 | ||
|
|
a5bc2e7632 | ||
|
|
b56185fa31 | ||
|
|
4e67f13187 | ||
|
|
6dfb18bc0b |
@@ -294,6 +294,9 @@ The following methods are supported:
|
|||||||
- `"delete_original": true` to delete the original documents after editing.
|
- `"delete_original": true` to delete the original documents after editing.
|
||||||
- `"update_document": true` to update the existing document with the edited PDF.
|
- `"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.
|
- `"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`
|
- `merge`
|
||||||
- No additional `parameters` required.
|
- No additional `parameters` required.
|
||||||
- The ordering of the merged document is determined by the list of IDs.
|
- The ordering of the merged document is determined by the list of IDs.
|
||||||
|
|||||||
@@ -65,6 +65,12 @@
|
|||||||
<button ngbDropdownItem (click)="editPdf()" [disabled]="!userIsOwner || !userCanEdit || originalContentRenderType !== ContentRenderType.PDF">
|
<button ngbDropdownItem (click)="editPdf()" [disabled]="!userIsOwner || !userCanEdit || originalContentRenderType !== ContentRenderType.PDF">
|
||||||
<i-bs name="pencil"></i-bs> <ng-container i18n>PDF Editor</ng-container>
|
<i-bs name="pencil"></i-bs> <ng-container i18n>PDF Editor</ng-container>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
@if (userIsOwner && (requiresPassword || password)) {
|
||||||
|
<button ngbDropdownItem (click)="removePassword()" [disabled]="!password">
|
||||||
|
<i-bs name="unlock"></i-bs> <ng-container i18n>Remove Password</ng-container>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1209,6 +1209,24 @@ describe('DocumentDetailComponent', () => {
|
|||||||
expect(closeSpy).toHaveBeenCalled()
|
expect(closeSpy).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should support removing password protection from pdfs', () => {
|
||||||
|
initNormally()
|
||||||
|
component.password = 'secret'
|
||||||
|
component.removePassword()
|
||||||
|
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: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
req.flush(true)
|
||||||
|
})
|
||||||
|
|
||||||
it('should support keyboard shortcuts', () => {
|
it('should support keyboard shortcuts', () => {
|
||||||
initNormally()
|
initNormally()
|
||||||
|
|
||||||
|
|||||||
@@ -1428,6 +1428,38 @@ 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,
|
||||||
|
update_document: true,
|
||||||
|
})
|
||||||
|
.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() {
|
printDocument() {
|
||||||
const printUrl = this.documentsService.getDownloadUrl(
|
const printUrl = this.documentsService.getDownloadUrl(
|
||||||
this.document.id,
|
this.document.id,
|
||||||
|
|||||||
@@ -132,6 +132,7 @@ import {
|
|||||||
threeDotsVertical,
|
threeDotsVertical,
|
||||||
trash,
|
trash,
|
||||||
uiRadios,
|
uiRadios,
|
||||||
|
unlock,
|
||||||
upcScan,
|
upcScan,
|
||||||
windowStack,
|
windowStack,
|
||||||
x,
|
x,
|
||||||
@@ -346,6 +347,7 @@ const icons = {
|
|||||||
threeDotsVertical,
|
threeDotsVertical,
|
||||||
trash,
|
trash,
|
||||||
uiRadios,
|
uiRadios,
|
||||||
|
unlock,
|
||||||
upcScan,
|
upcScan,
|
||||||
windowStack,
|
windowStack,
|
||||||
x,
|
x,
|
||||||
|
|||||||
@@ -644,6 +644,77 @@ def edit_pdf(
|
|||||||
return "OK"
|
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(
|
def reflect_doclinks(
|
||||||
document: Document,
|
document: Document,
|
||||||
field: CustomField,
|
field: CustomField,
|
||||||
|
|||||||
@@ -1400,6 +1400,7 @@ class BulkEditSerializer(
|
|||||||
"split",
|
"split",
|
||||||
"delete_pages",
|
"delete_pages",
|
||||||
"edit_pdf",
|
"edit_pdf",
|
||||||
|
"remove_password",
|
||||||
],
|
],
|
||||||
label="Method",
|
label="Method",
|
||||||
write_only=True,
|
write_only=True,
|
||||||
@@ -1475,6 +1476,8 @@ class BulkEditSerializer(
|
|||||||
return bulk_edit.delete_pages
|
return bulk_edit.delete_pages
|
||||||
elif method == "edit_pdf":
|
elif method == "edit_pdf":
|
||||||
return bulk_edit.edit_pdf
|
return bulk_edit.edit_pdf
|
||||||
|
elif method == "remove_password":
|
||||||
|
return bulk_edit.remove_password
|
||||||
else: # pragma: no cover
|
else: # pragma: no cover
|
||||||
# This will never happen as it is handled by the ChoiceField
|
# This will never happen as it is handled by the ChoiceField
|
||||||
raise serializers.ValidationError("Unsupported method.")
|
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.",
|
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):
|
def validate(self, attrs):
|
||||||
method = attrs["method"]
|
method = attrs["method"]
|
||||||
parameters = attrs["parameters"]
|
parameters = attrs["parameters"]
|
||||||
@@ -1711,6 +1720,8 @@ class BulkEditSerializer(
|
|||||||
"Edit PDF method only supports one document",
|
"Edit PDF method only supports one document",
|
||||||
)
|
)
|
||||||
self._validate_parameters_edit_pdf(parameters, attrs["documents"][0])
|
self._validate_parameters_edit_pdf(parameters, attrs["documents"][0])
|
||||||
|
elif method == bulk_edit.remove_password:
|
||||||
|
self.validate_parameters_remove_password(parameters)
|
||||||
|
|
||||||
return attrs
|
return attrs
|
||||||
|
|
||||||
|
|||||||
@@ -1582,6 +1582,58 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
self.assertIn(b"out of bounds", response.content)
|
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)
|
@override_settings(AUDIT_LOG_ENABLED=True)
|
||||||
def test_bulk_edit_audit_log_enabled_simple_field(self):
|
def test_bulk_edit_audit_log_enabled_simple_field(self):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import hashlib
|
||||||
import shutil
|
import shutil
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -1062,3 +1063,101 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
bulk_edit.edit_pdf(doc_ids, operations, update_document=True)
|
bulk_edit.edit_pdf(doc_ids, operations, update_document=True)
|
||||||
mock_group.assert_not_called()
|
mock_group.assert_not_called()
|
||||||
mock_consume_file.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("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])
|
||||||
|
|||||||
@@ -1486,6 +1486,7 @@ class BulkEditView(PassUserMixin):
|
|||||||
"merge": None,
|
"merge": None,
|
||||||
"edit_pdf": "checksum",
|
"edit_pdf": "checksum",
|
||||||
"reprocess": "checksum",
|
"reprocess": "checksum",
|
||||||
|
"remove_password": "checksum",
|
||||||
}
|
}
|
||||||
|
|
||||||
permission_classes = (IsAuthenticated,)
|
permission_classes = (IsAuthenticated,)
|
||||||
@@ -1504,6 +1505,7 @@ class BulkEditView(PassUserMixin):
|
|||||||
bulk_edit.split,
|
bulk_edit.split,
|
||||||
bulk_edit.merge,
|
bulk_edit.merge,
|
||||||
bulk_edit.edit_pdf,
|
bulk_edit.edit_pdf,
|
||||||
|
bulk_edit.remove_password,
|
||||||
]:
|
]:
|
||||||
parameters["user"] = user
|
parameters["user"] = user
|
||||||
|
|
||||||
@@ -1532,6 +1534,7 @@ class BulkEditView(PassUserMixin):
|
|||||||
bulk_edit.rotate,
|
bulk_edit.rotate,
|
||||||
bulk_edit.delete_pages,
|
bulk_edit.delete_pages,
|
||||||
bulk_edit.edit_pdf,
|
bulk_edit.edit_pdf,
|
||||||
|
bulk_edit.remove_password,
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
or (
|
or (
|
||||||
@@ -1548,7 +1551,7 @@ class BulkEditView(PassUserMixin):
|
|||||||
and (
|
and (
|
||||||
method in [bulk_edit.split, bulk_edit.merge]
|
method in [bulk_edit.split, bulk_edit.merge]
|
||||||
or (
|
or (
|
||||||
method == bulk_edit.edit_pdf
|
method in [bulk_edit.edit_pdf, bulk_edit.remove_password]
|
||||||
and not parameters["update_document"]
|
and not parameters["update_document"]
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user