224 lines
7.1 KiB
Rust
224 lines
7.1 KiB
Rust
//! HTTP handlers for posts. Orchestration only — parsing, image handling,
|
|
//! and the cache live in [`crate::post`].
|
|
|
|
use axum::{
|
|
Json,
|
|
extract::{Path, State},
|
|
http::{HeaderMap, StatusCode},
|
|
};
|
|
use chrono::Utc;
|
|
use std::sync::Arc;
|
|
use tokio::fs;
|
|
use tracing::{error, info, warn};
|
|
|
|
use crate::post::cache::{neighbors_from_cache, rebuild_posts_cache};
|
|
use crate::post::images::{cover_from, dims_for_urls, extract_images};
|
|
use crate::post::parse::{reading_time, serialize_post, validate_slug};
|
|
use crate::{
|
|
AppState,
|
|
auth::is_authed,
|
|
error::AppError,
|
|
models::{CreatePostRequest, PostDetail, PostInfo, PostMeta},
|
|
};
|
|
|
|
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)
|
|
.await
|
|
.map_err(|e| AppError::Internal("Write error".to_string(), Some(e.to_string())))?;
|
|
if let Err(e) = fs::rename(&tmp_path, &final_path).await {
|
|
let _ = fs::remove_file(&tmp_path).await;
|
|
return Err(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 fs::try_exists(&old_path).await.unwrap_or(false) {
|
|
if fs::try_exists(&file_path).await.unwrap_or(false) {
|
|
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).await.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 images = extract_images(&payload.content);
|
|
if images.is_empty() && state.site_mode == crate::models::SiteMode::Atelier {
|
|
return Err(AppError::BadRequest(
|
|
"A gallery entry must include at least one image ( in the markdown body)."
|
|
.to_string(),
|
|
));
|
|
}
|
|
|
|
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);
|
|
let image_count = images.len() as u32;
|
|
let mut cover = cover_from(&images);
|
|
|
|
rebuild_posts_cache(&state).await;
|
|
let (prev, next) = neighbors_from_cache(&state, &slug, true).await;
|
|
|
|
let image_urls: Vec<String> = images.iter().map(|(_, u)| u.clone()).collect();
|
|
let dimensions = dims_for_urls(&state, &image_urls).await;
|
|
if let Some(c) = cover.as_mut() {
|
|
if let Some(d) = dimensions.get(&c.url) {
|
|
c.w = Some(d.w);
|
|
c.h = Some(d.h);
|
|
}
|
|
}
|
|
|
|
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,
|
|
cover_image: cover,
|
|
image_count,
|
|
prev,
|
|
next,
|
|
dimensions,
|
|
}))
|
|
}
|
|
|
|
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 !fs::try_exists(&file_path).await.unwrap_or(false) {
|
|
warn!("Post not found for deletion: {}", slug);
|
|
return Err(AppError::NotFound("Post not found".to_string()));
|
|
}
|
|
|
|
fs::remove_file(file_path).await.map_err(|e| {
|
|
error!("Delete error for post {}: {}", slug, e);
|
|
AppError::Internal("Delete error".to_string(), Some(e.to_string()))
|
|
})?;
|
|
drop(_guard);
|
|
|
|
info!("Post deleted: {}", slug);
|
|
rebuild_posts_cache(&state).await;
|
|
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 cache = state.posts_cache.read().await;
|
|
let posts: Vec<PostInfo> = cache
|
|
.iter()
|
|
.filter(|p| admin || !p.info.draft)
|
|
.map(|p| p.info.clone())
|
|
.collect();
|
|
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 (info, body) = {
|
|
let cache = state.posts_cache.read().await;
|
|
let Some(p) = cache.iter().find(|p| p.info.slug == slug) else {
|
|
return Err(AppError::NotFound("Post not found".to_string()));
|
|
};
|
|
if p.info.draft && !admin {
|
|
return Err(AppError::NotFound("Post not found".to_string()));
|
|
}
|
|
(p.info.clone(), p.body.clone())
|
|
};
|
|
|
|
let (prev, next) = neighbors_from_cache(&state, &slug, admin).await;
|
|
|
|
let image_urls: Vec<String> = extract_images(&body).into_iter().map(|(_, u)| u).collect();
|
|
let dimensions = dims_for_urls(&state, &image_urls).await;
|
|
|
|
Ok(Json(PostDetail {
|
|
slug: info.slug,
|
|
date: info.date,
|
|
title: info.title,
|
|
summary: info.summary,
|
|
tags: info.tags,
|
|
draft: info.draft,
|
|
reading_time: info.reading_time,
|
|
content: body,
|
|
cover_image: info.cover_image,
|
|
image_count: info.image_count,
|
|
prev,
|
|
next,
|
|
dimensions,
|
|
}))
|
|
}
|