added testing
This commit is contained in:
@@ -7,6 +7,7 @@ use tracing::error;
|
||||
|
||||
use crate::models::ErrorResponse;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum AppError {
|
||||
Unauthorized,
|
||||
NotFound(String),
|
||||
@@ -29,7 +30,10 @@ impl IntoResponse for AppError {
|
||||
} else {
|
||||
error!("Internal error: {}", msg);
|
||||
}
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "Internal error".to_string())
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Internal error".to_string(),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -35,16 +35,36 @@ pub async fn update_config(
|
||||
.and_then(|c| serde_json::from_str(&c).ok())
|
||||
.unwrap_or_default();
|
||||
|
||||
if let Some(v) = patch.title { config.title = v; }
|
||||
if let Some(v) = patch.subtitle { config.subtitle = v; }
|
||||
if let Some(v) = patch.welcome_title { config.welcome_title = v; }
|
||||
if let Some(v) = patch.welcome_subtitle { config.welcome_subtitle = v; }
|
||||
if let Some(v) = patch.footer { config.footer = v; }
|
||||
if let Some(v) = patch.favicon { config.favicon = v; }
|
||||
if let Some(v) = patch.theme { config.theme = v; }
|
||||
if let Some(v) = patch.custom_css { config.custom_css = v; }
|
||||
if let Some(v) = patch.contact_intro { config.contact_intro = v; }
|
||||
if let Some(v) = patch.contact_links { config.contact_links = v; }
|
||||
if let Some(v) = patch.title {
|
||||
config.title = v;
|
||||
}
|
||||
if let Some(v) = patch.subtitle {
|
||||
config.subtitle = v;
|
||||
}
|
||||
if let Some(v) = patch.welcome_title {
|
||||
config.welcome_title = v;
|
||||
}
|
||||
if let Some(v) = patch.welcome_subtitle {
|
||||
config.welcome_subtitle = v;
|
||||
}
|
||||
if let Some(v) = patch.footer {
|
||||
config.footer = v;
|
||||
}
|
||||
if let Some(v) = patch.favicon {
|
||||
config.favicon = v;
|
||||
}
|
||||
if let Some(v) = patch.theme {
|
||||
config.theme = v;
|
||||
}
|
||||
if let Some(v) = patch.custom_css {
|
||||
config.custom_css = v;
|
||||
}
|
||||
if let Some(v) = patch.contact_intro {
|
||||
config.contact_intro = v;
|
||||
}
|
||||
if let Some(v) = patch.contact_links {
|
||||
config.contact_links = v;
|
||||
}
|
||||
|
||||
let config_str = serde_json::to_string_pretty(&config).map_err(|e| {
|
||||
error!("Serialization error: {}", e);
|
||||
|
||||
@@ -123,7 +123,10 @@ pub async fn submit_contact(
|
||||
if name.as_ref().is_some_and(|s| s.chars().count() > MAX_NAME) {
|
||||
return Err(AppError::BadRequest("Name is too long.".into()));
|
||||
}
|
||||
if email.as_ref().is_some_and(|s| s.chars().count() > MAX_EMAIL) {
|
||||
if email
|
||||
.as_ref()
|
||||
.is_some_and(|s| s.chars().count() > MAX_EMAIL)
|
||||
{
|
||||
return Err(AppError::BadRequest("Email is too long.".into()));
|
||||
}
|
||||
if subject
|
||||
@@ -160,9 +163,8 @@ pub async fn submit_contact(
|
||||
AppError::Internal("Storage error".into(), Some(e.to_string()))
|
||||
})?;
|
||||
let path = messages_dir.join(format!("{}.json", id));
|
||||
let json = serde_json::to_string_pretty(&msg).map_err(|e| {
|
||||
AppError::Internal("Serialization error".into(), Some(e.to_string()))
|
||||
})?;
|
||||
let json = serde_json::to_string_pretty(&msg)
|
||||
.map_err(|e| AppError::Internal("Serialization error".into(), Some(e.to_string())))?;
|
||||
fs::write(&path, json).await.map_err(|e| {
|
||||
error!("Failed to write message {}: {}", id, e);
|
||||
AppError::Internal("Storage error".into(), Some(e.to_string()))
|
||||
@@ -189,8 +191,7 @@ pub async fn list_messages(
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
error!("Failed to read messages dir: {}", e);
|
||||
return AppError::Internal("Read error".into(), Some(e.to_string()))
|
||||
.into_response();
|
||||
return AppError::Internal("Read error".into(), Some(e.to_string())).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -239,8 +240,8 @@ pub async fn delete_message(
|
||||
if !fs::try_exists(&path).await.unwrap_or(false) {
|
||||
return Err(AppError::NotFound("Message not found.".into()));
|
||||
}
|
||||
fs::remove_file(&path).await.map_err(|e| {
|
||||
AppError::Internal("Delete failed".into(), Some(e.to_string()))
|
||||
})?;
|
||||
fs::remove_file(&path)
|
||||
.await
|
||||
.map_err(|e| AppError::Internal("Delete failed".into(), Some(e.to_string())))?;
|
||||
Ok(Json(ContactResponse { ok: true }))
|
||||
}
|
||||
|
||||
+129
-41
@@ -21,9 +21,8 @@ const WORDS_PER_MINUTE: u32 = 200;
|
||||
|
||||
const MAX_SLUG_LEN: usize = 100;
|
||||
const WINDOWS_RESERVED: &[&str] = &[
|
||||
"CON", "PRN", "AUX", "NUL",
|
||||
"COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
|
||||
"LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
|
||||
"CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8",
|
||||
"COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
|
||||
];
|
||||
|
||||
fn validate_slug(s: &str) -> Result<(), AppError> {
|
||||
@@ -47,9 +46,7 @@ fn validate_slug(s: &str) -> Result<(), AppError> {
|
||||
));
|
||||
}
|
||||
if s.contains("..") {
|
||||
return Err(AppError::BadRequest(
|
||||
"Slug cannot contain '..'".to_string(),
|
||||
));
|
||||
return Err(AppError::BadRequest("Slug cannot contain '..'".to_string()));
|
||||
}
|
||||
for c in s.chars() {
|
||||
if c.is_control() {
|
||||
@@ -66,15 +63,15 @@ fn validate_slug(s: &str) -> Result<(), AppError> {
|
||||
}
|
||||
let stem = s.split('.').next().unwrap_or("").to_ascii_uppercase();
|
||||
if WINDOWS_RESERVED.iter().any(|r| *r == stem) {
|
||||
return Err(AppError::BadRequest(
|
||||
"Slug is a reserved name".to_string(),
|
||||
));
|
||||
return Err(AppError::BadRequest("Slug is a reserved name".to_string()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn split_frontmatter(raw: &str) -> Option<(&str, &str)> {
|
||||
let raw = raw.strip_prefix("---\n").or_else(|| raw.strip_prefix("---\r\n"))?;
|
||||
let raw = raw
|
||||
.strip_prefix("---\n")
|
||||
.or_else(|| raw.strip_prefix("---\r\n"))?;
|
||||
let end_marker = raw.find("\n---\n").or_else(|| raw.find("\r\n---\r\n"))?;
|
||||
let yaml = &raw[..end_marker];
|
||||
let body_start = end_marker
|
||||
@@ -82,7 +79,9 @@ fn split_frontmatter(raw: &str) -> Option<(&str, &str)> {
|
||||
.find("---\n")
|
||||
.or_else(|| raw[end_marker..].find("---\r\n"))?
|
||||
+ "---\n".len();
|
||||
let body = raw[body_start..].trim_start_matches('\n').trim_start_matches('\r');
|
||||
let body = raw[body_start..]
|
||||
.trim_start_matches('\n')
|
||||
.trim_start_matches('\r');
|
||||
Some((yaml, body))
|
||||
}
|
||||
|
||||
@@ -103,12 +102,8 @@ fn parse_post(raw: &str) -> Result<(PostMeta, String), AppError> {
|
||||
}
|
||||
|
||||
fn serialize_post(meta: &PostMeta, body: &str) -> Result<String, AppError> {
|
||||
let yaml = serde_yaml::to_string(meta).map_err(|e| {
|
||||
AppError::Internal(
|
||||
"Serialization error".to_string(),
|
||||
Some(e.to_string()),
|
||||
)
|
||||
})?;
|
||||
let yaml = serde_yaml::to_string(meta)
|
||||
.map_err(|e| AppError::Internal("Serialization error".to_string(), Some(e.to_string())))?;
|
||||
Ok(format!("---\n{}---\n{}", yaml, body))
|
||||
}
|
||||
|
||||
@@ -176,11 +171,7 @@ fn cover_from(images: &[(String, String)]) -> Option<CoverImage> {
|
||||
/// 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('/')
|
||||
{
|
||||
if name.is_empty() || name.contains("..") || name.contains('\\') || name.starts_with('/') {
|
||||
return None;
|
||||
}
|
||||
let path = state.data_dir.join("uploads").join(name);
|
||||
@@ -254,9 +245,7 @@ fn excerpt_from(meta: &PostMeta, body: &str) -> String {
|
||||
return s.trim().to_string();
|
||||
}
|
||||
}
|
||||
let plain = body
|
||||
.replace(['#', '*', '_', '`'], "")
|
||||
.replace('\n', " ");
|
||||
let plain = body.replace(['#', '*', '_', '`'], "").replace('\n', " ");
|
||||
let mut out: String = plain.chars().take(200).collect();
|
||||
if plain.chars().count() > 200 {
|
||||
out.push_str("...");
|
||||
@@ -340,17 +329,16 @@ pub async fn rebuild_posts_cache(state: &AppState) {
|
||||
*state.posts_cache.write().await = posts;
|
||||
}
|
||||
|
||||
async fn write_post_atomic(
|
||||
state: &AppState,
|
||||
slug: &str,
|
||||
contents: &str,
|
||||
) -> Result<(), AppError> {
|
||||
async fn write_post_atomic(state: &AppState, slug: &str, contents: &str) -> Result<(), AppError> {
|
||||
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).await.map_err(|e| {
|
||||
AppError::Internal("Write error".to_string(), Some(e.to_string()))
|
||||
})?;
|
||||
let tmp_path = state
|
||||
.data_dir
|
||||
.join("posts")
|
||||
.join(format!(".{}.md.tmp", slug));
|
||||
fs::write(&tmp_path, contents)
|
||||
.await
|
||||
.map_err(|e| AppError::Internal("Write 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(
|
||||
@@ -407,13 +395,17 @@ pub async fn create_post(
|
||||
let images = extract_images(&payload.content);
|
||||
if images.is_empty() {
|
||||
return Err(AppError::BadRequest(
|
||||
"A gallery entry must include at least one image ( in the markdown body).".to_string(),
|
||||
"A gallery entry must include at least one image ( in the markdown body)."
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let meta = PostMeta {
|
||||
date: payload.date.unwrap_or_else(|| Utc::now().date_naive()),
|
||||
title: payload.title.map(|t| t.trim().to_string()).filter(|t| !t.is_empty()),
|
||||
title: payload
|
||||
.title
|
||||
.map(|t| t.trim().to_string())
|
||||
.filter(|t| !t.is_empty()),
|
||||
summary: payload.summary.filter(|s| !s.trim().is_empty()),
|
||||
tags: payload.tags,
|
||||
draft: payload.draft,
|
||||
@@ -515,7 +507,11 @@ async fn neighbors_from_cache(
|
||||
slug: p.slug.clone(),
|
||||
title: p.title.clone(),
|
||||
};
|
||||
let prev = if i > 0 { Some(to_neighbor(visible[i - 1])) } else { None };
|
||||
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)
|
||||
}
|
||||
@@ -541,10 +537,7 @@ pub async fn get_post(
|
||||
|
||||
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 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 {
|
||||
@@ -563,3 +556,98 @@ pub async fn get_post(
|
||||
dimensions,
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
cover_from, extract_images, parse_post, reading_time, split_frontmatter, validate_slug,
|
||||
};
|
||||
use crate::error::AppError;
|
||||
|
||||
#[test]
|
||||
fn validate_slug_accepts_normal_slugs() {
|
||||
assert!(validate_slug("hello-world").is_ok());
|
||||
assert!(validate_slug("a_b.c-123").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_slug_rejects_traversal_and_bad_chars() {
|
||||
for bad in [
|
||||
"",
|
||||
"../etc",
|
||||
"with/slash",
|
||||
"back\\slash",
|
||||
"ends.",
|
||||
"trailing ",
|
||||
".hidden",
|
||||
] {
|
||||
assert!(
|
||||
matches!(validate_slug(bad), Err(AppError::BadRequest(_))),
|
||||
"expected {bad:?} to be rejected"
|
||||
);
|
||||
}
|
||||
let too_long = "x".repeat(101);
|
||||
assert!(validate_slug(&too_long).is_err());
|
||||
assert!(matches!(validate_slug("CON"), Err(AppError::BadRequest(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_frontmatter_handles_lf_and_crlf() {
|
||||
let (yaml, body) = split_frontmatter("---\ndate: 2026-05-16\n---\nHello").unwrap();
|
||||
assert_eq!(yaml, "date: 2026-05-16");
|
||||
assert_eq!(body, "Hello");
|
||||
|
||||
let (y2, b2) = split_frontmatter("---\r\ndate: 2026-05-16\r\n---\r\nHi").unwrap();
|
||||
assert!(y2.contains("date: 2026-05-16"));
|
||||
assert_eq!(b2, "Hi");
|
||||
|
||||
assert!(split_frontmatter("no frontmatter here").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_post_reads_meta_and_body() {
|
||||
let raw = "---\ndate: 2026-05-16\ntitle: Hello\ndraft: true\n---\nBody text";
|
||||
let (meta, body) = parse_post(raw).unwrap();
|
||||
assert_eq!(meta.title.as_deref(), Some("Hello"));
|
||||
assert!(meta.draft);
|
||||
assert_eq!(meta.date.to_string(), "2026-05-16");
|
||||
assert_eq!(body, "Body text");
|
||||
|
||||
assert!(parse_post("no frontmatter").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reading_time_rounds_up_by_wpm() {
|
||||
assert_eq!(reading_time(""), 0);
|
||||
assert_eq!(reading_time("one"), 1);
|
||||
assert_eq!(reading_time(&"word ".repeat(200)), 1);
|
||||
assert_eq!(reading_time(&"word ".repeat(201)), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_images_skips_fences_and_strips_titles() {
|
||||
let md = "intro\n\
|
||||
\n\
|
||||
```\n\
|
||||
\n\
|
||||
```\n\
|
||||
";
|
||||
let imgs = extract_images(md);
|
||||
assert_eq!(
|
||||
imgs,
|
||||
vec![
|
||||
("a".to_string(), "/u/one.png".to_string()),
|
||||
("c".to_string(), "/u/two.png".to_string()),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cover_from_takes_first_or_none() {
|
||||
assert!(cover_from(&[]).is_none());
|
||||
let imgs = vec![("alt".to_string(), "/u/first.png".to_string())];
|
||||
let cover = cover_from(&imgs).unwrap();
|
||||
assert_eq!(cover.url, "/u/first.png");
|
||||
assert_eq!(cover.alt, "alt");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,10 +23,8 @@ pub struct UploadQuery {
|
||||
/// Allowed upload extensions. SVG, HTML, JS, executables intentionally absent —
|
||||
/// /uploads/* is served as-is, so any active content there is XSS waiting to happen.
|
||||
const ALLOWED_EXTS: &[&str] = &[
|
||||
"jpg", "jpeg", "png", "webp", "gif", "avif",
|
||||
"pdf", "txt", "md",
|
||||
"mp3", "wav", "ogg",
|
||||
"mp4", "webm", "mov",
|
||||
"jpg", "jpeg", "png", "webp", "gif", "avif", "pdf", "txt", "md", "mp3", "wav", "ogg", "mp4",
|
||||
"webm", "mov",
|
||||
];
|
||||
|
||||
fn validate_filename(name: &str) -> Result<(), AppError> {
|
||||
@@ -77,9 +75,9 @@ pub async fn delete_upload(
|
||||
let uploads_dir = state.data_dir.join("uploads");
|
||||
let file_path = uploads_dir.join(&filename);
|
||||
|
||||
let canonical_dir = fs::canonicalize(&uploads_dir).await.map_err(|e| {
|
||||
AppError::Internal("Path resolution".to_string(), Some(e.to_string()))
|
||||
})?;
|
||||
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) = fs::canonicalize(&file_path).await {
|
||||
if !canonical_file.starts_with(&canonical_dir) {
|
||||
warn!("Refused delete outside uploads dir: {}", filename);
|
||||
@@ -209,9 +207,9 @@ pub async fn upload_file(
|
||||
};
|
||||
|
||||
// Final containment check.
|
||||
let canonical_dir = fs::canonicalize(&uploads_dir).await.map_err(|e| {
|
||||
AppError::Internal("Path resolution".to_string(), Some(e.to_string()))
|
||||
})?;
|
||||
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 = fs::canonicalize(parent).await.map_err(|e| {
|
||||
AppError::Internal("Path resolution".to_string(), Some(e.to_string()))
|
||||
|
||||
+8
-8
@@ -47,9 +47,7 @@ async fn main() {
|
||||
.filter(|t| !t.trim().is_empty())
|
||||
.expect("ADMIN_TOKEN must be set to a non-empty value");
|
||||
if admin_token.len() < 16 {
|
||||
warn!(
|
||||
"ADMIN_TOKEN is shorter than 16 characters. Use a long random string in production."
|
||||
);
|
||||
warn!("ADMIN_TOKEN is shorter than 16 characters. Use a long random string in production.");
|
||||
}
|
||||
let data_dir_str = env::var("DATA_DIR").unwrap_or_else(|_| "../data".to_string());
|
||||
let data_dir = PathBuf::from(data_dir_str);
|
||||
@@ -79,7 +77,10 @@ async fn main() {
|
||||
});
|
||||
|
||||
handlers::posts::rebuild_posts_cache(&state).await;
|
||||
info!("Posts cache primed with {} entries", state.posts_cache.read().await.len());
|
||||
info!(
|
||||
"Posts cache primed with {} entries",
|
||||
state.posts_cache.read().await.len()
|
||||
);
|
||||
|
||||
spawn_rate_limit_reaper(state.clone());
|
||||
|
||||
@@ -89,8 +90,8 @@ async fn main() {
|
||||
// server-to-server and not subject to CORS.
|
||||
let cors = match env::var("FRONTEND_ORIGIN").ok().filter(|s| !s.is_empty()) {
|
||||
Some(origin) => {
|
||||
let value = HeaderValue::from_str(&origin)
|
||||
.expect("FRONTEND_ORIGIN must be a valid origin URL");
|
||||
let value =
|
||||
HeaderValue::from_str(&origin).expect("FRONTEND_ORIGIN must be a valid origin URL");
|
||||
CorsLayer::new()
|
||||
.allow_origin(AllowOrigin::exact(value))
|
||||
.allow_methods([
|
||||
@@ -132,8 +133,7 @@ async fn main() {
|
||||
)
|
||||
.route(
|
||||
"/api/upload",
|
||||
post(handlers::upload::upload_file)
|
||||
.layer(DefaultBodyLimit::max(UPLOAD_BODY_LIMIT)),
|
||||
post(handlers::upload::upload_file).layer(DefaultBodyLimit::max(UPLOAD_BODY_LIMIT)),
|
||||
)
|
||||
.route("/api/contact", post(handlers::contact::submit_contact))
|
||||
.route("/api/messages", get(handlers::contact::list_messages))
|
||||
|
||||
@@ -31,9 +31,8 @@ impl Default for SiteConfig {
|
||||
title: "Ela's Atelier".to_string(),
|
||||
subtitle: "Works on paper, canvas, and elsewhere".to_string(),
|
||||
welcome_title: "Works on view".to_string(),
|
||||
welcome_subtitle:
|
||||
"An ongoing arrangement of pieces, sketches, and stray observations."
|
||||
.to_string(),
|
||||
welcome_subtitle: "An ongoing arrangement of pieces, sketches, and stray observations."
|
||||
.to_string(),
|
||||
footer: "Hand-arranged with care".to_string(),
|
||||
favicon: "/favicon.svg".to_string(),
|
||||
theme: "salon".to_string(),
|
||||
|
||||
Reference in New Issue
Block a user