diff --git a/backend/src/handlers/config.rs b/backend/src/handlers/config.rs index e737131..0364cbc 100644 --- a/backend/src/handlers/config.rs +++ b/backend/src/handlers/config.rs @@ -1,5 +1,6 @@ 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 crate::{ @@ -12,6 +13,7 @@ use crate::{ pub async fn get_config(State(state): State>) -> impl IntoResponse { let config_path = state.data_dir.join("config.json"); let config = fs::read_to_string(&config_path) + .await .ok() .and_then(|c| serde_json::from_str::(&c).ok()) .unwrap_or_default(); @@ -28,6 +30,7 @@ pub async fn update_config( let config_path = state.data_dir.join("config.json"); let mut config: SiteConfig = fs::read_to_string(&config_path) + .await .ok() .and_then(|c| serde_json::from_str(&c).ok()) .unwrap_or_default(); @@ -48,7 +51,7 @@ pub async fn update_config( 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); AppError::Internal("Write error".to_string(), Some(e.to_string())) })?; diff --git a/backend/src/handlers/contact.rs b/backend/src/handlers/contact.rs index 16b1820..131edb2 100644 --- a/backend/src/handlers/contact.rs +++ b/backend/src/handlers/contact.rs @@ -7,10 +7,10 @@ use axum::{ use chrono::Utc; use std::{ collections::hash_map::DefaultHasher, - fs, hash::{Hash, Hasher}, sync::Arc, }; +use tokio::fs; use tracing::{error, info, warn}; use crate::{ @@ -22,7 +22,7 @@ use crate::{ const MIN_FILL_TIME_MS: i64 = 3_000; 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 MAX_NAME: usize = 200; const MAX_EMAIL: usize = 200; @@ -155,7 +155,7 @@ pub async fn submit_contact( }; 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); 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| { 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); 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"); - if !messages_dir.exists() { + if !fs::try_exists(&messages_dir).await.unwrap_or(false) { return Json(Vec::::new()).into_response(); } - let entries = match fs::read_dir(&messages_dir) { + let mut rd = match fs::read_dir(&messages_dir).await { Ok(e) => e, Err(e) => { error!("Failed to read messages dir: {}", e); @@ -195,21 +195,30 @@ pub async fn list_messages( }; let mut messages: Vec = Vec::new(); - for entry in entries.flatten() { - let path = entry.path(); - if path.extension().and_then(|e| e.to_str()) != Some("json") { - continue; - } - let content = match fs::read_to_string(&path) { - Ok(c) => c, - Err(e) => { - error!("Failed to read {:?}: {}", path, e); - continue; + loop { + match rd.next_entry().await { + Ok(Some(entry)) => { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("json") { + continue; + } + let content = match fs::read_to_string(&path).await { + Ok(c) => c, + Err(e) => { + error!("Failed to read {:?}: {}", path, e); + continue; + } + }; + match serde_json::from_str::(&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::(&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)); @@ -227,10 +236,10 @@ pub async fn delete_message( return Err(AppError::BadRequest("Invalid id.".into())); } 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())); } - fs::remove_file(&path).map_err(|e| { + fs::remove_file(&path).await.map_err(|e| { AppError::Internal("Delete failed".into(), Some(e.to_string())) })?; Ok(Json(ContactResponse { ok: true })) diff --git a/backend/src/handlers/posts.rs b/backend/src/handlers/posts.rs index 0a090dd..786a576 100644 --- a/backend/src/handlers/posts.rs +++ b/backend/src/handlers/posts.rs @@ -4,14 +4,15 @@ use axum::{ http::{HeaderMap, StatusCode}, }; use chrono::Utc; -use std::{fs, sync::Arc}; +use std::sync::Arc; +use tokio::fs; use tracing::{error, info, warn}; use crate::{ AppState, auth::is_authed, error::AppError, - models::{CoverImage, CreatePostRequest, PostDetail, PostInfo, PostMeta}, + models::{CoverImage, CreatePostRequest, PostDetail, PostInfo, PostMeta, PostNeighbor}, }; const WORDS_PER_MINUTE: u32 = 200; @@ -183,6 +184,70 @@ fn excerpt_from(meta: &PostMeta, body: &str) -> 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 = 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( state: &AppState, slug: &str, @@ -191,13 +256,16 @@ async fn write_post_atomic( let _guard = state.post_lock.lock().await; 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)); - 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())) })?; - fs::rename(&tmp_path, &final_path).map_err(|e| { - let _ = fs::remove_file(&tmp_path); - AppError::Internal("Rename error".to_string(), Some(e.to_string())) - })?; + if let Err(e) = fs::rename(&tmp_path, &final_path).await { + let _ = fs::remove_file(&tmp_path).await; + return Err(AppError::Internal( + "Rename error".to_string(), + Some(e.to_string()), + )); + } Ok(()) } @@ -227,14 +295,14 @@ pub async fn create_post( if let Some(ref old_slug) = payload.old_slug { if old_slug != &slug { let old_path = posts_dir.join(format!("{}.md", old_slug)); - if old_path.exists() { - if file_path.exists() { + if fs::try_exists(&old_path).await.unwrap_or(false) { + if fs::try_exists(&file_path).await.unwrap_or(false) { return Err(AppError::BadRequest( "A post with this new title already exists".to_string(), )); } 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); AppError::Internal("Rename error".to_string(), Some(e.to_string())) })?; @@ -264,6 +332,10 @@ pub async fn create_post( info!("Post saved: {}", slug); let image_count = images.len() as u32; let cover = cover_from(&images); + + rebuild_posts_cache(&state).await; + let (prev, next) = neighbors_from_cache(&state, &slug, true).await; + Ok(Json(PostDetail { slug, date: meta.date, @@ -275,6 +347,8 @@ pub async fn create_post( content: payload.content, cover_image: cover, image_count, + prev, + next, })) } @@ -291,17 +365,19 @@ pub async fn delete_post( let _guard = state.post_lock.lock().await; 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); 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); AppError::Internal("Delete error".to_string(), Some(e.to_string())) })?; + drop(_guard); info!("Post deleted: {}", slug); + rebuild_posts_cache(&state).await; Ok(StatusCode::NO_CONTENT) } @@ -310,51 +386,37 @@ pub async fn list_posts( headers: HeaderMap, ) -> Json> { let admin = is_authed(&headers, &state.admin_token); - let posts_dir = state.data_dir.join("posts"); - let mut posts: Vec = Vec::new(); - - if let Ok(entries) = fs::read_dir(posts_dir) { - for entry in entries.flatten() { - 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) 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))); + let cache = state.posts_cache.read().await; + let posts: Vec = cache + .iter() + .filter(|p| admin || !p.draft) + .cloned() + .collect(); Json(posts) } +async fn neighbors_from_cache( + state: &AppState, + slug: &str, + admin: bool, +) -> (Option, Option) { + 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( State(state): State>, headers: HeaderMap, @@ -364,7 +426,7 @@ pub async fn get_post( 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) + let raw = fs::read_to_string(&file_path).await .map_err(|_| AppError::NotFound("Post not found".to_string()))?; let (meta, body) = parse_post(&raw)?; @@ -373,6 +435,7 @@ pub async fn get_post( } let images = extract_images(&body); + let (prev, next) = neighbors_from_cache(&state, &slug, admin).await; Ok(Json(PostDetail { slug, date: meta.date, @@ -384,5 +447,7 @@ pub async fn get_post( content: body, cover_image: cover_from(&images), image_count: images.len() as u32, + prev, + next, })) } diff --git a/backend/src/handlers/upload.rs b/backend/src/handlers/upload.rs index 2e99033..fb87ead 100644 --- a/backend/src/handlers/upload.rs +++ b/backend/src/handlers/upload.rs @@ -4,7 +4,8 @@ use axum::{ http::{HeaderMap, StatusCode}, }; use serde::Deserialize; -use std::{fs, sync::Arc}; +use std::sync::Arc; +use tokio::fs; use tracing::{error, info, warn}; use crate::{ @@ -76,15 +77,15 @@ pub async fn delete_upload( let uploads_dir = state.data_dir.join("uploads"); 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())) })?; - 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) { warn!("Refused delete outside uploads dir: {}", filename); 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); 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 mut files = Vec::new(); - if let Ok(entries) = fs::read_dir(uploads_dir) { - for entry in entries.flatten() { - let path = entry.path(); - if path.is_file() { - if let Some(name) = path.file_name().and_then(|n| n.to_str()) { - files.push(FileInfo { - name: name.to_string(), - url: format!("/uploads/{}", name), - }); + if let Ok(mut rd) = fs::read_dir(&uploads_dir).await { + loop { + match rd.next_entry().await { + Ok(Some(entry)) => { + let path = entry.path(); + let is_file = entry + .file_type() + .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 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(); uploads_dir.join(format!("{}_{}", timestamp, final_name)) } else { @@ -186,11 +204,11 @@ pub async fn upload_file( }; // 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())) })?; 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())) })?; if canonical_parent != canonical_dir { @@ -204,7 +222,7 @@ pub async fn upload_file( .unwrap_or(&final_name) .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); AppError::Internal("Write error".to_string(), Some(e.to_string())) })?; diff --git a/backend/src/main.rs b/backend/src/main.rs index c242a61..9d3c604 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -9,19 +9,23 @@ use axum::{ http::{HeaderValue, header}, routing::{delete, get, post}, }; -use std::{collections::HashMap, env, fs, path::PathBuf, sync::Arc}; -use tokio::sync::Mutex; +use std::{collections::HashMap, env, path::PathBuf, sync::Arc, time::Duration}; +use tokio::sync::{Mutex, RwLock}; use tower_http::{ cors::{AllowOrigin, CorsLayer}, services::ServeDir, }; use tracing::{error, info, warn}; +use crate::handlers::contact::RATE_LIMIT_WINDOW_MS; +use crate::models::PostInfo; + pub struct AppState { pub admin_token: String, pub data_dir: PathBuf, pub cookie_secure: bool, pub post_lock: Mutex<()>, + pub posts_cache: RwLock>, pub contact_rate_limit: Mutex>>, } @@ -50,10 +54,10 @@ async fn main() { let posts_dir = data_dir.join("posts"); 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); } - 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); } @@ -62,9 +66,15 @@ async fn main() { data_dir, cookie_secure, post_lock: Mutex::new(()), + posts_cache: RwLock::new(Vec::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 // the frontend if you ever expose the backend directly to browsers. // 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(); 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) { + 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() + }); + } + }); +} diff --git a/backend/src/models.rs b/backend/src/models.rs index d51b451..c8f478c 100644 --- a/backend/src/models.rs +++ b/backend/src/models.rs @@ -86,7 +86,7 @@ pub struct CoverImage { pub alt: String, } -#[derive(Serialize)] +#[derive(Serialize, Clone)] pub struct PostInfo { pub slug: String, pub date: NaiveDate, @@ -103,6 +103,13 @@ pub struct PostInfo { pub image_count: u32, } +#[derive(Serialize, Clone)] +pub struct PostNeighbor { + pub slug: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option, +} + #[derive(Serialize)] pub struct PostDetail { pub slug: String, @@ -118,6 +125,10 @@ pub struct PostDetail { #[serde(skip_serializing_if = "Option::is_none")] pub cover_image: Option, pub image_count: u32, + #[serde(skip_serializing_if = "Option::is_none")] + pub prev: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub next: Option, } #[derive(Deserialize)] diff --git a/frontend/src/components/react/admin/Editor.tsx b/frontend/src/components/react/admin/Editor.tsx index 83b04e1..024ff26 100644 --- a/frontend/src/components/react/admin/Editor.tsx +++ b/frontend/src/components/react/admin/Editor.tsx @@ -100,6 +100,14 @@ export default function Editor({ editSlug }: Props) { const [isDragging, setIsDragging] = useState(false); const [uploadingCount, setUploadingCount] = useState(0); const dragDepthRef = useRef(0); + const assetsCacheRef = useRef(null); + + async function getCachedAssets(): Promise { + if (assetsCacheRef.current) return assetsCacheRef.current; + const assets = await getAssets(); + assetsCacheRef.current = assets; + return assets; + } function showAlertMsg(msg: string, type: 'success' | 'error') { setAlert({ msg, type }); @@ -245,7 +253,7 @@ export default function Editor({ editSlug }: Props) { async function triggerAutocomplete(view: EditorView) { try { - const assets = await getAssets(); + const assets = await getCachedAssets(); setAutocompleteAssets(assets.slice(0, 8)); const pos = view.state.selection.main.head; const coords = view.coordsAtPos(pos); @@ -292,22 +300,41 @@ export default function Editor({ editSlug }: Props) { return; } 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; - for (const file of images) { - try { - const asset = await uploadAsset(file); + const newAssets: Asset[] = []; + for (const promise of uploads) { + 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 line = view.state.doc.lineAt(pos); const atLineEnd = pos === line.to; const insertText = atLineEnd ? `\n\n${md}\n` : `${md}\n\n`; view.dispatch({ changes: { from: pos, insert: insertText } }); pos += insertText.length; - } catch (e) { + } else { + const e = result.err; 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(); } @@ -316,6 +343,11 @@ export default function Editor({ editSlug }: Props) { setShowModal(false); } + function closeAssetModal() { + assetsCacheRef.current = null; + setShowModal(false); + } + async function handleSave() { const content = viewRef.current?.state.doc.toString() || ''; if (!title.trim() || !slug || !content) { @@ -647,7 +679,7 @@ export default function Editor({ editSlug }: Props) {

Add image

Click an image to insert it. Drag new files in to upload.

- diff --git a/frontend/src/pages/posts/[slug].astro b/frontend/src/pages/posts/[slug].astro index e7ac27e..7a79052 100644 --- a/frontend/src/pages/posts/[slug].astro +++ b/frontend/src/pages/posts/[slug].astro @@ -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'; interface CoverImage { url: string; alt: string } +interface PostNeighbor { + slug: string; + title?: string; +} interface PostDetail { slug: string; date: string; @@ -18,10 +22,8 @@ interface PostDetail { reading_time: number; cover_image?: CoverImage; image_count: number; -} -interface PostInfo { - slug: string; - title?: string; + prev?: PostNeighbor; + next?: PostNeighbor; } function formatDate(d: string) { @@ -36,35 +38,26 @@ function formatSlug(s: string) { let post: PostDetail | null = null; let html = ''; let error = ''; -let neighbors: { prev?: PostInfo; next?: PostInfo } = {}; try { - const [postRes, listRes] = await Promise.all([ - fetch(`${API_URL}/api/posts/${encodeURIComponent(slug ?? '')}`), - fetch(`${API_URL}/api/posts`), - ]); + const postRes = await fetch(`${API_URL}/api/posts/${encodeURIComponent(slug ?? '')}`); if (postRes.ok) { post = await postRes.json(); html = renderMarkdown(post!.content); } else { 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) { 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)) + ')' : ''}`; console.error(error); } +const neighbors = { + prev: post?.prev, + next: post?.next, +}; + const isAdmin = Astro.cookies.get('admin_session')?.value === '1'; const displayTitle = post ? (post.title || formatSlug(post.slug)) : 'Work'; --- diff --git a/frontend/src/pages/uploads/[...path].astro b/frontend/src/pages/uploads/[...path].astro index b227453..0ed8efd 100644 --- a/frontend/src/pages/uploads/[...path].astro +++ b/frontend/src/pages/uploads/[...path].astro @@ -8,10 +8,16 @@ if (!response.ok) { return new Response(null, { status: 404 }); } -return new Response(await response.blob(), { - headers: { - 'content-type': response.headers.get('content-type') || 'application/octet-stream', - 'cache-control': 'public, max-age=3600' - } -}); +const headers = new Headers(); +const contentType = response.headers.get('content-type'); +if (contentType) headers.set('content-type', contentType); +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 }); ---