performance improvements

This commit is contained in:
2026-05-14 17:52:13 +02:00
parent 6bc51d6d14
commit 046f60dcb6
9 changed files with 299 additions and 134 deletions
+5 -2
View File
@@ -1,5 +1,6 @@
use axum::{Json, extract::State, http::HeaderMap, response::IntoResponse}; use axum::{Json, extract::State, http::HeaderMap, response::IntoResponse};
use std::{fs, sync::Arc}; use std::sync::Arc;
use tokio::fs;
use tracing::error; use tracing::error;
use crate::{ use crate::{
@@ -12,6 +13,7 @@ use crate::{
pub async fn get_config(State(state): State<Arc<AppState>>) -> impl IntoResponse { pub async fn get_config(State(state): State<Arc<AppState>>) -> impl IntoResponse {
let config_path = state.data_dir.join("config.json"); let config_path = state.data_dir.join("config.json");
let config = fs::read_to_string(&config_path) let config = fs::read_to_string(&config_path)
.await
.ok() .ok()
.and_then(|c| serde_json::from_str::<SiteConfig>(&c).ok()) .and_then(|c| serde_json::from_str::<SiteConfig>(&c).ok())
.unwrap_or_default(); .unwrap_or_default();
@@ -28,6 +30,7 @@ pub async fn update_config(
let config_path = state.data_dir.join("config.json"); let config_path = state.data_dir.join("config.json");
let mut config: SiteConfig = fs::read_to_string(&config_path) let mut config: SiteConfig = fs::read_to_string(&config_path)
.await
.ok() .ok()
.and_then(|c| serde_json::from_str(&c).ok()) .and_then(|c| serde_json::from_str(&c).ok())
.unwrap_or_default(); .unwrap_or_default();
@@ -48,7 +51,7 @@ pub async fn update_config(
AppError::Internal("Serialization error".to_string(), Some(e.to_string())) AppError::Internal("Serialization error".to_string(), Some(e.to_string()))
})?; })?;
fs::write(&config_path, config_str).map_err(|e| { fs::write(&config_path, config_str).await.map_err(|e| {
error!("Write error for config: {}", e); error!("Write error for config: {}", e);
AppError::Internal("Write error".to_string(), Some(e.to_string())) AppError::Internal("Write error".to_string(), Some(e.to_string()))
})?; })?;
+31 -22
View File
@@ -7,10 +7,10 @@ use axum::{
use chrono::Utc; use chrono::Utc;
use std::{ use std::{
collections::hash_map::DefaultHasher, collections::hash_map::DefaultHasher,
fs,
hash::{Hash, Hasher}, hash::{Hash, Hasher},
sync::Arc, sync::Arc,
}; };
use tokio::fs;
use tracing::{error, info, warn}; use tracing::{error, info, warn};
use crate::{ use crate::{
@@ -22,7 +22,7 @@ use crate::{
const MIN_FILL_TIME_MS: i64 = 3_000; const MIN_FILL_TIME_MS: i64 = 3_000;
const MAX_FORM_AGE_MS: i64 = 24 * 60 * 60 * 1000; const MAX_FORM_AGE_MS: i64 = 24 * 60 * 60 * 1000;
const RATE_LIMIT_WINDOW_MS: i64 = 60 * 60 * 1000; pub const RATE_LIMIT_WINDOW_MS: i64 = 60 * 60 * 1000;
const RATE_LIMIT_MAX: usize = 5; const RATE_LIMIT_MAX: usize = 5;
const MAX_NAME: usize = 200; const MAX_NAME: usize = 200;
const MAX_EMAIL: usize = 200; const MAX_EMAIL: usize = 200;
@@ -155,7 +155,7 @@ pub async fn submit_contact(
}; };
let messages_dir = state.data_dir.join("messages"); let messages_dir = state.data_dir.join("messages");
fs::create_dir_all(&messages_dir).map_err(|e| { fs::create_dir_all(&messages_dir).await.map_err(|e| {
error!("Failed to create messages dir: {}", e); error!("Failed to create messages dir: {}", e);
AppError::Internal("Storage error".into(), Some(e.to_string())) AppError::Internal("Storage error".into(), Some(e.to_string()))
})?; })?;
@@ -163,7 +163,7 @@ pub async fn submit_contact(
let json = serde_json::to_string_pretty(&msg).map_err(|e| { let json = serde_json::to_string_pretty(&msg).map_err(|e| {
AppError::Internal("Serialization error".into(), Some(e.to_string())) AppError::Internal("Serialization error".into(), Some(e.to_string()))
})?; })?;
fs::write(&path, json).map_err(|e| { fs::write(&path, json).await.map_err(|e| {
error!("Failed to write message {}: {}", id, e); error!("Failed to write message {}: {}", id, e);
AppError::Internal("Storage error".into(), Some(e.to_string())) AppError::Internal("Storage error".into(), Some(e.to_string()))
})?; })?;
@@ -181,11 +181,11 @@ pub async fn list_messages(
} }
let messages_dir = state.data_dir.join("messages"); let messages_dir = state.data_dir.join("messages");
if !messages_dir.exists() { if !fs::try_exists(&messages_dir).await.unwrap_or(false) {
return Json(Vec::<Message>::new()).into_response(); return Json(Vec::<Message>::new()).into_response();
} }
let entries = match fs::read_dir(&messages_dir) { let mut rd = match fs::read_dir(&messages_dir).await {
Ok(e) => e, Ok(e) => e,
Err(e) => { Err(e) => {
error!("Failed to read messages dir: {}", e); error!("Failed to read messages dir: {}", e);
@@ -195,21 +195,30 @@ pub async fn list_messages(
}; };
let mut messages: Vec<Message> = Vec::new(); let mut messages: Vec<Message> = Vec::new();
for entry in entries.flatten() { loop {
let path = entry.path(); match rd.next_entry().await {
if path.extension().and_then(|e| e.to_str()) != Some("json") { Ok(Some(entry)) => {
continue; let path = entry.path();
} if path.extension().and_then(|e| e.to_str()) != Some("json") {
let content = match fs::read_to_string(&path) { continue;
Ok(c) => c, }
Err(e) => { let content = match fs::read_to_string(&path).await {
error!("Failed to read {:?}: {}", path, e); Ok(c) => c,
continue; Err(e) => {
error!("Failed to read {:?}: {}", path, e);
continue;
}
};
match serde_json::from_str::<Message>(&content) {
Ok(m) => messages.push(m),
Err(e) => error!("Malformed message {:?}: {}", path, e),
}
}
Ok(None) => break,
Err(e) => {
error!("Error iterating messages dir: {}", e);
break;
} }
};
match serde_json::from_str::<Message>(&content) {
Ok(m) => messages.push(m),
Err(e) => error!("Malformed message {:?}: {}", path, e),
} }
} }
messages.sort_by(|a, b| b.received_at.cmp(&a.received_at)); messages.sort_by(|a, b| b.received_at.cmp(&a.received_at));
@@ -227,10 +236,10 @@ pub async fn delete_message(
return Err(AppError::BadRequest("Invalid id.".into())); return Err(AppError::BadRequest("Invalid id.".into()));
} }
let path = state.data_dir.join("messages").join(format!("{}.json", id)); let path = state.data_dir.join("messages").join(format!("{}.json", id));
if !path.exists() { if !fs::try_exists(&path).await.unwrap_or(false) {
return Err(AppError::NotFound("Message not found.".into())); return Err(AppError::NotFound("Message not found.".into()));
} }
fs::remove_file(&path).map_err(|e| { fs::remove_file(&path).await.map_err(|e| {
AppError::Internal("Delete failed".into(), Some(e.to_string())) AppError::Internal("Delete failed".into(), Some(e.to_string()))
})?; })?;
Ok(Json(ContactResponse { ok: true })) Ok(Json(ContactResponse { ok: true }))
+120 -55
View File
@@ -4,14 +4,15 @@ use axum::{
http::{HeaderMap, StatusCode}, http::{HeaderMap, StatusCode},
}; };
use chrono::Utc; use chrono::Utc;
use std::{fs, sync::Arc}; use std::sync::Arc;
use tokio::fs;
use tracing::{error, info, warn}; use tracing::{error, info, warn};
use crate::{ use crate::{
AppState, AppState,
auth::is_authed, auth::is_authed,
error::AppError, error::AppError,
models::{CoverImage, CreatePostRequest, PostDetail, PostInfo, PostMeta}, models::{CoverImage, CreatePostRequest, PostDetail, PostInfo, PostMeta, PostNeighbor},
}; };
const WORDS_PER_MINUTE: u32 = 200; const WORDS_PER_MINUTE: u32 = 200;
@@ -183,6 +184,70 @@ fn excerpt_from(meta: &PostMeta, body: &str) -> String {
out.trim().to_string() out.trim().to_string()
} }
fn build_post_info(slug: &str, meta: &PostMeta, body: &str) -> PostInfo {
let images = extract_images(body);
PostInfo {
slug: slug.to_string(),
date: meta.date,
title: meta.title.clone(),
summary: meta.summary.clone(),
tags: meta.tags.clone(),
draft: meta.draft,
reading_time: reading_time(body),
excerpt: excerpt_from(meta, body),
cover_image: cover_from(&images),
image_count: images.len() as u32,
}
}
/// Scans the posts directory and replaces the in-memory cache.
/// 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 rd = match fs::read_dir(&posts_dir).await {
Ok(rd) => rd,
Err(_) => {
*state.posts_cache.write().await = posts;
return;
}
};
loop {
match rd.next_entry().await {
Ok(Some(entry)) => {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("md") {
continue;
}
let Some(slug) = path.file_stem().and_then(|s| s.to_str()) else {
continue;
};
if slug.starts_with('.') {
continue;
}
let Ok(raw) = fs::read_to_string(&path).await else {
continue;
};
let Ok((meta, body)) = parse_post(&raw) else {
warn!("Skipping post with bad frontmatter: {}", slug);
continue;
};
posts.push(build_post_info(slug, &meta, &body));
}
Ok(None) => break,
Err(e) => {
warn!("Error iterating posts dir: {}", e);
break;
}
}
}
posts.sort_by(|a, b| b.date.cmp(&a.date).then_with(|| a.slug.cmp(&b.slug)));
*state.posts_cache.write().await = posts;
}
async fn write_post_atomic( async fn write_post_atomic(
state: &AppState, state: &AppState,
slug: &str, slug: &str,
@@ -191,13 +256,16 @@ async fn write_post_atomic(
let _guard = state.post_lock.lock().await; let _guard = state.post_lock.lock().await;
let final_path = state.data_dir.join("posts").join(format!("{}.md", slug)); 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)); let tmp_path = state.data_dir.join("posts").join(format!(".{}.md.tmp", slug));
fs::write(&tmp_path, contents).map_err(|e| { fs::write(&tmp_path, contents).await.map_err(|e| {
AppError::Internal("Write error".to_string(), Some(e.to_string())) AppError::Internal("Write error".to_string(), Some(e.to_string()))
})?; })?;
fs::rename(&tmp_path, &final_path).map_err(|e| { if let Err(e) = fs::rename(&tmp_path, &final_path).await {
let _ = fs::remove_file(&tmp_path); let _ = fs::remove_file(&tmp_path).await;
AppError::Internal("Rename error".to_string(), Some(e.to_string())) return Err(AppError::Internal(
})?; "Rename error".to_string(),
Some(e.to_string()),
));
}
Ok(()) Ok(())
} }
@@ -227,14 +295,14 @@ pub async fn create_post(
if let Some(ref old_slug) = payload.old_slug { if let Some(ref old_slug) = payload.old_slug {
if old_slug != &slug { if old_slug != &slug {
let old_path = posts_dir.join(format!("{}.md", old_slug)); let old_path = posts_dir.join(format!("{}.md", old_slug));
if old_path.exists() { if fs::try_exists(&old_path).await.unwrap_or(false) {
if file_path.exists() { if fs::try_exists(&file_path).await.unwrap_or(false) {
return Err(AppError::BadRequest( return Err(AppError::BadRequest(
"A post with this new title already exists".to_string(), "A post with this new title already exists".to_string(),
)); ));
} }
let _guard = state.post_lock.lock().await; let _guard = state.post_lock.lock().await;
fs::rename(&old_path, &file_path).map_err(|e| { fs::rename(&old_path, &file_path).await.map_err(|e| {
error!("Rename error from {} to {}: {}", old_slug, slug, e); error!("Rename error from {} to {}: {}", old_slug, slug, e);
AppError::Internal("Rename error".to_string(), Some(e.to_string())) AppError::Internal("Rename error".to_string(), Some(e.to_string()))
})?; })?;
@@ -264,6 +332,10 @@ pub async fn create_post(
info!("Post saved: {}", slug); info!("Post saved: {}", slug);
let image_count = images.len() as u32; let image_count = images.len() as u32;
let cover = cover_from(&images); let cover = cover_from(&images);
rebuild_posts_cache(&state).await;
let (prev, next) = neighbors_from_cache(&state, &slug, true).await;
Ok(Json(PostDetail { Ok(Json(PostDetail {
slug, slug,
date: meta.date, date: meta.date,
@@ -275,6 +347,8 @@ pub async fn create_post(
content: payload.content, content: payload.content,
cover_image: cover, cover_image: cover,
image_count, image_count,
prev,
next,
})) }))
} }
@@ -291,17 +365,19 @@ pub async fn delete_post(
let _guard = state.post_lock.lock().await; let _guard = state.post_lock.lock().await;
let file_path = state.data_dir.join("posts").join(format!("{}.md", slug)); let file_path = state.data_dir.join("posts").join(format!("{}.md", slug));
if !file_path.exists() { if !fs::try_exists(&file_path).await.unwrap_or(false) {
warn!("Post not found for deletion: {}", slug); warn!("Post not found for deletion: {}", slug);
return Err(AppError::NotFound("Post not found".to_string())); return Err(AppError::NotFound("Post not found".to_string()));
} }
fs::remove_file(file_path).map_err(|e| { fs::remove_file(file_path).await.map_err(|e| {
error!("Delete error for post {}: {}", slug, e); error!("Delete error for post {}: {}", slug, e);
AppError::Internal("Delete error".to_string(), Some(e.to_string())) AppError::Internal("Delete error".to_string(), Some(e.to_string()))
})?; })?;
drop(_guard);
info!("Post deleted: {}", slug); info!("Post deleted: {}", slug);
rebuild_posts_cache(&state).await;
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }
@@ -310,51 +386,37 @@ pub async fn list_posts(
headers: HeaderMap, headers: HeaderMap,
) -> Json<Vec<PostInfo>> { ) -> Json<Vec<PostInfo>> {
let admin = is_authed(&headers, &state.admin_token); let admin = is_authed(&headers, &state.admin_token);
let posts_dir = state.data_dir.join("posts"); let cache = state.posts_cache.read().await;
let mut posts: Vec<PostInfo> = Vec::new(); let posts: Vec<PostInfo> = cache
.iter()
if let Ok(entries) = fs::read_dir(posts_dir) { .filter(|p| admin || !p.draft)
for entry in entries.flatten() { .cloned()
let path = entry.path(); .collect();
if path.extension().and_then(|e| e.to_str()) != Some("md") {
continue;
}
let Some(slug) = path.file_stem().and_then(|s| s.to_str()) else {
continue;
};
if slug.starts_with('.') {
continue;
}
let Ok(raw) = fs::read_to_string(&path) else {
continue;
};
let Ok((meta, body)) = parse_post(&raw) else {
warn!("Skipping post with bad frontmatter: {}", slug);
continue;
};
if meta.draft && !admin {
continue;
}
let images = extract_images(&body);
posts.push(PostInfo {
slug: slug.to_string(),
date: meta.date,
title: meta.title.clone(),
summary: meta.summary.clone(),
tags: meta.tags.clone(),
draft: meta.draft,
reading_time: reading_time(&body),
excerpt: excerpt_from(&meta, &body),
cover_image: cover_from(&images),
image_count: images.len() as u32,
});
}
}
posts.sort_by(|a, b| b.date.cmp(&a.date).then_with(|| a.slug.cmp(&b.slug)));
Json(posts) Json(posts)
} }
async fn neighbors_from_cache(
state: &AppState,
slug: &str,
admin: bool,
) -> (Option<PostNeighbor>, Option<PostNeighbor>) {
let cache = state.posts_cache.read().await;
let visible: Vec<&PostInfo> = cache
.iter()
.filter(|p| admin || !p.draft)
.collect();
let Some(i) = visible.iter().position(|p| p.slug == slug) else {
return (None, None);
};
let to_neighbor = |p: &PostInfo| PostNeighbor {
slug: p.slug.clone(),
title: p.title.clone(),
};
let prev = if i > 0 { Some(to_neighbor(visible[i - 1])) } else { None };
let next = visible.get(i + 1).map(|p| to_neighbor(p));
(prev, next)
}
pub async fn get_post( pub async fn get_post(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
headers: HeaderMap, headers: HeaderMap,
@@ -364,7 +426,7 @@ pub async fn get_post(
let admin = is_authed(&headers, &state.admin_token); let admin = is_authed(&headers, &state.admin_token);
let file_path = state.data_dir.join("posts").join(format!("{}.md", slug)); let file_path = state.data_dir.join("posts").join(format!("{}.md", slug));
let raw = fs::read_to_string(&file_path) let raw = fs::read_to_string(&file_path).await
.map_err(|_| AppError::NotFound("Post not found".to_string()))?; .map_err(|_| AppError::NotFound("Post not found".to_string()))?;
let (meta, body) = parse_post(&raw)?; let (meta, body) = parse_post(&raw)?;
@@ -373,6 +435,7 @@ pub async fn get_post(
} }
let images = extract_images(&body); let images = extract_images(&body);
let (prev, next) = neighbors_from_cache(&state, &slug, admin).await;
Ok(Json(PostDetail { Ok(Json(PostDetail {
slug, slug,
date: meta.date, date: meta.date,
@@ -384,5 +447,7 @@ pub async fn get_post(
content: body, content: body,
cover_image: cover_from(&images), cover_image: cover_from(&images),
image_count: images.len() as u32, image_count: images.len() as u32,
prev,
next,
})) }))
} }
+35 -17
View File
@@ -4,7 +4,8 @@ use axum::{
http::{HeaderMap, StatusCode}, http::{HeaderMap, StatusCode},
}; };
use serde::Deserialize; use serde::Deserialize;
use std::{fs, sync::Arc}; use std::sync::Arc;
use tokio::fs;
use tracing::{error, info, warn}; use tracing::{error, info, warn};
use crate::{ use crate::{
@@ -76,15 +77,15 @@ pub async fn delete_upload(
let uploads_dir = state.data_dir.join("uploads"); let uploads_dir = state.data_dir.join("uploads");
let file_path = uploads_dir.join(&filename); let file_path = uploads_dir.join(&filename);
let canonical_dir = uploads_dir.canonicalize().map_err(|e| { let canonical_dir = fs::canonicalize(&uploads_dir).await.map_err(|e| {
AppError::Internal("Path resolution".to_string(), Some(e.to_string())) AppError::Internal("Path resolution".to_string(), Some(e.to_string()))
})?; })?;
if let Ok(canonical_file) = file_path.canonicalize() { if let Ok(canonical_file) = fs::canonicalize(&file_path).await {
if !canonical_file.starts_with(&canonical_dir) { if !canonical_file.starts_with(&canonical_dir) {
warn!("Refused delete outside uploads dir: {}", filename); warn!("Refused delete outside uploads dir: {}", filename);
return Err(AppError::BadRequest("Invalid filename".to_string())); return Err(AppError::BadRequest("Invalid filename".to_string()));
} }
fs::remove_file(canonical_file).map_err(|e| { fs::remove_file(canonical_file).await.map_err(|e| {
error!("Delete error for file {}: {}", filename, e); error!("Delete error for file {}: {}", filename, e);
AppError::Internal("Delete error".to_string(), Some(e.to_string())) AppError::Internal("Delete error".to_string(), Some(e.to_string()))
})?; })?;
@@ -104,15 +105,30 @@ pub async fn list_uploads(
let uploads_dir = state.data_dir.join("uploads"); let uploads_dir = state.data_dir.join("uploads");
let mut files = Vec::new(); let mut files = Vec::new();
if let Ok(entries) = fs::read_dir(uploads_dir) { if let Ok(mut rd) = fs::read_dir(&uploads_dir).await {
for entry in entries.flatten() { loop {
let path = entry.path(); match rd.next_entry().await {
if path.is_file() { Ok(Some(entry)) => {
if let Some(name) = path.file_name().and_then(|n| n.to_str()) { let path = entry.path();
files.push(FileInfo { let is_file = entry
name: name.to_string(), .file_type()
url: format!("/uploads/{}", name), .await
}); .map(|t| t.is_file())
.unwrap_or(false);
if !is_file {
continue;
}
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
files.push(FileInfo {
name: name.to_string(),
url: format!("/uploads/{}", name),
});
}
}
Ok(None) => break,
Err(e) => {
warn!("Error iterating uploads dir: {}", e);
break;
} }
} }
} }
@@ -178,7 +194,9 @@ pub async fn upload_file(
let uploads_dir = state.data_dir.join("uploads"); let uploads_dir = state.data_dir.join("uploads");
let target_path = uploads_dir.join(&final_name); let target_path = uploads_dir.join(&final_name);
let final_path = if target_path.exists() && !query.replace.unwrap_or(false) { let final_path = if fs::try_exists(&target_path).await.unwrap_or(false)
&& !query.replace.unwrap_or(false)
{
let timestamp = chrono::Utc::now().timestamp(); let timestamp = chrono::Utc::now().timestamp();
uploads_dir.join(format!("{}_{}", timestamp, final_name)) uploads_dir.join(format!("{}_{}", timestamp, final_name))
} else { } else {
@@ -186,11 +204,11 @@ pub async fn upload_file(
}; };
// Final containment check. // Final containment check.
let canonical_dir = uploads_dir.canonicalize().map_err(|e| { let canonical_dir = fs::canonicalize(&uploads_dir).await.map_err(|e| {
AppError::Internal("Path resolution".to_string(), Some(e.to_string())) AppError::Internal("Path resolution".to_string(), Some(e.to_string()))
})?; })?;
if let Some(parent) = final_path.parent() { if let Some(parent) = final_path.parent() {
let canonical_parent = parent.canonicalize().map_err(|e| { let canonical_parent = fs::canonicalize(parent).await.map_err(|e| {
AppError::Internal("Path resolution".to_string(), Some(e.to_string())) AppError::Internal("Path resolution".to_string(), Some(e.to_string()))
})?; })?;
if canonical_parent != canonical_dir { if canonical_parent != canonical_dir {
@@ -204,7 +222,7 @@ pub async fn upload_file(
.unwrap_or(&final_name) .unwrap_or(&final_name)
.to_string(); .to_string();
fs::write(&final_path, &data).map_err(|e| { fs::write(&final_path, &data).await.map_err(|e| {
error!("Failed to write file to {:?}: {}", final_path, e); error!("Failed to write file to {:?}: {}", final_path, e);
AppError::Internal("Write error".to_string(), Some(e.to_string())) AppError::Internal("Write error".to_string(), Some(e.to_string()))
})?; })?;
+32 -4
View File
@@ -9,19 +9,23 @@ use axum::{
http::{HeaderValue, header}, http::{HeaderValue, header},
routing::{delete, get, post}, routing::{delete, get, post},
}; };
use std::{collections::HashMap, env, fs, path::PathBuf, sync::Arc}; use std::{collections::HashMap, env, path::PathBuf, sync::Arc, time::Duration};
use tokio::sync::Mutex; use tokio::sync::{Mutex, RwLock};
use tower_http::{ use tower_http::{
cors::{AllowOrigin, CorsLayer}, cors::{AllowOrigin, CorsLayer},
services::ServeDir, services::ServeDir,
}; };
use tracing::{error, info, warn}; use tracing::{error, info, warn};
use crate::handlers::contact::RATE_LIMIT_WINDOW_MS;
use crate::models::PostInfo;
pub struct AppState { pub struct AppState {
pub admin_token: String, pub admin_token: String,
pub data_dir: PathBuf, pub data_dir: PathBuf,
pub cookie_secure: bool, pub cookie_secure: bool,
pub post_lock: Mutex<()>, pub post_lock: Mutex<()>,
pub posts_cache: RwLock<Vec<PostInfo>>,
pub contact_rate_limit: Mutex<HashMap<String, Vec<i64>>>, pub contact_rate_limit: Mutex<HashMap<String, Vec<i64>>>,
} }
@@ -50,10 +54,10 @@ async fn main() {
let posts_dir = data_dir.join("posts"); let posts_dir = data_dir.join("posts");
let uploads_dir = data_dir.join("uploads"); let uploads_dir = data_dir.join("uploads");
if let Err(e) = fs::create_dir_all(&posts_dir) { if let Err(e) = tokio::fs::create_dir_all(&posts_dir).await {
error!("Failed to create posts directory: {}", e); error!("Failed to create posts directory: {}", e);
} }
if let Err(e) = fs::create_dir_all(&uploads_dir) { if let Err(e) = tokio::fs::create_dir_all(&uploads_dir).await {
error!("Failed to create uploads directory: {}", e); error!("Failed to create uploads directory: {}", e);
} }
@@ -62,9 +66,15 @@ async fn main() {
data_dir, data_dir,
cookie_secure, cookie_secure,
post_lock: Mutex::new(()), post_lock: Mutex::new(()),
posts_cache: RwLock::new(Vec::new()),
contact_rate_limit: Mutex::new(HashMap::new()), contact_rate_limit: Mutex::new(HashMap::new()),
}); });
handlers::posts::rebuild_posts_cache(&state).await;
info!("Posts cache primed with {} entries", state.posts_cache.read().await.len());
spawn_rate_limit_reaper(state.clone());
// CORS — locked down by default. Set FRONTEND_ORIGIN to the public URL of // CORS — locked down by default. Set FRONTEND_ORIGIN to the public URL of
// the frontend if you ever expose the backend directly to browsers. // the frontend if you ever expose the backend directly to browsers.
// Normal deployments hit the backend through the Astro proxy, which is // Normal deployments hit the backend through the Astro proxy, which is
@@ -126,3 +136,21 @@ async fn main() {
let listener = tokio::net::TcpListener::bind(&addr).await.unwrap(); let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
axum::serve(listener, app).await.unwrap(); axum::serve(listener, app).await.unwrap();
} }
/// Periodically prunes expired entries from the contact rate-limit map so it
/// can't grow unbounded across the lifetime of the process.
fn spawn_rate_limit_reaper(state: Arc<AppState>) {
tokio::spawn(async move {
let mut ticker = tokio::time::interval(Duration::from_secs(300));
ticker.tick().await;
loop {
ticker.tick().await;
let now_ms = chrono::Utc::now().timestamp_millis();
let mut map = state.contact_rate_limit.lock().await;
map.retain(|_, times| {
times.retain(|t| now_ms - *t < RATE_LIMIT_WINDOW_MS);
!times.is_empty()
});
}
});
}
+12 -1
View File
@@ -86,7 +86,7 @@ pub struct CoverImage {
pub alt: String, pub alt: String,
} }
#[derive(Serialize)] #[derive(Serialize, Clone)]
pub struct PostInfo { pub struct PostInfo {
pub slug: String, pub slug: String,
pub date: NaiveDate, pub date: NaiveDate,
@@ -103,6 +103,13 @@ pub struct PostInfo {
pub image_count: u32, pub image_count: u32,
} }
#[derive(Serialize, Clone)]
pub struct PostNeighbor {
pub slug: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
}
#[derive(Serialize)] #[derive(Serialize)]
pub struct PostDetail { pub struct PostDetail {
pub slug: String, pub slug: String,
@@ -118,6 +125,10 @@ pub struct PostDetail {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub cover_image: Option<CoverImage>, pub cover_image: Option<CoverImage>,
pub image_count: u32, pub image_count: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub prev: Option<PostNeighbor>,
#[serde(skip_serializing_if = "Option::is_none")]
pub next: Option<PostNeighbor>,
} }
#[derive(Deserialize)] #[derive(Deserialize)]
+40 -8
View File
@@ -100,6 +100,14 @@ export default function Editor({ editSlug }: Props) {
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
const [uploadingCount, setUploadingCount] = useState(0); const [uploadingCount, setUploadingCount] = useState(0);
const dragDepthRef = useRef(0); const dragDepthRef = useRef(0);
const assetsCacheRef = useRef<Asset[] | null>(null);
async function getCachedAssets(): Promise<Asset[]> {
if (assetsCacheRef.current) return assetsCacheRef.current;
const assets = await getAssets();
assetsCacheRef.current = assets;
return assets;
}
function showAlertMsg(msg: string, type: 'success' | 'error') { function showAlertMsg(msg: string, type: 'success' | 'error') {
setAlert({ msg, type }); setAlert({ msg, type });
@@ -245,7 +253,7 @@ export default function Editor({ editSlug }: Props) {
async function triggerAutocomplete(view: EditorView) { async function triggerAutocomplete(view: EditorView) {
try { try {
const assets = await getAssets(); const assets = await getCachedAssets();
setAutocompleteAssets(assets.slice(0, 8)); setAutocompleteAssets(assets.slice(0, 8));
const pos = view.state.selection.main.head; const pos = view.state.selection.main.head;
const coords = view.coordsAtPos(pos); const coords = view.coordsAtPos(pos);
@@ -292,22 +300,41 @@ export default function Editor({ editSlug }: Props) {
return; return;
} }
setUploadingCount(c => c + images.length); setUploadingCount(c => c + images.length);
// Fire all uploads in parallel; the browser caps per-origin concurrency.
// Insert results in submission order so the markdown reflects user intent.
const uploads = images.map(file =>
uploadAsset(file).then(
asset => ({ ok: true as const, asset }),
err => ({ ok: false as const, err }),
),
);
let pos = typeof insertAt === 'number' ? insertAt : view.state.selection.main.head; let pos = typeof insertAt === 'number' ? insertAt : view.state.selection.main.head;
for (const file of images) { const newAssets: Asset[] = [];
try { for (const promise of uploads) {
const asset = await uploadAsset(file); const result = await promise;
setUploadingCount(c => Math.max(0, c - 1));
if (result.ok) {
const { asset } = result;
newAssets.push(asset);
const md = `![${asset.name}](${asset.url})`; const md = `![${asset.name}](${asset.url})`;
const line = view.state.doc.lineAt(pos); const line = view.state.doc.lineAt(pos);
const atLineEnd = pos === line.to; const atLineEnd = pos === line.to;
const insertText = atLineEnd ? `\n\n${md}\n` : `${md}\n\n`; const insertText = atLineEnd ? `\n\n${md}\n` : `${md}\n\n`;
view.dispatch({ changes: { from: pos, insert: insertText } }); view.dispatch({ changes: { from: pos, insert: insertText } });
pos += insertText.length; pos += insertText.length;
} catch (e) { } else {
const e = result.err;
showAlertMsg(e instanceof ApiError ? `Upload failed: ${e.message}` : 'Upload failed.', 'error'); showAlertMsg(e instanceof ApiError ? `Upload failed: ${e.message}` : 'Upload failed.', 'error');
} finally {
setUploadingCount(c => Math.max(0, c - 1));
} }
} }
if (newAssets.length > 0) {
assetsCacheRef.current = assetsCacheRef.current
? [...newAssets, ...assetsCacheRef.current]
: null;
}
view.focus(); view.focus();
} }
@@ -316,6 +343,11 @@ export default function Editor({ editSlug }: Props) {
setShowModal(false); setShowModal(false);
} }
function closeAssetModal() {
assetsCacheRef.current = null;
setShowModal(false);
}
async function handleSave() { async function handleSave() {
const content = viewRef.current?.state.doc.toString() || ''; const content = viewRef.current?.state.doc.toString() || '';
if (!title.trim() || !slug || !content) { if (!title.trim() || !slug || !content) {
@@ -647,7 +679,7 @@ export default function Editor({ editSlug }: Props) {
<h2 className="font-display italic text-2xl md:text-3xl text-[var(--text)] leading-tight">Add image</h2> <h2 className="font-display italic text-2xl md:text-3xl text-[var(--text)] leading-tight">Add image</h2>
<p className="text-xs text-[var(--subtext0)] font-display italic mt-1">Click an image to insert it. Drag new files in to upload.</p> <p className="text-xs text-[var(--subtext0)] font-display italic mt-1">Click an image to insert it. Drag new files in to upload.</p>
</div> </div>
<button onClick={() => setShowModal(false)} className="p-2 text-[var(--subtext0)] hover:text-[var(--red)] transition-colors"> <button onClick={closeAssetModal} className="p-2 text-[var(--subtext0)] hover:text-[var(--red)] transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg> <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
</button> </button>
</header> </header>
+12 -19
View File
@@ -7,6 +7,10 @@ const { slug } = Astro.params;
const API_URL = (typeof process !== 'undefined' ? process.env.PUBLIC_API_URL : import.meta.env.PUBLIC_API_URL) || 'http://localhost:3000'; const API_URL = (typeof process !== 'undefined' ? process.env.PUBLIC_API_URL : import.meta.env.PUBLIC_API_URL) || 'http://localhost:3000';
interface CoverImage { url: string; alt: string } interface CoverImage { url: string; alt: string }
interface PostNeighbor {
slug: string;
title?: string;
}
interface PostDetail { interface PostDetail {
slug: string; slug: string;
date: string; date: string;
@@ -18,10 +22,8 @@ interface PostDetail {
reading_time: number; reading_time: number;
cover_image?: CoverImage; cover_image?: CoverImage;
image_count: number; image_count: number;
} prev?: PostNeighbor;
interface PostInfo { next?: PostNeighbor;
slug: string;
title?: string;
} }
function formatDate(d: string) { function formatDate(d: string) {
@@ -36,35 +38,26 @@ function formatSlug(s: string) {
let post: PostDetail | null = null; let post: PostDetail | null = null;
let html = ''; let html = '';
let error = ''; let error = '';
let neighbors: { prev?: PostInfo; next?: PostInfo } = {};
try { try {
const [postRes, listRes] = await Promise.all([ const postRes = await fetch(`${API_URL}/api/posts/${encodeURIComponent(slug ?? '')}`);
fetch(`${API_URL}/api/posts/${encodeURIComponent(slug ?? '')}`),
fetch(`${API_URL}/api/posts`),
]);
if (postRes.ok) { if (postRes.ok) {
post = await postRes.json(); post = await postRes.json();
html = renderMarkdown(post!.content); html = renderMarkdown(post!.content);
} else { } else {
error = 'Work not found in the catalogue'; error = 'Work not found in the catalogue';
} }
if (listRes.ok) {
const list: PostInfo[] = await listRes.json();
const i = list.findIndex(p => p.slug === slug);
if (i >= 0) {
neighbors = {
prev: i > 0 ? list[i - 1] : undefined,
next: i < list.length - 1 ? list[i + 1] : undefined,
};
}
}
} catch (e) { } catch (e) {
const cause = (e as any)?.cause; const cause = (e as any)?.cause;
error = `Could not connect to backend at ${API_URL}: ${e instanceof Error ? e.message : String(e)}${cause ? ' (Cause: ' + (cause.message || cause.code || JSON.stringify(cause)) + ')' : ''}`; error = `Could not connect to backend at ${API_URL}: ${e instanceof Error ? e.message : String(e)}${cause ? ' (Cause: ' + (cause.message || cause.code || JSON.stringify(cause)) + ')' : ''}`;
console.error(error); console.error(error);
} }
const neighbors = {
prev: post?.prev,
next: post?.next,
};
const isAdmin = Astro.cookies.get('admin_session')?.value === '1'; const isAdmin = Astro.cookies.get('admin_session')?.value === '1';
const displayTitle = post ? (post.title || formatSlug(post.slug)) : 'Work'; const displayTitle = post ? (post.title || formatSlug(post.slug)) : 'Work';
--- ---
+12 -6
View File
@@ -8,10 +8,16 @@ if (!response.ok) {
return new Response(null, { status: 404 }); return new Response(null, { status: 404 });
} }
return new Response(await response.blob(), { const headers = new Headers();
headers: { const contentType = response.headers.get('content-type');
'content-type': response.headers.get('content-type') || 'application/octet-stream', if (contentType) headers.set('content-type', contentType);
'cache-control': 'public, max-age=3600' const contentLength = response.headers.get('content-length');
} if (contentLength) headers.set('content-length', contentLength);
}); const etag = response.headers.get('etag');
if (etag) headers.set('etag', etag);
const lastModified = response.headers.get('last-modified');
if (lastModified) headers.set('last-modified', lastModified);
headers.set('cache-control', 'public, max-age=3600');
return new Response(response.body, { headers });
--- ---