From 0be7975db59e309ae70cd85a5409fa0770c71c8d Mon Sep 17 00:00:00 2001 From: Yurii Date: Tue, 17 Feb 2026 12:58:42 +0400 Subject: [PATCH] fix: handle non-ASCII filenames in Content-Disposition headers The export endpoints (export-audio, export generation, export profile, export story) crash with `'latin-1' codec can't encode characters` when the generated text or profile/story name contains non-ASCII characters (e.g. Cyrillic, Chinese, Arabic). Root cause: Python's `str.isalnum()` passes Unicode letters through to the filename, but HTTP headers are encoded as latin-1 by the ASGI server, which cannot represent characters outside the 0-255 range. Fix: introduce `_safe_content_disposition()` helper that builds a standards-compliant header with an ASCII-only `filename` fallback and a RFC 5987 `filename*=UTF-8''...` parameter for Unicode-capable clients. Fixes #68 Co-authored-by: Cursor --- backend/main.py | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/backend/main.py b/backend/main.py index 59fb9e18..cf666456 100644 --- a/backend/main.py +++ b/backend/main.py @@ -22,6 +22,24 @@ import uuid import asyncio import signal import os +from urllib.parse import quote + + +def _safe_content_disposition(disposition_type: str, filename: str) -> str: + """Build a Content-Disposition header that is safe for non-ASCII filenames. + + Uses RFC 5987 ``filename*`` parameter so that browsers can decode + UTF-8 filenames while the ``filename`` fallback stays ASCII-only. + """ + ascii_name = "".join( + c for c in filename if c.isascii() and (c.isalnum() or c in " -_.") + ).strip() or "download" + utf8_name = quote(filename, safe="") + return ( + f'{disposition_type}; filename="{ascii_name}"; ' + f"filename*=UTF-8''{utf8_name}" + ) + from . import database, models, profiles, history, tts, transcribe, config, export_import, channels, stories, __version__ from .database import get_db, Generation as DBGeneration, VoiceProfile as DBVoiceProfile @@ -388,7 +406,7 @@ async def export_profile( io.BytesIO(zip_bytes), media_type="application/zip", headers={ - "Content-Disposition": f'attachment; filename="{filename}"' + "Content-Disposition": _safe_content_disposition("attachment", filename) } ) except ValueError as e: @@ -753,7 +771,7 @@ async def export_generation( io.BytesIO(zip_bytes), media_type="application/zip", headers={ - "Content-Disposition": f'attachment; filename="{filename}"' + "Content-Disposition": _safe_content_disposition("attachment", filename) } ) except ValueError as e: @@ -786,7 +804,7 @@ async def export_generation_audio( audio_path, media_type="audio/wav", headers={ - "Content-Disposition": f'attachment; filename="{filename}"' + "Content-Disposition": _safe_content_disposition("attachment", filename) } ) @@ -1054,7 +1072,7 @@ async def export_story_audio( io.BytesIO(audio_bytes), media_type="audio/wav", headers={ - "Content-Disposition": f'attachment; filename="{filename}"' + "Content-Disposition": _safe_content_disposition("attachment", filename) } ) except HTTPException: