From 47f1203131463f2c8b83a4d05803548d6b99e2b0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 9 May 2026 15:50:47 +0000 Subject: [PATCH 1/5] feat: add upload size/type guards and user-facing 413 handling Agent-Logs-Url: https://github.com/gzhang33/imgtowebp/sessions/24659e10-c5d6-4655-9b99-3fdf0366ad5b Co-authored-by: gzhang33 <120421065+gzhang33@users.noreply.github.com> --- src/imgtowebp/web/app.py | 79 +++++++++++- src/imgtowebp/web/templates/index.html | 164 +++++++++++++++++++++++-- tests/test_web_upload_limits.py | 57 +++++++++ 3 files changed, 284 insertions(+), 16 deletions(-) create mode 100644 tests/test_web_upload_limits.py diff --git a/src/imgtowebp/web/app.py b/src/imgtowebp/web/app.py index 763a9e9..770f977 100644 --- a/src/imgtowebp/web/app.py +++ b/src/imgtowebp/web/app.py @@ -13,6 +13,7 @@ from flask import Flask, abort, redirect, render_template, request, send_file, session, url_for from PIL import Image +from werkzeug.exceptions import RequestEntityTooLarge from werkzeug.utils import secure_filename # Handle both direct execution and package import @@ -86,12 +87,19 @@ def resolve_output_file(output_dir: Path, relative_path: str) -> Path | None: # Inline ZIP in the upload HTML avoids a second HTTP request (needed on serverless # where /tmp may not exist on a different instance than /upload). MAX_INLINE_ZIP_BYTES = 4 * 1024 * 1024 +VERCEL_FUNCTION_PAYLOAD_LIMIT_BYTES = int(4.5 * 1024 * 1024) +# Keep a safety margin under Vercel's 4.5MB request payload limit. +UPLOAD_TOTAL_LIMIT_BYTES = 4 * 1024 * 1024 +UPLOAD_FILE_LIMIT_BYTES = 4 * 1024 * 1024 +ALLOWED_MIME_TYPES = {"image/jpeg", "image/png", "image/webp"} +ALLOWED_EXTENSIONS = set(SUPPORTED_EXTENSIONS) # Ephemeral Web UI output: each browser session writes under output_dir/_sessions//. SESSION_WORKSPACE_KEY = "imgtowebp_ws" SESSIONS_DIRNAME = "_sessions" # One-shot conversion payload for PRG: GET / consumes it; refresh hits empty session -> GET /. SESSION_RESULTS_ONCE_KEY = "imgtowebp_results_once" +SESSION_FORM_ERROR_ONCE_KEY = "imgtowebp_form_error_once" def _is_safe_workspace_id(workspace_id: str) -> bool: @@ -152,6 +160,31 @@ def is_decodable_raster_image(data: bytes) -> bool: return False +def normalize_mimetype(raw: str | None) -> str: + return (raw or "").split(";", 1)[0].strip().lower() + + +def validate_upload_payload( + *, + filename: str, + ext: str, + data: bytes, + mimetype: str | None, +) -> str | None: + if not data: + return "Empty file." + if len(data) > UPLOAD_FILE_LIMIT_BYTES: + return "Single file is too large. Keep each file under 4 MB." + if ext not in ALLOWED_EXTENSIONS: + return "Unsupported file type. Only JPG/JPEG/PNG are allowed." + normalized_mime = normalize_mimetype(mimetype) + if normalized_mime and normalized_mime not in ALLOWED_MIME_TYPES: + return "Unsupported file type. Please upload a JPG or PNG image." + if not is_decodable_raster_image(data): + return "Unsupported or unreadable image." + return None + + def _load_repo_dotenv() -> None: try: from dotenv import load_dotenv @@ -172,6 +205,7 @@ def create_app(output_dir: Path) -> Flask: template_folder=str(web_dir / "templates"), static_folder=str(web_dir / "static"), ) + app.config["MAX_CONTENT_LENGTH"] = UPLOAD_TOTAL_LIMIT_BYTES app.secret_key = ( os.environ.get("FLASK_SECRET_KEY") or os.environ.get("IMGTOWEBP_SECRET_KEY") @@ -179,11 +213,27 @@ def create_app(output_dir: Path) -> Flask: ) output_dir.mkdir(parents=True, exist_ok=True) + upload_policy: dict[str, Any] = { + "vercel_function_limit_bytes": VERCEL_FUNCTION_PAYLOAD_LIMIT_BYTES, + "max_total_bytes": UPLOAD_TOTAL_LIMIT_BYTES, + "max_file_bytes": UPLOAD_FILE_LIMIT_BYTES, + "allowed_mime_types": sorted(ALLOWED_MIME_TYPES), + "allowed_extensions": sorted(ALLOWED_EXTENSIONS), + } + + @app.errorhandler(RequestEntityTooLarge) + def handle_request_too_large(_: RequestEntityTooLarge): + session[SESSION_FORM_ERROR_ONCE_KEY] = ( + "Upload is too large. Keep total upload size under 4 MB." + ) + return redirect(url_for("index"), code=303) + @app.get("/") def index() -> str: session.pop(SESSION_RESULTS_ONCE_KEY, None) wid = ensure_workspace_session_id() clear_session_workspace(output_dir, wid) + form_error = session.pop(SESSION_FORM_ERROR_ONCE_KEY, None) return render_template( "index.html", results=None, @@ -191,6 +241,8 @@ def index() -> str: zip_relpaths=[], inline_zip_b64=None, zip_fallback_only=False, + form_error=form_error, + upload_policy=upload_policy, ) @app.post("/upload") @@ -208,8 +260,10 @@ def upload() -> str: quality = max(0, min(100, quality)) results: list[UploadItemResult] = [] + validated_uploads: list[tuple[str, str, bytes]] = [] total_in = 0 total_out = 0 + total_received = 0 converted = 0 skipped = 0 failed = 0 @@ -235,22 +289,33 @@ def upload() -> str: stem = Path(safe_name).stem or "image" data = f.read() - if not data: - skipped += 1 - results.append(UploadItemResult(original_name, "skipped", "Empty file.")) - continue + total_received += len(data) + if total_received > UPLOAD_TOTAL_LIMIT_BYTES: + session[SESSION_FORM_ERROR_ONCE_KEY] = ( + "Upload is too large. Keep total upload size under 4 MB." + ) + return redirect(url_for("index"), code=303) - if ext not in SUPPORTED_EXTENSIONS and not is_decodable_raster_image(data): + validation_error = validate_upload_payload( + filename=original_name, + ext=ext, + data=data, + mimetype=f.mimetype, + ) + if validation_error: skipped += 1 results.append( UploadItemResult( original_name, "skipped", - "Unsupported or unreadable image.", + validation_error, ) ) continue + validated_uploads.append((original_name, stem, data)) + + for original_name, stem, data in validated_uploads: out_path = target_dir / f"{stem}.webp" res = convert_image(data, out_path, quality=quality, overwrite=overwrite) @@ -351,6 +416,8 @@ def results() -> Any: zip_relpaths=zip_relpaths, inline_zip_b64=inline_zip_b64, zip_fallback_only=zip_fallback_only, + form_error=None, + upload_policy=upload_policy, ) @app.post("/session/discard") diff --git a/src/imgtowebp/web/templates/index.html b/src/imgtowebp/web/templates/index.html index 961c4cc..263a250 100644 --- a/src/imgtowebp/web/templates/index.html +++ b/src/imgtowebp/web/templates/index.html @@ -206,6 +206,27 @@

