ui redesign, markdown fix + metadata and auth header
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::State,
|
||||
http::{HeaderMap, HeaderValue, StatusCode},
|
||||
response::{AppendHeaders, IntoResponse},
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use std::sync::Arc;
|
||||
use subtle::ConstantTimeEq;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::{AppState, error::AppError};
|
||||
|
||||
const COOKIE_TOKEN: &str = "admin";
|
||||
const COOKIE_FLAG: &str = "admin_session";
|
||||
const MAX_AGE_SECS: i64 = 60 * 60 * 24 * 30; // 30 days
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct LoginRequest {
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
fn cookie_attrs(secure: bool) -> String {
|
||||
let secure_part = if secure { "; Secure" } else { "" };
|
||||
format!(
|
||||
"; HttpOnly{}; SameSite=Strict; Path=/; Max-Age={}",
|
||||
secure_part, MAX_AGE_SECS
|
||||
)
|
||||
}
|
||||
|
||||
fn flag_cookie_attrs(secure: bool) -> String {
|
||||
let secure_part = if secure { "; Secure" } else { "" };
|
||||
format!(
|
||||
"{}; SameSite=Strict; Path=/; Max-Age={}",
|
||||
secure_part, MAX_AGE_SECS
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn login(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(payload): Json<LoginRequest>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let provided = payload.token.as_bytes();
|
||||
let expected = state.admin_token.as_bytes();
|
||||
let ok = if provided.len() == expected.len() {
|
||||
provided.ct_eq(expected).into()
|
||||
} else {
|
||||
// Run constant-time compare anyway to flatten timing.
|
||||
let _: bool = provided.ct_eq(provided).into();
|
||||
false
|
||||
};
|
||||
|
||||
if !ok {
|
||||
warn!("Failed login attempt");
|
||||
return Err(AppError::Unauthorized);
|
||||
}
|
||||
|
||||
info!("Admin logged in");
|
||||
let token_cookie = format!(
|
||||
"{}={}{}",
|
||||
COOKIE_TOKEN,
|
||||
state.admin_token,
|
||||
cookie_attrs(state.cookie_secure)
|
||||
);
|
||||
let flag_cookie = format!(
|
||||
"{}=1{}",
|
||||
COOKIE_FLAG,
|
||||
flag_cookie_attrs(state.cookie_secure)
|
||||
);
|
||||
|
||||
let headers = AppendHeaders([
|
||||
(
|
||||
axum::http::header::SET_COOKIE,
|
||||
HeaderValue::from_str(&token_cookie)
|
||||
.map_err(|e| AppError::Internal("Cookie".to_string(), Some(e.to_string())))?,
|
||||
),
|
||||
(
|
||||
axum::http::header::SET_COOKIE,
|
||||
HeaderValue::from_str(&flag_cookie)
|
||||
.map_err(|e| AppError::Internal("Cookie".to_string(), Some(e.to_string())))?,
|
||||
),
|
||||
]);
|
||||
|
||||
Ok((StatusCode::NO_CONTENT, headers))
|
||||
}
|
||||
|
||||
pub async fn logout(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
||||
let secure_part = if state.cookie_secure { "; Secure" } else { "" };
|
||||
let token_cookie = format!(
|
||||
"{}=; HttpOnly{}; SameSite=Strict; Path=/; Max-Age=0",
|
||||
COOKIE_TOKEN, secure_part
|
||||
);
|
||||
let flag_cookie = format!(
|
||||
"{}={}; SameSite=Strict; Path=/; Max-Age=0",
|
||||
COOKIE_FLAG, secure_part
|
||||
);
|
||||
let headers = AppendHeaders([
|
||||
(
|
||||
axum::http::header::SET_COOKIE,
|
||||
HeaderValue::from_str(&token_cookie).unwrap(),
|
||||
),
|
||||
(
|
||||
axum::http::header::SET_COOKIE,
|
||||
HeaderValue::from_str(&flag_cookie).unwrap(),
|
||||
),
|
||||
]);
|
||||
(StatusCode::NO_CONTENT, headers)
|
||||
}
|
||||
|
||||
pub async fn me(State(state): State<Arc<AppState>>, headers: HeaderMap) -> StatusCode {
|
||||
if crate::auth::is_authed(&headers, &state.admin_token) {
|
||||
StatusCode::NO_CONTENT
|
||||
} else {
|
||||
StatusCode::UNAUTHORIZED
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod auth;
|
||||
pub mod config;
|
||||
pub mod posts;
|
||||
pub mod upload;
|
||||
|
||||
+182
-105
@@ -2,79 +2,164 @@ use axum::{
|
||||
Json,
|
||||
extract::{Path, State},
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::IntoResponse,
|
||||
};
|
||||
use chrono::Utc;
|
||||
use std::{fs, sync::Arc};
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
use crate::{
|
||||
AppState,
|
||||
auth::check_auth,
|
||||
auth::is_authed,
|
||||
error::AppError,
|
||||
models::{CreatePostRequest, PostDetail, PostInfo},
|
||||
models::{CreatePostRequest, PostDetail, PostInfo, PostMeta},
|
||||
};
|
||||
|
||||
const WORDS_PER_MINUTE: u32 = 200;
|
||||
|
||||
fn validate_slug(s: &str) -> Result<(), AppError> {
|
||||
if s.is_empty()
|
||||
|| s.contains('/')
|
||||
|| s.contains('\\')
|
||||
|| s.contains("..")
|
||||
|| s.contains('\0')
|
||||
{
|
||||
return Err(AppError::BadRequest("Invalid slug".to_string()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn split_frontmatter(raw: &str) -> Option<(&str, &str)> {
|
||||
let raw = raw.strip_prefix("---\n").or_else(|| raw.strip_prefix("---\r\n"))?;
|
||||
let end_marker = raw.find("\n---\n").or_else(|| raw.find("\r\n---\r\n"))?;
|
||||
let yaml = &raw[..end_marker];
|
||||
let body_start = end_marker
|
||||
+ raw[end_marker..]
|
||||
.find("---\n")
|
||||
.or_else(|| raw[end_marker..].find("---\r\n"))?
|
||||
+ "---\n".len();
|
||||
let body = raw[body_start..].trim_start_matches('\n').trim_start_matches('\r');
|
||||
Some((yaml, body))
|
||||
}
|
||||
|
||||
fn parse_post(raw: &str) -> Result<(PostMeta, String), AppError> {
|
||||
let (yaml, body) = split_frontmatter(raw).ok_or_else(|| {
|
||||
AppError::Internal(
|
||||
"Missing frontmatter".to_string(),
|
||||
Some("post is missing the YAML --- block".to_string()),
|
||||
)
|
||||
})?;
|
||||
let meta: PostMeta = serde_yaml::from_str(yaml).map_err(|e| {
|
||||
AppError::Internal(
|
||||
"Invalid frontmatter".to_string(),
|
||||
Some(format!("YAML parse error: {}", e)),
|
||||
)
|
||||
})?;
|
||||
Ok((meta, body.to_string()))
|
||||
}
|
||||
|
||||
fn serialize_post(meta: &PostMeta, body: &str) -> Result<String, AppError> {
|
||||
let yaml = serde_yaml::to_string(meta).map_err(|e| {
|
||||
AppError::Internal(
|
||||
"Serialization error".to_string(),
|
||||
Some(e.to_string()),
|
||||
)
|
||||
})?;
|
||||
Ok(format!("---\n{}---\n{}", yaml, body))
|
||||
}
|
||||
|
||||
fn reading_time(body: &str) -> u32 {
|
||||
let words = body.split_whitespace().count() as u32;
|
||||
(words + WORDS_PER_MINUTE - 1) / WORDS_PER_MINUTE.max(1)
|
||||
}
|
||||
|
||||
fn excerpt_from(meta: &PostMeta, body: &str) -> String {
|
||||
if let Some(s) = meta.summary.as_ref() {
|
||||
if !s.trim().is_empty() {
|
||||
return s.trim().to_string();
|
||||
}
|
||||
}
|
||||
let plain = body
|
||||
.replace(['#', '*', '_', '`'], "")
|
||||
.replace('\n', " ");
|
||||
let mut out: String = plain.chars().take(200).collect();
|
||||
if plain.chars().count() > 200 {
|
||||
out.push_str("...");
|
||||
}
|
||||
out.trim().to_string()
|
||||
}
|
||||
|
||||
async fn write_post_atomic(
|
||||
state: &AppState,
|
||||
slug: &str,
|
||||
contents: &str,
|
||||
) -> Result<(), AppError> {
|
||||
let _guard = state.post_lock.lock().await;
|
||||
let final_path = state.data_dir.join("posts").join(format!("{}.md", slug));
|
||||
let tmp_path = state.data_dir.join("posts").join(format!(".{}.md.tmp", slug));
|
||||
fs::write(&tmp_path, contents).map_err(|e| {
|
||||
AppError::Internal("Write error".to_string(), Some(e.to_string()))
|
||||
})?;
|
||||
fs::rename(&tmp_path, &final_path).map_err(|e| {
|
||||
let _ = fs::remove_file(&tmp_path);
|
||||
AppError::Internal("Rename error".to_string(), Some(e.to_string()))
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn create_post(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Json(payload): Json<CreatePostRequest>,
|
||||
) -> Result<Json<PostDetail>, AppError> {
|
||||
check_auth(&headers, &state.admin_token)?;
|
||||
|
||||
if payload.slug.contains('/') || payload.slug.contains('\\') || payload.slug.contains("..") {
|
||||
return Err(AppError::BadRequest("Invalid slug".to_string()));
|
||||
if !is_authed(&headers, &state.admin_token) {
|
||||
return Err(AppError::Unauthorized);
|
||||
}
|
||||
|
||||
let file_path = state
|
||||
.data_dir
|
||||
.join("posts")
|
||||
.join(format!("{}.md", payload.slug));
|
||||
validate_slug(&payload.slug)?;
|
||||
if let Some(ref old) = payload.old_slug {
|
||||
validate_slug(old)?;
|
||||
}
|
||||
|
||||
let posts_dir = state.data_dir.join("posts");
|
||||
let file_path = posts_dir.join(format!("{}.md", payload.slug));
|
||||
|
||||
// Handle renaming
|
||||
if let Some(ref old_slug) = payload.old_slug {
|
||||
if old_slug != &payload.slug {
|
||||
let old_file_path = state
|
||||
.data_dir
|
||||
.join("posts")
|
||||
.join(format!("{}.md", old_slug));
|
||||
if old_file_path.exists() {
|
||||
// If new path already exists and it's different from old path, error out
|
||||
let old_path = posts_dir.join(format!("{}.md", old_slug));
|
||||
if old_path.exists() {
|
||||
if file_path.exists() {
|
||||
return Err(AppError::BadRequest(
|
||||
"A post with this new title already exists".to_string(),
|
||||
));
|
||||
}
|
||||
if let Err(e) = fs::rename(&old_file_path, &file_path) {
|
||||
let _guard = state.post_lock.lock().await;
|
||||
fs::rename(&old_path, &file_path).map_err(|e| {
|
||||
error!("Rename error from {} to {}: {}", old_slug, payload.slug, e);
|
||||
return Err(AppError::Internal(
|
||||
"Rename error".to_string(),
|
||||
Some(e.to_string()),
|
||||
));
|
||||
}
|
||||
AppError::Internal("Rename error".to_string(), Some(e.to_string()))
|
||||
})?;
|
||||
drop(_guard);
|
||||
info!("Renamed post from {} to {}", old_slug, payload.slug);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut file_content = String::new();
|
||||
if let Some(ref summary) = payload.summary {
|
||||
if !summary.trim().is_empty() {
|
||||
file_content.push_str("---\nsummary: ");
|
||||
file_content.push_str(&summary.replace('\n', " "));
|
||||
file_content.push_str("\n---\n");
|
||||
}
|
||||
}
|
||||
file_content.push_str(&payload.content);
|
||||
let meta = PostMeta {
|
||||
date: payload.date.unwrap_or_else(|| Utc::now().date_naive()),
|
||||
summary: payload.summary.filter(|s| !s.trim().is_empty()),
|
||||
tags: payload.tags,
|
||||
draft: payload.draft,
|
||||
};
|
||||
let contents = serialize_post(&meta, &payload.content)?;
|
||||
write_post_atomic(&state, &payload.slug, &contents).await?;
|
||||
|
||||
fs::write(&file_path, &file_content).map_err(|e| {
|
||||
error!("Write error for post {}: {}", payload.slug, e);
|
||||
AppError::Internal("Write error".to_string(), Some(e.to_string()))
|
||||
})?;
|
||||
|
||||
info!("Post created/updated: {}", payload.slug);
|
||||
info!("Post saved: {}", payload.slug);
|
||||
Ok(Json(PostDetail {
|
||||
slug: payload.slug,
|
||||
summary: payload.summary,
|
||||
date: meta.date,
|
||||
summary: meta.summary,
|
||||
tags: meta.tags,
|
||||
draft: meta.draft,
|
||||
reading_time: reading_time(&payload.content),
|
||||
content: payload.content,
|
||||
}))
|
||||
}
|
||||
@@ -84,10 +169,13 @@ pub async fn delete_post(
|
||||
headers: HeaderMap,
|
||||
Path(slug): Path<String>,
|
||||
) -> Result<StatusCode, AppError> {
|
||||
check_auth(&headers, &state.admin_token)?;
|
||||
if !is_authed(&headers, &state.admin_token) {
|
||||
return Err(AppError::Unauthorized);
|
||||
}
|
||||
validate_slug(&slug)?;
|
||||
|
||||
let _guard = state.post_lock.lock().await;
|
||||
let file_path = state.data_dir.join("posts").join(format!("{}.md", slug));
|
||||
info!("Attempting to delete post at: {:?}", file_path);
|
||||
|
||||
if !file_path.exists() {
|
||||
warn!("Post not found for deletion: {}", slug);
|
||||
@@ -103,87 +191,76 @@ pub async fn delete_post(
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
pub async fn list_posts(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
||||
pub async fn list_posts(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
) -> Json<Vec<PostInfo>> {
|
||||
let admin = is_authed(&headers, &state.admin_token);
|
||||
let posts_dir = state.data_dir.join("posts");
|
||||
let mut posts = Vec::new();
|
||||
let mut posts: Vec<PostInfo> = Vec::new();
|
||||
|
||||
if let Ok(entries) = fs::read_dir(posts_dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|e| e.to_str()) == Some("md") {
|
||||
if let Some(slug) = path.file_stem().and_then(|s| s.to_str()) {
|
||||
let mut excerpt = String::new();
|
||||
if let Ok(content) = fs::read_to_string(&path) {
|
||||
if content.starts_with("---\n") {
|
||||
let parts: Vec<&str> = content.splitn(3, "---\n").collect();
|
||||
if parts.len() == 3 {
|
||||
let frontmatter = parts[1];
|
||||
for line in frontmatter.lines() {
|
||||
if line.starts_with("summary: ") {
|
||||
excerpt = line.trim_start_matches("summary: ").to_string();
|
||||
break;
|
||||
}
|
||||
}
|
||||
if excerpt.is_empty() {
|
||||
let body = parts[2];
|
||||
let clean_content = body.replace("#", "").replace("\n", " ");
|
||||
excerpt = clean_content.chars().take(200).collect::<String>();
|
||||
if clean_content.len() > 200 {
|
||||
excerpt.push_str("...");
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let clean_content = content.replace("#", "").replace("\n", " ");
|
||||
excerpt = clean_content.chars().take(200).collect::<String>();
|
||||
if clean_content.len() > 200 {
|
||||
excerpt.push_str("...");
|
||||
}
|
||||
}
|
||||
}
|
||||
posts.push(PostInfo {
|
||||
slug: slug.to_string(),
|
||||
excerpt: excerpt.trim().to_string(),
|
||||
});
|
||||
}
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("md") {
|
||||
continue;
|
||||
}
|
||||
let Some(slug) = path.file_stem().and_then(|s| s.to_str()) else {
|
||||
continue;
|
||||
};
|
||||
if slug.starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
let Ok(raw) = fs::read_to_string(&path) else {
|
||||
continue;
|
||||
};
|
||||
let Ok((meta, body)) = parse_post(&raw) else {
|
||||
warn!("Skipping post with bad frontmatter: {}", slug);
|
||||
continue;
|
||||
};
|
||||
if meta.draft && !admin {
|
||||
continue;
|
||||
}
|
||||
posts.push(PostInfo {
|
||||
slug: slug.to_string(),
|
||||
date: meta.date,
|
||||
summary: meta.summary.clone(),
|
||||
tags: meta.tags.clone(),
|
||||
draft: meta.draft,
|
||||
reading_time: reading_time(&body),
|
||||
excerpt: excerpt_from(&meta, &body),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
posts.sort_by(|a, b| b.date.cmp(&a.date).then_with(|| a.slug.cmp(&b.slug)));
|
||||
Json(posts)
|
||||
}
|
||||
|
||||
pub async fn get_post(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Path(slug): Path<String>,
|
||||
) -> Result<Json<PostDetail>, AppError> {
|
||||
validate_slug(&slug)?;
|
||||
let admin = is_authed(&headers, &state.admin_token);
|
||||
let file_path = state.data_dir.join("posts").join(format!("{}.md", slug));
|
||||
|
||||
match fs::read_to_string(&file_path) {
|
||||
Ok(raw_content) => {
|
||||
let mut summary = None;
|
||||
let mut content = raw_content.clone();
|
||||
let raw = fs::read_to_string(&file_path)
|
||||
.map_err(|_| AppError::NotFound("Post not found".to_string()))?;
|
||||
let (meta, body) = parse_post(&raw)?;
|
||||
|
||||
if raw_content.starts_with("---\n") {
|
||||
let parts: Vec<&str> = raw_content.splitn(3, "---\n").collect();
|
||||
if parts.len() == 3 {
|
||||
let frontmatter = parts[1];
|
||||
for line in frontmatter.lines() {
|
||||
if line.starts_with("summary: ") {
|
||||
summary = Some(line.trim_start_matches("summary: ").to_string());
|
||||
break;
|
||||
}
|
||||
}
|
||||
content = parts[2].to_string();
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Json(PostDetail {
|
||||
slug,
|
||||
summary,
|
||||
content,
|
||||
}))
|
||||
}
|
||||
Err(_) => Err(AppError::NotFound("Post not found".to_string())),
|
||||
if meta.draft && !admin {
|
||||
return Err(AppError::NotFound("Post not found".to_string()));
|
||||
}
|
||||
|
||||
Ok(Json(PostDetail {
|
||||
slug,
|
||||
date: meta.date,
|
||||
summary: meta.summary,
|
||||
tags: meta.tags,
|
||||
draft: meta.draft,
|
||||
reading_time: reading_time(&body),
|
||||
content: body,
|
||||
}))
|
||||
}
|
||||
|
||||
+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