From 3f4bbee6de48c0c229b3889f1fb305bd8053e867 Mon Sep 17 00:00:00 2001 From: "reza.g" Date: Tue, 15 Sep 2026 17:38:47 +0330 Subject: [PATCH] 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). 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. --- backend/app/routers/files.py | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/backend/app/routers/files.py b/backend/app/routers/files.py index 0b1adc0..803e647 100644 --- a/backend/app/routers/files.py +++ b/backend/app/routers/files.py @@ -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 user can attach the invoice from the edit page at any time). """ +import os import uuid +from urllib.parse import quote from fastapi import APIRouter, Depends, File, Response, UploadFile 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 +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: quote = await db.get(Quote, quote_id) if quote is None: @@ -92,7 +114,7 @@ async def download_invoice(quote_id: uuid.UUID, attachment_id: uuid.UUID, db: As content=content, media_type=attachment.mime_type, headers={ - "Content-Disposition": f'attachment; filename="{attachment.file_name}"', + "Content-Disposition": _content_disposition(attachment.file_name), "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, media_type=attachment.mime_type, headers={ - "Content-Disposition": f'attachment; filename="{attachment.file_name}"', + "Content-Disposition": _content_disposition(attachment.file_name), "X-File-Id": str(attachment.id), }, )