performance improvements
This commit is contained in:
@@ -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()))
|
||||||
})?;
|
})?;
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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)]
|
||||||
|
|||||||
@@ -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 = ``;
|
const md = ``;
|
||||||
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>
|
||||||
|
|||||||
@@ -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';
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -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 });
|
||||||
---
|
---
|
||||||
|
|||||||
Reference in New Issue
Block a user