Fix: keep counter and extension when shortening long public filenames (#14472)

get_public_filename relied on sanitize_filename to truncate, which cuts
from the end and so dropped the counter and extension of long titles.
Multi-byte titles could also exceed the 255 byte filesystem limit.

Shorten the title by UTF-8 bytes before appending the counter and
extension. The exporter reserves room for its longer derived thumbnail
and archive suffixes.
This commit is contained in:
Trenton H authored and GitHub committed 2026-10-16 10:11:34 -07:00
1 parent 30a24ee02e
commit 183cfd2da4
4 files changed
+178 -15

No files matched your search

@@ -1537,3 +1537,70 @@ class TestExportDerivedFileNameCollisions:
assert (
target / record[EXPORTER_ARCHIVE_NAME]
).read_text() == f"archive {pk}"
def test_long_titles_export_within_file_name_limit(
self,
paperless_dirs: PaperlessDirs,
tmp_path: Path,
) -> None:
"""
GIVEN:
- Two documents with the same created date and the same title
- The title alone is longer than 255 bytes and contains umlauts
WHEN:
- The exporter runs
THEN:
- The export finishes with distinct original, thumbnail and archive names
- Every exported file name fits in the byte limit
- Each exported file holds its own document's content
"""
for pk in (1, 2):
(paperless_dirs.originals_dir / f"{pk:07}.pdf").write_bytes(
f"original {pk}".encode(),
)
(paperless_dirs.archive_dir / f"{pk:07}.pdf").write_bytes(
f"archive {pk}".encode(),
)
(paperless_dirs.thumbnail_dir / f"{pk:07}.webp").write_bytes(
f"thumbnail {pk}".encode(),
)
DocumentFactory(
pk=pk,
title="Bestellbestätigung " * 15,
created=date(2026, 10, 15),
filename=f"{pk:07}.pdf",
archive_filename=f"{pk:07}.pdf",
mime_type="application/pdf",
)
target = tmp_path / "export"
target.mkdir()
call_command(
"document_exporter",
str(target),
"--no-progress-bar",
skip_checks=True,
)
manifest = json.loads((target / "manifest.json").read_text())
records = {r["pk"]: r for r in manifest if r["model"] == "documents.document"}
for key in (
EXPORTER_FILE_NAME,
EXPORTER_THUMBNAIL_NAME,
EXPORTER_ARCHIVE_NAME,
):
assert records[1][key] != records[2][key]
for pk in (1, 2):
record = records[pk]
for key, content in (
(EXPORTER_FILE_NAME, f"original {pk}"),
(EXPORTER_THUMBNAIL_NAME, f"thumbnail {pk}"),
(EXPORTER_ARCHIVE_NAME, f"archive {pk}"),
):
assert (
len(Path(record[key]).name.encode())
<= Document.MAX_PUBLIC_FILENAME_BYTES
)
assert (target / record[key]).read_text() == content