From a3324e2b233459e199a02c6e249f5c16a533ed94 Mon Sep 17 00:00:00 2001 From: RUiNtheExtinct Date: Sun, 28 Dec 2025 12:10:56 +0530 Subject: [PATCH] fix: RFC 2183/5987 compliant Content-Disposition header in /view endpoint The Content-Disposition header was missing the required disposition-type (attachment;) and proper encoding for non-ASCII filenames. Changes: - Add `create_content_disposition_header()` helper function - Update all 4 Content-Disposition headers in view_image endpoint - Provide ASCII fallback filename for legacy clients - Add RFC 5987 UTF-8 encoded filename* parameter for international support This fixes third-party download libraries (e.g., Go's mime.ParseMediaType) failing to parse the filename correctly. Fixes #8914 --- server.py | 23 +++- .../server_test/test_content_disposition.py | 108 ++++++++++++++++++ 2 files changed, 127 insertions(+), 4 deletions(-) create mode 100644 tests-unit/server_test/test_content_disposition.py diff --git a/server.py b/server.py index c27f8be7d..6a918db04 100644 --- a/server.py +++ b/server.py @@ -25,6 +25,7 @@ from aiohttp import web import logging import mimetypes +from urllib.parse import quote from comfy.cli_args import args import comfy.utils import comfy.model_management @@ -190,6 +191,20 @@ def create_block_external_middleware(): return block_external_middleware +def create_content_disposition_header(filename: str) -> str: + """ + Generate RFC 2183/5987 compliant Content-Disposition header value. + + Provides both ASCII fallback (filename=) and UTF-8 encoded (filename*=) + for international filename support across all clients. + """ + # ASCII-safe filename for legacy clients (replace non-ASCII with ?) + ascii_filename = filename.encode('ascii', 'replace').decode('ascii') + # RFC 5987 percent-encoded filename for UTF-8 support + encoded_filename = quote(filename, safe='') + return f"attachment; filename=\"{ascii_filename}\"; filename*=UTF-8''{encoded_filename}" + + class PromptServer(): def __init__(self, loop): PromptServer.instance = self @@ -518,7 +533,7 @@ class PromptServer(): buffer.seek(0) return web.Response(body=buffer.read(), content_type=f'image/{image_format}', - headers={"Content-Disposition": f"filename=\"{filename}\""}) + headers={"Content-Disposition": create_content_disposition_header(filename)}) if 'channel' not in request.rel_url.query: channel = 'rgba' @@ -538,7 +553,7 @@ class PromptServer(): buffer.seek(0) return web.Response(body=buffer.read(), content_type='image/png', - headers={"Content-Disposition": f"filename=\"{filename}\""}) + headers={"Content-Disposition": create_content_disposition_header(filename)}) elif channel == 'a': with Image.open(file) as img: @@ -555,7 +570,7 @@ class PromptServer(): alpha_buffer.seek(0) return web.Response(body=alpha_buffer.read(), content_type='image/png', - headers={"Content-Disposition": f"filename=\"{filename}\""}) + headers={"Content-Disposition": create_content_disposition_header(filename)}) else: # Get content type from mimetype, defaulting to 'application/octet-stream' content_type = mimetypes.guess_type(filename)[0] or 'application/octet-stream' @@ -567,7 +582,7 @@ class PromptServer(): return web.FileResponse( file, headers={ - "Content-Disposition": f"filename=\"{filename}\"", + "Content-Disposition": create_content_disposition_header(filename), "Content-Type": content_type } ) diff --git a/tests-unit/server_test/test_content_disposition.py b/tests-unit/server_test/test_content_disposition.py new file mode 100644 index 000000000..07ccb5214 --- /dev/null +++ b/tests-unit/server_test/test_content_disposition.py @@ -0,0 +1,108 @@ +"""Tests for Content-Disposition header generation (RFC 2183/5987)""" + +import pytest +from urllib.parse import quote + + +def create_content_disposition_header(filename: str) -> str: + """ + Generate RFC 2183/5987 compliant Content-Disposition header value. + + Provides both ASCII fallback (filename=) and UTF-8 encoded (filename*=) + for international filename support across all clients. + + Note: This is a copy of the function from server.py for isolated testing. + """ + # ASCII-safe filename for legacy clients (replace non-ASCII with ?) + ascii_filename = filename.encode('ascii', 'replace').decode('ascii') + # RFC 5987 percent-encoded filename for UTF-8 support + encoded_filename = quote(filename, safe='') + return f"attachment; filename=\"{ascii_filename}\"; filename*=UTF-8''{encoded_filename}" + + +class TestContentDispositionHeader: + """Test RFC 2183/5987 compliant Content-Disposition header generation.""" + + def test_ascii_filename(self): + """Test that ASCII filenames are properly formatted.""" + result = create_content_disposition_header("test_image.png") + + # Must have attachment disposition type (RFC 2183) + assert result.startswith("attachment;") + # Must have ASCII filename parameter + assert 'filename="test_image.png"' in result + # Must have UTF-8 encoded filename parameter (RFC 5987) + assert "filename*=UTF-8''test_image.png" in result + + def test_unicode_filename_chinese(self): + """Test that Chinese filenames are properly encoded.""" + result = create_content_disposition_header("图片.png") + + assert result.startswith("attachment;") + # ASCII fallback should replace non-ASCII chars with ? + assert 'filename="??.png"' in result + # UTF-8 encoded version should be percent-encoded + assert "filename*=UTF-8''" in result + assert "%E5%9B%BE%E7%89%87.png" in result # URL-encoded 图片 + + def test_unicode_filename_japanese(self): + """Test that Japanese filenames are properly encoded.""" + result = create_content_disposition_header("画像.jpg") + + assert result.startswith("attachment;") + # UTF-8 encoded version should be percent-encoded + assert "filename*=UTF-8''" in result + assert "%E7%94%BB%E5%83%8F.jpg" in result # URL-encoded 画像 + + def test_filename_with_spaces(self): + """Test that spaces in filenames are properly encoded.""" + result = create_content_disposition_header("my image file.png") + + assert result.startswith("attachment;") + assert 'filename="my image file.png"' in result + # Spaces should be percent-encoded in filename* + assert "my%20image%20file.png" in result + + def test_filename_with_special_chars(self): + """Test that special characters are properly handled.""" + result = create_content_disposition_header("file(1)[2]{3}.png") + + assert result.startswith("attachment;") + # Special chars should be percent-encoded in filename* + assert "filename*=UTF-8''" in result + + def test_empty_filename(self): + """Test handling of empty filename.""" + result = create_content_disposition_header("") + + assert result.startswith("attachment;") + assert 'filename=""' in result + + def test_filename_with_quotes(self): + """Test that quotes in filename don't break the header.""" + # Note: Quotes in filenames are edge cases but should not crash + result = create_content_disposition_header('file"name.png') + + assert result.startswith("attachment;") + # The function should still produce valid output + assert "filename*=UTF-8''" in result + + def test_mixed_ascii_unicode(self): + """Test filenames with both ASCII and Unicode characters.""" + result = create_content_disposition_header("photo_照片_2024.png") + + assert result.startswith("attachment;") + # ASCII fallback replaces unicode with ? + assert 'filename="photo_??_2024.png"' in result + # UTF-8 version preserves everything (encoded) + assert "filename*=UTF-8''" in result + assert "photo_" in result or "photo_%E7%85%A7%E7%89%87_2024.png" in result.replace("photo_", "") + + def test_long_filename(self): + """Test that long filenames are handled without truncation.""" + long_name = "a" * 200 + ".png" + result = create_content_disposition_header(long_name) + + assert result.startswith("attachment;") + # Should contain the full filename + assert long_name in result