244 lines
8.1 KiB
Rust
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()))
|
|
}
|