1515import zipfile
1616from pathlib import Path
1717from typing import List , Optional , Tuple
18+ from urllib .parse import quote
1819
1920from flask import Blueprint , Response , jsonify , request
2021from lxml import etree
@@ -286,10 +287,24 @@ def _glossary_summary(glossary: Glossary) -> dict:
286287
287288 def _safe_filename (name : str ) -> str :
288289 """Turn a glossary name into a download-safe file stem."""
289- cleaned = '' . join ( c if c . isalnum () or c in ( '- ' , '_' , '.' ) else '_' for c in ( name or 'glossary' ) )
290+ cleaned = re . sub ( r'[^A-Za-z0-9._-]+ ' , '_' , name or 'glossary' )
290291 cleaned = cleaned .strip ('._' ) or 'glossary'
291292 return cleaned
292293
294+ def _attachment_disposition (filename : str ) -> str :
295+ """Build a WSGI-safe attachment header with UTF-8 filename support."""
296+ normalized = (filename or 'glossary' ).replace ('\r ' , ' ' ).replace ('\n ' , ' ' ).strip () or 'glossary'
297+ if '.' in normalized :
298+ stem , suffix = normalized .rsplit ('.' , 1 )
299+ safe_suffix = re .sub (r'[^A-Za-z0-9]+' , '' , suffix )
300+ ascii_fallback = _safe_filename (stem )
301+ if safe_suffix :
302+ ascii_fallback = f'{ ascii_fallback } .{ safe_suffix } '
303+ else :
304+ ascii_fallback = _safe_filename (normalized )
305+ encoded = quote (normalized , safe = '' )
306+ return f'attachment; filename="{ ascii_fallback } "; filename*=UTF-8\' \' { encoded } '
307+
293308 def _terms_from_payload (payload ) -> List [GlossaryTerm ]:
294309 """Build a list of GlossaryTerm from a JSON payload (list or dict)."""
295310 if isinstance (payload , dict ):
@@ -645,7 +660,6 @@ def export_glossary(gid: int):
645660 return jsonify ({"error" : f"Glossary { gid } not found" }), 404
646661
647662 fmt = (request .args .get ('format' ) or 'json' ).lower ()
648- stem = _safe_filename (glossary .name )
649663
650664 if fmt == 'csv' :
651665 buffer = io .StringIO ()
@@ -661,7 +675,7 @@ def export_glossary(gid: int):
661675 buffer .getvalue (),
662676 mimetype = 'text/csv; charset=utf-8' ,
663677 headers = {
664- 'Content-Disposition' : f'attachment; filename= { stem } .csv'
678+ 'Content-Disposition' : _attachment_disposition ( f' { glossary . name or "glossary" } .csv')
665679 },
666680 )
667681
@@ -677,7 +691,7 @@ def export_glossary(gid: int):
677691 _json .dumps (payload , ensure_ascii = False , indent = 2 ),
678692 mimetype = 'application/json; charset=utf-8' ,
679693 headers = {
680- 'Content-Disposition' : f'attachment; filename= { stem } .json'
694+ 'Content-Disposition' : _attachment_disposition ( f' { glossary . name or "glossary" } .json')
681695 },
682696 )
683697
0 commit comments