feat(uploads): restrict to webp/avif with conversion hint
This commit is contained in:
@@ -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!
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user