init elas atelier #1
@@ -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<Arc<AppState>>) -> 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::<SiteConfig>(&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()))
|
||||
})?;
|
||||
|
||||
@@ -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::<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,
|
||||
Err(e) => {
|
||||
error!("Failed to read messages dir: {}", e);
|
||||
@@ -195,21 +195,30 @@ pub async fn list_messages(
|
||||
};
|
||||
|
||||
let mut messages: Vec<Message> = 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::<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));
|
||||
@@ -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 }))
|
||||
|
||||
+120
-55
@@ -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<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(
|
||||
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<Vec<PostInfo>> {
|
||||
let admin = is_authed(&headers, &state.admin_token);
|
||||
let posts_dir = state.data_dir.join("posts");
|
||||
let mut posts: Vec<PostInfo> = 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<PostInfo> = cache
|
||||
.iter()
|
||||
.filter(|p| admin || !p.draft)
|
||||
.cloned()
|
||||
.collect();
|
||||
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(
|
||||
State(state): State<Arc<AppState>>,
|
||||
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,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -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()))
|
||||
})?;
|
||||
|
||||
+32
-4
@@ -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<Vec<PostInfo>>,
|
||||
pub contact_rate_limit: Mutex<HashMap<String, Vec<i64>>>,
|
||||
}
|
||||
|
||||
@@ -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<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,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
}
|
||||
|
||||
#[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<CoverImage>,
|
||||
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)]
|
||||
|
||||
@@ -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<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') {
|
||||
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 = ``;
|
||||
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) {
|
||||
<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>
|
||||
</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>
|
||||
</button>
|
||||
</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';
|
||||
|
||||
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';
|
||||
---
|
||||
|
||||
@@ -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 });
|
||||
---
|
||||
|
||||
Reference in New Issue
Block a user