> + +
+

+ Upload policy: JPG/JPEG/PNG only, up to 4 MB total. +

+ {% if form_error %} + + {% else %} + + {% endif %} +
@@ -788,10 +809,99 @@

Files

// --- Shared Logic --- + const uploadPolicy = {{ upload_policy | tojson }}; const mainForm = document.getElementById("mainForm"); const convertingOverlay = document.getElementById("convertingOverlay"); + const formErrorBanner = document.getElementById("formErrorBanner"); + let selectedFiles = []; + + function formatBytes(bytes) { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(2)} MB`; + } + + function setFormError(message) { + if (!formErrorBanner) return; + if (!message) { + formErrorBanner.classList.add("hidden"); + formErrorBanner.textContent = ""; + return; + } + formErrorBanner.textContent = message; + formErrorBanner.classList.remove("hidden"); + } + + function normalizeMime(mime) { + return String(mime || "").split(";")[0].trim().toLowerCase(); + } + + function getFileExt(name) { + const parts = String(name || "").toLowerCase().split("."); + if (parts.length < 2) return ""; + return `.${parts.pop()}`; + } + + function validateSelectedFiles(fileList) { + const validFiles = []; + const errors = []; + const allowedMimes = new Set(uploadPolicy.allowed_mime_types || []); + const allowedExts = new Set(uploadPolicy.allowed_extensions || []); + const maxFileBytes = Number(uploadPolicy.max_file_bytes || 0); + const maxTotalBytes = Number(uploadPolicy.max_total_bytes || 0); + let totalBytes = 0; + + Array.from(fileList || []).forEach((file) => { + const ext = getFileExt(file.name); + const mime = normalizeMime(file.type); + if (!allowedExts.has(ext)) { + errors.push(`${file.name}: unsupported type.`); + return; + } + if (mime && !allowedMimes.has(mime)) { + errors.push(`${file.name}: unsupported type.`); + return; + } + if (maxFileBytes > 0 && file.size > maxFileBytes) { + errors.push( + `${file.name}: too large (${formatBytes(file.size)} > ${formatBytes( + maxFileBytes + )}).` + ); + return; + } + totalBytes += file.size; + if (maxTotalBytes > 0 && totalBytes > maxTotalBytes) { + errors.push( + `Total size is too large (${formatBytes(totalBytes)} > ${formatBytes( + maxTotalBytes + )}).` + ); + return; + } + validFiles.push(file); + }); + + return { validFiles, errors, totalBytes }; + } + if (mainForm && convertingOverlay) { - mainForm.addEventListener("submit", function () { + mainForm.addEventListener("submit", function (e) { + const maxTotalBytes = Number(uploadPolicy.max_total_bytes || 0); + const totalBytes = selectedFiles.reduce((sum, file) => sum + file.size, 0); + if (!selectedFiles.length) { + e.preventDefault(); + setFormError("Please select at least one JPG or PNG image."); + return; + } + if (maxTotalBytes > 0 && totalBytes > maxTotalBytes) { + e.preventDefault(); + setFormError( + `Total size is too large. Keep upload under ${formatBytes(maxTotalBytes)}.` + ); + return; + } + setFormError(""); convertingOverlay.classList.remove("hidden"); const cbtn = document.getElementById("convertBtn"); if (cbtn) { @@ -825,6 +935,23 @@

