A little app to serve my photography from my personal website

config struct, blog caching

ericwood.org 696d7f3e 3f266a82

Waiting for spindle ...
+142 -26
.DS_Store

This is a binary file and will not be displayed.

+1
.gitignore
··· 1 1 /target 2 2 .env 3 3 /blog_posts/ 4 + .cache/
+36 -7
src/blog.rs
··· 1 1 use std::{ 2 2 collections::HashMap, 3 - env, 4 3 fs::{self, File, read_to_string}, 5 - io::{BufRead, BufReader}, 4 + io::{BufRead, BufReader, Write}, 6 5 path::{Path, PathBuf}, 7 6 }; 8 7 9 8 use comrak::{markdown_to_html_with_plugins, options, plugins::syntect::SyntectAdapterBuilder}; 9 + use minijinja::context; 10 10 11 - use crate::date_time::DateTime; 11 + use crate::{AppState, config::Config, date_time::DateTime, templates::render}; 12 12 13 - #[derive(serde::Deserialize, serde::Serialize)] 13 + #[derive(serde::Deserialize, serde::Serialize, Clone)] 14 14 pub struct BlogPost { 15 15 pub title: Option<String>, 16 16 pub published_at: Option<DateTime>, 17 17 #[serde(skip_deserializing)] 18 18 pub file_path: PathBuf, 19 + #[serde(skip_deserializing)] 20 + pub cache_path: PathBuf, 19 21 } 20 22 21 - pub fn get_posts() -> anyhow::Result<HashMap<String, BlogPost>> { 22 - let path_str = env::var("BLOG_POSTS_PATH").expect("BLOG_POSTS_PATH not set"); 23 - let root_path = Path::new(&path_str); 23 + pub fn load_index(config: &Config) -> anyhow::Result<HashMap<String, BlogPost>> { 24 + let root_path = Path::new(&config.blog_posts_path); 24 25 let mut map = HashMap::new(); 25 26 26 27 let names: Vec<String> = fs::read_dir(root_path)? ··· 44 45 45 46 if let Some(mut post) = maybe_post { 46 47 post.file_path = path; 48 + post.cache_path = Path::new(&config.cache_path).join("blog").join(&slug); 47 49 map.insert(slug, post); 48 50 } 49 51 } ··· 112 114 113 115 Ok(body) 114 116 } 117 + 118 + // Render all of our blog posts as fully static HTML files so we can serve them up without having 119 + // to do all that extra markdown stuff. Maybe find a better way to share this logic with the 120 + // controller in the near future. 121 + pub fn cache_posts(state: &AppState) -> anyhow::Result<()> { 122 + if !state.config.is_prod() { 123 + return Ok(()); 124 + } 125 + 126 + for post in state.blog_slugs.values() { 127 + let body = render_post(&post.file_path)?; 128 + let post = post.clone(); 129 + let rendered = render( 130 + &state.reloader, 131 + "blog/show", 132 + context! { 133 + post, 134 + body, 135 + }, 136 + )?; 137 + 138 + let mut file = File::create(post.cache_path)?; 139 + file.write_all(rendered.as_bytes())?; 140 + } 141 + 142 + Ok(()) 143 + }
+61
src/config.rs
··· 1 + use std::env; 2 + 3 + #[derive(PartialEq)] 4 + pub enum Environment { 5 + Development, 6 + Production, 7 + } 8 + 9 + pub struct Config { 10 + pub environment: Environment, 11 + pub cache_path: String, 12 + pub auto_reload_templates: bool, 13 + pub blog_posts_path: String, 14 + pub photos_db_path: String, 15 + pub photos_thumbnail_path: String, 16 + pub photos_image_path: String, 17 + pub assets_path: String, 18 + } 19 + 20 + impl Config { 21 + pub fn new() -> anyhow::Result<Self> { 22 + let environment = match env::var("ENVIRONMENT") 23 + .unwrap_or("development".to_string()) 24 + .as_str() 25 + { 26 + "development" => Environment::Development, 27 + "production" => Environment::Production, 28 + _ => Environment::Development, 29 + }; 30 + 31 + let cache_path = env::var("CACHE_PATH").unwrap_or(".cache".to_string()); 32 + 33 + let auto_reload_templates = env::var("AUTO_RELOAD_TEMPLATES").is_ok_and(|c| c == "true"); 34 + 35 + let blog_posts_path = 36 + env::var("BLOG_POSTS_PATH").expect("BLOG_POSTS_PATH env variable not set"); 37 + 38 + let photos_db_path = 39 + env::var("PHOTOS_DB_PATH").expect("PHOTOS_DB_PATH env variable not set"); 40 + let photos_thumbnail_path = 41 + env::var("PHOTOS_THUMBNAIL_PATH").expect("PHOTOS_THUMBNAIL_PATH env variable not set"); 42 + let photos_image_path = 43 + env::var("PHOTOS_IMAGE_PATH").expect("PHOTOS_IMAGE_PATH env variable not set"); 44 + let assets_path = env::var("ASSETS_PATH").expect("ASSETS_PATH env variable not set"); 45 + 46 + Ok(Self { 47 + environment, 48 + cache_path, 49 + auto_reload_templates, 50 + blog_posts_path, 51 + photos_db_path, 52 + photos_thumbnail_path, 53 + photos_image_path, 54 + assets_path, 55 + }) 56 + } 57 + 58 + pub fn is_prod(&self) -> bool { 59 + self.environment == Environment::Production 60 + } 61 + }
+1
src/date_time.rs
··· 6 6 de::{self, Visitor}, 7 7 }; 8 8 9 + #[derive(Clone)] 9 10 pub struct DateTime(chrono::DateTime<FixedOffset>); 10 11 11 12 impl convert::TryFrom<String> for DateTime {
+35 -15
src/main.rs
··· 7 7 }; 8 8 use dotenvy::dotenv; 9 9 use minijinja_autoreload::AutoReloader; 10 - use std::{collections::HashMap, env, sync::Arc}; 10 + use std::{collections::HashMap, fs, path::Path, sync::Arc}; 11 11 use tower::ServiceBuilder; 12 12 mod app_error; 13 13 use app_error::AppError; ··· 23 23 use sqlx::SqlitePool; 24 24 use templates::load_templates_dyn; 25 25 use tower_http::{services::ServeDir, set_header::SetResponseHeaderLayer}; 26 + mod config; 27 + use config::Config; 26 28 27 29 use crate::blog::BlogPost; 28 30 29 31 struct AppState { 32 + config: Config, 30 33 reloader: AutoReloader, 31 34 photos_db_pool: SqlitePool, 32 35 blog_slugs: HashMap<String, BlogPost>, ··· 34 37 35 38 type Response = Result<Html<String>, AppError>; 36 39 40 + pub fn bootstrap_cache(config: &Config) -> anyhow::Result<()> { 41 + let cache_path = Path::new(&config.cache_path); 42 + fs::create_dir_all(cache_path).expect("failed to create cache directory"); 43 + fs::create_dir_all(cache_path.join("blog"))?; 44 + Ok(()) 45 + } 46 + 37 47 #[tokio::main] 38 48 async fn main() -> anyhow::Result<()> { 39 49 let _ = dotenv(); 40 - let environment = env::var("ENVIRONMENT").unwrap_or("development".to_string()); 41 - let tracing_config = if environment == "production" { 50 + 51 + let config = Config::new()?; 52 + bootstrap_cache(&config)?; 53 + 54 + let tracing_config = if config.is_prod() { 42 55 TracingConfig::production() 43 56 } else { 44 57 TracingConfig::development() ··· 46 59 47 60 let _guard = tracing_config.init_subscriber()?; 48 61 49 - let photos_db_pool = SqlitePool::connect(&env::var("DATABASE_URL")?) 62 + let photos_db_pool = SqlitePool::connect(&config.photos_db_path) 50 63 .await 51 64 .expect("Where's the database???"); 52 65 53 66 tracing::info!("connected to DB"); 54 67 55 - let blog_slugs = blog::get_posts()?; 68 + let blog_slugs = blog::load_index(&config)?; 56 69 57 - let reloader = load_templates_dyn(); 58 - let app_state = Arc::new(AppState { 59 - reloader, 60 - photos_db_pool, 61 - blog_slugs, 62 - }); 70 + let reloader = load_templates_dyn(config.auto_reload_templates); 63 71 let app = Router::new() 64 72 .nest_service( 65 73 "/photos/assets", ··· 68 76 header::CACHE_CONTROL, 69 77 HeaderValue::from_static("public, max-age=31536000, immutible"), 70 78 )) 71 - .service(ServeDir::new(&env::var("ASSETS_PATH")?)), 79 + .service(ServeDir::new(&config.assets_path)), 72 80 ) 73 81 .nest_service( 74 82 "/photos/thumbnails", ··· 77 85 header::CACHE_CONTROL, 78 86 HeaderValue::from_static("public, max-age=31536000, immutible"), 79 87 )) 80 - .service(ServeDir::new(&env::var("THUMBNAIL_PATH")?)), 88 + .service(ServeDir::new(&config.photos_thumbnail_path)), 81 89 ) 82 90 .nest_service( 83 91 "/photos/images", ··· 86 94 header::CACHE_CONTROL, 87 95 HeaderValue::from_static("public, max-age=31536000, immutible"), 88 96 )) 89 - .service(ServeDir::new(&env::var("IMAGE_PATH")?)), 97 + .service(ServeDir::new(&config.photos_image_path)), 90 98 ) 91 99 .route("/photos", get(routes::photos::index)) 92 100 .route("/photos/{id}", get(routes::photos::show)) 93 101 .route("/blog", get(routes::blog::index)) 94 - .route("/blog/{slug}", get(routes::blog::show)) 102 + .route("/blog/{slug}", get(routes::blog::show)); 103 + 104 + let app_state = Arc::new(AppState { 105 + config, 106 + reloader, 107 + photos_db_pool, 108 + blog_slugs, 109 + }); 110 + 111 + blog::cache_posts(&app_state)?; 112 + 113 + let app = app 95 114 .with_state(app_state) 96 115 .fallback(handler_404) 97 116 .layer(OtelInResponseLayer) 98 117 .layer(OtelAxumLayer::default()); 118 + 99 119 let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap(); 100 120 axum::serve(listener, app).await?; 101 121
+6 -1
src/routes/blog/show.rs
··· 4 4 response::Html, 5 5 }; 6 6 use minijinja::context; 7 - use std::sync::Arc; 7 + use std::{fs::read_to_string, sync::Arc}; 8 8 9 9 pub async fn show(Path(slug): Path<String>, State(state): State<Arc<AppState>>) -> Response { 10 10 let post = state.blog_slugs.get(&slug).ok_or(AppError::NotFound)?; 11 11 let body = render_post(&post.file_path)?; 12 + 13 + if state.config.is_prod() { 14 + let html = read_to_string(&post.cache_path)?; 15 + return Ok(Html(html)); 16 + } 12 17 13 18 let rendered = render( 14 19 &state.reloader,
+2 -3
src/templates.rs
··· 1 1 use minijinja::{Environment, Error, ErrorKind, Value}; 2 2 use minijinja_autoreload::AutoReloader; 3 3 use serde::Serialize; 4 - use std::{env, fs::read_to_string, path::Path}; 4 + use std::{fs::read_to_string, path::Path}; 5 5 6 6 #[derive(Serialize)] 7 7 struct NavLink<'a> { ··· 10 10 href: &'a str, 11 11 } 12 12 13 - pub fn load_templates_dyn() -> AutoReloader { 13 + pub fn load_templates_dyn(should_autoreload: bool) -> AutoReloader { 14 14 AutoReloader::new(move |notifier| { 15 15 let mut env = Environment::new(); 16 16 env.set_loader(loader); 17 17 18 18 notifier.set_fast_reload(true); 19 19 20 - let should_autoreload = env::var("AUTO_RELOAD_TEMPLATES").is_ok_and(|c| c == "true"); 21 20 if should_autoreload { 22 21 let template_path = Path::new("templates"); 23 22 notifier.watch_path(template_path, true);