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
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!
+17 -5
View File
@@ -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);
+5 -5
View File
@@ -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