fix: use content_type for image format detection on mobile uploads (#489)

on iOS, selecting a HEIC photo from the Photos library often results
in a file with a .heic filename but jpeg content (iOS converts on the
fly). the backend was using only the filename extension to validate
image format, causing these files to be silently rejected.

changes:
- backend: add from_content_type() method to ImageFormat
- backend: validate_and_extract() now prefers content_type over extension
- backend: pass image content_type through upload pipeline
- frontend: use accept="image/*" for broader iOS compatibility
- tests: add coverage for iOS HEIC case

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude <noreply@anthropic.com>

authored by zzstoatzz.io Claude and committed by GitHub 9eb4a0cd 29360d58

Changed files
+102 -5
backend
src
backend
_internal
api
tracks
tests
frontend
src
routes
upload
+33 -3
backend/src/backend/_internal/image.py
··· 32 return None 33 34 @classmethod 35 def validate_and_extract( 36 - cls, filename: str | None 37 ) -> tuple["ImageFormat | None", bool]: 38 - """validate image format from filename. 39 40 returns: 41 tuple of (image_format, is_valid) where: ··· 47 unconditionally used. 48 49 usage: 50 - image_format, is_valid = ImageFormat.validate_and_extract(filename) 51 if not is_valid: 52 logger.warning(f"unsupported image format: {filename}") 53 """ 54 if not filename: 55 return None, True 56 57 image_format = cls.from_filename(filename) 58 return image_format, image_format is not None
··· 32 return None 33 34 @classmethod 35 + def from_content_type(cls, content_type: str | None) -> "ImageFormat | None": 36 + """extract image format from MIME content type. 37 + 38 + this is more reliable than filename extension, especially on iOS 39 + where HEIC photos may be converted to JPEG but keep the .heic filename. 40 + """ 41 + if not content_type: 42 + return None 43 + 44 + content_type = content_type.lower().split(";")[0].strip() 45 + mapping = { 46 + "image/jpeg": cls.JPEG, 47 + "image/jpg": cls.JPEG, 48 + "image/png": cls.PNG, 49 + "image/webp": cls.WEBP, 50 + "image/gif": cls.GIF, 51 + } 52 + return mapping.get(content_type) 53 + 54 + @classmethod 55 def validate_and_extract( 56 + cls, filename: str | None, content_type: str | None = None 57 ) -> tuple["ImageFormat | None", bool]: 58 + """validate image format from filename or content type. 59 + 60 + prefers content_type over filename extension when available, since 61 + iOS may convert HEIC to JPEG but keep the original filename. 62 63 returns: 64 tuple of (image_format, is_valid) where: ··· 70 unconditionally used. 71 72 usage: 73 + image_format, is_valid = ImageFormat.validate_and_extract(filename, content_type) 74 if not is_valid: 75 logger.warning(f"unsupported image format: {filename}") 76 """ 77 if not filename: 78 return None, True 79 80 + # prefer content_type over filename - more reliable on iOS 81 + if content_type: 82 + image_format = cls.from_content_type(content_type) 83 + if image_format: 84 + return image_format, True 85 + 86 + # fall back to filename extension 87 image_format = cls.from_filename(filename) 88 return image_format, image_format is not None
+6 -1
backend/src/backend/api/tracks/uploads.py
··· 99 auth_session: AuthSession, 100 image_path: str | None = None, 101 image_filename: str | None = None, 102 ) -> None: 103 """Background task to process upload.""" 104 with logfire.span( ··· 216 "saving image...", 217 phase="image", 218 ) 219 image_format, is_valid = ImageFormat.validate_and_extract( 220 - image_filename 221 ) 222 if is_valid and image_format: 223 try: ··· 540 541 # stream image to temp file if provided 542 image_filename = None 543 if image and image.filename: 544 image_filename = image.filename 545 # images have much smaller limit (20MB is generous for cover art) 546 max_image_size = 20 * 1024 * 1024 547 image_bytes_read = 0 ··· 584 auth_session, 585 image_path, 586 image_filename, 587 ) 588 except Exception: 589 if file_path:
··· 99 auth_session: AuthSession, 100 image_path: str | None = None, 101 image_filename: str | None = None, 102 + image_content_type: str | None = None, 103 ) -> None: 104 """Background task to process upload.""" 105 with logfire.span( ··· 217 "saving image...", 218 phase="image", 219 ) 220 + # use content_type for format detection (more reliable on iOS) 221 image_format, is_valid = ImageFormat.validate_and_extract( 222 + image_filename, image_content_type 223 ) 224 if is_valid and image_format: 225 try: ··· 542 543 # stream image to temp file if provided 544 image_filename = None 545 + image_content_type = None 546 if image and image.filename: 547 image_filename = image.filename 548 + image_content_type = image.content_type 549 # images have much smaller limit (20MB is generous for cover art) 550 max_image_size = 20 * 1024 * 1024 551 image_bytes_read = 0 ··· 588 auth_session, 589 image_path, 590 image_filename, 591 + image_content_type, 592 ) 593 except Exception: 594 if file_path:
+62
backend/tests/test_image_formats.py
··· 72 assert ( 73 ImageFormat.from_filename("C:\\Users\\test\\pic.webp") == ImageFormat.WEBP 74 )
··· 72 assert ( 73 ImageFormat.from_filename("C:\\Users\\test\\pic.webp") == ImageFormat.WEBP 74 ) 75 + 76 + @pytest.mark.parametrize( 77 + ("content_type", "expected_format"), 78 + [ 79 + ("image/jpeg", ImageFormat.JPEG), 80 + ("image/jpg", ImageFormat.JPEG), 81 + ("image/png", ImageFormat.PNG), 82 + ("image/webp", ImageFormat.WEBP), 83 + ("image/gif", ImageFormat.GIF), 84 + # with charset 85 + ("image/jpeg; charset=utf-8", ImageFormat.JPEG), 86 + # case insensitive 87 + ("IMAGE/JPEG", ImageFormat.JPEG), 88 + ("Image/Png", ImageFormat.PNG), 89 + ], 90 + ) 91 + def test_from_content_type_supported( 92 + self, content_type: str, expected_format: ImageFormat 93 + ): 94 + """test supported content type recognition.""" 95 + assert ImageFormat.from_content_type(content_type) == expected_format 96 + 97 + @pytest.mark.parametrize( 98 + "content_type", 99 + [ 100 + "image/heic", 101 + "image/bmp", 102 + "image/tiff", 103 + "application/octet-stream", 104 + "", 105 + None, 106 + ], 107 + ) 108 + def test_from_content_type_unsupported(self, content_type: str | None): 109 + """test unsupported content types return None.""" 110 + assert ImageFormat.from_content_type(content_type) is None 111 + 112 + def test_validate_and_extract_prefers_content_type(self): 113 + """test that content_type is preferred over filename extension. 114 + 115 + this is the iOS HEIC case: filename is .heic but content is jpeg. 116 + """ 117 + # HEIC filename but JPEG content type -> should return JPEG 118 + image_format, is_valid = ImageFormat.validate_and_extract( 119 + "IMG_1234.HEIC", "image/jpeg" 120 + ) 121 + assert is_valid is True 122 + assert image_format == ImageFormat.JPEG 123 + 124 + def test_validate_and_extract_falls_back_to_filename(self): 125 + """test fallback to filename when no content_type provided.""" 126 + image_format, is_valid = ImageFormat.validate_and_extract("photo.png", None) 127 + assert is_valid is True 128 + assert image_format == ImageFormat.PNG 129 + 130 + def test_validate_and_extract_unsupported_both(self): 131 + """test unsupported format when both filename and content_type are invalid.""" 132 + image_format, is_valid = ImageFormat.validate_and_extract( 133 + "image.heic", "image/heic" 134 + ) 135 + assert is_valid is False 136 + assert image_format is None
+1 -1
frontend/src/routes/upload/+page.svelte
··· 241 <input 242 id="image-input" 243 type="file" 244 - accept=".jpg,.jpeg,.png,.webp,.gif,image/jpeg,image/png,image/webp,image/gif" 245 onchange={handleImageChange} 246 /> 247 <p class="format-hint">supported: jpg, png, webp, gif</p>
··· 241 <input 242 id="image-input" 243 type="file" 244 + accept="image/*" 245 onchange={handleImageChange} 246 /> 247 <p class="format-hint">supported: jpg, png, webp, gif</p>