From d3673c54daf69e26e0796c0b675c95bef0e5eeb9 Mon Sep 17 00:00:00 2001 From: Ophir LOJKINE Date: Fri, 12 Jun 2026 15:33:34 +0200 Subject: [PATCH 1/2] refactor: split SQLPage functions into one module each Each built-in `sqlpage.*` function is now a plain `async fn` in its own file under `sqlpage_functions/functions/`, with an ordinary Rust signature and no marker comments or macros inside it. Registration is automatic: `build.rs` lists the files in `functions/` into a `sqlpage_functions!` call (the only generated code, one line per function), and that macro declares the modules and builds the `SqlPageFunctionName` enum the SQL engine dispatches on. There is no marker-comment parsing and no per-argument codegen. Argument extraction, dispatch and return-value conversion are ordinary generic code in function_traits.rs (`Extract`, `Handler`, `IntoCowResult`), using the same `Fn`-arity trick axum uses for handlers, so a function's argument and return types are read straight from its signature. Also folds in main's FileAccess centralization (#1327) for the file-reading functions. Verified: `cargo test` passes (148 lib + 70 integration tests, including run_all_sql_test_files, the hmac webhook tests, the upload tests and test_file_upload_through_runsql which exercises the &mut DbConn path). `cargo clippy` and `cargo fmt --check` are clean. --- build.rs | 30 + .../database/sqlpage_functions/README.md | 34 + .../function_definition_macro.rs | 90 -- .../sqlpage_functions/function_traits.rs | 281 +++- .../database/sqlpage_functions/functions.rs | 1155 +---------------- .../functions/basic_auth_password.rs | 25 + .../functions/basic_auth_username.rs | 7 + .../sqlpage_functions/functions/client_ip.rs | 5 + .../functions/configuration_directory.rs | 11 + .../sqlpage_functions/functions/cookie.rs | 5 + .../functions/current_working_directory.rs | 7 + .../functions/environment_variable.rs | 15 + .../sqlpage_functions/functions/exec.rs | 41 + .../sqlpage_functions/functions/fetch.rs | 167 +++ .../functions/fetch_with_meta.rs | 86 ++ .../functions/hash_password.rs | 28 + .../sqlpage_functions/functions/header.rs | 9 + .../sqlpage_functions/functions/headers.rs | 5 + .../sqlpage_functions/functions/hmac.rs | 74 ++ .../sqlpage_functions/functions/link.rs | 22 + .../functions/oidc_logout_url.rs | 34 + .../sqlpage_functions/functions/path.rs | 6 + .../functions/persist_uploaded_file.rs | 86 ++ .../sqlpage_functions/functions/protocol.rs | 6 + .../functions/random_string.rs | 22 + .../functions/read_file_as_data_url.rs | 35 + .../functions/read_file_as_text.rs | 17 + .../functions/regex_match.rs | 50 + .../functions/request_body.rs | 10 + .../functions/request_body_base64.rs | 15 + .../functions/request_method.rs | 5 + .../sqlpage_functions/functions/run_sql.rs | 72 + .../functions/set_variable.rs | 22 + .../functions/uploaded_file_mime_type.rs | 28 + .../functions/uploaded_file_name.rs | 13 + .../functions/uploaded_file_path.rs | 9 + .../sqlpage_functions/functions/url_encode.rs | 25 + .../sqlpage_functions/functions/user_info.rs | 83 ++ .../functions/user_info_token.rs | 9 + .../sqlpage_functions/functions/variables.rs | 45 + .../sqlpage_functions/functions/version.rs | 5 + .../sqlpage_functions/functions/web_root.rs | 11 + .../database/sqlpage_functions/mod.rs | 1 - 43 files changed, 1406 insertions(+), 1300 deletions(-) create mode 100644 src/webserver/database/sqlpage_functions/README.md delete mode 100644 src/webserver/database/sqlpage_functions/function_definition_macro.rs create mode 100644 src/webserver/database/sqlpage_functions/functions/basic_auth_password.rs create mode 100644 src/webserver/database/sqlpage_functions/functions/basic_auth_username.rs create mode 100644 src/webserver/database/sqlpage_functions/functions/client_ip.rs create mode 100644 src/webserver/database/sqlpage_functions/functions/configuration_directory.rs create mode 100644 src/webserver/database/sqlpage_functions/functions/cookie.rs create mode 100644 src/webserver/database/sqlpage_functions/functions/current_working_directory.rs create mode 100644 src/webserver/database/sqlpage_functions/functions/environment_variable.rs create mode 100644 src/webserver/database/sqlpage_functions/functions/exec.rs create mode 100644 src/webserver/database/sqlpage_functions/functions/fetch.rs create mode 100644 src/webserver/database/sqlpage_functions/functions/fetch_with_meta.rs create mode 100644 src/webserver/database/sqlpage_functions/functions/hash_password.rs create mode 100644 src/webserver/database/sqlpage_functions/functions/header.rs create mode 100644 src/webserver/database/sqlpage_functions/functions/headers.rs create mode 100644 src/webserver/database/sqlpage_functions/functions/hmac.rs create mode 100644 src/webserver/database/sqlpage_functions/functions/link.rs create mode 100644 src/webserver/database/sqlpage_functions/functions/oidc_logout_url.rs create mode 100644 src/webserver/database/sqlpage_functions/functions/path.rs create mode 100644 src/webserver/database/sqlpage_functions/functions/persist_uploaded_file.rs create mode 100644 src/webserver/database/sqlpage_functions/functions/protocol.rs create mode 100644 src/webserver/database/sqlpage_functions/functions/random_string.rs create mode 100644 src/webserver/database/sqlpage_functions/functions/read_file_as_data_url.rs create mode 100644 src/webserver/database/sqlpage_functions/functions/read_file_as_text.rs create mode 100644 src/webserver/database/sqlpage_functions/functions/regex_match.rs create mode 100644 src/webserver/database/sqlpage_functions/functions/request_body.rs create mode 100644 src/webserver/database/sqlpage_functions/functions/request_body_base64.rs create mode 100644 src/webserver/database/sqlpage_functions/functions/request_method.rs create mode 100644 src/webserver/database/sqlpage_functions/functions/run_sql.rs create mode 100644 src/webserver/database/sqlpage_functions/functions/set_variable.rs create mode 100644 src/webserver/database/sqlpage_functions/functions/uploaded_file_mime_type.rs create mode 100644 src/webserver/database/sqlpage_functions/functions/uploaded_file_name.rs create mode 100644 src/webserver/database/sqlpage_functions/functions/uploaded_file_path.rs create mode 100644 src/webserver/database/sqlpage_functions/functions/url_encode.rs create mode 100644 src/webserver/database/sqlpage_functions/functions/user_info.rs create mode 100644 src/webserver/database/sqlpage_functions/functions/user_info_token.rs create mode 100644 src/webserver/database/sqlpage_functions/functions/variables.rs create mode 100644 src/webserver/database/sqlpage_functions/functions/version.rs create mode 100644 src/webserver/database/sqlpage_functions/functions/web_root.rs diff --git a/build.rs b/build.rs index 6f9f4a963..b8f78d65f 100644 --- a/build.rs +++ b/build.rs @@ -17,6 +17,7 @@ async fn main() { .unwrap(); println!("cargo:rerun-if-changed=build.rs"); + generate_sqlpage_functions(); let c = Rc::new(make_client()); for h in [ @@ -35,6 +36,35 @@ async fn main() { set_odbc_rpath(); } +/// Lists the modules in `sqlpage_functions/functions/` into a `sqlpage_functions! { ... }` call, so +/// built-in SQL functions register themselves just by having a file, with no hand-maintained list. +fn generate_sqlpage_functions() { + const DIR: &str = "src/webserver/database/sqlpage_functions/functions"; + println!("cargo:rerun-if-changed={DIR}"); + let out = PathBuf::from(std::env::var("OUT_DIR").unwrap()).join("sqlpage_functions.rs"); + let Ok(dir) = std::fs::read_dir(DIR) else { + // The source tree isn't present yet (e.g. the dependency-only Docker build stage, which + // compiles before `src/` is copied in). Emit an empty registry; once the directory exists + // the `rerun-if-changed` above makes the real build run this again. + std::fs::write(&out, "sqlpage_functions! {}\n").unwrap(); + return; + }; + let mut files: Vec = dir + .map(|entry| entry.unwrap().path()) + .filter(|path| path.extension().is_some_and(|ext| ext == "rs")) + .collect(); + files.sort(); + let entries: String = files + .iter() + .map(|path| { + let name = path.file_stem().unwrap().to_str().unwrap(); + let abs = path.canonicalize().unwrap(); + format!(" {name} = {abs:?},\n") + }) + .collect(); + std::fs::write(out, format!("sqlpage_functions! {{\n{entries}}}\n")).unwrap(); +} + fn make_client() -> awc::Client { awc::ClientBuilder::new() .timeout(Duration::from_secs(10)) diff --git a/src/webserver/database/sqlpage_functions/README.md b/src/webserver/database/sqlpage_functions/README.md new file mode 100644 index 000000000..63b981d38 --- /dev/null +++ b/src/webserver/database/sqlpage_functions/README.md @@ -0,0 +1,34 @@ +# Built-in SQL functions + +Each built-in `sqlpage.*` function is a plain `async fn` in its own file in [`functions/`](functions), +with an ordinary Rust signature: + +```rust +#[allow(clippy::wildcard_imports)] +use super::*; + +pub(super) async fn example(request: &RequestInfo, value: Option>) -> Option { + // ... +} +``` + +To register it, add one line to the list in [`functions.rs`](functions.rs), giving the SQL names of +its arguments (used only in error messages): + +```rust +sqlpage_functions! { + // ... + example("value"); +} +``` + +The [`sqlpage_functions!`](function_traits.rs) macro declares the modules and generates the +`SqlPageFunctionName` enum the SQL engine dispatches on. That is the only compile-time code: there is +no build script involvement and no per-function generated code. Argument extraction, dispatch, and +return-value conversion are handled generically in [`function_traits.rs`](function_traits.rs) by the +`Extract`, `Handler`, and `IntoCowResult` traits. A function's argument and return types are read +straight from its signature, so the supported argument types are exactly those that implement +`Extract` (add an `impl` there to support a new one). + +Keep helpers and unit tests that are specific to a function in that function's file. Shared helpers can +be made `pub(super)` and used by sibling function modules through `use super::*`. diff --git a/src/webserver/database/sqlpage_functions/function_definition_macro.rs b/src/webserver/database/sqlpage_functions/function_definition_macro.rs deleted file mode 100644 index 235b7ffa2..000000000 --- a/src/webserver/database/sqlpage_functions/function_definition_macro.rs +++ /dev/null @@ -1,90 +0,0 @@ -/// Defines all sqlpage functions -#[macro_export] -macro_rules! sqlpage_functions { - ($($func_name:ident( - $(($request:ty $(, $db_conn:ty)?))? - $(,)? - $($param_name:ident : $param_type:ty),* - ); - )*) => { - #[derive(Debug, PartialEq, Eq, Clone, Copy)] - pub enum SqlPageFunctionName { - $( #[allow(non_camel_case_types)] $func_name ),* - } - - impl ::std::str::FromStr for SqlPageFunctionName { - type Err = anyhow::Error; - - fn from_str(s: &str) -> anyhow::Result { - match s { - $(stringify!($func_name) => Ok(SqlPageFunctionName::$func_name),)* - unknown_name => anyhow::bail!( - "Unknown function {unknown_name:?}.\n\ - Supported functions: \n\ - {}", [$(SqlPageFunctionName::$func_name),*] - .iter() - .map(|f| format!(" - {f:#}\n")) - .collect::()) - } - } - } - - impl std::fmt::Display for SqlPageFunctionName { - fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { - match self { - $(SqlPageFunctionName::$func_name => { - write!(f, "sqlpage.{}", stringify!($func_name))?; - if f.alternate() { - write!(f, "(")?; - let mut _first = true; - $( - if !_first { - write!(f, ", ")?; - } - write!(f, "{}", stringify!($param_name))?; - _first = false; - )* - write!(f, ")")?; - } - Ok(()) - }),* - } - } - } - impl SqlPageFunctionName { - pub(crate) async fn evaluate<'a>( - &self, - #[allow(unused_variables)] - request: &'a $crate::webserver::http_request_info::ExecutionContext, - db_connection: &mut Option>, - params: Vec>> - ) -> anyhow::Result>> { - use $crate::webserver::database::sqlpage_functions::function_traits::*; - match self { - $( - SqlPageFunctionName::$func_name => { - let mut iter_params = params.into_iter(); - $( - let $param_name = <$param_type as FunctionParamType<'_>>::from_args(&mut iter_params) - .with_context(|| format!("Invalid value for parameter {}", stringify!($param_name)))?; - )* - if let Some(extraneous_param) = iter_params.next() { - anyhow::bail!("Too many arguments. Remove extra argument {}", as_sql(extraneous_param)); - } - let result = $func_name( - $( - <$request>::from(request), - $(<$db_conn>::from(db_connection),)* - )* - $($param_name.into()),* - ).await; - result.into_cow_result() - } - )* - } - } - } - } -} - -pub use sqlpage_functions; diff --git a/src/webserver/database/sqlpage_functions/function_traits.rs b/src/webserver/database/sqlpage_functions/function_traits.rs index b3125a0dc..abeab1127 100644 --- a/src/webserver/database/sqlpage_functions/function_traits.rs +++ b/src/webserver/database/sqlpage_functions/function_traits.rs @@ -1,118 +1,188 @@ -use std::{borrow::Cow, str::FromStr}; +//! Dispatch machinery for the built-in `sqlpage.*` SQL functions. +//! +//! Each function is a plain `async fn` in its own module under [`functions/`](super::functions). +//! `build.rs` lists those modules and the [`sqlpage_functions!`] macro turns the list into the +//! [`SqlPageFunctionName`](super::functions::SqlPageFunctionName) enum the engine dispatches on, so +//! functions register themselves just by existing. Adapting each signature to the uniform +//! `(request, db, args) -> Option` convention is done generically by [`Extract`] (per +//! argument type), [`Handler`] (per argument count, the trick `axum` uses) and [`IntoCowResult`] +//! (per return type); the macro itself carries no type-level glue. + +use std::borrow::Cow; +use std::future::Future; use anyhow::Context as _; -pub(super) fn as_sql(param: Option>) -> String { +use super::http_fetch_request::HttpFetchRequest; +use crate::webserver::database::execute_queries::DbConn; +use crate::webserver::http_request_info::{ExecutionContext, RequestInfo}; + +/// Renders a SQL argument as it would appear in a query, for error messages. +fn as_sql(param: Option>) -> String { param.map_or_else(|| "NULL".into(), |x| format!("'{}'", x.replace('\'', "''"))) } -pub(super) trait FunctionParamType<'a>: Sized { - type TargetType: 'a; - fn from_args( - arg: &mut std::vec::IntoIter>>, - ) -> anyhow::Result; +/// The request, optional database connection, and evaluated SQL arguments a function call works on. +pub(crate) struct FunctionContext<'a, 'c> { + request: &'a ExecutionContext, + db: Option<&'c mut DbConn>, + arguments: std::vec::IntoIter>>, } -impl<'a> FunctionParamType<'a> for Option> { - type TargetType = Self; - fn from_args(arg: &mut std::vec::IntoIter>>) -> anyhow::Result { - Ok(arg.next().flatten()) +impl<'a, 'c> FunctionContext<'a, 'c> { + pub(crate) fn new( + request: &'a ExecutionContext, + db_connection: &'c mut DbConn, + arguments: Vec>>, + ) -> Self { + Self { + request, + db: Some(db_connection), + arguments: arguments.into_iter(), + } + } + + /// The next argument, treating both a missing and an explicit `NULL` argument as `None`. + fn next_arg(&mut self) -> Option> { + self.arguments.next().flatten() + } + + fn next_required(&mut self) -> anyhow::Result> { + self.next_arg() + .ok_or_else(|| anyhow::anyhow!("Unexpected NULL value")) } -} -impl<'a> FunctionParamType<'a> for Vec>> { - type TargetType = Self; - fn from_args(arg: &mut std::vec::IntoIter>>) -> anyhow::Result { - Ok(arg.collect()) + fn expect_no_extra_args(&mut self) -> anyhow::Result<()> { + match self.arguments.next() { + None => Ok(()), + Some(extra) => anyhow::bail!( + "Too many arguments. Remove extra argument {}", + as_sql(extra) + ), + } } } -impl<'a> FunctionParamType<'a> for Vec> { - type TargetType = Self; - fn from_args(arg: &mut std::vec::IntoIter>>) -> anyhow::Result { - Ok(arg.flatten().collect()) +/// Obtains one function argument from the context. Implemented per concrete argument type, so the +/// context is only borrowed briefly and the extracted values can coexist while the function runs. +pub(crate) trait Extract<'a, 'c>: Sized { + fn extract(ctx: &mut FunctionContext<'a, 'c>) -> anyhow::Result; +} + +impl<'a, 'c> Extract<'a, 'c> for &'a RequestInfo { + fn extract(ctx: &mut FunctionContext<'a, 'c>) -> anyhow::Result { + Ok(ctx.request.into()) } } -impl<'a> FunctionParamType<'a> for Cow<'a, str> { - type TargetType = Self; - fn from_args(arg: &mut std::vec::IntoIter>>) -> anyhow::Result { - >>::from_args(arg)? - .ok_or_else(|| anyhow::anyhow!("Unexpected NULL value")) +impl<'a, 'c> Extract<'a, 'c> for &'a ExecutionContext { + fn extract(ctx: &mut FunctionContext<'a, 'c>) -> anyhow::Result { + Ok(ctx.request) } } -impl<'a> FunctionParamType<'a> for String { - type TargetType = Self; - fn from_args(arg: &mut std::vec::IntoIter>>) -> anyhow::Result { - Ok(>::from_args(arg)?.into_owned()) +impl<'a, 'c> Extract<'a, 'c> for &'c mut DbConn { + fn extract(ctx: &mut FunctionContext<'a, 'c>) -> anyhow::Result { + ctx.db + .take() + .context("This function cannot be called in this context (no database connection)") } } -impl<'a> FunctionParamType<'a> for Option { - type TargetType = Self; - fn from_args(arg: &mut std::vec::IntoIter>>) -> anyhow::Result { - >>::from_args(arg).map(|x| x.map(Cow::into_owned)) +impl<'a, 'c> Extract<'a, 'c> for Cow<'a, str> { + fn extract(ctx: &mut FunctionContext<'a, 'c>) -> anyhow::Result { + ctx.next_required() } } -/// similar to `FromStr`, but borrows the input string -pub(super) trait BorrowFromStr<'a>: Sized { - fn borrow_from_str(s: Cow<'a, str>) -> anyhow::Result; +impl<'a, 'c> Extract<'a, 'c> for Option> { + fn extract(ctx: &mut FunctionContext<'a, 'c>) -> anyhow::Result { + Ok(ctx.next_arg()) + } } -impl<'a, T: FromStr> BorrowFromStr<'a> for T -where - ::Err: Sync + Send + std::error::Error + 'static, -{ - fn borrow_from_str(s: Cow<'a, str>) -> anyhow::Result { - s.parse() - .with_context(|| format!("Unable to parse {s:?} as {}", std::any::type_name::())) +impl<'a, 'c> Extract<'a, 'c> for Option { + fn extract(ctx: &mut FunctionContext<'a, 'c>) -> anyhow::Result { + Ok(ctx.next_arg().map(Cow::into_owned)) } } -pub(super) struct SqlPageFunctionParam(pub T); +/// Collects the remaining arguments (dropping `NULL`s) for variadic functions. +impl<'a, 'c> Extract<'a, 'c> for Vec> { + fn extract(ctx: &mut FunctionContext<'a, 'c>) -> anyhow::Result { + Ok(ctx.arguments.by_ref().flatten().collect()) + } +} -impl<'a, T: BorrowFromStr<'a> + Sized + 'a> FunctionParamType<'a> for SqlPageFunctionParam { - type TargetType = T; +impl<'a, 'c> Extract<'a, 'c> for usize { + fn extract(ctx: &mut FunctionContext<'a, 'c>) -> anyhow::Result { + let arg = ctx.next_required()?; + arg.parse() + .with_context(|| format!("Unable to parse {arg:?} as a positive integer")) + } +} - fn from_args( - arg: &mut std::vec::IntoIter>>, - ) -> anyhow::Result { - let param = >::from_args(arg)?; - T::borrow_from_str(param) +impl<'a, 'c> Extract<'a, 'c> for Option> { + fn extract(ctx: &mut FunctionContext<'a, 'c>) -> anyhow::Result { + ctx.next_arg() + .map(HttpFetchRequest::borrow_from_str) + .transpose() } } -impl<'a, T: BorrowFromStr<'a> + Sized + 'a> FunctionParamType<'a> - for Option> -{ - type TargetType = Option; +/// Like [`FromStr`](std::str::FromStr) but able to borrow from the input (see [`HttpFetchRequest`]). +pub(crate) trait BorrowFromStr<'a>: Sized { + fn borrow_from_str(s: Cow<'a, str>) -> anyhow::Result; +} - fn from_args( - arg: &mut std::vec::IntoIter>>, - ) -> anyhow::Result { - let param = >>::from_args(arg)?; - let res = if let Some(param) = param { - Some(T::borrow_from_str(param)?) - } else { - None - }; - Ok(res) - } +/// Implemented for every `async fn` whose arguments all [`Extract`] and whose output +/// [`IntoCowResult`]. One `impl_handler!` line per argument count; adding an argument is one more. +pub(crate) trait Handler<'a, 'c, Args> { + fn call( + self, + ctx: FunctionContext<'a, 'c>, + ) -> impl Future>>>; } -pub(super) trait FunctionResultType<'a> { +macro_rules! impl_handler { + ($($arg:ident),*) => { + impl<'a, 'c, Func, Fut, Ret $(, $arg)*> Handler<'a, 'c, ($($arg,)*)> for Func + where + 'a: 'c, + Func: Fn($($arg),*) -> Fut, + Fut: Future, + Ret: IntoCowResult<'a>, + $($arg: Extract<'a, 'c>,)* + { + #[allow(non_snake_case, unused_mut)] + async fn call(self, mut ctx: FunctionContext<'a, 'c>) -> anyhow::Result>> { + $(let $arg = $arg::extract(&mut ctx)?;)* + ctx.expect_no_extra_args()?; + self($($arg),*).await.into_cow_result() + } + } + }; +} + +impl_handler!(); +impl_handler!(A0); +impl_handler!(A0, A1); +impl_handler!(A0, A1, A2); +impl_handler!(A0, A1, A2, A3); +impl_handler!(A0, A1, A2, A3, A4); + +/// Normalises a function's return value into what the SQL engine consumes. +pub(crate) trait IntoCowResult<'a> { fn into_cow_result(self) -> anyhow::Result>>; } -impl<'a, T: IntoCow<'a>> FunctionResultType<'a> for anyhow::Result { +impl<'a, T: IntoCow<'a>> IntoCowResult<'a> for anyhow::Result { fn into_cow_result(self) -> anyhow::Result>> { self.map(IntoCow::into_cow) } } -impl<'a, T: IntoCow<'a>> FunctionResultType<'a> for T { +impl<'a, T: IntoCow<'a>> IntoCowResult<'a> for T { fn into_cow_result(self) -> anyhow::Result>> { Ok(self.into_cow()) } @@ -134,7 +204,7 @@ impl<'a> IntoCow<'a> for String { } } -impl<'a> IntoCow<'a> for &'a str { +impl<'a, 'b: 'a> IntoCow<'a> for &'b str { fn into_cow(self) -> Option> { Some(Cow::Borrowed(self)) } @@ -145,3 +215,74 @@ impl<'a, T: IntoCow<'a>> IntoCow<'a> for Option { self.and_then(IntoCow::into_cow) } } + +/// Declares the listed function modules and builds the [`SqlPageFunctionName`] dispatch enum from +/// them. The list is produced by `build.rs`, so there is no hand-maintained registry. +macro_rules! sqlpage_functions { + ($($func:ident = $path:literal),* $(,)?) => { + $( + // `build.rs` passes the absolute path because the `mod` is expanded from a file + // `include!`d out of `OUT_DIR`, where the default relative lookup would not find it. + #[path = $path] + mod $func; + // Re-export so sibling modules can use each other's helpers via `use super::*`. + #[allow(unused_imports)] + use $func::*; + )* + + /// One variant per built-in `sqlpage.*` function. + #[derive(Debug, PartialEq, Eq, Clone, Copy)] + #[allow(non_camel_case_types)] + pub enum SqlPageFunctionName { + $($func),* + } + + impl SqlPageFunctionName { + pub(crate) async fn evaluate<'a, 'c>( + self, + request: &'a ExecutionContext, + db_connection: &'c mut DbConn, + arguments: Vec>>, + ) -> anyhow::Result>> + where + 'a: 'c, + { + use $crate::webserver::database::sqlpage_functions::function_traits::{ + FunctionContext, Handler, + }; + let ctx = FunctionContext::new(request, db_connection, arguments); + match self { + $(SqlPageFunctionName::$func => Handler::call($func::$func, ctx).await),* + } + } + } + + impl ::std::str::FromStr for SqlPageFunctionName { + type Err = anyhow::Error; + + fn from_str(name: &str) -> anyhow::Result { + match name { + $(stringify!($func) => Ok(SqlPageFunctionName::$func),)* + unknown => anyhow::bail!( + "Unknown function {unknown:?}. Supported functions:\n{}", + [$(SqlPageFunctionName::$func),*] + .iter() + .map(|f| format!(" - {f}\n")) + .collect::() + ), + } + } + } + + impl ::std::fmt::Display for SqlPageFunctionName { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + f.write_str("sqlpage.")?; + f.write_str(match self { + $(SqlPageFunctionName::$func => stringify!($func)),* + }) + } + } + }; +} + +pub(crate) use sqlpage_functions; diff --git a/src/webserver/database/sqlpage_functions/functions.rs b/src/webserver/database/sqlpage_functions/functions.rs index d931bdb74..c5c67c39a 100644 --- a/src/webserver/database/sqlpage_functions/functions.rs +++ b/src/webserver/database/sqlpage_functions/functions.rs @@ -1,3 +1,15 @@ +//! Built-in `SQLPage` SQL functions. +//! +//! Every function is a plain `async fn` in its own module under [`functions/`](self). To add one, +//! just create `functions/.rs` with an `async fn `: `build.rs` discovers it and emits +//! a [`sqlpage_functions!`](super::function_traits::sqlpage_functions) call (the file `include!`d +//! below) that declares the module and adds it to the dispatch enum. Argument conversion and +//! dispatch are handled generically in [`super::function_traits`]. + +// Every function module reaches the shared imports below, and its siblings' `pub(super)` helpers, +// through `use super::*`. Allow it once here rather than on each module. +#![allow(clippy::wildcard_imports)] + use super::{ExecutionContext, RequestInfo}; use crate::filesystem::FileAccess; use crate::webserver::{ @@ -19,1143 +31,8 @@ use std::fmt::Write; use std::{borrow::Cow, ffi::OsStr, str::FromStr}; use tracing::Instrument; -super::function_definition_macro::sqlpage_functions! { - basic_auth_password((&RequestInfo)); - basic_auth_username((&RequestInfo)); - - client_ip((&RequestInfo)); - configuration_directory((&RequestInfo)); - cookie((&RequestInfo), name: Cow); - current_working_directory(); - - environment_variable(name: Cow); - exec((&RequestInfo), program_name: Cow, args: Vec>); - - fetch((&RequestInfo), http_request: Option>>); - fetch_with_meta((&RequestInfo), http_request: Option>>); - - hash_password(password: Option); - header((&RequestInfo), name: Cow); - headers((&RequestInfo)); - hmac(data: Cow, key: Cow, algorithm: Option>); - - oidc_logout_url((&RequestInfo), redirect_uri: Option>); - - user_info_token((&RequestInfo)); - link(file: Cow, parameters: Option>, hash: Option>); - - path((&RequestInfo)); - persist_uploaded_file((&RequestInfo), field_name: Cow, folder: Option>, allowed_extensions: Option>, mode: Option>); - protocol((&RequestInfo)); - - random_string(string_length: SqlPageFunctionParam); - read_file_as_data_url((&RequestInfo), file_path: Option>); - read_file_as_text((&RequestInfo), file_path: Option>); - regex_match(pattern: Cow, text: Option>); - request_method((&RequestInfo)); - run_sql((&ExecutionContext, &mut DbConn), sql_file_path: Option>, variables: Option>); - set_variable((&ExecutionContext), name: Cow, value: Option>); - - uploaded_file_mime_type((&RequestInfo), upload_name: Cow); - uploaded_file_path((&RequestInfo), upload_name: Cow); - uploaded_file_name((&RequestInfo), upload_name: Cow); - url_encode(raw_text: Option>); - user_info((&RequestInfo), claim: Cow); - - variables((&ExecutionContext), get_or_post: Option>); - version(); - web_root((&RequestInfo)); - request_body((&RequestInfo)); - request_body_base64((&RequestInfo)); -} - -/// Returns the password from the HTTP basic auth header, if present. -async fn basic_auth_password(request: &RequestInfo) -> anyhow::Result<&str> { - let password = extract_basic_auth(request)?.password().ok_or_else(|| { - anyhow::Error::new(ErrorWithStatus { - status: actix_web::http::StatusCode::UNAUTHORIZED, - }) - })?; - Ok(password) -} - -/// Returns the username from the HTTP basic auth header, if present. -/// Otherwise, returns an HTTP 401 Unauthorized error. -async fn basic_auth_username(request: &RequestInfo) -> anyhow::Result<&str> { - Ok(extract_basic_auth(request)?.user_id()) -} - -fn extract_basic_auth( - request: &RequestInfo, -) -> anyhow::Result<&actix_web_httpauth::headers::authorization::Basic> { - request - .basic_auth - .as_ref() - .ok_or_else(|| { - anyhow::Error::new(ErrorWithStatus { - status: actix_web::http::StatusCode::UNAUTHORIZED, - }) - }) - .with_context(|| "Expected the user to be authenticated with HTTP basic auth") -} - -async fn cookie<'a>(request: &'a RequestInfo, name: Cow<'a, str>) -> Option> { - request.cookies.get(&*name).map(SingleOrVec::as_json_str) -} - -/// Returns the directory where the sqlpage.json configuration file, templates, and migrations are located. -async fn configuration_directory(request: &RequestInfo) -> String { - request - .app_state - .config - .configuration_directory - .to_string_lossy() - .into_owned() -} - -async fn current_working_directory() -> anyhow::Result { - std::env::current_dir() - .with_context(|| "unable to access the current working directory") - .map(|x| x.to_string_lossy().into_owned()) -} - -/// Returns the value of an environment variable. -async fn environment_variable(name: Cow<'_, str>) -> anyhow::Result>> { - match std::env::var(&*name) { - Ok(value) => Ok(Some(Cow::Owned(value))), - Err(std::env::VarError::NotPresent) if name.contains(['=', '\0']) => anyhow::bail!( - "Invalid environment variable name: {name:?}. Environment variable names cannot contain an equals sign or a null character." - ), - Err(std::env::VarError::NotPresent) => Ok(None), - Err(err) => { - Err(err).with_context(|| format!("unable to read the environment variable {name:?}")) - } - } -} - -/// Executes an external command and returns its output. -async fn exec<'a>( - request: &'a RequestInfo, - program_name: Cow<'a, str>, - args: Vec>, -) -> anyhow::Result { - if !request.app_state.config.allow_exec { - anyhow::bail!("The sqlpage.exec() function is disabled in the configuration, for security reasons. - Make sure you understand the security implications before enabling it, and never allow user input to be passed as the first argument to this function. - You can enable it by setting the allow_exec option to true in the sqlpage.json configuration file.") - } - let exec_span = tracing::info_span!( - "subprocess", - otel.name = format!("EXEC {program_name}"), - process.command = %program_name, - process.args_count = args.len(), - ); - let res = tokio::process::Command::new(&*program_name) - .args(args.iter().map(|x| &**x)) - .output() - .instrument(exec_span) - .await - .with_context(|| { - let mut s = format!("Unable to execute command: {program_name}"); - for arg in args { - s.push(' '); - s.push_str(&arg); - } - s - })?; - if !res.status.success() { - anyhow::bail!( - "Command '{program_name}' failed with exit code {}: {}", - res.status, - String::from_utf8_lossy(&res.stderr) - ); - } - Ok(String::from_utf8_lossy(&res.stdout).into_owned()) -} - -fn build_request<'a>( - client: &'a awc::Client, - http_request: &'a super::http_fetch_request::HttpFetchRequest<'_>, -) -> anyhow::Result { - use awc::http::Method; - let method = if let Some(method) = &http_request.method { - Method::from_str(method).with_context(|| format!("Invalid HTTP method: {method}"))? - } else { - Method::GET - }; - let mut req = client.request(method, http_request.url.as_ref()); - if let Some(timeout) = http_request.timeout_ms { - req = req.timeout(core::time::Duration::from_millis(timeout)); - } - for (k, v) in &http_request.headers { - req = req.insert_header((k.as_ref(), v.as_ref())); - } - if let Some(username) = &http_request.username { - let password = http_request.password.as_deref().unwrap_or_default(); - req = req.basic_auth(username, password); - } - Ok(req) -} - -fn prepare_request_body( - body: &serde_json::value::RawValue, - mut req: awc::ClientRequest, -) -> anyhow::Result<(String, awc::ClientRequest)> { - let val = body.get(); - let body_str = if val.starts_with('"') { - serde_json::from_str::<'_, String>(val).with_context(|| { - format!("Invalid JSON string in the body of the HTTP request: {val}") - })? - } else { - req = req.content_type("application/json"); - val.to_owned() - }; - Ok((body_str, req)) -} - -async fn fetch( - request: &RequestInfo, - http_request: Option>, -) -> anyhow::Result> { - let Some(http_request) = http_request else { - return Ok(None); - }; - let method = http_request.method.as_deref().unwrap_or("GET"); - let fetch_span = tracing::info_span!( - "http.client", - "otel.name" = format!("{method}"), - { otel::HTTP_REQUEST_METHOD } = method, - { otel::URL_FULL } = %http_request.url, - { otel::HTTP_REQUEST_BODY_SIZE } = tracing::field::Empty, - { otel::HTTP_RESPONSE_STATUS_CODE } = tracing::field::Empty, - ); - - async { - let client = make_http_client(&request.app_state.config) - .with_context(|| "Unable to create an HTTP client")?; - let req = build_request(&client, &http_request)?; - - log::info!("Fetching {}", http_request.url); - let mut response = if let Some(body) = &http_request.body { - let (body, req) = prepare_request_body(body, req)?; - tracing::Span::current().record( - otel::HTTP_REQUEST_BODY_SIZE, - i64::try_from(body.len()).unwrap_or(i64::MAX), - ); - req.send_body(body) - } else { - req.send() - } - .await - .map_err(|e| anyhow!("Unable to fetch {}: {e}", http_request.url))?; - - tracing::Span::current().record( - otel::HTTP_RESPONSE_STATUS_CODE, - i64::from(response.status().as_u16()), - ); - - log::debug!( - "Finished fetching {}. Status: {}", - http_request.url, - response.status() - ); - log::debug!( - "Fetch response headers for {}: content_type={:?}", - http_request.url, - response - .headers() - .get("content-type") - .and_then(|value| value.to_str().ok()) - ); - - let body = response - .body() - .await - .with_context(|| { - format!( - "Unable to read the body of the response from {}", - http_request.url - ) - })? - .to_vec(); - log::debug!( - "Fetched {} response body: body_len={} bytes, encoding={:?}", - http_request.url, - body.len(), - http_request.response_encoding - ); - let response_str = decode_response(body, http_request.response_encoding.as_deref())?; - Ok(Some(response_str)) - } - .instrument(fetch_span) - .await -} - -fn decode_response(response: Vec, encoding: Option<&str>) -> anyhow::Result { - match encoding { - Some("base64") => Ok(base64::Engine::encode( - &base64::engine::general_purpose::STANDARD, - response, - )), - Some("base64url") => Ok(base64::Engine::encode( - &base64::engine::general_purpose::URL_SAFE, - response, - )), - Some("hex") => Ok(response.into_iter().fold(String::new(), |mut acc, byte| { - write!(&mut acc, "{byte:02x}").unwrap(); - acc - })), - Some(encoding_label) => Ok(encoding_rs::Encoding::for_label(encoding_label.as_bytes()) - .with_context(|| format!("Invalid encoding name: {encoding_label}"))? - .decode(&response) - .0 - .into_owned()), - None => { - let body_str = String::from_utf8(response); - match body_str { - Ok(body_str) => Ok(body_str), - Err(decoding_error) => { - log::warn!( - "fetch(...) response is not UTF-8 and no encoding was specified. Decoding the response as base64. Please explicitly set the encoding to \"base64\" if this is the expected behavior." - ); - Ok(base64::Engine::encode( - &base64::engine::general_purpose::STANDARD, - decoding_error.into_bytes(), - )) - } - } - } - } -} - -async fn fetch_with_meta( - request: &RequestInfo, - http_request: Option>, -) -> anyhow::Result> { - use serde::{Serializer, ser::SerializeMap}; - - let Some(http_request) = http_request else { - return Ok(None); - }; - - let method = http_request.method.as_deref().unwrap_or("GET"); - let fetch_span = tracing::info_span!( - "http.client", - "otel.name" = format!("{method}"), - { otel::HTTP_REQUEST_METHOD } = method, - { otel::URL_FULL } = %http_request.url, - { otel::HTTP_REQUEST_BODY_SIZE } = tracing::field::Empty, - { otel::HTTP_RESPONSE_STATUS_CODE } = tracing::field::Empty, - ); - - async { - let client = make_http_client(&request.app_state.config) - .with_context(|| "Unable to create an HTTP client")?; - let req = build_request(&client, &http_request)?; - - log::info!("Fetching {} with metadata", http_request.url); - let response_result = if let Some(body) = &http_request.body { - let (body, req) = prepare_request_body(body, req)?; - tracing::Span::current().record( - otel::HTTP_REQUEST_BODY_SIZE, - i64::try_from(body.len()).unwrap_or(i64::MAX), - ); - req.send_body(body).await - } else { - req.send().await - }; - - let mut resp_str = Vec::new(); - let mut encoder = serde_json::Serializer::new(&mut resp_str); - let mut obj = encoder.serialize_map(Some(3))?; - match response_result { - Ok(mut response) => { - let status = response.status(); - tracing::Span::current() - .record(otel::HTTP_RESPONSE_STATUS_CODE, i64::from(status.as_u16())); - obj.serialize_entry("status", &status.as_u16())?; - let mut has_error = false; - if status.is_server_error() { - has_error = true; - obj.serialize_entry("error", &format!("Server error: {status}"))?; - } - - let headers = response.headers(); - - let is_json = headers - .get("content-type") - .and_then(|v| v.to_str().ok()) - .unwrap_or_default() - .starts_with("application/json"); - - obj.serialize_entry( - "headers", - &headers - .iter() - .map(|(k, v)| (k.to_string(), v.to_str().unwrap_or_default())) - .collect::>(), - )?; - - match response.body().await { - Ok(body) => { - let body_bytes = body.to_vec(); - let body_str = - decode_response(body_bytes, http_request.response_encoding.as_deref())?; - if is_json { - obj.serialize_entry( - "json_body", - &serde_json::value::RawValue::from_string(body_str)?, - )?; - } else { - obj.serialize_entry("body", &body_str)?; - } - } - Err(e) => { - log::warn!("Failed to read response body: {e}"); - if !has_error { - obj.serialize_entry( - "error", - &format!("Failed to read response body: {e}"), - )?; - } - } - } - } - Err(e) => { - log::warn!("Request failed: {e}"); - obj.serialize_entry("error", &format!("Request failed: {e}"))?; - } - } - - obj.end()?; - let return_value = String::from_utf8(resp_str)?; - Ok(Some(return_value)) - } - .instrument(fetch_span) - .await -} - -pub(crate) async fn hash_password(password: Option) -> anyhow::Result> { - let Some(password) = password else { - return Ok(None); - }; - actix_web::rt::task::spawn_blocking(move || { - // Hashes a password using Argon2. This is a CPU-intensive blocking operation. - let phf = argon2::Argon2::default(); - let salt = argon2::password_hash::SaltString::generate( - &mut argon2::password_hash::rand_core::OsRng, - ); - let password_hash = &argon2::password_hash::PasswordHash::generate(phf, password, &salt) - .map_err(|e| anyhow!("Unable to hash password: {e}"))?; - Ok(password_hash.to_string()) - }) - .await? - .map(Some) -} - -async fn header<'a>(request: &'a RequestInfo, name: Cow<'a, str>) -> Option> { - let lower_name = name.to_ascii_lowercase(); - request - .headers - .get(&lower_name) - .map(SingleOrVec::as_json_str) -} - -/// Builds a URL from a file name and a JSON object conatining URL parameters. -/// For instance, if the file is "index.sql" and the parameters are {"x": "hello world"}, -/// the result will be "index.sql?x=hello%20world". -async fn link<'a>( - file: Cow<'a, str>, - parameters: Option>, - hash: Option>, -) -> anyhow::Result { - let mut url = file.into_owned(); - if let Some(parameters) = parameters { - let encoded = serde_json::from_str::(¶meters) - .with_context(|| format!("sqlpage.link: {parameters:?} is not a valid JSON object. The URL parameters should be passed as a json object with parameter names as keys."))?; - encoded.append_to_path(&mut url); - } - if let Some(hash) = hash { - url.push('#'); - url.push_str(&hash); - } - Ok(url) -} - -/// Returns the path component of the URL of the current request. -async fn path(request: &RequestInfo) -> &str { - &request.path -} - -const DEFAULT_ALLOWED_EXTENSIONS: &str = - "jpg,jpeg,png,gif,bmp,webp,pdf,txt,doc,docx,xls,xlsx,csv,mp3,mp4,wav,avi,mov"; - -async fn persist_uploaded_file<'a>( - request: &'a RequestInfo, - field_name: Cow<'a, str>, - folder: Option>, - allowed_extensions: Option>, - mode: Option>, -) -> anyhow::Result> { - let folder = folder.unwrap_or(Cow::Borrowed("uploads")); - let allowed_extensions_str = - allowed_extensions.unwrap_or(Cow::Borrowed(DEFAULT_ALLOWED_EXTENSIONS)); - let allowed_extensions = allowed_extensions_str.split(','); - let Some(uploaded_file) = request.uploaded_files.get(&field_name.to_string()) else { - return Ok(None); - }; - let file_name = uploaded_file.file_name.as_deref().unwrap_or_default(); - let extension = file_name.split('.').next_back().unwrap_or_default(); - if !allowed_extensions - .clone() - .any(|x| x.eq_ignore_ascii_case(extension)) - { - let exts = allowed_extensions.collect::>().join(", "); - anyhow::bail!("file extension {extension} is not allowed. Allowed extensions: {exts}"); - } - // Resolve the folder path relative to the web root. - // `folder` is trusted application input: it is expected to be a constant chosen by the - // app author in their SQL code, never attacker-controlled request data. It is joined - // directly to the web root, so a `folder` containing `..` or an absolute path would let - // the caller write the uploaded file outside the web root. Callers must not pass - // untrusted input (form fields, query parameters, headers, ...) as the folder. - let web_root = &request.app_state.config.web_root; - let target_folder = web_root.join(&*folder); - // create the folder if it doesn't exist - tokio::fs::create_dir_all(&target_folder) - .await - .with_context(|| format!("unable to create folder {}", target_folder.display()))?; - let date = chrono::Utc::now().format("%Y-%m-%d_%Hh%Mm%Ss"); - let random_part = random_string_sync(8); - let random_target_name = format!("{date}_{random_part}.{extension}"); - let target_path = target_folder.join(&random_target_name); - tokio::fs::copy(&uploaded_file.file.path(), &target_path) - .await - .with_context(|| { - format!( - "unable to copy uploaded file {field_name:?} to \"{}\"", - target_path.display() - ) - })?; - set_file_mode(&target_path, mode.as_deref()).await?; - // remove the WEB_ROOT prefix from the path, but keep the leading slash - let path = "/".to_string() - + target_path - .strip_prefix(web_root)? - .to_str() - .with_context(|| { - format!( - "unable to convert path \"{}\" to a string", - target_path.display() - ) - })?; - Ok(Some(path)) -} - -/// Returns the protocol of the current request (http or https). -async fn protocol(request: &RequestInfo) -> &str { - &request.protocol -} - -#[cfg(unix)] -async fn set_file_mode(path: &std::path::Path, mode: Option<&str>) -> anyhow::Result<()> { - use std::os::unix::fs::PermissionsExt; - let mode = if let Some(mode) = mode { - u32::from_str_radix(mode, 8) - .with_context(|| format!("unable to parse file mode {mode:?} as an octal number"))? - } else { - 0o600 - }; - tokio::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)) - .await - .with_context(|| format!("unable to set permissions on {}", path.display()))?; - Ok(()) -} - -#[cfg(not(unix))] -async fn set_file_mode(_path: &std::path::Path, _mode: Option<&str>) -> anyhow::Result<()> { - Ok(()) -} - -/// Returns a random string of the specified length. -pub(crate) async fn random_string(len: usize) -> anyhow::Result { - // OsRng can block on Linux, so we run this on a blocking thread. - Ok(tokio::task::spawn_blocking(move || random_string_sync(len)).await?) -} - -/// Returns a random string of the specified length. -pub(crate) fn random_string_sync(len: usize) -> String { - use rand::{RngExt, distr::Alphanumeric}; - rand::rng() - .sample_iter(&Alphanumeric) - .take(len) - .map(char::from) - .collect() -} - -#[tokio::test] -async fn test_random_string() { - let s = random_string(10).await.unwrap(); - assert_eq!(s.len(), 10); -} - -async fn read_file_bytes(request: &RequestInfo, path_str: &str) -> Result, anyhow::Error> { - let path = std::path::Path::new(path_str); - // If the path is relative, it's relative to the web root, not the current working directory, - // and it can be fetched from the on-database filesystem table - if path.is_relative() { - request - .app_state - .file_system - .read_file(&request.app_state, FileAccess::privileged(path)) - .await - } else { - tokio::fs::read(path) - .await - .with_context(|| format!("Unable to read file \"{}\"", path.display())) - } -} - -async fn read_file_as_data_url<'a>( - request: &'a RequestInfo, - file_path: Option>, -) -> Result>, anyhow::Error> { - let Some(file_path) = file_path else { - log::debug!("read_file: first argument is NULL, returning NULL"); - return Ok(None); - }; - let bytes = read_file_bytes(request, &file_path).await?; - let mime = mime_from_upload_path(request, &file_path).map_or_else( - || Cow::Owned(mime_guess_from_filename(&file_path)), - Cow::Borrowed, - ); - let data_url = vec_to_data_uri_with_mime(&bytes, &mime.to_string()); - Ok(Some(Cow::Owned(data_url))) -} - -/// Returns the contents of a file as a string -async fn read_file_as_text<'a>( - request: &'a RequestInfo, - file_path: Option>, -) -> Result>, anyhow::Error> { - let Some(file_path) = file_path else { - log::debug!("read_file: first argument is NULL, returning NULL"); - return Ok(None); - }; - let bytes = read_file_bytes(request, &file_path).await?; - let as_str = String::from_utf8(bytes).with_context(|| { - format!("read_file_as_text: {file_path} does not contain raw UTF8 text") - })?; - Ok(Some(Cow::Owned(as_str))) -} - -fn mime_from_upload_path<'a>(request: &'a RequestInfo, path: &str) -> Option<&'a mime_guess::Mime> { - request.uploaded_files.values().find_map(|uploaded_file| { - if uploaded_file.file.path() == OsStr::new(path) { - uploaded_file.content_type.as_ref() - } else { - None - } - }) -} - -fn mime_guess_from_filename(filename: &str) -> mime_guess::Mime { - let maybe_mime = mime_guess::from_path(filename).first(); - maybe_mime.unwrap_or(mime::APPLICATION_OCTET_STREAM) -} - -/// Returns a string containing a JSON-encoded match object, or `null` if no match was found. -/// The match object contains one key per capture group, with the value being the matched text. -/// For named capture groups (`(?pattern)`), the key is the name. -/// For unnamed capture groups (`(pattern)`), the key is the index of the capture group as a string. -async fn regex_match<'a>( - pattern: Cow<'a, str>, - text: Option>, -) -> Result, anyhow::Error> { - use serde::{Serializer, ser::SerializeMap}; - let regex = regex::Regex::new(&pattern)?; - let Some(text) = text else { - return Ok(None); - }; - let Some(match_obj) = regex.captures(&text) else { - return Ok(None); - }; - let mut result = Vec::with_capacity(64); - let mut ser = serde_json::Serializer::new(&mut result); - let mut map = ser.serialize_map(Some(match_obj.len()))?; - for (idx, maybe_name) in regex.capture_names().enumerate() { - if let Some(match_group) = match_obj.get(idx) { - if let Some(name) = maybe_name { - map.serialize_entry(name, match_group.as_str())?; - } else { - let key = idx.to_string(); - map.serialize_entry(&key, match_group.as_str())?; - } - } - } - map.end()?; - Ok(Some(String::from_utf8(result)?)) -} - -#[tokio::test] -async fn regex_match_serializes_named_and_unnamed_groups() { - use std::borrow::Cow; - let result = regex_match( - Cow::Borrowed(r"(?foo)(bar)"), - Some(Cow::Borrowed("_foobar_")), - ) - .await - .unwrap(); - - assert_eq!( - result.as_deref(), - Some(r#"{"0":"foobar","word":"foo","2":"bar"}"#) - ); -} - -async fn request_method(request: &RequestInfo) -> String { - request.method.to_string() -} - -async fn run_sql<'a>( - request: &'a ExecutionContext, - db_connection: &mut DbConn, - sql_file_path: Option>, - variables: Option>, -) -> anyhow::Result>> { - use serde::ser::{SerializeSeq, Serializer}; - let Some(sql_file_path) = sql_file_path else { - log::debug!("run_sql: first argument is NULL, returning NULL"); - return Ok(None); - }; - let run_sql_span = tracing::info_span!( - "sqlpage.file", - otel.name = format!("SQL {sql_file_path}"), - code.file.path = %sql_file_path, - ); - let app_state = &request.app_state; - let sql_file = app_state - .sql_file_cache - .get( - app_state, - FileAccess::privileged(std::path::Path::new(sql_file_path.as_ref())), - ) - .instrument(run_sql_span.clone()) - .await - .with_context(|| format!("run_sql: invalid path {sql_file_path:?}"))?; - let tmp_req = if let Some(variables) = variables { - let variables: SetVariablesMap = serde_json::from_str(&variables).with_context(|| { - format!("run_sql(\'{sql_file_path}\', \'{variables}\'): the second argument should be a JSON object with string keys and values") - })?; - request.fork_with_variables(variables) - } else { - request.fork() - }; - let max_recursion_depth = app_state.config.max_recursion_depth; - if tmp_req.clone_depth > max_recursion_depth { - anyhow::bail!( - "Too many nested inclusions. run_sql can include a file that includes another file, but the depth is limited to {max_recursion_depth} levels. \n\ - Executing sqlpage.run_sql('{sql_file_path}') would exceed this limit. \n\ - This is to prevent infinite loops and stack overflows.\n\ - Make sure that your SQL file does not try to run itself, directly or through a chain of other files.\n\ - If you need to include more files, you can increase max_recursion_depth in the configuration file.\ - " - ); - } - let mut results_stream = - crate::webserver::database::execute_queries::stream_query_results_boxed( - &sql_file, - &tmp_req, - db_connection, - ); - let mut json_results_bytes = Vec::new(); - let mut json_encoder = serde_json::Serializer::new(&mut json_results_bytes); - let mut seq = json_encoder.serialize_seq(None)?; - while let Some(db_item) = results_stream.next().instrument(run_sql_span.clone()).await { - use crate::webserver::database::DbItem::{Error, FinishedQuery, Row}; - match db_item { - Row(row) => { - log::debug!("run_sql: row: {row:?}"); - seq.serialize_element(&row)?; - } - FinishedQuery => log::trace!("run_sql: Finished query"), - Error(err) => { - return Err(err.context(format!("run_sql: unable to run {sql_file_path:?}"))); - } - } - } - seq.end()?; - Ok(Some(Cow::Owned(String::from_utf8(json_results_bytes)?))) -} - -async fn set_variable<'a>( - context: &'a ExecutionContext, - name: Cow<'a, str>, - value: Option>, -) -> anyhow::Result { - let mut params = URLParameters::new(); - - for (k, v) in &context.url_params { - if k == &name { - continue; - } - params.push_single_or_vec(k, v.clone()); - } - - if let Some(value) = value { - params.push_single_or_vec(&name, SingleOrVec::Single(value.into_owned())); - } - - Ok(params.with_empty_path()) -} - -#[tokio::test] -async fn test_hash_password() { - let s = hash_password(Some("password".to_string())) - .await - .unwrap() - .unwrap(); - assert!(s.starts_with("$argon2")); -} - -async fn uploaded_file_mime_type<'a>( - request: &'a RequestInfo, - upload_name: Cow<'a, str>, -) -> Option> { - let mime = request - .uploaded_files - .get(&*upload_name)? - .content_type - .as_ref()?; - Some(Cow::Borrowed(mime.as_ref())) -} - -async fn uploaded_file_path<'a>( - request: &'a RequestInfo, - upload_name: Cow<'a, str>, -) -> Option> { - let uploaded_file = request.uploaded_files.get(&*upload_name)?; - Some(uploaded_file.file.path().to_string_lossy()) -} - -async fn uploaded_file_name<'a>( - request: &'a RequestInfo, - upload_name: Cow<'a, str>, -) -> Option> { - let fname = request - .uploaded_files - .get(&*upload_name)? - .file_name - .as_ref()?; - Some(Cow::Borrowed(fname.as_str())) -} - -/// escapes a string for use in a URL using percent encoding -/// for example, spaces are replaced with %20, '/' with %2F, etc. -/// This is useful for constructing URLs in SQL queries. -/// If this function is passed a NULL value, it will return NULL (None in Rust), -/// rather than an empty string or an error. -async fn url_encode(raw_text: Option>) -> Option> { - Some(match raw_text? { - Cow::Borrowed(inner) => { - let encoded = percent_encoding::percent_encode( - inner.as_bytes(), - percent_encoding::NON_ALPHANUMERIC, - ); - encoded.into() - } - Cow::Owned(inner) => { - let encoded = percent_encoding::percent_encode( - inner.as_bytes(), - percent_encoding::NON_ALPHANUMERIC, - ); - Cow::Owned(encoded.collect()) - } - }) -} - -/// Returns all variables in the request as a JSON object. -async fn variables<'a>( - request: &'a ExecutionContext, - get_or_post: Option>, -) -> anyhow::Result { - Ok(if let Some(get_or_post) = get_or_post { - if get_or_post.eq_ignore_ascii_case("get") { - serde_json::to_string(&request.url_params)? - } else if get_or_post.eq_ignore_ascii_case("post") { - serde_json::to_string(&request.post_variables)? - } else if get_or_post.eq_ignore_ascii_case("set") { - serde_json::to_string(&*request.set_variables.borrow())? - } else { - return Err(anyhow!( - "Expected 'get', 'post', or 'set' as the argument to sqlpage.variables" - )); - } - } else { - use serde::{Serializer, ser::SerializeMap}; - let mut res = Vec::new(); - let mut serializer = serde_json::Serializer::new(&mut res); - let set_vars = request.set_variables.borrow(); - let len = request.url_params.len() + request.post_variables.len() + set_vars.len(); - let mut ser = serializer.serialize_map(Some(len))?; - let mut seen_keys = std::collections::HashSet::new(); - for (k, v) in &*set_vars { - seen_keys.insert(k); - ser.serialize_entry(k, v)?; - } - for (k, v) in &request.post_variables { - if seen_keys.insert(k) { - ser.serialize_entry(k, v)?; - } - } - for (k, v) in &request.url_params { - if seen_keys.insert(k) { - ser.serialize_entry(k, v)?; - } - } - ser.end()?; - String::from_utf8(res)? - }) -} - -/// Returns the version of the sqlpage that is running. -async fn version() -> &'static str { - env!("CARGO_PKG_VERSION") -} - -/// Returns the directory where the .sql files are located (the web root). -async fn web_root(request: &RequestInfo) -> String { - request - .app_state - .config - .web_root - .to_string_lossy() - .into_owned() -} - -/// Returns the raw request body as a string. -/// If the request body is not valid UTF-8, invalid characters are replaced with the Unicode replacement character. -/// Returns NULL if there is no request body or if the request content type is -/// application/x-www-form-urlencoded or multipart/form-data (in this case, the body is accessible via the `post_variables` field). -async fn request_body(request: &RequestInfo) -> Option { - let raw_body = request.raw_body.as_ref()?; - Some(String::from_utf8_lossy(raw_body).to_string()) -} - -/// Returns the raw request body encoded in base64. -/// Returns NULL if there is no request body or if the request content type is -/// application/x-www-form-urlencoded or multipart/form-data (in this case, the body is accessible via the `post_variables` field). -async fn request_body_base64(request: &RequestInfo) -> Option { - let raw_body = request.raw_body.as_ref()?; - let mut base64_string = String::with_capacity((raw_body.len() * 4).div_ceil(3)); - base64::Engine::encode_string( - &base64::engine::general_purpose::STANDARD, - raw_body, - &mut base64_string, - ); - Some(base64_string) -} - -async fn headers(request: &RequestInfo) -> String { - serde_json::to_string(&request.headers).unwrap_or_default() -} - -/// Computes the HMAC (Hash-based Message Authentication Code) of the input data -/// using the specified key and hashing algorithm. -async fn hmac<'a>( - data: Cow<'a, str>, - key: Cow<'a, str>, - algorithm: Option>, -) -> anyhow::Result> { - use hmac::{Hmac, KeyInit, Mac}; - use sha2::{Sha256, Sha512}; - - let algorithm = algorithm.as_deref().unwrap_or("sha256"); - - // Parse algorithm and output format (e.g., "sha256" or "sha256-base64") - let (hash_algo, output_format) = if let Some((algo, format)) = algorithm.split_once('-') { - (algo, format) - } else { - (algorithm, "hex") - }; - - let result = match hash_algo.to_lowercase().as_str() { - "sha256" => { - let mut mac = Hmac::::new_from_slice(key.as_bytes()) - .map_err(|e| anyhow!("Invalid HMAC key: {e}"))?; - mac.update(data.as_bytes()); - mac.finalize().into_bytes().to_vec() - } - "sha512" => { - let mut mac = Hmac::::new_from_slice(key.as_bytes()) - .map_err(|e| anyhow!("Invalid HMAC key: {e}"))?; - mac.update(data.as_bytes()); - mac.finalize().into_bytes().to_vec() - } - _ => { - anyhow::bail!( - "Unsupported HMAC algorithm: {hash_algo}. Supported algorithms: sha256, sha512" - ) - } - }; - - // Convert to requested output format - let output = match output_format.to_lowercase().as_str() { - "hex" => result.into_iter().fold(String::new(), |mut acc, byte| { - write!(&mut acc, "{byte:02x}").unwrap(); - acc - }), - "base64" => base64::Engine::encode(&base64::engine::general_purpose::STANDARD, result), - _ => { - anyhow::bail!( - "Unsupported output format: {output_format}. Supported formats: hex, base64" - ) - } - }; - - Ok(Some(output)) -} - -async fn client_ip(request: &RequestInfo) -> Option { - Some(request.client_ip?.to_string()) -} - -#[tokio::test] -async fn test_hmac() { - // Test vector from RFC 4231 - HMAC-SHA256 - let result = hmac( - Cow::Borrowed("The quick brown fox jumps over the lazy dog"), - Cow::Borrowed("key"), - Some(Cow::Borrowed("sha256")), - ) - .await - .unwrap() - .unwrap(); - assert_eq!( - result, - "f7bc83f430538424b13298e6aa6fb143ef4d59a14946175997479dbc2d1a3cd8" - ); -} - -/// Returns the ID token claims as a JSON object. -async fn user_info_token(request: &RequestInfo) -> anyhow::Result> { - let Some(claims) = &request.oidc_claims else { - return Ok(None); - }; - Ok(Some(serde_json::to_string(claims)?)) -} - -async fn oidc_logout_url<'a>( - request: &'a RequestInfo, - redirect_uri: Option>, -) -> anyhow::Result> { - let Some(oidc_state) = &request.app_state.oidc_state else { - return Ok(None); - }; - - let redirect_uri = redirect_uri.as_deref().unwrap_or("/"); - - if !crate::webserver::oidc::is_safe_relative_redirect(redirect_uri) { - anyhow::bail!( - "oidc_logout_url: redirect_uri must be a relative path starting with a single '/'. Got: {redirect_uri}" - ); - } - - // Bind the logout URL to the current session so that it can only log out - // the browser it was generated for, never a different user's session. Use - // the first cookie value, matching how verification reads the auth cookie - // (HttpRequest::cookie returns the first cookie of that name); signing a - // JSON array of duplicate cookies here would never match verification. - let session_token = request - .cookies - .get("sqlpage_auth") - .map(SingleOrVec::first_str); - - let logout_url = oidc_state - .config - .create_logout_url(redirect_uri, session_token); - - Ok(Some(logout_url)) -} - -/// Returns a specific claim from the ID token. -async fn user_info<'a>( - request: &'a RequestInfo, - claim: Cow<'a, str>, -) -> anyhow::Result> { - let Some(claims) = &request.oidc_claims else { - return Ok(None); - }; - - // Match against known OIDC claims accessible via direct methods. - let claim_value_str = match claim.as_ref() { - // Core Claims - "iss" => Some(claims.issuer().to_string()), - // aud requires serialization: handled separately if needed - "exp" => Some(claims.expiration().timestamp().to_string()), - "iat" => Some(claims.issue_time().timestamp().to_string()), - "sub" => Some(claims.subject().to_string()), - "auth_time" => claims.auth_time().map(|t| t.timestamp().to_string()), - "nonce" => claims.nonce().map(|n| n.secret().clone()), // Assuming Nonce has secret() - "acr" => claims.auth_context_ref().map(|acr| acr.to_string()), - // amr requires serialization: handled separately if needed - "azp" => claims.authorized_party().map(|azp| azp.to_string()), - "at_hash" => claims.access_token_hash().map(|h| h.to_string()), - "c_hash" => claims.code_hash().map(|h| h.to_string()), - - // Standard Claims (Profile Scope - subset) - "name" => claims - .name() - .and_then(|n| n.get(None)) - .map(|s| s.to_string()), - "given_name" => claims - .given_name() - .and_then(|n| n.get(None)) - .map(|s| s.to_string()), - "family_name" => claims - .family_name() - .and_then(|n| n.get(None)) - .map(|s| s.to_string()), - "middle_name" => claims - .middle_name() - .and_then(|n| n.get(None)) - .map(|s| s.to_string()), - "nickname" => claims - .nickname() - .and_then(|n| n.get(None)) - .map(|s| s.to_string()), - "preferred_username" => claims.preferred_username().map(|u| u.to_string()), - "profile" => claims - .profile() - .and_then(|n| n.get(None)) - .map(|url_claim| url_claim.as_str().to_string()), - "picture" => claims - .picture() - .and_then(|n| n.get(None)) - .map(|url_claim| url_claim.as_str().to_string()), - "website" => claims - .website() - .and_then(|n| n.get(None)) - .map(|url_claim| url_claim.as_str().to_string()), - "gender" => claims.gender().map(|g| g.to_string()), // Assumes GenderClaim impls ToString - "birthdate" => claims.birthdate().map(|b| b.to_string()), // Assumes Birthdate impls ToString - "zoneinfo" => claims.zoneinfo().map(|z| z.to_string()), // Assumes ZoneInfo impls ToString - "locale" => claims.locale().map(std::string::ToString::to_string), // Assumes Locale impls ToString - "updated_at" => claims.updated_at().map(|t| t.timestamp().to_string()), - - // Standard Claims (Email Scope) - "email" => claims.email().map(|e| e.to_string()), - "email_verified" => claims.email_verified().map(|b| b.to_string()), - - // Standard Claims (Phone Scope) - "phone_number" => claims.phone_number().map(|p| p.to_string()), - "phone_number_verified" => claims.phone_number_verified().map(|b| b.to_string()), - additional_claim => claims - .additional_claims() - .0 - .get(additional_claim) - .map(std::string::ToString::to_string), - }; +use super::function_traits::sqlpage_functions; - Ok(claim_value_str) -} +// Generated by build.rs: a `sqlpage_functions!` call listing every module file in `functions/`. +// Discovering them there is what frees us from a hand-maintained registry. +include!(concat!(env!("OUT_DIR"), "/sqlpage_functions.rs")); diff --git a/src/webserver/database/sqlpage_functions/functions/basic_auth_password.rs b/src/webserver/database/sqlpage_functions/functions/basic_auth_password.rs new file mode 100644 index 000000000..d4488c2ae --- /dev/null +++ b/src/webserver/database/sqlpage_functions/functions/basic_auth_password.rs @@ -0,0 +1,25 @@ +use super::*; + +/// Returns the password from the HTTP basic auth header, if present. +pub(super) async fn basic_auth_password(request: &RequestInfo) -> anyhow::Result<&str> { + let password = extract_basic_auth(request)?.password().ok_or_else(|| { + anyhow::Error::new(ErrorWithStatus { + status: actix_web::http::StatusCode::UNAUTHORIZED, + }) + })?; + Ok(password) +} + +pub(super) fn extract_basic_auth( + request: &RequestInfo, +) -> anyhow::Result<&actix_web_httpauth::headers::authorization::Basic> { + request + .basic_auth + .as_ref() + .ok_or_else(|| { + anyhow::Error::new(ErrorWithStatus { + status: actix_web::http::StatusCode::UNAUTHORIZED, + }) + }) + .with_context(|| "Expected the user to be authenticated with HTTP basic auth") +} diff --git a/src/webserver/database/sqlpage_functions/functions/basic_auth_username.rs b/src/webserver/database/sqlpage_functions/functions/basic_auth_username.rs new file mode 100644 index 000000000..446ef765b --- /dev/null +++ b/src/webserver/database/sqlpage_functions/functions/basic_auth_username.rs @@ -0,0 +1,7 @@ +use super::*; + +/// Returns the username from the HTTP basic auth header, if present. +/// Otherwise, returns an HTTP 401 Unauthorized error. +pub(super) async fn basic_auth_username(request: &RequestInfo) -> anyhow::Result<&str> { + Ok(extract_basic_auth(request)?.user_id()) +} diff --git a/src/webserver/database/sqlpage_functions/functions/client_ip.rs b/src/webserver/database/sqlpage_functions/functions/client_ip.rs new file mode 100644 index 000000000..bfb2adb9d --- /dev/null +++ b/src/webserver/database/sqlpage_functions/functions/client_ip.rs @@ -0,0 +1,5 @@ +use super::*; + +pub(super) async fn client_ip(request: &RequestInfo) -> Option { + Some(request.client_ip?.to_string()) +} diff --git a/src/webserver/database/sqlpage_functions/functions/configuration_directory.rs b/src/webserver/database/sqlpage_functions/functions/configuration_directory.rs new file mode 100644 index 000000000..b87e95241 --- /dev/null +++ b/src/webserver/database/sqlpage_functions/functions/configuration_directory.rs @@ -0,0 +1,11 @@ +use super::*; + +/// Returns the directory where the sqlpage.json configuration file, templates, and migrations are located. +pub(super) async fn configuration_directory(request: &RequestInfo) -> String { + request + .app_state + .config + .configuration_directory + .to_string_lossy() + .into_owned() +} diff --git a/src/webserver/database/sqlpage_functions/functions/cookie.rs b/src/webserver/database/sqlpage_functions/functions/cookie.rs new file mode 100644 index 000000000..3dba902ec --- /dev/null +++ b/src/webserver/database/sqlpage_functions/functions/cookie.rs @@ -0,0 +1,5 @@ +use super::*; + +pub(super) async fn cookie<'a>(request: &'a RequestInfo, name: Cow<'a, str>) -> Option> { + request.cookies.get(&*name).map(SingleOrVec::as_json_str) +} diff --git a/src/webserver/database/sqlpage_functions/functions/current_working_directory.rs b/src/webserver/database/sqlpage_functions/functions/current_working_directory.rs new file mode 100644 index 000000000..7811e2124 --- /dev/null +++ b/src/webserver/database/sqlpage_functions/functions/current_working_directory.rs @@ -0,0 +1,7 @@ +use super::*; + +pub(super) async fn current_working_directory() -> anyhow::Result { + std::env::current_dir() + .with_context(|| "unable to access the current working directory") + .map(|x| x.to_string_lossy().into_owned()) +} diff --git a/src/webserver/database/sqlpage_functions/functions/environment_variable.rs b/src/webserver/database/sqlpage_functions/functions/environment_variable.rs new file mode 100644 index 000000000..fc79af87f --- /dev/null +++ b/src/webserver/database/sqlpage_functions/functions/environment_variable.rs @@ -0,0 +1,15 @@ +use super::*; + +/// Returns the value of an environment variable. +pub(super) async fn environment_variable(name: Cow<'_, str>) -> anyhow::Result>> { + match std::env::var(&*name) { + Ok(value) => Ok(Some(Cow::Owned(value))), + Err(std::env::VarError::NotPresent) if name.contains(['=', '\0']) => anyhow::bail!( + "Invalid environment variable name: {name:?}. Environment variable names cannot contain an equals sign or a null character." + ), + Err(std::env::VarError::NotPresent) => Ok(None), + Err(err) => { + Err(err).with_context(|| format!("unable to read the environment variable {name:?}")) + } + } +} diff --git a/src/webserver/database/sqlpage_functions/functions/exec.rs b/src/webserver/database/sqlpage_functions/functions/exec.rs new file mode 100644 index 000000000..15a2418db --- /dev/null +++ b/src/webserver/database/sqlpage_functions/functions/exec.rs @@ -0,0 +1,41 @@ +use super::*; + +/// Executes an external command and returns its output. +pub(super) async fn exec<'a>( + request: &'a RequestInfo, + program_name: Cow<'a, str>, + args: Vec>, +) -> anyhow::Result { + if !request.app_state.config.allow_exec { + anyhow::bail!("The sqlpage.exec() function is disabled in the configuration, for security reasons. + Make sure you understand the security implications before enabling it, and never allow user input to be passed as the first argument to this function. + You can enable it by setting the allow_exec option to true in the sqlpage.json configuration file.") + } + let exec_span = tracing::info_span!( + "subprocess", + otel.name = format!("EXEC {program_name}"), + process.command = %program_name, + process.args_count = args.len(), + ); + let res = tokio::process::Command::new(&*program_name) + .args(args.iter().map(|x| &**x)) + .output() + .instrument(exec_span) + .await + .with_context(|| { + let mut s = format!("Unable to execute command: {program_name}"); + for arg in args { + s.push(' '); + s.push_str(&arg); + } + s + })?; + if !res.status.success() { + anyhow::bail!( + "Command '{program_name}' failed with exit code {}: {}", + res.status, + String::from_utf8_lossy(&res.stderr) + ); + } + Ok(String::from_utf8_lossy(&res.stdout).into_owned()) +} diff --git a/src/webserver/database/sqlpage_functions/functions/fetch.rs b/src/webserver/database/sqlpage_functions/functions/fetch.rs new file mode 100644 index 000000000..214aadabb --- /dev/null +++ b/src/webserver/database/sqlpage_functions/functions/fetch.rs @@ -0,0 +1,167 @@ +use super::*; + +pub(super) fn build_request<'a>( + client: &'a awc::Client, + http_request: &'a HttpFetchRequest<'_>, +) -> anyhow::Result { + use awc::http::Method; + let method = if let Some(method) = &http_request.method { + Method::from_str(method).with_context(|| format!("Invalid HTTP method: {method}"))? + } else { + Method::GET + }; + let mut req = client.request(method, http_request.url.as_ref()); + if let Some(timeout) = http_request.timeout_ms { + req = req.timeout(core::time::Duration::from_millis(timeout)); + } + for (k, v) in &http_request.headers { + req = req.insert_header((k.as_ref(), v.as_ref())); + } + if let Some(username) = &http_request.username { + let password = http_request.password.as_deref().unwrap_or_default(); + req = req.basic_auth(username, password); + } + Ok(req) +} + +pub(super) fn prepare_request_body( + body: &serde_json::value::RawValue, + mut req: awc::ClientRequest, +) -> anyhow::Result<(String, awc::ClientRequest)> { + let val = body.get(); + let body_str = if val.starts_with('"') { + serde_json::from_str::<'_, String>(val).with_context(|| { + format!("Invalid JSON string in the body of the HTTP request: {val}") + })? + } else { + req = req.content_type("application/json"); + val.to_owned() + }; + Ok((body_str, req)) +} + +pub(super) fn fetch_span(http_request: &HttpFetchRequest<'_>) -> tracing::Span { + let method = http_request.method.as_deref().unwrap_or("GET"); + tracing::info_span!( + "http.client", + "otel.name" = format!("{method}"), + { otel::HTTP_REQUEST_METHOD } = method, + { otel::URL_FULL } = %http_request.url, + { otel::HTTP_REQUEST_BODY_SIZE } = tracing::field::Empty, + { otel::HTTP_RESPONSE_STATUS_CODE } = tracing::field::Empty, + ) +} + +pub(super) fn send_request( + request: &RequestInfo, + http_request: &HttpFetchRequest<'_>, +) -> anyhow::Result { + let client = make_http_client(&request.app_state.config) + .with_context(|| "Unable to create an HTTP client")?; + let req = build_request(&client, http_request)?; + + log::info!("Fetching {}", http_request.url); + if let Some(body) = &http_request.body { + let (body, req) = prepare_request_body(body, req)?; + tracing::Span::current().record( + otel::HTTP_REQUEST_BODY_SIZE, + i64::try_from(body.len()).unwrap_or(i64::MAX), + ); + Ok(req.send_body(body)) + } else { + Ok(req.send()) + } +} + +pub(super) async fn fetch( + request: &RequestInfo, + http_request: Option>, +) -> anyhow::Result> { + let Some(http_request) = http_request else { + return Ok(None); + }; + let fetch_span = fetch_span(&http_request); + + async { + let response_result = send_request(request, &http_request)?.await; + let mut response = response_result + .map_err(|e| anyhow!("Unable to fetch {}: {e}", http_request.url))?; + + tracing::Span::current().record( + otel::HTTP_RESPONSE_STATUS_CODE, + i64::from(response.status().as_u16()), + ); + + log::debug!( + "Finished fetching {}. Status: {}", + http_request.url, + response.status() + ); + log::debug!( + "Fetch response headers for {}: content_type={:?}", + http_request.url, + response + .headers() + .get("content-type") + .and_then(|value| value.to_str().ok()) + ); + + let body = response + .body() + .await + .with_context(|| { + format!( + "Unable to read the body of the response from {}", + http_request.url + ) + })? + .to_vec(); + log::debug!( + "Fetched {} response body: body_len={} bytes, encoding={:?}", + http_request.url, + body.len(), + http_request.response_encoding + ); + let response_str = decode_response(body, http_request.response_encoding.as_deref())?; + Ok(Some(response_str)) + } + .instrument(fetch_span) + .await +} + +pub(super) fn decode_response(response: Vec, encoding: Option<&str>) -> anyhow::Result { + match encoding { + Some("base64") => Ok(base64::Engine::encode( + &base64::engine::general_purpose::STANDARD, + response, + )), + Some("base64url") => Ok(base64::Engine::encode( + &base64::engine::general_purpose::URL_SAFE, + response, + )), + Some("hex") => Ok(response.into_iter().fold(String::new(), |mut acc, byte| { + write!(&mut acc, "{byte:02x}").unwrap(); + acc + })), + Some(encoding_label) => Ok(encoding_rs::Encoding::for_label(encoding_label.as_bytes()) + .with_context(|| format!("Invalid encoding name: {encoding_label}"))? + .decode(&response) + .0 + .into_owned()), + None => { + let body_str = String::from_utf8(response); + match body_str { + Ok(body_str) => Ok(body_str), + Err(decoding_error) => { + log::warn!( + "fetch(...) response is not UTF-8 and no encoding was specified. Decoding the response as base64. Please explicitly set the encoding to \"base64\" if this is the expected behavior." + ); + Ok(base64::Engine::encode( + &base64::engine::general_purpose::STANDARD, + decoding_error.into_bytes(), + )) + } + } + } + } +} diff --git a/src/webserver/database/sqlpage_functions/functions/fetch_with_meta.rs b/src/webserver/database/sqlpage_functions/functions/fetch_with_meta.rs new file mode 100644 index 000000000..e8c04c4e3 --- /dev/null +++ b/src/webserver/database/sqlpage_functions/functions/fetch_with_meta.rs @@ -0,0 +1,86 @@ +use super::*; + +pub(super) async fn fetch_with_meta( + request: &RequestInfo, + http_request: Option>, +) -> anyhow::Result> { + use serde::{Serializer, ser::SerializeMap}; + + let Some(http_request) = http_request else { + return Ok(None); + }; + + let fetch_span = fetch_span(&http_request); + + async { + let response_result = send_request(request, &http_request)?.await; + + let mut resp_str = Vec::new(); + let mut encoder = serde_json::Serializer::new(&mut resp_str); + let mut obj = encoder.serialize_map(Some(3))?; + match response_result { + Ok(mut response) => { + let status = response.status(); + tracing::Span::current() + .record(otel::HTTP_RESPONSE_STATUS_CODE, i64::from(status.as_u16())); + obj.serialize_entry("status", &status.as_u16())?; + let mut has_error = false; + if status.is_server_error() { + has_error = true; + obj.serialize_entry("error", &format!("Server error: {status}"))?; + } + + let headers = response.headers(); + + let is_json = headers + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or_default() + .starts_with("application/json"); + + obj.serialize_entry( + "headers", + &headers + .iter() + .map(|(k, v)| (k.to_string(), v.to_str().unwrap_or_default())) + .collect::>(), + )?; + + match response.body().await { + Ok(body) => { + let body_bytes = body.to_vec(); + let body_str = + decode_response(body_bytes, http_request.response_encoding.as_deref())?; + if is_json { + obj.serialize_entry( + "json_body", + &serde_json::value::RawValue::from_string(body_str)?, + )?; + } else { + obj.serialize_entry("body", &body_str)?; + } + } + Err(e) => { + log::warn!("Failed to read response body: {e}"); + if !has_error { + obj.serialize_entry( + "error", + &format!("Failed to read response body: {e}"), + )?; + } + } + } + } + Err(e) => { + log::warn!("Request failed: {e}"); + obj.serialize_entry("error", &format!("Request failed: {e}"))?; + } + } + + obj.end()?; + let return_value = String::from_utf8(resp_str)?; + Ok(Some(return_value)) + } + .instrument(fetch_span) + .await +} diff --git a/src/webserver/database/sqlpage_functions/functions/hash_password.rs b/src/webserver/database/sqlpage_functions/functions/hash_password.rs new file mode 100644 index 000000000..8cf206d24 --- /dev/null +++ b/src/webserver/database/sqlpage_functions/functions/hash_password.rs @@ -0,0 +1,28 @@ +use super::*; + +pub(super) async fn hash_password(password: Option) -> anyhow::Result> { + let Some(password) = password else { + return Ok(None); + }; + actix_web::rt::task::spawn_blocking(move || { + // Hashes a password using Argon2. This is a CPU-intensive blocking operation. + let phf = argon2::Argon2::default(); + let salt = argon2::password_hash::SaltString::generate( + &mut argon2::password_hash::rand_core::OsRng, + ); + let password_hash = &argon2::password_hash::PasswordHash::generate(phf, password, &salt) + .map_err(|e| anyhow!("Unable to hash password: {e}"))?; + Ok(password_hash.to_string()) + }) + .await? + .map(Some) +} + +#[tokio::test] +pub(super) async fn test_hash_password() { + let s = hash_password(Some("password".to_string())) + .await + .unwrap() + .unwrap(); + assert!(s.starts_with("$argon2")); +} diff --git a/src/webserver/database/sqlpage_functions/functions/header.rs b/src/webserver/database/sqlpage_functions/functions/header.rs new file mode 100644 index 000000000..b45c412b4 --- /dev/null +++ b/src/webserver/database/sqlpage_functions/functions/header.rs @@ -0,0 +1,9 @@ +use super::*; + +pub(super) async fn header<'a>(request: &'a RequestInfo, name: Cow<'a, str>) -> Option> { + let lower_name = name.to_ascii_lowercase(); + request + .headers + .get(&lower_name) + .map(SingleOrVec::as_json_str) +} diff --git a/src/webserver/database/sqlpage_functions/functions/headers.rs b/src/webserver/database/sqlpage_functions/functions/headers.rs new file mode 100644 index 000000000..73fefa087 --- /dev/null +++ b/src/webserver/database/sqlpage_functions/functions/headers.rs @@ -0,0 +1,5 @@ +use super::*; + +pub(super) async fn headers(request: &RequestInfo) -> String { + serde_json::to_string(&request.headers).unwrap_or_default() +} diff --git a/src/webserver/database/sqlpage_functions/functions/hmac.rs b/src/webserver/database/sqlpage_functions/functions/hmac.rs new file mode 100644 index 000000000..44fad8219 --- /dev/null +++ b/src/webserver/database/sqlpage_functions/functions/hmac.rs @@ -0,0 +1,74 @@ +use super::*; + +/// Computes the HMAC (Hash-based Message Authentication Code) of the input data +/// using the specified key and hashing algorithm. +pub(super) async fn hmac<'a>( + data: Cow<'a, str>, + key: Cow<'a, str>, + algorithm: Option>, +) -> anyhow::Result> { + use ::hmac::{Hmac, KeyInit, Mac}; + use sha2::{Sha256, Sha512}; + + let algorithm = algorithm.as_deref().unwrap_or("sha256"); + + // Parse algorithm and output format (e.g., "sha256" or "sha256-base64") + let (hash_algo, output_format) = if let Some((algo, format)) = algorithm.split_once('-') { + (algo, format) + } else { + (algorithm, "hex") + }; + + let result = match hash_algo.to_lowercase().as_str() { + "sha256" => { + let mut mac = Hmac::::new_from_slice(key.as_bytes()) + .map_err(|e| anyhow!("Invalid HMAC key: {e}"))?; + mac.update(data.as_bytes()); + mac.finalize().into_bytes().to_vec() + } + "sha512" => { + let mut mac = Hmac::::new_from_slice(key.as_bytes()) + .map_err(|e| anyhow!("Invalid HMAC key: {e}"))?; + mac.update(data.as_bytes()); + mac.finalize().into_bytes().to_vec() + } + _ => { + anyhow::bail!( + "Unsupported HMAC algorithm: {hash_algo}. Supported algorithms: sha256, sha512" + ) + } + }; + + // Convert to requested output format + let output = match output_format.to_lowercase().as_str() { + "hex" => result.into_iter().fold(String::new(), |mut acc, byte| { + write!(&mut acc, "{byte:02x}").unwrap(); + acc + }), + "base64" => base64::Engine::encode(&base64::engine::general_purpose::STANDARD, result), + _ => { + anyhow::bail!( + "Unsupported output format: {output_format}. Supported formats: hex, base64" + ) + } + }; + + Ok(Some(output)) +} + +#[tokio::test] +pub(super) async fn test_hmac() { + // Test vector from RFC 4231 - HMAC-SHA256 + let result = hmac( + Cow::Borrowed("The quick brown fox jumps over the lazy dog"), + Cow::Borrowed("key"), + Some(Cow::Borrowed("sha256")), + ) + .await + .unwrap() + .unwrap(); + assert_eq!( + result, + "f7bc83f430538424b13298e6aa6fb143ef4d59a14946175997479dbc2d1a3cd8" + ); +} diff --git a/src/webserver/database/sqlpage_functions/functions/link.rs b/src/webserver/database/sqlpage_functions/functions/link.rs new file mode 100644 index 000000000..52ea3214f --- /dev/null +++ b/src/webserver/database/sqlpage_functions/functions/link.rs @@ -0,0 +1,22 @@ +use super::*; + +/// Builds a URL from a file name and a JSON object conatining URL parameters. +/// For instance, if the file is "index.sql" and the parameters are {"x": "hello world"}, +/// the result will be "index.sql?x=hello%20world". +pub(super) async fn link<'a>( + file: Cow<'a, str>, + parameters: Option>, + hash: Option>, +) -> anyhow::Result { + let mut url = file.into_owned(); + if let Some(parameters) = parameters { + let encoded = serde_json::from_str::(¶meters) + .with_context(|| format!("sqlpage.link: {parameters:?} is not a valid JSON object. The URL parameters should be passed as a json object with parameter names as keys."))?; + encoded.append_to_path(&mut url); + } + if let Some(hash) = hash { + url.push('#'); + url.push_str(&hash); + } + Ok(url) +} diff --git a/src/webserver/database/sqlpage_functions/functions/oidc_logout_url.rs b/src/webserver/database/sqlpage_functions/functions/oidc_logout_url.rs new file mode 100644 index 000000000..b229b8098 --- /dev/null +++ b/src/webserver/database/sqlpage_functions/functions/oidc_logout_url.rs @@ -0,0 +1,34 @@ +use super::*; + +pub(super) async fn oidc_logout_url<'a>( + request: &'a RequestInfo, + redirect_uri: Option>, +) -> anyhow::Result> { + let Some(oidc_state) = &request.app_state.oidc_state else { + return Ok(None); + }; + + let redirect_uri = redirect_uri.as_deref().unwrap_or("/"); + + if !crate::webserver::oidc::is_safe_relative_redirect(redirect_uri) { + anyhow::bail!( + "oidc_logout_url: redirect_uri must be a relative path starting with a single '/'. Got: {redirect_uri}" + ); + } + + // Bind the logout URL to the current session so that it can only log out + // the browser it was generated for, never a different user's session. Use + // the first cookie value, matching how verification reads the auth cookie + // (HttpRequest::cookie returns the first cookie of that name); signing a + // JSON array of duplicate cookies here would never match verification. + let session_token = request + .cookies + .get("sqlpage_auth") + .map(SingleOrVec::first_str); + + let logout_url = oidc_state + .config + .create_logout_url(redirect_uri, session_token); + + Ok(Some(logout_url)) +} diff --git a/src/webserver/database/sqlpage_functions/functions/path.rs b/src/webserver/database/sqlpage_functions/functions/path.rs new file mode 100644 index 000000000..9baa90a49 --- /dev/null +++ b/src/webserver/database/sqlpage_functions/functions/path.rs @@ -0,0 +1,6 @@ +use super::*; + +/// Returns the path component of the URL of the current request. +pub(super) async fn path(request: &RequestInfo) -> &str { + &request.path +} diff --git a/src/webserver/database/sqlpage_functions/functions/persist_uploaded_file.rs b/src/webserver/database/sqlpage_functions/functions/persist_uploaded_file.rs new file mode 100644 index 000000000..7f92b44ea --- /dev/null +++ b/src/webserver/database/sqlpage_functions/functions/persist_uploaded_file.rs @@ -0,0 +1,86 @@ +use super::*; + +const DEFAULT_ALLOWED_EXTENSIONS: &str = + "jpg,jpeg,png,gif,bmp,webp,pdf,txt,doc,docx,xls,xlsx,csv,mp3,mp4,wav,avi,mov"; + +pub(super) async fn persist_uploaded_file<'a>( + request: &'a RequestInfo, + field_name: Cow<'a, str>, + folder: Option>, + allowed_extensions: Option>, + mode: Option>, +) -> anyhow::Result> { + let folder = folder.unwrap_or(Cow::Borrowed("uploads")); + let allowed_extensions_str = + allowed_extensions.unwrap_or(Cow::Borrowed(DEFAULT_ALLOWED_EXTENSIONS)); + let allowed_extensions = allowed_extensions_str.split(','); + let Some(uploaded_file) = request.uploaded_files.get(&field_name.to_string()) else { + return Ok(None); + }; + let file_name = uploaded_file.file_name.as_deref().unwrap_or_default(); + let extension = file_name.split('.').next_back().unwrap_or_default(); + if !allowed_extensions + .clone() + .any(|x| x.eq_ignore_ascii_case(extension)) + { + let exts = allowed_extensions.collect::>().join(", "); + anyhow::bail!("file extension {extension} is not allowed. Allowed extensions: {exts}"); + } + // Resolve the folder path relative to the web root. + // `folder` is trusted application input: it is expected to be a constant chosen by the + // app author in their SQL code, never attacker-controlled request data. It is joined + // directly to the web root, so a `folder` containing `..` or an absolute path would let + // the caller write the uploaded file outside the web root. Callers must not pass + // untrusted input (form fields, query parameters, headers, ...) as the folder. + let web_root = &request.app_state.config.web_root; + let target_folder = web_root.join(&*folder); + // create the folder if it doesn't exist + tokio::fs::create_dir_all(&target_folder) + .await + .with_context(|| format!("unable to create folder {}", target_folder.display()))?; + let date = chrono::Utc::now().format("%Y-%m-%d_%Hh%Mm%Ss"); + let random_part = random_string_sync(8); + let random_target_name = format!("{date}_{random_part}.{extension}"); + let target_path = target_folder.join(&random_target_name); + tokio::fs::copy(&uploaded_file.file.path(), &target_path) + .await + .with_context(|| { + format!( + "unable to copy uploaded file {field_name:?} to \"{}\"", + target_path.display() + ) + })?; + set_file_mode(&target_path, mode.as_deref()).await?; + // remove the WEB_ROOT prefix from the path, but keep the leading slash + let path = "/".to_string() + + target_path + .strip_prefix(web_root)? + .to_str() + .with_context(|| { + format!( + "unable to convert path \"{}\" to a string", + target_path.display() + ) + })?; + Ok(Some(path)) +} + +#[cfg(unix)] +pub(super) async fn set_file_mode(path: &std::path::Path, mode: Option<&str>) -> anyhow::Result<()> { + use std::os::unix::fs::PermissionsExt; + let mode = if let Some(mode) = mode { + u32::from_str_radix(mode, 8) + .with_context(|| format!("unable to parse file mode {mode:?} as an octal number"))? + } else { + 0o600 + }; + tokio::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)) + .await + .with_context(|| format!("unable to set permissions on {}", path.display()))?; + Ok(()) +} + +#[cfg(not(unix))] +pub(super) async fn set_file_mode(_path: &std::path::Path, _mode: Option<&str>) -> anyhow::Result<()> { + Ok(()) +} diff --git a/src/webserver/database/sqlpage_functions/functions/protocol.rs b/src/webserver/database/sqlpage_functions/functions/protocol.rs new file mode 100644 index 000000000..16ab4595c --- /dev/null +++ b/src/webserver/database/sqlpage_functions/functions/protocol.rs @@ -0,0 +1,6 @@ +use super::*; + +/// Returns the protocol of the current request (http or https). +pub(super) async fn protocol(request: &RequestInfo) -> &str { + &request.protocol +} diff --git a/src/webserver/database/sqlpage_functions/functions/random_string.rs b/src/webserver/database/sqlpage_functions/functions/random_string.rs new file mode 100644 index 000000000..4446d34ee --- /dev/null +++ b/src/webserver/database/sqlpage_functions/functions/random_string.rs @@ -0,0 +1,22 @@ + +/// Returns a random string of the specified length. +pub(super) async fn random_string(len: usize) -> anyhow::Result { + // OsRng can block on Linux, so we run this on a blocking thread. + Ok(tokio::task::spawn_blocking(move || random_string_sync(len)).await?) +} + +/// Returns a random string of the specified length. +pub(crate) fn random_string_sync(len: usize) -> String { + use rand::{RngExt, distr::Alphanumeric}; + rand::rng() + .sample_iter(&Alphanumeric) + .take(len) + .map(char::from) + .collect() +} + +#[tokio::test] +pub(super) async fn test_random_string() { + let s = random_string(10).await.unwrap(); + assert_eq!(s.len(), 10); +} diff --git a/src/webserver/database/sqlpage_functions/functions/read_file_as_data_url.rs b/src/webserver/database/sqlpage_functions/functions/read_file_as_data_url.rs new file mode 100644 index 000000000..90cb94c67 --- /dev/null +++ b/src/webserver/database/sqlpage_functions/functions/read_file_as_data_url.rs @@ -0,0 +1,35 @@ +use super::*; + +pub(super) async fn read_file_bytes(request: &RequestInfo, path_str: &str) -> Result, anyhow::Error> { + let path = std::path::Path::new(path_str); + // If the path is relative, it's relative to the web root, not the current working directory, + // and it can be fetched from the on-database filesystem table + if path.is_relative() { + request + .app_state + .file_system + .read_file(&request.app_state, FileAccess::privileged(path)) + .await + } else { + tokio::fs::read(path) + .await + .with_context(|| format!("Unable to read file \"{}\"", path.display())) + } +} + +pub(super) async fn read_file_as_data_url<'a>( + request: &'a RequestInfo, + file_path: Option>, +) -> Result>, anyhow::Error> { + let Some(file_path) = file_path else { + log::debug!("read_file: first argument is NULL, returning NULL"); + return Ok(None); + }; + let bytes = read_file_bytes(request, &file_path).await?; + let mime = mime_from_upload_path(request, &file_path).map_or_else( + || Cow::Owned(mime_guess_from_filename(&file_path)), + Cow::Borrowed, + ); + let data_url = vec_to_data_uri_with_mime(&bytes, &mime.to_string()); + Ok(Some(Cow::Owned(data_url))) +} diff --git a/src/webserver/database/sqlpage_functions/functions/read_file_as_text.rs b/src/webserver/database/sqlpage_functions/functions/read_file_as_text.rs new file mode 100644 index 000000000..f004d9874 --- /dev/null +++ b/src/webserver/database/sqlpage_functions/functions/read_file_as_text.rs @@ -0,0 +1,17 @@ +use super::*; + +/// Returns the contents of a file as a string +pub(super) async fn read_file_as_text<'a>( + request: &'a RequestInfo, + file_path: Option>, +) -> Result>, anyhow::Error> { + let Some(file_path) = file_path else { + log::debug!("read_file: first argument is NULL, returning NULL"); + return Ok(None); + }; + let bytes = read_file_bytes(request, &file_path).await?; + let as_str = String::from_utf8(bytes).with_context(|| { + format!("read_file_as_text: {file_path} does not contain raw UTF8 text") + })?; + Ok(Some(Cow::Owned(as_str))) +} diff --git a/src/webserver/database/sqlpage_functions/functions/regex_match.rs b/src/webserver/database/sqlpage_functions/functions/regex_match.rs new file mode 100644 index 000000000..93b40c228 --- /dev/null +++ b/src/webserver/database/sqlpage_functions/functions/regex_match.rs @@ -0,0 +1,50 @@ +use super::*; + +/// Returns a string containing a JSON-encoded match object, or `null` if no match was found. +/// The match object contains one key per capture group, with the value being the matched text. +/// For named capture groups (`(?pattern)`), the key is the name. +/// For unnamed capture groups (`(pattern)`), the key is the index of the capture group as a string. +pub(super) async fn regex_match<'a>( + pattern: Cow<'a, str>, + text: Option>, +) -> Result, anyhow::Error> { + use serde::{Serializer, ser::SerializeMap}; + let regex = regex::Regex::new(&pattern)?; + let Some(text) = text else { + return Ok(None); + }; + let Some(match_obj) = regex.captures(&text) else { + return Ok(None); + }; + let mut result = Vec::with_capacity(64); + let mut ser = serde_json::Serializer::new(&mut result); + let mut map = ser.serialize_map(Some(match_obj.len()))?; + for (idx, maybe_name) in regex.capture_names().enumerate() { + if let Some(match_group) = match_obj.get(idx) { + if let Some(name) = maybe_name { + map.serialize_entry(name, match_group.as_str())?; + } else { + let key = idx.to_string(); + map.serialize_entry(&key, match_group.as_str())?; + } + } + } + map.end()?; + Ok(Some(String::from_utf8(result)?)) +} + +#[tokio::test] +pub(super) async fn regex_match_serializes_named_and_unnamed_groups() { + use std::borrow::Cow; + let result = regex_match( + Cow::Borrowed(r"(?foo)(bar)"), + Some(Cow::Borrowed("_foobar_")), + ) + .await + .unwrap(); + + assert_eq!( + result.as_deref(), + Some(r#"{"0":"foobar","word":"foo","2":"bar"}"#) + ); +} diff --git a/src/webserver/database/sqlpage_functions/functions/request_body.rs b/src/webserver/database/sqlpage_functions/functions/request_body.rs new file mode 100644 index 000000000..a9bee0479 --- /dev/null +++ b/src/webserver/database/sqlpage_functions/functions/request_body.rs @@ -0,0 +1,10 @@ +use super::*; + +/// Returns the raw request body as a string. +/// If the request body is not valid UTF-8, invalid characters are replaced with the Unicode replacement character. +/// Returns NULL if there is no request body or if the request content type is +/// application/x-www-form-urlencoded or multipart/form-data (in this case, the body is accessible via the `post_variables` field). +pub(super) async fn request_body(request: &RequestInfo) -> Option { + let raw_body = request.raw_body.as_ref()?; + Some(String::from_utf8_lossy(raw_body).to_string()) +} diff --git a/src/webserver/database/sqlpage_functions/functions/request_body_base64.rs b/src/webserver/database/sqlpage_functions/functions/request_body_base64.rs new file mode 100644 index 000000000..47bc8a070 --- /dev/null +++ b/src/webserver/database/sqlpage_functions/functions/request_body_base64.rs @@ -0,0 +1,15 @@ +use super::*; + +/// Returns the raw request body encoded in base64. +/// Returns NULL if there is no request body or if the request content type is +/// application/x-www-form-urlencoded or multipart/form-data (in this case, the body is accessible via the `post_variables` field). +pub(super) async fn request_body_base64(request: &RequestInfo) -> Option { + let raw_body = request.raw_body.as_ref()?; + let mut base64_string = String::with_capacity((raw_body.len() * 4).div_ceil(3)); + base64::Engine::encode_string( + &base64::engine::general_purpose::STANDARD, + raw_body, + &mut base64_string, + ); + Some(base64_string) +} diff --git a/src/webserver/database/sqlpage_functions/functions/request_method.rs b/src/webserver/database/sqlpage_functions/functions/request_method.rs new file mode 100644 index 000000000..50d5dd457 --- /dev/null +++ b/src/webserver/database/sqlpage_functions/functions/request_method.rs @@ -0,0 +1,5 @@ +use super::*; + +pub(super) async fn request_method(request: &RequestInfo) -> String { + request.method.to_string() +} diff --git a/src/webserver/database/sqlpage_functions/functions/run_sql.rs b/src/webserver/database/sqlpage_functions/functions/run_sql.rs new file mode 100644 index 000000000..4743c7c99 --- /dev/null +++ b/src/webserver/database/sqlpage_functions/functions/run_sql.rs @@ -0,0 +1,72 @@ +use super::*; + +pub(super) async fn run_sql<'a>( + request: &'a ExecutionContext, + db_connection: &mut DbConn, + sql_file_path: Option>, + variables: Option>, +) -> anyhow::Result>> { + use serde::ser::{SerializeSeq, Serializer}; + let Some(sql_file_path) = sql_file_path else { + log::debug!("run_sql: first argument is NULL, returning NULL"); + return Ok(None); + }; + let run_sql_span = tracing::info_span!( + "sqlpage.file", + otel.name = format!("SQL {sql_file_path}"), + code.file.path = %sql_file_path, + ); + let app_state = &request.app_state; + let sql_file = app_state + .sql_file_cache + .get( + app_state, + FileAccess::privileged(std::path::Path::new(sql_file_path.as_ref())), + ) + .instrument(run_sql_span.clone()) + .await + .with_context(|| format!("run_sql: invalid path {sql_file_path:?}"))?; + let tmp_req = if let Some(variables) = variables { + let variables: SetVariablesMap = serde_json::from_str(&variables).with_context(|| { + format!("run_sql(\'{sql_file_path}\', \'{variables}\'): the second argument should be a JSON object with string keys and values") + })?; + request.fork_with_variables(variables) + } else { + request.fork() + }; + let max_recursion_depth = app_state.config.max_recursion_depth; + if tmp_req.clone_depth > max_recursion_depth { + anyhow::bail!( + "Too many nested inclusions. run_sql can include a file that includes another file, but the depth is limited to {max_recursion_depth} levels. \n\ + Executing sqlpage.run_sql('{sql_file_path}') would exceed this limit. \n\ + This is to prevent infinite loops and stack overflows.\n\ + Make sure that your SQL file does not try to run itself, directly or through a chain of other files.\n\ + If you need to include more files, you can increase max_recursion_depth in the configuration file.\ + " + ); + } + let mut results_stream = + crate::webserver::database::execute_queries::stream_query_results_boxed( + &sql_file, + &tmp_req, + db_connection, + ); + let mut json_results_bytes = Vec::new(); + let mut json_encoder = serde_json::Serializer::new(&mut json_results_bytes); + let mut seq = json_encoder.serialize_seq(None)?; + while let Some(db_item) = results_stream.next().instrument(run_sql_span.clone()).await { + use crate::webserver::database::DbItem::{Error, FinishedQuery, Row}; + match db_item { + Row(row) => { + log::debug!("run_sql: row: {row:?}"); + seq.serialize_element(&row)?; + } + FinishedQuery => log::trace!("run_sql: Finished query"), + Error(err) => { + return Err(err.context(format!("run_sql: unable to run {sql_file_path:?}"))); + } + } + } + seq.end()?; + Ok(Some(Cow::Owned(String::from_utf8(json_results_bytes)?))) +} diff --git a/src/webserver/database/sqlpage_functions/functions/set_variable.rs b/src/webserver/database/sqlpage_functions/functions/set_variable.rs new file mode 100644 index 000000000..6000b0236 --- /dev/null +++ b/src/webserver/database/sqlpage_functions/functions/set_variable.rs @@ -0,0 +1,22 @@ +use super::*; + +pub(super) async fn set_variable<'a>( + context: &'a ExecutionContext, + name: Cow<'a, str>, + value: Option>, +) -> anyhow::Result { + let mut params = URLParameters::new(); + + for (k, v) in &context.url_params { + if k == &name { + continue; + } + params.push_single_or_vec(k, v.clone()); + } + + if let Some(value) = value { + params.push_single_or_vec(&name, SingleOrVec::Single(value.into_owned())); + } + + Ok(params.with_empty_path()) +} diff --git a/src/webserver/database/sqlpage_functions/functions/uploaded_file_mime_type.rs b/src/webserver/database/sqlpage_functions/functions/uploaded_file_mime_type.rs new file mode 100644 index 000000000..53406fac8 --- /dev/null +++ b/src/webserver/database/sqlpage_functions/functions/uploaded_file_mime_type.rs @@ -0,0 +1,28 @@ +use super::*; + +pub(super) fn mime_from_upload_path<'a>(request: &'a RequestInfo, path: &str) -> Option<&'a mime_guess::Mime> { + request.uploaded_files.values().find_map(|uploaded_file| { + if uploaded_file.file.path() == OsStr::new(path) { + uploaded_file.content_type.as_ref() + } else { + None + } + }) +} + +pub(super) fn mime_guess_from_filename(filename: &str) -> mime_guess::Mime { + let maybe_mime = mime_guess::from_path(filename).first(); + maybe_mime.unwrap_or(mime::APPLICATION_OCTET_STREAM) +} + +pub(super) async fn uploaded_file_mime_type<'a>( + request: &'a RequestInfo, + upload_name: Cow<'a, str>, +) -> Option> { + let mime = request + .uploaded_files + .get(&*upload_name)? + .content_type + .as_ref()?; + Some(Cow::Borrowed(mime.as_ref())) +} diff --git a/src/webserver/database/sqlpage_functions/functions/uploaded_file_name.rs b/src/webserver/database/sqlpage_functions/functions/uploaded_file_name.rs new file mode 100644 index 000000000..16f9b098f --- /dev/null +++ b/src/webserver/database/sqlpage_functions/functions/uploaded_file_name.rs @@ -0,0 +1,13 @@ +use super::*; + +pub(super) async fn uploaded_file_name<'a>( + request: &'a RequestInfo, + upload_name: Cow<'a, str>, +) -> Option> { + let fname = request + .uploaded_files + .get(&*upload_name)? + .file_name + .as_ref()?; + Some(Cow::Borrowed(fname.as_str())) +} diff --git a/src/webserver/database/sqlpage_functions/functions/uploaded_file_path.rs b/src/webserver/database/sqlpage_functions/functions/uploaded_file_path.rs new file mode 100644 index 000000000..58464e44e --- /dev/null +++ b/src/webserver/database/sqlpage_functions/functions/uploaded_file_path.rs @@ -0,0 +1,9 @@ +use super::*; + +pub(super) async fn uploaded_file_path<'a>( + request: &'a RequestInfo, + upload_name: Cow<'a, str>, +) -> Option> { + let uploaded_file = request.uploaded_files.get(&*upload_name)?; + Some(uploaded_file.file.path().to_string_lossy()) +} diff --git a/src/webserver/database/sqlpage_functions/functions/url_encode.rs b/src/webserver/database/sqlpage_functions/functions/url_encode.rs new file mode 100644 index 000000000..17c60453d --- /dev/null +++ b/src/webserver/database/sqlpage_functions/functions/url_encode.rs @@ -0,0 +1,25 @@ +use super::*; + +/// escapes a string for use in a URL using percent encoding +/// for example, spaces are replaced with %20, '/' with %2F, etc. +/// This is useful for constructing URLs in SQL queries. +/// If this function is passed a NULL value, it will return NULL (None in Rust), +/// rather than an empty string or an error. +pub(super) async fn url_encode(raw_text: Option>) -> Option> { + Some(match raw_text? { + Cow::Borrowed(inner) => { + let encoded = percent_encoding::percent_encode( + inner.as_bytes(), + percent_encoding::NON_ALPHANUMERIC, + ); + encoded.into() + } + Cow::Owned(inner) => { + let encoded = percent_encoding::percent_encode( + inner.as_bytes(), + percent_encoding::NON_ALPHANUMERIC, + ); + Cow::Owned(encoded.collect()) + } + }) +} diff --git a/src/webserver/database/sqlpage_functions/functions/user_info.rs b/src/webserver/database/sqlpage_functions/functions/user_info.rs new file mode 100644 index 000000000..b3a556861 --- /dev/null +++ b/src/webserver/database/sqlpage_functions/functions/user_info.rs @@ -0,0 +1,83 @@ +use super::*; + +/// Returns a specific claim from the ID token. +pub(super) async fn user_info<'a>( + request: &'a RequestInfo, + claim: Cow<'a, str>, +) -> anyhow::Result> { + let Some(claims) = &request.oidc_claims else { + return Ok(None); + }; + + // Match against known OIDC claims accessible via direct methods. + let claim_value_str = match claim.as_ref() { + // Core Claims + "iss" => Some(claims.issuer().to_string()), + // aud requires serialization: handled separately if needed + "exp" => Some(claims.expiration().timestamp().to_string()), + "iat" => Some(claims.issue_time().timestamp().to_string()), + "sub" => Some(claims.subject().to_string()), + "auth_time" => claims.auth_time().map(|t| t.timestamp().to_string()), + "nonce" => claims.nonce().map(|n| n.secret().clone()), // Assuming Nonce has secret() + "acr" => claims.auth_context_ref().map(|acr| acr.to_string()), + // amr requires serialization: handled separately if needed + "azp" => claims.authorized_party().map(|azp| azp.to_string()), + "at_hash" => claims.access_token_hash().map(|h| h.to_string()), + "c_hash" => claims.code_hash().map(|h| h.to_string()), + + // Standard Claims (Profile Scope - subset) + "name" => claims + .name() + .and_then(|n| n.get(None)) + .map(|s| s.to_string()), + "given_name" => claims + .given_name() + .and_then(|n| n.get(None)) + .map(|s| s.to_string()), + "family_name" => claims + .family_name() + .and_then(|n| n.get(None)) + .map(|s| s.to_string()), + "middle_name" => claims + .middle_name() + .and_then(|n| n.get(None)) + .map(|s| s.to_string()), + "nickname" => claims + .nickname() + .and_then(|n| n.get(None)) + .map(|s| s.to_string()), + "preferred_username" => claims.preferred_username().map(|u| u.to_string()), + "profile" => claims + .profile() + .and_then(|n| n.get(None)) + .map(|url_claim| url_claim.as_str().to_string()), + "picture" => claims + .picture() + .and_then(|n| n.get(None)) + .map(|url_claim| url_claim.as_str().to_string()), + "website" => claims + .website() + .and_then(|n| n.get(None)) + .map(|url_claim| url_claim.as_str().to_string()), + "gender" => claims.gender().map(|g| g.to_string()), // Assumes GenderClaim impls ToString + "birthdate" => claims.birthdate().map(|b| b.to_string()), // Assumes Birthdate impls ToString + "zoneinfo" => claims.zoneinfo().map(|z| z.to_string()), // Assumes ZoneInfo impls ToString + "locale" => claims.locale().map(std::string::ToString::to_string), // Assumes Locale impls ToString + "updated_at" => claims.updated_at().map(|t| t.timestamp().to_string()), + + // Standard Claims (Email Scope) + "email" => claims.email().map(|e| e.to_string()), + "email_verified" => claims.email_verified().map(|b| b.to_string()), + + // Standard Claims (Phone Scope) + "phone_number" => claims.phone_number().map(|p| p.to_string()), + "phone_number_verified" => claims.phone_number_verified().map(|b| b.to_string()), + additional_claim => claims + .additional_claims() + .0 + .get(additional_claim) + .map(std::string::ToString::to_string), + }; + + Ok(claim_value_str) +} diff --git a/src/webserver/database/sqlpage_functions/functions/user_info_token.rs b/src/webserver/database/sqlpage_functions/functions/user_info_token.rs new file mode 100644 index 000000000..634748788 --- /dev/null +++ b/src/webserver/database/sqlpage_functions/functions/user_info_token.rs @@ -0,0 +1,9 @@ +use super::*; + +/// Returns the ID token claims as a JSON object. +pub(super) async fn user_info_token(request: &RequestInfo) -> anyhow::Result> { + let Some(claims) = &request.oidc_claims else { + return Ok(None); + }; + Ok(Some(serde_json::to_string(claims)?)) +} diff --git a/src/webserver/database/sqlpage_functions/functions/variables.rs b/src/webserver/database/sqlpage_functions/functions/variables.rs new file mode 100644 index 000000000..e45830c6d --- /dev/null +++ b/src/webserver/database/sqlpage_functions/functions/variables.rs @@ -0,0 +1,45 @@ +use super::*; + +/// Returns all variables in the request as a JSON object. +pub(super) async fn variables<'a>( + request: &'a ExecutionContext, + get_or_post: Option>, +) -> anyhow::Result { + Ok(if let Some(get_or_post) = get_or_post { + if get_or_post.eq_ignore_ascii_case("get") { + serde_json::to_string(&request.url_params)? + } else if get_or_post.eq_ignore_ascii_case("post") { + serde_json::to_string(&request.post_variables)? + } else if get_or_post.eq_ignore_ascii_case("set") { + serde_json::to_string(&*request.set_variables.borrow())? + } else { + return Err(anyhow!( + "Expected 'get', 'post', or 'set' as the argument to sqlpage.variables" + )); + } + } else { + use serde::{Serializer, ser::SerializeMap}; + let mut res = Vec::new(); + let mut serializer = serde_json::Serializer::new(&mut res); + let set_vars = request.set_variables.borrow(); + let len = request.url_params.len() + request.post_variables.len() + set_vars.len(); + let mut ser = serializer.serialize_map(Some(len))?; + let mut seen_keys = std::collections::HashSet::new(); + for (k, v) in &*set_vars { + seen_keys.insert(k); + ser.serialize_entry(k, v)?; + } + for (k, v) in &request.post_variables { + if seen_keys.insert(k) { + ser.serialize_entry(k, v)?; + } + } + for (k, v) in &request.url_params { + if seen_keys.insert(k) { + ser.serialize_entry(k, v)?; + } + } + ser.end()?; + String::from_utf8(res)? + }) +} diff --git a/src/webserver/database/sqlpage_functions/functions/version.rs b/src/webserver/database/sqlpage_functions/functions/version.rs new file mode 100644 index 000000000..6d0cde3cd --- /dev/null +++ b/src/webserver/database/sqlpage_functions/functions/version.rs @@ -0,0 +1,5 @@ + +/// Returns the version of the sqlpage that is running. +pub(super) async fn version() -> &'static str { + env!("CARGO_PKG_VERSION") +} diff --git a/src/webserver/database/sqlpage_functions/functions/web_root.rs b/src/webserver/database/sqlpage_functions/functions/web_root.rs new file mode 100644 index 000000000..ffe37b69d --- /dev/null +++ b/src/webserver/database/sqlpage_functions/functions/web_root.rs @@ -0,0 +1,11 @@ +use super::*; + +/// Returns the directory where the .sql files are located (the web root). +pub(super) async fn web_root(request: &RequestInfo) -> String { + request + .app_state + .config + .web_root + .to_string_lossy() + .into_owned() +} diff --git a/src/webserver/database/sqlpage_functions/mod.rs b/src/webserver/database/sqlpage_functions/mod.rs index e5912e076..de3917b3c 100644 --- a/src/webserver/database/sqlpage_functions/mod.rs +++ b/src/webserver/database/sqlpage_functions/mod.rs @@ -1,4 +1,3 @@ -mod function_definition_macro; mod function_traits; pub(super) mod functions; mod http_fetch_request; From bd715576e8a09b7674a04eb815fc4ab794cdea6f Mon Sep 17 00:00:00 2001 From: Ophir LOJKINE Date: Fri, 12 Jun 2026 23:33:44 +0200 Subject: [PATCH 2/2] less magic --- build.rs | 30 ----- .../database/sqlpage_functions/README.md | 29 ++--- .../sqlpage_functions/function_traits.rs | 58 +++------- .../database/sqlpage_functions/functions.rs | 103 +++++++++++++----- .../functions/basic_auth_password.rs | 4 +- .../functions/basic_auth_username.rs | 4 +- .../sqlpage_functions/functions/client_ip.rs | 2 +- .../functions/configuration_directory.rs | 2 +- .../sqlpage_functions/functions/cookie.rs | 4 +- .../functions/current_working_directory.rs | 2 +- .../functions/environment_variable.rs | 4 +- .../sqlpage_functions/functions/exec.rs | 7 +- .../sqlpage_functions/functions/fetch.rs | 12 +- .../functions/fetch_with_meta.rs | 10 +- .../functions/hash_password.rs | 2 +- .../sqlpage_functions/functions/header.rs | 4 +- .../sqlpage_functions/functions/headers.rs | 2 +- .../sqlpage_functions/functions/hmac.rs | 4 +- .../sqlpage_functions/functions/link.rs | 6 +- .../functions/oidc_logout_url.rs | 4 +- .../sqlpage_functions/functions/path.rs | 2 +- .../functions/persist_uploaded_file.rs | 8 +- .../sqlpage_functions/functions/protocol.rs | 2 +- .../functions/read_file_as_data_url.rs | 14 ++- .../functions/read_file_as_text.rs | 8 +- .../functions/regex_match.rs | 2 +- .../functions/request_body.rs | 2 +- .../functions/request_body_base64.rs | 2 +- .../functions/request_method.rs | 2 +- .../sqlpage_functions/functions/run_sql.rs | 14 ++- .../functions/set_variable.rs | 8 +- .../functions/uploaded_file_mime_type.rs | 6 +- .../functions/uploaded_file_name.rs | 4 +- .../functions/uploaded_file_path.rs | 4 +- .../sqlpage_functions/functions/url_encode.rs | 2 +- .../sqlpage_functions/functions/user_info.rs | 4 +- .../functions/user_info_token.rs | 2 +- .../sqlpage_functions/functions/variables.rs | 6 +- .../sqlpage_functions/functions/web_root.rs | 2 +- .../database/sqlpage_functions/mod.rs | 2 - 40 files changed, 238 insertions(+), 151 deletions(-) diff --git a/build.rs b/build.rs index b8f78d65f..6f9f4a963 100644 --- a/build.rs +++ b/build.rs @@ -17,7 +17,6 @@ async fn main() { .unwrap(); println!("cargo:rerun-if-changed=build.rs"); - generate_sqlpage_functions(); let c = Rc::new(make_client()); for h in [ @@ -36,35 +35,6 @@ async fn main() { set_odbc_rpath(); } -/// Lists the modules in `sqlpage_functions/functions/` into a `sqlpage_functions! { ... }` call, so -/// built-in SQL functions register themselves just by having a file, with no hand-maintained list. -fn generate_sqlpage_functions() { - const DIR: &str = "src/webserver/database/sqlpage_functions/functions"; - println!("cargo:rerun-if-changed={DIR}"); - let out = PathBuf::from(std::env::var("OUT_DIR").unwrap()).join("sqlpage_functions.rs"); - let Ok(dir) = std::fs::read_dir(DIR) else { - // The source tree isn't present yet (e.g. the dependency-only Docker build stage, which - // compiles before `src/` is copied in). Emit an empty registry; once the directory exists - // the `rerun-if-changed` above makes the real build run this again. - std::fs::write(&out, "sqlpage_functions! {}\n").unwrap(); - return; - }; - let mut files: Vec = dir - .map(|entry| entry.unwrap().path()) - .filter(|path| path.extension().is_some_and(|ext| ext == "rs")) - .collect(); - files.sort(); - let entries: String = files - .iter() - .map(|path| { - let name = path.file_stem().unwrap().to_str().unwrap(); - let abs = path.canonicalize().unwrap(); - format!(" {name} = {abs:?},\n") - }) - .collect(); - std::fs::write(out, format!("sqlpage_functions! {{\n{entries}}}\n")).unwrap(); -} - fn make_client() -> awc::Client { awc::ClientBuilder::new() .timeout(Duration::from_secs(10)) diff --git a/src/webserver/database/sqlpage_functions/README.md b/src/webserver/database/sqlpage_functions/README.md index 63b981d38..0b01d1269 100644 --- a/src/webserver/database/sqlpage_functions/README.md +++ b/src/webserver/database/sqlpage_functions/README.md @@ -1,34 +1,35 @@ # Built-in SQL functions -Each built-in `sqlpage.*` function is a plain `async fn` in its own file in [`functions/`](functions), -with an ordinary Rust signature: +Each built-in `sqlpage.*` function is a plain `async fn` in its own file in +[`functions/`](functions). The file stem is the SQL function name and must match the Rust function it +exports: ```rust -#[allow(clippy::wildcard_imports)] -use super::*; +use std::borrow::Cow; + +use crate::webserver::http_request_info::RequestInfo; pub(super) async fn example(request: &RequestInfo, value: Option>) -> Option { // ... } ``` -To register it, add one line to the list in [`functions.rs`](functions.rs), giving the SQL names of -its arguments (used only in error messages): +To add `sqlpage.example`, create `functions/example.rs` and add it to the +[`sqlpage_functions!`](function_traits.rs) call in [`functions.rs`](functions.rs): ```rust sqlpage_functions! { // ... - example("value"); + example, } ``` The [`sqlpage_functions!`](function_traits.rs) macro declares the modules and generates the -`SqlPageFunctionName` enum the SQL engine dispatches on. That is the only compile-time code: there is -no build script involvement and no per-function generated code. Argument extraction, dispatch, and -return-value conversion are handled generically in [`function_traits.rs`](function_traits.rs) by the -`Extract`, `Handler`, and `IntoCowResult` traits. A function's argument and return types are read -straight from its signature, so the supported argument types are exactly those that implement -`Extract` (add an `impl` there to support a new one). +`SqlPageFunctionName` enum the SQL engine dispatches on. Per-function argument extraction, dispatch, +and return-value conversion are handled generically in [`function_traits.rs`](function_traits.rs) by +the `Extract`, `Handler`, and `IntoCowResult` traits. A function's argument and return types are read +straight from its signature, so supported argument types are the types that implement `Extract` there. +Functions can take up to five arguments. Keep helpers and unit tests that are specific to a function in that function's file. Shared helpers can -be made `pub(super)` and used by sibling function modules through `use super::*`. +be made `pub(super)` and imported by name from sibling function modules. diff --git a/src/webserver/database/sqlpage_functions/function_traits.rs b/src/webserver/database/sqlpage_functions/function_traits.rs index abeab1127..fc3c282df 100644 --- a/src/webserver/database/sqlpage_functions/function_traits.rs +++ b/src/webserver/database/sqlpage_functions/function_traits.rs @@ -1,9 +1,9 @@ //! Dispatch machinery for the built-in `sqlpage.*` SQL functions. //! //! Each function is a plain `async fn` in its own module under [`functions/`](super::functions). -//! `build.rs` lists those modules and the [`sqlpage_functions!`] macro turns the list into the -//! [`SqlPageFunctionName`](super::functions::SqlPageFunctionName) enum the engine dispatches on, so -//! functions register themselves just by existing. Adapting each signature to the uniform +//! [`sqlpage_functions!`] turns the module list in [`functions`](super::functions) into the +//! [`SqlPageFunctionName`](super::functions::SqlPageFunctionName) enum the engine dispatches on. +//! Adapting each signature to the uniform //! `(request, db, args) -> Option` convention is done generically by [`Extract`] (per //! argument type), [`Handler`] (per argument count, the trick `axum` uses) and [`IntoCowResult`] //! (per return type); the macro itself carries no type-level glue. @@ -217,17 +217,11 @@ impl<'a, T: IntoCow<'a>> IntoCow<'a> for Option { } /// Declares the listed function modules and builds the [`SqlPageFunctionName`] dispatch enum from -/// them. The list is produced by `build.rs`, so there is no hand-maintained registry. +/// them. macro_rules! sqlpage_functions { - ($($func:ident = $path:literal),* $(,)?) => { + ($($func:ident),* $(,)?) => { $( - // `build.rs` passes the absolute path because the `mod` is expanded from a file - // `include!`d out of `OUT_DIR`, where the default relative lookup would not find it. - #[path = $path] mod $func; - // Re-export so sibling modules can use each other's helpers via `use super::*`. - #[allow(unused_imports)] - use $func::*; )* /// One variant per built-in `sqlpage.*` function. @@ -238,12 +232,20 @@ macro_rules! sqlpage_functions { } impl SqlPageFunctionName { + const ALL: &'static [Self] = &[$(Self::$func),*]; + + fn name(self) -> &'static str { + match self { + $(Self::$func => stringify!($func)),* + } + } + pub(crate) async fn evaluate<'a, 'c>( self, - request: &'a ExecutionContext, - db_connection: &'c mut DbConn, - arguments: Vec>>, - ) -> anyhow::Result>> + request: &'a $crate::webserver::http_request_info::ExecutionContext, + db_connection: &'c mut $crate::webserver::database::execute_queries::DbConn, + arguments: Vec>>, + ) -> anyhow::Result>> where 'a: 'c, { @@ -256,32 +258,6 @@ macro_rules! sqlpage_functions { } } } - - impl ::std::str::FromStr for SqlPageFunctionName { - type Err = anyhow::Error; - - fn from_str(name: &str) -> anyhow::Result { - match name { - $(stringify!($func) => Ok(SqlPageFunctionName::$func),)* - unknown => anyhow::bail!( - "Unknown function {unknown:?}. Supported functions:\n{}", - [$(SqlPageFunctionName::$func),*] - .iter() - .map(|f| format!(" - {f}\n")) - .collect::() - ), - } - } - } - - impl ::std::fmt::Display for SqlPageFunctionName { - fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { - f.write_str("sqlpage.")?; - f.write_str(match self { - $(SqlPageFunctionName::$func => stringify!($func)),* - }) - } - } }; } diff --git a/src/webserver/database/sqlpage_functions/functions.rs b/src/webserver/database/sqlpage_functions/functions.rs index c5c67c39a..e6709bd86 100644 --- a/src/webserver/database/sqlpage_functions/functions.rs +++ b/src/webserver/database/sqlpage_functions/functions.rs @@ -1,38 +1,83 @@ //! Built-in `SQLPage` SQL functions. //! //! Every function is a plain `async fn` in its own module under [`functions/`](self). To add one, -//! just create `functions/.rs` with an `async fn `: `build.rs` discovers it and emits -//! a [`sqlpage_functions!`](super::function_traits::sqlpage_functions) call (the file `include!`d -//! below) that declares the module and adds it to the dispatch enum. Argument conversion and +//! create `functions/.rs` with an `async fn ` and add it to the +//! [`sqlpage_functions!`](super::function_traits::sqlpage_functions) call below. The macro declares +//! the module and adds it to the dispatch enum. Argument conversion and //! dispatch are handled generically in [`super::function_traits`]. -// Every function module reaches the shared imports below, and its siblings' `pub(super)` helpers, -// through `use super::*`. Allow it once here rather than on each module. -#![allow(clippy::wildcard_imports)] - -use super::{ExecutionContext, RequestInfo}; -use crate::filesystem::FileAccess; -use crate::webserver::{ - ErrorWithStatus, - database::{ - blob_to_data_url::vec_to_data_uri_with_mime, - execute_queries::DbConn, - sqlpage_functions::{http_fetch_request::HttpFetchRequest, url_parameters::URLParameters}, - }, - http_client::make_http_client, - request_variables::SetVariablesMap, - single_or_vec::SingleOrVec, -}; -use anyhow::{Context, anyhow}; -use futures_util::StreamExt; -use mime_guess::mime; -use opentelemetry_semantic_conventions::attribute as otel; use std::fmt::Write; -use std::{borrow::Cow, ffi::OsStr, str::FromStr}; -use tracing::Instrument; use super::function_traits::sqlpage_functions; -// Generated by build.rs: a `sqlpage_functions!` call listing every module file in `functions/`. -// Discovering them there is what frees us from a hand-maintained registry. -include!(concat!(env!("OUT_DIR"), "/sqlpage_functions.rs")); +sqlpage_functions! { + basic_auth_password, + basic_auth_username, + client_ip, + configuration_directory, + cookie, + current_working_directory, + environment_variable, + exec, + fetch, + fetch_with_meta, + hash_password, + header, + headers, + hmac, + link, + oidc_logout_url, + path, + persist_uploaded_file, + protocol, + random_string, + read_file_as_data_url, + read_file_as_text, + regex_match, + request_body, + request_body_base64, + request_method, + run_sql, + set_variable, + uploaded_file_mime_type, + uploaded_file_name, + uploaded_file_path, + url_encode, + user_info, + user_info_token, + variables, + version, + web_root, +} + +impl ::std::str::FromStr for SqlPageFunctionName { + type Err = anyhow::Error; + + fn from_str(name: &str) -> anyhow::Result { + SqlPageFunctionName::ALL + .iter() + .copied() + .find(|function| function.name() == name) + .ok_or_else(|| { + anyhow::anyhow!( + "Unknown function {name:?}. Supported functions:\n{}", + supported_function_list() + ) + }) + } +} + +impl ::std::fmt::Display for SqlPageFunctionName { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + f.write_str("sqlpage.")?; + f.write_str(self.name()) + } +} + +fn supported_function_list() -> String { + let mut supported = String::new(); + for function in SqlPageFunctionName::ALL { + writeln!(supported, " - {function}").expect("writing to a String cannot fail"); + } + supported +} diff --git a/src/webserver/database/sqlpage_functions/functions/basic_auth_password.rs b/src/webserver/database/sqlpage_functions/functions/basic_auth_password.rs index d4488c2ae..a73149da6 100644 --- a/src/webserver/database/sqlpage_functions/functions/basic_auth_password.rs +++ b/src/webserver/database/sqlpage_functions/functions/basic_auth_password.rs @@ -1,4 +1,6 @@ -use super::*; +use anyhow::Context; + +use crate::webserver::{ErrorWithStatus, http_request_info::RequestInfo}; /// Returns the password from the HTTP basic auth header, if present. pub(super) async fn basic_auth_password(request: &RequestInfo) -> anyhow::Result<&str> { diff --git a/src/webserver/database/sqlpage_functions/functions/basic_auth_username.rs b/src/webserver/database/sqlpage_functions/functions/basic_auth_username.rs index 446ef765b..cb370137c 100644 --- a/src/webserver/database/sqlpage_functions/functions/basic_auth_username.rs +++ b/src/webserver/database/sqlpage_functions/functions/basic_auth_username.rs @@ -1,4 +1,6 @@ -use super::*; +use crate::webserver::http_request_info::RequestInfo; + +use super::basic_auth_password::extract_basic_auth; /// Returns the username from the HTTP basic auth header, if present. /// Otherwise, returns an HTTP 401 Unauthorized error. diff --git a/src/webserver/database/sqlpage_functions/functions/client_ip.rs b/src/webserver/database/sqlpage_functions/functions/client_ip.rs index bfb2adb9d..8dcf23d4d 100644 --- a/src/webserver/database/sqlpage_functions/functions/client_ip.rs +++ b/src/webserver/database/sqlpage_functions/functions/client_ip.rs @@ -1,4 +1,4 @@ -use super::*; +use crate::webserver::http_request_info::RequestInfo; pub(super) async fn client_ip(request: &RequestInfo) -> Option { Some(request.client_ip?.to_string()) diff --git a/src/webserver/database/sqlpage_functions/functions/configuration_directory.rs b/src/webserver/database/sqlpage_functions/functions/configuration_directory.rs index b87e95241..20cb5d1c5 100644 --- a/src/webserver/database/sqlpage_functions/functions/configuration_directory.rs +++ b/src/webserver/database/sqlpage_functions/functions/configuration_directory.rs @@ -1,4 +1,4 @@ -use super::*; +use crate::webserver::http_request_info::RequestInfo; /// Returns the directory where the sqlpage.json configuration file, templates, and migrations are located. pub(super) async fn configuration_directory(request: &RequestInfo) -> String { diff --git a/src/webserver/database/sqlpage_functions/functions/cookie.rs b/src/webserver/database/sqlpage_functions/functions/cookie.rs index 3dba902ec..5b0a210a3 100644 --- a/src/webserver/database/sqlpage_functions/functions/cookie.rs +++ b/src/webserver/database/sqlpage_functions/functions/cookie.rs @@ -1,4 +1,6 @@ -use super::*; +use std::borrow::Cow; + +use crate::webserver::{http_request_info::RequestInfo, single_or_vec::SingleOrVec}; pub(super) async fn cookie<'a>(request: &'a RequestInfo, name: Cow<'a, str>) -> Option> { request.cookies.get(&*name).map(SingleOrVec::as_json_str) diff --git a/src/webserver/database/sqlpage_functions/functions/current_working_directory.rs b/src/webserver/database/sqlpage_functions/functions/current_working_directory.rs index 7811e2124..13016f866 100644 --- a/src/webserver/database/sqlpage_functions/functions/current_working_directory.rs +++ b/src/webserver/database/sqlpage_functions/functions/current_working_directory.rs @@ -1,4 +1,4 @@ -use super::*; +use anyhow::Context; pub(super) async fn current_working_directory() -> anyhow::Result { std::env::current_dir() diff --git a/src/webserver/database/sqlpage_functions/functions/environment_variable.rs b/src/webserver/database/sqlpage_functions/functions/environment_variable.rs index fc79af87f..4a94e9112 100644 --- a/src/webserver/database/sqlpage_functions/functions/environment_variable.rs +++ b/src/webserver/database/sqlpage_functions/functions/environment_variable.rs @@ -1,4 +1,6 @@ -use super::*; +use std::borrow::Cow; + +use anyhow::Context; /// Returns the value of an environment variable. pub(super) async fn environment_variable(name: Cow<'_, str>) -> anyhow::Result>> { diff --git a/src/webserver/database/sqlpage_functions/functions/exec.rs b/src/webserver/database/sqlpage_functions/functions/exec.rs index 15a2418db..f109d1d07 100644 --- a/src/webserver/database/sqlpage_functions/functions/exec.rs +++ b/src/webserver/database/sqlpage_functions/functions/exec.rs @@ -1,4 +1,9 @@ -use super::*; +use std::borrow::Cow; + +use anyhow::Context; +use tracing::Instrument; + +use crate::webserver::http_request_info::RequestInfo; /// Executes an external command and returns its output. pub(super) async fn exec<'a>( diff --git a/src/webserver/database/sqlpage_functions/functions/fetch.rs b/src/webserver/database/sqlpage_functions/functions/fetch.rs index 214aadabb..74945752d 100644 --- a/src/webserver/database/sqlpage_functions/functions/fetch.rs +++ b/src/webserver/database/sqlpage_functions/functions/fetch.rs @@ -1,4 +1,14 @@ -use super::*; +use std::{fmt::Write, str::FromStr}; + +use anyhow::{Context, anyhow}; +use opentelemetry_semantic_conventions::attribute as otel; +use tracing::Instrument; + +use crate::webserver::{ + database::sqlpage_functions::http_fetch_request::HttpFetchRequest, + http_client::make_http_client, + http_request_info::RequestInfo, +}; pub(super) fn build_request<'a>( client: &'a awc::Client, diff --git a/src/webserver/database/sqlpage_functions/functions/fetch_with_meta.rs b/src/webserver/database/sqlpage_functions/functions/fetch_with_meta.rs index e8c04c4e3..8cb2908a8 100644 --- a/src/webserver/database/sqlpage_functions/functions/fetch_with_meta.rs +++ b/src/webserver/database/sqlpage_functions/functions/fetch_with_meta.rs @@ -1,4 +1,12 @@ -use super::*; +use opentelemetry_semantic_conventions::attribute as otel; +use tracing::Instrument; + +use crate::webserver::{ + database::sqlpage_functions::http_fetch_request::HttpFetchRequest, + http_request_info::RequestInfo, +}; + +use super::fetch::{decode_response, fetch_span, send_request}; pub(super) async fn fetch_with_meta( request: &RequestInfo, diff --git a/src/webserver/database/sqlpage_functions/functions/hash_password.rs b/src/webserver/database/sqlpage_functions/functions/hash_password.rs index 8cf206d24..6494565ff 100644 --- a/src/webserver/database/sqlpage_functions/functions/hash_password.rs +++ b/src/webserver/database/sqlpage_functions/functions/hash_password.rs @@ -1,4 +1,4 @@ -use super::*; +use anyhow::anyhow; pub(super) async fn hash_password(password: Option) -> anyhow::Result> { let Some(password) = password else { diff --git a/src/webserver/database/sqlpage_functions/functions/header.rs b/src/webserver/database/sqlpage_functions/functions/header.rs index b45c412b4..a1be99b99 100644 --- a/src/webserver/database/sqlpage_functions/functions/header.rs +++ b/src/webserver/database/sqlpage_functions/functions/header.rs @@ -1,4 +1,6 @@ -use super::*; +use std::borrow::Cow; + +use crate::webserver::{http_request_info::RequestInfo, single_or_vec::SingleOrVec}; pub(super) async fn header<'a>(request: &'a RequestInfo, name: Cow<'a, str>) -> Option> { let lower_name = name.to_ascii_lowercase(); diff --git a/src/webserver/database/sqlpage_functions/functions/headers.rs b/src/webserver/database/sqlpage_functions/functions/headers.rs index 73fefa087..062141b3f 100644 --- a/src/webserver/database/sqlpage_functions/functions/headers.rs +++ b/src/webserver/database/sqlpage_functions/functions/headers.rs @@ -1,4 +1,4 @@ -use super::*; +use crate::webserver::http_request_info::RequestInfo; pub(super) async fn headers(request: &RequestInfo) -> String { serde_json::to_string(&request.headers).unwrap_or_default() diff --git a/src/webserver/database/sqlpage_functions/functions/hmac.rs b/src/webserver/database/sqlpage_functions/functions/hmac.rs index 44fad8219..b2771588d 100644 --- a/src/webserver/database/sqlpage_functions/functions/hmac.rs +++ b/src/webserver/database/sqlpage_functions/functions/hmac.rs @@ -1,4 +1,6 @@ -use super::*; +use std::{borrow::Cow, fmt::Write}; + +use anyhow::anyhow; /// Computes the HMAC (Hash-based Message Authentication Code) of the input data /// using the specified key and hashing algorithm. diff --git a/src/webserver/database/sqlpage_functions/functions/link.rs b/src/webserver/database/sqlpage_functions/functions/link.rs index 52ea3214f..f6bb28001 100644 --- a/src/webserver/database/sqlpage_functions/functions/link.rs +++ b/src/webserver/database/sqlpage_functions/functions/link.rs @@ -1,4 +1,8 @@ -use super::*; +use std::borrow::Cow; + +use anyhow::Context; + +use crate::webserver::database::sqlpage_functions::url_parameters::URLParameters; /// Builds a URL from a file name and a JSON object conatining URL parameters. /// For instance, if the file is "index.sql" and the parameters are {"x": "hello world"}, diff --git a/src/webserver/database/sqlpage_functions/functions/oidc_logout_url.rs b/src/webserver/database/sqlpage_functions/functions/oidc_logout_url.rs index b229b8098..c690f080b 100644 --- a/src/webserver/database/sqlpage_functions/functions/oidc_logout_url.rs +++ b/src/webserver/database/sqlpage_functions/functions/oidc_logout_url.rs @@ -1,4 +1,6 @@ -use super::*; +use std::borrow::Cow; + +use crate::webserver::{http_request_info::RequestInfo, single_or_vec::SingleOrVec}; pub(super) async fn oidc_logout_url<'a>( request: &'a RequestInfo, diff --git a/src/webserver/database/sqlpage_functions/functions/path.rs b/src/webserver/database/sqlpage_functions/functions/path.rs index 9baa90a49..c58e7fdb9 100644 --- a/src/webserver/database/sqlpage_functions/functions/path.rs +++ b/src/webserver/database/sqlpage_functions/functions/path.rs @@ -1,4 +1,4 @@ -use super::*; +use crate::webserver::http_request_info::RequestInfo; /// Returns the path component of the URL of the current request. pub(super) async fn path(request: &RequestInfo) -> &str { diff --git a/src/webserver/database/sqlpage_functions/functions/persist_uploaded_file.rs b/src/webserver/database/sqlpage_functions/functions/persist_uploaded_file.rs index 7f92b44ea..ddc766ab2 100644 --- a/src/webserver/database/sqlpage_functions/functions/persist_uploaded_file.rs +++ b/src/webserver/database/sqlpage_functions/functions/persist_uploaded_file.rs @@ -1,4 +1,10 @@ -use super::*; +use std::borrow::Cow; + +use anyhow::Context; + +use crate::webserver::http_request_info::RequestInfo; + +use super::random_string::random_string_sync; const DEFAULT_ALLOWED_EXTENSIONS: &str = "jpg,jpeg,png,gif,bmp,webp,pdf,txt,doc,docx,xls,xlsx,csv,mp3,mp4,wav,avi,mov"; diff --git a/src/webserver/database/sqlpage_functions/functions/protocol.rs b/src/webserver/database/sqlpage_functions/functions/protocol.rs index 16ab4595c..7fec3eb75 100644 --- a/src/webserver/database/sqlpage_functions/functions/protocol.rs +++ b/src/webserver/database/sqlpage_functions/functions/protocol.rs @@ -1,4 +1,4 @@ -use super::*; +use crate::webserver::http_request_info::RequestInfo; /// Returns the protocol of the current request (http or https). pub(super) async fn protocol(request: &RequestInfo) -> &str { diff --git a/src/webserver/database/sqlpage_functions/functions/read_file_as_data_url.rs b/src/webserver/database/sqlpage_functions/functions/read_file_as_data_url.rs index 90cb94c67..5645842c1 100644 --- a/src/webserver/database/sqlpage_functions/functions/read_file_as_data_url.rs +++ b/src/webserver/database/sqlpage_functions/functions/read_file_as_data_url.rs @@ -1,4 +1,16 @@ -use super::*; +use std::borrow::Cow; + +use anyhow::Context; + +use crate::{ + filesystem::FileAccess, + webserver::{ + database::blob_to_data_url::vec_to_data_uri_with_mime, + http_request_info::RequestInfo, + }, +}; + +use super::uploaded_file_mime_type::{mime_from_upload_path, mime_guess_from_filename}; pub(super) async fn read_file_bytes(request: &RequestInfo, path_str: &str) -> Result, anyhow::Error> { let path = std::path::Path::new(path_str); diff --git a/src/webserver/database/sqlpage_functions/functions/read_file_as_text.rs b/src/webserver/database/sqlpage_functions/functions/read_file_as_text.rs index f004d9874..7b44adbad 100644 --- a/src/webserver/database/sqlpage_functions/functions/read_file_as_text.rs +++ b/src/webserver/database/sqlpage_functions/functions/read_file_as_text.rs @@ -1,4 +1,10 @@ -use super::*; +use std::borrow::Cow; + +use anyhow::Context; + +use crate::webserver::http_request_info::RequestInfo; + +use super::read_file_as_data_url::read_file_bytes; /// Returns the contents of a file as a string pub(super) async fn read_file_as_text<'a>( diff --git a/src/webserver/database/sqlpage_functions/functions/regex_match.rs b/src/webserver/database/sqlpage_functions/functions/regex_match.rs index 93b40c228..fc504d2d7 100644 --- a/src/webserver/database/sqlpage_functions/functions/regex_match.rs +++ b/src/webserver/database/sqlpage_functions/functions/regex_match.rs @@ -1,4 +1,4 @@ -use super::*; +use std::borrow::Cow; /// Returns a string containing a JSON-encoded match object, or `null` if no match was found. /// The match object contains one key per capture group, with the value being the matched text. diff --git a/src/webserver/database/sqlpage_functions/functions/request_body.rs b/src/webserver/database/sqlpage_functions/functions/request_body.rs index a9bee0479..1c74c7766 100644 --- a/src/webserver/database/sqlpage_functions/functions/request_body.rs +++ b/src/webserver/database/sqlpage_functions/functions/request_body.rs @@ -1,4 +1,4 @@ -use super::*; +use crate::webserver::http_request_info::RequestInfo; /// Returns the raw request body as a string. /// If the request body is not valid UTF-8, invalid characters are replaced with the Unicode replacement character. diff --git a/src/webserver/database/sqlpage_functions/functions/request_body_base64.rs b/src/webserver/database/sqlpage_functions/functions/request_body_base64.rs index 47bc8a070..2555caf3d 100644 --- a/src/webserver/database/sqlpage_functions/functions/request_body_base64.rs +++ b/src/webserver/database/sqlpage_functions/functions/request_body_base64.rs @@ -1,4 +1,4 @@ -use super::*; +use crate::webserver::http_request_info::RequestInfo; /// Returns the raw request body encoded in base64. /// Returns NULL if there is no request body or if the request content type is diff --git a/src/webserver/database/sqlpage_functions/functions/request_method.rs b/src/webserver/database/sqlpage_functions/functions/request_method.rs index 50d5dd457..04229c802 100644 --- a/src/webserver/database/sqlpage_functions/functions/request_method.rs +++ b/src/webserver/database/sqlpage_functions/functions/request_method.rs @@ -1,4 +1,4 @@ -use super::*; +use crate::webserver::http_request_info::RequestInfo; pub(super) async fn request_method(request: &RequestInfo) -> String { request.method.to_string() diff --git a/src/webserver/database/sqlpage_functions/functions/run_sql.rs b/src/webserver/database/sqlpage_functions/functions/run_sql.rs index 4743c7c99..6de31f525 100644 --- a/src/webserver/database/sqlpage_functions/functions/run_sql.rs +++ b/src/webserver/database/sqlpage_functions/functions/run_sql.rs @@ -1,4 +1,16 @@ -use super::*; +use std::borrow::Cow; + +use anyhow::Context; +use futures_util::StreamExt; +use tracing::Instrument; + +use crate::{ + filesystem::FileAccess, + webserver::{ + database::execute_queries::DbConn, http_request_info::ExecutionContext, + request_variables::SetVariablesMap, + }, +}; pub(super) async fn run_sql<'a>( request: &'a ExecutionContext, diff --git a/src/webserver/database/sqlpage_functions/functions/set_variable.rs b/src/webserver/database/sqlpage_functions/functions/set_variable.rs index 6000b0236..ee33c7410 100644 --- a/src/webserver/database/sqlpage_functions/functions/set_variable.rs +++ b/src/webserver/database/sqlpage_functions/functions/set_variable.rs @@ -1,4 +1,10 @@ -use super::*; +use std::borrow::Cow; + +use crate::webserver::{ + database::sqlpage_functions::url_parameters::URLParameters, + http_request_info::ExecutionContext, + single_or_vec::SingleOrVec, +}; pub(super) async fn set_variable<'a>( context: &'a ExecutionContext, diff --git a/src/webserver/database/sqlpage_functions/functions/uploaded_file_mime_type.rs b/src/webserver/database/sqlpage_functions/functions/uploaded_file_mime_type.rs index 53406fac8..2105773e0 100644 --- a/src/webserver/database/sqlpage_functions/functions/uploaded_file_mime_type.rs +++ b/src/webserver/database/sqlpage_functions/functions/uploaded_file_mime_type.rs @@ -1,4 +1,8 @@ -use super::*; +use std::{borrow::Cow, ffi::OsStr}; + +use mime_guess::mime; + +use crate::webserver::http_request_info::RequestInfo; pub(super) fn mime_from_upload_path<'a>(request: &'a RequestInfo, path: &str) -> Option<&'a mime_guess::Mime> { request.uploaded_files.values().find_map(|uploaded_file| { diff --git a/src/webserver/database/sqlpage_functions/functions/uploaded_file_name.rs b/src/webserver/database/sqlpage_functions/functions/uploaded_file_name.rs index 16f9b098f..5a0961587 100644 --- a/src/webserver/database/sqlpage_functions/functions/uploaded_file_name.rs +++ b/src/webserver/database/sqlpage_functions/functions/uploaded_file_name.rs @@ -1,4 +1,6 @@ -use super::*; +use std::borrow::Cow; + +use crate::webserver::http_request_info::RequestInfo; pub(super) async fn uploaded_file_name<'a>( request: &'a RequestInfo, diff --git a/src/webserver/database/sqlpage_functions/functions/uploaded_file_path.rs b/src/webserver/database/sqlpage_functions/functions/uploaded_file_path.rs index 58464e44e..a84abd8d1 100644 --- a/src/webserver/database/sqlpage_functions/functions/uploaded_file_path.rs +++ b/src/webserver/database/sqlpage_functions/functions/uploaded_file_path.rs @@ -1,4 +1,6 @@ -use super::*; +use std::borrow::Cow; + +use crate::webserver::http_request_info::RequestInfo; pub(super) async fn uploaded_file_path<'a>( request: &'a RequestInfo, diff --git a/src/webserver/database/sqlpage_functions/functions/url_encode.rs b/src/webserver/database/sqlpage_functions/functions/url_encode.rs index 17c60453d..95c645227 100644 --- a/src/webserver/database/sqlpage_functions/functions/url_encode.rs +++ b/src/webserver/database/sqlpage_functions/functions/url_encode.rs @@ -1,4 +1,4 @@ -use super::*; +use std::borrow::Cow; /// escapes a string for use in a URL using percent encoding /// for example, spaces are replaced with %20, '/' with %2F, etc. diff --git a/src/webserver/database/sqlpage_functions/functions/user_info.rs b/src/webserver/database/sqlpage_functions/functions/user_info.rs index b3a556861..7da9470e1 100644 --- a/src/webserver/database/sqlpage_functions/functions/user_info.rs +++ b/src/webserver/database/sqlpage_functions/functions/user_info.rs @@ -1,4 +1,6 @@ -use super::*; +use std::borrow::Cow; + +use crate::webserver::http_request_info::RequestInfo; /// Returns a specific claim from the ID token. pub(super) async fn user_info<'a>( diff --git a/src/webserver/database/sqlpage_functions/functions/user_info_token.rs b/src/webserver/database/sqlpage_functions/functions/user_info_token.rs index 634748788..d10816621 100644 --- a/src/webserver/database/sqlpage_functions/functions/user_info_token.rs +++ b/src/webserver/database/sqlpage_functions/functions/user_info_token.rs @@ -1,4 +1,4 @@ -use super::*; +use crate::webserver::http_request_info::RequestInfo; /// Returns the ID token claims as a JSON object. pub(super) async fn user_info_token(request: &RequestInfo) -> anyhow::Result> { diff --git a/src/webserver/database/sqlpage_functions/functions/variables.rs b/src/webserver/database/sqlpage_functions/functions/variables.rs index e45830c6d..d482b1534 100644 --- a/src/webserver/database/sqlpage_functions/functions/variables.rs +++ b/src/webserver/database/sqlpage_functions/functions/variables.rs @@ -1,4 +1,8 @@ -use super::*; +use std::borrow::Cow; + +use anyhow::anyhow; + +use crate::webserver::http_request_info::ExecutionContext; /// Returns all variables in the request as a JSON object. pub(super) async fn variables<'a>( diff --git a/src/webserver/database/sqlpage_functions/functions/web_root.rs b/src/webserver/database/sqlpage_functions/functions/web_root.rs index ffe37b69d..a5a4103a9 100644 --- a/src/webserver/database/sqlpage_functions/functions/web_root.rs +++ b/src/webserver/database/sqlpage_functions/functions/web_root.rs @@ -1,4 +1,4 @@ -use super::*; +use crate::webserver::http_request_info::RequestInfo; /// Returns the directory where the .sql files are located (the web root). pub(super) async fn web_root(request: &RequestInfo) -> String { diff --git a/src/webserver/database/sqlpage_functions/mod.rs b/src/webserver/database/sqlpage_functions/mod.rs index de3917b3c..aea396bbc 100644 --- a/src/webserver/database/sqlpage_functions/mod.rs +++ b/src/webserver/database/sqlpage_functions/mod.rs @@ -5,8 +5,6 @@ mod url_parameters; use sqlparser::ast::FunctionArg; -use crate::webserver::http_request_info::{ExecutionContext, RequestInfo}; - use super::sql::ParamExtractContext; use super::syntax_tree::SqlPageFunctionCall; use super::syntax_tree::StmtParam;