diff --git a/src/documents/templates/socialaccount/authentication_error.html b/src/documents/templates/socialaccount/authentication_error.html
index 6450a1678..8891f924e 100644
--- a/src/documents/templates/socialaccount/authentication_error.html
+++ b/src/documents/templates/socialaccount/authentication_error.html
@@ -7,5 +7,5 @@
{% block form_content %}
{% url 'account_login' as login_url %}
-
{% blocktranslate %}An error occurred while attempting to login via your social network account. Back to the login page{% endblocktranslate %}
{% translate "An error occurred while attempting to login via your social network account. Back to the" %} {% translate "login page" %}
{% endblock form_content %}
diff --git a/src/documents/templates/socialaccount/login.html b/src/documents/templates/socialaccount/login.html
index 70c71ced2..4e2e595a0 100644
--- a/src/documents/templates/socialaccount/login.html
+++ b/src/documents/templates/socialaccount/login.html
@@ -7,7 +7,9 @@
{% block form_content %}
- {% blocktrans with provider.name as provider %}You are about to connect a new third-party account from {{ provider }}.{% endblocktrans %}
+ {% filter force_escape %}
+ {% blocktrans with provider=provider.name %}You are about to connect a new third-party account from {{ provider }}.{% endblocktrans %}
+ {% endfilter %}
diff --git a/src/documents/templates/socialaccount/signup.html b/src/documents/templates/socialaccount/signup.html
index 01f70a524..cf84ea935 100644
--- a/src/documents/templates/socialaccount/signup.html
+++ b/src/documents/templates/socialaccount/signup.html
@@ -7,18 +7,20 @@
{% block form_content %}
- {% blocktrans with provider_name=account.get_provider.name %}You are about to use your {{provider_name}} account to login.{% endblocktrans %}
- {% blocktrans %}As a final step, please complete the following form:{% endblocktrans %}
+ {% filter force_escape %}
+ {% blocktrans with provider_name=account.get_provider.name %}You are about to use your {{ provider_name }} account to login.{% endblocktrans %}
+ {% endfilter %}
+ {% translate "As a final step, please complete the following form:" %}
{% translate "Username" as i18n_username %}
{% translate "Email (optional)" as i18n_email %}
-
-
+
+
-
-
+
+
{% if redirect_field_value %}
diff --git a/src/documents/tests/conftest.py b/src/documents/tests/conftest.py
index a33771fd1..7e75b9194 100644
--- a/src/documents/tests/conftest.py
+++ b/src/documents/tests/conftest.py
@@ -82,8 +82,8 @@ def sample_doc(
return DocumentFactory(
title="test",
- checksum="42995833e01aea9b3edee44bbfdd7ce1",
- archive_checksum="62acb0bcbfbcaa62ca6ad3668e4e404b",
+ checksum="1093cf6e32adbd16b06969df09215d42c4a3a8938cc18b39455953f08d1ff2ab",
+ archive_checksum="706124ecde3c31616992fa979caed17a726b1c9ccdba70e82a4ff796cea97ccf",
content="test content",
pk=1,
filename="0000001.pdf",
diff --git a/src/documents/tests/factories.py b/src/documents/tests/factories.py
index d1d88587c..b0fd68428 100644
--- a/src/documents/tests/factories.py
+++ b/src/documents/tests/factories.py
@@ -60,7 +60,7 @@ class DocumentFactory(DjangoModelFactory):
model = Document
title = factory.Faker("sentence", nb_words=4)
- checksum = factory.Faker("md5")
+ checksum = factory.Faker("sha256")
content = factory.Faker("paragraph")
correspondent = None
document_type = None
diff --git a/src/documents/tests/test_consumer.py b/src/documents/tests/test_consumer.py
index df4c7d9c4..165b27e0b 100644
--- a/src/documents/tests/test_consumer.py
+++ b/src/documents/tests/test_consumer.py
@@ -261,8 +261,14 @@ class TestConsumer(
self.assertIsFile(document.archive_path)
- self.assertEqual(document.checksum, "42995833e01aea9b3edee44bbfdd7ce1")
- self.assertEqual(document.archive_checksum, "62acb0bcbfbcaa62ca6ad3668e4e404b")
+ self.assertEqual(
+ document.checksum,
+ "1093cf6e32adbd16b06969df09215d42c4a3a8938cc18b39455953f08d1ff2ab",
+ )
+ self.assertEqual(
+ document.archive_checksum,
+ "706124ecde3c31616992fa979caed17a726b1c9ccdba70e82a4ff796cea97ccf",
+ )
self.assertIsNotFile(filename)
diff --git a/src/documents/tests/test_management_exporter.py b/src/documents/tests/test_management_exporter.py
index 9307bab45..fb9effa0e 100644
--- a/src/documents/tests/test_management_exporter.py
+++ b/src/documents/tests/test_management_exporter.py
@@ -63,8 +63,8 @@ class TestExportImport(
self.d1 = Document.objects.create(
content="Content",
- checksum="42995833e01aea9b3edee44bbfdd7ce1",
- archive_checksum="62acb0bcbfbcaa62ca6ad3668e4e404b",
+ checksum="1093cf6e32adbd16b06969df09215d42c4a3a8938cc18b39455953f08d1ff2ab",
+ archive_checksum="706124ecde3c31616992fa979caed17a726b1c9ccdba70e82a4ff796cea97ccf",
title="wow1",
filename="0000001.pdf",
mime_type="application/pdf",
@@ -72,21 +72,21 @@ class TestExportImport(
)
self.d2 = Document.objects.create(
content="Content",
- checksum="9c9691e51741c1f4f41a20896af31770",
+ checksum="550d1bae0f746d4f7c6be07054eb20cc2f11988a58ef64ceae45e98f85e92a5b",
title="wow2",
filename="0000002.pdf",
mime_type="application/pdf",
)
self.d3 = Document.objects.create(
content="Content",
- checksum="d38d7ed02e988e072caf924e0f3fcb76",
+ checksum="f1ba6b7ff8548214a75adec228f5468a14fe187f445bc0b9485cbf1c35b15915",
title="wow2",
filename="0000003.pdf",
mime_type="application/pdf",
)
self.d4 = Document.objects.create(
content="Content",
- checksum="82186aaa94f0b98697d704b90fd1c072",
+ checksum="a81b16b6b313cfd7e60eb7b12598d1343b58622b4030cfa19a2724a02e98db1b",
title="wow_dec",
filename="0000004.pdf",
mime_type="application/pdf",
@@ -239,7 +239,7 @@ class TestExportImport(
)
with Path(fname).open("rb") as f:
- checksum = hashlib.md5(f.read()).hexdigest()
+ checksum = hashlib.sha256(f.read()).hexdigest()
self.assertEqual(checksum, element["fields"]["checksum"])
# Generated field "content_length" should not be exported,
@@ -253,7 +253,7 @@ class TestExportImport(
self.assertIsFile(fname)
with Path(fname).open("rb") as f:
- checksum = hashlib.md5(f.read()).hexdigest()
+ checksum = hashlib.sha256(f.read()).hexdigest()
self.assertEqual(checksum, element["fields"]["archive_checksum"])
elif element["model"] == "documents.note":
diff --git a/src/documents/tests/test_management_importer.py b/src/documents/tests/test_management_importer.py
index ef20cf895..a890718a1 100644
--- a/src/documents/tests/test_management_importer.py
+++ b/src/documents/tests/test_management_importer.py
@@ -277,8 +277,8 @@ class TestCommandImport(
Document.objects.create(
content="Content",
- checksum="42995833e01aea9b3edee44bbfdd7ce1",
- archive_checksum="62acb0bcbfbcaa62ca6ad3668e4e404b",
+ checksum="1093cf6e32adbd16b06969df09215d42c4a3a8938cc18b39455953f08d1ff2ab",
+ archive_checksum="706124ecde3c31616992fa979caed17a726b1c9ccdba70e82a4ff796cea97ccf",
title="wow1",
filename="0000001.pdf",
mime_type="application/pdf",
diff --git a/src/documents/tests/test_migration_sha256_checksums.py b/src/documents/tests/test_migration_sha256_checksums.py
new file mode 100644
index 000000000..4a53b724c
--- /dev/null
+++ b/src/documents/tests/test_migration_sha256_checksums.py
@@ -0,0 +1,132 @@
+import hashlib
+import shutil
+import tempfile
+from pathlib import Path
+
+from django.conf import settings
+from django.db import connection
+from django.test import override_settings
+
+from documents.tests.utils import TestMigrations
+
+
+def _sha256(data: bytes) -> str:
+ return hashlib.sha256(data).hexdigest()
+
+
+class TestSha256ChecksumDataMigration(TestMigrations):
+ """recompute_checksums correctly updates document checksums from MD5 to SHA256."""
+
+ migrate_from = "0015_document_version_index_and_more"
+ migrate_to = "0016_sha256_checksums"
+ reset_sequences = True
+
+ ORIGINAL_CONTENT = b"original file content for sha256 migration test"
+ ARCHIVE_CONTENT = b"archive file content for sha256 migration test"
+
+ def setUpBeforeMigration(self, apps) -> None:
+ self._originals_dir = Path(tempfile.mkdtemp())
+ self._archive_dir = Path(tempfile.mkdtemp())
+ self._settings_override = override_settings(
+ ORIGINALS_DIR=self._originals_dir,
+ ARCHIVE_DIR=self._archive_dir,
+ )
+ self._settings_override.enable()
+ Document = apps.get_model("documents", "Document")
+
+ # doc1: original file present, no archive
+ (settings.ORIGINALS_DIR / "doc1.txt").write_bytes(self.ORIGINAL_CONTENT)
+ self.doc1_id = Document.objects.create(
+ title="Doc 1",
+ mime_type="text/plain",
+ filename="doc1.txt",
+ checksum="a" * 32,
+ ).pk
+
+ # doc2: original and archive both present
+ (settings.ORIGINALS_DIR / "doc2.txt").write_bytes(self.ORIGINAL_CONTENT)
+ (settings.ARCHIVE_DIR / "doc2.pdf").write_bytes(self.ARCHIVE_CONTENT)
+ self.doc2_id = Document.objects.create(
+ title="Doc 2",
+ mime_type="text/plain",
+ filename="doc2.txt",
+ checksum="b" * 32,
+ archive_filename="doc2.pdf",
+ archive_checksum="c" * 32,
+ ).pk
+
+ # doc3: original file missing — checksum must stay unchanged
+ self.doc3_id = Document.objects.create(
+ title="Doc 3",
+ mime_type="text/plain",
+ filename="missing_original.txt",
+ checksum="d" * 32,
+ ).pk
+
+ # doc4: original present, archive_filename set but archive file missing
+ (settings.ORIGINALS_DIR / "doc4.txt").write_bytes(self.ORIGINAL_CONTENT)
+ self.doc4_id = Document.objects.create(
+ title="Doc 4",
+ mime_type="text/plain",
+ filename="doc4.txt",
+ checksum="e" * 32,
+ archive_filename="missing_archive.pdf",
+ archive_checksum="f" * 32,
+ ).pk
+
+ # doc5: original present, archive_filename is None — archive_checksum must stay null
+ (settings.ORIGINALS_DIR / "doc5.txt").write_bytes(self.ORIGINAL_CONTENT)
+ self.doc5_id = Document.objects.create(
+ title="Doc 5",
+ mime_type="text/plain",
+ filename="doc5.txt",
+ checksum="0" * 32,
+ archive_filename=None,
+ archive_checksum=None,
+ ).pk
+
+ def _fixture_teardown(self) -> None:
+ super()._fixture_teardown()
+ # Django's SQLite backend returns [] from sequence_reset_sql(), so
+ # reset_sequences=True flushes rows but never clears sqlite_sequence.
+ # Explicitly delete the entry so subsequent tests start from pk=1.
+ if connection.vendor == "sqlite":
+ with connection.cursor() as cursor:
+ cursor.execute(
+ "DELETE FROM sqlite_sequence WHERE name='documents_document'",
+ )
+
+ def tearDown(self) -> None:
+ super().tearDown()
+ self._settings_override.disable()
+ shutil.rmtree(self._originals_dir, ignore_errors=True)
+ shutil.rmtree(self._archive_dir, ignore_errors=True)
+
+ def test_original_checksum_updated_to_sha256_when_file_exists(self) -> None:
+ Document = self.apps.get_model("documents", "Document")
+ doc = Document.objects.get(pk=self.doc1_id)
+ self.assertEqual(doc.checksum, _sha256(self.ORIGINAL_CONTENT))
+
+ def test_both_checksums_updated_when_original_and_archive_exist(self) -> None:
+ Document = self.apps.get_model("documents", "Document")
+ doc = Document.objects.get(pk=self.doc2_id)
+ self.assertEqual(doc.checksum, _sha256(self.ORIGINAL_CONTENT))
+ self.assertEqual(doc.archive_checksum, _sha256(self.ARCHIVE_CONTENT))
+
+ def test_checksum_unchanged_when_original_file_missing(self) -> None:
+ Document = self.apps.get_model("documents", "Document")
+ doc = Document.objects.get(pk=self.doc3_id)
+ self.assertEqual(doc.checksum, "d" * 32)
+
+ def test_archive_checksum_unchanged_when_archive_file_missing(self) -> None:
+ Document = self.apps.get_model("documents", "Document")
+ doc = Document.objects.get(pk=self.doc4_id)
+ # Original was updated (file exists)
+ self.assertEqual(doc.checksum, _sha256(self.ORIGINAL_CONTENT))
+ # Archive was not updated (file missing)
+ self.assertEqual(doc.archive_checksum, "f" * 32)
+
+ def test_archive_checksum_stays_null_when_no_archive_filename(self) -> None:
+ Document = self.apps.get_model("documents", "Document")
+ doc = Document.objects.get(pk=self.doc5_id)
+ self.assertIsNone(doc.archive_checksum)
diff --git a/src/documents/utils.py b/src/documents/utils.py
index 2b6a60749..975185a5f 100644
--- a/src/documents/utils.py
+++ b/src/documents/utils.py
@@ -1,3 +1,4 @@
+import hashlib
import logging
import shutil
from os import utime
@@ -128,3 +129,28 @@ def get_boolean(boolstr: str) -> bool:
Return a boolean value from a string representation.
"""
return bool(boolstr.lower() in ("yes", "y", "1", "t", "true"))
+
+
+def compute_checksum(path: Path, chunk_size: int = 65536) -> str:
+ """
+ Compute the SHA-256 checksum of a file.
+
+ Reads the file in chunks to avoid loading the entire file into memory.
+
+ Args:
+ path (Path): Path to the file to hash.
+ chunk_size (int, optional): Number of bytes to read per chunk.
+ Defaults to 65536.
+
+ Returns:
+ str: Hexadecimal SHA-256 digest of the file contents.
+
+ Raises:
+ FileNotFoundError: If the file does not exist.
+ OSError: If the file cannot be read.
+ """
+ h = hashlib.sha256()
+ with path.open("rb") as f:
+ while chunk := f.read(chunk_size):
+ h.update(chunk)
+ return h.hexdigest()
diff --git a/src/documents/views.py b/src/documents/views.py
index 0716ce66d..600acf078 100644
--- a/src/documents/views.py
+++ b/src/documents/views.py
@@ -2027,7 +2027,10 @@ class UnifiedSearchViewSet(DocumentViewSet):
except NotFound:
raise
except PermissionDenied as e:
- return HttpResponseForbidden(str(e.detail))
+ invalid_more_like_id_message = _("Invalid more_like_id")
+ if str(e.detail) == str(invalid_more_like_id_message):
+ return HttpResponseForbidden(invalid_more_like_id_message)
+ return HttpResponseForbidden(_("Insufficient permissions."))
except Exception as e:
logger.warning(f"An error occurred listing search results: {e!s}")
return HttpResponseBadRequest(
diff --git a/src/documents/workflows/actions.py b/src/documents/workflows/actions.py
index 46d9f5c4a..9744048e5 100644
--- a/src/documents/workflows/actions.py
+++ b/src/documents/workflows/actions.py
@@ -282,7 +282,7 @@ def execute_password_removal_action(
passwords = action.passwords
if not passwords:
logger.warning(
- "Password removal action %s has no passwords configured",
+ "Workflow action %s has no configured unlock values",
action.pk,
extra={"group": logging_group},
)
@@ -321,22 +321,23 @@ def execute_password_removal_action(
user=document.owner,
)
logger.info(
- "Removed password from document %s using workflow action %s",
+ "Unlocked document %s using workflow action %s",
document.pk,
action.pk,
extra={"group": logging_group},
)
return
- except ValueError as e:
+ except ValueError:
logger.warning(
- "Password removal failed for document %s with supplied password: %s",
+ "Workflow action %s could not unlock document %s with one configured value",
+ action.pk,
document.pk,
- e,
extra={"group": logging_group},
)
logger.error(
- "Password removal failed for document %s after trying all provided passwords",
+ "Workflow action %s could not unlock document %s with any configured value",
+ action.pk,
document.pk,
extra={"group": logging_group},
)
diff --git a/src/locale/en_US/LC_MESSAGES/django.po b/src/locale/en_US/LC_MESSAGES/django.po
index ceb207dfa..36caa615f 100644
--- a/src/locale/en_US/LC_MESSAGES/django.po
+++ b/src/locale/en_US/LC_MESSAGES/django.po
@@ -2,7 +2,7 @@ msgid ""
msgstr ""
"Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2026-03-22 13:54+0000\n"
+"POT-Creation-Date: 2026-03-26 14:37+0000\n"
"PO-Revision-Date: 2022-02-17 04:17\n"
"Last-Translator: \n"
"Language-Team: English\n"
@@ -1301,7 +1301,7 @@ msgstr ""
#: documents/serialisers.py:463 documents/serialisers.py:815
#: documents/serialisers.py:2501 documents/views.py:1990
-#: paperless_mail/serialisers.py:143
+#: documents/views.py:2033 paperless_mail/serialisers.py:143
msgid "Insufficient permissions."
msgstr ""
@@ -1341,7 +1341,7 @@ msgstr ""
msgid "Duplicate document identifiers are not allowed."
msgstr ""
-#: documents/serialisers.py:2587 documents/views.py:3596
+#: documents/serialisers.py:2587 documents/views.py:3599
#, python-format
msgid "Documents not found: %(ids)s"
msgstr ""
@@ -1383,13 +1383,18 @@ msgid "Please sign in."
msgstr ""
#: documents/templates/account/login.html:12
-#, python-format
-msgid "Don't have an account yet?
Sign up"
+msgid "Don't have an account yet?"
+msgstr ""
+
+#: documents/templates/account/login.html:12
+#: documents/templates/account/signup.html:43
+#: documents/templates/socialaccount/signup.html:29
+msgid "Sign up"
msgstr ""
#: documents/templates/account/login.html:25
#: documents/templates/account/signup.html:22
-#: documents/templates/socialaccount/signup.html:13
+#: documents/templates/socialaccount/signup.html:15
msgid "Username"
msgstr ""
@@ -1399,6 +1404,7 @@ msgid "Password"
msgstr ""
#: documents/templates/account/login.html:36
+#: documents/templates/account/signup.html:11
#: documents/templates/mfa/authenticate.html:23
msgid "Sign in"
msgstr ""
@@ -1477,10 +1483,11 @@ msgid "Password reset complete."
msgstr ""
#: documents/templates/account/password_reset_from_key_done.html:14
-#, python-format
-msgid ""
-"Your new password has been set. You can now
log "
-"in"
+msgid "Your new password has been set. You can now"
+msgstr ""
+
+#: documents/templates/account/password_reset_from_key_done.html:14
+msgid "log in"
msgstr ""
#: documents/templates/account/signup.html:5
@@ -1488,8 +1495,7 @@ msgid "Paperless-ngx sign up"
msgstr ""
#: documents/templates/account/signup.html:11
-#, python-format
-msgid "Already have an account?
Sign in"
+msgid "Already have an account?"
msgstr ""
#: documents/templates/account/signup.html:19
@@ -1499,7 +1505,7 @@ msgid ""
msgstr ""
#: documents/templates/account/signup.html:23
-#: documents/templates/socialaccount/signup.html:14
+#: documents/templates/socialaccount/signup.html:16
msgid "Email (optional)"
msgstr ""
@@ -1507,11 +1513,6 @@ msgstr ""
msgid "Password (again)"
msgstr ""
-#: documents/templates/account/signup.html:43
-#: documents/templates/socialaccount/signup.html:27
-msgid "Sign up"
-msgstr ""
-
#: documents/templates/index.html:61
msgid "Paperless-ngx is loading..."
msgstr ""
@@ -1556,18 +1557,21 @@ msgid "Paperless-ngx social account sign in"
msgstr ""
#: documents/templates/socialaccount/authentication_error.html:10
-#, python-format
msgid ""
"An error occurred while attempting to login via your social network account. "
-"Back to the
login page"
+"Back to the"
msgstr ""
-#: documents/templates/socialaccount/login.html:10
+#: documents/templates/socialaccount/authentication_error.html:10
+msgid "login page"
+msgstr ""
+
+#: documents/templates/socialaccount/login.html:11
#, python-format
msgid "You are about to connect a new third-party account from %(provider)s."
msgstr ""
-#: documents/templates/socialaccount/login.html:13
+#: documents/templates/socialaccount/login.html:15
msgid "Continue"
msgstr ""
@@ -1575,12 +1579,12 @@ msgstr ""
msgid "Paperless-ngx social account sign up"
msgstr ""
-#: documents/templates/socialaccount/signup.html:10
+#: documents/templates/socialaccount/signup.html:11
#, python-format
msgid "You are about to use your %(provider_name)s account to login."
msgstr ""
-#: documents/templates/socialaccount/signup.html:11
+#: documents/templates/socialaccount/signup.html:13
msgid "As a final step, please complete the following form:"
msgstr ""
@@ -1605,24 +1609,24 @@ msgstr ""
msgid "Unable to parse URI {value}"
msgstr ""
-#: documents/views.py:1983
+#: documents/views.py:1983 documents/views.py:2030
msgid "Invalid more_like_id"
msgstr ""
-#: documents/views.py:3608
+#: documents/views.py:3611
#, python-format
msgid "Insufficient permissions to share document %(id)s."
msgstr ""
-#: documents/views.py:3651
+#: documents/views.py:3654
msgid "Bundle is already being processed."
msgstr ""
-#: documents/views.py:3708
+#: documents/views.py:3711
msgid "The share link bundle is still being prepared. Please try again later."
msgstr ""
-#: documents/views.py:3718
+#: documents/views.py:3721
msgid "The share link bundle is unavailable."
msgstr ""
diff --git a/src/paperless_mail/templates/email_msg_template.html b/src/paperless_mail/templates/email_msg_template.html
index a22666957..9df67e997 100644
--- a/src/paperless_mail/templates/email_msg_template.html
+++ b/src/paperless_mail/templates/email_msg_template.html
@@ -1,4 +1,3 @@
-{% autoescape off %}
@@ -13,36 +12,34 @@
-
{{ date }}
+
{{ date|safe }}
{{ from_label }}
-
{{ from }}
+
{{ from|safe }}
{{ subject_label }}
-
{{ subject }}
+
{{ subject|safe }}
{{ to_label }}
-
{{ to }}
+
{{ to|safe }}
{{ cc_label }}
-
{{ cc }}
+
{{ cc|safe }}
{{ bcc_label }}
-
{{ bcc }}
+
{{ bcc|safe }}
{{ attachments_label }}
-
{{ attachments }}
+
{{ attachments|safe }}
-
{{ content }}
+
{{ content|safe }}