diff --git a/error_template.html b/error_template.html new file mode 100644 index 0000000..566310d --- /dev/null +++ b/error_template.html @@ -0,0 +1,58 @@ + + + + + + + Error ERROR_CODE + + + + + +
+
+

That's a ERROR_CODE error.

+ Let's go home. +
+
+ + \ No newline at end of file diff --git a/src/config.rs b/src/config.rs index 35fcdcf..cc7a359 100644 --- a/src/config.rs +++ b/src/config.rs @@ -77,8 +77,9 @@ impl ThrottleConfig /// - ```server_cache_period_seconds: u16```: internal cache period if content is not static /// - ```static_content: Option```: all content is immutably cached at launch /// - ```ignore_regexes: Option>```: do not serve content matching any of these patterns -/// - ```generate_sitemap: Option```: sitemap.xml will be automatically generated (and updated) +/// - ```generate_sitemap: Option```: sitemap.xml will be automatically generated (and updated) /// - ```message_on_sitemap_reload: Option```: optionally send Discord notifications when sitemap is reloaded +/// - ```error_template: Option```: path to error template page. #[derive(Clone, Serialize, Deserialize)] pub struct ContentConfig { @@ -90,7 +91,8 @@ pub struct ContentConfig pub server_cache_period_seconds: u16, pub static_content: Option, pub generate_sitemap: Option, - pub message_on_sitemap_reload: Option + pub message_on_sitemap_reload: Option, + pub error_template: Option } impl ContentConfig @@ -107,7 +109,8 @@ impl ContentConfig server_cache_period_seconds: 3600, static_content: Some(false), generate_sitemap: Some(true), - message_on_sitemap_reload: Some(false) + message_on_sitemap_reload: Some(false), + error_template: None } } } @@ -116,7 +119,7 @@ impl ContentConfig /// - ```key_path```: optional location of ssh key (ssh connection will be used) /// - ```user```: user name for authentication /// - ```passphrase```: passphrase for ssh key or for user-pass auth -///

If using ssh keys be sure the host is added to ~/ssh/known_hosts for +///

If using ssh keys be sure the host is added to ~/ssh/known_hosts for ///the user that runs busser, including root

#[derive(Clone, Serialize, Deserialize)] pub struct GitAuthConfig @@ -188,7 +191,7 @@ pub struct Config pub relay: Option> } -impl Config +impl Config { pub fn default() -> Config { @@ -239,7 +242,7 @@ pub fn read_config(path: &str) -> Option let config: Config = match serde_json::from_str(&data) { Ok(data) => {data}, - Err(why) => + Err(why) => { crate::debug(format!("Error reading configuration file {}\n{}", path, why), None); return None @@ -248,7 +251,7 @@ pub fn read_config(path: &str) -> Option Some(config) } - else + else { crate::debug(format!("Error configuration file {} does not exist", path), None); None diff --git a/src/content/error_page.rs b/src/content/error_page.rs new file mode 100644 index 0000000..53aa466 --- /dev/null +++ b/src/content/error_page.rs @@ -0,0 +1,91 @@ +use crate::{config::Config, filesystem::file::read_file_utf8}; + +pub const DEFAULT_BODY: &str = r#" + + + + + + Error ERROR_CODE + + + + + +
+
+

That's a ERROR_CODE error.

