319 lines
10 KiB
Rust
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,
|
|
}))
|
|
}
|