use axum::{ Json, extract::{Path, State}, http::{HeaderMap, StatusCode}, }; use chrono::Utc; use std::{fs, sync::Arc}; use tracing::{error, info, warn}; use crate::{ AppState, auth::is_authed, error::AppError, models::{CreatePostRequest, PostDetail, PostInfo, PostMeta}, }; const WORDS_PER_MINUTE: u32 = 200; const MAX_SLUG_LEN: usize = 100; const WINDOWS_RESERVED: &[&str] = &[ "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9", ]; fn validate_slug(s: &str) -> Result<(), AppError> { if s.is_empty() { return Err(AppError::BadRequest("Slug is empty".to_string())); } if s.len() > MAX_SLUG_LEN { return Err(AppError::BadRequest(format!( "Slug exceeds {} characters", MAX_SLUG_LEN ))); } if s.starts_with('.') { return Err(AppError::BadRequest( "Slug cannot start with '.'".to_string(), )); } if s.ends_with('.') || s.ends_with(' ') { return Err(AppError::BadRequest( "Slug cannot end with '.' or space".to_string(), )); } if s.contains("..") { return Err(AppError::BadRequest( "Slug cannot contain '..'".to_string(), )); } for c in s.chars() { if c.is_control() { return Err(AppError::BadRequest( "Slug contains control characters".to_string(), )); } if matches!(c, '/' | '\\' | '<' | '>' | ':' | '"' | '|' | '?' | '*') { return Err(AppError::BadRequest(format!( "Slug contains invalid character '{}'", c ))); } } let stem = s.split('.').next().unwrap_or("").to_ascii_uppercase(); if WINDOWS_RESERVED.iter().any(|r| *r == stem) { return Err(AppError::BadRequest( "Slug is a reserved name".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 { 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>, headers: HeaderMap, Json(payload): Json, ) -> Result, AppError> { if !is_authed(&headers, &state.admin_token) { return Err(AppError::Unauthorized); } let slug = slug::slugify(&payload.slug); if slug.is_empty() { return Err(AppError::BadRequest( "Slug is empty after normalization (try ASCII letters/numbers)".to_string(), )); } validate_slug(&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", slug)); if let Some(ref old_slug) = payload.old_slug { if old_slug != &slug { 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(), )); } let _guard = state.post_lock.lock().await; fs::rename(&old_path, &file_path).map_err(|e| { error!("Rename error from {} to {}: {}", old_slug, slug, e); AppError::Internal("Rename error".to_string(), Some(e.to_string())) })?; drop(_guard); info!("Renamed post from {} to {}", old_slug, slug); } } } let meta = PostMeta { date: payload.date.unwrap_or_else(|| Utc::now().date_naive()), title: payload.title.map(|t| t.trim().to_string()).filter(|t| !t.is_empty()), 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, &slug, &contents).await?; info!("Post saved: {}", slug); Ok(Json(PostDetail { slug, date: meta.date, title: meta.title, summary: meta.summary, tags: meta.tags, draft: meta.draft, reading_time: reading_time(&payload.content), content: payload.content, })) } pub async fn delete_post( State(state): State>, headers: HeaderMap, Path(slug): Path, ) -> Result { 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)); if !file_path.exists() { warn!("Post not found for deletion: {}", slug); return Err(AppError::NotFound("Post not found".to_string())); } fs::remove_file(file_path).map_err(|e| { error!("Delete error for post {}: {}", slug, e); AppError::Internal("Delete error".to_string(), Some(e.to_string())) })?; info!("Post deleted: {}", slug); Ok(StatusCode::NO_CONTENT) } pub async fn list_posts( State(state): State>, headers: HeaderMap, ) -> Json> { let admin = is_authed(&headers, &state.admin_token); let posts_dir = state.data_dir.join("posts"); let mut posts: Vec = 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") { 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, title: meta.title.clone(), 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>, headers: HeaderMap, Path(slug): Path, ) -> Result, AppError> { validate_slug(&slug)?; let admin = is_authed(&headers, &state.admin_token); let file_path = state.data_dir.join("posts").join(format!("{}.md", slug)); let raw = fs::read_to_string(&file_path) .map_err(|_| AppError::NotFound("Post not found".to_string()))?; let (meta, body) = parse_post(&raw)?; if meta.draft && !admin { return Err(AppError::NotFound("Post not found".to_string())); } Ok(Json(PostDetail { slug, date: meta.date, title: meta.title, summary: meta.summary, tags: meta.tags, draft: meta.draft, reading_time: reading_time(&body), content: body, })) }