diff --git a/photo-upload/README.md b/photo-upload/README.md index 8459509..38fb885 100644 --- a/photo-upload/README.md +++ b/photo-upload/README.md @@ -167,7 +167,8 @@ Older batch documents are still split at render time for backward compatibility. Metadata sidecars under `__MACOSX/` and `.DS_Store` are ignored. Ordinary API clients can also send repeated `photos` parts; send either `archive` or `photos`, never both. -Select still photos; videos/Live Photo video components aren't supported. The +Select JPEG, PNG, WebP, HEIC, AVIF, or TIFF still photos; videos/Live Photo video +components aren't supported. The service reads EXIF `DateTimeOriginal` automatically when it is present. Because iOS Shortcuts can blank that EXIF field while creating the ZIP, `photo_dates` supplies the same Photos-library metadata explicitly, one ISO 8601 value per diff --git a/photo-upload/app.py b/photo-upload/app.py index b0835ed..716c414 100644 --- a/photo-upload/app.py +++ b/photo-upload/app.py @@ -128,16 +128,21 @@ def public_text(text): def web_image(raw, destination): with Image.open(io.BytesIO(raw)) as original: - if original.format not in {"JPEG", "PNG", "WEBP", "HEIF"}: - raise ValueError("Use JPEG, PNG, WebP, or HEIC photos") + if original.format not in {"JPEG", "PNG", "WEBP", "HEIF", "AVIF", "TIFF"}: + raise ValueError("Use JPEG, PNG, WebP, HEIC, AVIF, or TIFF photos") taken_at = exif_photo_date(original.getexif()) image = ImageOps.exif_transpose(original) image.thumbnail((GALLERY_MAX_EDGE, GALLERY_MAX_EDGE), Image.Resampling.LANCZOS) if image.info.get("icc_profile"): - image = ImageCms.profileToProfile( - image, ImageCms.ImageCmsProfile(io.BytesIO(image.info["icc_profile"])), - ImageCms.createProfile("sRGB"), outputMode="RGB", - ) + try: + image = ImageCms.profileToProfile( + image, ImageCms.ImageCmsProfile(io.BytesIO(image.info["icc_profile"])), + ImageCms.createProfile("sRGB"), outputMode="RGB", + ) + except (ImageCms.PyCMSError, OSError, ValueError, SyntaxError): + # Bad embedded color metadata should not block an otherwise + # decodable phone photo. + image = image.convert("RGB") if image.mode in {"RGBA", "LA"} or "transparency" in image.info: rgba = image.convert("RGBA") background = Image.new("RGB", image.size, "white") @@ -362,10 +367,22 @@ def create_app(data_dir=None, token=None): capture_dates.append(batch_date or (supplied_dates[index] if supplied_dates else embedded_date) or received_at) - except (UnidentifiedImageError, OSError, ValueError, SyntaxError, - Image.DecompressionBombError, Image.DecompressionBombWarning, - ImageCms.PyCMSError): - abort(400, f"Photo {index + 1} is unsupported, damaged, or exceeds 60 megapixels") + except (Image.DecompressionBombError, Image.DecompressionBombWarning) as error: + app.logger.warning("Photo %d exceeded the pixel limit", index + 1, + exc_info=error) + abort(400, f"Photo {index + 1} exceeds the 60 megapixel limit") + except UnidentifiedImageError as error: + app.logger.warning("Photo %d could not be identified", index + 1, + exc_info=error) + abort(400, f"Photo {index + 1} is not a supported still-image file") + except ValueError as error: + app.logger.warning("Photo %d has an unsupported format", index + 1, + exc_info=error) + abort(400, f"Photo {index + 1}: {error}") + except (OSError, SyntaxError, ImageCms.PyCMSError) as error: + app.logger.warning("Photo %d could not be decoded", index + 1, + exc_info=error) + abort(400, f"Photo {index + 1} could not be decoded ({type(error).__name__})") # Preserve existing IDs when omitted; named highlights participate in deduplication. if highlight: digest.update(b"\0highlight\0" + highlight.encode()) diff --git a/photo-upload/tests/test_upload.py b/photo-upload/tests/test_upload.py index 0ae6ed2..37828c2 100644 --- a/photo-upload/tests/test_upload.py +++ b/photo-upload/tests/test_upload.py @@ -157,6 +157,20 @@ def test_orientation_metadata_and_heic(service): assert image.height > image.width +def test_tiff_and_invalid_color_profile_are_accepted(service): + _, client, data = service + invalid_profile = io.BytesIO() + Image.new("RGB", (40, 20), "purple").save( + invalid_profile, "JPEG", icc_profile=b"not an ICC profile") + + for raw in (photo(format="TIFF"), invalid_profile.getvalue()): + response = upload(client, [raw]) + assert response.status_code == 202 + output = data / "submissions" / response.json["id"] / "images/00.jpg" + with Image.open(output) as image: + assert image.format == "JPEG" + + def test_photo_date_comes_from_original_metadata(service): _, client, data = service exif = Image.Exif()