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,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
|
||||
@@ -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)]
|
||||
|
||||
Reference in New Issue
Block a user