Files
narlblog/backend/src/handlers/upload.rs
T
2026-05-16 23:48:57 +02:00

244 lines
8.1 KiB
Rust

use axum::{
Json,
extract::{Multipart, Path, Query, State},
http::{HeaderMap, StatusCode},
};
use serde::Deserialize;
use std::sync::Arc;
use tokio::fs;
use tracing::{error, info, warn};
use crate::{
AppState,
auth::check_auth,
error::AppError,
models::{FileInfo, UploadResponse},
};
#[derive(Deserialize)]
pub struct UploadQuery {
pub replace: Option<bool>,
}
/// Allowed upload extensions. SVG, HTML, JS, executables intentionally absent —
/// /uploads/* is served as-is, so any active content there is XSS waiting to happen.
const ALLOWED_EXTS: &[&str] = &[
"jpg", "jpeg", "png", "webp", "gif", "avif", "pdf", "txt", "md", "mp3", "wav", "ogg", "mp4",
"webm", "mov",
];
fn validate_filename(name: &str) -> Result<(), AppError> {
if name.is_empty()
|| name.contains('/')
|| name.contains('\\')
|| name.contains("..")
|| name.contains('\0')
|| name.starts_with('.')
{
return Err(AppError::BadRequest("Invalid filename".to_string()));
}
Ok(())
}
fn check_mime_matches_ext(bytes: &[u8], ext: &str) -> bool {
let Some(kind) = infer::get(bytes) else {
// Plain text formats (txt, md) won't be detected by magic bytes.
return matches!(ext, "txt" | "md");
};
let mime = kind.mime_type();
match ext {
"jpg" | "jpeg" => mime == "image/jpeg",
"png" => mime == "image/png",
"webp" => mime == "image/webp",
"gif" => mime == "image/gif",
"avif" => mime == "image/avif",
"pdf" => mime == "application/pdf",
"mp3" => mime == "audio/mpeg",
"wav" => mime == "audio/x-wav" || mime == "audio/wav",
"ogg" => mime == "audio/ogg" || mime == "video/ogg",
"mp4" => mime == "video/mp4",
"webm" => mime == "video/webm",
"mov" => mime == "video/quicktime",
"txt" | "md" => true,
_ => false,
}
}
pub async fn delete_upload(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Path(filename): Path<String>,
) -> Result<StatusCode, AppError> {
check_auth(&headers, &state.admin_token)?;
validate_filename(&filename)?;
let uploads_dir = state.data_dir.join("uploads");
let file_path = uploads_dir.join(&filename);
let canonical_dir = fs::canonicalize(&uploads_dir)
.await
.map_err(|e| AppError::Internal("Path resolution".to_string(), Some(e.to_string())))?;
if let Ok(canonical_file) = fs::canonicalize(&file_path).await {
if !canonical_file.starts_with(&canonical_dir) {
warn!("Refused delete outside uploads dir: {}", filename);
return Err(AppError::BadRequest("Invalid filename".to_string()));
}
fs::remove_file(canonical_file).await.map_err(|e| {
error!("Delete error for file {}: {}", filename, e);
AppError::Internal("Delete error".to_string(), Some(e.to_string()))
})?;
state
.image_dims_cache
.write()
.await
.remove(&format!("/uploads/{}", filename));
info!("Deleted file: {}", filename);
Ok(StatusCode::NO_CONTENT)
} else {
Err(AppError::NotFound("File not found".to_string()))
}
}
pub async fn list_uploads(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
) -> Result<Json<Vec<FileInfo>>, AppError> {
check_auth(&headers, &state.admin_token)?;
let uploads_dir = state.data_dir.join("uploads");
let mut files = Vec::new();
if let Ok(mut rd) = fs::read_dir(&uploads_dir).await {
loop {
match rd.next_entry().await {
Ok(Some(entry)) => {
let path = entry.path();
let is_file = entry
.file_type()
.await
.map(|t| t.is_file())
.unwrap_or(false);
if !is_file {
continue;
}
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
files.push(FileInfo {
name: name.to_string(),
url: format!("/uploads/{}", name),
});
}
}
Ok(None) => break,
Err(e) => {
warn!("Error iterating uploads dir: {}", e);
break;
}
}
}
}
Ok(Json(files))
}
pub async fn upload_file(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Query(query): Query<UploadQuery>,
mut multipart: Multipart,
) -> Result<Json<UploadResponse>, AppError> {
check_auth(&headers, &state.admin_token)?;
info!("Upload requested");
while let Ok(Some(field)) = multipart.next_field().await {
let original_name = match field.file_name() {
Some(name) => name.to_string(),
None => continue,
};
info!("Processing upload for: {}", original_name);
let extension = std::path::Path::new(&original_name)
.extension()
.and_then(|e| e.to_str())
.map(|e| e.to_ascii_lowercase())
.unwrap_or_default();
if !ALLOWED_EXTS.contains(&extension.as_str()) {
warn!("Upload rejected: extension '{}' not allowed", extension);
return Err(AppError::BadRequest(format!(
"File type '.{}' not allowed",
extension
)));
}
let stem = std::path::Path::new(&original_name)
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("file");
let slugified = slug::slugify(stem);
let final_name = format!("{}.{}", slugified, extension);
let data = field.bytes().await.map_err(|e| {
error!("Failed to read multipart bytes: {}", e);
AppError::BadRequest(format!("Read error: {}", e))
})?;
if !check_mime_matches_ext(&data, &extension) {
warn!(
"Upload rejected: magic bytes don't match extension '{}'",
extension
);
return Err(AppError::BadRequest(
"File contents don't match extension".to_string(),
));
}
let uploads_dir = state.data_dir.join("uploads");
let target_path = uploads_dir.join(&final_name);
let final_path = if fs::try_exists(&target_path).await.unwrap_or(false)
&& !query.replace.unwrap_or(false)
{
let timestamp = chrono::Utc::now().timestamp();
uploads_dir.join(format!("{}_{}", timestamp, final_name))
} else {
target_path
};
// Final containment check.
let canonical_dir = fs::canonicalize(&uploads_dir)
.await
.map_err(|e| AppError::Internal("Path resolution".to_string(), Some(e.to_string())))?;
if let Some(parent) = final_path.parent() {
let canonical_parent = fs::canonicalize(parent).await.map_err(|e| {
AppError::Internal("Path resolution".to_string(), Some(e.to_string()))
})?;
if canonical_parent != canonical_dir {
return Err(AppError::BadRequest("Invalid filename".to_string()));
}
}
let final_name_str = final_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(&final_name)
.to_string();
fs::write(&final_path, &data).await.map_err(|e| {
error!("Failed to write file to {:?}: {}", final_path, e);
AppError::Internal("Write error".to_string(), Some(e.to_string()))
})?;
let url = format!("/uploads/{}", final_name_str);
// Invalidate any stale dim cache entry (matters when replacing an existing file).
state.image_dims_cache.write().await.remove(&url);
info!("File uploaded successfully to {:?}", final_path);
return Ok(Json(UploadResponse { url }));
}
warn!("Upload failed: no file found in multipart stream");
Err(AppError::BadRequest("No file found".to_string()))
}