init elas atelier #1

Merged
nvrl merged 82 commits from ela into main 2026-05-18 13:55:42 +02:00
6 changed files with 301 additions and 36 deletions
Showing only changes of commit 2a6a4e6483 - Show all commits
+115
View File
@@ -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
View File
@@ -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"] }
+138 -26
View File
@@ -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 meta.draft && !admin { };
if p.info.draft && !admin {
return Err(AppError::NotFound("Post not found".to_string())); return Err(AppError::NotFound("Post not found".to_string()));
} }
(p.info.clone(), p.body.clone())
};
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,
})) }))
} }
+10 -3
View File
@@ -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
View File
@@ -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);
+13
View File
@@ -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)]