Intern-Decision / uploads.py
Spico's picture
Upload 16 files
0673086 verified
Raw History Blame Contribute Delete
4.18 kB
"""Validate browser image bytes and create request-scoped, private image files."""
import base64
import binascii
import io
import shutil
import tempfile
import warnings
from pathlib import Path
from PIL import Image, ImageOps, UnidentifiedImageError
MAX_UPLOAD_BYTES = 12 * 1024 * 1024
MAX_UPLOAD_TOTAL = 32 * 1024 * 1024
MAX_IMAGES = 8
MAX_IMAGE_PIXELS = 16_000_000
FORMATS = {"image/jpeg": "JPEG", "image/png": "PNG", "image/webp": "WEBP", "image/gif": "GIF"}
def materialize_images(request):
images = request.get("images", [])
if not isinstance(images, list) or len(images) > MAX_IMAGES:
raise ValueError("images must be a list of at most 8 uploaded files.")
if not images:
return request, None
# mkdtemp creates a private directory; client filenames never become paths.
directory = Path(tempfile.mkdtemp(prefix="interndecision-upload-"))
paths, total, normalized_total = [], 0, 0
try:
for index, item in enumerate(images):
if not isinstance(item, dict) or not isinstance(item.get("data"), str):
raise ValueError("Upload image bytes; local paths and remote URLs are not accepted.")
data = item["data"]
if len(data) > 4 * ((MAX_UPLOAD_BYTES + 2) // 3) + 128:
raise ValueError("Each image is limited to 12 MB.")
if data.startswith("data:"):
header, separator, encoded = data.partition(",")
if not separator or not header.endswith(";base64"):
raise ValueError("Images must use base64 data URLs.")
content_type = header[5:].split(";")[0].lower()
else:
encoded, content_type = data, str(item.get("type", "")).lower()
if content_type not in FORMATS:
raise ValueError("Supported image formats: JPEG, PNG, WebP and static GIF.")
try:
payload = base64.b64decode(encoded, validate=True)
except (ValueError, binascii.Error) as exc:
raise ValueError("Invalid image base64.") from exc
if not payload or len(payload) > MAX_UPLOAD_BYTES:
raise ValueError("Each image must be between 1 byte and 12 MB.")
total += len(payload)
if total > MAX_UPLOAD_TOTAL:
raise ValueError("Combined image upload is limited to 32 MB.")
try:
with warnings.catch_warnings():
warnings.simplefilter("error", Image.DecompressionBombWarning)
with Image.open(io.BytesIO(payload)) as source:
if source.format != FORMATS[content_type]:
raise ValueError("Image content does not match its declared format.")
if source.width * source.height > MAX_IMAGE_PIXELS:
raise ValueError("Each image is limited to 16 million pixels.")
if getattr(source, "n_frames", 1) != 1:
raise ValueError("Animated images are not supported; upload a still image.")
source.load()
# Apply camera orientation, strip metadata and preserve pixel resolution.
image = ImageOps.exif_transpose(source).convert("RGB")
image.info.clear()
path = directory / f"{index:02d}.png"
image.save(path, format="PNG")
image.close()
except (
UnidentifiedImageError,
OSError,
Image.DecompressionBombError,
Image.DecompressionBombWarning,
) as exc:
raise ValueError("Invalid, incomplete or oversized image.") from exc
normalized_total += path.stat().st_size
if normalized_total > MAX_UPLOAD_TOTAL:
raise ValueError("Decoded images exceed 32 MB; upload smaller images.")
paths.append(str(path))
return {**request, "images": paths}, directory
except BaseException:
shutil.rmtree(directory, ignore_errors=True)
raise