diff --git a/backend/Cargo.lock b/backend/Cargo.lock index fad2a19..cccb471 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "aho-corasick" version = "1.1.4" @@ -11,6 +17,21 @@ dependencies = [ "memchr", ] +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +dependencies = [ + "alloc-no-stdlib", +] + [[package]] name = "android_system_properties" version = "0.1.5" @@ -20,6 +41,18 @@ dependencies = [ "libc", ] +[[package]] +name = "async-compression" +version = "0.4.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0f9ee0f6e02ffd7ad5816e9464499fba7b3effd01123b515c41d1697c43dad1" +dependencies = [ + "compression-codecs", + "compression-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -104,6 +137,7 @@ dependencies = [ "axum", "chrono", "dotenvy", + "imagesize", "infer", "serde", "serde_json", @@ -122,6 +156,27 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +[[package]] +name = "brotli" +version = "8.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + [[package]] name = "bumpalo" version = "3.20.2" @@ -181,12 +236,39 @@ dependencies = [ "windows-link", ] +[[package]] +name = "compression-codecs" +version = "0.4.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb7b51a7d9c967fc26773061ba86150f19c50c0d65c887cb1fbe295fd16619b7" +dependencies = [ + "brotli", + "compression-core", + "flate2", + "memchr", +] + +[[package]] +name = "compression-core" +version = "0.4.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75984efb6ed102a0d42db99afb6c1948f0380d1d91808d5529916e6c08b49d8d" + [[package]] name = "core-foundation-sys" version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "deunicode" version = "1.6.2" @@ -230,6 +312,16 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "fnv" version = "1.0.7" @@ -401,6 +493,12 @@ dependencies = [ "cc", ] +[[package]] +name = "imagesize" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e54e57b4c48b40f7aec75635392b12b3421fa26fe8b4332e63138ed278459c" + [[package]] name = "indexmap" version = "2.14.0" @@ -500,6 +598,16 @@ dependencies = [ "unicase", ] +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.1.1" @@ -759,6 +867,12 @@ dependencies = [ "libc", ] +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + [[package]] name = "slab" version = "0.4.12" @@ -892,6 +1006,7 @@ version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ + "async-compression", "bitflags", "bytes", "futures-core", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 605f650..2098e72 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -8,12 +8,13 @@ axum = { version = "0.8.8", features = ["multipart", "macros"] } chrono = { version = "0.4.44", features = ["serde"] } dotenvy = "0.15.7" infer = "0.19.0" +imagesize = "0.14" serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.149" serde_yaml = "0.9.34" slug = "0.1.6" subtle = "2.6.1" tokio = { version = "1.50.0", features = ["full"] } -tower-http = { version = "0.6.8", features = ["cors", "fs"] } +tower-http = { version = "0.6.8", features = ["cors", "fs", "compression-br", "compression-gzip"] } tracing = "0.1.44" tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } diff --git a/backend/src/handlers/posts.rs b/backend/src/handlers/posts.rs index 786a576..45763ee 100644 --- a/backend/src/handlers/posts.rs +++ b/backend/src/handlers/posts.rs @@ -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 { 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 { + 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 { + { + 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 { + let mut out: HashMap = HashMap::new(); + let mut missing: Vec = 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 = Vec::new(); + let mut posts: Vec = 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 = 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 = 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, 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 = 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, })) } diff --git a/backend/src/handlers/upload.rs b/backend/src/handlers/upload.rs index fb87ead..98c8555 100644 --- a/backend/src/handlers/upload.rs +++ b/backend/src/handlers/upload.rs @@ -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"); diff --git a/backend/src/main.rs b/backend/src/main.rs index 9d3c604..f7030c3 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -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>, + pub posts_cache: RwLock>, + pub image_dims_cache: RwLock>, pub contact_rate_limit: Mutex>>, } @@ -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); diff --git a/backend/src/models.rs b/backend/src/models.rs index c8f478c..60d565b 100644 --- a/backend/src/models.rs +++ b/backend/src/models.rs @@ -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, + #[serde(skip_serializing_if = "Option::is_none")] + pub h: Option, } #[derive(Serialize, Clone)] @@ -129,6 +140,8 @@ pub struct PostDetail { pub prev: Option, #[serde(skip_serializing_if = "Option::is_none")] pub next: Option, + #[serde(skip_serializing_if = "HashMap::is_empty")] + pub dimensions: HashMap, } #[derive(Deserialize)]