diff --git a/backend/app/api/subroutes/pastes.py b/backend/app/api/subroutes/pastes.py index bcc0810..378c7df 100644 --- a/backend/app/api/subroutes/pastes.py +++ b/backend/app/api/subroutes/pastes.py @@ -1,4 +1,5 @@ import logging +import re from typing import TYPE_CHECKING from dependency_injector.wiring import Provide, inject @@ -161,9 +162,23 @@ async def get_paste_raw( cached_content = await cache.get(cache_key) if cached_content: cache_operations.labels(operation="get", result="hit").inc() + # Build a sanitized filename from the paste_id for cached responses + def _sanitize_filename(source: str) -> str: + name = re.sub(r"[^A-Za-z0-9 \-_.]", "", source) + name = re.sub(r"\s+", "_", name) + name = name.strip("_.") + name = name[:30] + if not name: + return str(paste_id) + return name + + filename = f"{_sanitize_filename(str(paste_id))}.txt" return PlainTextResponse( content=cached_content, - headers={"Cache-Control": f"public, max-age={config.CACHE_TTL}"}, + headers={ + "Cache-Control": f"public, max-age={config.CACHE_TTL}", + "Content-Disposition": f'attachment; filename="{filename}"', + }, ) cache_operations.labels(operation="get", result="miss").inc() @@ -177,9 +192,25 @@ async def get_paste_raw( await cache.set(cache_key, content, ttl=config.CACHE_TTL) cache_operations.labels(operation="set", result="success").inc() + # Sanitize filename from title (fallback to id) + def _sanitize_filename(source: str) -> str: + name = re.sub(r"[^A-Za-z0-9 \-_.]", "", source) + name = re.sub(r"\s+", "_", name) + name = name.strip("_.") + name = name[:30] + if not name: + return str(paste_id) + return name + + filename_source = paste_result.title if paste_result.title else str(paste_result.id) + filename = f"{_sanitize_filename(str(filename_source))}.txt" + return PlainTextResponse( content=content, - headers={"Cache-Control": f"public, max-age={config.CACHE_TTL}"}, + headers={ + "Cache-Control": f"public, max-age={config.CACHE_TTL}", + "Content-Disposition": f'attachment; filename="{filename}"', + }, ) diff --git a/backend/tests/api/test_paste_routes.py b/backend/tests/api/test_paste_routes.py index 3c56d90..65bd51d 100644 --- a/backend/tests/api/test_paste_routes.py +++ b/backend/tests/api/test_paste_routes.py @@ -279,6 +279,11 @@ async def test_get_raw_paste_returns_plain_text( assert response.status_code == 200 assert response.headers["content-type"] == "text/plain; charset=utf-8" assert response.text == "This is test content" + # New: ensure download header present + assert "content-disposition" in response.headers + cd = response.headers["content-disposition"] + assert "attachment" in cd + assert ".txt" in cd async def test_get_raw_paste_returns_404_for_nonexistent(self, test_client: AsyncClient, bypass_headers): """GET /pastes/{id}/raw should return 404 for non-existent paste.""" @@ -317,6 +322,8 @@ async def test_get_raw_paste_with_unicode_content(self, test_client: AsyncClient assert response.status_code == 200 assert response.text == paste_data["content"] + # Ensure download header exists for unicode titles as well + assert "content-disposition" in response.headers @pytest.mark.asyncio