Files
narlblog/backend/src/handlers/posts.rs
T
2026-05-09 09:59:27 +02:00

319 lines
10 KiB
Rust

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<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> {
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<Arc<AppState>>,
headers: HeaderMap,
Path(slug): Path<String>,
) -> Result<StatusCode, AppError> {
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<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<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") {
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<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));
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,
}))
}