backend opti

This commit is contained in:
2026-05-14 18:34:07 +02:00
parent 8f4556b968
commit 2a6a4e6483
6 changed files with 301 additions and 36 deletions
+140 -28
View File
@@ -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,
}))
}
+10 -3
View File
@@ -89,6 +89,11 @@ pub async fn delete_upload(
error!("Delete error for file {}: {}", filename, e);
AppError::Internal("Delete error".to_string(), Some(e.to_string()))
})?;
state
.image_dims_cache
.write()
.await
.remove(&format!("/uploads/{}", filename));
info!("Deleted file: {}", filename);
Ok(StatusCode::NO_CONTENT)
} else {
@@ -227,10 +232,12 @@ pub async fn upload_file(
AppError::Internal("Write error".to_string(), Some(e.to_string()))
})?;
let url = format!("/uploads/{}", final_name_str);
// Invalidate any stale dim cache entry (matters when replacing an existing file).
state.image_dims_cache.write().await.remove(&url);
info!("File uploaded successfully to {:?}", final_path);
return Ok(Json(UploadResponse {
url: format!("/uploads/{}", final_name_str),
}));
return Ok(Json(UploadResponse { url }));
}
warn!("Upload failed: no file found in multipart stream");
+21 -4
View File
@@ -12,20 +12,27 @@ use axum::{
use std::{collections::HashMap, env, path::PathBuf, sync::Arc, time::Duration};
use tokio::sync::{Mutex, RwLock};
use tower_http::{
compression::CompressionLayer,
cors::{AllowOrigin, CorsLayer},
services::ServeDir,
};
use tracing::{error, info, warn};
use crate::handlers::contact::RATE_LIMIT_WINDOW_MS;
use crate::models::PostInfo;
use crate::models::{ImageDim, PostInfo};
pub struct CachedPost {
pub info: PostInfo,
pub body: String,
}
pub struct AppState {
pub admin_token: String,
pub data_dir: PathBuf,
pub cookie_secure: bool,
pub post_lock: Mutex<()>,
pub posts_cache: RwLock<Vec<PostInfo>>,
pub posts_cache: RwLock<Vec<CachedPost>>,
pub image_dims_cache: RwLock<HashMap<String, ImageDim>>,
pub contact_rate_limit: Mutex<HashMap<String, Vec<i64>>>,
}
@@ -67,6 +74,7 @@ async fn main() {
cookie_secure,
post_lock: Mutex::new(()),
posts_cache: RwLock::new(Vec::new()),
image_dims_cache: RwLock::new(HashMap::new()),
contact_rate_limit: Mutex::new(HashMap::new()),
});
@@ -97,6 +105,10 @@ async fn main() {
None => CorsLayer::new(),
};
// JSON routes get a tight 1 MB cap; the upload route keeps 50 MB.
const JSON_BODY_LIMIT: usize = 1024 * 1024;
const UPLOAD_BODY_LIMIT: usize = 50 * 1024 * 1024;
let app = Router::new()
.route("/api/auth/login", post(handlers::auth::login))
.route("/api/auth/logout", post(handlers::auth::logout))
@@ -118,7 +130,11 @@ async fn main() {
"/api/uploads/{filename}",
delete(handlers::upload::delete_upload),
)
.route("/api/upload", post(handlers::upload::upload_file))
.route(
"/api/upload",
post(handlers::upload::upload_file)
.layer(DefaultBodyLimit::max(UPLOAD_BODY_LIMIT)),
)
.route("/api/contact", post(handlers::contact::submit_contact))
.route("/api/messages", get(handlers::contact::list_messages))
.route(
@@ -127,7 +143,8 @@ async fn main() {
)
.route("/healthz", get(|| async { "ok" }))
.nest_service("/uploads", ServeDir::new(uploads_dir))
.layer(DefaultBodyLimit::max(50 * 1024 * 1024))
.layer(DefaultBodyLimit::max(JSON_BODY_LIMIT))
.layer(CompressionLayer::new().br(true).gzip(true))
.layer(cors)
.with_state(state);
+13
View File
@@ -1,5 +1,6 @@
use chrono::NaiveDate;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Serialize, Deserialize, Clone)]
pub struct ContactLink {
@@ -80,10 +81,20 @@ pub struct PostMeta {
pub draft: bool,
}
#[derive(Serialize, Clone, Copy)]
pub struct ImageDim {
pub w: u32,
pub h: u32,
}
#[derive(Serialize, Clone)]
pub struct CoverImage {
pub url: String,
pub alt: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub w: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub h: Option<u32>,
}
#[derive(Serialize, Clone)]
@@ -129,6 +140,8 @@ pub struct PostDetail {
pub prev: Option<PostNeighbor>,
#[serde(skip_serializing_if = "Option::is_none")]
pub next: Option<PostNeighbor>,
#[serde(skip_serializing_if = "HashMap::is_empty")]
pub dimensions: HashMap<String, ImageDim>,
}
#[derive(Deserialize)]