use std::fs; use std::path::{Path, PathBuf}; use std::thread; use axum::Router; use axum::extract::{Path as AxumPath, Query, State}; use axum::http::{StatusCode, Uri, header}; use axum::response::{Html, IntoResponse, Response}; use axum::routing::get; use serde::Deserialize; use tokio::sync::oneshot; use crate::db::{Database, queries}; use crate::engine::generation::PublishedPostSource; use crate::engine::{EngineError, EngineResult}; use crate::model::{Post, PostStatus, pico_stylesheet_href}; use crate::render::{PostLanguageVariant, build_preview_response, select_post_language_variant}; use crate::util::frontmatter::{read_post_file, read_translation_file}; pub const PREVIEW_HOST: &str = "127.0.0.1"; pub const PREVIEW_PORT: u16 = 4123; #[derive(Debug)] pub struct PreviewServerHandle { shutdown: Option>, thread: Option>, } impl PreviewServerHandle { pub fn stop(mut self) -> EngineResult<()> { if let Some(shutdown) = self.shutdown.take() { let _ = shutdown.send(()); } if let Some(thread) = self.thread.take() { let _ = thread.join(); } Ok(()) } } impl Drop for PreviewServerHandle { fn drop(&mut self) { if let Some(shutdown) = self.shutdown.take() { let _ = shutdown.send(()); } if let Some(thread) = self.thread.take() { let _ = thread.join(); } } } #[derive(Debug, Clone)] struct PreviewServerState { db_path: PathBuf, data_dir: PathBuf, project_id: String, } #[derive(Debug, Deserialize, Default)] struct DraftPreviewQuery { language: Option, theme: Option, mode: Option, } #[derive(Debug, Clone, Deserialize, Default)] struct StylePreviewQuery { theme: Option, mode: Option, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum PreviewFeedKind { Rss, Atom, } pub fn start_preview_server( db_path: PathBuf, data_dir: PathBuf, project_id: String, ) -> EngineResult { let state = PreviewServerState { db_path, data_dir, project_id, }; let listener = std::net::TcpListener::bind((PREVIEW_HOST, PREVIEW_PORT)).map_err(|error| { if error.kind() == std::io::ErrorKind::AddrInUse { EngineError::Conflict(format!( "preview server already running on {PREVIEW_HOST}:{PREVIEW_PORT}" )) } else { EngineError::Io(error) } })?; listener.set_nonblocking(true)?; let (shutdown_tx, shutdown_rx) = oneshot::channel(); let thread = thread::spawn(move || { let runtime = tokio::runtime::Builder::new_current_thread() .enable_all() .build() .expect("preview runtime"); runtime.block_on(async move { let listener = tokio::net::TcpListener::from_std(listener).expect("preview listener"); let app = Router::new() .route("/__draft/{post_id}", get(handle_draft_preview)) .route("/__style-preview", get(handle_style_preview)) .route("/", get(handle_preview_request)) .route("/{*path}", get(handle_preview_request)) .with_state(state); let server = axum::serve(listener, app).with_graceful_shutdown(async { let _ = shutdown_rx.await; }); let _ = server.await; }); }); Ok(PreviewServerHandle { shutdown: Some(shutdown_tx), thread: Some(thread), }) } async fn handle_preview_request( State(state): State, Query(query): Query, uri: Uri, ) -> Response { match render_preview_response(&state, uri.path(), None, Some(&query)) { Ok(response) => response, Err(error) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()), } } async fn handle_draft_preview( State(state): State, AxumPath(post_id): AxumPath, Query(query): Query, ) -> Response { let style = StylePreviewQuery { theme: query.theme.clone(), mode: query.mode.clone(), }; match render_preview_response( &state, &format!("/__draft/{post_id}"), query.language.as_deref(), Some(&style), ) { Ok(response) => response, Err(error) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()), } } async fn handle_style_preview( State(state): State, Query(query): Query, ) -> Response { let metadata = match crate::engine::meta::read_project_json(&state.data_dir) { Ok(metadata) => metadata, Err(error) => { return error_response(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()); } }; let theme = query.theme.as_deref().unwrap_or("default"); let stylesheet = pico_stylesheet_href(Some(theme)); let mode = match query.mode.as_deref().map(str::trim) { Some("light") => Some("light"), Some("dark") => Some("dark"), _ => None, }; let mode_attributes = mode .map(|mode| format!(" data-theme=\"{mode}\" data-mode=\"{mode}\"")) .unwrap_or_default(); let project_name = escape_html(&metadata.name); let language = escape_html(metadata.main_language.as_deref().unwrap_or("en")); let description = escape_html(metadata.description.as_deref().unwrap_or(&metadata.name)); let html = format!( "{project_name}

