From ff0452bcc3dda84f8ef6069ea2ec066b517c6cca Mon Sep 17 00:00:00 2001 From: Petr Date: Sun, 12 Jul 2026 11:03:49 +0200 Subject: [PATCH] feat(uploads): restrict to webp/avif with conversion hint --- lib/volumen/server.rb | 10 ++++++---- lib/volumen/web/views/form.erb | 22 +++++++++++++++++----- test/admin_test.rb | 10 +++++----- 3 files changed, 28 insertions(+), 14 deletions(-) diff --git a/lib/volumen/server.rb b/lib/volumen/server.rb index b94cbc7..58f9efd 100644 --- a/lib/volumen/server.rb +++ b/lib/volumen/server.rb @@ -19,9 +19,7 @@ module Volumen MAX_LOGIN_ATTEMPTS = 10 LOGIN_WINDOW = 60 # seconds MAX_UPLOAD_BYTES = 10 * 1024 * 1024 # 10 MB - ALLOWED_UPLOAD_TYPES = %w[ - image/jpeg image/png image/gif image/webp image/avif image/svg+xml - ].freeze + ALLOWED_UPLOAD_TYPES = %w[image/webp image/avif].freeze SLUG_REGEX = /\A[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?\z/ register ApiRoutes @@ -248,7 +246,11 @@ module Volumen content_type = upload[:type].to_s.split(";").first.to_s.strip.downcase return if ALLOWED_UPLOAD_TYPES.include?(content_type) - halt(415, json("error" => "unsupported_media_type", "allowed" => ALLOWED_UPLOAD_TYPES)) + halt(415, json("error" => "unsupported_format", + "message" => "Only WebP (.webp) and AVIF (.avif) images are supported. " \ + "Please convert your image before uploading — for example with " \ + "`cwebp` (JPEG/PNG → WebP) or `avifenc` (PNG/JPEG → AVIF).", + "allowed" => ALLOWED_UPLOAD_TYPES)) end def check_login_rate_limit! diff --git a/lib/volumen/web/views/form.erb b/lib/volumen/web/views/form.erb index 9ffee07..b9353f9 100644 --- a/lib/volumen/web/views/form.erb +++ b/lib/volumen/web/views/form.erb @@ -299,13 +299,25 @@ // --- Image upload ------------------------------------------------------ - function uploadImage(file, onDone) { + function uploadImage(file, onSuccess, onError) { var data = new FormData(); data.append("file", file); data.append("_csrf", csrf); fetch(uploadUrl, { method: "POST", body: data }) - .then(function (response) { return response.json(); }) - .then(function (json) { if (json.url) { onDone(json.url); } }); + .then(function (response) { + return response.json().then(function (json) { return { ok: response.ok, json: json }; }); + }) + .then(function (result) { + if (result.ok && result.json.url) { + onSuccess(result.json.url); + } else { + onError(result.json.message || ("Upload failed (HTTP " + (result.json.error || "error") + ")")); + } + }); + } + + function showUploadError(message) { + alert("Upload failed:\n\n" + message); } imageInput.addEventListener("change", function () { @@ -317,7 +329,7 @@ } else { exec("insertImage", url); } - }); + }, showUploadError); imageInput.value = ""; }); @@ -346,7 +358,7 @@ uploadImage(file, function (url) { coverInput.value = url; refreshCoverPreview(); - }); + }, showUploadError); coverFile.value = ""; }); coverInput.addEventListener("input", refreshCoverPreview); diff --git a/test/admin_test.rb b/test/admin_test.rb index f342129..30785df 100644 --- a/test/admin_test.rb +++ b/test/admin_test.rb @@ -134,7 +134,7 @@ class AdminTest < Minitest::Test "_csrf" => token, "file" => Rack::Test::UploadedFile.new(txt_path, "text/plain") assert_equal 415, last_response.status - assert_equal "unsupported_media_type", JSON.parse(last_response.body)["error"] + assert_equal "unsupported_format", JSON.parse(last_response.body)["error"] end def test_upload_rejects_file_too_large @@ -154,18 +154,18 @@ class AdminTest < Minitest::Test login get "/admin/posts/new" token = csrf(last_response.body) - image_path = File.join(@dir, "pic.png") - File.binwrite(image_path, "PNGDATA") + image_path = File.join(@dir, "pic.webp") + File.binwrite(image_path, "WEBDATA") post "/admin/uploads", "_csrf" => token, - "file" => Rack::Test::UploadedFile.new(image_path, "image/png") + "file" => Rack::Test::UploadedFile.new(image_path, "image/webp") assert_predicate last_response, :ok? url = JSON.parse(last_response.body)["url"] assert_match(%r{\A/media/}, url) get url assert_predicate last_response, :ok? - assert_equal "PNGDATA", last_response.body + assert_equal "WEBDATA", last_response.body end def test_settings_requires_login