backend opti
This commit is contained in:
+140
-28
@@ -4,15 +4,17 @@ use axum::{
|
||||
http::{HeaderMap, StatusCode},
|
||||
};
|
||||
use chrono::Utc;
|
||||
use std::sync::Arc;
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
use tokio::fs;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
use crate::{
|
||||
AppState,
|
||||
AppState, CachedPost,
|
||||
auth::is_authed,
|
||||
error::AppError,
|
||||
models::{CoverImage, CreatePostRequest, PostDetail, PostInfo, PostMeta, PostNeighbor},
|
||||
models::{
|
||||
CoverImage, CreatePostRequest, ImageDim, PostDetail, PostInfo, PostMeta, PostNeighbor,
|
||||
},
|
||||
};
|
||||
|
||||
const WORDS_PER_MINUTE: u32 = 200;
|
||||
@@ -165,9 +167,87 @@ fn cover_from(images: &[(String, String)]) -> Option<CoverImage> {
|
||||
images.first().map(|(alt, url)| CoverImage {
|
||||
url: url.clone(),
|
||||
alt: alt.clone(),
|
||||
w: None,
|
||||
h: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Probe an uploads-relative URL for image dimensions. Reads only header
|
||||
/// bytes via `imagesize::size`, off the runtime via `spawn_blocking`.
|
||||
async fn compute_dim_from_url(state: &AppState, url: &str) -> Option<ImageDim> {
|
||||
let name = url.strip_prefix("/uploads/")?;
|
||||
if name.is_empty()
|
||||
|| name.contains("..")
|
||||
|| name.contains('\\')
|
||||
|| name.starts_with('/')
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let path = state.data_dir.join("uploads").join(name);
|
||||
tokio::task::spawn_blocking(move || imagesize::size(&path).ok())
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|s| ImageDim {
|
||||
w: s.width as u32,
|
||||
h: s.height as u32,
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns cached dim if present, else probes the file and caches the result.
|
||||
async fn dim_for_url(state: &AppState, url: &str) -> Option<ImageDim> {
|
||||
{
|
||||
let cache = state.image_dims_cache.read().await;
|
||||
if let Some(d) = cache.get(url) {
|
||||
return Some(*d);
|
||||
}
|
||||
}
|
||||
let d = compute_dim_from_url(state, url).await?;
|
||||
state
|
||||
.image_dims_cache
|
||||
.write()
|
||||
.await
|
||||
.insert(url.to_string(), d);
|
||||
Some(d)
|
||||
}
|
||||
|
||||
/// Returns a map of `url -> ImageDim` for the given URLs, using the cache
|
||||
/// and probing only the URLs that aren't cached yet.
|
||||
async fn dims_for_urls(state: &AppState, urls: &[String]) -> HashMap<String, ImageDim> {
|
||||
let mut out: HashMap<String, ImageDim> = HashMap::new();
|
||||
let mut missing: Vec<String> = Vec::new();
|
||||
{
|
||||
let cache = state.image_dims_cache.read().await;
|
||||
for url in urls {
|
||||
if out.contains_key(url) {
|
||||
continue;
|
||||
}
|
||||
if let Some(d) = cache.get(url) {
|
||||
out.insert(url.clone(), *d);
|
||||
} else {
|
||||
missing.push(url.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
if missing.is_empty() {
|
||||
return out;
|
||||
}
|
||||
let mut newly: Vec<(String, ImageDim)> = Vec::new();
|
||||
for url in &missing {
|
||||
if let Some(d) = compute_dim_from_url(state, url).await {
|
||||
newly.push((url.clone(), d));
|
||||
}
|
||||
}
|
||||
if !newly.is_empty() {
|
||||
let mut cache = state.image_dims_cache.write().await;
|
||||
for (url, d) in &newly {
|
||||
cache.insert(url.clone(), *d);
|
||||
out.insert(url.clone(), *d);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn excerpt_from(meta: &PostMeta, body: &str) -> String {
|
||||
if let Some(s) = meta.summary.as_ref() {
|
||||
if !s.trim().is_empty() {
|
||||
@@ -204,7 +284,7 @@ fn build_post_info(slug: &str, meta: &PostMeta, body: &str) -> PostInfo {
|
||||
/// Called at startup and after any mutation (create/rename/delete).
|
||||
pub async fn rebuild_posts_cache(state: &AppState) {
|
||||
let posts_dir = state.data_dir.join("posts");
|
||||
let mut posts: Vec<PostInfo> = Vec::new();
|
||||
let mut posts: Vec<CachedPost> = Vec::new();
|
||||
|
||||
let mut rd = match fs::read_dir(&posts_dir).await {
|
||||
Ok(rd) => rd,
|
||||
@@ -234,7 +314,14 @@ pub async fn rebuild_posts_cache(state: &AppState) {
|
||||
warn!("Skipping post with bad frontmatter: {}", slug);
|
||||
continue;
|
||||
};
|
||||
posts.push(build_post_info(slug, &meta, &body));
|
||||
let mut info = build_post_info(slug, &meta, &body);
|
||||
if let Some(cover) = info.cover_image.as_mut() {
|
||||
if let Some(d) = dim_for_url(state, &cover.url).await {
|
||||
cover.w = Some(d.w);
|
||||
cover.h = Some(d.h);
|
||||
}
|
||||
}
|
||||
posts.push(CachedPost { info, body });
|
||||
}
|
||||
Ok(None) => break,
|
||||
Err(e) => {
|
||||
@@ -244,7 +331,12 @@ pub async fn rebuild_posts_cache(state: &AppState) {
|
||||
}
|
||||
}
|
||||
|
||||
posts.sort_by(|a, b| b.date.cmp(&a.date).then_with(|| a.slug.cmp(&b.slug)));
|
||||
posts.sort_by(|a, b| {
|
||||
b.info
|
||||
.date
|
||||
.cmp(&a.info.date)
|
||||
.then_with(|| a.info.slug.cmp(&b.info.slug))
|
||||
});
|
||||
*state.posts_cache.write().await = posts;
|
||||
}
|
||||
|
||||
@@ -331,11 +423,20 @@ pub async fn create_post(
|
||||
|
||||
info!("Post saved: {}", slug);
|
||||
let image_count = images.len() as u32;
|
||||
let cover = cover_from(&images);
|
||||
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,
|
||||
@@ -349,6 +450,7 @@ pub async fn create_post(
|
||||
image_count,
|
||||
prev,
|
||||
next,
|
||||
dimensions,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -389,8 +491,8 @@ pub async fn list_posts(
|
||||
let cache = state.posts_cache.read().await;
|
||||
let posts: Vec<PostInfo> = cache
|
||||
.iter()
|
||||
.filter(|p| admin || !p.draft)
|
||||
.cloned()
|
||||
.filter(|p| admin || !p.info.draft)
|
||||
.map(|p| p.info.clone())
|
||||
.collect();
|
||||
Json(posts)
|
||||
}
|
||||
@@ -403,7 +505,8 @@ async fn neighbors_from_cache(
|
||||
let cache = state.posts_cache.read().await;
|
||||
let visible: Vec<&PostInfo> = cache
|
||||
.iter()
|
||||
.filter(|p| admin || !p.draft)
|
||||
.filter(|p| admin || !p.info.draft)
|
||||
.map(|p| &p.info)
|
||||
.collect();
|
||||
let Some(i) = visible.iter().position(|p| p.slug == slug) else {
|
||||
return (None, None);
|
||||
@@ -424,30 +527,39 @@ pub async fn get_post(
|
||||
) -> 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).await
|
||||
.map_err(|_| AppError::NotFound("Post not found".to_string()))?;
|
||||
let (meta, body) = parse_post(&raw)?;
|
||||
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())
|
||||
};
|
||||
|
||||
if meta.draft && !admin {
|
||||
return Err(AppError::NotFound("Post not found".to_string()));
|
||||
}
|
||||
|
||||
let images = extract_images(&body);
|
||||
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,
|
||||
date: meta.date,
|
||||
title: meta.title,
|
||||
summary: meta.summary,
|
||||
tags: meta.tags,
|
||||
draft: meta.draft,
|
||||
reading_time: reading_time(&body),
|
||||
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: cover_from(&images),
|
||||
image_count: images.len() as u32,
|
||||
cover_image: info.cover_image,
|
||||
image_count: info.image_count,
|
||||
prev,
|
||||
next,
|
||||
dimensions,
|
||||
}))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user