http.py 2.3 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253
  1. """HTTP response helpers."""
  2. from pathlib import Path
  3. from urllib.parse import quote
  4. from starlette.responses import PlainTextResponse
  5. def download_error_response(status_code: int, message: str) -> PlainTextResponse:
  6. """Answer a browser-native download with a file that says what went wrong.
  7. These URLs are reached by an ``<a download>`` click, and a browser saves
  8. whatever comes back under the name it was going to use. A JSON error body
  9. therefore lands on the user's disk as a .zip that will not open, with
  10. nothing on screen to explain it -- the download simply appears to have
  11. produced a broken file. A short text file, named for the failure rather
  12. than for the download, is at least legible when opened.
  13. """
  14. return PlainTextResponse(
  15. f"{message}\n",
  16. status_code=status_code,
  17. headers={"Content-Disposition": build_content_disposition("download-failed.txt")},
  18. )
  19. def safe_download_filename(filename: str, fallback: str = "download", max_chars: int = 200) -> str:
  20. """Return a basename safe for a bounded download response header."""
  21. basename = Path(filename.replace("\\", "/")).name
  22. cleaned = "".join("_" if ord(char) < 32 or ord(char) == 127 else char for char in basename).strip(" .")
  23. if not cleaned:
  24. return fallback
  25. if len(cleaned) <= max_chars:
  26. return cleaned
  27. suffixes = "".join(Path(cleaned).suffixes)
  28. suffix = suffixes if len(suffixes) <= 32 else ""
  29. stem_chars = max(1, max_chars - len(suffix))
  30. return f"{cleaned[:stem_chars]}{suffix}"
  31. def build_content_disposition(filename: str, disposition: str = "attachment") -> str:
  32. """Build an RFC 6266-compliant Content-Disposition header value.
  33. Starlette/uvicorn encodes response headers as latin-1, so any non-ASCII
  34. character in a raw `filename="..."` parameter raises UnicodeEncodeError.
  35. The fix is RFC 5987's `filename*=UTF-8''<percent-encoded>` form alongside
  36. a stripped ASCII fallback in the legacy `filename="..."` parameter — every
  37. modern browser prefers the `*` form when present.
  38. """
  39. ascii_fallback = filename.encode("ascii", "ignore").decode("ascii").strip(" ._-") or "download"
  40. ascii_fallback = ascii_fallback.replace('"', "").replace("\\", "")
  41. return f"{disposition}; filename=\"{ascii_fallback}\"; filename*=UTF-8''{quote(filename)}"