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), }, )