feat(uploads): restrict to webp/avif with conversion hint

This commit is contained in:
2026-07-12 11:03:49 +02:00
parent 34dc67ce99
commit ff0452bcc3
3 changed files with 28 additions and 14 deletions
+6 -4
View File
@@ -19,9 +19,7 @@ module Volumen
MAX_LOGIN_ATTEMPTS = 10 MAX_LOGIN_ATTEMPTS = 10
LOGIN_WINDOW = 60 # seconds LOGIN_WINDOW = 60 # seconds
MAX_UPLOAD_BYTES = 10 * 1024 * 1024 # 10 MB MAX_UPLOAD_BYTES = 10 * 1024 * 1024 # 10 MB
ALLOWED_UPLOAD_TYPES = %w[ ALLOWED_UPLOAD_TYPES = %w[image/webp image/avif].freeze
image/jpeg image/png image/gif image/webp image/avif image/svg+xml
].freeze
SLUG_REGEX = /\A[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?\z/ SLUG_REGEX = /\A[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?\z/
register ApiRoutes register ApiRoutes
@@ -248,7 +246,11 @@ module Volumen
content_type = upload[:type].to_s.split(";").first.to_s.strip.downcase content_type = upload[:type].to_s.split(";").first.to_s.strip.downcase
return if ALLOWED_UPLOAD_TYPES.include?(content_type) 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 end
def check_login_rate_limit! def check_login_rate_limit!
+17 -5
View File
@@ -299,13 +299,25 @@
// --- Image upload ------------------------------------------------------ // --- Image upload ------------------------------------------------------
function uploadImage(file, onDone) { function uploadImage(file, onSuccess, onError) {
var data = new FormData(); var data = new FormData();
data.append("file", file); data.append("file", file);
data.append("_csrf", csrf); data.append("_csrf", csrf);
fetch(uploadUrl, { method: "POST", body: data }) fetch(uploadUrl, { method: "POST", body: data })
.then(function (response) { return response.json(); }) .then(function (response) {
.then(function (json) { if (json.url) { onDone(json.url); } }); 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 () { imageInput.addEventListener("change", function () {
@@ -317,7 +329,7 @@
} else { } else {
exec("insertImage", url); exec("insertImage", url);
} }
}); }, showUploadError);
imageInput.value = ""; imageInput.value = "";
}); });
@@ -346,7 +358,7 @@
uploadImage(file, function (url) { uploadImage(file, function (url) {
coverInput.value = url; coverInput.value = url;
refreshCoverPreview(); refreshCoverPreview();
}); }, showUploadError);
coverFile.value = ""; coverFile.value = "";
}); });
coverInput.addEventListener("input", refreshCoverPreview); coverInput.addEventListener("input", refreshCoverPreview);
+5 -5
View File
@@ -134,7 +134,7 @@ class AdminTest < Minitest::Test
"_csrf" => token, "_csrf" => token,
"file" => Rack::Test::UploadedFile.new(txt_path, "text/plain") "file" => Rack::Test::UploadedFile.new(txt_path, "text/plain")
assert_equal 415, last_response.status 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 end
def test_upload_rejects_file_too_large def test_upload_rejects_file_too_large
@@ -154,18 +154,18 @@ class AdminTest < Minitest::Test
login login
get "/admin/posts/new" get "/admin/posts/new"
token = csrf(last_response.body) token = csrf(last_response.body)
image_path = File.join(@dir, "pic.png") image_path = File.join(@dir, "pic.webp")
File.binwrite(image_path, "PNGDATA") File.binwrite(image_path, "WEBDATA")
post "/admin/uploads", post "/admin/uploads",
"_csrf" => token, "_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? assert_predicate last_response, :ok?
url = JSON.parse(last_response.body)["url"] url = JSON.parse(last_response.body)["url"]
assert_match(%r{\A/media/}, url) assert_match(%r{\A/media/}, url)
get url get url
assert_predicate last_response, :ok? assert_predicate last_response, :ok?
assert_equal "PNGDATA", last_response.body assert_equal "WEBDATA", last_response.body
end end
def test_settings_requires_login def test_settings_requires_login