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

+23 -12
View File
@@ -157,6 +157,8 @@ class StoragePath(MatchingModel):
class Document(SoftDeleteModel, ModelWithOwner): # type: ignore[django-manager-missing]
MAX_STORED_FILENAME_LENGTH: Final[int] = 1024
# Per-component limit of common filesystems (ext4, XFS, btrfs, ZFS, APFS)
MAX_PUBLIC_FILENAME_BYTES: Final[int] = 255
correspondent = models.ForeignKey(
Correspondent,
@@ -484,28 +486,37 @@ class Document(SoftDeleteModel, ModelWithOwner): # type: ignore[django-manager-
def archive_file(self):
return Path(self.archive_path).open("rb")
def get_public_filename(self, *, archive=False, counter=0, suffix=None) -> str:
def get_public_filename(
self,
*,
archive: bool = False,
counter: int = 0,
max_bytes: int = MAX_PUBLIC_FILENAME_BYTES,
) -> str:
"""
Returns a sanitized filename for the document, not including any paths.
The name fits in max_bytes (UTF-8). A long title is shortened before
the counter and extension are appended, because sanitize_filename
truncates from the end. The tail is cut only when max_bytes is
smaller than the tail itself.
"""
# Root owns metadata for all versions
context_document = (
self.root_document if self.root_document_id is not None else self
)
result = str(context_document)
if counter:
result += f"_{counter:02}"
extension = ".pdf" if archive else self.file_type
tail = f"_{counter:02}{extension}" if counter else extension
if suffix:
result += suffix
head_bytes = max(0, max_bytes - len(tail.encode()))
head = str(context_document).encode()[:head_bytes].decode(errors="ignore")
if archive:
result += ".pdf"
else:
result += self.file_type
return pathvalidate.sanitize_filename(result, replacement_text="-")
return pathvalidate.sanitize_filename(
head + tail,
replacement_text="-",
max_len=max_bytes,
)
@property
def file_type(self):