diff --git a/src/app_config.rs b/src/app_config.rs index 21c59441..33702f5a 100644 --- a/src/app_config.rs +++ b/src/app_config.rs @@ -6,6 +6,7 @@ use serde::de::Error; use serde::{Deserialize, Deserializer, Serialize}; use std::net::{SocketAddr, ToSocketAddrs}; use std::path::{Path, PathBuf}; +use crate::webserver::routing::RoutingConfig; #[derive(Parser)] #[clap(author, version, about, long_about = None)] @@ -266,6 +267,12 @@ impl AppConfig { } } +impl RoutingConfig for AppConfig { + fn prefix(&self) -> &str { + &self.site_prefix + } +} + /// The directory where the `sqlpage.json` file is located. /// Determined by the `SQLPAGE_CONFIGURATION_DIRECTORY` environment variable fn configuration_directory() -> PathBuf { diff --git a/src/file_cache.rs b/src/file_cache.rs index fb45f498..68941cbf 100644 --- a/src/file_cache.rs +++ b/src/file_cache.rs @@ -13,6 +13,7 @@ use std::sync::atomic::{ use std::sync::Arc; use std::time::SystemTime; use tokio::sync::RwLock; +use crate::webserver::routing::FileStore; /// The maximum time in milliseconds that a file can be cached before its freshness is checked /// (in production mode) @@ -74,6 +75,13 @@ pub struct FileCache { static_files: HashMap>, } +impl FileStore for FileCache { + async fn contains(&self, path: &PathBuf) -> bool { + self.cache.read().await.contains_key(path) + || self.static_files.contains_key(path) + } +} + impl Default for FileCache { fn default() -> Self { Self::new() diff --git a/src/filesystem.rs b/src/filesystem.rs index 9ff8cb46..7902e896 100644 --- a/src/filesystem.rs +++ b/src/filesystem.rs @@ -8,6 +8,7 @@ use sqlx::postgres::types::PgTimeTz; use sqlx::{Postgres, Statement, Type}; use std::io::ErrorKind; use std::path::{Component, Path, PathBuf}; +use crate::webserver::routing::FileStore; pub(crate) struct FileSystem { local_root: PathBuf, @@ -133,6 +134,12 @@ impl FileSystem { } } +impl FileStore for FileSystem { + async fn contains(&self, path: &PathBuf) -> bool { + tokio::fs::try_exists(self.local_root.join(path)).await.unwrap_or(false) + } +} + async fn file_modified_since_local(path: &Path, since: DateTime) -> tokio::io::Result { tokio::fs::metadata(path) .await diff --git a/src/webserver/http.rs b/src/webserver/http.rs index a49302a6..c1d81c70 100644 --- a/src/webserver/http.rs +++ b/src/webserver/http.rs @@ -34,6 +34,8 @@ use std::pin::Pin; use std::sync::Arc; use std::time::SystemTime; use tokio::sync::mpsc; +use crate::webserver::routing::{calculate_route, AppFileStore}; +use crate::webserver::routing::RoutingAction::{NotFound, Execute, CustomNotFound, Redirect, Serve}; #[derive(Clone)] pub struct RequestContext { @@ -423,41 +425,58 @@ async fn serve_fallback( pub async fn main_handler( mut service_request: ServiceRequest, ) -> actix_web::Result { - if let Some(redirect) = redirect_missing_prefix(&service_request) { - return Ok(service_request.into_response(redirect)); - } - - let path = req_path(&service_request); - let sql_file_path = path_to_sql_file(&path); - let maybe_response = if let Some(sql_path) = sql_file_path { - if let Some(redirect) = redirect_missing_trailing_slash(service_request.uri()) { - Ok(redirect) - } else { - log::debug!("Processing SQL request: {:?}", sql_path); - process_sql_request(&mut service_request, sql_path).await - } - } else { - log::debug!("Serving file: {:?}", path); - let path = req_path(&service_request); - let if_modified_since = IfModifiedSince::parse(&service_request).ok(); - let app_state: &web::Data = service_request.app_data().expect("app_state"); - serve_file(&path, app_state, if_modified_since).await - }; - - // On 404/NOT_FOUND error, fall back to `404.sql` handler if it exists - let response = match maybe_response { - // File could not be served due to a 404 error. Try to find a user provide 404 handler in - // the form of a `404.sql` in the current directory. If there is none, look in the parent - // directeory, and its parent directory, ... - Err(e) if e.as_response_error().status_code() == StatusCode::NOT_FOUND => { - serve_fallback(&mut service_request, e).await? + let app_state: &web::Data = service_request.app_data().expect("app_state"); + let store = AppFileStore::new(&app_state.sql_file_cache, &app_state.file_system); + let path_and_query = service_request.uri().path_and_query().expect("expected valid path with query from request"); + return match calculate_route(path_and_query, &store, &app_state.config).await { + NotFound => { serve_fallback(&mut service_request, ErrorWithStatus { status: StatusCode::NOT_FOUND }.into()).await } + Execute(path) => { process_sql_request(&mut service_request, path).await } + CustomNotFound(path) => { process_sql_request(&mut service_request, path).await } + Redirect(uri) => { Ok(HttpResponse::MovedPermanently() + .insert_header((header::LOCATION, uri.to_string())) + .finish()) } + Serve(path) => { + let if_modified_since = IfModifiedSince::parse(&service_request).ok(); + let app_state: &web::Data = service_request.app_data().expect("app_state"); + serve_file(path.as_os_str().to_str().unwrap(), app_state, if_modified_since).await } - - // Either a valid response, or an unrelated error that shall be bubbled up. - e => e?, - }; - - Ok(service_request.into_response(response)) + }.map(|response| service_request.into_response(response)); + + // if let Some(redirect) = redirect_missing_prefix(&service_request) { + // return Ok(service_request.into_response(redirect)); + // } + // + // let path = req_path(&service_request); + // let sql_file_path = path_to_sql_file(&path); + // let maybe_response = if let Some(sql_path) = sql_file_path { + // if let Some(redirect) = redirect_missing_trailing_slash(service_request.uri()) { + // Ok(redirect) + // } else { + // log::debug!("Processing SQL request: {:?}", sql_path); + // process_sql_request(&mut service_request, sql_path).await + // } + // } else { + // log::debug!("Serving file: {:?}", path); + // let path = req_path(&service_request); + // let if_modified_since = IfModifiedSince::parse(&service_request).ok(); + // let app_state: &web::Data = service_request.app_data().expect("app_state"); + // serve_file(&path, app_state, if_modified_since).await + // }; + // + // // On 404/NOT_FOUND error, fall back to `404.sql` handler if it exists + // let response = match maybe_response { + // // File could not be served due to a 404 error. Try to find a user provide 404 handler in + // // the form of a `404.sql` in the current directory. If there is none, look in the parent + // // directeory, and its parent directory, ... + // Err(e) if e.as_response_error().status_code() == StatusCode::NOT_FOUND => { + // serve_fallback(&mut service_request, e).await? + // } + // + // // Either a valid response, or an unrelated error that shall be bubbled up. + // e => e?, + // }; + // + // Ok(service_request.into_response(response)) } fn redirect_missing_prefix(service_request: &ServiceRequest) -> Option { diff --git a/src/webserver/mod.rs b/src/webserver/mod.rs index 6592d412..1393d9e6 100644 --- a/src/webserver/mod.rs +++ b/src/webserver/mod.rs @@ -43,4 +43,5 @@ pub use error_with_status::ErrorWithStatus; pub use database::make_placeholder; pub use database::migrations::apply; pub mod response_writer; +pub mod routing; mod static_content; diff --git a/src/webserver/routing.rs b/src/webserver/routing.rs new file mode 100644 index 00000000..e0b68068 --- /dev/null +++ b/src/webserver/routing.rs @@ -0,0 +1,521 @@ +use crate::file_cache::FileCache; +use crate::filesystem::FileSystem; +use crate::webserver::database::ParsedSqlFile; +use awc::http::uri::PathAndQuery; +use awc::http::Uri; +use log::debug; +use std::path::PathBuf; +use RoutingAction::{CustomNotFound, Execute, NotFound, Redirect, Serve}; + +const INDEX: &'static str = "index.sql"; +const NOT_FOUND: &'static str = "404.sql"; +const SQL_EXTENSION: &'static str = "sql"; +const FORWARD_SLASH: &'static str = "/"; + +#[derive(Debug, PartialEq)] +pub enum RoutingAction { + CustomNotFound(PathBuf), + Execute(PathBuf), + NotFound, + Redirect(Uri), + Serve(PathBuf), +} + +#[expect(async_fn_in_trait)] +pub trait FileStore { + async fn contains(&self, path: &PathBuf) -> bool; +} + +pub trait RoutingConfig { + fn prefix(&self) -> &str; +} + +pub(crate) struct AppFileStore<'a> { + cache: &'a FileCache, + filesystem: &'a FileSystem, +} + +impl<'a> AppFileStore<'a> { + pub fn new( + cache: &'a FileCache, + filesystem: &'a FileSystem, + ) -> AppFileStore<'a> { + Self { cache, filesystem } + } +} + +impl FileStore for AppFileStore<'_> { + async fn contains(&self, path: &PathBuf) -> bool { + self.cache.contains(path).await || self.filesystem.contains(path).await + } +} + +pub async fn calculate_route( + path_and_query: &PathAndQuery, + store: &T, + config: &C, +) -> RoutingAction +where + T: FileStore, + C: RoutingConfig, +{ + let result = match check_path(path_and_query, config) { + Ok(path) => match path.clone().extension() { + None => calculate_route_without_extension(path_and_query, path, store).await, + Some(extension) => { + let ext = extension.to_str().expect("invalid file extension"); + find_file_or_not_found(&path, ext, store).await + } + }, + Err(action) => action, + }; + debug!("Route: [{}] -> {:?}", path_and_query, result); + result +} + +fn check_path(path_and_query: &PathAndQuery, config: &C) -> Result +where + C: RoutingConfig, +{ + match path_and_query.path().strip_prefix(config.prefix()) { + None => Err(Redirect( + config + .prefix() + .parse() + .expect("Expected prefix to be valid uri path"), + )), + Some(path) => Ok(PathBuf::from(path)), + } +} + +async fn calculate_route_without_extension( + path_and_query: &PathAndQuery, + mut path: PathBuf, + store: &T, +) -> RoutingAction +where + T: FileStore, +{ + if path_and_query.path().ends_with(FORWARD_SLASH) { + path.push(INDEX); + find_file_or_not_found(&path, SQL_EXTENSION, store).await + } else { + let path_with_ext = path.with_extension(SQL_EXTENSION); + match find_file(&path_with_ext, SQL_EXTENSION, store).await { + Some(action) => action, + None => Redirect(append_to_path(&path_and_query, FORWARD_SLASH)), + } + } +} + +async fn find_file_or_not_found(path: &PathBuf, extension: &str, store: &T) -> RoutingAction +where + T: FileStore, +{ + match find_file(path, extension, store).await { + None => find_not_found(path, store).await, + Some(execute) => execute, + } +} + +async fn find_file(path: &PathBuf, extension: &str, store: &T) -> Option +where + T: FileStore, +{ + if store.contains(path).await { + if extension == SQL_EXTENSION { + Some(Execute(path.clone())) + } else { + Some(Serve(path.clone())) + } + } else { + None + } +} + +async fn find_not_found(path: &PathBuf, store: &T) -> RoutingAction +where + T: FileStore, +{ + let mut parent = path.parent(); + while let Some(p) = parent { + let target = p.join(NOT_FOUND); + if store.contains(&target).await { + return CustomNotFound(target); + } + parent = p.parent() + } + + NotFound +} + +fn append_to_path(path_and_query: &PathAndQuery, append: &str) -> Uri { + let mut full_uri = path_and_query.to_string(); + full_uri.insert_str(path_and_query.path().len(), append); + full_uri.parse().expect("Could not append uri path") +} + +#[cfg(test)] +mod tests { + use super::RoutingAction::{CustomNotFound, Execute, NotFound, Redirect, Serve}; + use super::{calculate_route, FileStore, RoutingAction, RoutingConfig}; + use awc::http::uri::PathAndQuery; + use awc::http::Uri; + use std::default::Default as StdDefault; + use std::path::PathBuf; + use std::str::FromStr; + use StoreConfig::{Default, Empty, File}; + + mod execute { + use super::StoreConfig::{Default, File}; + use super::{do_route, execute}; + + #[tokio::test] + async fn root_path_executes_index() { + let actual = do_route("/", Default, None).await; + let expected = execute("index.sql"); + + assert_eq!(expected, actual); + } + + #[tokio::test] + async fn root_path_and_site_prefix_executes_index() { + let actual = do_route("/prefix/", Default, Some("/prefix/")).await; + let expected = execute("index.sql"); + + assert_eq!(expected, actual); + } + + #[tokio::test] + async fn extension() { + let actual = do_route("/index.sql", Default, None).await; + let expected = execute("index.sql"); + + assert_eq!(expected, actual); + } + + #[tokio::test] + async fn extension_and_site_prefix() { + let actual = do_route("/prefix/index.sql", Default, Some("/prefix/")).await; + let expected = execute("index.sql"); + + assert_eq!(expected, actual); + } + + #[tokio::test] + async fn no_extension() { + let actual = do_route("/path", File("path.sql"), None).await; + let expected = execute("path.sql"); + + assert_eq!(expected, actual); + } + + #[tokio::test] + async fn no_extension_and_site_prefix() { + let actual = do_route("/prefix/path", File("path.sql"), Some("/prefix/")).await; + let expected = execute("path.sql"); + + assert_eq!(expected, actual); + } + + #[tokio::test] + async fn trailing_slash_executes_index_in_directory() { + let actual = do_route("/folder/", File("folder/index.sql"), None).await; + let expected = execute("folder/index.sql"); + + assert_eq!(expected, actual); + } + + #[tokio::test] + async fn trailing_slash_and_site_prefix_executes_index_in_directory() { + let actual = do_route( + "/prefix/folder/", + File("folder/index.sql"), + Some("/prefix/"), + ) + .await; + let expected = execute("folder/index.sql"); + + assert_eq!(expected, actual); + } + } + + mod custom_not_found { + use super::StoreConfig::{Default, File}; + use super::{custom_not_found, do_route}; + + #[tokio::test] + async fn sql_extension() { + let actual = do_route("/unknown.sql", Default, None).await; + let expected = custom_not_found("404.sql"); + + assert_eq!(expected, actual); + } + + #[tokio::test] + async fn sql_extension_and_site_prefix() { + let actual = do_route("/prefix/unknown.sql", Default, Some("/prefix/")).await; + let expected = custom_not_found("404.sql"); + + assert_eq!(expected, actual); + } + + #[tokio::test] + async fn sql_extension_executes_deeper_not_found_file_if_exists() { + let actual = do_route("/unknown/unknown.sql", File("unknown/404.sql"), None).await; + let expected = custom_not_found("unknown/404.sql"); + + assert_eq!(expected, actual); + } + + #[tokio::test] + async fn sql_extension_and_site_prefix_executes_deeper_not_found_file_if_exists() { + let actual = do_route( + "/prefix/unknown/unknown.sql", + File("unknown/404.sql"), + Some("/prefix/"), + ) + .await; + let expected = custom_not_found("unknown/404.sql"); + + assert_eq!(expected, actual); + } + + #[tokio::test] + async fn sql_extension_executes_deepest_not_found_file_that_exists() { + let actual = do_route( + "/unknown/unknown/unknown.sql", + File("unknown/404.sql"), + None, + ) + .await; + let expected = custom_not_found("unknown/404.sql"); + + assert_eq!(expected, actual); + } + + #[tokio::test] + async fn sql_extension_and_site_prefix_executes_deepest_not_found_file_that_exists() { + let actual = do_route( + "/prefix/unknown/unknown/unknown.sql", + File("unknown/404.sql"), + Some("/prefix/"), + ) + .await; + let expected = custom_not_found("unknown/404.sql"); + + assert_eq!(expected, actual); + } + } + + mod not_found { + use super::StoreConfig::Empty; + use super::{default_not_found, do_route}; + + #[tokio::test] + async fn default_404_when_no_not_found_file_available() { + let actual = do_route("/unknown.sql", Empty, None).await; + let expected = default_not_found(); + + assert_eq!(expected, actual); + } + + #[tokio::test] + async fn default_404_when_no_not_found_file_available_and_site_prefix() { + let actual = do_route("/prefix/unknown.sql", Empty, Some("/prefix/")).await; + let expected = default_not_found(); + + assert_eq!(expected, actual); + } + + #[tokio::test] + async fn asset_not_found() { + let actual = do_route("/favicon.ico", Empty, None).await; + let expected = default_not_found(); + + assert_eq!(expected, actual); + } + } + + mod asset { + use super::StoreConfig::File; + use super::{do_route, serve}; + + #[tokio::test] + async fn serves_corresponding_asset() { + let actual = do_route("/favicon.ico", File("favicon.ico"), None).await; + let expected = serve("favicon.ico"); + + assert_eq!(expected, actual); + } + + #[tokio::test] + async fn asset_trims_query() { + let actual = do_route("/favicon.ico?version=10", File("favicon.ico"), None).await; + let expected = serve("favicon.ico"); + + assert_eq!(expected, actual); + } + + #[tokio::test] + async fn asset_trims_fragment() { + let actual = do_route("/favicon.ico#asset1", File("favicon.ico"), None).await; + let expected = serve("favicon.ico"); + + assert_eq!(expected, actual); + } + + #[tokio::test] + async fn serves_corresponding_asset_given_site_prefix() { + let actual = + do_route("/prefix/favicon.ico", File("favicon.ico"), Some("/prefix/")).await; + let expected = serve("favicon.ico"); + + assert_eq!(expected, actual); + } + } + + mod redirect { + use super::StoreConfig::Default; + use super::{do_route, redirect}; + + #[tokio::test] + async fn path_without_site_prefix_redirects_to_site_prefix() { + let actual = do_route("/path", Default, Some("/prefix/")).await; + let expected = redirect("/prefix/"); + + assert_eq!(expected, actual); + } + + #[tokio::test] + async fn no_extension_and_no_corresponding_file_redirects_with_trailing_slash() { + let actual = do_route("/folder", Default, None).await; + let expected = redirect("/folder/"); + + assert_eq!(expected, actual); + } + + #[tokio::test] + async fn no_extension_no_corresponding_file_redirects_with_trailing_slash_and_query() { + let actual = do_route("/folder?misc=1&foo=bar", Default, None).await; + let expected = redirect("/folder/?misc=1&foo=bar"); + + assert_eq!(expected, actual); + } + + #[tokio::test] + async fn no_extension_no_corresponding_file_redirects_with_trailing_slash_and_fragment() { + let actual = do_route("/folder#anchor1", Default, None).await; + let expected = redirect("/folder/#anchor1"); + + assert_eq!(expected, actual); + } + + #[tokio::test] + async fn no_extension_site_prefix_and_no_corresponding_file_redirects_with_trailing_slash() + { + let actual = do_route("/prefix/folder", Default, Some("/prefix/")).await; + let expected = redirect("/prefix/folder/"); + + assert_eq!(expected, actual); + } + } + + async fn do_route(path: &str, config: StoreConfig, prefix: Option<&str>) -> RoutingAction { + let store = match config { + Default => Store::default(), + Empty => Store::empty(), + File(file) => Store::new(file), + }; + let config = match prefix { + None => Config::default(), + Some(value) => Config::new(value), + }; + calculate_route(&PathAndQuery::from_str(path).unwrap(), &store, &config).await + } + + fn default_not_found() -> RoutingAction { + NotFound + } + + fn execute(path: &str) -> RoutingAction { + Execute(PathBuf::from(path)) + } + + fn custom_not_found(path: &str) -> RoutingAction { + CustomNotFound(PathBuf::from(path)) + } + + fn redirect(uri: &str) -> RoutingAction { + Redirect(Uri::from_str(uri).unwrap()) + } + + fn serve(path: &str) -> RoutingAction { + Serve(PathBuf::from(path)) + } + + enum StoreConfig { + Default, + Empty, + File(&'static str), + } + + struct Store { + contents: Vec, + } + + impl Store { + const INDEX: &'static str = "index.sql"; + const NOT_FOUND: &'static str = "404.sql"; + fn new(path: &str) -> Self { + let mut contents = Self::default_contents(); + contents.push(path.to_string()); + Self { contents } + } + + fn default_contents() -> Vec { + vec![Self::INDEX.to_string(), Self::NOT_FOUND.to_string()] + } + + fn empty() -> Self { + Self { contents: vec![] } + } + } + + impl StdDefault for Store { + fn default() -> Self { + Self { + contents: Self::default_contents(), + } + } + } + + impl FileStore for Store { + async fn contains(&self, path: &PathBuf) -> bool { + self.contents.contains(&path.to_string_lossy().to_string()) + } + } + + struct Config { + prefix: String, + } + + impl Config { + fn new(prefix: &str) -> Self { + Self { + prefix: prefix.to_string(), + } + } + } + impl RoutingConfig for Config { + fn prefix(&self) -> &str { + &self.prefix + } + } + + impl StdDefault for Config { + fn default() -> Self { + Self::new("/") + } + } +}