diff --git a/Cargo.lock b/Cargo.lock index ebb7741..3406f64 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -323,6 +323,7 @@ dependencies = [ "tokio", "tokio-util", "toml", + "tower", "tower-http", "tracing", "tracing-subscriber", @@ -2343,6 +2344,7 @@ version = "0.1.40" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef" dependencies = [ + "log", "pin-project-lite", "tracing-attributes", "tracing-core", diff --git a/Cargo.toml b/Cargo.toml index b94d7a1..322d065 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -56,6 +56,7 @@ tokio = { version = "1.37.0", features = [ ] } tokio-util = { version = "0.7.10", default-features = false } toml = "0.8.12" +tower = "0.4.13" tower-http = { version = "0.5.2", features = [ "compression-gzip", "fs", diff --git a/README.md b/README.md index f131b72..6d2cc16 100644 --- a/README.md +++ b/README.md @@ -61,8 +61,9 @@ link = "https://..." # public url of the blog, required if rss is enabled [dirs] posts = "posts" # where posts are stored media = "media" # directory served under /media/ -static = "static" # directory server under /static/ (css and js) - +custom_templates = "custom/templates" # custom templates dir +custom_static = "custom/static" # custom static dir + # see CUSTOM.md for documentation [http] host = "0.0.0.0" # ip to listen on port = 3000 # port to listen on diff --git a/src/app.rs b/src/app.rs index db8d723..b957fd6 100644 --- a/src/app.rs +++ b/src/app.rs @@ -9,10 +9,12 @@ use axum::response::{IntoResponse, Redirect, Response}; use axum::routing::get; use axum::{Json, Router}; use handlebars::Handlebars; +use include_dir::{include_dir, Dir}; use rss::{Category, ChannelBuilder, ItemBuilder}; use serde::{Deserialize, Serialize}; use serde_json::Map; use tokio::sync::RwLock; +use tower::service_fn; use tower_http::services::ServeDir; use tower_http::trace::TraceLayer; use tracing::{info, info_span, Span}; @@ -20,6 +22,9 @@ use tracing::{info, info_span, Span}; use crate::config::{Config, DateFormat, Sort}; use crate::error::{AppError, AppResult}; use crate::post::{MarkdownPosts, PostManager, PostMetadata, RenderStats, ReturnedPost}; +use crate::serve_dir_included::handle; + +const STATIC: Dir<'static> = include_dir!("$CARGO_MANIFEST_DIR/static"); #[derive(Clone)] #[non_exhaustive] @@ -252,7 +257,9 @@ pub fn new(config: &Config) -> Router { .route("/feed.xml", get(rss)) .nest_service( "/static", - ServeDir::new(&config.dirs._static).precompressed_gzip(), + ServeDir::new(&config.dirs.custom_static) + .precompressed_gzip() + .fallback(service_fn(|req| handle(req, &STATIC))), ) .nest_service("/media", ServeDir::new(&config.dirs.media)) .layer( diff --git a/src/config.rs b/src/config.rs index 92ec628..4f9c42b 100644 --- a/src/config.rs +++ b/src/config.rs @@ -49,8 +49,8 @@ pub struct HttpConfig { pub struct DirsConfig { pub posts: PathBuf, pub media: PathBuf, - #[serde(rename = "static")] - pub _static: PathBuf, + pub custom_static: PathBuf, + pub custom_templates: PathBuf, } #[derive(Serialize, Deserialize, Debug, Clone)] @@ -126,7 +126,8 @@ impl Default for DirsConfig { Self { posts: "posts".into(), media: "media".into(), - _static: "static".into(), + custom_static: "custom/static".into(), + custom_templates: "custom/templates".into(), } } } diff --git a/src/main.rs b/src/main.rs index 9b0a620..5b4411a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,4 @@ -#![feature(let_chains)] +#![feature(let_chains, pattern)] mod app; mod config; @@ -9,6 +9,7 @@ mod markdown_render; mod platform; mod post; mod ranged_i128_visitor; +mod serve_dir_included; mod systemtime_as_secs; mod templates; diff --git a/src/serve_dir_included.rs b/src/serve_dir_included.rs new file mode 100644 index 0000000..9a92c20 --- /dev/null +++ b/src/serve_dir_included.rs @@ -0,0 +1,81 @@ +use std::convert::Infallible; +use std::str::pattern::Pattern; + +use axum::extract::Request; +use axum::http::{header, StatusCode}; +use axum::response::{IntoResponse, Response}; +use include_dir::{Dir, DirEntry}; +use tracing::{debug, trace}; + +fn if_empty<'a>(a: &'a str, b: &'a str) -> &'a str { + if a.is_empty() { + b + } else { + a + } +} + +fn remove_prefixes(mut src: &str, pat: (impl Pattern + Copy)) -> &str { + while let Some(removed) = src.strip_prefix(pat) { + src = removed; + } + src +} + +fn from_included_file(file: &'static include_dir::File<'static>) -> Response { + let mime_type = mime_guess::from_path(file.path()).first_or_octet_stream(); + + ( + [( + header::CONTENT_TYPE, + header::HeaderValue::try_from(mime_type.essence_str()).expect("invalid mime type"), + )], + file.contents(), + ) + .into_response() +} + +pub async fn handle( + req: Request, + included_dir: &'static Dir<'static>, +) -> Result { + #[cfg(windows)] + compile_error!("this is not safe"); + + let path = req.uri().path(); + + let has_dotdot = path.split('/').any(|seg| seg == ".."); + if has_dotdot { + return Ok(StatusCode::NOT_FOUND.into_response()); + } + + let relative_path = if_empty(remove_prefixes(path, '/'), "."); + + match included_dir.get_entry(relative_path) { + Some(DirEntry::Dir(dir)) => { + trace!("{relative_path:?} is a directory, trying \"index.html\""); + if let Some(file) = dir.get_file("index.html") { + debug!("{path:?} (index.html) serving from included dir"); + return Ok(from_included_file(file)); + } else { + trace!("\"index.html\" not found in {relative_path:?} in included files"); + } + } + None if relative_path == "." => { + trace!("requested root, trying \"index.html\""); + if let Some(file) = included_dir.get_file("index.html") { + debug!("{path:?} (index.html) serving from included dir"); + return Ok(from_included_file(file)); + } else { + trace!("\"index.html\" not found in included files"); + } + } + Some(DirEntry::File(file)) => { + debug!("{path:?} serving from included dir"); + return Ok(from_included_file(file)); + } + None => trace!("{relative_path:?} not found in included files"), + }; + + Ok(StatusCode::NOT_FOUND.into_response()) +}