ui redesign, markdown fix + metadata and auth header
This commit is contained in:
+113
-38
@@ -19,22 +19,72 @@ 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 file_path = state.data_dir.join("uploads").join(&filename);
|
||||
let uploads_dir = state.data_dir.join("uploads");
|
||||
let file_path = uploads_dir.join(&filename);
|
||||
|
||||
// Security check to prevent directory traversal
|
||||
if file_path.parent() != Some(&state.data_dir.join("uploads")) {
|
||||
return Err(AppError::BadRequest("Invalid filename".to_string()));
|
||||
}
|
||||
|
||||
if file_path.exists() {
|
||||
fs::remove_file(file_path).map_err(|e| {
|
||||
let canonical_dir = uploads_dir.canonicalize().map_err(|e| {
|
||||
AppError::Internal("Path resolution".to_string(), Some(e.to_string()))
|
||||
})?;
|
||||
if let Ok(canonical_file) = file_path.canonicalize() {
|
||||
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).map_err(|e| {
|
||||
error!("Delete error for file {}: {}", filename, e);
|
||||
AppError::Internal("Delete error".to_string(), Some(e.to_string()))
|
||||
})?;
|
||||
@@ -82,57 +132,82 @@ pub async fn upload_file(
|
||||
info!("Upload requested");
|
||||
|
||||
while let Ok(Some(field)) = multipart.next_field().await {
|
||||
let file_name = match field.file_name() {
|
||||
let original_name = match field.file_name() {
|
||||
Some(name) => name.to_string(),
|
||||
None => continue,
|
||||
};
|
||||
|
||||
info!("Processing upload for: {}", file_name);
|
||||
let slugified_name = slug::slugify(&file_name);
|
||||
info!("Processing upload for: {}", original_name);
|
||||
|
||||
let extension = std::path::Path::new(&file_name)
|
||||
let extension = std::path::Path::new(&original_name)
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.unwrap_or("");
|
||||
.map(|e| e.to_ascii_lowercase())
|
||||
.unwrap_or_default();
|
||||
|
||||
let final_name = if !extension.is_empty() {
|
||||
format!("{}.{}", slugified_name, extension)
|
||||
} else {
|
||||
slugified_name
|
||||
};
|
||||
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 file_path = uploads_dir.join(&final_name);
|
||||
let target_path = uploads_dir.join(&final_name);
|
||||
|
||||
let final_path = if file_path.exists() && !query.replace.unwrap_or(false) {
|
||||
let final_path = if target_path.exists() && !query.replace.unwrap_or(false) {
|
||||
let timestamp = chrono::Utc::now().timestamp();
|
||||
uploads_dir.join(format!("{}_{}", timestamp, final_name))
|
||||
} else {
|
||||
file_path
|
||||
target_path
|
||||
};
|
||||
|
||||
// Final containment check.
|
||||
let canonical_dir = uploads_dir.canonicalize().map_err(|e| {
|
||||
AppError::Internal("Path resolution".to_string(), Some(e.to_string()))
|
||||
})?;
|
||||
if let Some(parent) = final_path.parent() {
|
||||
let canonical_parent = parent.canonicalize().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()
|
||||
.unwrap()
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or(&final_name)
|
||||
.to_string();
|
||||
|
||||
let data = match field.bytes().await {
|
||||
Ok(bytes) => bytes,
|
||||
Err(e) => {
|
||||
error!("Failed to read multipart bytes: {}", e);
|
||||
return Err(AppError::BadRequest(format!("Read error: {}", e)));
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) = fs::write(&final_path, &data) {
|
||||
fs::write(&final_path, &data).map_err(|e| {
|
||||
error!("Failed to write file to {:?}: {}", final_path, e);
|
||||
return Err(AppError::Internal(
|
||||
"Write error".to_string(),
|
||||
Some(e.to_string()),
|
||||
));
|
||||
}
|
||||
AppError::Internal("Write error".to_string(), Some(e.to_string()))
|
||||
})?;
|
||||
|
||||
info!("File uploaded successfully to {:?}", final_path);
|
||||
return Ok(Json(UploadResponse {
|
||||
|
||||
Reference in New Issue
Block a user