fix(files): downloads with Persian filenames no longer 500

Both download endpoints (invoice/proforma) put the stored file_name raw into the Content-Disposition header. HTTP headers are latin-1, so any non-ASCII name (i.e. every Persian filename) raised UnicodeEncodeError before the body was sent -> HTTP 500. Uploads were unaffected.

Content-Disposition is now built by a helper following RFC 6266 / RFC 5987: the UTF-8 name is percent-encoded in filename*=UTF-8''..., with a header-safe ASCII fallback in filename= (quotes and control characters stripped; a name that folds away entirely becomes attachment<ext>).

Verified locally after reproducing the exact production error (same latin-1 positions 22-27): Persian proforma + invoice downloads 500 -> 200 with filename*= carrying the full Persian name and bytes sha256-matching the MinIO object; ASCII filename regression 200; helper edge cases (CRLF/quote injection stripped, empty name -> attachment, pure-Persian png -> attachment.png); no UnicodeEncodeError in the api logs after the fix.
This commit is contained in:
2026-09-15 17:38:47 +03:30
parent 4e59f6e25c
commit 3f4bbee6de
+24 -2
View File
@@ -13,7 +13,9 @@ The same four endpoints exist under /proforma. Bytes live in MinIO
Both invoice and proforma uploads are allowed in every quote status (the Both invoice and proforma uploads are allowed in every quote status (the
user can attach the invoice from the edit page at any time). user can attach the invoice from the edit page at any time).
""" """
import os
import uuid import uuid
from urllib.parse import quote
from fastapi import APIRouter, Depends, File, Response, UploadFile from fastapi import APIRouter, Depends, File, Response, UploadFile
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -34,6 +36,26 @@ ALLOWED_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".gif", ".pdf"}
MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024 MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024
def _content_disposition(file_name: str) -> str:
"""Header-safe Content-Disposition for non-ASCII (e.g. Persian) filenames.
HTTP headers are encoded as latin-1, so a Persian name raises
UnicodeEncodeError and kills the response. Per RFC 6266 / RFC 5987 the
UTF-8 name goes percent-encoded in ``filename*``, with an ASCII fallback
in ``filename`` for old clients.
"""
raw = file_name.encode("ascii", "ignore").decode("ascii")
# keep the fallback header-safe: no quotes, no control characters
fallback = "".join(ch for ch in raw if ch >= " " and ch != '"').strip()
ext = os.path.splitext(file_name)[1]
stem = fallback[: -len(ext)].strip() if ext and fallback.lower().endswith(ext.lower()) else fallback
if not stem or not any(ch.isalnum() for ch in stem):
# nothing meaningful survived ASCII-folding (e.g. a pure-Persian
# name): use a generic attachment name that keeps the extension
fallback = f"attachment{ext}" if ext else "attachment"
return f"attachment; filename=\"{fallback}\"; filename*=UTF-8''{quote(file_name)}"
async def _quote_or_404(db: AsyncSession, quote_id: uuid.UUID) -> Quote: async def _quote_or_404(db: AsyncSession, quote_id: uuid.UUID) -> Quote:
quote = await db.get(Quote, quote_id) quote = await db.get(Quote, quote_id)
if quote is None: if quote is None:
@@ -92,7 +114,7 @@ async def download_invoice(quote_id: uuid.UUID, attachment_id: uuid.UUID, db: As
content=content, content=content,
media_type=attachment.mime_type, media_type=attachment.mime_type,
headers={ headers={
"Content-Disposition": f'attachment; filename="{attachment.file_name}"', "Content-Disposition": _content_disposition(attachment.file_name),
"X-File-Id": str(attachment.id), "X-File-Id": str(attachment.id),
}, },
) )
@@ -137,7 +159,7 @@ async def download_proforma(quote_id: uuid.UUID, attachment_id: uuid.UUID, db: A
content=content, content=content,
media_type=attachment.mime_type, media_type=attachment.mime_type,
headers={ headers={
"Content-Disposition": f'attachment; filename="{attachment.file_name}"', "Content-Disposition": _content_disposition(attachment.file_name),
"X-File-Id": str(attachment.id), "X-File-Id": str(attachment.id),
}, },
) )