From 8b8307571a95ac14febf7541e890087bda81c3cc Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Tue, 3 Mar 2026 13:19:56 -0800 Subject: [PATCH 1/5] Fix: enforce path limit for db filename fields (#12235) --- src/documents/consumer.py | 30 +++++++++++++++++++++-- src/documents/file_handling.py | 18 ++++++++------ src/documents/models.py | 7 +++--- src/documents/signals/handlers.py | 25 ++++++++++++++++--- src/documents/tests/test_consumer.py | 27 ++++++++++++++++++++ src/documents/tests/test_file_handling.py | 15 ++++++++++++ 6 files changed, 107 insertions(+), 15 deletions(-) diff --git a/src/documents/consumer.py b/src/documents/consumer.py index 86641a243..8700305a3 100644 --- a/src/documents/consumer.py +++ b/src/documents/consumer.py @@ -19,6 +19,7 @@ from documents.classifier import load_classifier from documents.data_models import ConsumableDocument from documents.data_models import DocumentMetadataOverrides from documents.file_handling import create_source_path_directory +from documents.file_handling import generate_filename from documents.file_handling import generate_unique_filename from documents.loggers import LoggingMixin from documents.models import Correspondent @@ -493,7 +494,19 @@ class ConsumerPlugin( # After everything is in the database, copy the files into # place. If this fails, we'll also rollback the transaction. with FileLock(settings.MEDIA_LOCK): - document.filename = generate_unique_filename(document) + generated_filename = generate_unique_filename(document) + if ( + len(str(generated_filename)) + > Document.MAX_STORED_FILENAME_LENGTH + ): + self.log.warning( + "Generated source filename exceeds db path limit, falling back to default naming", + ) + generated_filename = generate_filename( + document, + use_format=False, + ) + document.filename = generated_filename create_source_path_directory(document.source_path) self._write( @@ -511,10 +524,23 @@ class ConsumerPlugin( ) if archive_path and Path(archive_path).is_file(): - document.archive_filename = generate_unique_filename( + generated_archive_filename = generate_unique_filename( document, archive_filename=True, ) + if ( + len(str(generated_archive_filename)) + > Document.MAX_STORED_FILENAME_LENGTH + ): + self.log.warning( + "Generated archive filename exceeds db path limit, falling back to default naming", + ) + generated_archive_filename = generate_filename( + document, + archive_filename=True, + use_format=False, + ) + document.archive_filename = generated_archive_filename create_source_path_directory(document.archive_path) self._write( document.storage_type, diff --git a/src/documents/file_handling.py b/src/documents/file_handling.py index 48cd57311..3abcdd842 100644 --- a/src/documents/file_handling.py +++ b/src/documents/file_handling.py @@ -128,17 +128,21 @@ def generate_filename( counter=0, append_gpg=True, archive_filename=False, + use_format=True, ) -> Path: base_path: Path | None = None # Determine the source of the format string - if doc.storage_path is not None: - filename_format = doc.storage_path.path - elif settings.FILENAME_FORMAT is not None: - # Maybe convert old to new style - filename_format = convert_format_str_to_template_format( - settings.FILENAME_FORMAT, - ) + if use_format: + if doc.storage_path is not None: + filename_format = doc.storage_path.path + elif settings.FILENAME_FORMAT is not None: + # Maybe convert old to new style + filename_format = convert_format_str_to_template_format( + settings.FILENAME_FORMAT, + ) + else: + filename_format = None else: filename_format = None diff --git a/src/documents/models.py b/src/documents/models.py index c7c082a7b..3a1c393fa 100644 --- a/src/documents/models.py +++ b/src/documents/models.py @@ -160,6 +160,7 @@ class Document(SoftDeleteModel, ModelWithOwner): (STORAGE_TYPE_UNENCRYPTED, _("Unencrypted")), (STORAGE_TYPE_GPG, _("Encrypted with GNU Privacy Guard")), ) + MAX_STORED_FILENAME_LENGTH: Final[int] = 1024 correspondent = models.ForeignKey( Correspondent, @@ -267,7 +268,7 @@ class Document(SoftDeleteModel, ModelWithOwner): filename = models.FilePathField( _("filename"), - max_length=1024, + max_length=MAX_STORED_FILENAME_LENGTH, editable=False, default=None, unique=True, @@ -277,7 +278,7 @@ class Document(SoftDeleteModel, ModelWithOwner): archive_filename = models.FilePathField( _("archive filename"), - max_length=1024, + max_length=MAX_STORED_FILENAME_LENGTH, editable=False, default=None, unique=True, @@ -287,7 +288,7 @@ class Document(SoftDeleteModel, ModelWithOwner): original_filename = models.CharField( _("original filename"), - max_length=1024, + max_length=MAX_STORED_FILENAME_LENGTH, editable=False, default=None, unique=False, diff --git a/src/documents/signals/handlers.py b/src/documents/signals/handlers.py index d6edb523a..591d235bd 100644 --- a/src/documents/signals/handlers.py +++ b/src/documents/signals/handlers.py @@ -460,8 +460,22 @@ def update_filename_and_move_files( old_filename = instance.filename old_source_path = instance.source_path + move_original = False + + old_archive_filename = instance.archive_filename + old_archive_path = instance.archive_path + move_archive = False candidate_filename = generate_filename(instance) + if len(str(candidate_filename)) > Document.MAX_STORED_FILENAME_LENGTH: + msg = ( + f"Document {instance!s}: Generated filename exceeds db path " + f"limit ({len(str(candidate_filename))} > " + f"{Document.MAX_STORED_FILENAME_LENGTH}): {candidate_filename!s}" + ) + logger.warning(msg) + raise CannotMoveFilesException(msg) + candidate_source_path = ( settings.ORIGINALS_DIR / candidate_filename ).resolve() @@ -480,11 +494,16 @@ def update_filename_and_move_files( instance.filename = str(new_filename) move_original = old_filename != instance.filename - old_archive_filename = instance.archive_filename - old_archive_path = instance.archive_path - if instance.has_archive_version: archive_candidate = generate_filename(instance, archive_filename=True) + if len(str(archive_candidate)) > Document.MAX_STORED_FILENAME_LENGTH: + msg = ( + f"Document {instance!s}: Generated archive filename exceeds " + f"db path limit ({len(str(archive_candidate))} > " + f"{Document.MAX_STORED_FILENAME_LENGTH}): {archive_candidate!s}" + ) + logger.warning(msg) + raise CannotMoveFilesException(msg) archive_candidate_path = ( settings.ARCHIVE_DIR / archive_candidate ).resolve() diff --git a/src/documents/tests/test_consumer.py b/src/documents/tests/test_consumer.py index 6387b5e95..58435eac5 100644 --- a/src/documents/tests/test_consumer.py +++ b/src/documents/tests/test_consumer.py @@ -633,6 +633,33 @@ class TestConsumer( self._assert_first_last_send_progress() + @mock.patch("documents.consumer.generate_unique_filename") + def testFilenameHandlingFallsBackWhenGeneratedPathExceedsDbLimit(self, m): + m.side_effect = lambda doc, archive_filename=False: Path( + ("a" * 1100 + ".pdf") if not archive_filename else ("b" * 1100 + ".pdf"), + ) + + with self.get_consumer( + self.get_test_file(), + DocumentMetadataOverrides(title="new docs"), + ) as consumer: + consumer.run() + + document = Document.objects.first() + self.assertIsNotNone(document) + assert document is not None + + self.assertEqual(document.filename, f"{document.pk:07d}.pdf") + self.assertLessEqual(len(document.filename), 1024) + self.assertLessEqual( + len(document.archive_filename), + 1024, + ) + self.assertIsFile(document.source_path) + self.assertIsFile(document.archive_path) + + self._assert_first_last_send_progress() + @override_settings(FILENAME_FORMAT="{correspondent}/{title}") @mock.patch("documents.signals.handlers.generate_unique_filename") def testFilenameHandlingUnstableFormat(self, m): diff --git a/src/documents/tests/test_file_handling.py b/src/documents/tests/test_file_handling.py index 186483655..52f06cb41 100644 --- a/src/documents/tests/test_file_handling.py +++ b/src/documents/tests/test_file_handling.py @@ -1699,6 +1699,21 @@ class TestCustomFieldFilenameUpdates( self.assertTrue(Path(self.doc.source_path).is_file()) self.assertLessEqual(m.call_count, 1) + @override_settings(FILENAME_FORMAT=None) + def test_overlong_storage_path_keeps_existing_filename(self): + initial_filename = generate_filename(self.doc) + Document.objects.filter(pk=self.doc.pk).update(filename=str(initial_filename)) + self.doc.refresh_from_db() + Path(self.doc.source_path).parent.mkdir(parents=True, exist_ok=True) + Path(self.doc.source_path).touch() + + self.doc.storage_path = StoragePath.objects.create(path="a" * 1100) + self.doc.save() + + self.doc.refresh_from_db() + self.assertEqual(Path(self.doc.filename), initial_filename) + self.assertTrue(Path(self.doc.source_path).is_file()) + class TestPathDateLocalization: """ From 5b809122b5a543923c668061d4da75828905ba52 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Wed, 4 Mar 2026 00:33:13 -0800 Subject: [PATCH 2/5] Fix: apply ordering after annotating tag document count (#12238) --- src/documents/tests/test_tag_hierarchy.py | 10 ++++++++++ src/documents/views.py | 8 ++++---- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/documents/tests/test_tag_hierarchy.py b/src/documents/tests/test_tag_hierarchy.py index e748225cd..d423b1cc8 100644 --- a/src/documents/tests/test_tag_hierarchy.py +++ b/src/documents/tests/test_tag_hierarchy.py @@ -147,6 +147,16 @@ class TestTagHierarchy(APITestCase): assert serializer.data # triggers serialization assert "document_count_filter" in context + def test_tag_list_can_order_by_document_count_with_children(self) -> None: + self.document.tags.add(self.child) + + response = self.client.get( + "/api/tags/", + {"ordering": "document_count"}, + ) + + assert response.status_code == 200 + def test_cannot_set_parent_to_self(self): tag = Tag.objects.create(name="Selfie") resp = self.client.patch( diff --git a/src/documents/views.py b/src/documents/views.py index 52687d135..842ebeab8 100644 --- a/src/documents/views.py +++ b/src/documents/views.py @@ -487,13 +487,13 @@ class TagViewSet(PermissionsAwareDocumentCountMixin, ModelViewSet): user = getattr(getattr(self, "request", None), "user", None) children_source = list( annotate_document_count_for_related_queryset( - Tag.objects.filter(pk__in=descendant_pks | {t.pk for t in all_tags}) - .select_related("owner") - .order_by(*ordering), + Tag.objects.filter( + pk__in=descendant_pks | {t.pk for t in all_tags}, + ).select_related("owner"), through_model=self.document_count_through, related_object_field=self.document_count_source_field, user=user, - ), + ).order_by(*ordering), ) else: children_source = all_tags From 615f27e6fb9f68fe38c7fc481454df708344a923 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Wed, 4 Mar 2026 08:32:34 -0800 Subject: [PATCH 3/5] Fix: support string coercion in filepath jinja templates (#12244) --- src/documents/templating/filepath.py | 36 +++++++++++++++++++---- src/documents/tests/test_file_handling.py | 35 ++++++++++++++++++++++ 2 files changed, 65 insertions(+), 6 deletions(-) diff --git a/src/documents/templating/filepath.py b/src/documents/templating/filepath.py index b4dd367fb..bbd92b463 100644 --- a/src/documents/templating/filepath.py +++ b/src/documents/templating/filepath.py @@ -79,6 +79,23 @@ class PlaceholderString(str): NO_VALUE_PLACEHOLDER = PlaceholderString("-none-") +class MatchingModelContext: + """ + Safe template context for related objects. + + Keeps legacy behavior where including the object ina template yields the related object's + name as a string, while still exposing limited attributes. + """ + + def __init__(self, *, id: int, name: str, path: str | None = None): + self.id = id + self.name = name + self.path = path + + def __str__(self) -> str: + return self.name + + _template_environment.undefined = _LogStrictUndefined _template_environment.filters["get_cf_value"] = get_cf_value @@ -221,19 +238,26 @@ def get_safe_document_context( else None, "tags": [{"name": tag.name, "id": tag.id} for tag in tags], "correspondent": ( - {"name": document.correspondent.name, "id": document.correspondent.id} + MatchingModelContext( + name=document.correspondent.name, + id=document.correspondent.id, + ) if document.correspondent else None ), "document_type": ( - {"name": document.document_type.name, "id": document.document_type.id} + MatchingModelContext( + name=document.document_type.name, + id=document.document_type.id, + ) if document.document_type else None ), - "storage_path": { - "path": document.storage_path.path, - "id": document.storage_path.id, - } + "storage_path": MatchingModelContext( + name=document.storage_path.name, + path=document.storage_path.path, + id=document.storage_path.id, + ) if document.storage_path else None, } diff --git a/src/documents/tests/test_file_handling.py b/src/documents/tests/test_file_handling.py index 52f06cb41..9430bf669 100644 --- a/src/documents/tests/test_file_handling.py +++ b/src/documents/tests/test_file_handling.py @@ -1341,6 +1341,41 @@ class TestFilenameGeneration(DirectoriesMixin, TestCase): Path("somepath/asn-201-400/asn-3xx/Does Matter.pdf"), ) + def test_template_related_context_keeps_legacy_string_coercion(self): + """ + GIVEN: + - A storage path template that uses related objects directly as strings + WHEN: + - Filepath for a document with this format is called + THEN: + - Related objects coerce to their names (legacy behavior) + - Explicit attribute access remains available for new templates + """ + sp = StoragePath.objects.create( + name="PARTNER", + path=( + "{{ document.storage_path|lower }} / " + "{{ document.correspondent|lower|replace('mi:', 'mieter/') }} / " + "{{ document_type|lower }} / " + "{{ title|lower }}" + ), + ) + doc = Document.objects.create( + title="scan_017562", + created=datetime.date(2025, 7, 2), + added=timezone.make_aware(datetime.datetime(2026, 3, 3, 11, 53, 16)), + mime_type="application/pdf", + checksum="test-checksum", + storage_path=sp, + correspondent=Correspondent.objects.create(name="mi:kochkach"), + document_type=DocumentType.objects.create(name="Mietvertrag"), + ) + + self.assertEqual( + generate_filename(doc), + Path("partner/mieter/kochkach/mietvertrag/scan_017562.pdf"), + ) + @override_settings( FILENAME_FORMAT="{{creation_date}}/{{ title_name_str }}", ) From 8f311c4b6bbe83ad1a1dd363806c44441254f523 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Wed, 4 Mar 2026 10:38:14 -0800 Subject: [PATCH 4/5] Bump version to 2.20.10 --- pyproject.toml | 2 +- src-ui/package.json | 2 +- src-ui/src/environments/environment.prod.ts | 2 +- src/paperless/version.py | 2 +- uv.lock | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 4ca50e884..6225913be 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "paperless-ngx" -version = "2.20.9" +version = "2.20.10" description = "A community-supported supercharged document management system: scan, index and archive all your physical documents" readme = "README.md" requires-python = ">=3.10" diff --git a/src-ui/package.json b/src-ui/package.json index 4a83e731a..69eaae1b7 100644 --- a/src-ui/package.json +++ b/src-ui/package.json @@ -1,6 +1,6 @@ { "name": "paperless-ngx-ui", - "version": "2.20.9", + "version": "2.20.10", "scripts": { "preinstall": "npx only-allow pnpm", "ng": "ng", diff --git a/src-ui/src/environments/environment.prod.ts b/src-ui/src/environments/environment.prod.ts index afd702263..28a27911f 100644 --- a/src-ui/src/environments/environment.prod.ts +++ b/src-ui/src/environments/environment.prod.ts @@ -6,7 +6,7 @@ export const environment = { apiVersion: '9', // match src/paperless/settings.py appTitle: 'Paperless-ngx', tag: 'prod', - version: '2.20.9', + version: '2.20.10', webSocketHost: window.location.host, webSocketProtocol: window.location.protocol == 'https:' ? 'wss:' : 'ws:', webSocketBaseUrl: base_url.pathname + 'ws/', diff --git a/src/paperless/version.py b/src/paperless/version.py index ab46b36db..3f35bde70 100644 --- a/src/paperless/version.py +++ b/src/paperless/version.py @@ -1,6 +1,6 @@ from typing import Final -__version__: Final[tuple[int, int, int]] = (2, 20, 9) +__version__: Final[tuple[int, int, int]] = (2, 20, 10) # Version string like X.Y.Z __full_version_str__: Final[str] = ".".join(map(str, __version__)) # Version string like X.Y diff --git a/uv.lock b/uv.lock index 9debf964b..f468b7f27 100644 --- a/uv.lock +++ b/uv.lock @@ -1991,7 +1991,7 @@ wheels = [ [[package]] name = "paperless-ngx" -version = "2.20.9" +version = "2.20.10" source = { virtual = "." } dependencies = [ { name = "babel", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, From d6a316b1dfbf43f526c110f644db4d47a91ef9cd Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 4 Mar 2026 11:25:44 -0800 Subject: [PATCH 5/5] Changelog v2.20.10 - GHA (#12247) Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com> --- docs/changelog.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/docs/changelog.md b/docs/changelog.md index 13b935e01..404e6d355 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,5 +1,23 @@ # Changelog +## paperless-ngx 2.20.10 + +### Bug Fixes + +- Fix: support string coercion in filepath jinja templates [@shamoon](https://github.com/shamoon) ([#12244](https://github.com/paperless-ngx/paperless-ngx/pull/12244)) +- Fix: apply ordering after annotating tag document count [@shamoon](https://github.com/shamoon) ([#12238](https://github.com/paperless-ngx/paperless-ngx/pull/12238)) +- Fix: enforce path limit for db filename fields [@shamoon](https://github.com/shamoon) ([#12235](https://github.com/paperless-ngx/paperless-ngx/pull/12235)) + +### All App Changes + +
+3 changes + +- Fix: support string coercion in filepath jinja templates [@shamoon](https://github.com/shamoon) ([#12244](https://github.com/paperless-ngx/paperless-ngx/pull/12244)) +- Fix: apply ordering after annotating tag document count [@shamoon](https://github.com/shamoon) ([#12238](https://github.com/paperless-ngx/paperless-ngx/pull/12238)) +- Fix: enforce path limit for db filename fields [@shamoon](https://github.com/shamoon) ([#12235](https://github.com/paperless-ngx/paperless-ngx/pull/12235)) +
+ ## paperless-ngx 2.20.9 ### Security