init elas atelier #1
Generated
+115
@@ -2,6 +2,12 @@
|
|||||||
# It is not intended for manual editing.
|
# It is not intended for manual editing.
|
||||||
version = 4
|
version = 4
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "adler2"
|
||||||
|
version = "2.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "aho-corasick"
|
name = "aho-corasick"
|
||||||
version = "1.1.4"
|
version = "1.1.4"
|
||||||
@@ -11,6 +17,21 @@ dependencies = [
|
|||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "alloc-no-stdlib"
|
||||||
|
version = "2.0.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "alloc-stdlib"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece"
|
||||||
|
dependencies = [
|
||||||
|
"alloc-no-stdlib",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "android_system_properties"
|
name = "android_system_properties"
|
||||||
version = "0.1.5"
|
version = "0.1.5"
|
||||||
@@ -20,6 +41,18 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "async-compression"
|
||||||
|
version = "0.4.41"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d0f9ee0f6e02ffd7ad5816e9464499fba7b3effd01123b515c41d1697c43dad1"
|
||||||
|
dependencies = [
|
||||||
|
"compression-codecs",
|
||||||
|
"compression-core",
|
||||||
|
"pin-project-lite",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "atomic-waker"
|
name = "atomic-waker"
|
||||||
version = "1.1.2"
|
version = "1.1.2"
|
||||||
@@ -104,6 +137,7 @@ dependencies = [
|
|||||||
"axum",
|
"axum",
|
||||||
"chrono",
|
"chrono",
|
||||||
"dotenvy",
|
"dotenvy",
|
||||||
|
"imagesize",
|
||||||
"infer",
|
"infer",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -122,6 +156,27 @@ version = "2.11.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
|
checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "brotli"
|
||||||
|
version = "8.0.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560"
|
||||||
|
dependencies = [
|
||||||
|
"alloc-no-stdlib",
|
||||||
|
"alloc-stdlib",
|
||||||
|
"brotli-decompressor",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "brotli-decompressor"
|
||||||
|
version = "5.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03"
|
||||||
|
dependencies = [
|
||||||
|
"alloc-no-stdlib",
|
||||||
|
"alloc-stdlib",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bumpalo"
|
name = "bumpalo"
|
||||||
version = "3.20.2"
|
version = "3.20.2"
|
||||||
@@ -181,12 +236,39 @@ dependencies = [
|
|||||||
"windows-link",
|
"windows-link",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "compression-codecs"
|
||||||
|
version = "0.4.37"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "eb7b51a7d9c967fc26773061ba86150f19c50c0d65c887cb1fbe295fd16619b7"
|
||||||
|
dependencies = [
|
||||||
|
"brotli",
|
||||||
|
"compression-core",
|
||||||
|
"flate2",
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "compression-core"
|
||||||
|
version = "0.4.31"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "75984efb6ed102a0d42db99afb6c1948f0380d1d91808d5529916e6c08b49d8d"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "core-foundation-sys"
|
name = "core-foundation-sys"
|
||||||
version = "0.8.7"
|
version = "0.8.7"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crc32fast"
|
||||||
|
version = "1.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "deunicode"
|
name = "deunicode"
|
||||||
version = "1.6.2"
|
version = "1.6.2"
|
||||||
@@ -230,6 +312,16 @@ version = "0.1.9"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "flate2"
|
||||||
|
version = "1.1.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
|
||||||
|
dependencies = [
|
||||||
|
"crc32fast",
|
||||||
|
"miniz_oxide",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fnv"
|
name = "fnv"
|
||||||
version = "1.0.7"
|
version = "1.0.7"
|
||||||
@@ -401,6 +493,12 @@ dependencies = [
|
|||||||
"cc",
|
"cc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "imagesize"
|
||||||
|
version = "0.14.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "09e54e57b4c48b40f7aec75635392b12b3421fa26fe8b4332e63138ed278459c"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "indexmap"
|
name = "indexmap"
|
||||||
version = "2.14.0"
|
version = "2.14.0"
|
||||||
@@ -500,6 +598,16 @@ dependencies = [
|
|||||||
"unicase",
|
"unicase",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "miniz_oxide"
|
||||||
|
version = "0.8.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
|
||||||
|
dependencies = [
|
||||||
|
"adler2",
|
||||||
|
"simd-adler32",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mio"
|
name = "mio"
|
||||||
version = "1.1.1"
|
version = "1.1.1"
|
||||||
@@ -759,6 +867,12 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "simd-adler32"
|
||||||
|
version = "0.3.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "slab"
|
name = "slab"
|
||||||
version = "0.4.12"
|
version = "0.4.12"
|
||||||
@@ -892,6 +1006,7 @@ version = "0.6.8"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8"
|
checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"async-compression",
|
||||||
"bitflags",
|
"bitflags",
|
||||||
"bytes",
|
"bytes",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
|
|||||||
+2
-1
@@ -8,12 +8,13 @@ axum = { version = "0.8.8", features = ["multipart", "macros"] }
|
|||||||
chrono = { version = "0.4.44", features = ["serde"] }
|
chrono = { version = "0.4.44", features = ["serde"] }
|
||||||
dotenvy = "0.15.7"
|
dotenvy = "0.15.7"
|
||||||
infer = "0.19.0"
|
infer = "0.19.0"
|
||||||
|
imagesize = "0.14"
|
||||||
serde = { version = "1.0.228", features = ["derive"] }
|
serde = { version = "1.0.228", features = ["derive"] }
|
||||||
serde_json = "1.0.149"
|
serde_json = "1.0.149"
|
||||||
serde_yaml = "0.9.34"
|
serde_yaml = "0.9.34"
|
||||||
slug = "0.1.6"
|
slug = "0.1.6"
|
||||||
subtle = "2.6.1"
|
subtle = "2.6.1"
|
||||||
tokio = { version = "1.50.0", features = ["full"] }
|
tokio = { version = "1.50.0", features = ["full"] }
|
||||||
tower-http = { version = "0.6.8", features = ["cors", "fs"] }
|
tower-http = { version = "0.6.8", features = ["cors", "fs", "compression-br", "compression-gzip"] }
|
||||||
tracing = "0.1.44"
|
tracing = "0.1.44"
|
||||||
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
|
||||||
|
|||||||
+140
-28
@@ -4,15 +4,17 @@ use axum::{
|
|||||||
http::{HeaderMap, StatusCode},
|
http::{HeaderMap, StatusCode},
|
||||||
};
|
};
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use std::sync::Arc;
|
use std::{collections::HashMap, sync::Arc};
|
||||||
use tokio::fs;
|
use tokio::fs;
|
||||||
use tracing::{error, info, warn};
|
use tracing::{error, info, warn};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
AppState,
|
AppState, CachedPost,
|
||||||
auth::is_authed,
|
auth::is_authed,
|
||||||
error::AppError,
|
error::AppError,
|
||||||
models::{CoverImage, CreatePostRequest, PostDetail, PostInfo, PostMeta, PostNeighbor},
|
models::{
|
||||||
|
CoverImage, CreatePostRequest, ImageDim, PostDetail, PostInfo, PostMeta, PostNeighbor,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const WORDS_PER_MINUTE: u32 = 200;
|
const WORDS_PER_MINUTE: u32 = 200;
|
||||||
@@ -165,9 +167,87 @@ fn cover_from(images: &[(String, String)]) -> Option<CoverImage> {
|
|||||||
images.first().map(|(alt, url)| CoverImage {
|
images.first().map(|(alt, url)| CoverImage {
|
||||||
url: url.clone(),
|
url: url.clone(),
|
||||||
alt: alt.clone(),
|
alt: alt.clone(),
|
||||||
|
w: None,
|
||||||
|
h: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Probe an uploads-relative URL for image dimensions. Reads only header
|
||||||
|
/// bytes via `imagesize::size`, off the runtime via `spawn_blocking`.
|
||||||
|
async fn compute_dim_from_url(state: &AppState, url: &str) -> Option<ImageDim> {
|
||||||
|
let name = url.strip_prefix("/uploads/")?;
|
||||||
|
if name.is_empty()
|
||||||
|
|| name.contains("..")
|
||||||
|
|| name.contains('\\')
|
||||||
|
|| name.starts_with('/')
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let path = state.data_dir.join("uploads").join(name);
|
||||||
|
tokio::task::spawn_blocking(move || imagesize::size(&path).ok())
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.map(|s| ImageDim {
|
||||||
|
w: s.width as u32,
|
||||||
|
h: s.height as u32,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns cached dim if present, else probes the file and caches the result.
|
||||||
|
async fn dim_for_url(state: &AppState, url: &str) -> Option<ImageDim> {
|
||||||
|
{
|
||||||
|
let cache = state.image_dims_cache.read().await;
|
||||||
|
if let Some(d) = cache.get(url) {
|
||||||
|
return Some(*d);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let d = compute_dim_from_url(state, url).await?;
|
||||||
|
state
|
||||||
|
.image_dims_cache
|
||||||
|
.write()
|
||||||
|
.await
|
||||||
|
.insert(url.to_string(), d);
|
||||||
|
Some(d)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a map of `url -> ImageDim` for the given URLs, using the cache
|
||||||
|
/// and probing only the URLs that aren't cached yet.
|
||||||
|
async fn dims_for_urls(state: &AppState, urls: &[String]) -> HashMap<String, ImageDim> {
|
||||||
|
let mut out: HashMap<String, ImageDim> = HashMap::new();
|
||||||
|
let mut missing: Vec<String> = Vec::new();
|
||||||
|
{
|
||||||
|
let cache = state.image_dims_cache.read().await;
|
||||||
|
for url in urls {
|
||||||
|
if out.contains_key(url) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(d) = cache.get(url) {
|
||||||
|
out.insert(url.clone(), *d);
|
||||||
|
} else {
|
||||||
|
missing.push(url.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if missing.is_empty() {
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
let mut newly: Vec<(String, ImageDim)> = Vec::new();
|
||||||
|
for url in &missing {
|
||||||
|
if let Some(d) = compute_dim_from_url(state, url).await {
|
||||||
|
newly.push((url.clone(), d));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !newly.is_empty() {
|
||||||
|
let mut cache = state.image_dims_cache.write().await;
|
||||||
|
for (url, d) in &newly {
|
||||||
|
cache.insert(url.clone(), *d);
|
||||||
|
out.insert(url.clone(), *d);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
fn excerpt_from(meta: &PostMeta, body: &str) -> String {
|
fn excerpt_from(meta: &PostMeta, body: &str) -> String {
|
||||||
if let Some(s) = meta.summary.as_ref() {
|
if let Some(s) = meta.summary.as_ref() {
|
||||||
if !s.trim().is_empty() {
|
if !s.trim().is_empty() {
|
||||||
@@ -204,7 +284,7 @@ fn build_post_info(slug: &str, meta: &PostMeta, body: &str) -> PostInfo {
|
|||||||
/// Called at startup and after any mutation (create/rename/delete).
|
/// Called at startup and after any mutation (create/rename/delete).
|
||||||
pub async fn rebuild_posts_cache(state: &AppState) {
|
pub async fn rebuild_posts_cache(state: &AppState) {
|
||||||
let posts_dir = state.data_dir.join("posts");
|
let posts_dir = state.data_dir.join("posts");
|
||||||
let mut posts: Vec<PostInfo> = Vec::new();
|
let mut posts: Vec<CachedPost> = Vec::new();
|
||||||
|
|
||||||
let mut rd = match fs::read_dir(&posts_dir).await {
|
let mut rd = match fs::read_dir(&posts_dir).await {
|
||||||
Ok(rd) => rd,
|
Ok(rd) => rd,
|
||||||
@@ -234,7 +314,14 @@ pub async fn rebuild_posts_cache(state: &AppState) {
|
|||||||
warn!("Skipping post with bad frontmatter: {}", slug);
|
warn!("Skipping post with bad frontmatter: {}", slug);
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
posts.push(build_post_info(slug, &meta, &body));
|
let mut info = build_post_info(slug, &meta, &body);
|
||||||
|
if let Some(cover) = info.cover_image.as_mut() {
|
||||||
|
if let Some(d) = dim_for_url(state, &cover.url).await {
|
||||||
|
cover.w = Some(d.w);
|
||||||
|
cover.h = Some(d.h);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
posts.push(CachedPost { info, body });
|
||||||
}
|
}
|
||||||
Ok(None) => break,
|
Ok(None) => break,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -244,7 +331,12 @@ pub async fn rebuild_posts_cache(state: &AppState) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
posts.sort_by(|a, b| b.date.cmp(&a.date).then_with(|| a.slug.cmp(&b.slug)));
|
posts.sort_by(|a, b| {
|
||||||
|
b.info
|
||||||
|
.date
|
||||||
|
.cmp(&a.info.date)
|
||||||
|
.then_with(|| a.info.slug.cmp(&b.info.slug))
|
||||||
|
});
|
||||||
*state.posts_cache.write().await = posts;
|
*state.posts_cache.write().await = posts;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -331,11 +423,20 @@ 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 mut cover = cover_from(&images);
|
||||||
|
|
||||||
rebuild_posts_cache(&state).await;
|
rebuild_posts_cache(&state).await;
|
||||||
let (prev, next) = neighbors_from_cache(&state, &slug, true).await;
|
let (prev, next) = neighbors_from_cache(&state, &slug, true).await;
|
||||||
|
|
||||||
|
let image_urls: Vec<String> = images.iter().map(|(_, u)| u.clone()).collect();
|
||||||
|
let dimensions = dims_for_urls(&state, &image_urls).await;
|
||||||
|
if let Some(c) = cover.as_mut() {
|
||||||
|
if let Some(d) = dimensions.get(&c.url) {
|
||||||
|
c.w = Some(d.w);
|
||||||
|
c.h = Some(d.h);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Ok(Json(PostDetail {
|
Ok(Json(PostDetail {
|
||||||
slug,
|
slug,
|
||||||
date: meta.date,
|
date: meta.date,
|
||||||
@@ -349,6 +450,7 @@ pub async fn create_post(
|
|||||||
image_count,
|
image_count,
|
||||||
prev,
|
prev,
|
||||||
next,
|
next,
|
||||||
|
dimensions,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -389,8 +491,8 @@ pub async fn list_posts(
|
|||||||
let cache = state.posts_cache.read().await;
|
let cache = state.posts_cache.read().await;
|
||||||
let posts: Vec<PostInfo> = cache
|
let posts: Vec<PostInfo> = cache
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|p| admin || !p.draft)
|
.filter(|p| admin || !p.info.draft)
|
||||||
.cloned()
|
.map(|p| p.info.clone())
|
||||||
.collect();
|
.collect();
|
||||||
Json(posts)
|
Json(posts)
|
||||||
}
|
}
|
||||||
@@ -403,7 +505,8 @@ async fn neighbors_from_cache(
|
|||||||
let cache = state.posts_cache.read().await;
|
let cache = state.posts_cache.read().await;
|
||||||
let visible: Vec<&PostInfo> = cache
|
let visible: Vec<&PostInfo> = cache
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|p| admin || !p.draft)
|
.filter(|p| admin || !p.info.draft)
|
||||||
|
.map(|p| &p.info)
|
||||||
.collect();
|
.collect();
|
||||||
let Some(i) = visible.iter().position(|p| p.slug == slug) else {
|
let Some(i) = visible.iter().position(|p| p.slug == slug) else {
|
||||||
return (None, None);
|
return (None, None);
|
||||||
@@ -424,30 +527,39 @@ pub async fn get_post(
|
|||||||
) -> Result<Json<PostDetail>, AppError> {
|
) -> Result<Json<PostDetail>, AppError> {
|
||||||
validate_slug(&slug)?;
|
validate_slug(&slug)?;
|
||||||
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 raw = fs::read_to_string(&file_path).await
|
let (info, body) = {
|
||||||
.map_err(|_| AppError::NotFound("Post not found".to_string()))?;
|
let cache = state.posts_cache.read().await;
|
||||||
let (meta, body) = parse_post(&raw)?;
|
let Some(p) = cache.iter().find(|p| p.info.slug == slug) else {
|
||||||
|
return Err(AppError::NotFound("Post not found".to_string()));
|
||||||
|
};
|
||||||
|
if p.info.draft && !admin {
|
||||||
|
return Err(AppError::NotFound("Post not found".to_string()));
|
||||||
|
}
|
||||||
|
(p.info.clone(), p.body.clone())
|
||||||
|
};
|
||||||
|
|
||||||
if meta.draft && !admin {
|
|
||||||
return Err(AppError::NotFound("Post not found".to_string()));
|
|
||||||
}
|
|
||||||
|
|
||||||
let images = extract_images(&body);
|
|
||||||
let (prev, next) = neighbors_from_cache(&state, &slug, admin).await;
|
let (prev, next) = neighbors_from_cache(&state, &slug, admin).await;
|
||||||
|
|
||||||
|
let image_urls: Vec<String> = extract_images(&body)
|
||||||
|
.into_iter()
|
||||||
|
.map(|(_, u)| u)
|
||||||
|
.collect();
|
||||||
|
let dimensions = dims_for_urls(&state, &image_urls).await;
|
||||||
|
|
||||||
Ok(Json(PostDetail {
|
Ok(Json(PostDetail {
|
||||||
slug,
|
slug: info.slug,
|
||||||
date: meta.date,
|
date: info.date,
|
||||||
title: meta.title,
|
title: info.title,
|
||||||
summary: meta.summary,
|
summary: info.summary,
|
||||||
tags: meta.tags,
|
tags: info.tags,
|
||||||
draft: meta.draft,
|
draft: info.draft,
|
||||||
reading_time: reading_time(&body),
|
reading_time: info.reading_time,
|
||||||
content: body,
|
content: body,
|
||||||
cover_image: cover_from(&images),
|
cover_image: info.cover_image,
|
||||||
image_count: images.len() as u32,
|
image_count: info.image_count,
|
||||||
prev,
|
prev,
|
||||||
next,
|
next,
|
||||||
|
dimensions,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,6 +89,11 @@ pub async fn delete_upload(
|
|||||||
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()))
|
||||||
})?;
|
})?;
|
||||||
|
state
|
||||||
|
.image_dims_cache
|
||||||
|
.write()
|
||||||
|
.await
|
||||||
|
.remove(&format!("/uploads/{}", filename));
|
||||||
info!("Deleted file: {}", filename);
|
info!("Deleted file: {}", filename);
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
} else {
|
} else {
|
||||||
@@ -227,10 +232,12 @@ pub async fn upload_file(
|
|||||||
AppError::Internal("Write error".to_string(), Some(e.to_string()))
|
AppError::Internal("Write error".to_string(), Some(e.to_string()))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
|
let url = format!("/uploads/{}", final_name_str);
|
||||||
|
// Invalidate any stale dim cache entry (matters when replacing an existing file).
|
||||||
|
state.image_dims_cache.write().await.remove(&url);
|
||||||
|
|
||||||
info!("File uploaded successfully to {:?}", final_path);
|
info!("File uploaded successfully to {:?}", final_path);
|
||||||
return Ok(Json(UploadResponse {
|
return Ok(Json(UploadResponse { url }));
|
||||||
url: format!("/uploads/{}", final_name_str),
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
warn!("Upload failed: no file found in multipart stream");
|
warn!("Upload failed: no file found in multipart stream");
|
||||||
|
|||||||
+21
-4
@@ -12,20 +12,27 @@ use axum::{
|
|||||||
use std::{collections::HashMap, env, path::PathBuf, sync::Arc, time::Duration};
|
use std::{collections::HashMap, env, path::PathBuf, sync::Arc, time::Duration};
|
||||||
use tokio::sync::{Mutex, RwLock};
|
use tokio::sync::{Mutex, RwLock};
|
||||||
use tower_http::{
|
use tower_http::{
|
||||||
|
compression::CompressionLayer,
|
||||||
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::handlers::contact::RATE_LIMIT_WINDOW_MS;
|
||||||
use crate::models::PostInfo;
|
use crate::models::{ImageDim, PostInfo};
|
||||||
|
|
||||||
|
pub struct CachedPost {
|
||||||
|
pub info: PostInfo,
|
||||||
|
pub body: String,
|
||||||
|
}
|
||||||
|
|
||||||
pub struct AppState {
|
pub 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 posts_cache: RwLock<Vec<CachedPost>>,
|
||||||
|
pub image_dims_cache: RwLock<HashMap<String, ImageDim>>,
|
||||||
pub contact_rate_limit: Mutex<HashMap<String, Vec<i64>>>,
|
pub contact_rate_limit: Mutex<HashMap<String, Vec<i64>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,6 +74,7 @@ async fn main() {
|
|||||||
cookie_secure,
|
cookie_secure,
|
||||||
post_lock: Mutex::new(()),
|
post_lock: Mutex::new(()),
|
||||||
posts_cache: RwLock::new(Vec::new()),
|
posts_cache: RwLock::new(Vec::new()),
|
||||||
|
image_dims_cache: RwLock::new(HashMap::new()),
|
||||||
contact_rate_limit: Mutex::new(HashMap::new()),
|
contact_rate_limit: Mutex::new(HashMap::new()),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -97,6 +105,10 @@ async fn main() {
|
|||||||
None => CorsLayer::new(),
|
None => CorsLayer::new(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// JSON routes get a tight 1 MB cap; the upload route keeps 50 MB.
|
||||||
|
const JSON_BODY_LIMIT: usize = 1024 * 1024;
|
||||||
|
const UPLOAD_BODY_LIMIT: usize = 50 * 1024 * 1024;
|
||||||
|
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
.route("/api/auth/login", post(handlers::auth::login))
|
.route("/api/auth/login", post(handlers::auth::login))
|
||||||
.route("/api/auth/logout", post(handlers::auth::logout))
|
.route("/api/auth/logout", post(handlers::auth::logout))
|
||||||
@@ -118,7 +130,11 @@ async fn main() {
|
|||||||
"/api/uploads/{filename}",
|
"/api/uploads/{filename}",
|
||||||
delete(handlers::upload::delete_upload),
|
delete(handlers::upload::delete_upload),
|
||||||
)
|
)
|
||||||
.route("/api/upload", post(handlers::upload::upload_file))
|
.route(
|
||||||
|
"/api/upload",
|
||||||
|
post(handlers::upload::upload_file)
|
||||||
|
.layer(DefaultBodyLimit::max(UPLOAD_BODY_LIMIT)),
|
||||||
|
)
|
||||||
.route("/api/contact", post(handlers::contact::submit_contact))
|
.route("/api/contact", post(handlers::contact::submit_contact))
|
||||||
.route("/api/messages", get(handlers::contact::list_messages))
|
.route("/api/messages", get(handlers::contact::list_messages))
|
||||||
.route(
|
.route(
|
||||||
@@ -127,7 +143,8 @@ async fn main() {
|
|||||||
)
|
)
|
||||||
.route("/healthz", get(|| async { "ok" }))
|
.route("/healthz", get(|| async { "ok" }))
|
||||||
.nest_service("/uploads", ServeDir::new(uploads_dir))
|
.nest_service("/uploads", ServeDir::new(uploads_dir))
|
||||||
.layer(DefaultBodyLimit::max(50 * 1024 * 1024))
|
.layer(DefaultBodyLimit::max(JSON_BODY_LIMIT))
|
||||||
|
.layer(CompressionLayer::new().br(true).gzip(true))
|
||||||
.layer(cors)
|
.layer(cors)
|
||||||
.with_state(state);
|
.with_state(state);
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
use chrono::NaiveDate;
|
use chrono::NaiveDate;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Clone)]
|
#[derive(Serialize, Deserialize, Clone)]
|
||||||
pub struct ContactLink {
|
pub struct ContactLink {
|
||||||
@@ -80,10 +81,20 @@ pub struct PostMeta {
|
|||||||
pub draft: bool,
|
pub draft: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Clone, Copy)]
|
||||||
|
pub struct ImageDim {
|
||||||
|
pub w: u32,
|
||||||
|
pub h: u32,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Clone)]
|
#[derive(Serialize, Clone)]
|
||||||
pub struct CoverImage {
|
pub struct CoverImage {
|
||||||
pub url: String,
|
pub url: String,
|
||||||
pub alt: String,
|
pub alt: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub w: Option<u32>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub h: Option<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Clone)]
|
#[derive(Serialize, Clone)]
|
||||||
@@ -129,6 +140,8 @@ pub struct PostDetail {
|
|||||||
pub prev: Option<PostNeighbor>,
|
pub prev: Option<PostNeighbor>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub next: Option<PostNeighbor>,
|
pub next: Option<PostNeighbor>,
|
||||||
|
#[serde(skip_serializing_if = "HashMap::is_empty")]
|
||||||
|
pub dimensions: HashMap<String, ImageDim>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
|
|||||||
Reference in New Issue
Block a user