+ Let's go home. +
+
+ + +"#; + +pub struct ErrorPage +{ + pub body_template: String +} + +impl ErrorPage +{ + fn expand_template(template: String, config: &Config) -> String + { + template.replace("LINK_TO_HOME", &format!("https://{}",&config.domain)) + } + + pub fn expand_error_code(&self, code: &str) -> String + { + self.body_template.replace("ERROR_CODE", code) + } + + pub fn from(config: &Config) -> ErrorPage + { + if let Some(ref path) = config.content.error_template + { + if let Some(body) = read_file_utf8(&path) + { + return ErrorPage {body_template: Self::expand_template(body, config)} + } + } + ErrorPage {body_template: Self::expand_template(DEFAULT_BODY.to_string(), config)} + } +} \ No newline at end of file diff --git a/src/content/mod.rs b/src/content/mod.rs index 58c093b..f15944e 100644 --- a/src/content/mod.rs +++ b/src/content/mod.rs @@ -17,9 +17,10 @@ use self::mime_type::{Mime, MIME}; pub mod mime_type; pub mod filter; pub mod sitemap; +pub mod error_page; -/// Store web content -/// +/// Store web content +/// /// - The body is unpopulated until [Content::load_from_file] is called /// - The body may be converted to a utf8 string using [Content::utf8_body] /// - A hash of the file is used to check it is stale, used by [Observed] @@ -38,9 +39,9 @@ pub struct Content tag_insertion: bool } -pub trait HasUir +pub trait HasUir { - fn get_uri(&self) -> String; + fn get_uri(&self) -> String; } impl PartialEq for Content @@ -96,7 +97,7 @@ impl Observed for Content } } - fn last_refreshed(&self) -> SystemTime + fn last_refreshed(&self) -> SystemTime { self.last_refreshed.clone() } @@ -114,11 +115,11 @@ impl Content { pub fn new(uri: &str, disk_path: &str, server_cache: u16, browser_cache: u16, tag_insertion: bool) -> Content { - Content - { - uri: uri.to_string(), - body: vec![], - disk_path: disk_path.to_string(), + Content + { + uri: uri.to_string(), + body: vec![], + disk_path: disk_path.to_string(), content_type: ::infer_mime_type(disk_path), server_cache_period_seconds: server_cache, browser_cache_period_seconds: browser_cache, @@ -145,14 +146,14 @@ impl Content { match self.read_bytes() { - Some(data) => + Some(data) => { self.body = data.clone(); self.hash = hash(data); self.last_refreshed = SystemTime::now(); Ok(()) } - None => + None => { self.last_refreshed = SystemTime::now(); Err(FileError { why: format!("Could not read bytes from {}", self.disk_path)}) @@ -180,7 +181,7 @@ impl Content let preview_body = match self.utf8_body() { Ok(s) => s[0..min(s.len(), n)].to_string(), - Err(_e) => + Err(_e) => { dump_bytes(&self.body)[0..min(self.body.len(), n)].to_string() } @@ -193,13 +194,13 @@ impl Content /// this may be disabled by launching as busser --no-tagging pub fn insert_tag(body: String) -> String -{ +{ format!("\n{}", program_version(), body) } impl IntoResponse for Content { fn into_response(self) -> Response { - + let mut response = if self.content_type == MIME::TextHtml { let mut string_body = match self.utf8_body() @@ -229,22 +230,22 @@ impl IntoResponse for Content { response.headers_mut() .insert("cache-control", format!("public, max-age={}", self.browser_cache_period_seconds).parse().unwrap()); - + response } } pub fn is_page(uri: &str, domain: &str) -> bool { - if uri == "/" + if uri == "/" { return true } - + let domain_escaped = domain.replace("https://", "").replace("http://", "").replace(".", r"\."); match Regex::new(format!(r"((^|(http)(s|)://){})(/|/[^\.]+|/[^\.]+.html|$)$",domain_escaped).as_str()) { - Ok(re) => + Ok(re) => { re.is_match(uri) }, diff --git a/src/content/sitemap.rs b/src/content/sitemap.rs index 10ffb20..fe8495d 100644 --- a/src/content/sitemap.rs +++ b/src/content/sitemap.rs @@ -1,5 +1,5 @@ -use std::{collections::BTreeMap, future::Future, sync::Arc, time::{Duration, Instant, SystemTime}, vec}; +use std::{collections::BTreeMap, sync::Arc, time::{Duration, Instant, SystemTime}, vec}; use openssl::sha::Sha256; use tokio::sync::Mutex; @@ -64,8 +64,8 @@ impl ContentTree { router = router.route ( - &uri, - get(move || async move + &uri, + get(move || async move { let mut content = content.lock().await; if !static_router && content.server_cache_expired() && content.is_stale() @@ -106,7 +106,7 @@ impl ContentTree /// List all uris pub fn collect_uris(&self) -> Vec - { + { let mut uris: Vec = self.contents.keys().cloned().collect(); for (_, child) in &self.children { @@ -130,7 +130,7 @@ impl ContentTree match uri_stem.find("/") { - Some(loc) => + Some(loc) => { if loc < uri_stem.len()-1 { @@ -145,7 +145,7 @@ impl ContentTree { if !self.children.contains_key(&child_uri_stem) { - self.children.insert(child_uri_stem.clone(), ContentTree::new(&reduced_uri_stem.clone())); + self.children.insert(child_uri_stem.clone(), ContentTree::new(&reduced_uri_stem.clone())); } self.children.get_mut(&child_uri_stem).unwrap().push(reduced_uri_stem, content); @@ -221,7 +221,7 @@ impl ContentTree Ok(()) }) { - Ok(_) => + Ok(_) => { if buffer.len() > 0 { @@ -244,7 +244,7 @@ impl ContentTree /// If no sitemap.xml or robots.txt is present /// these will be generated by calling [SiteMap::to_xml] /// and inserting the resulting sitemap.xml -/// +/// /// Convertable to a router, see [ContentTree] for dynamic /// options #[derive(Clone)] @@ -309,7 +309,7 @@ impl SiteMap contents.sort_by_key(|x|x.get_uri()); tic = Instant::now(); - let bar = if !silent + let bar = if !silent { println!("Building sitemap"); Some(ProgressBar::new(contents.len() as u64)) @@ -322,7 +322,7 @@ impl SiteMap let mut content_tree = ContentTree::new("/"); for content in contents - { + { if content.get_uri().contains("config.json") { continue } crate::debug(format!("Adding content {:?}", content.preview(64)), None); let path = config.content.path.clone()+"/"; @@ -354,10 +354,10 @@ impl SiteMap let home = Content::new ( - "/", - &config.content.home.clone(), - config.content.server_cache_period_seconds, - config.content.browser_cache_period_seconds, + "/", + &config.content.home.clone(), + config.content.server_cache_period_seconds, + config.content.browser_cache_period_seconds, tag ); @@ -449,7 +449,7 @@ impl SiteMap content.append(&mut content_buffer); content_buffer = content; } - + let mut str_content = String::from_utf8(content_buffer)?; let lines = str_content.matches("\n").count(); if lines > 1 @@ -490,7 +490,7 @@ impl Into for SiteMap } } -/// Format for lastmod (t) in an xml sitemap +/// Format for lastmod (t) in an xml sitemap pub fn lastmod(t: SystemTime) -> String { let date: DateTime = t.into(); diff --git a/src/main.rs b/src/main.rs index f056b11..73a4f46 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,14 +7,14 @@ use busser::integrations::git::clean_and_clone; use busser::server::http::ServerHttp; use busser::server::https::Server; use busser::util::formatted_differences; -use busser::{openssl_version, program_version, task}; +use busser::{openssl_version, program_version}; use tokio::task::spawn; #[tokio::main] async fn main() { let args: Vec = std::env::args().collect(); - + if args.iter().any(|x| x == "-v") { println!("Version: {}\n{}", program_version(), openssl_version()); @@ -39,13 +39,13 @@ async fn main() { { true }; - + let http_server = ServerHttp::new(0,0,0,0); let _http_redirect = spawn(http_server.serve()); match read_config(CONFIG_PATH) { - Some(c) => + Some(c) => { if c.git.is_some() { @@ -81,9 +81,9 @@ async fn main() { /// Serve by observing the site content found at the path [busser::config::ContentConfig] /// every [busser::config::ContentConfig::server_cache_period_seconds] the sitemap -/// hash (see [busser::content::sitemap::SiteMap::get_hash]) is checked, if it is -/// different the server is re-served. -/// +/// hash (see [busser::content::sitemap::SiteMap::get_hash]) is checked, if it is +/// different the server is re-served. +/// /// On a re-serve if [busser::config::ContentConfig::message_on_sitemap_reload] is true /// A status message with (uri) additions and removals will be posted to Discord. async fn serve_observed(insert_tag: bool) @@ -101,13 +101,13 @@ async fn serve_observed(insert_tag: bool) let mut server_handle = server.get_handle(); let mut thread_handle = spawn(async move {server.serve()}.await); let mut task_handle = spawn(async move {tasks.run()}.await); - + loop { - + busser::debug(format!("Next sitemap check: {}s", config.content.server_cache_period_seconds), None); tokio::time::sleep(Duration::from_secs(config.content.server_cache_period_seconds.into())).await; - + let new_sitemap = SiteMap::build(&config, insert_tag, false); let sitemap_hash = new_sitemap.get_hash(); diff --git a/src/server/https.rs b/src/server/https.rs index 519d71a..a68e90b 100644 --- a/src/server/https.rs +++ b/src/server/https.rs @@ -1,18 +1,18 @@ use crate:: { - config::{read_config, CONFIG_PATH}, content::sitemap::SiteMap, integrations::{git::refresh::GitRefreshTask, github::filter_github}, server::throttle::{handle_throttle, IpThrottler}, task::{schedule_from_option, TaskPool}, CRAB + config::{read_config, CONFIG_PATH}, content::{error_page::ErrorPage, sitemap::SiteMap}, integrations::{git::refresh::GitRefreshTask, github::filter_github}, server::throttle::{handle_throttle, IpThrottler}, task::{schedule_from_option, TaskPool}, CRAB }; use core::time; use std::{net::{IpAddr, Ipv4Addr, SocketAddr}, time::SystemTime}; use std::path::PathBuf; use std::sync::Arc; + use tokio::sync::Mutex; use axum:: { - middleware, - Router + middleware, response::Html, Router }; use axum_server::{tls_rustls::RustlsConfig, Handle}; @@ -47,16 +47,16 @@ pub fn parse_uri(uri: String, path: String) -> String } } -impl Server +impl Server { - pub fn new + pub fn new ( a: u8, b: u8, c: u8, d: u8, sitemap: SiteMap - ) + ) -> (Server, TaskPool) { @@ -71,13 +71,13 @@ impl Server let requests: IpThrottler = IpThrottler::new ( - config.throttle.max_requests_per_second, + config.throttle.max_requests_per_second, config.throttle.timeout_millis, config.throttle.clear_period_seconds ); let throttle_state = Arc::new(Mutex::new(requests)); - + let mut router: Router = sitemap.into(); let stats = Arc::new(Mutex::new( @@ -94,6 +94,9 @@ impl Server router = router.layer(middleware::from_fn_with_state(repo_mutex.clone(), filter_github)); router = router.layer(middleware::from_fn(filter_relay)); + let error_page = ErrorPage::from(&config); + router = router.fallback(Html(error_page.expand_error_code("404"))); + let server = Server { addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(a,b,c,d)), config.port_https), @@ -112,9 +115,9 @@ impl Server ( StatsSaveTask::new ( - stats.clone(), + stats.clone(), schedule_from_option(config.stats.save_schedule.clone()) - ) + ) ) ); @@ -124,9 +127,9 @@ impl Server ( StatsDigestTask::new ( - stats.clone(), + stats.clone(), schedule_from_option(config.stats.digest_schedule.clone()) - ) + ) ) ); @@ -173,7 +176,7 @@ impl Server .await { Ok(c) => c, - Err(e) => + Err(e) => { println!("error while reading certificates in {} and key {}\n{}", cert_path, key_path, e); std::process::exit(1); @@ -194,7 +197,7 @@ impl Server { println!("(or https://127.0.0.1)"); } - + axum_server::bind_rustls(self.addr, config) .handle(self.handle.clone()) .serve(self.router.clone().into_make_service_with_connect_info::()) diff --git a/tests/config.json b/tests/config.json index 96a6175..fd58858 100644 --- a/tests/config.json +++ b/tests/config.json @@ -1,13 +1,13 @@ { "port_https": 443, - "port_http": 80, - "throttle": + "port_http": 80, + "throttle": { - "max_requests_per_second": 64.0, - "timeout_millis": 5000, + "max_requests_per_second": 64.0, + "timeout_millis": 5000, "clear_period_seconds": 3600 }, - "stats": + "stats": { "path": "tests/stats", "hit_cooloff_seconds": 60, @@ -15,7 +15,7 @@ "digest_schedule": "0 0 1 * * Fri *", "ignore_regexes": ["/favicon.ico"] }, - "content": + "content": { "path": "tests/pages", "home": "tests/pages/a.html", diff --git a/tests/test_config.rs b/tests/test_config.rs index 44b57b9..040c62e 100644 --- a/tests/test_config.rs +++ b/tests/test_config.rs @@ -44,6 +44,7 @@ mod config assert_eq!(config.content.ignore_regexes.unwrap(), vec!["/.git", "workspace"]); assert_eq!(config.content.static_content, None); assert_eq!(config.content.generate_sitemap, Some(false)); + assert_eq!(config.content.error_template, None); assert!(config.relay.is_some()); let relay = config.relay.unwrap(); @@ -95,6 +96,7 @@ mod config assert_eq!(content.server_cache_period_seconds, 3600); assert_eq!(content.static_content, Some(false)); assert_eq!(content.message_on_sitemap_reload, Some(false)); + assert_eq!(content.error_template, None); let config = Config::default(); @@ -174,6 +176,7 @@ mod config assert_eq!(config.content.browser_cache_period_seconds, 3600); assert_eq!(config.content.server_cache_period_seconds, 1); assert_eq!(config.content.ignore_regexes.unwrap(), vec!["/.git", "workspace"]); + assert_eq!(config.content.error_template, None); } #[test] diff --git a/tests/test_content.rs b/tests/test_content.rs index 7f60aa7..0883ef9 100644 --- a/tests/test_content.rs +++ b/tests/test_content.rs @@ -5,7 +5,7 @@ mod test_content { use std::{collections::HashMap, fs::remove_file, path::Path, thread::sleep, time}; - use busser::{content::{filter::ContentFilter, get_content, insert_tag, is_page, mime_type::MIME, Content, HasUir}, filesystem::file::{file_hash, write_file_bytes, Observed}, util::read_bytes}; + use busser::{config::{read_config, Config}, content::{error_page::{ErrorPage, DEFAULT_BODY}, filter::ContentFilter, get_content, insert_tag, is_page, mime_type::MIME, Content, HasUir}, filesystem::file::{file_hash, write_file_bytes, Observed}, util::read_bytes}; #[test] fn test_load_content() @@ -233,5 +233,31 @@ mod test_content } } + #[test] + fn test_error_page() + { + let mut config = Config::default(); + let default_error = ErrorPage::from(&config); + assert_ne!(default_error.body_template, DEFAULT_BODY); + assert!(default_error.body_template.contains("127.0.0.1")); + assert!(!default_error.body_template.contains("LINK_TO_HOME")); + assert!(default_error.body_template.contains("ERROR_CODE")); + + let body = default_error.expand_error_code("404"); + assert!(!body.contains("ERROR_CODE")); + assert!(body.contains("404")); + + let config = read_config("tests/config.json").unwrap(); + let default_error = ErrorPage::from(&config); + assert_ne!(default_error.body_template, DEFAULT_BODY); + assert!(default_error.body_template.contains("127.0.0.1")); + assert!(!default_error.body_template.contains("LINK_TO_HOME")); + assert!(default_error.body_template.contains("ERROR_CODE")); + + let body = default_error.expand_error_code("404"); + assert!(!body.contains("ERROR_CODE")); + assert!(body.contains("404")); + } + }