use axum::body::Bytes;
use umbral::web::{HeaderMap, IntoResponse, Response, StatusCode};
use crate::auth::require_staff;
const MAX_UPLOAD_BYTES: usize = 10 * 1024 * 1024;
const ALLOWED_IMAGE_TYPES: &[&str] = &[
"image/png",
"image/jpeg",
"image/gif",
"image/webp",
"image/svg+xml",
];
fn sniff_raster_image(bytes: &[u8]) -> Option<&'static str> {
if bytes.starts_with(&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]) {
Some("image/png")
} else if bytes.starts_with(&[0xFF, 0xD8, 0xFF]) {
Some("image/jpeg")
} else if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") {
Some("image/gif")
} else if bytes.len() >= 12 && &bytes[0..4] == b"RIFF" && &bytes[8..12] == b"WEBP" {
Some("image/webp")
} else {
None
}
}
fn json_error(status: StatusCode, message: &str) -> Response {
let body = serde_json::json!({ "error": message }).to_string();
(
status,
[(axum::http::header::CONTENT_TYPE, "application/json")],
body,
)
.into_response()
}
pub(crate) async fn upload_image(headers: HeaderMap, body: Bytes) -> Response {
let base = crate::branding::current().base_path;
let path = format!("{base}/upload-image");
let _who = match require_staff(&headers, &path).await {
Ok(u) => u,
Err(r) => return r,
};
let content_type = headers
.get(axum::http::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or_default();
if !umbral::web::multipart::is_multipart(content_type) {
return json_error(
StatusCode::BAD_REQUEST,
"upload must be multipart/form-data",
);
}
let form = match umbral::web::multipart::parse_multipart(content_type, body).await {
Ok(f) => f,
Err(e) => {
return json_error(
StatusCode::BAD_REQUEST,
&format!("could not parse upload: {e}"),
);
}
};
let part = form
.files
.iter()
.find(|f| f.field_name == "image" || f.field_name == "file")
.or_else(|| form.files.first());
let Some(part) = part else {
return json_error(StatusCode::BAD_REQUEST, "no image part in upload");
};
if part.bytes.is_empty() {
return json_error(StatusCode::BAD_REQUEST, "uploaded image is empty");
}
if part.bytes.len() > MAX_UPLOAD_BYTES {
return json_error(
StatusCode::PAYLOAD_TOO_LARGE,
"image exceeds the 10 MiB upload limit",
);
}
let declared = part
.content_type
.as_deref()
.unwrap_or("application/octet-stream")
.split(';')
.next()
.unwrap_or("")
.trim()
.to_ascii_lowercase();
if !ALLOWED_IMAGE_TYPES.contains(&declared.as_str()) {
return json_error(
StatusCode::UNSUPPORTED_MEDIA_TYPE,
"only image uploads are allowed (png, jpeg, gif, webp, svg)",
);
}
if declared == "image/svg+xml" {
let head = part
.bytes
.strip_prefix(&[0xEF, 0xBB, 0xBF])
.unwrap_or(&part.bytes);
if head.iter().find(|b| !b.is_ascii_whitespace()) != Some(&b'<') {
return json_error(
StatusCode::UNSUPPORTED_MEDIA_TYPE,
"SVG upload does not look like SVG/XML markup",
);
}
} else if sniff_raster_image(&part.bytes) != Some(declared.as_str()) {
return json_error(
StatusCode::UNSUPPORTED_MEDIA_TYPE,
"uploaded file content does not match its declared image type",
);
}
let Some(storage) = umbral::storage::storage_opt() else {
return json_error(
StatusCode::CONFLICT,
"image upload requires a storage backend — add StoragePlugin",
);
};
let filename = part
.filename
.as_deref()
.filter(|f| !f.is_empty())
.unwrap_or("upload.png");
match storage.store(filename, &declared, &part.bytes).await {
Ok(stored) => {
let body = serde_json::json!({ "url": stored.url }).to_string();
(
StatusCode::OK,
[(axum::http::header::CONTENT_TYPE, "application/json")],
body,
)
.into_response()
}
Err(e) => {
tracing::error!(error = %e, "admin: editor image upload failed to store");
json_error(StatusCode::INTERNAL_SERVER_ERROR, "failed to store image")
}
}
}