{project_name}

{description}

" ); Html(html).into_response() } fn render_preview_response( state: &PreviewServerState, path: &str, requested_language: Option<&str>, style: Option<&StylePreviewQuery>, ) -> EngineResult { if let Some(post_id) = path.strip_prefix("/__draft/") { let html = apply_preview_style( render_draft_preview(state, post_id, requested_language)?, style, ); return Ok(Html(html).into_response()); } if let Some(file_response) = serve_project_file(&state.data_dir, path)? { return Ok(file_response); } let metadata = crate::engine::meta::read_project_json(&state.data_dir)?; let db = Database::open(&state.db_path)?; let preview_posts = collect_preview_posts(state)?; if path == "/calendar.json" { let posts = preview_posts .iter() .map(|source| source.post.clone()) .collect::>(); let json = crate::render::build_calendar_json(&posts) .map_err(|error| EngineError::Parse(error.to_string()))?; return Ok(( StatusCode::OK, [(header::CONTENT_TYPE, "application/json; charset=utf-8")], json, ) .into_response()); } if let Some((language, kind)) = preview_feed_request(path, &metadata) { let main_language = metadata.main_language.as_deref().unwrap_or("en"); let localized_posts = if language.eq_ignore_ascii_case(main_language) { preview_posts.clone() } else { localized_preview_posts( db.conn(), &state.data_dir, &preview_posts, &language, main_language, )? .into_iter() .filter(|source| { source .post .language .as_deref() .is_some_and(|post_language| post_language.eq_ignore_ascii_case(&language)) }) .collect() }; let (content_type, xml) = match kind { PreviewFeedKind::Rss => ( "application/rss+xml; charset=utf-8", crate::engine::generation::build_rss_xml(&metadata, &localized_posts, &language), ), PreviewFeedKind::Atom => ( "application/atom+xml; charset=utf-8", crate::engine::generation::build_atom_xml(&metadata, &localized_posts, &language), ), }; return Ok((StatusCode::OK, [(header::CONTENT_TYPE, content_type)], xml).into_response()); } let input_posts = preview_posts .iter() .map(|source| (source.post.clone(), source.body_markdown.clone())) .collect::>(); let response = build_preview_response( db.conn(), &state.data_dir, &state.project_id, &metadata, &input_posts, path, ) .map_err(|error| EngineError::Parse(error.to_string()))?; let status = StatusCode::from_u16(response.status_code).unwrap_or(StatusCode::OK); Ok((status, Html(apply_preview_style(response.html, style))).into_response()) } fn apply_preview_style(html: String, style: Option<&StylePreviewQuery>) -> String { let Some(style) = style else { return html; }; let mut styled = html; if let Some(theme) = style.theme.as_deref().filter(|theme| !theme.is_empty()) { styled = override_pico_stylesheet(&styled, &pico_stylesheet_href(Some(theme))); } if let Some(mode) = style.mode.as_deref() { let mode = match mode.trim() { "light" => Some("light"), "dark" => Some("dark"), _ => None, }; styled = override_html_attribute(&styled, "data-theme", mode); styled = override_html_attribute(&styled, "data-mode", mode); } styled } fn override_pico_stylesheet(html: &str, href: &str) -> String { let Some(start) = html.find("/assets/pico") else { return html.to_string(); }; let Some(end_offset) = html[start..].find(".min.css") else { return html.to_string(); }; let end = start + end_offset + ".min.css".len(); format!("{}{}{}", &html[..start], href, &html[end..]) } fn override_html_attribute(html: &str, attribute: &str, value: Option<&str>) -> String { let Some(html_start) = html.find("') else { return html.to_string(); }; let tag_end = html_start + tag_end_offset; let attribute_start_pattern = format!(" {attribute}=\""); let existing = html[html_start..tag_end] .find(&attribute_start_pattern) .and_then(|offset| { let start = html_start + offset; html[start + attribute_start_pattern.len()..tag_end] .find('"') .map(|end_offset| { ( start, start + attribute_start_pattern.len() + end_offset + 1, ) }) }); match (existing, value) { (Some((start, end)), Some(value)) => format!( "{} {attribute}=\"{}\"{}", &html[..start], value, &html[end..] ), (Some((start, end)), None) => format!("{}{}", &html[..start], &html[end..]), (None, Some(value)) => format!( "{} {attribute}=\"{}\"{}", &html[..tag_end], value, &html[tag_end..] ), (None, None) => html.to_string(), } } fn escape_html(value: &str) -> String { value .replace('&', "&") .replace('<', "<") .replace('>', ">") .replace('"', """) .replace('\'', "'") } fn render_draft_preview( state: &PreviewServerState, post_id: &str, requested_language: Option<&str>, ) -> EngineResult { let db = Database::open(&state.db_path)?; let metadata = crate::engine::meta::read_project_json(&state.data_dir)?; let post = queries::post::get_post_by_id(db.conn(), post_id)?; let canonical_language = post .language .as_deref() .unwrap_or_else(|| metadata.main_language.as_deref().unwrap_or("en")); let target_language = requested_language.unwrap_or(canonical_language); if target_language != canonical_language && let Ok(translation) = queries::post_translation::get_post_translation_by_post_and_language( db.conn(), post_id, target_language, ) { let mut translated_post = post.clone(); translated_post.title = translation.title.clone(); translated_post.excerpt = translation.excerpt.clone(); translated_post.language = Some(translation.language.clone()); translated_post.status = translation.status.clone(); translated_post.file_path = translation.file_path.clone(); translated_post.published_at = translation.published_at.or(post.published_at); let body = load_translation_body(&state.data_dir, &translation)?; let response = build_preview_response( db.conn(), &state.data_dir, &state.project_id, &metadata, &[(translated_post, body)], &crate::render::build_canonical_post_path( &post, target_language, metadata.main_language.as_deref().unwrap_or("en"), ), ) .map_err(|error| EngineError::Parse(error.to_string()))?; return Ok(response.html); } let body = load_post_body(&state.data_dir, &post)?; let response = build_preview_response( db.conn(), &state.data_dir, &state.project_id, &metadata, &[(post.clone(), body)], &crate::render::build_canonical_post_path( &post, canonical_language, metadata.main_language.as_deref().unwrap_or("en"), ), ) .map_err(|error| EngineError::Parse(error.to_string()))?; Ok(response.html) } fn collect_preview_posts(state: &PreviewServerState) -> EngineResult> { let db = Database::open(&state.db_path)?; let posts = queries::post::list_posts_by_project(db.conn(), &state.project_id)?; let mut preview_posts = Vec::new(); for post in posts .into_iter() .filter(|post| matches!(post.status, PostStatus::Draft | PostStatus::Published)) { preview_posts.push(PublishedPostSource { body_markdown: load_post_body(&state.data_dir, &post)?, post, }); } preview_posts.sort_by(|left, right| { right .post .created_at .cmp(&left.post.created_at) .then_with(|| right.post.published_at.cmp(&left.post.published_at)) .then_with(|| left.post.slug.cmp(&right.post.slug)) }); Ok(preview_posts) } fn preview_feed_request( path: &str, metadata: &crate::model::ProjectMetadata, ) -> Option<(String, PreviewFeedKind)> { let segments = path .trim_matches('/') .split('/') .filter(|segment| !segment.is_empty()) .collect::>(); let (language, filename) = match segments.as_slice() { [filename] => (metadata.main_language.as_deref().unwrap_or("en"), *filename), [language, filename] if metadata .blog_languages .iter() .any(|candidate| candidate.eq_ignore_ascii_case(language)) && !language .eq_ignore_ascii_case(metadata.main_language.as_deref().unwrap_or("en")) => { (*language, *filename) } _ => return None, }; let kind = match filename { "rss.xml" => PreviewFeedKind::Rss, "atom.xml" => PreviewFeedKind::Atom, _ => return None, }; Some((language.to_string(), kind)) } fn localized_preview_posts( conn: &crate::db::DbConnection, data_dir: &Path, posts: &[PublishedPostSource], language: &str, main_language: &str, ) -> EngineResult> { let mut localized = Vec::new(); for source in posts { let translation = queries::post_translation::get_post_translation_by_post_and_language( conn, &source.post.id, language, ) .ok() .filter(|translation| { (translation.status == PostStatus::Draft && translation.content.is_some()) || (!translation.file_path.trim().is_empty() && data_dir .join(translation.file_path.trim_start_matches('/')) .is_file()) }); match select_post_language_variant( &source.post, language, main_language, translation.is_some(), ) { Some(PostLanguageVariant::Base) => localized.push(source.clone()), Some(PostLanguageVariant::Translation) => { let Some(translation) = translation else { continue; }; let mut translated_post = source.post.clone(); translated_post.id = translation.id.clone(); translated_post.title = translation.title.clone(); translated_post.excerpt = translation.excerpt.clone(); translated_post.language = Some(translation.language.clone()); translated_post.status = translation.status.clone(); translated_post.file_path = translation.file_path.clone(); translated_post.updated_at = translation.updated_at; translated_post.published_at = translation.published_at.or(source.post.published_at); localized.push(PublishedPostSource { post: translated_post, body_markdown: load_translation_body(data_dir, &translation)?, }); } None => {} } } Ok(localized) } fn load_post_body(data_dir: &Path, post: &Post) -> EngineResult { if post.status == PostStatus::Draft && let Some(content) = &post.content { return Ok(content.clone()); } if post.file_path.trim().is_empty() { return Ok(String::new()); } load_markdown_body(data_dir, &post.file_path, false) } fn load_translation_body( data_dir: &Path, translation: &crate::model::PostTranslation, ) -> EngineResult { if translation.status == PostStatus::Draft && let Some(content) = &translation.content { return Ok(content.clone()); } if translation.file_path.trim().is_empty() { return Ok(String::new()); } load_markdown_body(data_dir, &translation.file_path, true) } fn load_markdown_body( data_dir: &Path, relative_path: &str, translation: bool, ) -> EngineResult { let raw = fs::read_to_string(data_dir.join(relative_path.trim_start_matches('/')))?; let body = if translation { read_translation_file(&raw).map(|(_, body)| body) } else { read_post_file(&raw).map(|(_, body)| body) } .map_err(EngineError::Parse)?; Ok(body) } fn serve_project_file(data_dir: &Path, path: &str) -> EngineResult> { if let Some(response) = serve_scoped_file(data_dir, path, "/media/", "media")? { return Ok(Some(response)); } if let Some(response) = serve_scoped_file(data_dir, path, "/assets/", "assets")? { return Ok(Some(response)); } if let Some(response) = serve_scoped_file(data_dir, path, "/images/", "images")? { return Ok(Some(response)); } if let Some(response) = serve_scoped_file(&data_dir.join("html"), path, "/pagefind/", "pagefind")? { return Ok(Some(response)); } Ok(None) } fn serve_scoped_file( data_dir: &Path, path: &str, prefix: &str, scope_dir: &str, ) -> EngineResult> { let Some(relative) = path.strip_prefix(prefix) else { return Ok(None); }; let scope_root = data_dir.join(scope_dir); let candidate = scope_root.join(relative); if !candidate.exists() || !candidate.is_file() { let bundled_path = format!("{scope_dir}/{relative}"); if let Some(bytes) = crate::engine::site_assets::bundled_site_asset(&bundled_path) { let mime = guess_content_type(Path::new(&bundled_path)); return Ok(Some( (StatusCode::OK, [(header::CONTENT_TYPE, mime)], bytes).into_response(), )); } return Ok(Some(error_response( StatusCode::NOT_FOUND, "preview asset not found", ))); } let canonical_candidate = candidate.canonicalize()?; let canonical_scope_root = scope_root.canonicalize().unwrap_or(scope_root); if !canonical_candidate.starts_with(&canonical_scope_root) { return Ok(Some(error_response( StatusCode::NOT_FOUND, "preview asset not found", ))); } let bytes = fs::read(&canonical_candidate)?; let mime = guess_content_type(&canonical_candidate); Ok(Some( (StatusCode::OK, [(header::CONTENT_TYPE, mime)], bytes).into_response(), )) } fn guess_content_type(path: &Path) -> &'static str { match path .extension() .and_then(|ext| ext.to_str()) .unwrap_or_default() { "css" => "text/css; charset=utf-8", "js" => "application/javascript; charset=utf-8", "json" => "application/json; charset=utf-8", "svg" => "image/svg+xml", "png" => "image/png", "jpg" | "jpeg" => "image/jpeg", "gif" => "image/gif", "webp" => "image/webp", _ => "application/octet-stream", } } fn error_response(status: StatusCode, message: &str) -> Response { ( status, [(header::CONTENT_TYPE, "text/plain; charset=utf-8")], message.to_string(), ) .into_response() } #[cfg(test)] mod tests { use super::*; use crate::db::queries; use crate::engine::meta; use crate::model::{Post, PostStatus, PostTranslation, Project, ProjectMetadata}; use std::sync::{Mutex, OnceLock}; fn preview_port_guard() -> &'static Mutex<()> { static GUARD: OnceLock> = OnceLock::new(); GUARD.get_or_init(|| Mutex::new(())) } fn setup_preview_fixture() -> (tempfile::TempDir, Database) { let dir = tempfile::tempdir().unwrap(); std::fs::create_dir_all(dir.path().join("meta")).unwrap(); std::fs::create_dir_all(dir.path().join("posts/2024/03")).unwrap(); std::fs::create_dir_all(dir.path().join("media")).unwrap(); meta::write_project_json(dir.path(), &make_metadata()).unwrap(); let db_path = dir.path().join("bds.db"); let db = Database::open(&db_path).unwrap(); db.migrate().unwrap(); queries::project::insert_project( db.conn(), &Project { id: "project-1".into(), name: "Blog".into(), slug: "blog".into(), description: None, data_path: Some(dir.path().to_string_lossy().to_string()), is_active: true, created_at: 1_710_000_000_000, updated_at: 1_710_000_000_000, }, ) .unwrap(); (dir, db) } fn response_text(response: Response) -> String { let runtime = tokio::runtime::Builder::new_current_thread() .enable_all() .build() .unwrap(); let bytes = runtime .block_on(axum::body::to_bytes(response.into_body(), usize::MAX)) .unwrap(); String::from_utf8(bytes.to_vec()).unwrap() } fn make_metadata() -> ProjectMetadata { ProjectMetadata { name: "Blog".into(), description: None, public_url: Some("https://example.com".into()), main_language: Some("en".into()), default_author: None, max_posts_per_page: 50, image_import_concurrency: 4, blogmark_category: None, pico_theme: None, semantic_similarity_enabled: false, blog_languages: vec!["en".into(), "de".into()], } } fn make_post() -> PublishedPostSource { PublishedPostSource { post: Post { id: "post-1".into(), project_id: "project-1".into(), title: "Hello".into(), slug: "hello".into(), excerpt: None, content: Some("Body".into()), status: PostStatus::Published, author: None, language: Some("en".into()), do_not_translate: false, template_slug: None, file_path: String::new(), checksum: None, tags: vec![], categories: vec![], published_title: None, published_content: None, published_tags: None, published_categories: None, published_excerpt: None, created_at: 1_710_000_000_000, updated_at: 1_710_000_000_000, published_at: Some(1_710_000_000_000), }, body_markdown: "Hello **world**".into(), } } fn make_draft_post() -> Post { Post { id: "post-1".into(), project_id: "project-1".into(), title: "Hello".into(), slug: "hello".into(), excerpt: Some("Excerpt".into()), content: Some("Draft **body**".into()), status: PostStatus::Draft, author: None, language: Some("en".into()), do_not_translate: false, template_slug: None, file_path: "posts/2024/03/hello.md".into(), checksum: None, tags: vec![], categories: vec![], published_title: None, published_content: None, published_tags: None, published_categories: None, published_excerpt: None, created_at: 1_710_000_000_000, updated_at: 1_710_000_000_000, published_at: Some(1_710_000_000_000), } } #[test] fn root_preview_renders_index_page() { let db = Database::open_in_memory().unwrap(); let html = build_preview_response( db.conn(), Path::new("."), "project-1", &make_metadata(), &[(make_post().post, make_post().body_markdown)], "/", ) .unwrap() .html; assert!(html.contains("post-list")); } #[test] fn preview_renders_single_post_for_canonical_path() { let db = Database::open_in_memory().unwrap(); let source = make_post(); let html = build_preview_response( db.conn(), Path::new("."), "project-1", &make_metadata(), &[(source.post, source.body_markdown)], "/2024/03/09/hello", ) .unwrap() .html; assert!(html.contains("

Hello

")); assert!(html.contains("world")); } #[test] fn preview_renders_page_category_post_at_flat_path() { let db = Database::open_in_memory().unwrap(); let mut source = make_post(); source.post.categories = vec!["page".into()]; let response = build_preview_response( db.conn(), Path::new("."), "project-1", &make_metadata(), &[(source.post, source.body_markdown)], "/hello", ) .unwrap(); assert_eq!(response.status_code, 200); assert!(response.html.contains("

Hello

")); } #[test] fn preview_renders_language_prefixed_single_post() { let dir = tempfile::tempdir().unwrap(); std::fs::create_dir_all(dir.path().join("meta")).unwrap(); std::fs::write( dir.path().join("posts/2024/03/hello.de.md"), "---\ntranslationFor: post-1\nlanguage: de\ntitle: Hallo\n---\nHallo **welt**", ) .ok(); let db = Database::open_in_memory().unwrap(); let source = make_post(); let html = build_preview_response( db.conn(), dir.path(), "project-1", &make_metadata(), &[(source.post, source.body_markdown)], "/de/2024/03/09/hello", ) .unwrap() .html; assert!(html.contains("lang=\"de\"")); } #[test] fn preview_server_serves_draft_post_from_localhost() { let _guard = preview_port_guard().lock().unwrap(); let (dir, db) = setup_preview_fixture(); queries::post::insert_post(db.conn(), &make_draft_post()).unwrap(); let server = start_preview_server( dir.path().join("bds.db"), dir.path().to_path_buf(), "project-1".into(), ) .unwrap(); let client = reqwest::blocking::Client::new(); let mut body = None; for _ in 0..20 { if let Ok(response) = client .get(format!( "http://{PREVIEW_HOST}:{PREVIEW_PORT}/__draft/post-1" )) .send() && response.status().is_success() { body = Some(response.text().unwrap()); break; } std::thread::sleep(std::time::Duration::from_millis(50)); } server.stop().unwrap(); let body = body.expect("draft preview response"); assert!(body.contains("

Hello

")); assert!(body.contains("body")); } #[test] fn canonical_preview_routes_overlay_drafts_and_use_files_for_published_posts() { let (dir, db) = setup_preview_fixture(); let draft = make_draft_post(); queries::post::insert_post(db.conn(), &draft).unwrap(); queries::post_translation::insert_post_translation( db.conn(), &PostTranslation { id: "translation-1".into(), project_id: "project-1".into(), translation_for: draft.id.clone(), language: "de".into(), title: "Hallo".into(), excerpt: None, content: Some("Deutscher **Entwurf**".into()), status: PostStatus::Draft, file_path: String::new(), checksum: None, created_at: draft.created_at, updated_at: draft.updated_at, published_at: None, }, ) .unwrap(); let mut published = make_draft_post(); published.id = "post-2".into(); published.title = "Published from file".into(); published.slug = "published-from-file".into(); published.status = PostStatus::Published; published.content = Some("Stale database body".into()); published.file_path = "posts/2024/03/published-from-file.md".into(); queries::post::insert_post(db.conn(), &published).unwrap(); std::fs::write( dir.path().join(&published.file_path), crate::util::frontmatter::write_post_file(&published, "Filesystem **body**"), ) .unwrap(); let state = PreviewServerState { db_path: dir.path().join("bds.db"), data_dir: dir.path().to_path_buf(), project_id: "project-1".into(), }; let draft_path = crate::render::build_canonical_post_path(&draft, "en", "en"); let published_path = crate::render::build_canonical_post_path(&published, "en", "en"); let translated_draft_path = crate::render::build_canonical_post_path(&draft, "de", "en"); let draft_response = render_preview_response(&state, &draft_path, None, None).unwrap(); let published_response = render_preview_response(&state, &published_path, None, None).unwrap(); let index_response = render_preview_response(&state, "/", None, None).unwrap(); let translated_draft_response = render_preview_response(&state, &translated_draft_path, None, None).unwrap(); let calendar_response = render_preview_response(&state, "/calendar.json", None, None).unwrap(); let rss_response = render_preview_response(&state, "/rss.xml", None, None).unwrap(); let translated_atom_response = render_preview_response(&state, "/de/atom.xml", None, None).unwrap(); let draft_html = response_text(draft_response); let published_html = response_text(published_response); let index_html = response_text(index_response); let translated_draft_html = response_text(translated_draft_response); let calendar_json = response_text(calendar_response); let rss_xml = response_text(rss_response); let translated_atom_xml = response_text(translated_atom_response); assert!(draft_html.contains("Draft body")); assert!(published_html.contains("Filesystem body")); assert!(!published_html.contains("Stale database body")); assert!(index_html.contains("Hello")); assert!(index_html.contains("Published from file")); assert!(translated_draft_html.contains("Deutscher Entwurf")); assert!(calendar_json.contains("2024")); assert!(rss_xml.contains("Hello")); assert!(rss_xml.contains("Published from file")); assert!(translated_atom_xml.contains("Hallo")); } #[test] fn preview_server_blocks_media_path_traversal() { let _guard = preview_port_guard().lock().unwrap(); let (dir, _db) = setup_preview_fixture(); std::fs::write(dir.path().join("outside.txt"), "nope").unwrap(); std::fs::write(dir.path().join("media/ok.txt"), "ok").unwrap(); let server = start_preview_server( dir.path().join("bds.db"), dir.path().to_path_buf(), "project-1".into(), ) .unwrap(); let client = reqwest::blocking::Client::new(); let response = client .get(format!( "http://{PREVIEW_HOST}:{PREVIEW_PORT}/media/../outside.txt" )) .send() .unwrap(); server.stop().unwrap(); assert_eq!(response.status(), StatusCode::NOT_FOUND); } #[test] fn preview_server_serves_media_and_asset_files() { let _guard = preview_port_guard().lock().unwrap(); let (dir, _db) = setup_preview_fixture(); std::fs::create_dir_all(dir.path().join("assets")).unwrap(); std::fs::create_dir_all(dir.path().join("images")).unwrap(); std::fs::create_dir_all(dir.path().join("html/pagefind")).unwrap(); std::fs::write(dir.path().join("media/ok.txt"), "ok").unwrap(); std::fs::write(dir.path().join("assets/site.css"), "body { color: red; }").unwrap(); std::fs::write(dir.path().join("images/custom.svg"), "").unwrap(); std::fs::write( dir.path().join("html/pagefind/pagefind-ui.js"), "window.pagefind = true;", ) .unwrap(); let server = start_preview_server( dir.path().join("bds.db"), dir.path().to_path_buf(), "project-1".into(), ) .unwrap(); let client = reqwest::blocking::Client::new(); let media = client .get(format!("http://{PREVIEW_HOST}:{PREVIEW_PORT}/media/ok.txt")) .send() .unwrap(); let asset = client .get(format!( "http://{PREVIEW_HOST}:{PREVIEW_PORT}/assets/site.css" )) .send() .unwrap(); let media_body = media.text().unwrap(); let asset_body = asset.text().unwrap(); let image_body = client .get(format!( "http://{PREVIEW_HOST}:{PREVIEW_PORT}/images/custom.svg" )) .send() .unwrap() .text() .unwrap(); let pagefind_body = client .get(format!( "http://{PREVIEW_HOST}:{PREVIEW_PORT}/pagefind/pagefind-ui.js" )) .send() .unwrap() .text() .unwrap(); server.stop().unwrap(); assert_eq!(media_body, "ok"); assert!(asset_body.contains("color: red")); assert!(image_body.contains("")); assert!(pagefind_body.contains("window.pagefind")); } #[test] fn preview_server_serves_style_preview() { let _guard = preview_port_guard().lock().unwrap(); let (dir, _db) = setup_preview_fixture(); let server = start_preview_server( dir.path().join("bds.db"), dir.path().to_path_buf(), "project-1".into(), ) .unwrap(); let client = reqwest::blocking::Client::new(); let response = client .get(format!( "http://{PREVIEW_HOST}:{PREVIEW_PORT}/__style-preview?theme=amber&mode=dark" )) .send() .unwrap(); let body = response.text().unwrap(); let stylesheet = client .get(format!( "http://{PREVIEW_HOST}:{PREVIEW_PORT}/assets/pico.amber.min.css" )) .send() .unwrap(); assert!(stylesheet.status().is_success()); let stylesheet = stylesheet.text().unwrap(); server.stop().unwrap(); assert!(body.contains("
")); assert!(body.contains("Blog")); assert!(body.contains("href=\"/assets/pico.amber.min.css\"")); assert!(body.contains("data-theme=\"dark\"")); assert!(body.contains("data-mode=\"dark\"")); assert!(!body.contains("data-theme=\"amber\"")); assert!(stylesheet.contains("--pico-primary-background:#ffbf00")); } #[test] fn preview_server_applies_theme_query_to_rendered_pages() { let _guard = preview_port_guard().lock().unwrap(); let (dir, _db) = setup_preview_fixture(); let server = start_preview_server( dir.path().join("bds.db"), dir.path().to_path_buf(), "project-1".into(), ) .unwrap(); let client = reqwest::blocking::Client::new(); let response = client .get(format!( "http://{PREVIEW_HOST}:{PREVIEW_PORT}/?theme=amber&mode=light" )) .send() .unwrap(); let body = response.text().unwrap(); server.stop().unwrap(); assert!(body.contains("href=\"/assets/pico.amber.min.css\"")); assert!(body.contains("data-theme=\"light\"")); assert!(body.contains("data-mode=\"light\"")); assert!(!body.contains("data-theme=\"amber\"")); } #[test] fn auto_preview_mode_removes_forced_color_scheme_and_invalid_theme_falls_back() { let html = "
".to_string(); let styled = apply_preview_style( html, Some(&StylePreviewQuery { theme: Some("../../secret".into()), mode: Some("auto".into()), }), ); assert!(styled.contains("href=\"/assets/pico.min.css\"")); assert!(!styled.contains("data-theme=")); assert!(!styled.contains("data-mode=")); assert!(!styled.contains("secret")); } #[test] fn preview_respects_category_list_visibility_and_show_title_rules() { let dir = tempfile::tempdir().unwrap(); std::fs::create_dir_all(dir.path().join("meta")).unwrap(); crate::engine::meta::write_category_meta_json( dir.path(), &std::collections::HashMap::from([ ( "hidden".to_string(), crate::model::CategorySettings { render_in_lists: false, show_title: true, post_template_slug: None, list_template_slug: None, }, ), ( "featured".to_string(), crate::model::CategorySettings { render_in_lists: true, show_title: false, post_template_slug: None, list_template_slug: None, }, ), ]), ) .unwrap(); let db = Database::open_in_memory().unwrap(); let mut hidden = make_post(); hidden.post.title = "Hidden Post".into(); hidden.post.slug = "hidden-post".into(); hidden.post.categories = vec!["hidden".into()]; hidden.body_markdown = "Hidden body".into(); let mut featured = make_post(); featured.post.title = "Featured Post".into(); featured.post.slug = "featured-post".into(); featured.post.categories = vec!["featured".into()]; featured.body_markdown = "Featured body".into(); let hidden_response = build_preview_response( db.conn(), dir.path(), "project-1", &make_metadata(), &[ (hidden.post.clone(), hidden.body_markdown.clone()), (featured.post.clone(), featured.body_markdown.clone()), ], "/category/hidden", ) .unwrap(); assert_eq!(hidden_response.status_code, 404); let featured_response = build_preview_response( db.conn(), dir.path(), "project-1", &make_metadata(), &[ (hidden.post, hidden.body_markdown), (featured.post, featured.body_markdown), ], "/category/featured", ) .unwrap(); assert_eq!(featured_response.status_code, 200); assert!(featured_response.html.contains("Featured body")); assert!(!featured_response.html.contains("