Files
narlblog/backend/src/handlers/posts.rs
T
nvrl 37f88f5ad1
CI / frontend (push) Failing after 1s
CI / backend (push) Failing after 1s
fix blog without image
2026-05-21 04:09:02 +02:00

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 (![](url) 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,
}))
}