diff --git a/src/imgtowebp/web/app.py b/src/imgtowebp/web/app.py index 763a9e9..9b1ecbb 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,18 @@ 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 +# 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"} +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 +159,35 @@ 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 limit_label(num_bytes: int) -> str: + return f"{num_bytes / (1024 * 1024):.0f} MB" + + +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 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) + 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 +208,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 +216,26 @@ def create_app(output_dir: Path) -> Flask: ) output_dir.mkdir(parents=True, exist_ok=True) + upload_policy: dict[str, Any] = { + "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(_error: RequestEntityTooLarge): + session[SESSION_FORM_ERROR_ONCE_KEY] = ( + f"Upload is too large. Keep total upload size under {limit_label(UPLOAD_TOTAL_LIMIT_BYTES)}." + ) + 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 +243,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") @@ -210,6 +264,7 @@ def upload() -> str: results: list[UploadItemResult] = [] total_in = 0 total_out = 0 + total_received = 0 converted = 0 skipped = 0 failed = 0 @@ -235,18 +290,26 @@ 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] = ( + f"Upload is too large. Keep total upload size under {limit_label(UPLOAD_TOTAL_LIMIT_BYTES)}." + ) + 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 @@ -351,6 +414,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..a141696 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 {{ (upload_policy.max_total_bytes / (1024 * 1024)) | int }} MB total. +

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

Files

// --- Shared Logic --- + 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"); + 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 maxFileBytes = Number(uploadPolicy.max_file_bytes || 0); + const maxTotalBytes = Number(uploadPolicy.max_total_bytes || 0); + let totalBytes = 0; + + 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.`); + continue; + } + if (mime && !ALLOWED_MIME_TYPES.has(mime)) { + errors.push(`${file.name}: unsupported type.`); + continue; + } + if (maxFileBytes > 0 && file.size > maxFileBytes) { + errors.push( + `${file.name}: too large (${formatBytes(file.size)} > ${formatBytes( + maxFileBytes + )}).` + ); + continue; + } + totalBytes += file.size; + if (maxTotalBytes > 0 && totalBytes > maxTotalBytes) { + errors.push( + `Total size is too large (${formatBytes(totalBytes)} > ${formatBytes( + maxTotalBytes + )}).` + ); + break; + } + validFiles.push(file); + } + + 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 { + if (typeof DataTransfer !== "function") { + 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 () { + 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 +958,20 @@

Files

const previewGrid = document.getElementById("previewGrid"); const actionBar = document.getElementById("actionBar"); const fileCountBadge = document.getElementById("fileCountBadge"); + function resetSelectionState() { + selectedFiles = []; + if (fileInput) setInputFiles(fileInput, []); + 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 +1018,41 @@

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, MAX_DISPLAYED_ERRORS).join(" ")); + } else { + setFormError(""); + } + if (!validFiles.length) { + resetSelectionState(); + return; + } + selectedFiles = validFiles; + if (!setInputFiles(fileInput, validFiles)) { + setFormError( + "Your browser does not support programmatic file selection. Please re-select files using the Select Files button." + ); + resetSelectionState(); + return; + } + 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()