Files

const previewGrid = document.getElementById("previewGrid"); const actionBar = document.getElementById("actionBar"); const fileCountBadge = document.getElementById("fileCountBadge"); + function resetSelectionState() { + selectedFiles = []; + if (fileInput) { + const dt = new DataTransfer(); + fileInput.files = dt.files; + } + if (emptyState) emptyState.classList.remove("hidden"); + if (previewGrid) { + previewGrid.classList.add("hidden"); + previewGrid.classList.remove("grid"); + previewGrid.innerHTML = ""; + } + if (actionBar) { + actionBar.classList.add("translate-y-24", "opacity-0", "scale-95"); + } + if (fileCountBadge) fileCountBadge.textContent = "0"; + } if (dropZone && fileInput) { // Click to upload @@ -871,20 +998,37 @@

Files

e.stopPropagation(); dropZone.classList.remove("border-brand-500", "bg-brand-50/20"); - const files = e.dataTransfer.files; - if (files.length) { - fileInput.files = files; // Update input - handleFiles(files); - } - }, - false - ); + const files = e.dataTransfer.files; + if (files.length) { + applyFiles(files); + } + }, + false + ); // File Input Change fileInput.addEventListener("change", (e) => { - handleFiles(e.target.files); + applyFiles(e.target.files); }); + function applyFiles(files) { + const { validFiles, errors } = validateSelectedFiles(files); + if (errors.length) { + setFormError(errors.slice(0, 3).join(" ")); + } else { + setFormError(""); + } + if (!validFiles.length) { + resetSelectionState(); + return; + } + selectedFiles = validFiles; + const dt = new DataTransfer(); + validFiles.forEach((file) => dt.items.add(file)); + fileInput.files = dt.files; + handleFiles(validFiles); + } + function handleFiles(files) { if (!files || files.length === 0) return; diff --git a/tests/test_web_upload_limits.py b/tests/test_web_upload_limits.py new file mode 100644 index 0000000..051eacf --- /dev/null +++ b/tests/test_web_upload_limits.py @@ -0,0 +1,57 @@ +import io +import sys +import tempfile +import unittest +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[1] +SRC_ROOT = REPO_ROOT / "src" +if str(SRC_ROOT) not in sys.path: + sys.path.insert(0, str(SRC_ROOT)) + +from imgtowebp.web.app import create_app, validate_upload_payload # noqa: E402 + + +class UploadValidationTests(unittest.TestCase): + def test_validate_upload_payload_rejects_bad_extension(self): + message = validate_upload_payload( + filename="file.pdf", + ext=".pdf", + data=b"not-an-image", + mimetype="application/pdf", + ) + self.assertEqual(message, "Unsupported file type. Only JPG/JPEG/PNG are allowed.") + + def test_validate_upload_payload_rejects_large_file(self): + data = b"a" * (4 * 1024 * 1024 + 1) + message = validate_upload_payload( + filename="large.jpg", + ext=".jpg", + data=data, + mimetype="image/jpeg", + ) + self.assertEqual(message, "Single file is too large. Keep each file under 4 MB.") + + def test_upload_413_redirect_shows_user_message(self): + with tempfile.TemporaryDirectory() as tmp: + app = create_app(Path(tmp)) + app.config.update(TESTING=True, MAX_CONTENT_LENGTH=128) + client = app.test_client() + + data = {"files": (io.BytesIO(b"a" * 512), "big.jpg")} + response = client.post( + "/upload", + data=data, + content_type="multipart/form-data", + follow_redirects=True, + ) + self.assertEqual(response.status_code, 200) + self.assertIn( + b"Upload is too large. Keep total upload size under 4 MB.", + response.data, + ) + + +if __name__ == "__main__": + unittest.main() From 68026ba2d07ac004088732d1882f75f885fcc0e7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 9 May 2026 15:51:31 +0000 Subject: [PATCH 2/5] refactor: harden upload UI validation constants and DataTransfer fallback Agent-Logs-Url: https://github.com/gzhang33/imgtowebp/sessions/24659e10-c5d6-4655-9b99-3fdf0366ad5b Co-authored-by: gzhang33 <120421065+gzhang33@users.noreply.github.com> --- src/imgtowebp/web/templates/index.html | 34 ++++++++++++++++++++------ 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/src/imgtowebp/web/templates/index.html b/src/imgtowebp/web/templates/index.html index 263a250..bf0a60d 100644 --- a/src/imgtowebp/web/templates/index.html +++ b/src/imgtowebp/web/templates/index.html @@ -810,6 +810,7 @@

Files

// --- Shared Logic --- const uploadPolicy = {{ upload_policy | tojson }}; + const MAX_DISPLAYED_ERRORS = 3; const mainForm = document.getElementById("mainForm"); const convertingOverlay = document.getElementById("convertingOverlay"); const formErrorBanner = document.getElementById("formErrorBanner"); @@ -885,6 +886,22 @@

Files

return { validFiles, errors, totalBytes }; } + function setInputFiles(fileInputEl, files) { + if (!fileInputEl) return false; + try { + if (typeof DataTransfer === "undefined") { + return false; + } + const dt = new DataTransfer(); + (files || []).forEach((file) => dt.items.add(file)); + fileInputEl.files = dt.files; + return true; + } catch (e) { + console.error(e); + return false; + } + } + if (mainForm && convertingOverlay) { mainForm.addEventListener("submit", function (e) { const maxTotalBytes = Number(uploadPolicy.max_total_bytes || 0); @@ -937,10 +954,7 @@

Files

const fileCountBadge = document.getElementById("fileCountBadge"); function resetSelectionState() { selectedFiles = []; - if (fileInput) { - const dt = new DataTransfer(); - fileInput.files = dt.files; - } + if (fileInput) setInputFiles(fileInput, []); if (emptyState) emptyState.classList.remove("hidden"); if (previewGrid) { previewGrid.classList.add("hidden"); @@ -1014,7 +1028,7 @@

Files

function applyFiles(files) { const { validFiles, errors } = validateSelectedFiles(files); if (errors.length) { - setFormError(errors.slice(0, 3).join(" ")); + setFormError(errors.slice(0, MAX_DISPLAYED_ERRORS).join(" ")); } else { setFormError(""); } @@ -1023,9 +1037,13 @@

Files

return; } selectedFiles = validFiles; - const dt = new DataTransfer(); - validFiles.forEach((file) => dt.items.add(file)); - fileInput.files = dt.files; + if (!setInputFiles(fileInput, validFiles)) { + setFormError( + "Browser does not support this upload workflow. Please use the file picker again." + ); + resetSelectionState(); + return; + } handleFiles(validFiles); } From b6eab6bd288d69d991c96a3c0de39fd70dbcf45b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 9 May 2026 15:53:20 +0000 Subject: [PATCH 3/5] test: add upload limit tests and polish validation UX text Agent-Logs-Url: https://github.com/gzhang33/imgtowebp/sessions/24659e10-c5d6-4655-9b99-3fdf0366ad5b Co-authored-by: gzhang33 <120421065+gzhang33@users.noreply.github.com> --- src/imgtowebp/web/app.py | 6 +----- src/imgtowebp/web/templates/index.html | 16 +++++++++++----- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/src/imgtowebp/web/app.py b/src/imgtowebp/web/app.py index 770f977..1e1862e 100644 --- a/src/imgtowebp/web/app.py +++ b/src/imgtowebp/web/app.py @@ -222,7 +222,7 @@ def create_app(output_dir: Path) -> Flask: } @app.errorhandler(RequestEntityTooLarge) - def handle_request_too_large(_: RequestEntityTooLarge): + def handle_request_too_large(error: RequestEntityTooLarge): session[SESSION_FORM_ERROR_ONCE_KEY] = ( "Upload is too large. Keep total upload size under 4 MB." ) @@ -260,7 +260,6 @@ def upload() -> str: quality = max(0, min(100, quality)) results: list[UploadItemResult] = [] - validated_uploads: list[tuple[str, str, bytes]] = [] total_in = 0 total_out = 0 total_received = 0 @@ -313,9 +312,6 @@ def upload() -> str: ) continue - validated_uploads.append((original_name, stem, data)) - - for original_name, stem, data in validated_uploads: out_path = target_dir / f"{stem}.webp" res = convert_image(data, out_path, quality=quality, overwrite=overwrite) diff --git a/src/imgtowebp/web/templates/index.html b/src/imgtowebp/web/templates/index.html index bf0a60d..9f67cdf 100644 --- a/src/imgtowebp/web/templates/index.html +++ b/src/imgtowebp/web/templates/index.html @@ -811,6 +811,8 @@

Files

const uploadPolicy = {{ upload_policy | tojson }}; const MAX_DISPLAYED_ERRORS = 3; + const ALLOWED_MIME_TYPES = new Set(uploadPolicy.allowed_mime_types || []); + const ALLOWED_EXTENSIONS = new Set(uploadPolicy.allowed_extensions || []); const mainForm = document.getElementById("mainForm"); const convertingOverlay = document.getElementById("convertingOverlay"); const formErrorBanner = document.getElementById("formErrorBanner"); @@ -846,8 +848,6 @@

Files

function validateSelectedFiles(fileList) { const validFiles = []; const errors = []; - const allowedMimes = new Set(uploadPolicy.allowed_mime_types || []); - const allowedExts = new Set(uploadPolicy.allowed_extensions || []); const maxFileBytes = Number(uploadPolicy.max_file_bytes || 0); const maxTotalBytes = Number(uploadPolicy.max_total_bytes || 0); let totalBytes = 0; @@ -855,11 +855,11 @@

Files

Array.from(fileList || []).forEach((file) => { const ext = getFileExt(file.name); const mime = normalizeMime(file.type); - if (!allowedExts.has(ext)) { + if (!ALLOWED_EXTENSIONS.has(ext)) { errors.push(`${file.name}: unsupported type.`); return; } - if (mime && !allowedMimes.has(mime)) { + if (mime && !ALLOWED_MIME_TYPES.has(mime)) { errors.push(`${file.name}: unsupported type.`); return; } @@ -886,6 +886,12 @@

Files

return { validFiles, errors, totalBytes }; } + /** + * Try to set files via DataTransfer. + * @param {HTMLInputElement | null} fileInputEl + * @param {File[]} files + * @returns {boolean} true if files were set successfully; false otherwise. + */ function setInputFiles(fileInputEl, files) { if (!fileInputEl) return false; try { @@ -1039,7 +1045,7 @@

Files

selectedFiles = validFiles; if (!setInputFiles(fileInput, validFiles)) { setFormError( - "Browser does not support this upload workflow. Please use the file picker again." + "Your browser does not support programmatic file selection. Please use the file picker to select your files again." ); resetSelectionState(); return; From edc5d0fdce3be11f7315237ec2138926be5c8286 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 9 May 2026 15:53:39 +0000 Subject: [PATCH 4/5] perf: stop file validation loop once total upload limit is exceeded Agent-Logs-Url: https://github.com/gzhang33/imgtowebp/sessions/24659e10-c5d6-4655-9b99-3fdf0366ad5b Co-authored-by: gzhang33 <120421065+gzhang33@users.noreply.github.com> --- src/imgtowebp/web/templates/index.html | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/imgtowebp/web/templates/index.html b/src/imgtowebp/web/templates/index.html index 9f67cdf..2475f6e 100644 --- a/src/imgtowebp/web/templates/index.html +++ b/src/imgtowebp/web/templates/index.html @@ -852,16 +852,16 @@

Files

const maxTotalBytes = Number(uploadPolicy.max_total_bytes || 0); let totalBytes = 0; - Array.from(fileList || []).forEach((file) => { + for (const file of Array.from(fileList || [])) { const ext = getFileExt(file.name); const mime = normalizeMime(file.type); if (!ALLOWED_EXTENSIONS.has(ext)) { errors.push(`${file.name}: unsupported type.`); - return; + continue; } if (mime && !ALLOWED_MIME_TYPES.has(mime)) { errors.push(`${file.name}: unsupported type.`); - return; + continue; } if (maxFileBytes > 0 && file.size > maxFileBytes) { errors.push( @@ -869,7 +869,7 @@

Files

maxFileBytes )}).` ); - return; + continue; } totalBytes += file.size; if (maxTotalBytes > 0 && totalBytes > maxTotalBytes) { @@ -878,10 +878,10 @@

Files

maxTotalBytes )}).` ); - return; + break; } validFiles.push(file); - }); + } return { validFiles, errors, totalBytes }; } From 4ad024253872162ca4a029461a52c0c563943dfd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 9 May 2026 15:56:02 +0000 Subject: [PATCH 5/5] chore: finalize upload guard refinements and message consistency Agent-Logs-Url: https://github.com/gzhang33/imgtowebp/sessions/24659e10-c5d6-4655-9b99-3fdf0366ad5b Co-authored-by: gzhang33 <120421065+gzhang33@users.noreply.github.com> --- src/imgtowebp/web/app.py | 16 +++++++++------- src/imgtowebp/web/templates/index.html | 6 +++--- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/src/imgtowebp/web/app.py b/src/imgtowebp/web/app.py index 1e1862e..9b1ecbb 100644 --- a/src/imgtowebp/web/app.py +++ b/src/imgtowebp/web/app.py @@ -87,11 +87,10 @@ def resolve_output_file(output_dir: Path, relative_path: str) -> Path | None: # Inline ZIP in the upload HTML avoids a second HTTP request (needed on serverless # where /tmp may not exist on a different instance than /upload). MAX_INLINE_ZIP_BYTES = 4 * 1024 * 1024 -VERCEL_FUNCTION_PAYLOAD_LIMIT_BYTES = int(4.5 * 1024 * 1024) # Keep a safety margin under Vercel's 4.5MB request payload limit. UPLOAD_TOTAL_LIMIT_BYTES = 4 * 1024 * 1024 UPLOAD_FILE_LIMIT_BYTES = 4 * 1024 * 1024 -ALLOWED_MIME_TYPES = {"image/jpeg", "image/png", "image/webp"} +ALLOWED_MIME_TYPES = {"image/jpeg", "image/png"} ALLOWED_EXTENSIONS = set(SUPPORTED_EXTENSIONS) # Ephemeral Web UI output: each browser session writes under output_dir/_sessions//. @@ -164,6 +163,10 @@ def normalize_mimetype(raw: str | None) -> str: return (raw or "").split(";", 1)[0].strip().lower() +def limit_label(num_bytes: int) -> str: + return f"{num_bytes / (1024 * 1024):.0f} MB" + + def validate_upload_payload( *, filename: str, @@ -174,7 +177,7 @@ def validate_upload_payload( if not data: return "Empty file." if len(data) > UPLOAD_FILE_LIMIT_BYTES: - return "Single file is too large. Keep each file under 4 MB." + return f"Single file is too large. Keep each file under {limit_label(UPLOAD_FILE_LIMIT_BYTES)}." if ext not in ALLOWED_EXTENSIONS: return "Unsupported file type. Only JPG/JPEG/PNG are allowed." normalized_mime = normalize_mimetype(mimetype) @@ -214,7 +217,6 @@ def create_app(output_dir: Path) -> Flask: output_dir.mkdir(parents=True, exist_ok=True) upload_policy: dict[str, Any] = { - "vercel_function_limit_bytes": VERCEL_FUNCTION_PAYLOAD_LIMIT_BYTES, "max_total_bytes": UPLOAD_TOTAL_LIMIT_BYTES, "max_file_bytes": UPLOAD_FILE_LIMIT_BYTES, "allowed_mime_types": sorted(ALLOWED_MIME_TYPES), @@ -222,9 +224,9 @@ def create_app(output_dir: Path) -> Flask: } @app.errorhandler(RequestEntityTooLarge) - def handle_request_too_large(error: RequestEntityTooLarge): + def handle_request_too_large(_error: RequestEntityTooLarge): session[SESSION_FORM_ERROR_ONCE_KEY] = ( - "Upload is too large. Keep total upload size under 4 MB." + f"Upload is too large. Keep total upload size under {limit_label(UPLOAD_TOTAL_LIMIT_BYTES)}." ) return redirect(url_for("index"), code=303) @@ -291,7 +293,7 @@ def upload() -> str: total_received += len(data) if total_received > UPLOAD_TOTAL_LIMIT_BYTES: session[SESSION_FORM_ERROR_ONCE_KEY] = ( - "Upload is too large. Keep total upload size under 4 MB." + f"Upload is too large. Keep total upload size under {limit_label(UPLOAD_TOTAL_LIMIT_BYTES)}." ) return redirect(url_for("index"), code=303) diff --git a/src/imgtowebp/web/templates/index.html b/src/imgtowebp/web/templates/index.html index 2475f6e..a141696 100644 --- a/src/imgtowebp/web/templates/index.html +++ b/src/imgtowebp/web/templates/index.html @@ -209,7 +209,7 @@

- Upload policy: JPG/JPEG/PNG only, up to 4 MB total. + Upload policy: JPG/JPEG/PNG only, up to {{ (upload_policy.max_total_bytes / (1024 * 1024)) | int }} MB total.

{% if form_error %}
Files

function setInputFiles(fileInputEl, files) { if (!fileInputEl) return false; try { - if (typeof DataTransfer === "undefined") { + if (typeof DataTransfer !== "function") { return false; } const dt = new DataTransfer(); @@ -1045,7 +1045,7 @@

Files

selectedFiles = validFiles; if (!setInputFiles(fileInput, validFiles)) { setFormError( - "Your browser does not support programmatic file selection. Please use the file picker to select your files again." + "Your browser does not support programmatic file selection. Please re-select files using the Select Files button." ); resetSelectionState(); return;