Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 71 additions & 6 deletions src/imgtowebp/web/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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/<id>/.
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:
Expand Down Expand Up @@ -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
Expand All @@ -172,25 +208,43 @@ 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")
or secrets.token_hex(32)
)
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,
summary=None,
zip_relpaths=[],
inline_zip_b64=None,
zip_fallback_only=False,
form_error=form_error,
upload_policy=upload_policy,
)

@app.post("/upload")
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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")
Expand Down
188 changes: 178 additions & 10 deletions src/imgtowebp/web/templates/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,27 @@ <h2 class="text-3xl font-bold text-slate-800 mb-2">
>
</div>
</div>

<div class="w-full max-w-3xl mx-auto space-y-2">
<p class="text-xs text-slate-500 text-center">
Upload policy: JPG/JPEG/PNG only, up to {{ (upload_policy.max_total_bytes / (1024 * 1024)) | int }} MB total.
</p>
{% if form_error %}
<div
id="formErrorBanner"
class="rounded-xl border border-red-200 bg-red-50 text-red-800 px-4 py-3 text-sm text-center"
role="alert"
>
{{ form_error }}
</div>
{% else %}
<div
id="formErrorBanner"
class="hidden rounded-xl border border-red-200 bg-red-50 text-red-800 px-4 py-3 text-sm text-center"
role="alert"
></div>
{% endif %}
</div>
</div>

<!-- Floating Action Bar -->
Expand Down Expand Up @@ -788,10 +809,122 @@ <h3 class="font-semibold text-slate-800 text-lg">Files</h3>

// --- 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 <input type="file"> 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) {
Expand Down Expand Up @@ -825,6 +958,20 @@ <h3 class="font-semibold text-slate-800 text-lg">Files</h3>
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
Expand Down Expand Up @@ -871,20 +1018,41 @@ <h3 class="font-semibold text-slate-800 text-lg">Files</h3>
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;

Expand Down
Loading
Loading