chore: import upstream snapshot with attribution
CI / windows_test (push) Waiting to run
CI / compile_and_lint (push) Failing after 0s
CI / docker_build (linux/amd64, -linux-amd64-duckdb, duckdb) (push) Failing after 0s
CI / docker_build (linux/arm64, -linux-arm64, minimal) (push) Failing after 2s
CI / docker_build (linux/arm64, -linux-arm64-duckdb, duckdb) (push) Failing after 1s
CI / docker_build (linux/amd64, minimal) (push) Failing after 1s
CI / test (, sqlite, sqlite::memory:) (push) Has been skipped
CI / test (mssql, mssql, mssql://root:Password123!@127.0.0.1/sqlpage) (push) Has been skipped
CI / test (mysql, mysql, mysql://root:Password123!@127.0.0.1/sqlpage) (push) Has been skipped
CI / test (oracle, oracle, Driver=Oracle 21 ODBC driver;Dbq=//127.0.0.1:1521/FREEPDB1;Uid=root;Pwd=Password123!) (push) Has been skipped
CI / test (postgres, odbc, Driver=PostgreSQL Unicode;Server=127.0.0.1;Port=5432;Database=sqlpage;UID=root;PWD=Password123!, true) (push) Has been skipped
CI / test (postgres, postgres, postgres://root:Password123!@127.0.0.1/sqlpage) (push) Has been skipped
CI / playwright (push) Has been skipped
CI / docker_build (linux/arm/v7, -linux-arm-v7, minimal) (push) Failing after 0s
CI / hurl_examples (push) Failing after 8s
deploy website / deploy_official_site (push) Failing after 1s
CI / docker_push (duckdb) (push) Waiting to run
CI / docker_push (minimal) (push) Waiting to run
CI / hurl (${{ matrix.example }}) (push) Has been skipped
CI / windows_test (push) Waiting to run
CI / compile_and_lint (push) Failing after 0s
CI / docker_build (linux/amd64, -linux-amd64-duckdb, duckdb) (push) Failing after 0s
CI / docker_build (linux/arm64, -linux-arm64, minimal) (push) Failing after 2s
CI / docker_build (linux/arm64, -linux-arm64-duckdb, duckdb) (push) Failing after 1s
CI / docker_build (linux/amd64, minimal) (push) Failing after 1s
CI / test (, sqlite, sqlite::memory:) (push) Has been skipped
CI / test (mssql, mssql, mssql://root:Password123!@127.0.0.1/sqlpage) (push) Has been skipped
CI / test (mysql, mysql, mysql://root:Password123!@127.0.0.1/sqlpage) (push) Has been skipped
CI / test (oracle, oracle, Driver=Oracle 21 ODBC driver;Dbq=//127.0.0.1:1521/FREEPDB1;Uid=root;Pwd=Password123!) (push) Has been skipped
CI / test (postgres, odbc, Driver=PostgreSQL Unicode;Server=127.0.0.1;Port=5432;Database=sqlpage;UID=root;PWD=Password123!, true) (push) Has been skipped
CI / test (postgres, postgres, postgres://root:Password123!@127.0.0.1/sqlpage) (push) Has been skipped
CI / playwright (push) Has been skipped
CI / docker_build (linux/arm/v7, -linux-arm-v7, minimal) (push) Failing after 0s
CI / hurl_examples (push) Failing after 8s
deploy website / deploy_official_site (push) Failing after 1s
CI / docker_push (duckdb) (push) Waiting to run
CI / docker_push (minimal) (push) Waiting to run
CI / hurl (${{ matrix.example }}) (push) Has been skipped
This commit is contained in:
@@ -0,0 +1,961 @@
|
||||
use crate::cli::arguments::{Cli, parse_cli};
|
||||
use crate::webserver::content_security_policy::ContentSecurityPolicyTemplate;
|
||||
use crate::webserver::routing::RoutingConfig;
|
||||
use actix_web::http::Uri;
|
||||
use anyhow::Context;
|
||||
use config::Config;
|
||||
use openidconnect::IssuerUrl;
|
||||
use percent_encoding::AsciiSet;
|
||||
use serde::de::Error;
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
use std::net::{SocketAddr, ToSocketAddrs};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::str::FromStr;
|
||||
use std::time::Duration;
|
||||
|
||||
#[cfg(not(feature = "lambda-web"))]
|
||||
const DEFAULT_DATABASE_FILE: &str = "sqlpage.db";
|
||||
|
||||
impl AppConfig {
|
||||
pub fn from_cli(cli: &Cli) -> anyhow::Result<Self> {
|
||||
let mut config = if let Some(config_file) = &cli.config_file {
|
||||
if !config_file.is_file() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Configuration file does not exist: {}",
|
||||
config_file.display()
|
||||
));
|
||||
}
|
||||
log::debug!("Loading configuration from file: {}", config_file.display());
|
||||
load_from_file(config_file)?
|
||||
} else if let Some(config_dir) = &cli.config_dir {
|
||||
log::debug!(
|
||||
"Loading configuration from directory: {}",
|
||||
config_dir.display()
|
||||
);
|
||||
load_from_directory(config_dir)?
|
||||
} else {
|
||||
log::debug!("Loading configuration from environment");
|
||||
load_from_env()?
|
||||
};
|
||||
if let Some(web_root) = &cli.web_root {
|
||||
log::debug!(
|
||||
"Setting web root to value from the command line: {}",
|
||||
web_root.display()
|
||||
);
|
||||
config.web_root.clone_from(web_root);
|
||||
}
|
||||
if let Some(config_dir) = &cli.config_dir {
|
||||
config.configuration_directory.clone_from(config_dir);
|
||||
}
|
||||
|
||||
config.configuration_directory = std::fs::canonicalize(&config.configuration_directory)
|
||||
.unwrap_or_else(|_| config.configuration_directory.clone());
|
||||
|
||||
if !config.configuration_directory.exists() {
|
||||
log::info!(
|
||||
"Configuration directory does not exist, creating it: {}",
|
||||
config.configuration_directory.display()
|
||||
);
|
||||
std::fs::create_dir_all(&config.configuration_directory).with_context(|| {
|
||||
format!(
|
||||
"Failed to create configuration directory in {}",
|
||||
config.configuration_directory.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
|
||||
if config.database_url.is_empty() {
|
||||
log::debug!(
|
||||
"Creating default database in {}",
|
||||
config.configuration_directory.display()
|
||||
);
|
||||
config.database_url = create_default_database(&config.configuration_directory);
|
||||
}
|
||||
|
||||
config
|
||||
.validate()
|
||||
.context("The provided configuration is invalid")?;
|
||||
|
||||
config.resolve_timeouts();
|
||||
|
||||
log::debug!("Loaded configuration: {config:#?}");
|
||||
log::info!(
|
||||
"Configuration loaded from {}",
|
||||
config.configuration_directory.display()
|
||||
);
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
fn resolve_timeouts(&mut self) {
|
||||
let is_sqlite = self.database_url.starts_with("sqlite:");
|
||||
self.database_connection_idle_timeout = resolve_timeout(
|
||||
self.database_connection_idle_timeout,
|
||||
if is_sqlite {
|
||||
None
|
||||
} else {
|
||||
Some(Duration::from_mins(30))
|
||||
},
|
||||
);
|
||||
self.database_connection_max_lifetime = resolve_timeout(
|
||||
self.database_connection_max_lifetime,
|
||||
if is_sqlite {
|
||||
None
|
||||
} else {
|
||||
Some(Duration::from_hours(1))
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn validate(&self) -> anyhow::Result<()> {
|
||||
if !self.web_root.is_dir() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Web root is not a valid directory: {}",
|
||||
self.web_root.display()
|
||||
));
|
||||
}
|
||||
if !self.configuration_directory.is_dir() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Configuration directory is not a valid directory: {}",
|
||||
self.configuration_directory.display()
|
||||
));
|
||||
}
|
||||
if self.database_connection_acquire_timeout_seconds <= 0.0 {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Database connection acquire timeout must be positive"
|
||||
));
|
||||
}
|
||||
if let Some(max_connections) = self.max_database_pool_connections
|
||||
&& max_connections == 0
|
||||
{
|
||||
return Err(anyhow::anyhow!(
|
||||
"Maximum database pool connections must be greater than 0"
|
||||
));
|
||||
}
|
||||
anyhow::ensure!(self.max_pending_rows > 0, "max_pending_rows cannot be null");
|
||||
|
||||
for path in &self.oidc_protected_paths {
|
||||
if !path.starts_with('/') {
|
||||
return Err(anyhow::anyhow!(
|
||||
"All protected paths must start with '/', but found: '{path}'"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
for path in &self.oidc_public_paths {
|
||||
if !path.starts_with('/') {
|
||||
return Err(anyhow::anyhow!(
|
||||
"All public paths must start with '/', but found: '{path}'"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load_config() -> anyhow::Result<AppConfig> {
|
||||
let cli = parse_cli()?;
|
||||
AppConfig::from_cli(&cli)
|
||||
}
|
||||
|
||||
pub fn load_from_env() -> anyhow::Result<AppConfig> {
|
||||
let config_dir = configuration_directory();
|
||||
load_from_directory(&config_dir)
|
||||
.with_context(|| format!("Unable to load configuration from {}", config_dir.display()))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, PartialEq, Clone)]
|
||||
#[allow(clippy::struct_excessive_bools)]
|
||||
pub struct AppConfig {
|
||||
#[serde(default = "default_database_url")]
|
||||
pub database_url: String,
|
||||
/// A separate field for the database password. If set, this will override any password specified in the `database_url`.
|
||||
#[serde(default)]
|
||||
pub database_password: Option<String>,
|
||||
pub max_database_pool_connections: Option<u32>,
|
||||
#[serde(
|
||||
default,
|
||||
deserialize_with = "deserialize_duration_seconds",
|
||||
rename = "database_connection_idle_timeout_seconds"
|
||||
)]
|
||||
pub database_connection_idle_timeout: Option<Duration>,
|
||||
#[serde(
|
||||
default,
|
||||
deserialize_with = "deserialize_duration_seconds",
|
||||
rename = "database_connection_max_lifetime_seconds"
|
||||
)]
|
||||
pub database_connection_max_lifetime: Option<Duration>,
|
||||
|
||||
#[serde(default)]
|
||||
pub sqlite_extensions: Vec<String>,
|
||||
|
||||
#[serde(default, deserialize_with = "deserialize_socket_addr")]
|
||||
pub listen_on: Option<SocketAddr>,
|
||||
#[serde(default, deserialize_with = "deserialize_port")]
|
||||
pub port: Option<u16>,
|
||||
pub unix_socket: Option<PathBuf>,
|
||||
|
||||
/// Number of times to retry connecting to the database after a failure when the server starts
|
||||
/// up. Retries will happen every 5 seconds. The default is 6 retries, which means the server
|
||||
/// will wait up to 30 seconds for the database to become available.
|
||||
#[serde(default = "default_database_connection_retries")]
|
||||
pub database_connection_retries: u32,
|
||||
|
||||
/// Maximum number of seconds to wait before giving up when acquiring a database connection from the
|
||||
/// pool. The default is 10 seconds.
|
||||
#[serde(default = "default_database_connection_acquire_timeout_seconds")]
|
||||
pub database_connection_acquire_timeout_seconds: f64,
|
||||
|
||||
/// The directory where the .sql files are located. Defaults to the current directory.
|
||||
#[serde(default = "default_web_root")]
|
||||
pub web_root: PathBuf,
|
||||
|
||||
/// The directory where the sqlpage configuration file is located. Defaults to `./sqlpage`.
|
||||
#[serde(default = "configuration_directory")]
|
||||
pub configuration_directory: PathBuf,
|
||||
|
||||
/// Set to true to allow the `sqlpage.exec` function to be used in SQL queries.
|
||||
/// This should be enabled only if you trust the users writing SQL queries, since it gives
|
||||
/// them the ability to execute arbitrary shell commands on the server.
|
||||
#[serde(default)]
|
||||
pub allow_exec: bool,
|
||||
|
||||
/// Maximum size of uploaded files in bytes. The default is 10MiB (10 * 1024 * 1024 bytes)
|
||||
#[serde(default = "default_max_file_size")]
|
||||
pub max_uploaded_file_size: usize,
|
||||
|
||||
/// The base URL of the `OpenID` Connect provider.
|
||||
/// Required when enabling Single Sign-On through an OIDC provider.
|
||||
pub oidc_issuer_url: Option<IssuerUrl>,
|
||||
/// The client ID assigned to `SQLPage` when registering with the OIDC provider.
|
||||
/// Defaults to `sqlpage`.
|
||||
#[serde(default = "default_oidc_client_id")]
|
||||
pub oidc_client_id: String,
|
||||
/// The client secret for authenticating `SQLPage` to the OIDC provider.
|
||||
/// Required when enabling Single Sign-On through an OIDC provider.
|
||||
pub oidc_client_secret: Option<String>,
|
||||
/// Space-separated list of [scopes](https://openid.net/specs/openid-connect-core-1_0.html#ScopeClaims) to request during OIDC authentication.
|
||||
/// Defaults to "openid email profile"
|
||||
#[serde(default = "default_oidc_scopes")]
|
||||
pub oidc_scopes: String,
|
||||
|
||||
/// Defines a list of path prefixes that should be protected by OIDC authentication.
|
||||
/// By default, all paths are protected.
|
||||
/// If you specify a list of prefixes, only requests whose path starts with one of the prefixes will require authentication.
|
||||
/// For example, if you set this to `["/private"]`, then requests to `/private/some_page.sql` will require authentication,
|
||||
/// but requests to `/index.sql` will not.
|
||||
/// NOTE: `OIDC_PUBLIC_PATHS` takes precedence over `OIDC_PROTECTED_PATHS`.
|
||||
/// For example, if you have `["/private"]` on the `protected_paths` like before, but also `["/private/public"]` on the `public_paths`, then `/private` requires authentication, but `/private/public` requires not authentication.
|
||||
/// You cannot make a path inside a public path private again. So expanding the previous example, if you now add `/private/public/private_again`, then this path will still be accessible.
|
||||
#[serde(default = "default_oidc_protected_paths")]
|
||||
pub oidc_protected_paths: Vec<String>,
|
||||
|
||||
/// Defines path prefixes to exclude from OIDC authentication.
|
||||
/// By default, no paths are excluded.
|
||||
/// Paths matching these prefixes will not require authentication.
|
||||
/// For example, if set to `["/public"]`, requests to `/public/some_page.sql` will not require authentication,
|
||||
/// but requests to `/index.sql` will still require it.
|
||||
/// To make `/protected/public.sql` public while protecting its containing directory,
|
||||
/// set `oidc_public_paths` to `["/protected/public.sql"]` and `oidc_protected_paths` to `["/protected"]`.
|
||||
/// Be aware that any path starting with `/protected/public.sql` (e.g., `/protected/public.sql.backup`) will also become public.
|
||||
#[serde(default)]
|
||||
pub oidc_public_paths: Vec<String>,
|
||||
|
||||
/// Additional trusted audiences for OIDC JWT tokens, beyond the client ID.
|
||||
/// By default (when None), all additional audiences are trusted for compatibility
|
||||
/// with providers that include multiple audience values (like ZITADEL, Azure AD, etc.).
|
||||
/// Set to an empty list to only allow the client ID as audience.
|
||||
/// Set to a specific list to only allow those specific additional audiences.
|
||||
#[serde(default)]
|
||||
pub oidc_additional_trusted_audiences: Option<Vec<String>>,
|
||||
|
||||
/// A domain name to use for the HTTPS server. If this is set, the server will perform all the necessary
|
||||
/// steps to set up an HTTPS server automatically. All you need to do is point your domain name to the
|
||||
/// server's IP address.
|
||||
///
|
||||
/// It will listen on port 443 for HTTPS connections,
|
||||
/// and will automatically request a certificate from Let's Encrypt
|
||||
/// using the ACME protocol (requesting a TLS-ALPN-01 challenge).
|
||||
pub https_domain: Option<String>,
|
||||
|
||||
/// The hostname where your application is publicly accessible (e.g., "myapp.example.com").
|
||||
/// This is used for OIDC redirect URLs. If not set, `https_domain` will be used instead.
|
||||
pub host: Option<String>,
|
||||
|
||||
/// The email address to use when requesting a certificate from Let's Encrypt.
|
||||
/// Defaults to `contact@<https_domain>`.
|
||||
pub https_certificate_email: Option<String>,
|
||||
|
||||
/// The directory to store the Let's Encrypt certificate in. Defaults to `./sqlpage/https`.
|
||||
#[serde(default = "default_https_certificate_cache_dir")]
|
||||
pub https_certificate_cache_dir: PathBuf,
|
||||
|
||||
/// URL to the ACME directory. Defaults to the Let's Encrypt production directory.
|
||||
#[serde(default = "default_https_acme_directory_url")]
|
||||
pub https_acme_directory_url: String,
|
||||
|
||||
/// Whether we should run in development or production mode. Used to determine
|
||||
/// whether to show error messages to the user.
|
||||
#[serde(default)]
|
||||
pub environment: DevOrProd,
|
||||
|
||||
/// Serve the website from a sub path. For example, if you set this to `/sqlpage/`, the website will be
|
||||
/// served from `https://yourdomain.com/sqlpage/`. Defaults to `/`.
|
||||
/// This is useful if you want to serve the website on the same domain as other content, and
|
||||
/// you are using a reverse proxy to route requests to the correct server.
|
||||
#[serde(
|
||||
deserialize_with = "deserialize_site_prefix",
|
||||
default = "default_site_prefix"
|
||||
)]
|
||||
pub site_prefix: String,
|
||||
|
||||
/// Maximum number of messages that can be stored in memory before sending them to the client.
|
||||
/// This prevents a single request from using up all available memory.
|
||||
#[serde(default = "default_max_pending_rows")]
|
||||
pub max_pending_rows: usize,
|
||||
|
||||
/// Whether to compress the http response body when the client supports it.
|
||||
/// Enabling response compression hinders the ability of `SQLPage` to stream every single byte
|
||||
/// of data as soon as it is produced.
|
||||
/// As a rule of thub, enable response compression when your app is fast.
|
||||
#[serde(default = "default_compress_responses")]
|
||||
pub compress_responses: bool,
|
||||
|
||||
/// Content-Security-Policy header to send to the client.
|
||||
/// If not set, a default policy allowing
|
||||
/// - scripts from the same origin,
|
||||
/// - script elements with the `nonce="{{@csp_nonce}}"` attribute,
|
||||
#[serde(default)]
|
||||
pub content_security_policy: ContentSecurityPolicyTemplate,
|
||||
|
||||
/// Whether `sqlpage.fetch` should load trusted certificates from the operating system's certificate store
|
||||
/// By default, it loads Mozilla's root certificates that are embedded in the `SQLPage` binary, or the ones pointed to by the
|
||||
/// `SSL_CERT_FILE` and `SSL_CERT_DIR` environment variables.
|
||||
#[serde(default = "default_system_root_ca_certificates")]
|
||||
pub system_root_ca_certificates: bool,
|
||||
|
||||
/// Maximum depth of recursion allowed in the `run_sql` function.
|
||||
#[serde(default = "default_max_recursion_depth")]
|
||||
pub max_recursion_depth: u8,
|
||||
|
||||
#[serde(default = "default_markdown_allow_dangerous_html")]
|
||||
pub markdown_allow_dangerous_html: bool,
|
||||
|
||||
#[serde(default = "default_markdown_allow_dangerous_protocol")]
|
||||
pub markdown_allow_dangerous_protocol: bool,
|
||||
|
||||
pub cache_stale_duration_ms: Option<u64>,
|
||||
}
|
||||
|
||||
impl AppConfig {
|
||||
#[must_use]
|
||||
pub fn cache_stale_duration_ms(&self) -> u64 {
|
||||
self.cache_stale_duration_ms
|
||||
.unwrap_or_else(|| if self.environment.is_prod() { 1000 } else { 0 })
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn listen_on(&self) -> SocketAddr {
|
||||
let mut addr = self.listen_on.unwrap_or_else(|| {
|
||||
if self.https_domain.is_some() {
|
||||
SocketAddr::from(([0, 0, 0, 0], 443))
|
||||
} else {
|
||||
SocketAddr::from(([0, 0, 0, 0], 8080))
|
||||
}
|
||||
});
|
||||
if let Some(port) = self.port {
|
||||
addr.set_port(port);
|
||||
}
|
||||
addr
|
||||
}
|
||||
}
|
||||
|
||||
impl RoutingConfig for AppConfig {
|
||||
fn prefix(&self) -> &str {
|
||||
&self.site_prefix
|
||||
}
|
||||
}
|
||||
|
||||
/// The directory where the `sqlpage.json` file is located.
|
||||
/// Determined by the `SQLPAGE_CONFIGURATION_DIRECTORY` environment variable
|
||||
fn configuration_directory() -> PathBuf {
|
||||
let env_var_name = "CONFIGURATION_DIRECTORY";
|
||||
// uppercase or lowercase, with or without the "SQLPAGE_" prefix
|
||||
for prefix in &["", "SQLPAGE_"] {
|
||||
let var = format!("{prefix}{env_var_name}");
|
||||
for t in [str::to_lowercase, str::to_uppercase] {
|
||||
let dir = t(&var);
|
||||
if let Ok(dir) = std::env::var(dir) {
|
||||
return PathBuf::from(dir);
|
||||
}
|
||||
}
|
||||
}
|
||||
PathBuf::from("./sqlpage")
|
||||
}
|
||||
|
||||
fn cannonicalize_if_possible(path: &std::path::Path) -> PathBuf {
|
||||
path.canonicalize().unwrap_or_else(|_| path.to_owned())
|
||||
}
|
||||
|
||||
/// Parses and loads the configuration from the `sqlpage.json` file in the current directory.
|
||||
/// This should be called only once at the start of the program.
|
||||
pub fn load_from_directory(directory: &Path) -> anyhow::Result<AppConfig> {
|
||||
let cannonical = cannonicalize_if_possible(directory);
|
||||
log::debug!("Loading configuration from {}", cannonical.display());
|
||||
let config_file = directory.join("sqlpage");
|
||||
let mut app_config = load_from_file(&config_file)?;
|
||||
app_config.configuration_directory = directory.into();
|
||||
Ok(app_config)
|
||||
}
|
||||
|
||||
/// Parses and loads the configuration from the given file.
|
||||
pub fn load_from_file(config_file: &Path) -> anyhow::Result<AppConfig> {
|
||||
log::debug!("Loading configuration from file: {}", config_file.display());
|
||||
let config = Config::builder()
|
||||
.add_source(config::File::from(config_file).required(false))
|
||||
.add_source(env_config())
|
||||
.add_source(env_config().prefix("SQLPAGE"))
|
||||
.build()
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"Unable to build configuration loader for {}",
|
||||
config_file.display()
|
||||
)
|
||||
})?;
|
||||
log::trace!("Configuration sources: {:#?}", config.cache);
|
||||
let app_config = config
|
||||
.try_deserialize::<AppConfig>()
|
||||
.context("Failed to load the configuration")?;
|
||||
Ok(app_config)
|
||||
}
|
||||
|
||||
fn env_config() -> config::Environment {
|
||||
config::Environment::default()
|
||||
.try_parsing(true)
|
||||
.list_separator(" ")
|
||||
.with_list_parse_key("sqlite_extensions")
|
||||
}
|
||||
|
||||
fn deserialize_port<'de, D>(deserializer: D) -> Result<Option<u16>, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
// deserializes both 8080 and "tcp://1.1.1.1:9090"
|
||||
#[derive(Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum PortOrUrl {
|
||||
Port(u16),
|
||||
Url(String),
|
||||
}
|
||||
let port_or_url: Option<PortOrUrl> = Deserialize::deserialize(deserializer)?;
|
||||
match port_or_url {
|
||||
Some(PortOrUrl::Port(p)) => Ok(Some(p)),
|
||||
Some(PortOrUrl::Url(u)) => {
|
||||
if let Ok(u) = Uri::from_str(&u) {
|
||||
log::warn!(
|
||||
"{u} is not a valid value for the SQLPage port number. Ignoring this error since kubernetes may set the SQLPAGE_PORT env variable to a service URI when there is a service named sqlpage. Rename your service to avoid this warning."
|
||||
);
|
||||
Ok(None)
|
||||
} else {
|
||||
Err(D::Error::custom(format!(
|
||||
"Invalid port number: {u}. Expected a number between {} and {}",
|
||||
u16::MIN,
|
||||
u16::MAX
|
||||
)))
|
||||
}
|
||||
}
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
fn deserialize_socket_addr<'de, D: Deserializer<'de>>(
|
||||
deserializer: D,
|
||||
) -> Result<Option<SocketAddr>, D::Error> {
|
||||
let host_str: Option<String> = Deserialize::deserialize(deserializer)?;
|
||||
host_str
|
||||
.map(|h| {
|
||||
parse_socket_addr(&h).map_err(|e| {
|
||||
D::Error::custom(anyhow::anyhow!("Failed to parse socket address {h:?}: {e}"))
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
fn deserialize_site_prefix<'de, D: Deserializer<'de>>(deserializer: D) -> Result<String, D::Error> {
|
||||
let prefix: String = Deserialize::deserialize(deserializer)?;
|
||||
Ok(normalize_site_prefix(prefix.as_str()))
|
||||
}
|
||||
|
||||
/// We standardize the site prefix to always be stored with both leading and trailing slashes.
|
||||
/// We also percent-encode special characters in the prefix, but allow it to contain slashes (to allow
|
||||
/// hosting on a sub-sub-path).
|
||||
fn normalize_site_prefix(prefix: &str) -> String {
|
||||
const TO_ENCODE: AsciiSet = percent_encoding::CONTROLS
|
||||
.add(b' ')
|
||||
.add(b'"')
|
||||
.add(b'#')
|
||||
.add(b'<')
|
||||
.add(b'>')
|
||||
.add(b'?');
|
||||
|
||||
let prefix = prefix.trim_start_matches('/').trim_end_matches('/');
|
||||
if prefix.is_empty() {
|
||||
return default_site_prefix();
|
||||
}
|
||||
let encoded_prefix = percent_encoding::percent_encode(prefix.as_bytes(), &TO_ENCODE);
|
||||
|
||||
let invalid_chars = ["%09", "%0A", "%0D"];
|
||||
|
||||
std::iter::once("/")
|
||||
.chain(encoded_prefix.filter(|c| !invalid_chars.contains(c)))
|
||||
.chain(std::iter::once("/"))
|
||||
.collect::<String>()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_site_prefix() {
|
||||
assert_eq!(normalize_site_prefix(""), "/");
|
||||
assert_eq!(normalize_site_prefix("/"), "/");
|
||||
assert_eq!(normalize_site_prefix("a"), "/a/");
|
||||
assert_eq!(normalize_site_prefix("a/"), "/a/");
|
||||
assert_eq!(normalize_site_prefix("/a"), "/a/");
|
||||
assert_eq!(normalize_site_prefix("a/b"), "/a/b/");
|
||||
assert_eq!(normalize_site_prefix("a/b/"), "/a/b/");
|
||||
assert_eq!(normalize_site_prefix("a/b/c"), "/a/b/c/");
|
||||
assert_eq!(normalize_site_prefix("a b"), "/a%20b/");
|
||||
assert_eq!(normalize_site_prefix("a b/c"), "/a%20b/c/");
|
||||
}
|
||||
|
||||
fn default_site_prefix() -> String {
|
||||
'/'.to_string()
|
||||
}
|
||||
|
||||
fn parse_socket_addr(host_str: &str) -> anyhow::Result<SocketAddr> {
|
||||
host_str
|
||||
.to_socket_addrs()?
|
||||
.next()
|
||||
.with_context(|| format!("Resolving host '{host_str}'"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn default_database_url() -> String {
|
||||
"sqlite://:memory:?cache=shared".to_owned()
|
||||
}
|
||||
#[cfg(not(test))]
|
||||
fn default_database_url() -> String {
|
||||
// When using a custom configuration directory, the default database URL
|
||||
// will be set later in `AppConfig::from_cli`.
|
||||
String::new()
|
||||
}
|
||||
|
||||
fn create_default_database(configuration_directory: &Path) -> String {
|
||||
let prefix = "sqlite://".to_owned();
|
||||
|
||||
#[cfg(not(feature = "lambda-web"))]
|
||||
{
|
||||
let config_dir = cannonicalize_if_possible(configuration_directory);
|
||||
let old_default_db_path = PathBuf::from(DEFAULT_DATABASE_FILE);
|
||||
let default_db_path = config_dir.join(DEFAULT_DATABASE_FILE);
|
||||
if let Ok(true) = old_default_db_path.try_exists() {
|
||||
log::warn!(
|
||||
"Your sqlite database in {} is publicly accessible through your web server. Please move it to {}.",
|
||||
old_default_db_path.display(),
|
||||
default_db_path.display()
|
||||
);
|
||||
return prefix + old_default_db_path.to_str().unwrap();
|
||||
} else if let Ok(true) = default_db_path.try_exists() {
|
||||
log::debug!(
|
||||
"Using the default database file in {}",
|
||||
default_db_path.display()
|
||||
);
|
||||
return prefix + &encode_uri(&default_db_path);
|
||||
}
|
||||
// Create the default database file if we can
|
||||
if let Ok(tmp_file) = std::fs::File::create(&default_db_path) {
|
||||
log::info!(
|
||||
"No DATABASE_URL provided, {} is writable, creating a new database file.",
|
||||
default_db_path.display()
|
||||
);
|
||||
drop(tmp_file);
|
||||
if let Err(e) = std::fs::remove_file(&default_db_path) {
|
||||
log::debug!(
|
||||
"Unable to remove temporary probe file. It might have already been removed by another instance started concurrently: {e}"
|
||||
);
|
||||
}
|
||||
return prefix + &encode_uri(&default_db_path) + "?mode=rwc";
|
||||
}
|
||||
}
|
||||
|
||||
log::warn!(
|
||||
"No DATABASE_URL provided, and {} is not writeable. Using a temporary in-memory SQLite database. All the data created will be lost when this server shuts down.",
|
||||
configuration_directory.display()
|
||||
);
|
||||
prefix + ":memory:?cache=shared"
|
||||
}
|
||||
|
||||
#[cfg(any(test, not(feature = "lambda-web")))]
|
||||
fn encode_uri(path: &Path) -> std::borrow::Cow<'_, str> {
|
||||
const ASCII_SET: &percent_encoding::AsciiSet = &percent_encoding::NON_ALPHANUMERIC
|
||||
.remove(b'-')
|
||||
.remove(b'_')
|
||||
.remove(b'.')
|
||||
.remove(b':')
|
||||
.remove(b' ')
|
||||
.remove(b'/');
|
||||
let path_bytes = path.as_os_str().as_encoded_bytes();
|
||||
percent_encoding::percent_encode(path_bytes, ASCII_SET).into()
|
||||
}
|
||||
|
||||
fn default_database_connection_retries() -> u32 {
|
||||
6
|
||||
}
|
||||
|
||||
fn default_database_connection_acquire_timeout_seconds() -> f64 {
|
||||
10.
|
||||
}
|
||||
|
||||
fn default_web_root() -> PathBuf {
|
||||
std::env::current_dir().unwrap_or_else(|e| {
|
||||
log::error!("Unable to get current directory: {e}");
|
||||
PathBuf::from(&std::path::Component::CurDir)
|
||||
})
|
||||
}
|
||||
|
||||
fn default_max_file_size() -> usize {
|
||||
5 * 1024 * 1024
|
||||
}
|
||||
|
||||
fn default_https_certificate_cache_dir() -> PathBuf {
|
||||
default_web_root().join("sqlpage").join("https")
|
||||
}
|
||||
|
||||
fn default_https_acme_directory_url() -> String {
|
||||
"https://acme-v02.api.letsencrypt.org/directory".to_string()
|
||||
}
|
||||
|
||||
/// If the sending queue exceeds this number of outgoing messages, an error will be thrown
|
||||
/// This prevents a single request from using up all available memory
|
||||
fn default_max_pending_rows() -> usize {
|
||||
256
|
||||
}
|
||||
|
||||
fn default_compress_responses() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn default_system_root_ca_certificates() -> bool {
|
||||
std::env::var("SSL_CERT_FILE").is_ok_and(|x| !x.is_empty())
|
||||
|| std::env::var("SSL_CERT_DIR").is_ok_and(|x| !x.is_empty())
|
||||
}
|
||||
|
||||
fn default_max_recursion_depth() -> u8 {
|
||||
10
|
||||
}
|
||||
|
||||
fn default_markdown_allow_dangerous_html() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn default_markdown_allow_dangerous_protocol() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn default_oidc_client_id() -> String {
|
||||
"sqlpage".to_string()
|
||||
}
|
||||
|
||||
fn default_oidc_scopes() -> String {
|
||||
"openid email profile".to_string()
|
||||
}
|
||||
|
||||
fn default_oidc_protected_paths() -> Vec<String> {
|
||||
vec!["/".to_string()]
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, PartialEq, Clone, Copy, Eq, Default)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum DevOrProd {
|
||||
#[default]
|
||||
Development,
|
||||
Production,
|
||||
}
|
||||
impl DevOrProd {
|
||||
pub(crate) fn is_prod(self) -> bool {
|
||||
self == DevOrProd::Production
|
||||
}
|
||||
}
|
||||
|
||||
fn deserialize_duration_seconds<'de, D>(deserializer: D) -> Result<Option<Duration>, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let seconds: Option<f64> = Option::deserialize(deserializer)?;
|
||||
match seconds {
|
||||
None => Ok(None),
|
||||
Some(s) if s <= 0.0 || !s.is_finite() => Ok(Some(Duration::ZERO)),
|
||||
Some(s) => Ok(Some(Duration::from_secs_f64(s))),
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_timeout(config_val: Option<Duration>, default: Option<Duration>) -> Option<Duration> {
|
||||
match config_val {
|
||||
Some(v) if v.is_zero() => None,
|
||||
Some(v) => Some(v),
|
||||
None => default,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn test_database_url() -> String {
|
||||
std::env::var("DATABASE_URL").unwrap_or_else(|_| "sqlite::memory:".to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub mod tests {
|
||||
use super::AppConfig;
|
||||
pub use super::test_database_url;
|
||||
|
||||
#[must_use]
|
||||
pub fn test_config() -> AppConfig {
|
||||
let mut config = serde_json::from_str::<AppConfig>(
|
||||
&serde_json::json!({
|
||||
"database_url": test_database_url(),
|
||||
"listen_on": "localhost:8080"
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
config.resolve_timeouts();
|
||||
config
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
use std::env;
|
||||
use std::sync::Mutex;
|
||||
|
||||
static ENV_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
#[test]
|
||||
fn test_default_site_prefix() {
|
||||
assert_eq!(default_site_prefix(), "/".to_string());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_encode_uri() {
|
||||
assert_eq!(
|
||||
encode_uri(Path::new("/hello world/xxx.db")),
|
||||
"/hello world/xxx.db"
|
||||
);
|
||||
assert_eq!(encode_uri(Path::new("é")), "%C3%A9");
|
||||
assert_eq!(encode_uri(Path::new("/a?b/c")), "/a%3Fb/c");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_site_prefix() {
|
||||
assert_eq!(normalize_site_prefix(""), "/");
|
||||
assert_eq!(normalize_site_prefix("/"), "/");
|
||||
assert_eq!(normalize_site_prefix("a"), "/a/");
|
||||
assert_eq!(normalize_site_prefix("a/"), "/a/");
|
||||
assert_eq!(normalize_site_prefix("/a"), "/a/");
|
||||
assert_eq!(normalize_site_prefix("a/b"), "/a/b/");
|
||||
assert_eq!(normalize_site_prefix("a/b/"), "/a/b/");
|
||||
assert_eq!(normalize_site_prefix("a/b/c"), "/a/b/c/");
|
||||
assert_eq!(normalize_site_prefix("a b"), "/a%20b/");
|
||||
assert_eq!(normalize_site_prefix("a b/c"), "/a%20b/c/");
|
||||
assert_eq!(normalize_site_prefix("*-+/:;,?%\"'{"), "/*-+/:;,%3F%%22'{/");
|
||||
assert_eq!(
|
||||
normalize_site_prefix(&(0..=0x7F).map(char::from).collect::<String>()),
|
||||
"/%00%01%02%03%04%05%06%07%08%0B%0C%0E%0F%10%11%12%13%14%15%16%17%18%19%1A%1B%1C%1D%1E%1F%20!%22%23$%&'()*+,-./0123456789:;%3C=%3E%3F@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~%7F/"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sqlpage_prefixed_env_variable_parsing() {
|
||||
let _lock = ENV_LOCK
|
||||
.lock()
|
||||
.expect("Another test panicked while holding the lock");
|
||||
unsafe {
|
||||
env::set_var("SQLPAGE_CONFIGURATION_DIRECTORY", "/path/to/config");
|
||||
}
|
||||
|
||||
let config = load_from_env().unwrap();
|
||||
|
||||
assert_eq!(
|
||||
config.configuration_directory,
|
||||
PathBuf::from("/path/to/config"),
|
||||
"Configuration directory should match the SQLPAGE_CONFIGURATION_DIRECTORY env var"
|
||||
);
|
||||
|
||||
unsafe {
|
||||
env::remove_var("SQLPAGE_CONFIGURATION_DIRECTORY");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_k8s_env_var_ignored() {
|
||||
let _lock = ENV_LOCK
|
||||
.lock()
|
||||
.expect("Another test panicked while holding the lock");
|
||||
unsafe {
|
||||
env::set_var("SQLPAGE_PORT", "tcp://10.0.0.1:8080");
|
||||
}
|
||||
|
||||
let config = load_from_env().unwrap();
|
||||
assert_eq!(config.port, None);
|
||||
|
||||
unsafe {
|
||||
env::remove_var("SQLPAGE_PORT");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_valid_port_env_var() {
|
||||
let _lock = ENV_LOCK
|
||||
.lock()
|
||||
.expect("Another test panicked while holding the lock");
|
||||
unsafe {
|
||||
env::set_var("SQLPAGE_PORT", "9000");
|
||||
}
|
||||
|
||||
let config = load_from_env().unwrap();
|
||||
assert_eq!(config.port, Some(9000));
|
||||
|
||||
unsafe {
|
||||
env::remove_var("SQLPAGE_PORT");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_priority() {
|
||||
let _lock = ENV_LOCK
|
||||
.lock()
|
||||
.expect("Another test panicked while holding the lock");
|
||||
unsafe {
|
||||
env::set_var("SQLPAGE_WEB_ROOT", "/");
|
||||
}
|
||||
|
||||
let cli = Cli {
|
||||
web_root: Some(PathBuf::from(".")),
|
||||
config_dir: None,
|
||||
config_file: None,
|
||||
command: None,
|
||||
};
|
||||
|
||||
let config = AppConfig::from_cli(&cli).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
config.web_root,
|
||||
PathBuf::from("."),
|
||||
"CLI argument should take precedence over environment variable"
|
||||
);
|
||||
|
||||
unsafe {
|
||||
env::remove_var("SQLPAGE_WEB_ROOT");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_file_priority() {
|
||||
let _lock = ENV_LOCK
|
||||
.lock()
|
||||
.expect("Another test panicked while holding the lock");
|
||||
let temp_dir = std::env::temp_dir().join("sqlpage_test");
|
||||
std::fs::create_dir_all(&temp_dir).unwrap();
|
||||
let config_file_path = temp_dir.join("sqlpage.json");
|
||||
let config_web_dir = temp_dir.join("config/web");
|
||||
let env_web_dir = temp_dir.join("env/web");
|
||||
let cli_web_dir = temp_dir.join("cli/web");
|
||||
std::fs::create_dir_all(&config_web_dir).unwrap();
|
||||
std::fs::create_dir_all(&env_web_dir).unwrap();
|
||||
std::fs::create_dir_all(&cli_web_dir).unwrap();
|
||||
|
||||
let config_content = serde_json::json!({
|
||||
"web_root": config_web_dir.to_str().unwrap()
|
||||
})
|
||||
.to_string();
|
||||
std::fs::write(&config_file_path, config_content).unwrap();
|
||||
|
||||
unsafe {
|
||||
env::set_var("SQLPAGE_WEB_ROOT", env_web_dir.to_str().unwrap());
|
||||
}
|
||||
|
||||
let cli = Cli {
|
||||
web_root: None,
|
||||
config_dir: None,
|
||||
config_file: Some(config_file_path.clone()),
|
||||
command: None,
|
||||
};
|
||||
|
||||
let config = AppConfig::from_cli(&cli).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
config.web_root, env_web_dir,
|
||||
"Environment variable should override config file"
|
||||
);
|
||||
assert_eq!(
|
||||
config.configuration_directory,
|
||||
cannonicalize_if_possible(&PathBuf::from("./sqlpage")),
|
||||
"Configuration directory should be default when not overridden"
|
||||
);
|
||||
|
||||
let cli_with_web_root = Cli {
|
||||
web_root: Some(cli_web_dir.clone()),
|
||||
config_dir: None,
|
||||
config_file: Some(config_file_path),
|
||||
command: None,
|
||||
};
|
||||
|
||||
let config = AppConfig::from_cli(&cli_with_web_root).unwrap();
|
||||
assert_eq!(
|
||||
config.web_root, cli_web_dir,
|
||||
"CLI argument should take precedence over environment variable and config file"
|
||||
);
|
||||
assert_eq!(
|
||||
config.configuration_directory,
|
||||
cannonicalize_if_possible(&PathBuf::from("./sqlpage")),
|
||||
"Configuration directory should remain unchanged"
|
||||
);
|
||||
|
||||
unsafe {
|
||||
env::remove_var("SQLPAGE_WEB_ROOT");
|
||||
}
|
||||
std::fs::remove_dir_all(&temp_dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_values() {
|
||||
let _lock = ENV_LOCK
|
||||
.lock()
|
||||
.expect("Another test panicked while holding the lock");
|
||||
unsafe {
|
||||
env::remove_var("SQLPAGE_CONFIGURATION_DIRECTORY");
|
||||
}
|
||||
unsafe {
|
||||
env::remove_var("SQLPAGE_WEB_ROOT");
|
||||
}
|
||||
|
||||
let cli = Cli {
|
||||
web_root: None,
|
||||
config_dir: None,
|
||||
config_file: None,
|
||||
command: None,
|
||||
};
|
||||
|
||||
let config = AppConfig::from_cli(&cli).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
config.web_root,
|
||||
default_web_root(),
|
||||
"Web root should default to current directory when not specified"
|
||||
);
|
||||
assert_eq!(
|
||||
config.configuration_directory,
|
||||
cannonicalize_if_possible(&PathBuf::from("./sqlpage")),
|
||||
"Configuration directory should default to ./sqlpage when not specified"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
use super::commands::SubCommand;
|
||||
use clap::Parser;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[clap(author, version, about, long_about = None)]
|
||||
pub struct Cli {
|
||||
/// The directory where the .sql files are located.
|
||||
#[clap(short, long)]
|
||||
pub web_root: Option<PathBuf>,
|
||||
/// The directory where the sqlpage.json configuration, the templates, and the migrations are located.
|
||||
#[clap(short = 'd', long)]
|
||||
pub config_dir: Option<PathBuf>,
|
||||
/// The path to the configuration file.
|
||||
#[clap(short = 'c', long)]
|
||||
pub config_file: Option<PathBuf>,
|
||||
|
||||
/// Subcommands for additional functionality.
|
||||
#[clap(subcommand)]
|
||||
pub command: Option<SubCommand>,
|
||||
}
|
||||
|
||||
pub fn parse_cli() -> anyhow::Result<Cli> {
|
||||
let cli = Cli::parse();
|
||||
Ok(cli)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cli_argument_parsing() {
|
||||
let cli = Cli::parse_from([
|
||||
"sqlpage",
|
||||
"--web-root",
|
||||
"/path/to/web",
|
||||
"--config-dir",
|
||||
"/path/to/config",
|
||||
"--config-file",
|
||||
"/path/to/config.json",
|
||||
]);
|
||||
|
||||
assert_eq!(cli.web_root, Some(PathBuf::from("/path/to/web")));
|
||||
assert_eq!(cli.config_dir, Some(PathBuf::from("/path/to/config")));
|
||||
assert_eq!(cli.config_file, Some(PathBuf::from("/path/to/config.json")));
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
use chrono::Utc;
|
||||
use clap::Parser;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::app_config::AppConfig;
|
||||
|
||||
/// Sub-commands for the sqlpage CLI.
|
||||
/// Each subcommand can be executed using the `sqlpage <subcommand name>` from the command line.
|
||||
#[derive(Parser)]
|
||||
pub enum SubCommand {
|
||||
/// Create a new migration file.
|
||||
CreateMigration {
|
||||
/// Name of the migration.
|
||||
migration_name: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl SubCommand {
|
||||
/// Execute the subcommand.
|
||||
pub async fn execute(&self, app_config: AppConfig) -> anyhow::Result<()> {
|
||||
match self {
|
||||
SubCommand::CreateMigration { migration_name } => {
|
||||
// Pass configuration_directory from app_config
|
||||
create_migration_file(migration_name, &app_config.configuration_directory).await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn create_migration_file(
|
||||
migration_name: &str,
|
||||
configuration_directory: &Path,
|
||||
) -> anyhow::Result<()> {
|
||||
let timestamp = Utc::now().format("%Y%m%d%H%M%S").to_string();
|
||||
let snake_case_name = migration_name
|
||||
.replace(|c: char| !c.is_alphanumeric(), "_")
|
||||
.to_lowercase();
|
||||
let file_name = format!("{timestamp}_{snake_case_name}.sql");
|
||||
let migrations_dir = Path::new(configuration_directory).join("migrations");
|
||||
|
||||
if !migrations_dir.exists() {
|
||||
tokio::fs::create_dir_all(&migrations_dir).await?;
|
||||
}
|
||||
|
||||
let mut unique_file_name = file_name.clone();
|
||||
let mut counter = 1;
|
||||
|
||||
while migrations_dir.join(&unique_file_name).exists() {
|
||||
unique_file_name = format!("{timestamp}_{snake_case_name}_{counter}.sql");
|
||||
counter += 1;
|
||||
}
|
||||
|
||||
let file_path = migrations_dir.join(unique_file_name);
|
||||
tokio::fs::write(&file_path, "-- Write your migration here\n").await?;
|
||||
|
||||
// the following code cleans up the display path to show where the migration was created
|
||||
// relative to the current working directory, and then outputs the path to the migration
|
||||
let file_path_canon = file_path.canonicalize().unwrap_or(file_path.clone());
|
||||
let cwd_canon = std::env::current_dir()?
|
||||
.canonicalize()
|
||||
.unwrap_or(std::env::current_dir()?);
|
||||
let rel_path = match file_path_canon.strip_prefix(&cwd_canon) {
|
||||
Ok(p) => p,
|
||||
Err(_) => file_path_canon.as_path(),
|
||||
};
|
||||
let mut display_path_str = rel_path.display().to_string();
|
||||
if display_path_str.starts_with("\\\\?\\") {
|
||||
display_path_str = display_path_str.trim_start_matches("\\\\?\\").to_string();
|
||||
}
|
||||
display_path_str = display_path_str.replace('\\', "/");
|
||||
println!("Migration file created: {display_path_str}");
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod arguments;
|
||||
pub mod commands;
|
||||
@@ -0,0 +1,31 @@
|
||||
SELECT
|
||||
'shell' as component,
|
||||
'Page Not Found' as title,
|
||||
'error-404' as body_class,
|
||||
'/' as link;
|
||||
|
||||
SELECT
|
||||
'empty_state' as component,
|
||||
'Page Not Found' as title,
|
||||
'404' as header,
|
||||
'The page you were looking for does not exist.' as description_md,
|
||||
'Go to Homepage' as link_text,
|
||||
'home' as link_icon,
|
||||
'/' as link;
|
||||
|
||||
select
|
||||
'text' as component,
|
||||
'
|
||||
> **Routing Debug Info**
|
||||
> When a URL is requested, SQLPage looks for matching files in this order:
|
||||
> 1. **Exact filename match** (e.g. `page.html` for `/page.html`)
|
||||
> 2. **For paths ending with `/`**:
|
||||
> - Looks for `index.sql` in that directory (e.g. `/dir/` → `dir/index.sql`)
|
||||
> 3. **For paths without extensions**:
|
||||
> - First tries adding `.sql` extension (e.g. `/dir/page` → `dir/page.sql`)
|
||||
> - If not found, redirects to add trailing `/` (e.g. `/dir` → `/dir/`)
|
||||
> 4. **If no matches found**:
|
||||
> - Searches for `404.sql` in current and parent directories (e.g. `dir/x/y/` could use `dir/404.sql`)
|
||||
>
|
||||
> Try creating one of these files to handle this route.
|
||||
' as contents_md;
|
||||
@@ -0,0 +1,218 @@
|
||||
use anyhow::{self, Context as _};
|
||||
use serde_json::Value as JsonValue;
|
||||
|
||||
use crate::webserver::database::DbItem;
|
||||
|
||||
pub fn parse_dynamic_rows(row: DbItem) -> impl Iterator<Item = DbItem> {
|
||||
DynamicComponentIterator {
|
||||
stack: vec![],
|
||||
db_item: Some(row),
|
||||
}
|
||||
}
|
||||
|
||||
struct DynamicComponentIterator {
|
||||
stack: Vec<anyhow::Result<JsonValue>>,
|
||||
db_item: Option<DbItem>,
|
||||
}
|
||||
|
||||
impl Iterator for DynamicComponentIterator {
|
||||
type Item = DbItem;
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
if let Some(db_item) = self.db_item.take() {
|
||||
if let DbItem::Row(mut row) = db_item {
|
||||
match extract_dynamic_properties(&mut row) {
|
||||
Ok(None) => {
|
||||
// Most common case: just a regular row. We allocated nothing.
|
||||
return Some(DbItem::Row(row));
|
||||
}
|
||||
Ok(Some(properties)) => {
|
||||
self.stack = dynamic_properties_to_vec(properties);
|
||||
}
|
||||
Err(err) => {
|
||||
return Some(DbItem::Error(err));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return Some(db_item);
|
||||
}
|
||||
}
|
||||
expand_dynamic_stack(&mut self.stack);
|
||||
self.stack.pop().map(|result| match result {
|
||||
Ok(row) => DbItem::Row(row),
|
||||
Err(err) => DbItem::Error(err),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn expand_dynamic_stack(stack: &mut Vec<anyhow::Result<JsonValue>>) {
|
||||
while let Some(mut next) = stack.pop() {
|
||||
let next_value = next.as_mut().ok();
|
||||
// .and_then(extract_dynamic_properties);
|
||||
let dyn_props = if let Some(val) = next_value {
|
||||
extract_dynamic_properties(val)
|
||||
} else {
|
||||
Ok(None)
|
||||
};
|
||||
match dyn_props {
|
||||
Ok(None) => {
|
||||
// If the properties are not dynamic, push the row back onto the stack
|
||||
stack.push(next);
|
||||
// return at the first non-dynamic row
|
||||
// we don't support non-dynamic rows after dynamic rows nested in the same array
|
||||
return;
|
||||
}
|
||||
Ok(Some(properties)) => {
|
||||
// if the properties contain new (nested) dynamic components, push them onto the stack
|
||||
stack.extend(dynamic_properties_to_vec(properties));
|
||||
}
|
||||
Err(err) => {
|
||||
// if an error occurs, push it onto the stack
|
||||
stack.push(Err(err));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// if row.component == 'dynamic', return Some(row.properties), otherwise return None
|
||||
#[inline]
|
||||
fn extract_dynamic_properties(data: &mut JsonValue) -> anyhow::Result<Option<JsonValue>> {
|
||||
let component = data.get("component").and_then(|v| v.as_str());
|
||||
if component == Some("dynamic") {
|
||||
let Some(properties) = data.get_mut("properties").map(JsonValue::take) else {
|
||||
anyhow::bail!(
|
||||
"The dynamic component requires a property named \"properties\". \
|
||||
Instead, it received the following: {data}"
|
||||
);
|
||||
};
|
||||
Ok(Some(properties))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
/// reverse the order of the vec returned by `dynamic_properties_to_result_vec`,
|
||||
/// and wrap each element in a Result
|
||||
fn dynamic_properties_to_vec(properties_obj: JsonValue) -> Vec<anyhow::Result<JsonValue>> {
|
||||
dynamic_properties_to_result_vec(properties_obj).map_or_else(
|
||||
|err| vec![Err(err)],
|
||||
|vec| vec.into_iter().rev().map(Ok).collect::<Vec<_>>(),
|
||||
)
|
||||
}
|
||||
|
||||
/// if properties is a string, parse it as JSON and return a vec with the parsed value
|
||||
/// if properties is an array, return it as is
|
||||
/// if properties is an object, return it as a single element vec
|
||||
/// otherwise, return an error
|
||||
fn dynamic_properties_to_result_vec(
|
||||
mut properties_obj: JsonValue,
|
||||
) -> anyhow::Result<Vec<JsonValue>> {
|
||||
if let JsonValue::String(s) = properties_obj {
|
||||
properties_obj = serde_json::from_str::<JsonValue>(&s)
|
||||
.with_context(|| format!("Invalid json in dynamic component properties: {s}"))?;
|
||||
}
|
||||
match properties_obj {
|
||||
obj @ JsonValue::Object(_) => Ok(vec![obj]),
|
||||
JsonValue::Array(values) => {
|
||||
let mut vec = Vec::with_capacity(values.len());
|
||||
for value in values {
|
||||
vec.extend_from_slice(&dynamic_properties_to_result_vec(value)?);
|
||||
}
|
||||
Ok(vec)
|
||||
}
|
||||
other => anyhow::bail!(
|
||||
"Dynamic component expected properties of type array or object, got {other} instead."
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_dynamic_properties_to_result_vec() {
|
||||
let mut properties = JsonValue::String(r#"{"a": 1}"#.to_string());
|
||||
assert_eq!(
|
||||
dynamic_properties_to_result_vec(properties.clone()).unwrap(),
|
||||
vec![JsonValue::Object(
|
||||
serde_json::from_str(r#"{"a": 1}"#).unwrap()
|
||||
)]
|
||||
);
|
||||
|
||||
properties = JsonValue::Array(vec![JsonValue::String(r#"{"a": 1}"#.to_string())]);
|
||||
assert_eq!(
|
||||
dynamic_properties_to_result_vec(properties.clone()).unwrap(),
|
||||
vec![serde_json::json!({"a": 1})]
|
||||
);
|
||||
|
||||
properties = JsonValue::Object(serde_json::from_str(r#"{"a": 1}"#).unwrap());
|
||||
assert_eq!(
|
||||
dynamic_properties_to_result_vec(properties.clone()).unwrap(),
|
||||
vec![JsonValue::Object(
|
||||
serde_json::from_str(r#"{"a": 1}"#).unwrap()
|
||||
)]
|
||||
);
|
||||
|
||||
properties = JsonValue::Null;
|
||||
assert!(dynamic_properties_to_result_vec(properties).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dynamic_properties_to_vec() {
|
||||
let properties = JsonValue::String(r#"{"a": 1}"#.to_string());
|
||||
assert_eq!(
|
||||
dynamic_properties_to_vec(properties.clone())
|
||||
.first()
|
||||
.unwrap()
|
||||
.as_ref()
|
||||
.unwrap(),
|
||||
&serde_json::json!({"a": 1})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_dynamic_rows() {
|
||||
let row = DbItem::Row(serde_json::json!({
|
||||
"component": "dynamic",
|
||||
"properties": [
|
||||
{"a": 1},
|
||||
{"component": "dynamic", "properties": {"nested": 2}},
|
||||
]
|
||||
}));
|
||||
let iter = parse_dynamic_rows(row)
|
||||
.map(|item| match item {
|
||||
DbItem::Row(row) => row,
|
||||
x => panic!("Expected a row, got {x:?}"),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
iter,
|
||||
vec![
|
||||
serde_json::json!({"a": 1}),
|
||||
serde_json::json!({"nested": 2}),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_dynamic_array_json_strings() {
|
||||
let row = DbItem::Row(serde_json::json!({
|
||||
"component": "dynamic",
|
||||
"properties": [
|
||||
r#"{"a": 1}"#,
|
||||
r#"{"b": 2}"#,
|
||||
]
|
||||
}));
|
||||
let iter = parse_dynamic_rows(row)
|
||||
.map(|item| match item {
|
||||
DbItem::Row(row) => row,
|
||||
x => panic!("Expected a row, got {x:?}"),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
iter,
|
||||
vec![serde_json::json!({"a": 1}), serde_json::json!({"b": 2}),]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
use crate::AppState;
|
||||
use crate::filesystem::FileAccess;
|
||||
use crate::webserver::ErrorWithStatus;
|
||||
use crate::webserver::routing::FileStore;
|
||||
use actix_web::http::StatusCode;
|
||||
use anyhow::Context;
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, TimeZone, Utc};
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{
|
||||
AtomicU64,
|
||||
Ordering::{Acquire, Release},
|
||||
};
|
||||
use std::time::SystemTime;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
#[derive(Default)]
|
||||
struct Cached<T> {
|
||||
last_checked_at: AtomicU64,
|
||||
content: Arc<T>,
|
||||
}
|
||||
|
||||
impl<T> Cached<T> {
|
||||
fn new(content: T) -> Self {
|
||||
let s = Self {
|
||||
last_checked_at: AtomicU64::new(0),
|
||||
content: Arc::new(content),
|
||||
};
|
||||
s.update_check_time();
|
||||
s
|
||||
}
|
||||
fn last_check_time(&self) -> DateTime<Utc> {
|
||||
let millis = self.last_checked_at.load(Acquire);
|
||||
let as_i64 = i64::try_from(millis).expect("file timestamp out of bound");
|
||||
Utc.timestamp_millis_opt(as_i64)
|
||||
.single()
|
||||
.expect("utc has a single mapping for every timestamp")
|
||||
}
|
||||
fn update_check_time(&self) {
|
||||
self.last_checked_at.store(Self::now_millis(), Release);
|
||||
}
|
||||
fn now_millis() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(SystemTime::UNIX_EPOCH)
|
||||
.expect("invalid duration")
|
||||
.as_millis()
|
||||
.try_into()
|
||||
.expect("invalid date")
|
||||
}
|
||||
fn needs_check(&self, stale_cache_duration_ms: u64) -> bool {
|
||||
self.last_checked_at
|
||||
.load(Acquire)
|
||||
.saturating_add(stale_cache_duration_ms)
|
||||
< Self::now_millis()
|
||||
}
|
||||
/// Creates a new cached entry with the same content but a new check time set to now
|
||||
fn make_fresh(&self) -> Self {
|
||||
Self {
|
||||
last_checked_at: AtomicU64::from(Self::now_millis()),
|
||||
content: Arc::clone(&self.content),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct FileCache<T: AsyncFromStrWithState> {
|
||||
cache: Arc<RwLock<HashMap<PathBuf, Cached<T>>>>,
|
||||
/// Files that are loaded at the beginning of the program,
|
||||
/// and used as fallback when there is no match for the request in the file system
|
||||
static_files: HashMap<PathBuf, Cached<T>>,
|
||||
}
|
||||
|
||||
impl<T: AsyncFromStrWithState> FileStore for FileCache<T> {
|
||||
async fn contains(&self, access: FileAccess<'_>) -> anyhow::Result<bool> {
|
||||
let path = access.path();
|
||||
Ok(self.cache.read().await.contains_key(path) || self.static_files.contains_key(path))
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: AsyncFromStrWithState> Default for FileCache<T> {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: AsyncFromStrWithState> FileCache<T> {
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
cache: Arc::default(),
|
||||
static_files: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds a static file to the cache so that it will never be looked up from the disk
|
||||
pub fn add_static(&mut self, path: PathBuf, contents: T) {
|
||||
log::trace!("Adding static file {} to the cache.", path.display());
|
||||
self.static_files.insert(path, Cached::new(contents));
|
||||
}
|
||||
|
||||
pub fn get_static(&self, path: &Path) -> anyhow::Result<Arc<T>> {
|
||||
self.static_files
|
||||
.get(path)
|
||||
.map(|cached| Arc::clone(&cached.content))
|
||||
.ok_or_else(|| anyhow::anyhow!("File {} not found in static files", path.display()))
|
||||
}
|
||||
|
||||
/// Gets a file from the cache, or loads it from the file system if it's not there.
|
||||
pub async fn get(
|
||||
&self,
|
||||
app_state: &AppState,
|
||||
access: FileAccess<'_>,
|
||||
) -> anyhow::Result<Arc<T>> {
|
||||
let path = access.path();
|
||||
|
||||
log::trace!("Attempting to get from cache {}", path.display());
|
||||
if let Some(cached) = self.cache.read().await.get(path) {
|
||||
if !cached.needs_check(app_state.config.cache_stale_duration_ms()) {
|
||||
log::trace!(
|
||||
"Cache answer without filesystem lookup for {}",
|
||||
path.display()
|
||||
);
|
||||
return Ok(Arc::clone(&cached.content));
|
||||
}
|
||||
match app_state
|
||||
.file_system
|
||||
.modified_since(app_state, access, cached.last_check_time())
|
||||
.await
|
||||
{
|
||||
Ok(false) => {
|
||||
log::trace!(
|
||||
"Cache answer with filesystem metadata read for {}",
|
||||
path.display()
|
||||
);
|
||||
cached.update_check_time();
|
||||
return Ok(Arc::clone(&cached.content));
|
||||
}
|
||||
Ok(true) => log::trace!("{} was changed, updating cache...", path.display()),
|
||||
Err(e) => log::trace!(
|
||||
"Cannot read metadata of {}, re-loading it: {:#}",
|
||||
path.display(),
|
||||
e
|
||||
),
|
||||
}
|
||||
}
|
||||
// Read lock is released
|
||||
log::trace!("Loading and parsing {}", path.display());
|
||||
let file_contents = app_state
|
||||
.file_system
|
||||
.read_to_string(app_state, access)
|
||||
.await;
|
||||
|
||||
let parsed = match file_contents {
|
||||
Ok(contents) => {
|
||||
let value = T::from_str_with_state(app_state, &contents, path).await?;
|
||||
Ok(Cached::new(value))
|
||||
}
|
||||
// If a file is not found, we try to load it from the static files
|
||||
Err(e)
|
||||
if e.downcast_ref()
|
||||
== Some(&ErrorWithStatus {
|
||||
status: StatusCode::NOT_FOUND,
|
||||
}) =>
|
||||
{
|
||||
if let Some(static_file) = self.static_files.get(path) {
|
||||
log::trace!(
|
||||
"File {} not found, loading it from static files instead.",
|
||||
path.display()
|
||||
);
|
||||
let cached: Cached<T> = static_file.make_fresh();
|
||||
Ok(cached)
|
||||
} else {
|
||||
Err(e)
|
||||
.with_context(|| format!("Couldn't load \"{}\" into cache", path.display()))
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
Err(e).with_context(|| format!("Couldn't load {} into cache", path.display()))
|
||||
}
|
||||
};
|
||||
|
||||
match parsed {
|
||||
Ok(value) => {
|
||||
let new_val = Arc::clone(&value.content);
|
||||
log::trace!("Writing to cache {}", path.display());
|
||||
self.cache.write().await.insert(PathBuf::from(path), value);
|
||||
log::trace!("Done writing to cache {}", path.display());
|
||||
log::trace!("{} loaded in cache", path.display());
|
||||
Ok(new_val)
|
||||
}
|
||||
Err(e) => {
|
||||
log::trace!(
|
||||
"Evicting {} from the cache because the following error occurred: {}",
|
||||
path.display(),
|
||||
e
|
||||
);
|
||||
log::trace!("Removing from cache {}", path.display());
|
||||
self.cache.write().await.remove(path);
|
||||
log::trace!("Done removing from cache {}", path.display());
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait(? Send)]
|
||||
pub trait AsyncFromStrWithState: Sized {
|
||||
/// Parses the string into an object.
|
||||
async fn from_str_with_state(
|
||||
app_state: &AppState,
|
||||
source: &str,
|
||||
source_path: &Path,
|
||||
) -> anyhow::Result<Self>;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_cache_duration() {
|
||||
let cached = Cached::new(());
|
||||
assert!(
|
||||
!cached.needs_check(1000),
|
||||
"Should not need check immediately after creation"
|
||||
);
|
||||
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||
assert!(
|
||||
!cached.needs_check(1000),
|
||||
"Should not need check before duration expires"
|
||||
);
|
||||
assert!(
|
||||
cached.needs_check(1),
|
||||
"Should need check after duration expires"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,499 @@
|
||||
use crate::webserver::ErrorWithStatus;
|
||||
use crate::webserver::database::SupportedDatabase;
|
||||
use crate::webserver::{Database, StatusCodeResultExt, make_placeholder};
|
||||
use crate::{AppState, TEMPLATES_DIR};
|
||||
use anyhow::Context;
|
||||
use chrono::{DateTime, Utc};
|
||||
use sqlx::any::{AnyStatement, AnyTypeInfo};
|
||||
use sqlx::postgres::types::PgTimeTz;
|
||||
use sqlx::{Executor, Postgres, Statement, Type};
|
||||
use std::fmt::Write;
|
||||
use std::io::ErrorKind;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
#[must_use]
|
||||
pub struct FileAccess<'a> {
|
||||
path: &'a Path,
|
||||
privileged: bool,
|
||||
}
|
||||
|
||||
impl<'a> FileAccess<'a> {
|
||||
/// Creates access for an untrusted, HTTP-facing path after validating it.
|
||||
pub fn unprivileged(path: &'a Path) -> anyhow::Result<Self> {
|
||||
validate_unprivileged_path(path)?;
|
||||
Ok(Self {
|
||||
path,
|
||||
privileged: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// Creates access for a trusted internal path.
|
||||
pub const fn privileged(path: &'a Path) -> Self {
|
||||
Self {
|
||||
path,
|
||||
privileged: true,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn path(&self) -> &'a Path {
|
||||
self.path
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct FileSystem {
|
||||
local_root: PathBuf,
|
||||
db_fs_queries: Option<DbFsQueries>,
|
||||
}
|
||||
|
||||
impl FileSystem {
|
||||
pub async fn init(local_root: impl Into<PathBuf>, db: &Database) -> Self {
|
||||
Self {
|
||||
local_root: local_root.into(),
|
||||
db_fs_queries: match DbFsQueries::init(db).await {
|
||||
Ok(q) => Some(q),
|
||||
Err(e) => {
|
||||
log::debug!(
|
||||
"Using local filesystem only, could not initialize on-database filesystem. \
|
||||
You can host sql files directly in your database by creating the following table: \n\
|
||||
{} \n\
|
||||
The error while trying to use the database file system is: {e:#}",
|
||||
DbFsQueries::get_create_table_sql(db.info.database_type)
|
||||
);
|
||||
None
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn modified_since(
|
||||
&self,
|
||||
app_state: &AppState,
|
||||
access: FileAccess<'_>,
|
||||
since: DateTime<Utc>,
|
||||
) -> anyhow::Result<bool> {
|
||||
let path = access.path();
|
||||
let local_path = self.safe_local_path(app_state, access);
|
||||
let local_result = file_modified_since_local(&local_path, since).await;
|
||||
log::trace!(
|
||||
"Local file {} modified since {since:?} ? {local_result:?}",
|
||||
local_path.display()
|
||||
);
|
||||
match (local_result, &self.db_fs_queries) {
|
||||
(Ok(modified), _) => Ok(modified),
|
||||
(Err(e), Some(db_fs)) if is_path_missing_error(&e) => {
|
||||
// no local file, try the database
|
||||
db_fs
|
||||
.file_modified_since_in_db(app_state, path, since)
|
||||
.await
|
||||
}
|
||||
(Err(e), _) => {
|
||||
let status = io_error_status(&e)
|
||||
.unwrap_or(actix_web::http::StatusCode::INTERNAL_SERVER_ERROR);
|
||||
Err(e).with_status(status).with_context(|| {
|
||||
format!("Unable to read local file metadata for {}", path.display())
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn read_to_string(
|
||||
&self,
|
||||
app_state: &AppState,
|
||||
access: FileAccess<'_>,
|
||||
) -> anyhow::Result<String> {
|
||||
let path = access.path();
|
||||
let bytes = self.read_file(app_state, access).await?;
|
||||
String::from_utf8(bytes).map_err(|utf8_err| {
|
||||
let invalid_idx = utf8_err.utf8_error().valid_up_to();
|
||||
let bytes = utf8_err.into_bytes();
|
||||
let valid_prefix = String::from_utf8_lossy(&bytes[..invalid_idx]);
|
||||
let line_num = valid_prefix.lines().count();
|
||||
let mut bad_seq = valid_prefix.lines().last().unwrap_or_default().to_string();
|
||||
let bad_char_idx = bad_seq.len() + 1;
|
||||
for b in bytes[invalid_idx..].iter().take(8) {
|
||||
write!(&mut bad_seq, "\\x{b:02X}").unwrap();
|
||||
}
|
||||
|
||||
let display_path = path.display();
|
||||
anyhow::format_err!(
|
||||
"SQLPage expects all sql files to be encoded in UTF-8. \n\
|
||||
In \"{display_path}\", around line {line_num} character {bad_char_idx}, the following invalid UTF-8 byte sequence was found: \n\
|
||||
\"{bad_seq}\". \n\
|
||||
Please convert the file to UTF-8.",
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn read_file(
|
||||
&self,
|
||||
app_state: &AppState,
|
||||
access: FileAccess<'_>,
|
||||
) -> anyhow::Result<Vec<u8>> {
|
||||
let path = access.path();
|
||||
let local_path = self.safe_local_path(app_state, access);
|
||||
log::debug!(
|
||||
"Reading file {} from {}",
|
||||
path.display(),
|
||||
local_path.display()
|
||||
);
|
||||
let local_result = tokio::fs::read(&local_path).await;
|
||||
match (local_result, &self.db_fs_queries) {
|
||||
(Ok(f), _) => Ok(f),
|
||||
(Err(e), Some(db_fs)) if is_path_missing_error(&e) => {
|
||||
// no local file, try the database
|
||||
db_fs.read_file(app_state, path.as_ref()).await
|
||||
}
|
||||
(Err(e), None) if is_path_missing_error(&e) => Err(e)
|
||||
.with_status(actix_web::http::StatusCode::NOT_FOUND)
|
||||
.with_context(|| format!("Unable to read local file {}", path.display())),
|
||||
(Err(e), _) => {
|
||||
let status = io_error_status(&e)
|
||||
.unwrap_or(actix_web::http::StatusCode::INTERNAL_SERVER_ERROR);
|
||||
Err(e)
|
||||
.with_status(status)
|
||||
.with_context(|| format!("Unable to read local file {}", path.display()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn safe_local_path(&self, app_state: &AppState, access: FileAccess<'_>) -> PathBuf {
|
||||
let path = access.path();
|
||||
if access.privileged {
|
||||
// Templates requests are always made to the static TEMPLATES_DIR, because this is where they are stored in the database
|
||||
// but when serving them from the filesystem, we need to serve them from the `SQLPAGE_CONFIGURATION_DIRECTORY/templates` directory
|
||||
if let Ok(template_path) = path.strip_prefix(TEMPLATES_DIR) {
|
||||
let normalized = app_state
|
||||
.config
|
||||
.configuration_directory
|
||||
.join("templates")
|
||||
.join(template_path);
|
||||
log::trace!(
|
||||
"Normalizing template path {} to {}",
|
||||
path.display(),
|
||||
normalized.display()
|
||||
);
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
self.local_root.join(path)
|
||||
}
|
||||
|
||||
pub(crate) async fn file_exists(
|
||||
&self,
|
||||
app_state: &AppState,
|
||||
access: FileAccess<'_>,
|
||||
) -> anyhow::Result<bool> {
|
||||
let path = access.path();
|
||||
let safe_path = self.safe_local_path(app_state, access);
|
||||
let local_exists = match tokio::fs::try_exists(safe_path).await {
|
||||
Ok(exists) => exists,
|
||||
Err(e) if is_path_missing_error(&e) => false,
|
||||
Err(e) => {
|
||||
let status = io_error_status(&e)
|
||||
.unwrap_or(actix_web::http::StatusCode::INTERNAL_SERVER_ERROR);
|
||||
return Err(e).with_status(status).with_context(|| {
|
||||
format!("Unable to check if {} exists locally", path.display())
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// If not in local fs and we have db_fs, check database
|
||||
if !local_exists {
|
||||
log::debug!(
|
||||
"File {} not found in local filesystem, checking database",
|
||||
path.display()
|
||||
);
|
||||
if let Some(db_fs) = &self.db_fs_queries {
|
||||
return db_fs.file_exists(app_state, path).await;
|
||||
}
|
||||
}
|
||||
Ok(local_exists)
|
||||
}
|
||||
}
|
||||
|
||||
/// Rejects paths that an untrusted HTTP request must never reach: the reserved
|
||||
/// `sqlpage/` prefix, dotfiles, parent-directory traversal and absolute/root paths.
|
||||
fn validate_unprivileged_path(path: &Path) -> anyhow::Result<()> {
|
||||
for (i, component) in path.components().enumerate() {
|
||||
if let Component::Normal(c) = component {
|
||||
if i == 0 && c.eq_ignore_ascii_case("sqlpage") {
|
||||
return Err(ErrorWithStatus {
|
||||
status: actix_web::http::StatusCode::FORBIDDEN,
|
||||
})
|
||||
.with_context(
|
||||
|| "The /sqlpage/ path prefix is reserved for internal use. It is not public.",
|
||||
);
|
||||
}
|
||||
if c.as_encoded_bytes().starts_with(b".") {
|
||||
return Err(ErrorWithStatus {
|
||||
status: actix_web::http::StatusCode::FORBIDDEN,
|
||||
})
|
||||
.with_context(|| "Directory traversal is not allowed");
|
||||
}
|
||||
} else {
|
||||
return Err(ErrorWithStatus {
|
||||
status: actix_web::http::StatusCode::FORBIDDEN,
|
||||
})
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"Unsupported path: {}. Path component '{component:?}' is not allowed.",
|
||||
path.display()
|
||||
)
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_path_missing_error(error: &std::io::Error) -> bool {
|
||||
matches!(error.kind(), ErrorKind::NotFound | ErrorKind::NotADirectory)
|
||||
}
|
||||
|
||||
fn io_error_status(error: &std::io::Error) -> Option<actix_web::http::StatusCode> {
|
||||
match error.kind() {
|
||||
ErrorKind::NotFound | ErrorKind::NotADirectory => {
|
||||
Some(actix_web::http::StatusCode::NOT_FOUND)
|
||||
}
|
||||
ErrorKind::PermissionDenied => Some(actix_web::http::StatusCode::FORBIDDEN),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
async fn file_modified_since_local(path: &Path, since: DateTime<Utc>) -> tokio::io::Result<bool> {
|
||||
tokio::fs::metadata(path)
|
||||
.await
|
||||
.and_then(|m| m.modified())
|
||||
.map(|modified_at| DateTime::<Utc>::from(modified_at) > since)
|
||||
}
|
||||
|
||||
pub struct DbFsQueries {
|
||||
was_modified: AnyStatement<'static>,
|
||||
read_file: AnyStatement<'static>,
|
||||
exists: AnyStatement<'static>,
|
||||
}
|
||||
|
||||
impl DbFsQueries {
|
||||
#[must_use]
|
||||
pub fn get_create_table_sql(dbms: SupportedDatabase) -> &'static str {
|
||||
match dbms {
|
||||
SupportedDatabase::Mssql => {
|
||||
"CREATE TABLE sqlpage_files(path NVARCHAR(255) NOT NULL PRIMARY KEY, contents VARBINARY(MAX), last_modified DATETIME2(3) NOT NULL DEFAULT CURRENT_TIMESTAMP);"
|
||||
}
|
||||
SupportedDatabase::Postgres => {
|
||||
"CREATE TABLE IF NOT EXISTS sqlpage_files(path VARCHAR(255) NOT NULL PRIMARY KEY, contents BYTEA, last_modified TIMESTAMP DEFAULT CURRENT_TIMESTAMP);"
|
||||
}
|
||||
SupportedDatabase::Snowflake => {
|
||||
"CREATE TABLE IF NOT EXISTS sqlpage_files(path VARCHAR(255) NOT NULL PRIMARY KEY, contents VARBINARY, last_modified TIMESTAMP_TZ DEFAULT CONVERT_TIMEZONE('UTC', CURRENT_TIMESTAMP()));"
|
||||
}
|
||||
_ => {
|
||||
"CREATE TABLE IF NOT EXISTS sqlpage_files(path VARCHAR(255) NOT NULL PRIMARY KEY, contents BLOB, last_modified TIMESTAMP DEFAULT CURRENT_TIMESTAMP);"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn init(db: &Database) -> anyhow::Result<Self> {
|
||||
log::debug!("Initializing database filesystem queries");
|
||||
Self::check_table_available(db).await?;
|
||||
Ok(Self {
|
||||
was_modified: Self::make_was_modified_query(db).await?,
|
||||
read_file: Self::make_read_file_query(db).await?,
|
||||
exists: Self::make_exists_query(db).await?,
|
||||
})
|
||||
}
|
||||
|
||||
async fn check_table_available(db: &Database) -> anyhow::Result<()> {
|
||||
db.connection
|
||||
.execute("SELECT 1 FROM sqlpage_files WHERE 1 = 0")
|
||||
.await
|
||||
.map(|_| ())
|
||||
.context("Unable to access sqlpage_files")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn make_was_modified_query(db: &Database) -> anyhow::Result<AnyStatement<'static>> {
|
||||
let was_modified_query = format!(
|
||||
"SELECT 1 from sqlpage_files WHERE last_modified >= {} AND path = {}",
|
||||
make_placeholder(db.info.kind, 1),
|
||||
make_placeholder(db.info.kind, 2)
|
||||
);
|
||||
let param_types: &[AnyTypeInfo; 2] = &[
|
||||
PgTimeTz::type_info().into(),
|
||||
<str as Type<Postgres>>::type_info().into(),
|
||||
];
|
||||
log::debug!("Preparing the database filesystem was_modified_query: {was_modified_query}");
|
||||
db.prepare_with(&was_modified_query, param_types).await
|
||||
}
|
||||
|
||||
async fn make_read_file_query(db: &Database) -> anyhow::Result<AnyStatement<'static>> {
|
||||
let read_file_query = format!(
|
||||
"SELECT contents from sqlpage_files WHERE path = {}",
|
||||
make_placeholder(db.info.kind, 1),
|
||||
);
|
||||
let param_types: &[AnyTypeInfo; 1] = &[<str as Type<Postgres>>::type_info().into()];
|
||||
log::debug!("Preparing the database filesystem read_file_query: {read_file_query}");
|
||||
db.prepare_with(&read_file_query, param_types).await
|
||||
}
|
||||
|
||||
async fn make_exists_query(db: &Database) -> anyhow::Result<AnyStatement<'static>> {
|
||||
let exists_query = format!(
|
||||
"SELECT 1 from sqlpage_files WHERE path = {}",
|
||||
make_placeholder(db.info.kind, 1),
|
||||
);
|
||||
let param_types: &[AnyTypeInfo; 1] = &[<str as Type<Postgres>>::type_info().into()];
|
||||
db.prepare_with(&exists_query, param_types).await
|
||||
}
|
||||
|
||||
async fn file_modified_since_in_db(
|
||||
&self,
|
||||
app_state: &AppState,
|
||||
path: &Path,
|
||||
since: DateTime<Utc>,
|
||||
) -> anyhow::Result<bool> {
|
||||
let query = self
|
||||
.was_modified
|
||||
.query_as::<(i32,)>()
|
||||
.bind(since)
|
||||
.bind(path.display().to_string());
|
||||
log::trace!(
|
||||
"Checking if file {} was modified since {} by executing query: \n\
|
||||
{}\n\
|
||||
with parameters: {:?}",
|
||||
path.display(),
|
||||
since,
|
||||
self.was_modified.sql(),
|
||||
(since, path)
|
||||
);
|
||||
let was_modified_i32 = query
|
||||
.fetch_optional(&app_state.db.connection)
|
||||
.await
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"Unable to check when {} was last modified in the database",
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
log::trace!(
|
||||
"DB File {} was modified result: {was_modified_i32:?}",
|
||||
path.display()
|
||||
);
|
||||
Ok(was_modified_i32 == Some((1,)))
|
||||
}
|
||||
|
||||
async fn read_file(&self, app_state: &AppState, path: &Path) -> anyhow::Result<Vec<u8>> {
|
||||
log::debug!("Reading file {} from the database", path.display());
|
||||
self.read_file
|
||||
.query_as::<(Vec<u8>,)>()
|
||||
.bind(path.display().to_string())
|
||||
.fetch_optional(&app_state.db.connection)
|
||||
.await
|
||||
.map_err(anyhow::Error::from)
|
||||
.and_then(|modified| {
|
||||
if let Some((modified,)) = modified {
|
||||
Ok(modified)
|
||||
} else {
|
||||
Err(ErrorWithStatus {
|
||||
status: actix_web::http::StatusCode::NOT_FOUND,
|
||||
}
|
||||
.into())
|
||||
}
|
||||
})
|
||||
.with_context(|| format!("Unable to read {} from the database", path.display()))
|
||||
}
|
||||
|
||||
async fn file_exists(&self, app_state: &AppState, path: &Path) -> anyhow::Result<bool> {
|
||||
let query = self
|
||||
.exists
|
||||
.query_as::<(i32,)>()
|
||||
.bind(path.display().to_string());
|
||||
log::trace!(
|
||||
"Checking if file {} exists by executing query: \n\
|
||||
{}\n\
|
||||
with parameters: {:?}",
|
||||
path.display(),
|
||||
self.exists.sql(),
|
||||
(path,)
|
||||
);
|
||||
let result = query.fetch_optional(&app_state.db.connection).await;
|
||||
log::debug!("DB File exists result: {result:?}");
|
||||
result.map(|result| result.is_some()).with_context(|| {
|
||||
format!(
|
||||
"Unable to check if {} exists in the database",
|
||||
path.display()
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[actix_web::test]
|
||||
async fn test_sql_file_read_utf8() -> anyhow::Result<()> {
|
||||
use crate::app_config;
|
||||
use sqlx::Executor;
|
||||
let config = app_config::tests::test_config();
|
||||
let state = AppState::init(&config).await?;
|
||||
|
||||
// Oracle has specific issues with implicit timestamp conversions and empty strings in this test setup
|
||||
// so we skip it for Oracle to avoid complex workarounds in the main codebase.
|
||||
if config.database_url.contains("Oracle") {
|
||||
log::warn!(
|
||||
"Skipping test_sql_file_read_utf8 for Oracle due to date format/implicit conversion issues"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let create_table_sql = DbFsQueries::get_create_table_sql(state.db.info.database_type);
|
||||
let db = &state.db;
|
||||
let conn = &db.connection;
|
||||
conn.execute("DROP TABLE IF EXISTS sqlpage_files").await?;
|
||||
log::debug!("Creating table sqlpage_files: {create_table_sql}");
|
||||
conn.execute(create_table_sql).await?;
|
||||
|
||||
let dbms = db.info.kind;
|
||||
let insert_sql = format!(
|
||||
"INSERT INTO sqlpage_files(path, contents) VALUES ({}, {})",
|
||||
make_placeholder(dbms, 1),
|
||||
make_placeholder(dbms, 2)
|
||||
);
|
||||
sqlx::query(&insert_sql)
|
||||
.bind("unit test file.txt")
|
||||
.bind("Héllö world! 😀".as_bytes())
|
||||
.execute(conn)
|
||||
.await?;
|
||||
|
||||
let fs = FileSystem::init("/", db).await;
|
||||
let actual = fs
|
||||
.read_to_string(
|
||||
&state,
|
||||
FileAccess::unprivileged("unit test file.txt".as_ref())?,
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(actual, "Héllö world! 😀");
|
||||
|
||||
let one_hour_ago = Utc::now() - chrono::Duration::hours(1);
|
||||
let one_hour_future = Utc::now() + chrono::Duration::hours(1);
|
||||
|
||||
let was_modified = fs
|
||||
.modified_since(
|
||||
&state,
|
||||
FileAccess::unprivileged("unit test file.txt".as_ref())?,
|
||||
one_hour_ago,
|
||||
)
|
||||
.await?;
|
||||
|
||||
assert!(was_modified, "File should be modified since one hour ago");
|
||||
|
||||
let was_modified = fs
|
||||
.modified_since(
|
||||
&state,
|
||||
FileAccess::unprivileged("unit test file.txt".as_ref())?,
|
||||
one_hour_future,
|
||||
)
|
||||
.await?;
|
||||
assert!(
|
||||
!was_modified,
|
||||
"File should not be modified since one hour in the future"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
-- Welcome to SQLPage ! This is a short demonstration of a few things you can do with SQLPage
|
||||
-- Using the 'shell' component at the top allows you to customize your web page, giving it a title and a description
|
||||
select 'shell' as component,
|
||||
'SQLpage' as title,
|
||||
'/' as link,
|
||||
'en' as lang,
|
||||
'Welcome to SQLPage' as description;
|
||||
-- Making a web page with SQLPage works by using a set of predefined "components"
|
||||
-- and filling them with contents from the results of your SQL queries
|
||||
select 'hero' as component, -- We select a component. The documentation for each component can be found on https://sql-page.com/documentation.sql
|
||||
'It works !' as title, -- 'title' is top-level parameter of the 'hero' component
|
||||
'If you can see this, then SQLPage v' ||
|
||||
sqlpage.version() ||
|
||||
' is running correctly on your server. Congratulations! ' as description;
|
||||
-- Properties can be textual, numeric, or booleans
|
||||
|
||||
-- Let's start with the text component
|
||||
SELECT 'text' as component, -- We can switch to another component at any time just with a select statement.
|
||||
'Get started' as title;
|
||||
-- We are now inside the text component. Each row that will be returned by our SELECT queries will be a span of text
|
||||
-- The text component has a property called "contents" that can be that we use to set the contents of our block of text
|
||||
-- and a property called "center" that we use to center the text
|
||||
SELECT 'In order to get started, visit ' as contents;
|
||||
select 'SQLPage''s website' as contents,
|
||||
'https://sql-page.com/your-first-sql-website/' as link,
|
||||
1 as italics;
|
||||
SELECT '. You can replace this page''s contents by creating a file named ' as contents;
|
||||
SELECT 'index.sql' as contents, 1 as code;
|
||||
SELECT ' in the web root directory: ' as contents;
|
||||
SELECT sqlpage.web_root() as contents, 1 as code;
|
||||
SELECT '.' as contents;
|
||||
SELECT 'You can customize your server''s [configuration](https://github.com/sqlpage/SQLPage/blob/main/configuration.md)
|
||||
by creating a file named `sqlpage.json` in the configuration directory: `' || sqlpage.configuration_directory() || '`.' as contents_md;
|
||||
|
||||
SELECT '
|
||||
Alternatively, you can create a table called `sqlpage_files` in your database with the following columns: `path`, `contents`, and `last_modified`.' as contents_md;
|
||||
+158
@@ -0,0 +1,158 @@
|
||||
#![deny(clippy::pedantic)]
|
||||
#![allow(clippy::missing_errors_doc, clippy::missing_panics_doc)]
|
||||
|
||||
//! [SQLPage](https://sql-page.com) is a high-performance web server that converts SQL queries
|
||||
//! into dynamic web applications by rendering [handlebars templates](https://sql-page.com/custom_components.sql)
|
||||
//! with data coming from SQL queries declared in `.sql` files.
|
||||
//!
|
||||
//! # Overview
|
||||
//!
|
||||
//! `SQLPage` is a web server that lets you build data-centric applications using only SQL queries.
|
||||
//! It automatically converts database queries into professional-looking web pages using pre-built components
|
||||
//! for common UI patterns like [tables](https://sql-page.com/component.sql?component=table),
|
||||
//! [charts](https://sql-page.com/component.sql?component=chart),
|
||||
//! [forms](https://sql-page.com/component.sql?component=form), and more.
|
||||
//!
|
||||
//! # Key Features
|
||||
//!
|
||||
//! - **SQL-Only Development**: Build full web applications without HTML, CSS, or JavaScript
|
||||
//! - **Built-in Components**: Rich library of [pre-made UI components](https://sql-page.com/documentation.sql)
|
||||
//! - **Security**: Protection against [SQL injection, XSS and other vulnerabilities](https://sql-page.com/safety.sql)
|
||||
//! - **Performance**: [Optimized request handling and rendering](https://sql-page.com/performance.sql)
|
||||
//! - **Database Support**: Works with `SQLite`, `PostgreSQL`, `MySQL`, and MS SQL Server
|
||||
//!
|
||||
//! # Architecture
|
||||
//!
|
||||
//! The crate is organized into several key modules:
|
||||
//!
|
||||
//! - [`webserver`]: Core HTTP server implementation using actix-web
|
||||
//! - [`render`]: Component rendering system, streaming rendering of the handlebars templates with data
|
||||
//! - [`templates`]: Pre-defined UI component definitions
|
||||
//! - [`file_cache`]: Caching layer for SQL file parsing
|
||||
//! - [`filesystem`]: Abstract interface for disk and DB-stored files
|
||||
//! - [`app_config`]: Configuration and environment handling
|
||||
//!
|
||||
//! # Query Processing Pipeline
|
||||
//!
|
||||
//! When processing a request, `SQLPage`:
|
||||
//!
|
||||
//! 1. Parses the SQL using sqlparser-rs. Once a SQL file is parsed, it is cached for later reuse.
|
||||
//! 2. Executes queries through sqlx.
|
||||
//! 3. Finds the requested component's handlebars template in the database or in the filesystem.
|
||||
//! 4. Maps results to the component template, using handlebars-rs.
|
||||
//! 5. Streams rendered HTML to the client.
|
||||
//!
|
||||
//! # Extended Functionality
|
||||
//!
|
||||
//! - [Custom SQL Functions](https://sql-page.com/functions.sql)
|
||||
//! - [Custom Components](https://sql-page.com/custom_components.sql)
|
||||
//! - [Authentication & Sessions](https://sql-page.com/examples/authentication)
|
||||
//! - [File Uploads](https://sql-page.com/examples/handle_picture_upload.sql)
|
||||
//!
|
||||
//! # Example
|
||||
//!
|
||||
//! ```sql
|
||||
//! -- Open a data list component
|
||||
//! SELECT 'list' as component, 'Users' as title;
|
||||
//!
|
||||
//! -- Populate it with data
|
||||
//! SELECT
|
||||
//! name as title,
|
||||
//! email as description
|
||||
//! FROM users
|
||||
//! ORDER BY created_at DESC;
|
||||
//! ```
|
||||
//!
|
||||
//! For more examples and documentation, visit:
|
||||
//! - [Getting Started Guide](https://sql-page.com/get%20started.sql)
|
||||
//! - [Component Reference](https://sql-page.com/components.sql)
|
||||
//! - [Example Gallery](https://sql-page.com/examples/tabs)
|
||||
|
||||
extern crate core;
|
||||
|
||||
pub mod app_config;
|
||||
pub mod cli;
|
||||
pub mod dynamic_component;
|
||||
pub mod file_cache;
|
||||
pub mod filesystem;
|
||||
pub mod render;
|
||||
pub mod telemetry;
|
||||
pub mod telemetry_metrics;
|
||||
pub mod template_helpers;
|
||||
pub mod templates;
|
||||
pub mod utils;
|
||||
pub mod webserver;
|
||||
|
||||
use crate::app_config::AppConfig;
|
||||
use crate::filesystem::FileSystem;
|
||||
use crate::webserver::database::ParsedSqlFile;
|
||||
use crate::webserver::oidc::OidcState;
|
||||
use file_cache::FileCache;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use telemetry_metrics::TelemetryMetrics;
|
||||
use templates::AllTemplates;
|
||||
use webserver::Database;
|
||||
|
||||
/// `TEMPLATES_DIR` is the directory where .handlebars files are stored
|
||||
/// When a template is requested, it is looked up in `sqlpage/templates/component_name.handlebars` in the database,
|
||||
/// or in `$SQLPAGE_CONFIGURATION_DIRECTORY/templates/component_name.handlebars` in the filesystem.
|
||||
pub const TEMPLATES_DIR: &str = "sqlpage/templates/";
|
||||
pub const MIGRATIONS_DIR: &str = "migrations";
|
||||
pub const ON_CONNECT_FILE: &str = "on_connect.sql";
|
||||
pub const ON_RESET_FILE: &str = "on_reset.sql";
|
||||
pub const DEFAULT_404_FILE: &str = "default_404.sql";
|
||||
|
||||
pub struct AppState {
|
||||
pub db: Database,
|
||||
all_templates: AllTemplates,
|
||||
sql_file_cache: FileCache<ParsedSqlFile>,
|
||||
file_system: FileSystem,
|
||||
config: AppConfig,
|
||||
pub oidc_state: Option<Arc<OidcState>>,
|
||||
pub telemetry_metrics: TelemetryMetrics,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub async fn init(config: &AppConfig) -> anyhow::Result<Self> {
|
||||
let db = Database::init(config).await?;
|
||||
Self::init_with_db(config, db).await
|
||||
}
|
||||
pub async fn init_with_db(config: &AppConfig, db: Database) -> anyhow::Result<Self> {
|
||||
let all_templates = AllTemplates::init(config)?;
|
||||
let mut sql_file_cache = FileCache::new();
|
||||
let file_system = FileSystem::init(&config.web_root, &db).await;
|
||||
sql_file_cache.add_static(
|
||||
PathBuf::from("index.sql"),
|
||||
ParsedSqlFile::new(&db, include_str!("index.sql"), Path::new("index.sql")),
|
||||
);
|
||||
sql_file_cache.add_static(
|
||||
PathBuf::from(DEFAULT_404_FILE),
|
||||
ParsedSqlFile::new(
|
||||
&db,
|
||||
include_str!("default_404.sql"),
|
||||
Path::new(DEFAULT_404_FILE),
|
||||
),
|
||||
);
|
||||
|
||||
let oidc_state = crate::webserver::oidc::initialize_oidc_state(config).await?;
|
||||
let telemetry_metrics =
|
||||
TelemetryMetrics::new(&db.connection, db.info.database_type.otel_name());
|
||||
|
||||
Ok(AppState {
|
||||
db,
|
||||
all_templates,
|
||||
sql_file_cache,
|
||||
file_system,
|
||||
config: config.clone(),
|
||||
oidc_state,
|
||||
telemetry_metrics,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for AppState {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("AppState").finish()
|
||||
}
|
||||
}
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
use sqlpage::{
|
||||
AppState,
|
||||
app_config::AppConfig,
|
||||
cli, telemetry,
|
||||
webserver::{self, Database},
|
||||
};
|
||||
|
||||
#[actix_web::main]
|
||||
async fn main() {
|
||||
let cli = match cli::arguments::parse_cli() {
|
||||
Ok(cli) => cli,
|
||||
Err(e) => {
|
||||
eprintln!("{e:#}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
let is_server_mode = cli.command.is_none();
|
||||
|
||||
if is_server_mode {
|
||||
if let Err(e) = init_logging() {
|
||||
eprintln!("Failed to initialize logging/telemetry: {e:#}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
} else {
|
||||
let _ = dotenvy::dotenv();
|
||||
}
|
||||
|
||||
if let Err(e) = start(cli).await {
|
||||
if is_server_mode {
|
||||
log::error!("{e:?}");
|
||||
} else {
|
||||
eprintln!("{e:#}");
|
||||
}
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async fn start(cli: cli::arguments::Cli) -> anyhow::Result<()> {
|
||||
let app_config = AppConfig::from_cli(&cli)?;
|
||||
|
||||
if let Some(command) = cli.command {
|
||||
return command.execute(app_config).await;
|
||||
}
|
||||
|
||||
let db = Database::init(&app_config).await?;
|
||||
webserver::database::migrations::apply(&app_config, &db).await?;
|
||||
let state = AppState::init_with_db(&app_config, db).await?;
|
||||
|
||||
log::debug!("Starting server...");
|
||||
webserver::http::run_server(&app_config, state).await?;
|
||||
log::info!("Server stopped gracefully. Goodbye!");
|
||||
telemetry::shutdown_telemetry();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn init_logging() -> anyhow::Result<()> {
|
||||
let load_env = dotenvy::dotenv();
|
||||
|
||||
let otel_active = telemetry::init_telemetry()?;
|
||||
|
||||
match load_env {
|
||||
Ok(path) => log::info!("Loaded environment variables from {path:?}"),
|
||||
Err(dotenvy::Error::Io(e)) if e.kind() == std::io::ErrorKind::NotFound => log::debug!(
|
||||
"No .env file found, using only environment variables and configuration files"
|
||||
),
|
||||
Err(e) => log::error!("Error loading .env file: {e}"),
|
||||
}
|
||||
|
||||
if otel_active {
|
||||
log::info!("OpenTelemetry tracing enabled (OTEL_EXPORTER_OTLP_ENDPOINT is set)");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
+1272
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,874 @@
|
||||
//! OpenTelemetry initialization and shutdown.
|
||||
//!
|
||||
//! When `OTEL_EXPORTER_OTLP_ENDPOINT` is set, sets up a full tracing pipeline
|
||||
//! with OTLP export. Otherwise, sets up tracing with logfmt output only.
|
||||
//!
|
||||
//! In both cases, the same logfmt log format is used, with carefully chosen
|
||||
//! fields for human readability and machine parseability.
|
||||
|
||||
use std::env;
|
||||
use std::sync::{Once, OnceLock};
|
||||
|
||||
use anyhow::Context as _;
|
||||
use opentelemetry_sdk::metrics::SdkMeterProvider;
|
||||
use opentelemetry_sdk::trace::SdkTracerProvider;
|
||||
|
||||
static TRACER_PROVIDER: OnceLock<SdkTracerProvider> = OnceLock::new();
|
||||
static METER_PROVIDER: OnceLock<SdkMeterProvider> = OnceLock::new();
|
||||
static TEST_LOGGING_INIT: Once = Once::new();
|
||||
static OTLP_HTTP_WORKER_SENDER: OnceLock<
|
||||
Result<tokio::sync::mpsc::UnboundedSender<OtlpHttpJob>, String>,
|
||||
> = OnceLock::new();
|
||||
const DEFAULT_ENV_FILTER_DIRECTIVES: &str = "sqlpage=info,actix_web=info,tracing_actix_web=info,opentelemetry=warn,opentelemetry_sdk=warn,opentelemetry_otlp=warn";
|
||||
pub(crate) const ACCESS_LOG_TARGET: &str = "sqlpage::access";
|
||||
|
||||
type OtlpHttpResponse = Result<opentelemetry_http::Response<opentelemetry_http::Bytes>, String>;
|
||||
|
||||
struct OtlpHttpJob {
|
||||
request: opentelemetry_http::Request<opentelemetry_http::Bytes>,
|
||||
response_sender: tokio::sync::oneshot::Sender<OtlpHttpResponse>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct AwcOtlpHttpClient {
|
||||
sender: tokio::sync::mpsc::UnboundedSender<OtlpHttpJob>,
|
||||
}
|
||||
|
||||
impl AwcOtlpHttpClient {
|
||||
fn new() -> anyhow::Result<Self> {
|
||||
Ok(Self {
|
||||
sender: otlp_http_worker_sender()?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for AwcOtlpHttpClient {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("AwcOtlpHttpClient").finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
fn otlp_http_worker_sender() -> anyhow::Result<tokio::sync::mpsc::UnboundedSender<OtlpHttpJob>> {
|
||||
let sender_result = OTLP_HTTP_WORKER_SENDER
|
||||
.get_or_init(|| init_otlp_http_worker_sender().map_err(|error| error.to_string()));
|
||||
|
||||
match sender_result {
|
||||
Ok(sender) => Ok(sender.clone()),
|
||||
Err(error) => {
|
||||
anyhow::bail!("Failed to initialize OTLP AWC worker thread: {error}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn init_otlp_http_worker_sender() -> anyhow::Result<tokio::sync::mpsc::UnboundedSender<OtlpHttpJob>>
|
||||
{
|
||||
let (sender, mut receiver) = tokio::sync::mpsc::unbounded_channel::<OtlpHttpJob>();
|
||||
let (init_result_sender, init_result_receiver) = std::sync::mpsc::sync_channel(1);
|
||||
let use_system_root_ca_certificates =
|
||||
crate::webserver::http_client::default_system_root_ca_certificates_from_env();
|
||||
|
||||
std::thread::Builder::new()
|
||||
.name("sqlpage-otlp-http".to_owned())
|
||||
.spawn(move || {
|
||||
actix_web::rt::System::new().block_on(async move {
|
||||
let awc_client =
|
||||
match crate::webserver::http_client::make_http_client_with_system_roots(
|
||||
use_system_root_ca_certificates,
|
||||
) {
|
||||
Ok(client) => {
|
||||
let _ = init_result_sender.send(Ok(()));
|
||||
client
|
||||
}
|
||||
Err(error) => {
|
||||
let error = format!("Failed to initialize OTLP AWC client: {error:#}");
|
||||
let _ = init_result_sender.send(Err(error.clone()));
|
||||
while let Some(job) = receiver.recv().await {
|
||||
let _ = job.response_sender.send(Err(error.clone()));
|
||||
}
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
while let Some(job) = receiver.recv().await {
|
||||
let response = execute_otlp_http_request_with_awc(&awc_client, job.request)
|
||||
.await
|
||||
.map_err(|error| error.to_string());
|
||||
let _ = job.response_sender.send(response);
|
||||
}
|
||||
});
|
||||
})
|
||||
.context("Failed to spawn OTLP AWC worker thread")?;
|
||||
|
||||
match init_result_receiver.recv() {
|
||||
Ok(Ok(())) => {}
|
||||
Ok(Err(error)) => anyhow::bail!("{error}"),
|
||||
Err(error) => anyhow::bail!("OTLP AWC worker initialization channel closed: {error}"),
|
||||
}
|
||||
|
||||
Ok(sender)
|
||||
}
|
||||
|
||||
async fn execute_otlp_http_request_with_awc(
|
||||
awc_client: &awc::Client,
|
||||
request: opentelemetry_http::Request<opentelemetry_http::Bytes>,
|
||||
) -> anyhow::Result<opentelemetry_http::Response<opentelemetry_http::Bytes>> {
|
||||
let (request_parts, request_body) = request.into_parts();
|
||||
|
||||
let awc_method = awc::http::Method::from_bytes(request_parts.method.as_str().as_bytes())
|
||||
.with_context(|| format!("Invalid OTLP HTTP method: {}", request_parts.method))?;
|
||||
let awc_uri: awc::http::Uri = request_parts
|
||||
.uri
|
||||
.to_string()
|
||||
.parse()
|
||||
.with_context(|| format!("Invalid OTLP collector URI: {}", request_parts.uri))?;
|
||||
|
||||
let mut awc_request = awc_client.request(awc_method, awc_uri.clone());
|
||||
for (header_name, header_value) in &request_parts.headers {
|
||||
let header_name_str = header_name.as_str();
|
||||
let awc_header_name = awc::http::header::HeaderName::from_bytes(header_name_str.as_bytes())
|
||||
.with_context(|| format!("Invalid OTLP header name: {header_name_str}"))?;
|
||||
let awc_header_value = awc::http::header::HeaderValue::from_bytes(header_value.as_bytes())
|
||||
.with_context(|| format!("Invalid OTLP header value for {header_name_str}"))?;
|
||||
awc_request = awc_request.insert_header((awc_header_name, awc_header_value));
|
||||
}
|
||||
|
||||
let mut awc_response = awc_request.send_body(request_body).await.map_err(|error| {
|
||||
anyhow::anyhow!("Failed to send OTLP HTTP request to {awc_uri}: {error}")
|
||||
})?;
|
||||
|
||||
let mut response_builder =
|
||||
opentelemetry_http::Response::builder().status(awc_response.status().as_u16());
|
||||
for (header_name, header_value) in awc_response.headers() {
|
||||
let header_value = header_value.to_str().map_err(|error| {
|
||||
anyhow::anyhow!(
|
||||
"Invalid OTLP response header value for {}: {error}",
|
||||
header_name.as_str()
|
||||
)
|
||||
})?;
|
||||
response_builder = response_builder.header(header_name.as_str(), header_value);
|
||||
}
|
||||
|
||||
let response_body = awc_response.body().await.map_err(|error| {
|
||||
anyhow::anyhow!("Failed to read OTLP HTTP response body from {awc_uri}: {error}")
|
||||
})?;
|
||||
|
||||
response_builder
|
||||
.body(response_body)
|
||||
.context("Failed to build OTLP HTTP response")
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl opentelemetry_http::HttpClient for AwcOtlpHttpClient {
|
||||
async fn send_bytes(
|
||||
&self,
|
||||
request: opentelemetry_http::Request<opentelemetry_http::Bytes>,
|
||||
) -> Result<
|
||||
opentelemetry_http::Response<opentelemetry_http::Bytes>,
|
||||
opentelemetry_http::HttpError,
|
||||
> {
|
||||
let (response_sender, response_receiver) = tokio::sync::oneshot::channel();
|
||||
self.sender
|
||||
.send(OtlpHttpJob {
|
||||
request,
|
||||
response_sender,
|
||||
})
|
||||
.map_err(|_| anyhow::anyhow!("OTLP AWC worker thread is unavailable"))?;
|
||||
|
||||
response_receiver
|
||||
.await
|
||||
.map_err(|_| anyhow::anyhow!("OTLP AWC worker dropped response channel"))?
|
||||
.map_err(Into::into)
|
||||
}
|
||||
}
|
||||
|
||||
/// Initializes logging / tracing. Returns whether `OTel` was activated.
|
||||
pub fn init_telemetry() -> anyhow::Result<bool> {
|
||||
init_telemetry_with_log_layer(logfmt::LogfmtLayer::new())
|
||||
}
|
||||
|
||||
fn init_telemetry_with_log_layer(logfmt_layer: logfmt::LogfmtLayer) -> anyhow::Result<bool> {
|
||||
let otel_endpoint = env::var("OTEL_EXPORTER_OTLP_ENDPOINT").ok();
|
||||
let otel_active = otel_endpoint.as_deref().is_some_and(|v| !v.is_empty());
|
||||
|
||||
if otel_active {
|
||||
init_otel_tracing(logfmt_layer)?;
|
||||
} else {
|
||||
init_tracing(logfmt_layer)?;
|
||||
}
|
||||
|
||||
Ok(otel_active)
|
||||
}
|
||||
|
||||
/// Initializes logging once for tests using the same formatter as production.
|
||||
///
|
||||
/// Unlike `init_telemetry`, this does not initialize OTEL exporters and does
|
||||
/// not panic on invalid `LOG_LEVEL` / `RUST_LOG` values.
|
||||
pub fn init_test_logging() {
|
||||
TEST_LOGGING_INIT.call_once(|| {
|
||||
init_test_tracing();
|
||||
});
|
||||
}
|
||||
|
||||
/// Shuts down the `OTel` tracer provider, flushing pending spans.
|
||||
pub fn shutdown_telemetry() {
|
||||
if let Some(provider) = TRACER_PROVIDER.get()
|
||||
&& let Err(e) = provider.shutdown()
|
||||
{
|
||||
eprintln!("Error shutting down tracer provider: {e}");
|
||||
}
|
||||
if let Some(provider) = METER_PROVIDER.get()
|
||||
&& let Err(e) = provider.shutdown()
|
||||
{
|
||||
eprintln!("Error shutting down meter provider: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Tracing subscriber without `OTel` export — logfmt output only.
|
||||
fn init_tracing(logfmt_layer: logfmt::LogfmtLayer) -> anyhow::Result<()> {
|
||||
use tracing_subscriber::layer::SubscriberExt;
|
||||
|
||||
let subscriber = tracing_subscriber::registry()
|
||||
.with(default_env_filter())
|
||||
.with(logfmt_layer);
|
||||
|
||||
set_global_subscriber(subscriber)
|
||||
}
|
||||
|
||||
fn init_test_tracing() {
|
||||
use tracing_subscriber::layer::SubscriberExt;
|
||||
|
||||
let subscriber = tracing_subscriber::registry()
|
||||
.with(test_env_filter())
|
||||
.with(logfmt::LogfmtLayer::test_writer());
|
||||
|
||||
set_global_subscriber(subscriber).expect("Failed to initialize test tracing subscriber");
|
||||
}
|
||||
|
||||
fn init_otel_tracing(logfmt_layer: logfmt::LogfmtLayer) -> anyhow::Result<()> {
|
||||
use opentelemetry::global;
|
||||
use opentelemetry::trace::TracerProvider as _;
|
||||
use opentelemetry_otlp::WithExportConfig as _;
|
||||
use opentelemetry_otlp::WithHttpConfig as _;
|
||||
use opentelemetry_sdk::propagation::TraceContextPropagator;
|
||||
use tracing_subscriber::layer::SubscriberExt;
|
||||
|
||||
let http_client = AwcOtlpHttpClient::new().context("Failed to initialize OTLP AWC client")?;
|
||||
|
||||
// W3C TraceContext propagation (traceparent header)
|
||||
global::set_text_map_propagator(TraceContextPropagator::new());
|
||||
// OTLP exporter — reads OTEL_EXPORTER_OTLP_ENDPOINT, OTEL_SERVICE_NAME, etc.
|
||||
let exporter = opentelemetry_otlp::SpanExporter::builder()
|
||||
.with_http()
|
||||
.with_http_client(http_client.clone())
|
||||
.with_protocol(opentelemetry_otlp::Protocol::HttpBinary)
|
||||
.build()
|
||||
.context("Failed to build OTLP span exporter")?;
|
||||
|
||||
let span_processor =
|
||||
opentelemetry_sdk::trace::span_processor_with_async_runtime::BatchSpanProcessor::builder(
|
||||
exporter,
|
||||
opentelemetry_sdk::runtime::Tokio,
|
||||
)
|
||||
.build();
|
||||
let provider = SdkTracerProvider::builder()
|
||||
.with_span_processor(span_processor)
|
||||
.build();
|
||||
|
||||
let tracer = provider.tracer("sqlpage");
|
||||
global::set_tracer_provider(provider.clone());
|
||||
let _ = TRACER_PROVIDER.set(provider);
|
||||
|
||||
// OTLP Metric exporter
|
||||
let metric_exporter = opentelemetry_otlp::MetricExporter::builder()
|
||||
.with_http()
|
||||
.with_http_client(http_client)
|
||||
.with_protocol(opentelemetry_otlp::Protocol::HttpBinary)
|
||||
.build()
|
||||
.context("Failed to build OTLP metric exporter")?;
|
||||
|
||||
let reader =
|
||||
opentelemetry_sdk::metrics::periodic_reader_with_async_runtime::PeriodicReader::builder(
|
||||
metric_exporter,
|
||||
opentelemetry_sdk::runtime::Tokio,
|
||||
)
|
||||
.build();
|
||||
let meter_provider = SdkMeterProvider::builder()
|
||||
.with_reader(reader)
|
||||
.with_view(|instrument: &opentelemetry_sdk::metrics::Instrument| {
|
||||
if instrument.kind() == opentelemetry_sdk::metrics::InstrumentKind::Histogram {
|
||||
opentelemetry_sdk::metrics::Stream::builder()
|
||||
.with_aggregation(
|
||||
opentelemetry_sdk::metrics::Aggregation::ExplicitBucketHistogram {
|
||||
boundaries: vec![
|
||||
0.001, 0.005, 0.01, 0.025, 0.05, 0.075, 0.1, 0.25, 0.5, 0.75, 1.0,
|
||||
2.5, 5.0, 7.5, 10.0,
|
||||
],
|
||||
record_min_max: true,
|
||||
},
|
||||
)
|
||||
.build()
|
||||
.ok()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.build();
|
||||
global::set_meter_provider(meter_provider.clone());
|
||||
let _ = METER_PROVIDER.set(meter_provider.clone());
|
||||
|
||||
let otel_layer = tracing_opentelemetry::layer()
|
||||
.with_tracer(tracer)
|
||||
.with_location(false);
|
||||
|
||||
let subscriber = tracing_subscriber::registry()
|
||||
.with(default_env_filter())
|
||||
.with(logfmt_layer)
|
||||
.with(otel_layer);
|
||||
|
||||
set_global_subscriber(subscriber)
|
||||
}
|
||||
|
||||
fn default_env_filter() -> tracing_subscriber::EnvFilter {
|
||||
env_filter_directives(
|
||||
env::var("LOG_LEVEL").ok().as_deref(),
|
||||
env::var("RUST_LOG").ok().as_deref(),
|
||||
)
|
||||
.parse()
|
||||
.expect("Invalid log filter value in LOG_LEVEL or RUST_LOG")
|
||||
}
|
||||
|
||||
fn test_env_filter() -> tracing_subscriber::EnvFilter {
|
||||
env_filter_directives(
|
||||
env::var("LOG_LEVEL").ok().as_deref(),
|
||||
env::var("RUST_LOG").ok().as_deref(),
|
||||
)
|
||||
.parse()
|
||||
.unwrap_or_else(|_| {
|
||||
DEFAULT_ENV_FILTER_DIRECTIVES
|
||||
.parse()
|
||||
.expect("Default filter directives should always be valid")
|
||||
})
|
||||
}
|
||||
|
||||
fn env_filter_directives(log_level: Option<&str>, rust_log: Option<&str>) -> String {
|
||||
match (
|
||||
log_level.filter(|value| !value.is_empty()),
|
||||
rust_log.filter(|value| !value.is_empty()),
|
||||
) {
|
||||
(Some(value), _) | (None, Some(value)) => value.to_owned(),
|
||||
(None, None) => DEFAULT_ENV_FILTER_DIRECTIVES.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
fn set_global_subscriber(subscriber: impl tracing::Subscriber + Send + Sync) -> anyhow::Result<()> {
|
||||
tracing::subscriber::set_global_default(subscriber)
|
||||
.context("Failed to set global tracing subscriber")?;
|
||||
tracing_log::LogTracer::init().context("Failed to set log→tracing bridge")
|
||||
}
|
||||
|
||||
/// Custom logfmt logging layer.
|
||||
///
|
||||
/// Outputs one line per event in logfmt format with carefully chosen fields:
|
||||
/// ```text
|
||||
/// ts=2026-03-08T20:56:15Z level=error target=sqlpage::webserver::error msg="..." http.request.method=GET url.path=/foo client.address=1.2.3.4 trace_id=abc123
|
||||
/// ```
|
||||
///
|
||||
/// With terminal colors when stderr is a TTY.
|
||||
mod logfmt {
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::HashMap;
|
||||
use std::fmt::Write;
|
||||
use std::io::{self, IsTerminal};
|
||||
|
||||
use tracing::Subscriber;
|
||||
use tracing::field::{Field, Visit};
|
||||
use tracing_subscriber::Layer;
|
||||
use tracing_subscriber::layer::Context;
|
||||
use tracing_subscriber::registry::LookupSpan;
|
||||
|
||||
/// Stores span fields so we can access them when formatting events.
|
||||
#[derive(Default)]
|
||||
struct SpanFields(HashMap<&'static str, String>);
|
||||
|
||||
/// Visitor that collects fields into a `HashMap`.
|
||||
struct FieldCollector<'a>(&'a mut HashMap<&'static str, String>);
|
||||
|
||||
impl Visit for FieldCollector<'_> {
|
||||
fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
|
||||
self.0.insert(field.name(), format!("{value:?}"));
|
||||
}
|
||||
fn record_str(&mut self, field: &Field, value: &str) {
|
||||
self.0.insert(field.name(), value.to_owned());
|
||||
}
|
||||
fn record_i64(&mut self, field: &Field, value: i64) {
|
||||
self.0.insert(field.name(), value.to_string());
|
||||
}
|
||||
fn record_u64(&mut self, field: &Field, value: u64) {
|
||||
self.0.insert(field.name(), value.to_string());
|
||||
}
|
||||
fn record_bool(&mut self, field: &Field, value: bool) {
|
||||
self.0.insert(field.name(), value.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
use opentelemetry_semantic_conventions::attribute as otel;
|
||||
/// Fields we pick from spans, in display order.
|
||||
/// (`span_field_name`, `logfmt_key`)
|
||||
const SPAN_FIELDS: &[(&str, &str)] = &[
|
||||
(otel::HTTP_REQUEST_METHOD, otel::HTTP_REQUEST_METHOD),
|
||||
(otel::URL_PATH, otel::URL_PATH),
|
||||
(
|
||||
otel::HTTP_RESPONSE_STATUS_CODE,
|
||||
otel::HTTP_RESPONSE_STATUS_CODE,
|
||||
),
|
||||
(otel::CODE_FILE_PATH, otel::CODE_FILE_PATH),
|
||||
(otel::CLIENT_ADDRESS, otel::CLIENT_ADDRESS),
|
||||
];
|
||||
|
||||
/// All-zeros trace ID means no real trace context.
|
||||
const INVALID_TRACE_ID: &str = "00000000000000000000000000000000";
|
||||
|
||||
// ANSI color codes
|
||||
const RED: &str = "\x1b[31m";
|
||||
const YELLOW: &str = "\x1b[33m";
|
||||
const GREEN: &str = "\x1b[32m";
|
||||
const BLUE: &str = "\x1b[34m";
|
||||
const DIM: &str = "\x1b[2m";
|
||||
const BOLD: &str = "\x1b[1m";
|
||||
const RESET: &str = "\x1b[0m";
|
||||
|
||||
#[derive(Copy, Clone)]
|
||||
enum OutputMode {
|
||||
StdoutAndStderr,
|
||||
TestWriter,
|
||||
}
|
||||
|
||||
pub(super) struct LogfmtLayer {
|
||||
stdout_colors: bool,
|
||||
stderr_colors: bool,
|
||||
output_mode: OutputMode,
|
||||
}
|
||||
|
||||
impl LogfmtLayer {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
stdout_colors: io::stdout().is_terminal(),
|
||||
stderr_colors: io::stderr().is_terminal(),
|
||||
output_mode: OutputMode::StdoutAndStderr,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn test_writer() -> Self {
|
||||
Self {
|
||||
stdout_colors: false,
|
||||
stderr_colors: false,
|
||||
output_mode: OutputMode::TestWriter,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<S> Layer<S> for LogfmtLayer
|
||||
where
|
||||
S: Subscriber + for<'a> LookupSpan<'a>,
|
||||
{
|
||||
fn on_new_span(
|
||||
&self,
|
||||
attrs: &tracing::span::Attributes<'_>,
|
||||
id: &tracing::span::Id,
|
||||
ctx: Context<'_, S>,
|
||||
) {
|
||||
let mut fields = SpanFields::default();
|
||||
attrs.record(&mut FieldCollector(&mut fields.0));
|
||||
if let Some(span) = ctx.span(id) {
|
||||
span.extensions_mut().insert(fields);
|
||||
}
|
||||
}
|
||||
|
||||
fn on_record(
|
||||
&self,
|
||||
id: &tracing::span::Id,
|
||||
values: &tracing::span::Record<'_>,
|
||||
ctx: Context<'_, S>,
|
||||
) {
|
||||
if let Some(span) = ctx.span(id) {
|
||||
let mut ext = span.extensions_mut();
|
||||
if let Some(fields) = ext.get_mut::<SpanFields>() {
|
||||
values.record(&mut FieldCollector(&mut fields.0));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn on_event(&self, event: &tracing::Event<'_>, ctx: Context<'_, S>) {
|
||||
let mut buf = String::with_capacity(256);
|
||||
let level = *event.metadata().level();
|
||||
let include_all_span_fields = includes_all_span_fields();
|
||||
let mut event_fields = HashMap::new();
|
||||
event.record(&mut FieldCollector(&mut event_fields));
|
||||
let target = event_target(event, &event_fields);
|
||||
let output_stream = output_stream_for_target(target);
|
||||
let colors = self.use_colors(output_stream);
|
||||
let msg = event_fields.get("message");
|
||||
let multiline_msg = is_multiline_terminal_message(colors, msg);
|
||||
let include_all_event_fields =
|
||||
include_all_span_fields || msg.is_none_or(String::is_empty);
|
||||
|
||||
write_timestamp(&mut buf, colors);
|
||||
write_level(&mut buf, level, colors);
|
||||
write_message(&mut buf, msg, multiline_msg);
|
||||
write_dimmed_field(&mut buf, "target", target, colors);
|
||||
write_event_fields(&mut buf, &event_fields, include_all_event_fields);
|
||||
write_span_fields(&mut buf, ctx.event_scope(event), include_all_span_fields);
|
||||
write_trace_id(&mut buf, ctx.event_scope(event), colors);
|
||||
|
||||
buf.push('\n');
|
||||
write_multiline_message(&mut buf, msg, multiline_msg);
|
||||
match self.output_mode {
|
||||
OutputMode::StdoutAndStderr => write_to_output(output_stream, &buf),
|
||||
OutputMode::TestWriter => {
|
||||
eprint!("{buf}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl LogfmtLayer {
|
||||
fn use_colors(&self, output_stream: OutputStream) -> bool {
|
||||
match output_stream {
|
||||
OutputStream::Stdout => self.stdout_colors,
|
||||
OutputStream::Stderr => self.stderr_colors,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
|
||||
enum OutputStream {
|
||||
Stdout,
|
||||
Stderr,
|
||||
}
|
||||
|
||||
fn output_stream_for_target(target: &str) -> OutputStream {
|
||||
if target == super::ACCESS_LOG_TARGET {
|
||||
OutputStream::Stdout
|
||||
} else {
|
||||
OutputStream::Stderr
|
||||
}
|
||||
}
|
||||
|
||||
fn write_to_output(output_stream: OutputStream, buf: &str) {
|
||||
use std::io::Write as _;
|
||||
|
||||
match output_stream {
|
||||
OutputStream::Stdout => {
|
||||
let _ = io::stdout().write_all(buf.as_bytes());
|
||||
}
|
||||
OutputStream::Stderr => {
|
||||
let _ = io::stderr().write_all(buf.as_bytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn event_target<'a>(
|
||||
event: &'a tracing::Event<'_>,
|
||||
event_fields: &'a HashMap<&'static str, String>,
|
||||
) -> &'a str {
|
||||
event_fields
|
||||
.get("log.target")
|
||||
.map_or_else(|| event.metadata().target(), String::as_str)
|
||||
}
|
||||
|
||||
fn is_multiline_terminal_message(colors: bool, msg: Option<&String>) -> bool {
|
||||
colors && msg.is_some_and(|message| message.contains('\n'))
|
||||
}
|
||||
|
||||
fn write_timestamp(buf: &mut String, colors: bool) {
|
||||
let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ");
|
||||
if colors {
|
||||
let _ = write!(buf, "{DIM}ts={now}{RESET}");
|
||||
} else {
|
||||
let _ = write!(buf, "ts={now}");
|
||||
}
|
||||
}
|
||||
|
||||
fn write_level(buf: &mut String, level: tracing::Level, colors: bool) {
|
||||
if colors {
|
||||
let (color, label) = level_style(level);
|
||||
let _ = write!(buf, " {DIM}level={RESET}{BOLD}{color}{label}{RESET}");
|
||||
} else {
|
||||
let _ = write!(buf, " level={}", level.as_str().to_ascii_lowercase());
|
||||
}
|
||||
}
|
||||
|
||||
fn level_style(level: tracing::Level) -> (&'static str, &'static str) {
|
||||
match level {
|
||||
tracing::Level::ERROR => (RED, "error"),
|
||||
tracing::Level::WARN => (YELLOW, "warn"),
|
||||
tracing::Level::INFO => (GREEN, "info"),
|
||||
tracing::Level::DEBUG => (BLUE, "debug"),
|
||||
tracing::Level::TRACE => (DIM, "trace"),
|
||||
}
|
||||
}
|
||||
|
||||
fn write_dimmed_field(buf: &mut String, key: &str, value: &str, colors: bool) {
|
||||
if colors {
|
||||
let _ = write!(buf, " {DIM}{key}={value}{RESET}");
|
||||
} else {
|
||||
let _ = write!(buf, " {key}={value}");
|
||||
}
|
||||
}
|
||||
|
||||
fn write_message(buf: &mut String, msg: Option<&String>, multiline_msg: bool) {
|
||||
if !multiline_msg && let Some(msg) = msg.filter(|msg| !msg.is_empty()) {
|
||||
write_logfmt_value(buf, "msg", msg);
|
||||
}
|
||||
}
|
||||
|
||||
fn write_event_fields(
|
||||
buf: &mut String,
|
||||
event_fields: &HashMap<&'static str, String>,
|
||||
include_all_event_fields: bool,
|
||||
) {
|
||||
if !include_all_event_fields {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut extra_fields = BTreeMap::new();
|
||||
for (&key, value) in event_fields {
|
||||
if key == "message" || key == "log.target" {
|
||||
continue;
|
||||
}
|
||||
extra_fields.insert(key, value);
|
||||
}
|
||||
|
||||
for (key, value) in extra_fields {
|
||||
write_logfmt_value(buf, key, value);
|
||||
}
|
||||
}
|
||||
|
||||
fn write_span_fields<S>(
|
||||
buf: &mut String,
|
||||
scope: Option<tracing_subscriber::registry::Scope<'_, S>>,
|
||||
include_all_span_fields: bool,
|
||||
) where
|
||||
S: Subscriber + for<'a> LookupSpan<'a>,
|
||||
{
|
||||
if let Some(scope) = scope {
|
||||
let mut seen_mapped_fields = [false; SPAN_FIELDS.len()];
|
||||
let mut extra_fields = BTreeMap::new();
|
||||
|
||||
for span in scope {
|
||||
let ext = span.extensions();
|
||||
if let Some(fields) = ext.get::<SpanFields>() {
|
||||
for (i, &(span_key, logfmt_key)) in SPAN_FIELDS.iter().enumerate() {
|
||||
if seen_mapped_fields[i] {
|
||||
continue;
|
||||
}
|
||||
if let Some(val) = fields.0.get(span_key) {
|
||||
write_logfmt_value(buf, logfmt_key, val);
|
||||
seen_mapped_fields[i] = true;
|
||||
}
|
||||
}
|
||||
if include_all_span_fields {
|
||||
for (&key, val) in &fields.0 {
|
||||
if SPAN_FIELDS.iter().any(|(span_key, _)| key == *span_key) {
|
||||
continue;
|
||||
}
|
||||
extra_fields.entry(key).or_insert_with(|| val.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if include_all_span_fields {
|
||||
for (key, val) in extra_fields {
|
||||
write_logfmt_value(buf, key, &val);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn includes_all_span_fields() -> bool {
|
||||
tracing::level_filters::LevelFilter::current() >= tracing::level_filters::LevelFilter::DEBUG
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn write_span_field_maps<'a>(
|
||||
buf: &mut String,
|
||||
span_fields: impl IntoIterator<Item = &'a HashMap<&'static str, String>>,
|
||||
include_all_span_fields: bool,
|
||||
) {
|
||||
let mut seen_mapped_fields = [false; SPAN_FIELDS.len()];
|
||||
let mut extra_fields = BTreeMap::new();
|
||||
|
||||
for fields in span_fields {
|
||||
for (i, &(span_key, logfmt_key)) in SPAN_FIELDS.iter().enumerate() {
|
||||
if seen_mapped_fields[i] {
|
||||
continue;
|
||||
}
|
||||
if let Some(val) = fields.get(span_key) {
|
||||
write_logfmt_value(buf, logfmt_key, val);
|
||||
seen_mapped_fields[i] = true;
|
||||
}
|
||||
}
|
||||
if include_all_span_fields {
|
||||
for (&key, val) in fields {
|
||||
if SPAN_FIELDS.iter().any(|(span_key, _)| key == *span_key) {
|
||||
continue;
|
||||
}
|
||||
extra_fields.entry(key).or_insert_with(|| val.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if include_all_span_fields {
|
||||
for (key, val) in extra_fields {
|
||||
write_logfmt_value(buf, key, &val);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn write_trace_id<S>(
|
||||
buf: &mut String,
|
||||
scope: Option<tracing_subscriber::registry::Scope<'_, S>>,
|
||||
colors: bool,
|
||||
) where
|
||||
S: Subscriber + for<'a> LookupSpan<'a>,
|
||||
{
|
||||
if let Some(trace_id) = first_valid_trace_id(scope) {
|
||||
write_dimmed_field(buf, "trace_id", &trace_id, colors);
|
||||
}
|
||||
}
|
||||
|
||||
fn first_valid_trace_id<S>(
|
||||
scope: Option<tracing_subscriber::registry::Scope<'_, S>>,
|
||||
) -> Option<String>
|
||||
where
|
||||
S: Subscriber + for<'a> LookupSpan<'a>,
|
||||
{
|
||||
use opentelemetry::trace::TraceContextExt as _;
|
||||
|
||||
let span_ids: Vec<_> = scope?.map(|span| span.id()).collect();
|
||||
tracing::dispatcher::get_default(|dispatch| {
|
||||
for span_id in &span_ids {
|
||||
if let Some(otel_context) =
|
||||
tracing_opentelemetry::get_otel_context(span_id, dispatch)
|
||||
{
|
||||
let trace_id = otel_context.span().span_context().trace_id().to_string();
|
||||
if trace_id != INVALID_TRACE_ID {
|
||||
return Some(trace_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
})
|
||||
}
|
||||
|
||||
fn write_multiline_message(buf: &mut String, msg: Option<&String>, multiline_msg: bool) {
|
||||
if multiline_msg && let Some(msg) = msg {
|
||||
buf.push_str(msg);
|
||||
buf.push('\n');
|
||||
}
|
||||
}
|
||||
|
||||
/// Write a logfmt key=value pair, quoting the value if it contains spaces or special chars.
|
||||
fn write_logfmt_value(buf: &mut String, key: &str, value: &str) {
|
||||
let needs_quoting = value.contains([' ', '"', '=', '\n', '\t']) || value.is_empty();
|
||||
|
||||
if needs_quoting {
|
||||
let escaped = value.replace('\n', " ").replace('"', "\\\"");
|
||||
let _ = write!(buf, " {key}=\"{escaped}\"");
|
||||
} else {
|
||||
let _ = write!(buf, " {key}={value}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::telemetry::env_filter_directives;
|
||||
|
||||
#[test]
|
||||
fn log_level_takes_precedence_over_rust_log() {
|
||||
assert_eq!(
|
||||
env_filter_directives(Some("sqlpage=debug"), Some("sqlpage=trace")),
|
||||
"sqlpage=debug"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rust_log_is_used_as_alias_when_log_level_is_missing() {
|
||||
assert_eq!(
|
||||
env_filter_directives(None, Some("sqlpage=trace")),
|
||||
"sqlpage=trace"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_values_fall_back_to_default_filter() {
|
||||
assert_eq!(
|
||||
env_filter_directives(Some(""), Some("")),
|
||||
"sqlpage=info,actix_web=info,tracing_actix_web=info,opentelemetry=warn,opentelemetry_sdk=warn,opentelemetry_otlp=warn"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn debug_logs_include_unmapped_span_fields() {
|
||||
let mut buf = String::new();
|
||||
let span_fields = HashMap::from([
|
||||
(otel::HTTP_REQUEST_METHOD, "GET".to_string()),
|
||||
(otel::HTTP_ROUTE, "/users/:id".to_string()),
|
||||
("otel.kind", "server".to_string()),
|
||||
]);
|
||||
|
||||
write_span_field_maps(&mut buf, [&span_fields], true);
|
||||
|
||||
assert_eq!(
|
||||
buf,
|
||||
" http.request.method=GET http.route=/users/:id otel.kind=server"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn info_logs_keep_only_mapped_span_fields_when_not_in_debug_mode() {
|
||||
let mut buf = String::new();
|
||||
let span_fields = HashMap::from([
|
||||
(otel::HTTP_REQUEST_METHOD, "GET".to_string()),
|
||||
(otel::HTTP_ROUTE, "/users/:id".to_string()),
|
||||
("otel.kind", "server".to_string()),
|
||||
]);
|
||||
|
||||
write_span_field_maps(&mut buf, [&span_fields], false);
|
||||
|
||||
assert_eq!(buf, " http.request.method=GET");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_fields_are_rendered_when_message_is_missing() {
|
||||
let mut buf = String::new();
|
||||
let event_fields = HashMap::from([
|
||||
("message", String::new()),
|
||||
("log.target", "opentelemetry_sdk".to_string()),
|
||||
("name", "BatchSpanProcessor.ExportFailed".to_string()),
|
||||
("reason", "connection error".to_string()),
|
||||
]);
|
||||
|
||||
write_event_fields(&mut buf, &event_fields, true);
|
||||
|
||||
assert_eq!(
|
||||
buf,
|
||||
" name=BatchSpanProcessor.ExportFailed reason=\"connection error\""
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn access_logs_use_stdout_and_other_logs_use_stderr() {
|
||||
assert_eq!(
|
||||
output_stream_for_target(super::super::ACCESS_LOG_TARGET),
|
||||
OutputStream::Stdout
|
||||
);
|
||||
assert_eq!(
|
||||
output_stream_for_target("sqlpage::webserver::http"),
|
||||
OutputStream::Stderr
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
use opentelemetry::global;
|
||||
use opentelemetry::metrics::{Histogram, ObservableGauge};
|
||||
use opentelemetry_semantic_conventions::attribute as otel;
|
||||
use opentelemetry_semantic_conventions::metric as otel_metric;
|
||||
use sqlx::AnyPool;
|
||||
|
||||
pub struct TelemetryMetrics {
|
||||
pub http_request_duration: Histogram<f64>,
|
||||
pub db_query_duration: Histogram<f64>,
|
||||
_pool_connection_count: ObservableGauge<i64>,
|
||||
}
|
||||
|
||||
impl Default for TelemetryMetrics {
|
||||
fn default() -> Self {
|
||||
let meter = global::meter("sqlpage");
|
||||
let http_request_duration = meter
|
||||
.f64_histogram(otel_metric::HTTP_SERVER_REQUEST_DURATION)
|
||||
.with_unit("s")
|
||||
.with_description("Duration of HTTP requests processed by the server.")
|
||||
.build();
|
||||
let db_query_duration = meter
|
||||
.f64_histogram(otel_metric::DB_CLIENT_OPERATION_DURATION)
|
||||
.with_unit("s")
|
||||
.with_description("Duration of executing SQL queries.")
|
||||
.build();
|
||||
// This default is only used in tests that don't touch pool metrics.
|
||||
let pool_connection_count = meter
|
||||
.i64_observable_gauge(otel_metric::DB_CLIENT_CONNECTION_COUNT)
|
||||
.with_unit("{connection}")
|
||||
.with_description("Number of connections in the database pool.")
|
||||
.with_callback(|_| {})
|
||||
.build();
|
||||
|
||||
Self {
|
||||
http_request_duration,
|
||||
db_query_duration,
|
||||
_pool_connection_count: pool_connection_count,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TelemetryMetrics {
|
||||
#[must_use]
|
||||
pub fn new(pool: &AnyPool, db_system_name: &'static str) -> Self {
|
||||
let meter = global::meter("sqlpage");
|
||||
let http_request_duration = meter
|
||||
.f64_histogram(otel_metric::HTTP_SERVER_REQUEST_DURATION)
|
||||
.with_unit("s")
|
||||
.with_description("Duration of HTTP requests processed by the server.")
|
||||
.build();
|
||||
let db_query_duration = meter
|
||||
.f64_histogram(otel_metric::DB_CLIENT_OPERATION_DURATION)
|
||||
.with_unit("s")
|
||||
.with_description("Duration of executing SQL queries.")
|
||||
.build();
|
||||
let pool_ref = pool.clone();
|
||||
let pool_connection_count = meter
|
||||
.i64_observable_gauge(otel_metric::DB_CLIENT_CONNECTION_COUNT)
|
||||
.with_unit("{connection}")
|
||||
.with_description("Number of connections in the database pool.")
|
||||
.with_callback(move |observer| {
|
||||
let size = pool_ref.size();
|
||||
let idle_u32 = u32::try_from(pool_ref.num_idle()).unwrap_or(u32::MAX);
|
||||
let used = i64::from(size.saturating_sub(idle_u32));
|
||||
let idle = i64::from(idle_u32);
|
||||
observer.observe(
|
||||
used,
|
||||
&[
|
||||
opentelemetry::KeyValue::new(otel::DB_SYSTEM_NAME, db_system_name),
|
||||
opentelemetry::KeyValue::new(
|
||||
otel::DB_CLIENT_CONNECTION_POOL_NAME,
|
||||
"sqlpage",
|
||||
),
|
||||
opentelemetry::KeyValue::new(otel::DB_CLIENT_CONNECTION_STATE, "used"),
|
||||
],
|
||||
);
|
||||
observer.observe(
|
||||
idle,
|
||||
&[
|
||||
opentelemetry::KeyValue::new(otel::DB_SYSTEM_NAME, db_system_name),
|
||||
opentelemetry::KeyValue::new(
|
||||
otel::DB_CLIENT_CONNECTION_POOL_NAME,
|
||||
"sqlpage",
|
||||
),
|
||||
opentelemetry::KeyValue::new(otel::DB_CLIENT_CONNECTION_STATE, "idle"),
|
||||
],
|
||||
);
|
||||
})
|
||||
.build();
|
||||
|
||||
Self {
|
||||
http_request_duration,
|
||||
db_query_duration,
|
||||
_pool_connection_count: pool_connection_count,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,734 @@
|
||||
use std::{borrow::Cow, collections::HashMap, sync::LazyLock};
|
||||
|
||||
use crate::{app_config::AppConfig, utils::static_filename};
|
||||
use anyhow::Context as _;
|
||||
use handlebars::{
|
||||
Context, Handlebars, HelperDef, JsonTruthy, PathAndJson, RenderError, RenderErrorReason,
|
||||
Renderable, ScopedJson, handlebars_helper,
|
||||
};
|
||||
use serde_json::Value as JsonValue;
|
||||
|
||||
/// Simple static json helper
|
||||
type H0 = fn() -> JsonValue;
|
||||
/// Simple json to json helper
|
||||
type H = fn(&JsonValue) -> JsonValue;
|
||||
/// Simple json to json helper with error handling
|
||||
type EH = fn(&JsonValue) -> anyhow::Result<JsonValue>;
|
||||
/// Helper that takes two arguments
|
||||
type HH = fn(&JsonValue, &JsonValue) -> JsonValue;
|
||||
/// Helper that takes three arguments
|
||||
#[allow(clippy::upper_case_acronyms)]
|
||||
type HHH = fn(&JsonValue, &JsonValue, &JsonValue) -> JsonValue;
|
||||
|
||||
pub fn register_all_helpers(h: &mut Handlebars<'_>, config: &AppConfig) {
|
||||
let site_prefix = config.site_prefix.clone();
|
||||
|
||||
register_helper(h, "all", HelperCheckTruthy(false));
|
||||
register_helper(h, "any", HelperCheckTruthy(true));
|
||||
|
||||
register_helper(h, "stringify", stringify_helper as H);
|
||||
register_helper(h, "parse_json", parse_json_helper as EH);
|
||||
register_helper(h, "default", default_helper as HH);
|
||||
register_helper(h, "entries", entries_helper as H);
|
||||
register_helper(h, "replace", replace_helper as HHH);
|
||||
// delay helper: store a piece of information in memory that can be output later with flush_delayed
|
||||
h.register_helper("delay", Box::new(delay_helper));
|
||||
h.register_helper("flush_delayed", Box::new(flush_delayed_helper));
|
||||
register_helper(h, "plus", plus_helper as HH);
|
||||
register_helper(h, "minus", minus_helper as HH);
|
||||
h.register_helper("sum", Box::new(sum_helper));
|
||||
register_helper(h, "loose_eq", loose_eq_helper as HH);
|
||||
register_helper(h, "starts_with", starts_with_helper as HH);
|
||||
|
||||
// to_array: convert a value to a single-element array. If the value is already an array, return it as-is.
|
||||
register_helper(h, "to_array", to_array_helper as H);
|
||||
|
||||
// array_contains: check if an array contains an element. If the first argument is not an array, it is compared to the second argument.
|
||||
handlebars_helper!(array_contains: |array: Json, element: Json| match array {
|
||||
JsonValue::Array(arr) => arr.contains(element),
|
||||
other => other == element
|
||||
});
|
||||
h.register_helper("array_contains", Box::new(array_contains));
|
||||
|
||||
// array_contains_case_insensitive: check if an array contains an element case-insensitively. If the first argument is not an array, it is compared to the second argument case-insensitively.
|
||||
handlebars_helper!(array_contains_case_insensitive: |array: Json, element: Json| {
|
||||
match array {
|
||||
JsonValue::Array(arr) => arr.iter().any(|v| json_eq_case_insensitive(v, element)),
|
||||
other => json_eq_case_insensitive(other, element),
|
||||
}
|
||||
});
|
||||
h.register_helper(
|
||||
"array_contains_case_insensitive",
|
||||
Box::new(array_contains_case_insensitive),
|
||||
);
|
||||
|
||||
// static_path helper: generate a path to a static file. Replaces sqpage.js by sqlpage.<hash>.js
|
||||
register_helper(h, "static_path", StaticPathHelper(site_prefix.clone()));
|
||||
register_helper(h, "app_config", AppConfigHelper(config.clone()));
|
||||
|
||||
// icon helper: generate an image with the specified icon
|
||||
h.register_helper("icon_img", Box::new(IconImgHelper));
|
||||
register_helper(h, "markdown", MarkdownHelper::new(config));
|
||||
register_helper(h, "buildinfo", buildinfo_helper as EH);
|
||||
register_helper(h, "typeof", typeof_helper as H);
|
||||
register_helper(h, "rfc2822_date", rfc2822_date_helper as EH);
|
||||
register_helper(h, "url_encode", url_encode_helper as H);
|
||||
register_helper(h, "csv_escape", csv_escape_helper as HH);
|
||||
}
|
||||
|
||||
fn json_eq_case_insensitive(a: &JsonValue, b: &JsonValue) -> bool {
|
||||
match (a, b) {
|
||||
(JsonValue::String(a), JsonValue::String(b)) => a.eq_ignore_ascii_case(b),
|
||||
_ => a == b,
|
||||
}
|
||||
}
|
||||
|
||||
fn stringify_helper(v: &JsonValue) -> JsonValue {
|
||||
v.to_string().into()
|
||||
}
|
||||
|
||||
fn parse_json_helper(v: &JsonValue) -> Result<JsonValue, anyhow::Error> {
|
||||
Ok(match v {
|
||||
serde_json::value::Value::String(s) => serde_json::from_str(s)?,
|
||||
other => other.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
fn default_helper(v: &JsonValue, default: &JsonValue) -> JsonValue {
|
||||
if v.is_null() {
|
||||
default.clone()
|
||||
} else {
|
||||
v.clone()
|
||||
}
|
||||
}
|
||||
|
||||
fn plus_helper(a: &JsonValue, b: &JsonValue) -> JsonValue {
|
||||
if let (Some(a), Some(b)) = (a.as_i64(), b.as_i64()) {
|
||||
(a + b).into()
|
||||
} else if let (Some(a), Some(b)) = (a.as_f64(), b.as_f64()) {
|
||||
(a + b).into()
|
||||
} else {
|
||||
JsonValue::Null
|
||||
}
|
||||
}
|
||||
|
||||
fn minus_helper(a: &JsonValue, b: &JsonValue) -> JsonValue {
|
||||
if let (Some(a), Some(b)) = (a.as_i64(), b.as_i64()) {
|
||||
(a - b).into()
|
||||
} else if let (Some(a), Some(b)) = (a.as_f64(), b.as_f64()) {
|
||||
(a - b).into()
|
||||
} else {
|
||||
JsonValue::Null
|
||||
}
|
||||
}
|
||||
|
||||
fn starts_with_helper(a: &JsonValue, b: &JsonValue) -> JsonValue {
|
||||
if let (Some(a), Some(b)) = (a.as_str(), b.as_str()) {
|
||||
a.starts_with(b)
|
||||
} else if let (Some(arr1), Some(arr2)) = (a.as_array(), b.as_array()) {
|
||||
arr1.starts_with(arr2)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
.into()
|
||||
}
|
||||
fn entries_helper(v: &JsonValue) -> JsonValue {
|
||||
match v {
|
||||
serde_json::value::Value::Object(map) => map
|
||||
.into_iter()
|
||||
.map(|(k, v)| serde_json::json!({"key": k, "value": v}))
|
||||
.collect(),
|
||||
serde_json::value::Value::Array(values) => values
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(k, v)| serde_json::json!({"key": k, "value": v}))
|
||||
.collect(),
|
||||
_ => vec![],
|
||||
}
|
||||
.into()
|
||||
}
|
||||
|
||||
fn to_array_helper(v: &JsonValue) -> JsonValue {
|
||||
match v {
|
||||
JsonValue::Array(arr) => arr.clone(),
|
||||
JsonValue::Null => vec![],
|
||||
JsonValue::String(s) if s.starts_with('[') => {
|
||||
if let Ok(JsonValue::Array(r)) = serde_json::from_str(s) {
|
||||
r
|
||||
} else {
|
||||
vec![JsonValue::String(s.clone())]
|
||||
}
|
||||
}
|
||||
other => vec![other.clone()],
|
||||
}
|
||||
.into()
|
||||
}
|
||||
|
||||
/// Generate the full path to a builtin sqlpage asset. Struct Param is the site prefix
|
||||
struct StaticPathHelper(String);
|
||||
|
||||
impl CanHelp for StaticPathHelper {
|
||||
fn call(&self, args: &[PathAndJson]) -> Result<JsonValue, String> {
|
||||
let static_file = match args {
|
||||
[v] => v.value(),
|
||||
_ => return Err("expected one argument".to_string()),
|
||||
};
|
||||
let name = static_file
|
||||
.as_str()
|
||||
.ok_or_else(|| format!("static_path: not a string: {static_file}"))?;
|
||||
let path = match name {
|
||||
"sqlpage.js" => static_filename!("sqlpage.js"),
|
||||
"sqlpage.css" => static_filename!("sqlpage.css"),
|
||||
"apexcharts.js" => static_filename!("apexcharts.js"),
|
||||
"tomselect.js" => static_filename!("tomselect.js"),
|
||||
"favicon.svg" => static_filename!("favicon.svg"),
|
||||
other => return Err(format!("unknown static file: {other:?}")),
|
||||
};
|
||||
Ok(format!("{}{}", self.0, path).into())
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate the full path to a builtin sqlpage asset. Struct Param is the site prefix
|
||||
struct AppConfigHelper(AppConfig);
|
||||
|
||||
impl CanHelp for AppConfigHelper {
|
||||
fn call(&self, args: &[PathAndJson]) -> Result<JsonValue, String> {
|
||||
let static_file = match args {
|
||||
[v] => v.value(),
|
||||
_ => return Err("expected one argument".to_string()),
|
||||
};
|
||||
let name = static_file
|
||||
.as_str()
|
||||
.ok_or_else(|| format!("app_config: not a string: {static_file}"))?;
|
||||
match name {
|
||||
"max_uploaded_file_size" => Ok(JsonValue::Number(self.0.max_uploaded_file_size.into())),
|
||||
"environment" => serde_json::to_value(self.0.environment).map_err(|e| e.to_string()),
|
||||
"site_prefix" => Ok(self.0.site_prefix.clone().into()),
|
||||
other => Err(format!("unknown app config property: {other:?}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub static ICON_MAP: LazyLock<HashMap<&'static str, &'static str>> =
|
||||
LazyLock::new(|| include!(concat!(env!("OUT_DIR"), "/icons.rs")).into());
|
||||
|
||||
/// Generate an image with the specified icon.
|
||||
struct IconImgHelper;
|
||||
impl HelperDef for IconImgHelper {
|
||||
fn call<'reg: 'rc, 'rc>(
|
||||
&self,
|
||||
helper: &handlebars::Helper<'rc>,
|
||||
_r: &'reg Handlebars<'reg>,
|
||||
_ctx: &'rc Context,
|
||||
_rc: &mut handlebars::RenderContext<'reg, 'rc>,
|
||||
writer: &mut dyn handlebars::Output,
|
||||
) -> handlebars::HelperResult {
|
||||
let null = handlebars::JsonValue::Null;
|
||||
let [name, size] = [0, 1].map(|i| helper.params().get(i).map_or(&null, PathAndJson::value));
|
||||
let size = size.as_u64().unwrap_or(24);
|
||||
let content = name.as_str().and_then(|name| ICON_MAP.get(name));
|
||||
let Some(&inner_content) = content else {
|
||||
log::warn!("icon_img: icon {name} not found");
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
write!(
|
||||
writer,
|
||||
r#"<svg viewBox="0 0 24 24" width="{size}" height="{size}" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">{inner_content}</svg>"#
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn typeof_helper(v: &JsonValue) -> JsonValue {
|
||||
match v {
|
||||
JsonValue::Null => "null",
|
||||
JsonValue::Bool(_) => "boolean",
|
||||
JsonValue::Number(_) => "number",
|
||||
JsonValue::String(_) => "string",
|
||||
JsonValue::Array(_) => "array",
|
||||
JsonValue::Object(_) => "object",
|
||||
}
|
||||
.into()
|
||||
}
|
||||
|
||||
pub trait MarkdownConfig {
|
||||
fn allow_dangerous_html(&self) -> bool;
|
||||
fn allow_dangerous_protocol(&self) -> bool;
|
||||
}
|
||||
|
||||
impl MarkdownConfig for AppConfig {
|
||||
fn allow_dangerous_html(&self) -> bool {
|
||||
self.markdown_allow_dangerous_html
|
||||
}
|
||||
|
||||
fn allow_dangerous_protocol(&self) -> bool {
|
||||
self.markdown_allow_dangerous_protocol
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper to render markdown with configurable options
|
||||
#[derive(Default)]
|
||||
struct MarkdownHelper {
|
||||
allow_dangerous_html: bool,
|
||||
allow_dangerous_protocol: bool,
|
||||
}
|
||||
|
||||
impl MarkdownHelper {
|
||||
fn new(config: &impl MarkdownConfig) -> Self {
|
||||
Self {
|
||||
allow_dangerous_html: config.allow_dangerous_html(),
|
||||
allow_dangerous_protocol: config.allow_dangerous_protocol(),
|
||||
}
|
||||
}
|
||||
|
||||
fn get_preset_options(&self, preset_name: &str) -> Result<markdown::Options, String> {
|
||||
let mut options = markdown::Options::gfm();
|
||||
options.compile.allow_dangerous_html = self.allow_dangerous_html;
|
||||
options.compile.allow_dangerous_protocol = self.allow_dangerous_protocol;
|
||||
options.compile.allow_any_img_src = true;
|
||||
|
||||
match preset_name {
|
||||
"default" => {}
|
||||
"allow_unsafe" => {
|
||||
options.compile.allow_dangerous_html = true;
|
||||
options.compile.allow_dangerous_protocol = true;
|
||||
}
|
||||
_ => return Err(format!("unknown markdown preset: {preset_name}")),
|
||||
}
|
||||
|
||||
Ok(options)
|
||||
}
|
||||
}
|
||||
|
||||
impl CanHelp for MarkdownHelper {
|
||||
fn call(&self, args: &[PathAndJson]) -> Result<JsonValue, String> {
|
||||
let (markdown_src_value, preset_name) = match args {
|
||||
[v] => (v.value(), "default"),
|
||||
[v, preset] => {
|
||||
let value = v.value();
|
||||
let preset_name_value = preset.value();
|
||||
let preset = preset_name_value.as_str()
|
||||
.ok_or_else(|| format!("markdown template helper expects a string as preset name. Got: {preset_name_value}"))?;
|
||||
(value, preset)
|
||||
}
|
||||
_ => return Err("markdown template helper expects one or two arguments".to_string()),
|
||||
};
|
||||
let markdown_src = match markdown_src_value {
|
||||
JsonValue::String(s) => Cow::Borrowed(s),
|
||||
JsonValue::Array(arr) => Cow::Owned(
|
||||
arr.iter()
|
||||
.map(|v| v.as_str().unwrap_or_default())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n"),
|
||||
),
|
||||
JsonValue::Null => Cow::Owned(String::new()),
|
||||
other => Cow::Owned(other.to_string()),
|
||||
};
|
||||
|
||||
let options = self.get_preset_options(preset_name)?;
|
||||
markdown::to_html_with_options(&markdown_src, &options)
|
||||
.map(JsonValue::String)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn buildinfo_helper(x: &JsonValue) -> anyhow::Result<JsonValue> {
|
||||
match x {
|
||||
JsonValue::String(s) if s == "CARGO_PKG_NAME" => Ok(env!("CARGO_PKG_NAME").into()),
|
||||
JsonValue::String(s) if s == "CARGO_PKG_VERSION" => Ok(env!("CARGO_PKG_VERSION").into()),
|
||||
other => Err(anyhow::anyhow!("unknown buildinfo key: {other:?}")),
|
||||
}
|
||||
}
|
||||
|
||||
// rfc2822_date: take an ISO date and convert it to an RFC 2822 date
|
||||
fn rfc2822_date_helper(v: &JsonValue) -> anyhow::Result<JsonValue> {
|
||||
let date: chrono::DateTime<chrono::FixedOffset> = match v {
|
||||
JsonValue::String(s) => {
|
||||
// we accept both dates with and without time
|
||||
chrono::DateTime::parse_from_rfc3339(s)
|
||||
.or_else(|_| {
|
||||
chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d")
|
||||
.map(|d| d.and_hms_opt(0, 0, 0).unwrap().and_utc().fixed_offset())
|
||||
})
|
||||
.with_context(|| format!("invalid date: {s}"))?
|
||||
}
|
||||
JsonValue::Number(n) => {
|
||||
chrono::DateTime::from_timestamp(n.as_i64().with_context(|| "not a timestamp")?, 0)
|
||||
.with_context(|| "invalid timestamp")?
|
||||
.into()
|
||||
}
|
||||
other => anyhow::bail!("expected a date, got {other:?}"),
|
||||
};
|
||||
// format: Thu, 01 Jan 1970 00:00:00 +0000
|
||||
Ok(date.format("%a, %d %b %Y %T %z").to_string().into())
|
||||
}
|
||||
|
||||
// Percent-encode a string
|
||||
fn url_encode_helper(v: &JsonValue) -> JsonValue {
|
||||
let as_str = match v {
|
||||
JsonValue::String(s) => s,
|
||||
other => &other.to_string(),
|
||||
};
|
||||
percent_encoding::percent_encode(as_str.as_bytes(), percent_encoding::NON_ALPHANUMERIC)
|
||||
.to_string()
|
||||
.into()
|
||||
}
|
||||
|
||||
// Percent-encode a string
|
||||
fn csv_escape_helper(v: &JsonValue, separator: &JsonValue) -> JsonValue {
|
||||
let as_str = match v {
|
||||
JsonValue::String(s) => s,
|
||||
other => &other.to_string(),
|
||||
};
|
||||
let separator = separator.as_str().unwrap_or(",");
|
||||
if as_str.contains(separator) || as_str.contains('"') || as_str.contains('\n') {
|
||||
format!(r#""{}""#, as_str.replace('"', r#""""#)).into()
|
||||
} else {
|
||||
as_str.to_owned().into()
|
||||
}
|
||||
}
|
||||
|
||||
fn with_each_block<'a, 'reg, 'rc>(
|
||||
rc: &'a mut handlebars::RenderContext<'reg, 'rc>,
|
||||
mut action: impl FnMut(&mut handlebars::BlockContext<'rc>, bool) -> Result<(), RenderError>,
|
||||
) -> Result<(), RenderError> {
|
||||
let mut blks = Vec::new();
|
||||
while let Some(mut top) = rc.block_mut().map(std::mem::take) {
|
||||
rc.pop_block();
|
||||
action(&mut top, rc.block().is_none())?;
|
||||
blks.push(top);
|
||||
}
|
||||
while let Some(blk) = blks.pop() {
|
||||
rc.push_block(blk);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) const DELAYED_CONTENTS: &str = "_delayed_contents";
|
||||
|
||||
fn delay_helper<'reg, 'rc>(
|
||||
h: &handlebars::Helper<'rc>,
|
||||
r: &'reg Handlebars<'reg>,
|
||||
ctx: &'rc Context,
|
||||
rc: &mut handlebars::RenderContext<'reg, 'rc>,
|
||||
_out: &mut dyn handlebars::Output,
|
||||
) -> handlebars::HelperResult {
|
||||
let inner = h
|
||||
.template()
|
||||
.ok_or(RenderErrorReason::BlockContentRequired)?;
|
||||
let mut str_out = handlebars::StringOutput::new();
|
||||
inner.render(r, ctx, rc, &mut str_out)?;
|
||||
let mut delayed_render = str_out.into_string()?;
|
||||
with_each_block(rc, |block, is_last| {
|
||||
if is_last {
|
||||
let old_delayed_render = block
|
||||
.get_local_var(DELAYED_CONTENTS)
|
||||
.and_then(JsonValue::as_str)
|
||||
.unwrap_or_default();
|
||||
delayed_render += old_delayed_render;
|
||||
let contents = JsonValue::String(std::mem::take(&mut delayed_render));
|
||||
block.set_local_var(DELAYED_CONTENTS, contents);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn flush_delayed_helper<'reg, 'rc>(
|
||||
_h: &handlebars::Helper<'rc>,
|
||||
_r: &'reg Handlebars<'reg>,
|
||||
_ctx: &'rc Context,
|
||||
rc: &mut handlebars::RenderContext<'reg, 'rc>,
|
||||
writer: &mut dyn handlebars::Output,
|
||||
) -> handlebars::HelperResult {
|
||||
with_each_block(rc, |block_context, _last| {
|
||||
let delayed = block_context
|
||||
.get_local_var(DELAYED_CONTENTS)
|
||||
.and_then(JsonValue::as_str)
|
||||
.filter(|s| !s.is_empty());
|
||||
if let Some(contents) = delayed {
|
||||
writer.write(contents)?;
|
||||
block_context.set_local_var(DELAYED_CONTENTS, JsonValue::Null);
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn sum_helper<'reg, 'rc>(
|
||||
helper: &handlebars::Helper<'rc>,
|
||||
_r: &'reg Handlebars<'reg>,
|
||||
_ctx: &'rc Context,
|
||||
_rc: &mut handlebars::RenderContext<'reg, 'rc>,
|
||||
writer: &mut dyn handlebars::Output,
|
||||
) -> handlebars::HelperResult {
|
||||
let mut sum = 0f64;
|
||||
for v in helper.params() {
|
||||
sum += v
|
||||
.value()
|
||||
.as_f64()
|
||||
.ok_or(RenderErrorReason::InvalidParamType("number"))?;
|
||||
}
|
||||
write!(writer, "{sum}")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Compare two values loosely, i.e. treat all values as strings. (42 == "42")
|
||||
fn loose_eq_helper(a: &JsonValue, b: &JsonValue) -> JsonValue {
|
||||
match (a, b) {
|
||||
(JsonValue::String(a), JsonValue::String(b)) => a == b,
|
||||
(JsonValue::String(a), non_str) => a == &non_str.to_string(),
|
||||
(non_str, JsonValue::String(b)) => &non_str.to_string() == b,
|
||||
(a, b) => a == b,
|
||||
}
|
||||
.into()
|
||||
}
|
||||
/// Helper that returns the first argument with the given truthiness, or the last argument if none have it.
|
||||
/// Equivalent to a && b && c && ... if the truthiness is false,
|
||||
/// or a || b || c || ... if the truthiness is true.
|
||||
pub struct HelperCheckTruthy(bool);
|
||||
|
||||
impl CanHelp for HelperCheckTruthy {
|
||||
fn call(&self, args: &[PathAndJson]) -> Result<JsonValue, String> {
|
||||
for arg in args {
|
||||
if arg.value().is_truthy(false) == self.0 {
|
||||
return Ok(arg.value().clone());
|
||||
}
|
||||
}
|
||||
if let Some(last) = args.last() {
|
||||
Ok(last.value().clone())
|
||||
} else {
|
||||
Err("expected at least one argument".to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
trait CanHelp: Send + Sync + 'static {
|
||||
fn call(&self, v: &[PathAndJson]) -> Result<JsonValue, String>;
|
||||
}
|
||||
|
||||
impl CanHelp for H0 {
|
||||
fn call(&self, args: &[PathAndJson]) -> Result<JsonValue, String> {
|
||||
match args {
|
||||
[] => Ok(self()),
|
||||
_ => Err("expected no arguments".to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CanHelp for H {
|
||||
fn call(&self, args: &[PathAndJson]) -> Result<JsonValue, String> {
|
||||
match args {
|
||||
[v] => Ok(self(v.value())),
|
||||
_ => Err("expected one argument".to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CanHelp for EH {
|
||||
fn call(&self, args: &[PathAndJson]) -> Result<JsonValue, String> {
|
||||
match args {
|
||||
[v] => self(v.value()).map_err(|e| e.to_string()),
|
||||
_ => Err("expected one argument".to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CanHelp for HH {
|
||||
fn call(&self, args: &[PathAndJson]) -> Result<JsonValue, String> {
|
||||
match args {
|
||||
[a, b] => Ok(self(a.value(), b.value())),
|
||||
_ => Err("expected two arguments".to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CanHelp for HHH {
|
||||
fn call(&self, args: &[PathAndJson]) -> Result<JsonValue, String> {
|
||||
match args {
|
||||
[a, b, c] => Ok(self(a.value(), b.value(), c.value())),
|
||||
_ => Err("expected three arguments".to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct JFun<F: CanHelp> {
|
||||
name: &'static str,
|
||||
fun: F,
|
||||
}
|
||||
impl<F: CanHelp> handlebars::HelperDef for JFun<F> {
|
||||
fn call_inner<'reg: 'rc, 'rc>(
|
||||
&self,
|
||||
helper: &handlebars::Helper<'rc>,
|
||||
_r: &'reg Handlebars<'reg>,
|
||||
_: &'rc Context,
|
||||
_rc: &mut handlebars::RenderContext<'reg, 'rc>,
|
||||
) -> Result<handlebars::ScopedJson<'rc>, RenderError> {
|
||||
let result = self
|
||||
.fun
|
||||
.call(helper.params().as_slice())
|
||||
.map_err(|s| RenderErrorReason::Other(format!("{}: {}", self.name, s)))?;
|
||||
Ok(ScopedJson::Derived(result))
|
||||
}
|
||||
}
|
||||
|
||||
fn register_helper(h: &mut Handlebars, name: &'static str, fun: impl CanHelp) {
|
||||
h.register_helper(name, Box::new(JFun { name, fun }));
|
||||
}
|
||||
|
||||
fn replace_helper(text: &JsonValue, original: &JsonValue, replacement: &JsonValue) -> JsonValue {
|
||||
let text_str = match text {
|
||||
JsonValue::String(s) => s,
|
||||
other => &other.to_string(),
|
||||
};
|
||||
let original_str = match original {
|
||||
JsonValue::String(s) => s,
|
||||
other => &other.to_string(),
|
||||
};
|
||||
let replacement_str = match replacement {
|
||||
JsonValue::String(s) => s,
|
||||
other => &other.to_string(),
|
||||
};
|
||||
|
||||
text_str.replace(original_str, replacement_str).into()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::template_helpers::{CanHelp, MarkdownHelper, rfc2822_date_helper};
|
||||
use handlebars::{JsonValue, PathAndJson, ScopedJson};
|
||||
use serde_json::Value;
|
||||
|
||||
const CONTENT_KEY: &str = "contents_md";
|
||||
|
||||
#[test]
|
||||
fn test_rfc2822_date() {
|
||||
assert_eq!(
|
||||
rfc2822_date_helper(&JsonValue::String("1970-01-02T03:04:05+02:00".into()))
|
||||
.unwrap()
|
||||
.as_str()
|
||||
.unwrap(),
|
||||
"Fri, 02 Jan 1970 03:04:05 +0200"
|
||||
);
|
||||
assert_eq!(
|
||||
rfc2822_date_helper(&JsonValue::String("1970-01-02".into()))
|
||||
.unwrap()
|
||||
.as_str()
|
||||
.unwrap(),
|
||||
"Fri, 02 Jan 1970 00:00:00 +0000"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_basic_gfm_markdown() {
|
||||
let helper = MarkdownHelper::default();
|
||||
|
||||
let contents = Value::String("# Heading".to_string());
|
||||
let actual = helper.call(&as_args(&contents)).unwrap();
|
||||
|
||||
assert_eq!(Some("<h1>Heading</h1>"), actual.as_str());
|
||||
}
|
||||
|
||||
// Optionally allow potentially unsafe html blocks
|
||||
// See https://spec.commonmark.org/0.31.2/#html-blocks
|
||||
mod markdown_html_blocks {
|
||||
|
||||
use super::*;
|
||||
|
||||
const UNSAFE_MARKUP: &str = "<table><tr><td>";
|
||||
const ESCAPED_UNSAFE_MARKUP: &str = "<table><tr><td>";
|
||||
#[test]
|
||||
fn test_html_blocks_with_various_settings() {
|
||||
struct TestCase {
|
||||
name: &'static str,
|
||||
preset: Option<Value>,
|
||||
expected_output: Result<&'static str, String>,
|
||||
}
|
||||
|
||||
let helper = MarkdownHelper::default();
|
||||
let content = contents();
|
||||
|
||||
let test_cases = [
|
||||
TestCase {
|
||||
name: "default settings",
|
||||
preset: Some(Value::String("default".to_string())),
|
||||
expected_output: Ok(ESCAPED_UNSAFE_MARKUP),
|
||||
},
|
||||
TestCase {
|
||||
name: "allow_unsafe preset",
|
||||
preset: Some(Value::String("allow_unsafe".to_string())),
|
||||
expected_output: Ok(UNSAFE_MARKUP),
|
||||
},
|
||||
TestCase {
|
||||
name: "undefined allow_unsafe",
|
||||
preset: Some(Value::Null),
|
||||
expected_output: Err(
|
||||
"markdown template helper expects a string as preset name. Got: null"
|
||||
.to_string(),
|
||||
),
|
||||
},
|
||||
TestCase {
|
||||
name: "allow_unsafe is false",
|
||||
preset: Some(Value::Bool(false)),
|
||||
expected_output: Err(
|
||||
"markdown template helper expects a string as preset name. Got: false"
|
||||
.to_string(),
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
for case in test_cases {
|
||||
let args = match case.preset {
|
||||
None => &as_args(&content)[..],
|
||||
Some(ref preset) => &as_args_with_unsafe(&content, preset)[..],
|
||||
};
|
||||
|
||||
match helper.call(args) {
|
||||
Ok(actual) => assert_eq!(
|
||||
case.expected_output.unwrap(),
|
||||
actual.as_str().unwrap(),
|
||||
"Failed on case: {}",
|
||||
case.name
|
||||
),
|
||||
Err(e) => assert_eq!(
|
||||
case.expected_output.unwrap_err(),
|
||||
e,
|
||||
"Failed on case: {}",
|
||||
case.name
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn as_args_with_unsafe<'a>(
|
||||
contents: &'a Value,
|
||||
allow_unsafe: &'a Value,
|
||||
) -> [PathAndJson<'a>; 2] {
|
||||
[
|
||||
as_helper_arg(CONTENT_KEY, contents),
|
||||
as_helper_arg("allow_unsafe", allow_unsafe),
|
||||
]
|
||||
}
|
||||
|
||||
fn contents() -> Value {
|
||||
Value::String(UNSAFE_MARKUP.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn as_args(contents: &Value) -> [PathAndJson<'_>; 1] {
|
||||
[as_helper_arg(CONTENT_KEY, contents)]
|
||||
}
|
||||
|
||||
fn as_helper_arg<'a>(path: &'a str, value: &'a Value) -> PathAndJson<'a> {
|
||||
let json_context = as_json_context(path, value);
|
||||
to_path_and_json(path, json_context)
|
||||
}
|
||||
|
||||
fn to_path_and_json<'a>(path: &'a str, value: ScopedJson<'a>) -> PathAndJson<'a> {
|
||||
PathAndJson::new(Some(path.to_string()), value)
|
||||
}
|
||||
|
||||
fn as_json_context<'a>(path: &'a str, value: &'a Value) -> ScopedJson<'a> {
|
||||
ScopedJson::Context(value, vec![path.to_string()])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
use crate::app_config::AppConfig;
|
||||
use crate::file_cache::AsyncFromStrWithState;
|
||||
use crate::filesystem::FileAccess;
|
||||
use crate::template_helpers::register_all_helpers;
|
||||
use crate::{AppState, FileCache, TEMPLATES_DIR};
|
||||
use async_trait::async_trait;
|
||||
use handlebars::{Handlebars, Template, template::TemplateElement};
|
||||
use include_dir::{Dir, include_dir};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
pub struct SplitTemplate {
|
||||
pub before_list: Template,
|
||||
pub list_content: Template,
|
||||
pub after_list: Template,
|
||||
}
|
||||
|
||||
impl SplitTemplate {
|
||||
#[must_use]
|
||||
pub fn name(&self) -> Option<&str> {
|
||||
self.before_list.name.as_deref()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn split_template(mut original: Template) -> SplitTemplate {
|
||||
let mut elements_after = Vec::new();
|
||||
let mut mapping_after = Vec::new();
|
||||
let mut items_template = None;
|
||||
let found = original.elements.iter().position(is_template_list_item);
|
||||
if let Some(idx) = found {
|
||||
elements_after = original.elements.split_off(idx + 1);
|
||||
mapping_after = original.mapping.split_off(idx + 1);
|
||||
if let Some(TemplateElement::HelperBlock(tpl)) = original.elements.pop() {
|
||||
original.mapping.pop();
|
||||
items_template = tpl.template;
|
||||
}
|
||||
}
|
||||
let mut before_list = original.clone();
|
||||
let mut list_content = items_template.unwrap_or_default();
|
||||
let mut after_list = Template::new();
|
||||
let original_name = original.name.unwrap_or_default();
|
||||
before_list.name = Some(format!("{original_name} before each block"));
|
||||
list_content.name = Some(format!("{original_name} each block"));
|
||||
after_list.name = Some(format!("{original_name} after each block"));
|
||||
after_list.elements = elements_after;
|
||||
after_list.mapping = mapping_after;
|
||||
SplitTemplate {
|
||||
before_list,
|
||||
list_content,
|
||||
after_list,
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait(? Send)]
|
||||
impl AsyncFromStrWithState for SplitTemplate {
|
||||
async fn from_str_with_state(
|
||||
_app_state: &AppState,
|
||||
source: &str,
|
||||
source_path: &Path,
|
||||
) -> anyhow::Result<Self> {
|
||||
log::debug!("Compiling template \"{}\"", source_path.display());
|
||||
let tpl = Template::compile_with_name(source, "SQLPage component".to_string())?;
|
||||
Ok(split_template(tpl))
|
||||
}
|
||||
}
|
||||
|
||||
fn is_template_list_item(element: &TemplateElement) -> bool {
|
||||
use Parameter::Name;
|
||||
use handlebars::template::Parameter;
|
||||
matches!(element,
|
||||
TemplateElement::HelperBlock(tpl)
|
||||
if matches!(&tpl.name, Name(name) if name == "each_row"))
|
||||
}
|
||||
|
||||
#[allow(clippy::module_name_repetitions)]
|
||||
pub struct AllTemplates {
|
||||
pub handlebars: Handlebars<'static>,
|
||||
split_templates: FileCache<SplitTemplate>,
|
||||
}
|
||||
|
||||
const STATIC_TEMPLATES: Dir = include_dir!("$CARGO_MANIFEST_DIR/sqlpage/templates");
|
||||
|
||||
impl AllTemplates {
|
||||
pub fn init(config: &AppConfig) -> anyhow::Result<Self> {
|
||||
let mut handlebars = Handlebars::new();
|
||||
register_all_helpers(&mut handlebars, config);
|
||||
let mut this = Self {
|
||||
handlebars,
|
||||
split_templates: FileCache::new(),
|
||||
};
|
||||
this.preregister_static_templates()?;
|
||||
Ok(this)
|
||||
}
|
||||
|
||||
/// Embeds pre-defined templates directly in the binary in release mode
|
||||
pub fn preregister_static_templates(&mut self) -> anyhow::Result<()> {
|
||||
for file in STATIC_TEMPLATES.files() {
|
||||
let mut path = PathBuf::from(TEMPLATES_DIR);
|
||||
path.push(file.path());
|
||||
let name = file
|
||||
.path()
|
||||
.file_stem()
|
||||
.unwrap()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
let source = String::from_utf8_lossy(file.contents());
|
||||
let tpl = Template::compile_with_name(&source, name)?;
|
||||
let split_template = split_template(tpl);
|
||||
self.split_templates.add_static(path, split_template);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn template_path(name: &str) -> PathBuf {
|
||||
let mut path: PathBuf =
|
||||
PathBuf::with_capacity(TEMPLATES_DIR.len() + 1 + name.len() + ".handlebars".len());
|
||||
path.push(TEMPLATES_DIR);
|
||||
path.push(name);
|
||||
path.set_extension("handlebars");
|
||||
path
|
||||
}
|
||||
|
||||
pub async fn get_template(
|
||||
&self,
|
||||
app_state: &AppState,
|
||||
name: &str,
|
||||
) -> anyhow::Result<Arc<SplitTemplate>> {
|
||||
use anyhow::Context;
|
||||
let path = Self::template_path(name);
|
||||
self.split_templates
|
||||
.get(app_state, FileAccess::privileged(&path))
|
||||
.await
|
||||
.with_context(|| format!("Unable to get the component '{name}'"))
|
||||
}
|
||||
|
||||
pub fn get_static_template(&self, name: &str) -> anyhow::Result<Arc<SplitTemplate>> {
|
||||
let path = Self::template_path(name);
|
||||
self.split_templates.get_static(&path)
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn test_split_template() {
|
||||
let template = Template::compile(
|
||||
"Hello {{name}} ! \
|
||||
{{#each_row}}<li>{{this}}</li>{{/each_row}}\
|
||||
end",
|
||||
)
|
||||
.unwrap();
|
||||
let split = split_template(template);
|
||||
assert_eq!(
|
||||
split.before_list.elements,
|
||||
Template::compile("Hello {{name}} ! ").unwrap().elements
|
||||
);
|
||||
assert_eq!(
|
||||
split.list_content.elements,
|
||||
Template::compile("<li>{{this}}</li>").unwrap().elements
|
||||
);
|
||||
assert_eq!(
|
||||
split.after_list.elements,
|
||||
Template::compile("end").unwrap().elements
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
#[must_use]
|
||||
pub fn add_value_to_map(
|
||||
mut map: Map<String, Value>,
|
||||
(key, value): (String, Value),
|
||||
) -> Map<String, Value> {
|
||||
use Value::Array;
|
||||
use serde_json::map::Entry::{Occupied, Vacant};
|
||||
match map.entry(key) {
|
||||
Vacant(vacant) => {
|
||||
vacant.insert(value);
|
||||
}
|
||||
Occupied(mut old_entry) => {
|
||||
let mut new_array = if let Array(v) = value { v } else { vec![value] };
|
||||
match old_entry.get_mut() {
|
||||
Array(old_array) => old_array.append(&mut new_array),
|
||||
old_scalar => {
|
||||
new_array.insert(0, old_scalar.take());
|
||||
*old_scalar = Array(new_array);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
map
|
||||
}
|
||||
|
||||
macro_rules! static_filename {
|
||||
($filename:expr) => {
|
||||
include_str!(concat!(env!("OUT_DIR"), "/", $filename, ".filename.txt"))
|
||||
};
|
||||
}
|
||||
|
||||
pub(crate) use static_filename;
|
||||
@@ -0,0 +1,107 @@
|
||||
use actix_web::HttpResponseBuilder;
|
||||
use actix_web::http::header::CONTENT_SECURITY_POLICY;
|
||||
use rand::random;
|
||||
use serde::Deserialize;
|
||||
|
||||
pub const DEFAULT_CONTENT_SECURITY_POLICY: &str = "script-src 'self' 'nonce-{NONCE}'";
|
||||
pub const NONCE_PLACEHOLDER: &str = "{NONCE}";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ContentSecurityPolicy {
|
||||
pub nonce: u64,
|
||||
}
|
||||
|
||||
/// A template for the Content Security Policy header.
|
||||
/// The template is a string that contains the nonce placeholder.
|
||||
/// The nonce placeholder is replaced with the nonce value when the Content Security Policy is applied to a response.
|
||||
/// This struct is cheap to clone.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ContentSecurityPolicyTemplate {
|
||||
pub template: String,
|
||||
pub nonce_position: Option<usize>,
|
||||
}
|
||||
|
||||
impl ContentSecurityPolicyTemplate {
|
||||
#[must_use]
|
||||
pub fn is_enabled(&self) -> bool {
|
||||
self.nonce_position.is_some()
|
||||
}
|
||||
|
||||
fn format_nonce(&self, nonce: u64) -> String {
|
||||
if let Some(pos) = self.nonce_position {
|
||||
format!(
|
||||
"{}{}{}",
|
||||
&self.template[..pos],
|
||||
nonce,
|
||||
&self.template[pos + NONCE_PLACEHOLDER.len()..]
|
||||
)
|
||||
} else {
|
||||
self.template.clone()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ContentSecurityPolicyTemplate {
|
||||
fn default() -> Self {
|
||||
Self::from(DEFAULT_CONTENT_SECURITY_POLICY)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for ContentSecurityPolicyTemplate {
|
||||
fn from(s: &str) -> Self {
|
||||
let nonce_position = s.find(NONCE_PLACEHOLDER);
|
||||
Self {
|
||||
template: s.to_owned(),
|
||||
nonce_position,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for ContentSecurityPolicyTemplate {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let s: String = Deserialize::deserialize(deserializer)?;
|
||||
Ok(Self::from(s.as_str()))
|
||||
}
|
||||
}
|
||||
|
||||
impl ContentSecurityPolicy {
|
||||
#[must_use]
|
||||
pub fn with_random_nonce() -> Self {
|
||||
Self { nonce: random() }
|
||||
}
|
||||
|
||||
pub fn apply_to_response(
|
||||
&self,
|
||||
template: &ContentSecurityPolicyTemplate,
|
||||
response: &mut HttpResponseBuilder,
|
||||
) {
|
||||
if template.is_enabled() {
|
||||
response.insert_header((CONTENT_SECURITY_POLICY, template.format_nonce(self.nonce)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_content_security_policy_display() {
|
||||
let template = ContentSecurityPolicyTemplate::from(
|
||||
"script-src 'self' 'nonce-{NONCE}' 'unsafe-inline'",
|
||||
);
|
||||
let csp = ContentSecurityPolicy::with_random_nonce();
|
||||
let csp_str = template.format_nonce(csp.nonce);
|
||||
assert!(csp_str.starts_with("script-src 'self' 'nonce-"));
|
||||
assert!(csp_str.ends_with("' 'unsafe-inline'"));
|
||||
let second_csp = ContentSecurityPolicy::with_random_nonce();
|
||||
let second_csp_str = template.format_nonce(second_csp.nonce);
|
||||
assert_ne!(
|
||||
csp_str, second_csp_str,
|
||||
"We should not generate the same nonce twice"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
/// Detects MIME type based on file signatures (magic bytes).
|
||||
/// Returns the most appropriate MIME type for common file formats.
|
||||
#[must_use]
|
||||
pub fn detect_mime_type(bytes: &[u8]) -> &'static str {
|
||||
// PNG: 89 50 4E 47 0D 0A 1A 0A
|
||||
if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
|
||||
return "image/png";
|
||||
}
|
||||
// JPEG: FF D8
|
||||
if bytes.starts_with(b"\xFF\xD8") {
|
||||
return "image/jpeg";
|
||||
}
|
||||
// GIF87a/89a: GIF87a or GIF89a
|
||||
if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") {
|
||||
return "image/gif";
|
||||
}
|
||||
// BMP: 42 4D
|
||||
if bytes.starts_with(b"BM") {
|
||||
return "image/bmp";
|
||||
}
|
||||
// WebP: RIFF....WEBP
|
||||
if bytes.starts_with(b"RIFF") && bytes.len() >= 12 && &bytes[8..12] == b"WEBP" {
|
||||
return "image/webp";
|
||||
}
|
||||
// PDF: %PDF
|
||||
if bytes.starts_with(b"%PDF") {
|
||||
return "application/pdf";
|
||||
}
|
||||
// ZIP: 50 4B 03 04
|
||||
if bytes.starts_with(b"PK\x03\x04") {
|
||||
// Check for Office document types in ZIP central directory
|
||||
if bytes.len() >= 50 {
|
||||
let central_dir = &bytes[30..bytes.len().min(50)];
|
||||
if central_dir.windows(6).any(|w| w == b"word/") {
|
||||
return "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
|
||||
}
|
||||
if central_dir.windows(3).any(|w| w == b"xl/") {
|
||||
return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
|
||||
}
|
||||
if central_dir.windows(4).any(|w| w == b"ppt/") {
|
||||
return "application/vnd.openxmlformats-officedocument.presentationml.presentation";
|
||||
}
|
||||
}
|
||||
return "application/zip";
|
||||
}
|
||||
|
||||
if bytes.starts_with(b"<?xml") {
|
||||
return "application/xml";
|
||||
}
|
||||
if bytes.starts_with(b"<svg") || bytes.starts_with(b"<!DOCTYPE svg") {
|
||||
return "image/svg+xml";
|
||||
}
|
||||
if bytes.starts_with(b"{") || bytes.starts_with(b"[") {
|
||||
return "application/json";
|
||||
}
|
||||
|
||||
"application/octet-stream"
|
||||
}
|
||||
|
||||
/// Converts binary data to a data URL string.
|
||||
/// This function is used by both SQL type conversion and file reading functions.
|
||||
/// Automatically detects common file types based on magic bytes.
|
||||
#[must_use]
|
||||
pub fn vec_to_data_uri(bytes: &[u8]) -> String {
|
||||
let mime_type = detect_mime_type(bytes);
|
||||
vec_to_data_uri_with_mime(bytes, mime_type)
|
||||
}
|
||||
|
||||
/// Converts binary data to a data URL string with a specific MIME type.
|
||||
/// This function is used by both SQL type conversion and file reading functions.
|
||||
#[must_use]
|
||||
pub fn vec_to_data_uri_with_mime(bytes: &[u8], mime_type: &str) -> String {
|
||||
let mut data_url = format!("data:{mime_type};base64,");
|
||||
base64::Engine::encode_string(
|
||||
&base64::engine::general_purpose::STANDARD,
|
||||
bytes,
|
||||
&mut data_url,
|
||||
);
|
||||
data_url
|
||||
}
|
||||
|
||||
/// Converts binary data to a data URL JSON value.
|
||||
/// This is a convenience function for SQL type conversion.
|
||||
#[must_use]
|
||||
pub fn vec_to_data_uri_value(bytes: &[u8]) -> serde_json::Value {
|
||||
serde_json::Value::String(vec_to_data_uri(bytes))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_detect_mime_type() {
|
||||
// Test empty data
|
||||
assert_eq!(detect_mime_type(&[]), "application/octet-stream");
|
||||
|
||||
// Test PNG
|
||||
assert_eq!(detect_mime_type(b"\x89PNG\r\n\x1a\n"), "image/png");
|
||||
|
||||
// Test JPEG
|
||||
assert_eq!(detect_mime_type(b"\xFF\xD8\xFF\xE0"), "image/jpeg");
|
||||
|
||||
// Test GIF87a
|
||||
assert_eq!(detect_mime_type(b"GIF87a"), "image/gif");
|
||||
|
||||
// Test GIF89a
|
||||
assert_eq!(detect_mime_type(b"GIF89a"), "image/gif");
|
||||
|
||||
// Test BMP
|
||||
assert_eq!(detect_mime_type(b"BM\x00\x00"), "image/bmp");
|
||||
|
||||
// Test PDF
|
||||
assert_eq!(detect_mime_type(b"%PDF-"), "application/pdf");
|
||||
|
||||
// Test SVG
|
||||
assert_eq!(
|
||||
detect_mime_type(b"<svg xmlns=\"http://www.w3.org/2000/svg\">"),
|
||||
"image/svg+xml"
|
||||
);
|
||||
|
||||
// Test XML (non-SVG)
|
||||
assert_eq!(
|
||||
detect_mime_type(b"<?xml version=\"1.0\"?><root><data>test</data></root>"),
|
||||
"application/xml"
|
||||
);
|
||||
|
||||
// Test JSON
|
||||
assert_eq!(
|
||||
detect_mime_type(b"{\"key\": \"value\"}"),
|
||||
"application/json"
|
||||
);
|
||||
|
||||
// Test ZIP
|
||||
assert_eq!(detect_mime_type(b"PK\x03\x04"), "application/zip");
|
||||
|
||||
// Test unknown data
|
||||
assert_eq!(
|
||||
detect_mime_type(&[0x00, 0x01, 0x02, 0x03]),
|
||||
"application/octet-stream"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_vec_to_data_uri() {
|
||||
// Test with empty bytes
|
||||
let result = vec_to_data_uri(&[]);
|
||||
assert_eq!(result, "data:application/octet-stream;base64,");
|
||||
|
||||
// Test with simple text
|
||||
let result = vec_to_data_uri(b"Hello World");
|
||||
assert_eq!(
|
||||
result,
|
||||
"data:application/octet-stream;base64,SGVsbG8gV29ybGQ="
|
||||
);
|
||||
|
||||
// Test with binary data
|
||||
let binary_data = [0, 1, 2, 255, 254, 253];
|
||||
let result = vec_to_data_uri(&binary_data);
|
||||
assert_eq!(result, "data:application/octet-stream;base64,AAEC//79");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_vec_to_data_uri_with_mime() {
|
||||
// Test with custom MIME type
|
||||
let result = vec_to_data_uri_with_mime(b"Hello", "text/plain");
|
||||
assert_eq!(result, "data:text/plain;base64,SGVsbG8=");
|
||||
|
||||
// Test with image MIME type
|
||||
let result = vec_to_data_uri_with_mime(&[255, 216, 255], "image/jpeg");
|
||||
assert_eq!(result, "data:image/jpeg;base64,/9j/");
|
||||
|
||||
// Test with empty bytes and custom MIME
|
||||
let result = vec_to_data_uri_with_mime(&[], "application/json");
|
||||
assert_eq!(result, "data:application/json;base64,");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_vec_to_data_uri_value() {
|
||||
// Test that it returns a JSON string value
|
||||
let result = vec_to_data_uri_value(b"test");
|
||||
match result {
|
||||
serde_json::Value::String(s) => {
|
||||
assert_eq!(s, "data:application/octet-stream;base64,dGVzdA==");
|
||||
}
|
||||
_ => panic!("Expected String value"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
use std::{mem::take, time::Duration};
|
||||
|
||||
use super::Database;
|
||||
use crate::{
|
||||
ON_CONNECT_FILE, ON_RESET_FILE,
|
||||
app_config::AppConfig,
|
||||
webserver::database::{DbInfo, SupportedDatabase},
|
||||
};
|
||||
use anyhow::Context;
|
||||
use futures_util::future::BoxFuture;
|
||||
use sqlx::odbc::OdbcConnectOptions;
|
||||
use sqlx::{
|
||||
ConnectOptions, Connection, Executor,
|
||||
any::{Any, AnyConnectOptions, AnyConnection, AnyKind},
|
||||
pool::PoolOptions,
|
||||
sqlite::{Function, SqliteConnectOptions, SqliteFunctionCtx},
|
||||
};
|
||||
|
||||
impl Database {
|
||||
pub async fn init(config: &AppConfig) -> anyhow::Result<Self> {
|
||||
let database_url = &config.database_url;
|
||||
let mut connect_options: AnyConnectOptions = database_url
|
||||
.parse()
|
||||
.with_context(|| format!("\"{database_url}\" is not a valid database URL. Please change the \"database_url\" option in the configuration file."))?;
|
||||
if let Some(password) = &config.database_password {
|
||||
set_database_password(&mut connect_options, password);
|
||||
}
|
||||
connect_options.log_statements(log::LevelFilter::Trace);
|
||||
connect_options.log_slow_statements(
|
||||
log::LevelFilter::Warn,
|
||||
std::time::Duration::from_millis(250),
|
||||
);
|
||||
log::debug!(
|
||||
"Connecting to a {:?} database on {}",
|
||||
connect_options.kind(),
|
||||
database_url
|
||||
);
|
||||
set_custom_connect_options(&mut connect_options, config);
|
||||
log::debug!("Connecting to database: {database_url}");
|
||||
let mut retries = config.database_connection_retries;
|
||||
|
||||
let mut conn: AnyConnection = loop {
|
||||
match AnyConnection::connect_with(&connect_options).await {
|
||||
Ok(c) => break c,
|
||||
Err(e) => {
|
||||
if retries == 0 {
|
||||
return Err(anyhow::Error::new(e)
|
||||
.context(format!("Unable to open connection to {database_url}")));
|
||||
}
|
||||
log::warn!("Failed to connect to the database: {e:#}. Retrying in 5 seconds.");
|
||||
retries -= 1;
|
||||
tokio::time::sleep(Duration::from_secs(5)).await;
|
||||
}
|
||||
}
|
||||
};
|
||||
let dbms_name: String = conn.dbms_name().await?;
|
||||
let database_type = SupportedDatabase::from_dbms_name(&dbms_name);
|
||||
drop(conn);
|
||||
|
||||
let db_kind = connect_options.kind();
|
||||
let pool = Self::create_pool_options(config, db_kind)
|
||||
.connect_with(connect_options)
|
||||
.await
|
||||
.with_context(|| format!("Unable to open connection pool to {database_url}"))?;
|
||||
|
||||
log::debug!("Initialized {dbms_name:?} database pool: {pool:#?}");
|
||||
Ok(Database {
|
||||
connection: pool,
|
||||
info: DbInfo {
|
||||
dbms_name,
|
||||
database_type,
|
||||
kind: db_kind,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn create_pool_options(config: &AppConfig, kind: AnyKind) -> PoolOptions<Any> {
|
||||
let mut pool_options = PoolOptions::new()
|
||||
.max_connections(if let Some(max) = config.max_database_pool_connections {
|
||||
max
|
||||
} else {
|
||||
// Different databases have a different number of max concurrent connections allowed by default
|
||||
match kind {
|
||||
AnyKind::Postgres | AnyKind::Odbc => 50, // Default to PostgreSQL-like limits for Generic
|
||||
AnyKind::MySql => 75,
|
||||
AnyKind::Sqlite => {
|
||||
if config.database_url.contains(":memory:") {
|
||||
128
|
||||
} else {
|
||||
16
|
||||
}
|
||||
}
|
||||
AnyKind::Mssql => 100,
|
||||
}
|
||||
})
|
||||
.idle_timeout(config.database_connection_idle_timeout)
|
||||
.max_lifetime(config.database_connection_max_lifetime)
|
||||
.acquire_timeout(Duration::from_secs_f64(
|
||||
config.database_connection_acquire_timeout_seconds,
|
||||
));
|
||||
pool_options = add_on_return_to_pool(config, pool_options);
|
||||
pool_options = add_on_connection_handler(config, pool_options);
|
||||
pool_options
|
||||
}
|
||||
}
|
||||
|
||||
fn add_on_return_to_pool(config: &AppConfig, pool_options: PoolOptions<Any>) -> PoolOptions<Any> {
|
||||
let on_disconnect_file = config.configuration_directory.join(ON_RESET_FILE);
|
||||
let sql = if on_disconnect_file.exists() {
|
||||
log::info!(
|
||||
"Creating a custom SQL connection cleanup handler from {}",
|
||||
on_disconnect_file.display()
|
||||
);
|
||||
match std::fs::read_to_string(&on_disconnect_file) {
|
||||
Ok(sql) => {
|
||||
log::trace!("The custom SQL connection cleanup handler is:\n{sql}");
|
||||
Some(std::sync::Arc::new(sql))
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!(
|
||||
"Unable to read the file {}: {}",
|
||||
on_disconnect_file.display(),
|
||||
e
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log::debug!(
|
||||
"Not creating a custom SQL connection cleanup handler because {} does not exist",
|
||||
on_disconnect_file.display()
|
||||
);
|
||||
None
|
||||
};
|
||||
|
||||
pool_options.after_release(move |conn, meta| {
|
||||
let sql = sql.clone();
|
||||
Box::pin(async move {
|
||||
if let Some(sql) = sql {
|
||||
on_return_to_pool(conn, meta, sql).await
|
||||
} else {
|
||||
Ok(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn on_return_to_pool(
|
||||
conn: &mut sqlx::AnyConnection,
|
||||
meta: sqlx::pool::PoolConnectionMetadata,
|
||||
sql: std::sync::Arc<String>,
|
||||
) -> BoxFuture<'_, Result<bool, sqlx::Error>> {
|
||||
use sqlx::Row;
|
||||
Box::pin(async move {
|
||||
log::trace!("Running the custom SQL connection cleanup handler. {meta:?}");
|
||||
let query_result = conn.fetch_optional(sql.as_str()).await?;
|
||||
if let Some(query_result) = query_result {
|
||||
let is_healthy = query_result.try_get::<bool, _>(0);
|
||||
log::debug!("Is the connection healthy? {is_healthy:?}");
|
||||
is_healthy
|
||||
} else {
|
||||
log::debug!("No result from the custom SQL connection cleanup handler");
|
||||
Ok(true)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn add_on_connection_handler(
|
||||
config: &AppConfig,
|
||||
pool_options: PoolOptions<Any>,
|
||||
) -> PoolOptions<Any> {
|
||||
let on_connect_file = config.configuration_directory.join(ON_CONNECT_FILE);
|
||||
let on_connect_file_display = on_connect_file.display().to_string();
|
||||
let sql = if on_connect_file.exists() {
|
||||
log::info!(
|
||||
"Creating a custom SQL database connection handler from {}",
|
||||
on_connect_file.display()
|
||||
);
|
||||
match std::fs::read_to_string(&on_connect_file) {
|
||||
Ok(sql) => {
|
||||
log::trace!("The custom SQL database connection handler is:\n{sql}");
|
||||
Some(std::sync::Arc::new(sql))
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!(
|
||||
"Unable to read the file {}: {}",
|
||||
on_connect_file.display(),
|
||||
e
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log::debug!(
|
||||
"Not creating a custom SQL database connection handler because {} does not exist",
|
||||
on_connect_file.display()
|
||||
);
|
||||
None
|
||||
};
|
||||
|
||||
pool_options.after_connect(move |conn, _| {
|
||||
let sql = sql.clone();
|
||||
let on_connect_file_display = on_connect_file_display.clone();
|
||||
Box::pin(async move {
|
||||
if let Some(sql) = sql {
|
||||
log::debug!("Running {on_connect_file_display} on new connection");
|
||||
let r = conn.execute(sql.as_str()).await?;
|
||||
log::debug!("Finished running connection handler on new connection: {r:?}");
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn set_custom_connect_options(options: &mut AnyConnectOptions, config: &AppConfig) {
|
||||
if let Some(sqlite_options) = options.as_sqlite_mut() {
|
||||
set_custom_connect_options_sqlite(sqlite_options, config);
|
||||
}
|
||||
|
||||
if let Some(odbc_options) = options.as_odbc_mut() {
|
||||
set_custom_connect_options_odbc(odbc_options, config);
|
||||
}
|
||||
}
|
||||
|
||||
fn set_custom_connect_options_sqlite(
|
||||
sqlite_options: &mut SqliteConnectOptions,
|
||||
config: &AppConfig,
|
||||
) {
|
||||
for extension_name in &config.sqlite_extensions {
|
||||
log::info!("Loading SQLite extension: {extension_name}");
|
||||
*sqlite_options = std::mem::take(sqlite_options).extension(extension_name.clone());
|
||||
}
|
||||
*sqlite_options = std::mem::take(sqlite_options)
|
||||
.collation("NOCASE", |a, b| a.to_lowercase().cmp(&b.to_lowercase()))
|
||||
.function(make_sqlite_fun("upper", str::to_uppercase))
|
||||
.function(make_sqlite_fun("lower", str::to_lowercase));
|
||||
}
|
||||
|
||||
fn make_sqlite_fun(name: &str, f: fn(&str) -> String) -> Function {
|
||||
Function::new(name, move |ctx: &SqliteFunctionCtx| {
|
||||
let arg = ctx.try_get_arg::<Option<&str>>(0);
|
||||
match arg {
|
||||
Ok(Some(s)) => ctx.set_result(f(s)),
|
||||
Ok(None) => ctx.set_result(None::<String>),
|
||||
Err(e) => ctx.set_error(&e.to_string()),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn set_custom_connect_options_odbc(odbc_options: &mut OdbcConnectOptions, config: &AppConfig) {
|
||||
// Allow fetching very large text fields when using ODBC by removing the max column size limit
|
||||
let batch_size = config.max_pending_rows.clamp(1, 1024);
|
||||
odbc_options.batch_size(batch_size);
|
||||
log::trace!("ODBC batch size set to {batch_size}");
|
||||
// Disables ODBC batching, but avoids truncation of large text fields
|
||||
odbc_options.max_column_size(None);
|
||||
}
|
||||
|
||||
fn set_database_password(options: &mut AnyConnectOptions, password: &str) {
|
||||
if let Some(opts) = options.as_postgres_mut() {
|
||||
*opts = take(opts).password(password);
|
||||
} else if let Some(opts) = options.as_mysql_mut() {
|
||||
*opts = take(opts).password(password);
|
||||
} else if let Some(opts) = options.as_mssql_mut() {
|
||||
*opts = take(opts).password(password);
|
||||
} else if let Some(_opts) = options.as_odbc_mut() {
|
||||
log::warn!(
|
||||
"Setting a password for an ODBC connection is not supported via separate config; include credentials in the DSN or connection string"
|
||||
);
|
||||
} else if let Some(_opts) = options.as_sqlite_mut() {
|
||||
log::warn!("Setting a password for a SQLite database is not supported");
|
||||
} else {
|
||||
unreachable!("Unsupported database type");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use anyhow::Context;
|
||||
use futures_util::StreamExt;
|
||||
use sqlparser::ast::{
|
||||
CopyLegacyCsvOption, CopyLegacyOption, CopyOption, CopySource, CopyTarget, Statement,
|
||||
};
|
||||
use sqlx::{
|
||||
AnyConnection, Arguments, Executor, PgConnection,
|
||||
any::{AnyArguments, AnyConnectionKind, AnyKind},
|
||||
};
|
||||
use tokio::io::AsyncRead;
|
||||
|
||||
use crate::webserver::http_request_info::RequestInfo;
|
||||
|
||||
use super::make_placeholder;
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub(super) struct CsvImport {
|
||||
/// Used only in postgres
|
||||
pub query: String,
|
||||
pub table_name: String,
|
||||
pub columns: Vec<String>,
|
||||
pub delimiter: Option<char>,
|
||||
pub quote: Option<char>,
|
||||
// If true, the first line of the CSV file will be interpreted as a header
|
||||
// If false, then the column order will be determined by the order of the columns in the table
|
||||
pub header: Option<bool>,
|
||||
// A string that will be interpreted as null
|
||||
pub null_str: Option<String>,
|
||||
pub escape: Option<char>,
|
||||
/// Reference the the uploaded file name
|
||||
pub uploaded_file: String,
|
||||
}
|
||||
|
||||
enum CopyCsvOption<'a> {
|
||||
Legacy(&'a sqlparser::ast::CopyLegacyOption),
|
||||
CopyLegacyCsvOption(&'a sqlparser::ast::CopyLegacyCsvOption),
|
||||
New(&'a sqlparser::ast::CopyOption),
|
||||
}
|
||||
|
||||
impl CopyCsvOption<'_> {
|
||||
fn delimiter(&self) -> Option<char> {
|
||||
match self {
|
||||
CopyCsvOption::Legacy(CopyLegacyOption::Delimiter(c))
|
||||
| CopyCsvOption::New(CopyOption::Delimiter(c)) => Some(*c),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn quote(&self) -> Option<char> {
|
||||
match self {
|
||||
CopyCsvOption::CopyLegacyCsvOption(CopyLegacyCsvOption::Quote(c))
|
||||
| CopyCsvOption::New(CopyOption::Quote(c)) => Some(*c),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn header(&self) -> Option<bool> {
|
||||
match self {
|
||||
CopyCsvOption::CopyLegacyCsvOption(CopyLegacyCsvOption::Header) => Some(true),
|
||||
CopyCsvOption::New(CopyOption::Header(b)) => Some(*b),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn null(&self) -> Option<String> {
|
||||
match self {
|
||||
CopyCsvOption::New(CopyOption::Null(s)) => Some(s.clone()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn escape(&self) -> Option<char> {
|
||||
match self {
|
||||
CopyCsvOption::New(CopyOption::Escape(c))
|
||||
| CopyCsvOption::CopyLegacyCsvOption(CopyLegacyCsvOption::Escape(c)) => Some(*c),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn extract_csv_copy_statement(stmt: &mut Statement) -> Option<CsvImport> {
|
||||
if let Statement::Copy {
|
||||
source: CopySource::Table {
|
||||
table_name,
|
||||
columns,
|
||||
},
|
||||
to: false,
|
||||
target: source,
|
||||
options,
|
||||
legacy_options,
|
||||
values,
|
||||
} = stmt
|
||||
{
|
||||
if !values.is_empty() {
|
||||
log::warn!("COPY ... VALUES not compatible with SQLPage: {stmt}");
|
||||
return None;
|
||||
}
|
||||
let uploaded_file = match std::mem::replace(source, CopyTarget::Stdin) {
|
||||
CopyTarget::File { filename } => filename,
|
||||
other => {
|
||||
log::warn!("COPY from {other} not compatible with SQLPage: {stmt}");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
let all_options: Vec<CopyCsvOption> = legacy_options
|
||||
.iter()
|
||||
.flat_map(|o| match o {
|
||||
CopyLegacyOption::Csv(o) => {
|
||||
o.iter().map(CopyCsvOption::CopyLegacyCsvOption).collect()
|
||||
}
|
||||
o => vec![CopyCsvOption::Legacy(o)],
|
||||
})
|
||||
.chain(options.iter().map(CopyCsvOption::New))
|
||||
.collect();
|
||||
|
||||
let table_name = table_name.to_string();
|
||||
let columns = columns.iter().map(|ident| ident.value.clone()).collect();
|
||||
let delimiter = all_options.iter().find_map(CopyCsvOption::delimiter);
|
||||
let quote = all_options.iter().find_map(CopyCsvOption::quote);
|
||||
let header = all_options.iter().find_map(CopyCsvOption::header);
|
||||
let null = all_options.iter().find_map(CopyCsvOption::null);
|
||||
let escape = all_options.iter().find_map(CopyCsvOption::escape);
|
||||
let query = stmt.to_string();
|
||||
|
||||
Some(CsvImport {
|
||||
query,
|
||||
table_name,
|
||||
columns,
|
||||
delimiter,
|
||||
quote,
|
||||
header,
|
||||
null_str: null,
|
||||
escape,
|
||||
uploaded_file,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn run_csv_import(
|
||||
db: &mut AnyConnection,
|
||||
csv_import: &CsvImport,
|
||||
request: &RequestInfo,
|
||||
) -> anyhow::Result<()> {
|
||||
let named_temp_file = &request
|
||||
.uploaded_files
|
||||
.get(&csv_import.uploaded_file)
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"The request does not contain a field named {:?} with an uploaded file.\n\
|
||||
Please check that :\n\
|
||||
- you have selected a file to upload, \n\
|
||||
- the form field name is correct.",
|
||||
csv_import.uploaded_file
|
||||
)
|
||||
})?
|
||||
.file;
|
||||
let file_path = named_temp_file.path();
|
||||
let file = tokio::fs::File::open(file_path).await.with_context(|| {
|
||||
format!(
|
||||
"The CSV file {} was uploaded correctly, but could not be opened",
|
||||
file_path.display()
|
||||
)
|
||||
})?;
|
||||
let buffered = tokio::io::BufReader::new(file);
|
||||
// private_get_mut is not supposed to be used outside of sqlx, but it is the only way to
|
||||
// access the underlying connection
|
||||
match db.private_get_mut() {
|
||||
AnyConnectionKind::Postgres(pg_connection) => {
|
||||
run_csv_import_postgres(pg_connection, csv_import, buffered).await
|
||||
}
|
||||
_ => run_csv_import_insert(db, csv_import, buffered).await,
|
||||
}
|
||||
.with_context(|| {
|
||||
let table_name = &csv_import.table_name;
|
||||
format!(
|
||||
"{} was uploaded correctly, but its records could not be imported into the table {}",
|
||||
file_path.display(),
|
||||
table_name
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// This function does not parse the CSV file, it only sends it to postgres.
|
||||
/// This is the fastest way to import a CSV file into postgres
|
||||
async fn run_csv_import_postgres(
|
||||
db: &mut PgConnection,
|
||||
csv_import: &CsvImport,
|
||||
file: impl AsyncRead + Unpin + Send,
|
||||
) -> anyhow::Result<()> {
|
||||
log::debug!("Running CSV import with postgres");
|
||||
let mut copy_transact = db
|
||||
.copy_in_raw(csv_import.query.as_str())
|
||||
.await
|
||||
.with_context(|| "The postgres COPY FROM STDIN command failed.")?;
|
||||
log::debug!("Copy transaction created");
|
||||
match copy_transact.read_from(file).await {
|
||||
Ok(_) => {
|
||||
log::debug!("Copy transaction finished successfully");
|
||||
copy_transact.finish().await?;
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
log::debug!("Copy transaction failed with error: {e}");
|
||||
copy_transact
|
||||
.abort("The COPY FROM STDIN command failed.")
|
||||
.await?;
|
||||
Err(e.into())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_csv_import_insert(
|
||||
db: &mut AnyConnection,
|
||||
csv_import: &CsvImport,
|
||||
file: impl AsyncRead + Unpin + Send,
|
||||
) -> anyhow::Result<()> {
|
||||
let insert_stmt = create_insert_stmt(db.kind(), csv_import);
|
||||
log::debug!("CSV data insert statement: {insert_stmt}");
|
||||
let mut reader = make_csv_reader(csv_import, file);
|
||||
let col_idxs = compute_column_indices(&mut reader, csv_import).await?;
|
||||
let mut records = reader.into_records();
|
||||
while let Some(record) = records.next().await {
|
||||
let r = record.with_context(|| "reading csv record")?;
|
||||
process_csv_record(r, db, &insert_stmt, csv_import, &col_idxs).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn compute_column_indices<R: AsyncRead + Unpin + Send>(
|
||||
reader: &mut csv_async::AsyncReader<R>,
|
||||
csv_import: &CsvImport,
|
||||
) -> anyhow::Result<Vec<usize>> {
|
||||
let mut col_idxs = Vec::with_capacity(csv_import.columns.len());
|
||||
if csv_import.header.unwrap_or(true) {
|
||||
let headers = reader
|
||||
.headers()
|
||||
.await?
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, h)| (h, i))
|
||||
.collect::<HashMap<&str, usize>>();
|
||||
for column in &csv_import.columns {
|
||||
let &idx = headers
|
||||
.get(column.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("CSV Column not found: {column}"))?;
|
||||
col_idxs.push(idx);
|
||||
}
|
||||
} else {
|
||||
col_idxs.extend(0..csv_import.columns.len());
|
||||
}
|
||||
Ok(col_idxs)
|
||||
}
|
||||
|
||||
fn create_insert_stmt(db_kind: AnyKind, csv_import: &CsvImport) -> String {
|
||||
let columns = csv_import.columns.join(", ");
|
||||
let placeholders = csv_import
|
||||
.columns
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, _)| make_placeholder(db_kind, i + 1))
|
||||
.fold(String::new(), |mut acc, f| {
|
||||
if !acc.is_empty() {
|
||||
acc.push_str(", ");
|
||||
}
|
||||
acc.push_str(&f);
|
||||
acc
|
||||
});
|
||||
let table_name = &csv_import.table_name;
|
||||
format!("INSERT INTO {table_name} ({columns}) VALUES ({placeholders})")
|
||||
}
|
||||
|
||||
async fn process_csv_record(
|
||||
record: csv_async::StringRecord,
|
||||
db: &mut AnyConnection,
|
||||
insert_stmt: &str,
|
||||
csv_import: &CsvImport,
|
||||
column_indices: &[usize],
|
||||
) -> anyhow::Result<()> {
|
||||
let mut arguments = AnyArguments::default();
|
||||
let null_str = csv_import.null_str.as_deref().unwrap_or_default();
|
||||
for (&i, column) in column_indices.iter().zip(csv_import.columns.iter()) {
|
||||
let value = record.get(i).unwrap_or_default();
|
||||
let value = if value == null_str { None } else { Some(value) };
|
||||
log::trace!("CSV value: {column}={value:?}");
|
||||
arguments.add(value);
|
||||
}
|
||||
db.execute((insert_stmt, Some(arguments))).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn make_csv_reader<R: AsyncRead + Unpin + Send>(
|
||||
csv_import: &CsvImport,
|
||||
file: R,
|
||||
) -> csv_async::AsyncReader<R> {
|
||||
let delimiter = csv_import
|
||||
.delimiter
|
||||
.and_then(|c| u8::try_from(c).ok())
|
||||
.unwrap_or(b',');
|
||||
let quote = csv_import
|
||||
.quote
|
||||
.and_then(|c| u8::try_from(c).ok())
|
||||
.unwrap_or(b'"');
|
||||
let has_headers = csv_import.header.unwrap_or(true);
|
||||
let escape = csv_import.escape.and_then(|c| u8::try_from(c).ok());
|
||||
csv_async::AsyncReaderBuilder::new()
|
||||
.delimiter(delimiter)
|
||||
.quote(quote)
|
||||
.has_headers(has_headers)
|
||||
.escape(escape)
|
||||
.create_reader(file)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_make_statement() {
|
||||
let csv_import = CsvImport {
|
||||
query: "COPY my_table (col1, col2) FROM 'my_file.csv' WITH (DELIMITER ';', HEADER)".into(),
|
||||
table_name: "my_table".into(),
|
||||
columns: vec!["col1".into(), "col2".into()],
|
||||
delimiter: Some(';'),
|
||||
quote: None,
|
||||
header: Some(true),
|
||||
null_str: None,
|
||||
escape: None,
|
||||
uploaded_file: "my_file.csv".into(),
|
||||
};
|
||||
let insert_stmt = create_insert_stmt(AnyKind::Postgres, &csv_import);
|
||||
assert_eq!(
|
||||
insert_stmt,
|
||||
"INSERT INTO my_table (col1, col2) VALUES ($1, $2)"
|
||||
);
|
||||
}
|
||||
|
||||
#[actix_web::test]
|
||||
async fn test_end_to_end() {
|
||||
use sqlx::ConnectOptions;
|
||||
|
||||
let mut copy_stmt = sqlparser::parser::Parser::parse_sql(
|
||||
&sqlparser::dialect::GenericDialect {},
|
||||
"COPY my_table (col1, col2) FROM 'my_file.csv' (DELIMITER ';', HEADER)",
|
||||
)
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.next()
|
||||
.unwrap();
|
||||
let csv_import = extract_csv_copy_statement(&mut copy_stmt).unwrap();
|
||||
assert_eq!(
|
||||
csv_import,
|
||||
CsvImport {
|
||||
query: "COPY my_table (col1, col2) FROM STDIN (DELIMITER ';', HEADER)".into(),
|
||||
table_name: "my_table".into(),
|
||||
columns: vec!["col1".into(), "col2".into()],
|
||||
delimiter: Some(';'),
|
||||
quote: None,
|
||||
header: Some(true),
|
||||
null_str: None,
|
||||
escape: None,
|
||||
uploaded_file: "my_file.csv".into(),
|
||||
}
|
||||
);
|
||||
let mut conn = "sqlite::memory:"
|
||||
.parse::<sqlx::any::AnyConnectOptions>()
|
||||
.unwrap()
|
||||
.connect()
|
||||
.await
|
||||
.unwrap();
|
||||
conn.execute("CREATE TABLE my_table (col1 TEXT, col2 TEXT)")
|
||||
.await
|
||||
.unwrap();
|
||||
let csv = "col2;col1\na;b\nc;d"; // order is different from the table
|
||||
let file = csv.as_bytes();
|
||||
run_csv_import_insert(&mut conn, &csv_import, file)
|
||||
.await
|
||||
.unwrap();
|
||||
let rows: Vec<(String, String)> = sqlx::query_as("SELECT * FROM my_table")
|
||||
.fetch_all(&mut conn)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
rows,
|
||||
vec![("b".into(), "a".into()), ("d".into(), "c".into())]
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
use std::{
|
||||
fmt::Write,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use super::sql::{SourceSpan, StmtWithParams};
|
||||
|
||||
#[derive(Debug)]
|
||||
struct NiceDatabaseError {
|
||||
/// The source file that contains the query.
|
||||
source_file: PathBuf,
|
||||
/// The error that occurred.
|
||||
db_err: sqlx::error::Error,
|
||||
/// The query that was executed.
|
||||
query: String,
|
||||
/// The start location of the query in the source file, if the query was extracted from a larger file.
|
||||
query_position: Option<SourceSpan>,
|
||||
}
|
||||
|
||||
fn write_source_position_info(
|
||||
f: &mut std::fmt::Formatter<'_>,
|
||||
source_file: &Path,
|
||||
query_position: Option<SourceSpan>,
|
||||
) -> Result<(), std::fmt::Error> {
|
||||
write!(f, "\n{}", source_file.display())?;
|
||||
if let Some(query_position) = query_position {
|
||||
let start_line = query_position.start.line;
|
||||
let end_line = query_position.end.line;
|
||||
if start_line == end_line {
|
||||
write!(f, ": line {start_line}")?;
|
||||
} else {
|
||||
write!(f, ": lines {start_line} to {end_line}")?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl std::fmt::Display for NiceDatabaseError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"In \"{}\": The following error occurred while executing an SQL statement:\n{}\n\nThe SQL statement sent by SQLPage was:\n",
|
||||
self.source_file.display(),
|
||||
self.db_err
|
||||
)?;
|
||||
if let sqlx::error::Error::Database(db_err) = &self.db_err {
|
||||
let Some(mut offset) = db_err.offset() else {
|
||||
write!(f, "{}", self.query)?;
|
||||
self.show_position_info(f)?;
|
||||
return Ok(());
|
||||
};
|
||||
for line in self.query.lines() {
|
||||
if offset > line.len() {
|
||||
offset -= line.len() + 1;
|
||||
} else {
|
||||
highlight_line_offset(f, line, offset);
|
||||
self.show_position_info(f)?;
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
} else {
|
||||
write!(f, "{}", self.query)?;
|
||||
self.show_position_info(f)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl NiceDatabaseError {
|
||||
fn show_position_info(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> {
|
||||
write_source_position_info(f, &self.source_file, self.query_position)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for NiceDatabaseError {}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct NicePositionedError {
|
||||
source_file: PathBuf,
|
||||
query_position: SourceSpan,
|
||||
error: anyhow::Error,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for NicePositionedError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "In \"{}\": {}", self.source_file.display(), self.error)?;
|
||||
write_source_position_info(f, &self.source_file, Some(self.query_position))
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for NicePositionedError {
|
||||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||
Some(self.error.as_ref())
|
||||
}
|
||||
}
|
||||
|
||||
/// Display a database error without any position information
|
||||
#[must_use]
|
||||
pub fn display_db_error(
|
||||
source_file: &Path,
|
||||
query: &str,
|
||||
db_err: sqlx::error::Error,
|
||||
) -> anyhow::Error {
|
||||
anyhow::Error::new(NiceDatabaseError {
|
||||
source_file: source_file.to_path_buf(),
|
||||
db_err,
|
||||
query: query.to_string(),
|
||||
query_position: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Display a database error with a highlighted line and character offset.
|
||||
#[must_use]
|
||||
pub fn display_stmt_db_error(
|
||||
source_file: &Path,
|
||||
stmt: &StmtWithParams,
|
||||
db_err: sqlx::error::Error,
|
||||
) -> anyhow::Error {
|
||||
anyhow::Error::new(NiceDatabaseError {
|
||||
source_file: source_file.to_path_buf(),
|
||||
db_err,
|
||||
query: stmt.query.clone(),
|
||||
query_position: Some(stmt.query_position),
|
||||
})
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn display_stmt_error(
|
||||
source_file: &Path,
|
||||
query_position: SourceSpan,
|
||||
error: anyhow::Error,
|
||||
) -> anyhow::Error {
|
||||
anyhow::Error::new(NicePositionedError {
|
||||
source_file: source_file.to_path_buf(),
|
||||
query_position,
|
||||
error,
|
||||
})
|
||||
}
|
||||
|
||||
/// Highlight a line with a character offset.
|
||||
pub fn highlight_line_offset<W: std::fmt::Write>(msg: &mut W, line: &str, offset: usize) {
|
||||
writeln!(msg, "{line}").unwrap();
|
||||
writeln!(msg, "{}⬆️", " ".repeat(offset)).unwrap();
|
||||
}
|
||||
|
||||
/// Highlight an error given a line and a character offset
|
||||
/// line and `col_num` are 1-based
|
||||
pub fn quote_source_with_highlight(source: &str, line_num: u64, col_num: u64) -> String {
|
||||
let mut msg = String::new();
|
||||
let col_num_usize = usize::try_from(col_num)
|
||||
.unwrap_or_default()
|
||||
.saturating_sub(1);
|
||||
for (current_line_num, line) in (1_u64..).zip(source.lines()) {
|
||||
if current_line_num + 1 == line_num || current_line_num == line_num + 1 {
|
||||
writeln!(msg, "{line}").unwrap();
|
||||
} else if current_line_num == line_num {
|
||||
highlight_line_offset(&mut msg, line, col_num_usize);
|
||||
} else if current_line_num > line_num + 1 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
msg
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_display_stmt_error_includes_file_and_line() {
|
||||
let err = display_stmt_error(
|
||||
Path::new("example.sql"),
|
||||
SourceSpan {
|
||||
start: super::sql::SourceLocation {
|
||||
line: 12,
|
||||
column: 3,
|
||||
},
|
||||
end: super::sql::SourceLocation {
|
||||
line: 12,
|
||||
column: 17,
|
||||
},
|
||||
},
|
||||
anyhow::anyhow!("boom"),
|
||||
);
|
||||
let message = err.to_string();
|
||||
assert!(message.contains("In \"example.sql\": boom"));
|
||||
assert!(message.contains("example.sql: line 12"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_quote_source_with_highlight() {
|
||||
let source = "SELECT *\nFROM table\nWHERE <syntax error>";
|
||||
let expected = "FROM table\nWHERE <syntax error>\n ⬆️\n";
|
||||
assert_eq!(quote_source_with_highlight(source, 3, 6), expected);
|
||||
}
|
||||
@@ -0,0 +1,961 @@
|
||||
use anyhow::{Context, anyhow};
|
||||
use futures_util::StreamExt;
|
||||
use futures_util::stream::Stream;
|
||||
use serde_json::Value;
|
||||
use std::borrow::Cow;
|
||||
use std::path::Path;
|
||||
use std::pin::Pin;
|
||||
use tracing::Instrument;
|
||||
|
||||
use super::csv_import::run_csv_import;
|
||||
use super::error_highlighting::{display_stmt_db_error, display_stmt_error};
|
||||
use super::sql::{
|
||||
DelayedFunctionCall, ParsedSqlFile, ParsedStatement, SimpleSelectValue, StmtWithParams,
|
||||
};
|
||||
use crate::dynamic_component::parse_dynamic_rows;
|
||||
use crate::utils::add_value_to_map;
|
||||
use crate::webserver::ErrorWithStatus;
|
||||
use crate::webserver::database::sql_to_json::row_to_string;
|
||||
use crate::webserver::http_request_info::ExecutionContext;
|
||||
use crate::webserver::request_variables::SetVariablesMap;
|
||||
use crate::webserver::single_or_vec::SingleOrVec;
|
||||
|
||||
use super::syntax_tree::{StmtParam, extract_req_param};
|
||||
use super::{Database, DbItem, error_highlighting::display_db_error};
|
||||
use sqlx::any::{AnyArguments, AnyQueryResult, AnyRow, AnyStatement, AnyTypeInfo};
|
||||
use sqlx::pool::PoolConnection;
|
||||
use sqlx::{
|
||||
Any, AnyConnection, Arguments, Column, Either, Executor, Row as _, Statement, ValueRef,
|
||||
};
|
||||
|
||||
pub type DbConn = Option<PoolConnection<sqlx::Any>>;
|
||||
|
||||
fn source_line_number(line: usize) -> i64 {
|
||||
i64::try_from(line).unwrap_or(i64::MAX)
|
||||
}
|
||||
|
||||
use crate::telemetry_metrics::TelemetryMetrics;
|
||||
use opentelemetry_semantic_conventions::attribute as otel;
|
||||
|
||||
fn record_query_params(span: &tracing::Span, params: &[Option<String>]) {
|
||||
use tracing_opentelemetry::OpenTelemetrySpanExt;
|
||||
for (idx, value) in params.iter().enumerate() {
|
||||
let key = opentelemetry::Key::new(format!("{}.{idx}", otel::DB_QUERY_PARAMETER));
|
||||
let otel_value = match value {
|
||||
Some(v) => opentelemetry::Value::String(v.clone().into()),
|
||||
None => opentelemetry::Value::String("NULL".into()),
|
||||
};
|
||||
span.set_attribute(key, otel_value);
|
||||
}
|
||||
}
|
||||
|
||||
struct DbQueryMetricsContext<'a> {
|
||||
span: tracing::Span,
|
||||
duration: std::time::Duration,
|
||||
db_system_name: &'static str,
|
||||
operation_name: String,
|
||||
metrics: &'a TelemetryMetrics,
|
||||
}
|
||||
|
||||
impl<'a> DbQueryMetricsContext<'a> {
|
||||
fn new(
|
||||
span: tracing::Span,
|
||||
operation_name: String,
|
||||
db_system_name: &'static str,
|
||||
metrics: &'a TelemetryMetrics,
|
||||
) -> Self {
|
||||
Self {
|
||||
span,
|
||||
duration: std::time::Duration::ZERO,
|
||||
db_system_name,
|
||||
operation_name,
|
||||
metrics,
|
||||
}
|
||||
}
|
||||
|
||||
fn add_duration(&mut self, duration: std::time::Duration) {
|
||||
self.duration += duration;
|
||||
}
|
||||
|
||||
fn record_success(&self, returned_rows: i64) {
|
||||
self.span
|
||||
.record(otel::DB_RESPONSE_RETURNED_ROWS, returned_rows);
|
||||
self.span.record(otel::OTEL_STATUS_CODE, "OK");
|
||||
let attributes = [
|
||||
opentelemetry::KeyValue::new(otel::DB_SYSTEM_NAME, self.db_system_name),
|
||||
opentelemetry::KeyValue::new(otel::DB_OPERATION_NAME, self.operation_name.clone()),
|
||||
opentelemetry::KeyValue::new(otel::OTEL_STATUS_CODE, "OK"),
|
||||
];
|
||||
self.metrics
|
||||
.db_query_duration
|
||||
.record(self.duration.as_secs_f64(), &attributes);
|
||||
}
|
||||
|
||||
fn record_error(&self, returned_rows: i64, error: &anyhow::Error) {
|
||||
self.span
|
||||
.record(otel::DB_RESPONSE_RETURNED_ROWS, returned_rows);
|
||||
self.span.record(otel::OTEL_STATUS_CODE, "ERROR");
|
||||
self.span
|
||||
.record(otel::EXCEPTION_MESSAGE, tracing::field::display(error));
|
||||
self.span
|
||||
.record("sqlpage.exception.details", tracing::field::debug(error));
|
||||
let attributes = [
|
||||
opentelemetry::KeyValue::new(otel::DB_SYSTEM_NAME, self.db_system_name),
|
||||
opentelemetry::KeyValue::new(otel::DB_OPERATION_NAME, self.operation_name.clone()),
|
||||
opentelemetry::KeyValue::new(otel::OTEL_STATUS_CODE, "ERROR"),
|
||||
opentelemetry::KeyValue::new(otel::ERROR_TYPE, error.to_string()),
|
||||
];
|
||||
self.metrics
|
||||
.db_query_duration
|
||||
.record(self.duration.as_secs_f64(), &attributes);
|
||||
}
|
||||
}
|
||||
|
||||
fn create_db_query_span(
|
||||
sql: &str,
|
||||
source_file: &Path,
|
||||
line: usize,
|
||||
db_system_name: &'static str,
|
||||
) -> (tracing::Span, String) {
|
||||
let operation_name = sql.split_whitespace().next().unwrap_or("").to_uppercase();
|
||||
let span = tracing::info_span!(
|
||||
"db.query",
|
||||
"otel.kind" = "client",
|
||||
"otel.name" = %operation_name,
|
||||
{ otel::DB_QUERY_TEXT } = sql,
|
||||
{ otel::DB_SYSTEM_NAME } = db_system_name,
|
||||
{ otel::DB_OPERATION_NAME } = operation_name,
|
||||
{ otel::CODE_FILE_PATH } = %source_file.display(),
|
||||
{ otel::CODE_LINE_NUMBER } = source_line_number(line),
|
||||
{ otel::OTEL_STATUS_CODE } = tracing::field::Empty,
|
||||
{ otel::EXCEPTION_MESSAGE } = tracing::field::Empty,
|
||||
"sqlpage.exception.details" = tracing::field::Empty,
|
||||
{ otel::DB_RESPONSE_RETURNED_ROWS } = tracing::field::Empty,
|
||||
);
|
||||
(span, operation_name)
|
||||
}
|
||||
|
||||
impl Database {
|
||||
pub(crate) async fn prepare_with(
|
||||
&self,
|
||||
query: &str,
|
||||
param_types: &[AnyTypeInfo],
|
||||
) -> anyhow::Result<AnyStatement<'static>> {
|
||||
self.connection
|
||||
.prepare_with(query, param_types)
|
||||
.await
|
||||
.map(|s| s.to_owned())
|
||||
.map_err(|e| display_db_error(Path::new("autogenerated sqlpage query"), query, e))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn stream_query_results_with_conn<'a>(
|
||||
sql_file: &'a ParsedSqlFile,
|
||||
request: &'a ExecutionContext,
|
||||
db_connection: &'a mut DbConn,
|
||||
) -> impl Stream<Item = DbItem> + 'a {
|
||||
let source_file = &sql_file.source_path;
|
||||
async_stream::try_stream! {
|
||||
for res in &sql_file.statements {
|
||||
match res {
|
||||
ParsedStatement::CsvImport(csv_import) => {
|
||||
let connection = take_connection(&request.app_state.db, db_connection, request).await?;
|
||||
log::debug!("Executing CSV import: {csv_import:?}");
|
||||
run_csv_import(connection, csv_import, request).await.with_context(|| format!("Failed to import the CSV file {:?} into the table {:?}", csv_import.uploaded_file, csv_import.table_name))?;
|
||||
},
|
||||
ParsedStatement::StmtWithParams(stmt) => {
|
||||
let query = bind_parameters(stmt, request, db_connection)
|
||||
.await
|
||||
.map_err(|e| with_stmt_position(source_file, stmt.query_position, e))?;
|
||||
request.server_timing.record("bind_params");
|
||||
let connection = take_connection(&request.app_state.db, db_connection, request).await?;
|
||||
log::trace!("Executing query {:?}", query.sql);
|
||||
let db_system_name = request.app_state.db.info.database_type.otel_name();
|
||||
let (query_span, operation_name) = create_db_query_span(
|
||||
query.sql,
|
||||
source_file,
|
||||
stmt.query_position.start.line,
|
||||
db_system_name,
|
||||
);
|
||||
let mut query_metrics = DbQueryMetricsContext::new(
|
||||
query_span.clone(),
|
||||
operation_name,
|
||||
db_system_name,
|
||||
&request.app_state.telemetry_metrics,
|
||||
);
|
||||
record_query_params(&query_metrics.span, &query.param_values);
|
||||
let mut stream = connection.fetch_many(query);
|
||||
let mut error = None;
|
||||
let mut returned_rows: i64 = 0;
|
||||
loop {
|
||||
let start_next = std::time::Instant::now();
|
||||
let next_elem = stream.next().instrument(query_span.clone()).await;
|
||||
query_metrics.add_duration(start_next.elapsed());
|
||||
let Some(elem) = next_elem else { break; };
|
||||
|
||||
let mut query_result = parse_single_sql_result(source_file, stmt, elem);
|
||||
if let DbItem::Error(e) = query_result {
|
||||
error = Some(e);
|
||||
break;
|
||||
}
|
||||
if matches!(query_result, DbItem::Row(_)) {
|
||||
returned_rows += 1;
|
||||
}
|
||||
apply_json_columns(&mut query_result, &stmt.json_columns);
|
||||
if let Err(err) = apply_delayed_functions(request, &stmt.delayed_functions, &mut query_result)
|
||||
.instrument(query_span.clone())
|
||||
.await
|
||||
{
|
||||
error = Some(err);
|
||||
break;
|
||||
}
|
||||
for db_item in parse_dynamic_rows(query_result) {
|
||||
yield db_item;
|
||||
}
|
||||
}
|
||||
drop(stream);
|
||||
if let Some(error) = error {
|
||||
query_metrics.record_error(returned_rows, &error);
|
||||
try_rollback_transaction(connection).await;
|
||||
yield DbItem::Error(error);
|
||||
} else {
|
||||
query_metrics.record_success(returned_rows);
|
||||
}
|
||||
},
|
||||
ParsedStatement::SetVariable { variable, value} => {
|
||||
execute_set_variable_query(db_connection, request, variable, value, source_file).await
|
||||
.with_context(||
|
||||
format!("Failed to set the {variable} variable to {value:?}")
|
||||
)?;
|
||||
},
|
||||
ParsedStatement::StaticSimpleSet { variable, value} => {
|
||||
execute_set_simple_static(db_connection, request, variable, value, source_file).await
|
||||
.with_context(||
|
||||
format!("Failed to set the {variable} variable to {value:?}")
|
||||
)?;
|
||||
},
|
||||
ParsedStatement::StaticSimpleSelect { values, query_position } => {
|
||||
let row = exec_static_simple_select(values, request, db_connection)
|
||||
.await
|
||||
.map_err(|e| with_stmt_position(source_file, *query_position, e))?;
|
||||
for i in parse_dynamic_rows(DbItem::Row(row)) {
|
||||
yield i;
|
||||
}
|
||||
}
|
||||
ParsedStatement::Error(e) => yield DbItem::Error(clone_anyhow_err(source_file, e)),
|
||||
}
|
||||
}
|
||||
}
|
||||
.map(|res| res.unwrap_or_else(DbItem::Error))
|
||||
}
|
||||
|
||||
fn with_stmt_position(
|
||||
source_file: &Path,
|
||||
query_position: super::sql::SourceSpan,
|
||||
error: anyhow::Error,
|
||||
) -> anyhow::Error {
|
||||
if error.downcast_ref::<ErrorWithStatus>().is_some() {
|
||||
error
|
||||
} else {
|
||||
display_stmt_error(source_file, query_position, error)
|
||||
}
|
||||
}
|
||||
|
||||
/// Transforms a stream of database items to stop processing after encountering the first error.
|
||||
/// The error item itself is still emitted before stopping.
|
||||
pub fn stop_at_first_error(
|
||||
results_stream: impl Stream<Item = DbItem>,
|
||||
) -> impl Stream<Item = DbItem> {
|
||||
// We need a oneshot channel rather than a simple boolean flag because
|
||||
// take_while would poll the stream one extra time after the error,
|
||||
// while take_until stops immediately when the future completes
|
||||
let (error_tx, error_rx) = tokio::sync::oneshot::channel();
|
||||
let mut error_tx = Some(error_tx);
|
||||
|
||||
results_stream
|
||||
.inspect(move |item| {
|
||||
if let DbItem::Error(err) = item {
|
||||
log::error!("{err:?}");
|
||||
if let Some(tx) = error_tx.take() {
|
||||
let _ = tx.send(());
|
||||
}
|
||||
}
|
||||
})
|
||||
.take_until(error_rx)
|
||||
}
|
||||
|
||||
/// Executes the sqlpage pseudo-functions contained in a static simple select
|
||||
async fn exec_static_simple_select(
|
||||
columns: &[(String, SimpleSelectValue)],
|
||||
req: &ExecutionContext,
|
||||
db_connection: &mut DbConn,
|
||||
) -> anyhow::Result<serde_json::Value> {
|
||||
let mut map = serde_json::Map::with_capacity(columns.len());
|
||||
for (name, value) in columns {
|
||||
let value = match value {
|
||||
SimpleSelectValue::Static(s) => s.clone(),
|
||||
SimpleSelectValue::Dynamic(p) => {
|
||||
extract_req_param_as_json(p, req, db_connection).await?
|
||||
}
|
||||
};
|
||||
map = add_value_to_map(map, (name.clone(), value));
|
||||
}
|
||||
Ok(serde_json::Value::Object(map))
|
||||
}
|
||||
|
||||
async fn try_rollback_transaction(db_connection: &mut AnyConnection) {
|
||||
log::debug!("Attempting to rollback transaction");
|
||||
match db_connection.execute("ROLLBACK").await {
|
||||
Ok(_) => log::debug!("Rolled back transaction"),
|
||||
Err(e) => {
|
||||
log::debug!("There was probably no transaction in progress when this happened: {e:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Extracts the value of a parameter from the request.
|
||||
/// Returns `Ok(None)` when NULL should be used as the parameter value.
|
||||
async fn extract_req_param_as_json(
|
||||
param: &StmtParam,
|
||||
request: &ExecutionContext,
|
||||
db_connection: &mut DbConn,
|
||||
) -> anyhow::Result<serde_json::Value> {
|
||||
if let Some(val) = extract_req_param(param, request, db_connection).await? {
|
||||
Ok(serde_json::Value::String(val.into_owned()))
|
||||
} else {
|
||||
Ok(serde_json::Value::Null)
|
||||
}
|
||||
}
|
||||
|
||||
/// This function is used to create a pinned boxed stream of query results.
|
||||
/// This allows recursive calls.
|
||||
pub fn stream_query_results_boxed<'a>(
|
||||
sql_file: &'a ParsedSqlFile,
|
||||
request: &'a ExecutionContext,
|
||||
db_connection: &'a mut DbConn,
|
||||
) -> Pin<Box<dyn Stream<Item = DbItem> + 'a>> {
|
||||
Box::pin(stream_query_results_with_conn(
|
||||
sql_file,
|
||||
request,
|
||||
db_connection,
|
||||
))
|
||||
}
|
||||
|
||||
async fn execute_set_variable_query<'a>(
|
||||
db_connection: &'a mut DbConn,
|
||||
request: &'a ExecutionContext,
|
||||
variable: &StmtParam,
|
||||
statement: &StmtWithParams,
|
||||
source_file: &Path,
|
||||
) -> anyhow::Result<()> {
|
||||
let query = bind_parameters(statement, request, db_connection).await?;
|
||||
let connection = take_connection(&request.app_state.db, db_connection, request).await?;
|
||||
log::debug!(
|
||||
"Executing query to set the {variable:?} variable: {:?}",
|
||||
query.sql
|
||||
);
|
||||
|
||||
let db_system_name = request.app_state.db.info.database_type.otel_name();
|
||||
let (query_span, operation_name) = create_db_query_span(
|
||||
query.sql,
|
||||
source_file,
|
||||
statement.query_position.start.line,
|
||||
db_system_name,
|
||||
);
|
||||
let mut query_metrics = DbQueryMetricsContext::new(
|
||||
query_span.clone(),
|
||||
operation_name,
|
||||
db_system_name,
|
||||
&request.app_state.telemetry_metrics,
|
||||
);
|
||||
record_query_params(&query_metrics.span, &query.param_values);
|
||||
let start_time = std::time::Instant::now();
|
||||
let value = match connection
|
||||
.fetch_optional(query)
|
||||
.instrument(query_span.clone())
|
||||
.await
|
||||
{
|
||||
Ok(Some(row)) => {
|
||||
query_metrics.add_duration(start_time.elapsed());
|
||||
query_metrics.record_success(1_i64);
|
||||
row_to_string(&row)
|
||||
}
|
||||
Ok(None) => {
|
||||
query_metrics.add_duration(start_time.elapsed());
|
||||
query_metrics.record_success(0_i64);
|
||||
None
|
||||
}
|
||||
Err(e) => {
|
||||
query_metrics.add_duration(start_time.elapsed());
|
||||
try_rollback_transaction(connection).await;
|
||||
let err = display_stmt_db_error(source_file, statement, e);
|
||||
query_metrics.record_error(0_i64, &err);
|
||||
return Err(err);
|
||||
}
|
||||
};
|
||||
|
||||
let (mut vars, name) = vars_and_name(request, variable)?;
|
||||
|
||||
log::debug!("Setting variable {name} to {value:?}");
|
||||
vars.insert(name.to_owned(), value.map(SingleOrVec::Single));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn execute_set_simple_static<'a>(
|
||||
db_connection: &'a mut DbConn,
|
||||
request: &'a ExecutionContext,
|
||||
variable: &StmtParam,
|
||||
value: &SimpleSelectValue,
|
||||
_source_file: &Path,
|
||||
) -> anyhow::Result<()> {
|
||||
let value_str = match value {
|
||||
SimpleSelectValue::Static(json_value) => match json_value {
|
||||
serde_json::Value::Null => None,
|
||||
serde_json::Value::String(s) => Some(s.clone()),
|
||||
other => Some(other.to_string()),
|
||||
},
|
||||
SimpleSelectValue::Dynamic(stmt_param) => {
|
||||
extract_req_param(stmt_param, request, db_connection)
|
||||
.await?
|
||||
.map(std::borrow::Cow::into_owned)
|
||||
}
|
||||
};
|
||||
|
||||
let (mut vars, name) = vars_and_name(request, variable)?;
|
||||
|
||||
log::debug!("Setting variable {name} to static value {value_str:?}");
|
||||
vars.insert(name.to_owned(), value_str.map(SingleOrVec::Single));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn vars_and_name<'a, 'b>(
|
||||
request: &'a ExecutionContext,
|
||||
variable: &'b StmtParam,
|
||||
) -> anyhow::Result<(std::cell::RefMut<'a, SetVariablesMap>, &'b str)> {
|
||||
match variable {
|
||||
StmtParam::PostOrGet(name) | StmtParam::Get(name) => {
|
||||
if request.post_variables.contains_key(name) {
|
||||
log::warn!(
|
||||
"Deprecation warning! Setting the value of ${name}, but there is already a form field named :{name}. This will stop working soon. Please rename the variable, or use :{name} directly if you intended to overwrite the posted form field value."
|
||||
);
|
||||
}
|
||||
Ok((request.set_variables.borrow_mut(), name))
|
||||
}
|
||||
StmtParam::Post(name) => Ok((request.set_variables.borrow_mut(), name)),
|
||||
_ => Err(anyhow!(
|
||||
"Only GET and POST variables can be set, not {variable:?}"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn take_connection<'a>(
|
||||
db: &'a Database,
|
||||
conn: &'a mut DbConn,
|
||||
request: &ExecutionContext,
|
||||
) -> anyhow::Result<&'a mut PoolConnection<sqlx::Any>> {
|
||||
if let Some(c) = conn {
|
||||
return Ok(c);
|
||||
}
|
||||
let pool_size = db.connection.size();
|
||||
let acquire_span = tracing::info_span!(
|
||||
"db.pool.acquire",
|
||||
{ otel::DB_SYSTEM_NAME } = db.info.database_type.otel_name(),
|
||||
{ otel::DB_CLIENT_CONNECTION_POOL_NAME } = "sqlpage",
|
||||
sqlpage.db.pool.size = pool_size,
|
||||
);
|
||||
match db.connection.acquire().instrument(acquire_span).await {
|
||||
Ok(c) => {
|
||||
log::debug!("Acquired a database connection");
|
||||
request.server_timing.record("db_conn");
|
||||
*conn = Some(c);
|
||||
let connection = conn.as_mut().unwrap();
|
||||
set_trace_context(connection, db).await;
|
||||
Ok(connection)
|
||||
}
|
||||
Err(e) => {
|
||||
let db_name = db.connection.any_kind();
|
||||
let active_count = db.connection.size();
|
||||
let err_msg = format!(
|
||||
"Unable to connect to {db_name:?}. The connection pool currently has {active_count} active connections."
|
||||
);
|
||||
Err(anyhow::Error::new(e).context(err_msg))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets the current `OTel` trace context on the database connection so it is visible
|
||||
/// in `pg_stat_activity.application_name` (`PostgreSQL`) or as a session variable (`MySQL`).
|
||||
/// This allows correlating `SQLPage` traces with database-side monitoring.
|
||||
async fn set_trace_context(connection: &mut AnyConnection, db: &Database) {
|
||||
use opentelemetry::trace::TraceContextExt;
|
||||
use tracing_opentelemetry::OpenTelemetrySpanExt;
|
||||
|
||||
let span = tracing::Span::current();
|
||||
let context = span.context();
|
||||
let otel_span = context.span();
|
||||
let span_context = otel_span.span_context();
|
||||
if !span_context.is_valid() {
|
||||
return;
|
||||
}
|
||||
let traceparent = format!(
|
||||
"00-{}-{}-{:02x}",
|
||||
span_context.trace_id(),
|
||||
span_context.span_id(),
|
||||
span_context.trace_flags()
|
||||
);
|
||||
let sql = match db.info.kind {
|
||||
sqlx::any::AnyKind::Postgres => {
|
||||
// postgresqlreceiver expects application_name to be a raw W3C traceparent value.
|
||||
format!("SET application_name = '{traceparent}'")
|
||||
}
|
||||
sqlx::any::AnyKind::MySql => {
|
||||
format!("SET @traceparent = '{traceparent}'")
|
||||
}
|
||||
_ => return,
|
||||
};
|
||||
if let Err(e) = connection.execute(sql.as_str()).await {
|
||||
log::debug!("Failed to set trace context on connection: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn parse_single_sql_result(
|
||||
source_file: &Path,
|
||||
stmt: &StmtWithParams,
|
||||
res: sqlx::Result<Either<AnyQueryResult, AnyRow>>,
|
||||
) -> DbItem {
|
||||
match res {
|
||||
Ok(Either::Right(r)) => {
|
||||
if log::log_enabled!(log::Level::Trace) {
|
||||
debug_row(&r);
|
||||
}
|
||||
DbItem::Row(super::sql_to_json::row_to_json(&r))
|
||||
}
|
||||
Ok(Either::Left(res)) => {
|
||||
log::debug!("Finished query with result: {res:?}");
|
||||
DbItem::FinishedQuery
|
||||
}
|
||||
Err(err) => {
|
||||
let nice_err = display_stmt_db_error(source_file, stmt, err);
|
||||
DbItem::Error(nice_err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn debug_row(r: &AnyRow) {
|
||||
use std::fmt::Write;
|
||||
let columns = r.columns();
|
||||
let mut row_str = String::new();
|
||||
for (i, col) in columns.iter().enumerate() {
|
||||
if let Ok(value) = r.try_get_raw(i) {
|
||||
write!(
|
||||
&mut row_str,
|
||||
"[{:?} ({}): {:?}: {:?}]",
|
||||
col.name(),
|
||||
if value.is_null() { "NULL" } else { "NOT NULL" },
|
||||
col,
|
||||
value.type_info()
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
log::trace!("Received db row: {row_str}");
|
||||
}
|
||||
|
||||
fn clone_anyhow_err(source_file: &Path, err: &anyhow::Error) -> anyhow::Error {
|
||||
if let Some(func_err) = err.downcast_ref::<super::sql::SqlPageFunctionError>() {
|
||||
let line = func_err.line;
|
||||
let loc = if line > 0 {
|
||||
format!(":{line}")
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
return anyhow::anyhow!("{}{loc} {}", source_file.display(), func_err);
|
||||
}
|
||||
|
||||
let mut e = anyhow!(
|
||||
"{} contains a syntax error preventing SQLPage from parsing and preparing its SQL statements.",
|
||||
source_file.display()
|
||||
);
|
||||
for c in err.chain().rev() {
|
||||
e = e.context(c.to_string());
|
||||
}
|
||||
e
|
||||
}
|
||||
|
||||
async fn bind_parameters<'a>(
|
||||
stmt: &'a StmtWithParams,
|
||||
request: &'a ExecutionContext,
|
||||
db_connection: &mut DbConn,
|
||||
) -> anyhow::Result<StatementWithParams<'a>> {
|
||||
let sql = stmt.query.as_str();
|
||||
log::debug!("Preparing statement: {sql}");
|
||||
let mut arguments = AnyArguments::default();
|
||||
let mut param_values = Vec::with_capacity(stmt.params.len());
|
||||
for (param_idx, param) in stmt.params.iter().enumerate() {
|
||||
log::trace!("\tevaluating parameter {}: {}", param_idx + 1, param);
|
||||
let argument = extract_req_param(param, request, db_connection).await?;
|
||||
log::debug!(
|
||||
"\tparameter {}: {}",
|
||||
param_idx + 1,
|
||||
argument.as_ref().unwrap_or(&Cow::Borrowed("NULL"))
|
||||
);
|
||||
param_values.push(argument.as_deref().map(str::to_owned));
|
||||
match argument {
|
||||
None => arguments.add(None::<String>),
|
||||
Some(Cow::Owned(s)) => arguments.add(s),
|
||||
Some(Cow::Borrowed(v)) => arguments.add(v),
|
||||
}
|
||||
}
|
||||
let has_arguments = !stmt.params.is_empty();
|
||||
Ok(StatementWithParams {
|
||||
sql,
|
||||
arguments,
|
||||
has_arguments,
|
||||
param_values,
|
||||
})
|
||||
}
|
||||
|
||||
async fn apply_delayed_functions(
|
||||
request: &ExecutionContext,
|
||||
delayed_functions: &[DelayedFunctionCall],
|
||||
item: &mut DbItem,
|
||||
) -> anyhow::Result<()> {
|
||||
// We need to open new connections for each delayed function call, because we are still fetching the results of the current query in the main connection.
|
||||
let mut db_conn = None;
|
||||
if let DbItem::Row(serde_json::Value::Object(results)) = item {
|
||||
for f in delayed_functions {
|
||||
log::trace!("Applying delayed function {} to {:?}", f.function, results);
|
||||
apply_single_delayed_function(request, &mut db_conn, f, results).await?;
|
||||
log::trace!(
|
||||
"Delayed function applied {}. Result: {:?}",
|
||||
f.function,
|
||||
results
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn apply_single_delayed_function(
|
||||
request: &ExecutionContext,
|
||||
db_connection: &mut DbConn,
|
||||
f: &DelayedFunctionCall,
|
||||
row: &mut serde_json::Map<String, serde_json::Value>,
|
||||
) -> anyhow::Result<()> {
|
||||
let mut params = Vec::new();
|
||||
for arg in &f.argument_col_names {
|
||||
let Some(arg_value) = row.remove(arg) else {
|
||||
anyhow::bail!(
|
||||
"The column {arg} is missing in the result set, but it is required by the {} function.",
|
||||
f.function
|
||||
);
|
||||
};
|
||||
params.push(json_to_fn_param(arg_value));
|
||||
}
|
||||
let result_str = f.function.evaluate(request, db_connection, params).await?;
|
||||
let result_json = result_str
|
||||
.map(Cow::into_owned)
|
||||
.map_or(serde_json::Value::Null, serde_json::Value::String);
|
||||
row.insert(f.target_col_name.clone(), result_json);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn json_to_fn_param(json: serde_json::Value) -> Option<Cow<'static, str>> {
|
||||
match json {
|
||||
serde_json::Value::String(s) => Some(Cow::Owned(s)),
|
||||
serde_json::Value::Null => None,
|
||||
_ => Some(Cow::Owned(json.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_json_columns(item: &mut DbItem, json_columns: &[String]) {
|
||||
if let DbItem::Row(Value::Object(row)) = item {
|
||||
for column in json_columns {
|
||||
if let Some(value) = row.get_mut(column) {
|
||||
if let Value::String(json_str) = value {
|
||||
if let Ok(parsed_json) = serde_json::from_str(json_str) {
|
||||
log::trace!("Parsed JSON column {column}: {parsed_json}");
|
||||
*value = parsed_json;
|
||||
} else {
|
||||
log::warn!("The column {column} contains invalid JSON: {json_str}");
|
||||
}
|
||||
} else if let Value::Array(array) = value {
|
||||
for item in array {
|
||||
if let Value::String(json_str) = item
|
||||
&& let Ok(parsed_json) = serde_json::from_str(json_str)
|
||||
{
|
||||
log::trace!("Parsed JSON array item: {parsed_json}");
|
||||
*item = parsed_json;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log::warn!(
|
||||
"The column {column} is missing from the result set, so it cannot be converted to JSON."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct StatementWithParams<'a> {
|
||||
sql: &'a str,
|
||||
arguments: AnyArguments<'a>,
|
||||
has_arguments: bool,
|
||||
param_values: Vec<Option<String>>,
|
||||
}
|
||||
|
||||
impl<'q> sqlx::Execute<'q, Any> for StatementWithParams<'q> {
|
||||
fn sql(&self) -> &'q str {
|
||||
self.sql
|
||||
}
|
||||
|
||||
fn statement(&self) -> Option<&<Any as sqlx::database::HasStatement<'q>>::Statement> {
|
||||
None
|
||||
}
|
||||
|
||||
fn take_arguments(&mut self) -> Option<<Any as sqlx::database::HasArguments<'q>>::Arguments> {
|
||||
if self.has_arguments {
|
||||
Some(std::mem::take(&mut self.arguments))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn persistent(&self) -> bool {
|
||||
// Let sqlx create a prepared statement the first time it is executed, and then reuse it.
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use tracing::field::{Field, Visit};
|
||||
use tracing_subscriber::Layer;
|
||||
use tracing_subscriber::layer::Context;
|
||||
use tracing_subscriber::prelude::*;
|
||||
use tracing_subscriber::registry::LookupSpan;
|
||||
|
||||
fn create_row_item(value: Value) -> DbItem {
|
||||
DbItem::Row(value)
|
||||
}
|
||||
|
||||
fn assert_json_value(item: &DbItem, key: &str, expected: Value) {
|
||||
let DbItem::Row(Value::Object(row)) = item else {
|
||||
panic!("Expected DbItem::Row");
|
||||
};
|
||||
assert_eq!(row[key], expected);
|
||||
drop(expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_basic_json_string_conversion() {
|
||||
let mut item = create_row_item(json!({
|
||||
"json_col": "{\"key\": \"value\"}",
|
||||
"normal_col": "text"
|
||||
}));
|
||||
apply_json_columns(&mut item, &["json_col".to_string()]);
|
||||
assert_json_value(&item, "json_col", json!({"key": "value"}));
|
||||
assert_json_value(&item, "normal_col", json!("text"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_json_array_conversion() {
|
||||
let mut item = create_row_item(json!({
|
||||
"array_col": ["{\"a\": 1}", "{\"b\": 2}"],
|
||||
"normal_array": ["text"]
|
||||
}));
|
||||
apply_json_columns(&mut item, &["array_col".to_string()]);
|
||||
assert_json_value(&item, "array_col", json!([{"a": 1}, {"b": 2}]));
|
||||
assert_json_value(&item, "normal_array", json!(["text"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_json_handling() {
|
||||
let mut item = create_row_item(json!({
|
||||
"invalid_json": "{not valid json}",
|
||||
"normal_col": "text"
|
||||
}));
|
||||
apply_json_columns(&mut item, &["invalid_json".to_string()]);
|
||||
assert_json_value(&item, "invalid_json", json!("{not valid json}"));
|
||||
assert_json_value(&item, "normal_col", json!("text"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_missing_column_handling() {
|
||||
let mut item = create_row_item(json!({
|
||||
"existing_col": "text"
|
||||
}));
|
||||
apply_json_columns(&mut item, &["missing_col".to_string()]);
|
||||
assert_json_value(&item, "existing_col", json!("text"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_non_row_dbitem_handling() {
|
||||
let mut item = DbItem::FinishedQuery;
|
||||
apply_json_columns(&mut item, &["json_col".to_string()]);
|
||||
assert!(matches!(item, DbItem::FinishedQuery));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_duplicate_json_column_names() {
|
||||
let mut item = create_row_item(json!({
|
||||
"json_col": "{\"key\": \"value\"}",
|
||||
"normal_col": "text"
|
||||
}));
|
||||
apply_json_columns(&mut item, &["json_col".to_string(), "json_col".to_string()]);
|
||||
assert_json_value(&item, "json_col", json!({"key": "value"}));
|
||||
assert_json_value(&item, "normal_col", json!("text"));
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct RecordedFields(HashMap<&'static str, String>);
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct TestSpanLayer {
|
||||
closed_spans: Arc<Mutex<Vec<HashMap<&'static str, String>>>>,
|
||||
}
|
||||
|
||||
struct TestFieldVisitor<'a>(&'a mut HashMap<&'static str, String>);
|
||||
|
||||
impl Visit for TestFieldVisitor<'_> {
|
||||
fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
|
||||
self.0.insert(field.name(), format!("{value:?}"));
|
||||
}
|
||||
|
||||
fn record_str(&mut self, field: &Field, value: &str) {
|
||||
self.0.insert(field.name(), value.to_owned());
|
||||
}
|
||||
|
||||
fn record_i64(&mut self, field: &Field, value: i64) {
|
||||
self.0.insert(field.name(), value.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
impl<S> Layer<S> for TestSpanLayer
|
||||
where
|
||||
S: tracing::Subscriber + for<'a> LookupSpan<'a>,
|
||||
{
|
||||
fn on_new_span(
|
||||
&self,
|
||||
attrs: &tracing::span::Attributes<'_>,
|
||||
id: &tracing::span::Id,
|
||||
ctx: Context<'_, S>,
|
||||
) {
|
||||
let mut fields = RecordedFields::default();
|
||||
attrs.record(&mut TestFieldVisitor(&mut fields.0));
|
||||
if let Some(span) = ctx.span(id) {
|
||||
span.extensions_mut().insert(fields);
|
||||
}
|
||||
}
|
||||
|
||||
fn on_record(
|
||||
&self,
|
||||
id: &tracing::span::Id,
|
||||
values: &tracing::span::Record<'_>,
|
||||
ctx: Context<'_, S>,
|
||||
) {
|
||||
if let Some(span) = ctx.span(id) {
|
||||
let mut extensions = span.extensions_mut();
|
||||
let fields = extensions
|
||||
.get_mut::<RecordedFields>()
|
||||
.expect("recorded fields");
|
||||
values.record(&mut TestFieldVisitor(&mut fields.0));
|
||||
}
|
||||
}
|
||||
|
||||
fn on_close(&self, id: tracing::span::Id, ctx: Context<'_, S>) {
|
||||
if let Some(span) = ctx.span(&id) {
|
||||
let extensions = span.extensions();
|
||||
let fields = extensions.get::<RecordedFields>().expect("recorded fields");
|
||||
self.closed_spans.lock().unwrap().push(fields.0.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn with_recorded_span_fields(
|
||||
f: impl FnOnce() + Send + 'static,
|
||||
) -> HashMap<&'static str, String> {
|
||||
let layer = TestSpanLayer::default();
|
||||
let closed_spans = layer.closed_spans.clone();
|
||||
let subscriber = tracing_subscriber::registry().with(layer);
|
||||
tracing::subscriber::with_default(subscriber, f);
|
||||
closed_spans
|
||||
.lock()
|
||||
.unwrap()
|
||||
.pop()
|
||||
.expect("closed span fields")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn db_query_span_uses_otel_database_client_semantics() {
|
||||
let fields = with_recorded_span_fields(|| {
|
||||
let (span, operation_name) =
|
||||
create_db_query_span("select * from users", Path::new("index.sql"), 7, "sqlite");
|
||||
assert_eq!(operation_name, "SELECT");
|
||||
drop(span);
|
||||
});
|
||||
|
||||
assert_eq!(fields["otel.kind"], "client");
|
||||
assert_eq!(fields["otel.name"], "SELECT");
|
||||
assert_eq!(fields[otel::DB_QUERY_TEXT], "select * from users");
|
||||
assert_eq!(fields[otel::DB_SYSTEM_NAME], "sqlite");
|
||||
assert_eq!(fields[otel::DB_OPERATION_NAME], "SELECT");
|
||||
assert_eq!(fields[otel::CODE_FILE_PATH], "index.sql");
|
||||
assert_eq!(fields[otel::CODE_LINE_NUMBER], "7");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn db_query_success_records_ok_status_and_row_count() {
|
||||
let fields = with_recorded_span_fields(|| {
|
||||
let span = tracing::info_span!(
|
||||
"db.query",
|
||||
otel.status_code = tracing::field::Empty,
|
||||
exception.message = tracing::field::Empty,
|
||||
sqlpage.exception.details = tracing::field::Empty,
|
||||
db.response.returned_rows = tracing::field::Empty,
|
||||
);
|
||||
let metrics = crate::telemetry_metrics::TelemetryMetrics::default();
|
||||
let query_metrics =
|
||||
DbQueryMetricsContext::new(span.clone(), "SELECT".to_string(), "sqlite", &metrics);
|
||||
query_metrics.record_success(3);
|
||||
drop(span);
|
||||
});
|
||||
|
||||
assert_eq!(fields[otel::OTEL_STATUS_CODE], "OK");
|
||||
assert_eq!(fields[otel::DB_RESPONSE_RETURNED_ROWS], "3");
|
||||
assert!(!fields.contains_key(otel::EXCEPTION_MESSAGE));
|
||||
assert!(!fields.contains_key("sqlpage.exception.details"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn db_query_error_records_error_status_and_exception_fields() {
|
||||
let fields = with_recorded_span_fields(|| {
|
||||
let span = tracing::info_span!(
|
||||
"db.query",
|
||||
otel.status_code = tracing::field::Empty,
|
||||
exception.message = tracing::field::Empty,
|
||||
sqlpage.exception.details = tracing::field::Empty,
|
||||
db.response.returned_rows = tracing::field::Empty,
|
||||
);
|
||||
let error = anyhow!("query failed").context("while executing SELECT 1");
|
||||
let metrics = crate::telemetry_metrics::TelemetryMetrics::default();
|
||||
let query_metrics =
|
||||
DbQueryMetricsContext::new(span.clone(), "SELECT".to_string(), "sqlite", &metrics);
|
||||
query_metrics.record_error(2, &error);
|
||||
drop(span);
|
||||
});
|
||||
|
||||
assert_eq!(fields[otel::OTEL_STATUS_CODE], "ERROR");
|
||||
assert_eq!(fields[otel::DB_RESPONSE_RETURNED_ROWS], "2");
|
||||
assert!(fields[otel::EXCEPTION_MESSAGE].contains("while executing SELECT 1"));
|
||||
assert!(fields["sqlpage.exception.details"].contains("query failed"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
use super::Database;
|
||||
use super::error_highlighting::display_db_error;
|
||||
use crate::MIGRATIONS_DIR;
|
||||
use anyhow;
|
||||
use anyhow::Context;
|
||||
use sqlx::migrate::MigrateError;
|
||||
use sqlx::migrate::Migration;
|
||||
use sqlx::migrate::Migrator;
|
||||
|
||||
pub async fn apply(config: &crate::app_config::AppConfig, db: &Database) -> anyhow::Result<()> {
|
||||
let migrations_dir = config.configuration_directory.join(MIGRATIONS_DIR);
|
||||
if !migrations_dir.exists() {
|
||||
log::info!(
|
||||
"Not applying database migrations because '{}' does not exist",
|
||||
migrations_dir.display()
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
log::debug!("Applying migrations from '{}'", migrations_dir.display());
|
||||
let migrator = Migrator::new(migrations_dir.clone())
|
||||
.await
|
||||
.with_context(|| migration_err("preparing the database migration"))?;
|
||||
if migrator.migrations.is_empty() {
|
||||
log::debug!(
|
||||
"No migration found in {}. \
|
||||
You can specify database operations to apply when the server first starts by creating files \
|
||||
in {MIGRATIONS_DIR}/<VERSION>_<DESCRIPTION>.sql \
|
||||
where <VERSION> is a number and <DESCRIPTION> is a short string.",
|
||||
migrations_dir.display()
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
log::info!("Found {} migrations:", migrator.migrations.len());
|
||||
for m in migrator.iter() {
|
||||
log::info!("\t{}", DisplayMigration(m));
|
||||
}
|
||||
migrator.run(&db.connection).await.map_err(|err| {
|
||||
match err {
|
||||
MigrateError::Execute(n, source) => {
|
||||
let migration = migrator.iter().find(|&m| m.version == n).unwrap();
|
||||
let source_file =
|
||||
migrations_dir.join(format!("{:04}_{}.sql", n, migration.description));
|
||||
display_db_error(&source_file, &migration.sql, source).context(format!(
|
||||
"Failed to apply {} migration {}",
|
||||
db,
|
||||
DisplayMigration(migration)
|
||||
))
|
||||
}
|
||||
source => anyhow::Error::new(source),
|
||||
}
|
||||
.context(format!(
|
||||
"Failed to apply database migrations from {MIGRATIONS_DIR:?}"
|
||||
))
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct DisplayMigration<'a>(&'a Migration);
|
||||
|
||||
impl std::fmt::Display for DisplayMigration<'_> {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let Migration {
|
||||
version,
|
||||
migration_type,
|
||||
description,
|
||||
..
|
||||
} = &self.0;
|
||||
write!(f, "[{version:04}]")?;
|
||||
if migration_type != &sqlx::migrate::MigrationType::Simple {
|
||||
write!(f, " ({migration_type:?})")?;
|
||||
}
|
||||
write!(f, " {description}")?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn migration_err(operation: &'static str) -> String {
|
||||
format!(
|
||||
"An error occurred while {operation}.
|
||||
The path '{MIGRATIONS_DIR}' has to point to a directory, which contains valid SQL files
|
||||
with names using the format '<VERSION>_<DESCRIPTION>.sql',
|
||||
where <VERSION> is a positive number, and <DESCRIPTION> is a string.
|
||||
The current state of migrations will be stored in a table called _sqlx_migrations."
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
pub mod blob_to_data_url;
|
||||
mod connect;
|
||||
mod csv_import;
|
||||
pub mod execute_queries;
|
||||
pub mod migrations;
|
||||
mod sql;
|
||||
mod sqlpage_functions;
|
||||
mod syntax_tree;
|
||||
|
||||
mod error_highlighting;
|
||||
mod sql_to_json;
|
||||
|
||||
pub use sql::ParsedSqlFile;
|
||||
use sql::{DB_PLACEHOLDERS, DbPlaceHolder};
|
||||
use sqlx::any::AnyKind;
|
||||
// SupportedDatabase is defined in this module
|
||||
|
||||
/// Supported database types in `SQLPage`. Represents an actual DBMS, not a sqlx backend kind (like "Odbc")
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum SupportedDatabase {
|
||||
Sqlite,
|
||||
Duckdb,
|
||||
Oracle,
|
||||
Postgres,
|
||||
MySql,
|
||||
Mssql,
|
||||
Snowflake,
|
||||
Generic,
|
||||
}
|
||||
|
||||
impl SupportedDatabase {
|
||||
/// Detect the database type from a connection's `dbms_name`
|
||||
#[must_use]
|
||||
pub fn from_dbms_name(dbms_name: &str) -> Self {
|
||||
match dbms_name.to_lowercase().as_str() {
|
||||
"sqlite" | "sqlite3" => Self::Sqlite,
|
||||
"duckdb" | "d\0\0\0\0\0" => Self::Duckdb, // ducksdb incorrectly truncates the db name: https://github.com/duckdb/duckdb-odbc/issues/350
|
||||
"oracle" => Self::Oracle,
|
||||
"postgres" | "postgresql" => Self::Postgres,
|
||||
"mysql" | "mariadb" => Self::MySql,
|
||||
"mssql" | "sql server" | "microsoft sql server" => Self::Mssql,
|
||||
"snowflake" => Self::Snowflake,
|
||||
_ => Self::Generic,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the display name for the database
|
||||
#[must_use]
|
||||
pub fn display_name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Sqlite => "SQLite",
|
||||
Self::Duckdb => "DuckDB",
|
||||
Self::Oracle => "Oracle",
|
||||
Self::Postgres => "PostgreSQL",
|
||||
Self::MySql => "MySQL",
|
||||
Self::Mssql => "Microsoft SQL Server",
|
||||
Self::Snowflake => "Snowflake",
|
||||
Self::Generic => "Generic",
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the `OTel` `db.system.name` well-known value.
|
||||
/// See <https://opentelemetry.io/docs/specs/semconv/registry/attributes/db/#db-system-name>
|
||||
#[must_use]
|
||||
pub fn otel_name(self) -> &'static str {
|
||||
Self::otel_name_from_kind(self)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn otel_name_from_kind(kind: impl Into<SupportedDatabase>) -> &'static str {
|
||||
match kind.into() {
|
||||
Self::Sqlite => "sqlite",
|
||||
Self::Duckdb => "duckdb",
|
||||
Self::Oracle => "oracle.db",
|
||||
Self::Postgres => "postgresql",
|
||||
Self::MySql => "mysql",
|
||||
Self::Mssql => "microsoft.sql_server",
|
||||
Self::Snowflake => "snowflake",
|
||||
Self::Generic => "other_sql",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<AnyKind> for SupportedDatabase {
|
||||
fn from(kind: AnyKind) -> Self {
|
||||
match kind {
|
||||
AnyKind::Postgres => Self::Postgres,
|
||||
AnyKind::MySql => Self::MySql,
|
||||
AnyKind::Sqlite => Self::Sqlite,
|
||||
AnyKind::Mssql => Self::Mssql,
|
||||
AnyKind::Odbc => Self::Generic,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Database {
|
||||
pub connection: sqlx::AnyPool,
|
||||
pub info: DbInfo,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DbInfo {
|
||||
pub dbms_name: String,
|
||||
/// The actual database we are connected to. Can be "Generic" when using an unknown ODBC driver
|
||||
pub database_type: SupportedDatabase,
|
||||
/// The sqlx database backend we are using. Can be "Odbc", in which case we need to use `database_type` to know what database we are actually using.
|
||||
pub kind: AnyKind,
|
||||
}
|
||||
|
||||
impl Database {
|
||||
pub async fn close(&self) -> anyhow::Result<()> {
|
||||
log::info!("Closing all database connections...");
|
||||
self.connection.close().await;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum DbItem {
|
||||
Row(serde_json::Value),
|
||||
FinishedQuery,
|
||||
Error(anyhow::Error),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Database {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{:?}", self.connection.any_kind())
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
#[must_use]
|
||||
pub fn make_placeholder(dbms: AnyKind, arg_number: usize) -> String {
|
||||
if let Some((_, placeholder)) = DB_PLACEHOLDERS.iter().find(|(kind, _)| *kind == dbms) {
|
||||
match *placeholder {
|
||||
DbPlaceHolder::PrefixedNumber { prefix } => format!("{prefix}{arg_number}"),
|
||||
DbPlaceHolder::Positional { placeholder } => placeholder.to_string(),
|
||||
}
|
||||
} else {
|
||||
unreachable!("missing dbms: {dbms:?} in DB_PLACEHOLDERS ({DB_PLACEHOLDERS:?})")
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,591 @@
|
||||
use super::super::{DbInfo, SupportedDatabase};
|
||||
use super::{is_sqlpage_func, sqlpage_func_name};
|
||||
use crate::webserver::database::sqlpage_functions::func_call_to_param;
|
||||
use crate::webserver::database::syntax_tree::StmtParam;
|
||||
use sqlparser::ast::{
|
||||
BinaryOperator, CastKind, CharacterLength, DataType, Expr, Function, FunctionArg,
|
||||
FunctionArgExpr, FunctionArgumentList, FunctionArguments, Ident, ObjectName, ObjectNamePart,
|
||||
Spanned, Statement, Value, ValueWithSpan, Visit, VisitMut, Visitor, VisitorMut,
|
||||
};
|
||||
use sqlx::any::AnyKind;
|
||||
use std::ops::ControlFlow;
|
||||
|
||||
pub(super) struct ParameterExtractor {
|
||||
pub(super) db_info: DbInfo,
|
||||
pub(super) parameters: Vec<StmtParam>,
|
||||
pub(super) extract_error: Option<anyhow::Error>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) enum DbPlaceHolder {
|
||||
PrefixedNumber { prefix: &'static str },
|
||||
Positional { placeholder: &'static str },
|
||||
}
|
||||
|
||||
pub(crate) const DB_PLACEHOLDERS: [(AnyKind, DbPlaceHolder); 5] = [
|
||||
(
|
||||
AnyKind::Sqlite,
|
||||
DbPlaceHolder::PrefixedNumber { prefix: "?" },
|
||||
),
|
||||
(
|
||||
AnyKind::Postgres,
|
||||
DbPlaceHolder::PrefixedNumber { prefix: "$" },
|
||||
),
|
||||
(
|
||||
AnyKind::MySql,
|
||||
DbPlaceHolder::Positional { placeholder: "?" },
|
||||
),
|
||||
(
|
||||
AnyKind::Mssql,
|
||||
DbPlaceHolder::PrefixedNumber { prefix: "@p" },
|
||||
),
|
||||
(
|
||||
AnyKind::Odbc,
|
||||
DbPlaceHolder::Positional { placeholder: "?" },
|
||||
),
|
||||
];
|
||||
|
||||
/// For positional parameters, we use a temporary placeholder during parameter extraction,
|
||||
/// And then replace it with the actual placeholder during statement rewriting.
|
||||
pub(crate) const TEMP_PLACEHOLDER_PREFIX: &str = "@SQLPAGE_TEMP";
|
||||
|
||||
fn get_placeholder_prefix(kind: AnyKind) -> &'static str {
|
||||
if let Some((_, DbPlaceHolder::PrefixedNumber { prefix })) = DB_PLACEHOLDERS
|
||||
.iter()
|
||||
.find(|(placeholder_kind, _prefix)| *placeholder_kind == kind)
|
||||
{
|
||||
prefix
|
||||
} else {
|
||||
TEMP_PLACEHOLDER_PREFIX
|
||||
}
|
||||
}
|
||||
|
||||
impl ParameterExtractor {
|
||||
pub(super) fn extract_parameters(
|
||||
sql_ast: &mut Statement,
|
||||
db_info: DbInfo,
|
||||
) -> anyhow::Result<Vec<StmtParam>> {
|
||||
let mut this = Self {
|
||||
db_info,
|
||||
parameters: vec![],
|
||||
extract_error: None,
|
||||
};
|
||||
let _ = sql_ast.visit(&mut this);
|
||||
if let Some(e) = this.extract_error {
|
||||
return Err(e);
|
||||
}
|
||||
Ok(this.parameters)
|
||||
}
|
||||
|
||||
fn replace_with_placeholder(&mut self, value: &mut Expr, param: StmtParam) {
|
||||
let placeholder =
|
||||
if let Some(existing_idx) = self.parameters.iter().position(|p| *p == param) {
|
||||
// Parameter already exists, use its index
|
||||
self.make_placeholder_for_index(existing_idx + 1)
|
||||
} else {
|
||||
// New parameter, add it to the list
|
||||
let placeholder = self.make_placeholder();
|
||||
log::trace!("Replacing {param} with {placeholder}");
|
||||
self.parameters.push(param);
|
||||
placeholder
|
||||
};
|
||||
*value = placeholder;
|
||||
}
|
||||
|
||||
fn make_placeholder_for_index(&self, index: usize) -> Expr {
|
||||
let name = make_tmp_placeholder(self.db_info.kind, index);
|
||||
let data_type = match self.db_info.database_type {
|
||||
SupportedDatabase::MySql => DataType::Char(None),
|
||||
SupportedDatabase::Mssql => DataType::Varchar(Some(CharacterLength::Max)),
|
||||
SupportedDatabase::Postgres | SupportedDatabase::Sqlite => DataType::Text,
|
||||
SupportedDatabase::Oracle => DataType::Varchar(Some(CharacterLength::IntegerLength {
|
||||
length: 4000,
|
||||
unit: None,
|
||||
})),
|
||||
_ => DataType::Varchar(None),
|
||||
};
|
||||
let value = Expr::value(Value::Placeholder(name));
|
||||
Expr::Cast {
|
||||
expr: Box::new(value),
|
||||
data_type,
|
||||
format: None,
|
||||
kind: CastKind::Cast,
|
||||
array: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn make_placeholder(&self) -> Expr {
|
||||
self.make_placeholder_for_index(self.parameters.len() + 1)
|
||||
}
|
||||
|
||||
pub(super) fn is_own_placeholder(&self, param: &str) -> bool {
|
||||
let prefix = get_placeholder_prefix(self.db_info.kind);
|
||||
if let Some(param) = param.strip_prefix(prefix)
|
||||
&& let Ok(index) = param.parse::<usize>()
|
||||
{
|
||||
return index <= self.parameters.len() + 1;
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
struct InvalidFunctionFinder;
|
||||
impl Visitor for InvalidFunctionFinder {
|
||||
type Break = (String, Vec<FunctionArg>);
|
||||
fn pre_visit_expr(&mut self, value: &Expr) -> ControlFlow<Self::Break> {
|
||||
match value {
|
||||
Expr::Function(Function {
|
||||
name: ObjectName(func_name_parts),
|
||||
args:
|
||||
FunctionArguments::List(FunctionArgumentList {
|
||||
args,
|
||||
duplicate_treatment: None,
|
||||
..
|
||||
}),
|
||||
..
|
||||
}) if is_sqlpage_func(func_name_parts) => {
|
||||
let func_name = sqlpage_func_name(func_name_parts);
|
||||
let arguments = args.clone();
|
||||
return ControlFlow::Break((func_name.to_string(), arguments));
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
ControlFlow::Continue(())
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn validate_function_calls(stmt: &Statement) -> anyhow::Result<()> {
|
||||
let mut finder = InvalidFunctionFinder;
|
||||
if let ControlFlow::Break((func_name, mut args)) = stmt.visit(&mut finder) {
|
||||
let ctx = ParamExtractContext {
|
||||
parent_func: Some(func_name.clone()),
|
||||
};
|
||||
function_args_to_stmt_params(&mut args, &ctx)?;
|
||||
|
||||
let args_str = FormatArguments(&args);
|
||||
let error_msg = format!(
|
||||
"Invalid SQLPage function call: sqlpage.{func_name}({args_str})\n\n\
|
||||
Arbitrary SQL expressions as function arguments are not supported.\n\n\
|
||||
SQLPage functions can either:\n\
|
||||
1. Run BEFORE the query (to provide input values)\n\
|
||||
2. Run AFTER the query (to process the results)\n\
|
||||
But they can't run DURING the query - the database doesn't know how to call them!\n\n\
|
||||
To fix this, you can either:\n\
|
||||
1. Store the function argument in a variable first:\n\
|
||||
SET {func_name}_arg = ...;\n\
|
||||
SET {func_name}_result = sqlpage.{func_name}(${func_name}_arg);\n\
|
||||
SELECT * FROM example WHERE xxx = ${func_name}_result;\n\n\
|
||||
2. Or move the function to the top level to process results:\n\
|
||||
SELECT sqlpage.{func_name}(...) FROM example;"
|
||||
);
|
||||
Err(anyhow::anyhow!(error_msg))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/** This is a helper struct to format a list of arguments for an error message. */
|
||||
struct FormatArguments<'a>(&'a [FunctionArg]);
|
||||
impl std::fmt::Display for FormatArguments<'_> {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let mut args = self.0.iter();
|
||||
if let Some(arg) = args.next() {
|
||||
write!(f, "{arg}")?;
|
||||
}
|
||||
for arg in args {
|
||||
write!(f, ", {arg}")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub(crate) struct ParamExtractContext {
|
||||
pub parent_func: Option<String>,
|
||||
}
|
||||
|
||||
impl ParamExtractContext {
|
||||
fn with_parent(parent: &str) -> Self {
|
||||
Self {
|
||||
parent_func: Some(parent.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_error(&self, e: &ExprToParamError, arguments: &[FunctionArg]) -> SqlPageFunctionError {
|
||||
let line = e.line.unwrap_or(0);
|
||||
let func_name = self.parent_func.as_deref().unwrap_or("unknown").to_string();
|
||||
let arguments_str = FormatArguments(arguments).to_string();
|
||||
|
||||
let reason = match &e.kind {
|
||||
ExprToParamErrorKind::UnsupportedExpr { summary } => {
|
||||
format!(
|
||||
"\"{summary}\" is an sql expression, which cannot be passed as a nested sqlpage function argument."
|
||||
)
|
||||
}
|
||||
ExprToParamErrorKind::UnemulatedFunction { name } => {
|
||||
format!(
|
||||
"\"{name}\" is not a supported sqlpage function. Only a few basic sql functions like concat or json_object can be used inside sqlpage functions."
|
||||
)
|
||||
}
|
||||
ExprToParamErrorKind::NamedArgs => "Named function arguments are not supported.\n\
|
||||
Please use positional arguments only."
|
||||
.to_string(),
|
||||
};
|
||||
|
||||
SqlPageFunctionError {
|
||||
line,
|
||||
func_name,
|
||||
arguments_str,
|
||||
reason,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct SqlPageFunctionError {
|
||||
pub line: u64,
|
||||
pub func_name: String,
|
||||
pub arguments_str: String,
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for SqlPageFunctionError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"Unsupported sqlpage function argument:\n\
|
||||
sqlpage.{func}({args_str})\n\n\
|
||||
{reason}\n\n\
|
||||
SQLPage functions can either:\n\
|
||||
1. Run BEFORE the query (to provide input values)\n\
|
||||
2. Run AFTER the query (to process the results)\n\
|
||||
But they can't run DURING the query - the database doesn't know how to call them!\n\n\
|
||||
To fix this, you can either:\n\
|
||||
1. Store the function argument in a variable first:\n\
|
||||
SET {func}_arg = ...;\n\
|
||||
SET {func}_result = sqlpage.{func}(${func}_arg);\n\
|
||||
SELECT * FROM example WHERE xxx = ${func}_result;\n\n\
|
||||
2. Or move the function to the top level to process results:\n\
|
||||
SELECT sqlpage.{func}(...) FROM example;",
|
||||
func = self.func_name,
|
||||
args_str = self.arguments_str,
|
||||
reason = self.reason
|
||||
)
|
||||
}
|
||||
}
|
||||
impl std::error::Error for SqlPageFunctionError {}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ExprToParamError {
|
||||
line: Option<u64>,
|
||||
kind: ExprToParamErrorKind,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum ExprToParamErrorKind {
|
||||
UnsupportedExpr { summary: String },
|
||||
UnemulatedFunction { name: String },
|
||||
NamedArgs,
|
||||
}
|
||||
|
||||
fn expr_summary(expr: &Expr) -> String {
|
||||
match expr {
|
||||
Expr::CompoundIdentifier(idents) => {
|
||||
let s = idents
|
||||
.iter()
|
||||
.map(|i| i.value.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(".");
|
||||
format!("column/table reference '{s}'")
|
||||
}
|
||||
_ => format!("{expr}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn function_arg_to_stmt_param(
|
||||
arg: &mut FunctionArg,
|
||||
ctx: &ParamExtractContext,
|
||||
) -> Result<StmtParam, ExprToParamError> {
|
||||
let expr = function_arg_expr(arg).ok_or(ExprToParamError {
|
||||
line: None,
|
||||
kind: ExprToParamErrorKind::NamedArgs,
|
||||
})?;
|
||||
expr_to_stmt_param(expr, ctx)
|
||||
}
|
||||
|
||||
pub(crate) fn function_args_to_stmt_params(
|
||||
arguments: &mut [FunctionArg],
|
||||
ctx: &ParamExtractContext,
|
||||
) -> anyhow::Result<Vec<StmtParam>> {
|
||||
let mut params = Vec::with_capacity(arguments.len());
|
||||
// We iterate manually so we can pass the entire `arguments` slice to into_error on failure
|
||||
for arg in arguments.iter_mut() {
|
||||
match function_arg_to_stmt_param(arg, ctx) {
|
||||
Ok(p) => params.push(p),
|
||||
Err(e) => {
|
||||
let func_err = ctx.build_error(&e, arguments);
|
||||
return Err(anyhow::Error::new(func_err));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(params)
|
||||
}
|
||||
|
||||
fn emulated_func_args_to_param(
|
||||
func_name: &str,
|
||||
args: &mut [FunctionArg],
|
||||
line: u64,
|
||||
) -> Result<StmtParam, ExprToParamError> {
|
||||
let inner = ParamExtractContext::with_parent(func_name);
|
||||
if func_name.eq_ignore_ascii_case("concat") {
|
||||
let mut concat_args = Vec::with_capacity(args.len());
|
||||
for a in args {
|
||||
concat_args.push(function_arg_to_stmt_param(a, &inner)?);
|
||||
}
|
||||
Ok(StmtParam::Concat(concat_args))
|
||||
} else if func_name.eq_ignore_ascii_case("json_object")
|
||||
|| func_name.eq_ignore_ascii_case("jsonb_object")
|
||||
|| func_name.eq_ignore_ascii_case("json_build_object")
|
||||
|| func_name.eq_ignore_ascii_case("jsonb_build_object")
|
||||
{
|
||||
let mut json_obj_args = Vec::with_capacity(args.len());
|
||||
for a in args {
|
||||
json_obj_args.push(function_arg_to_stmt_param(a, &inner)?);
|
||||
}
|
||||
Ok(StmtParam::JsonObject(json_obj_args))
|
||||
} else if func_name.eq_ignore_ascii_case("json_array")
|
||||
|| func_name.eq_ignore_ascii_case("jsonb_array")
|
||||
|| func_name.eq_ignore_ascii_case("json_build_array")
|
||||
|| func_name.eq_ignore_ascii_case("jsonb_build_array")
|
||||
{
|
||||
let mut json_obj_args = Vec::with_capacity(args.len());
|
||||
for a in args {
|
||||
json_obj_args.push(function_arg_to_stmt_param(a, &inner)?);
|
||||
}
|
||||
Ok(StmtParam::JsonArray(json_obj_args))
|
||||
} else if func_name.eq_ignore_ascii_case("coalesce") {
|
||||
let mut coalesce_args = Vec::with_capacity(args.len());
|
||||
for a in args {
|
||||
coalesce_args.push(function_arg_to_stmt_param(a, &inner)?);
|
||||
}
|
||||
Ok(StmtParam::Coalesce(coalesce_args))
|
||||
} else {
|
||||
Err(ExprToParamError {
|
||||
line: Some(line),
|
||||
kind: ExprToParamErrorKind::UnemulatedFunction {
|
||||
name: func_name.to_string(),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn expr_to_stmt_param(
|
||||
arg: &mut Expr,
|
||||
ctx: &ParamExtractContext,
|
||||
) -> Result<StmtParam, ExprToParamError> {
|
||||
let line = arg.span().start.line;
|
||||
match arg {
|
||||
Expr::Value(ValueWithSpan {
|
||||
value: Value::Placeholder(placeholder),
|
||||
..
|
||||
}) => Ok(map_param(std::mem::take(placeholder))),
|
||||
Expr::Identifier(ident) => extract_ident_param(ident).ok_or_else(|| ExprToParamError {
|
||||
line: Some(line),
|
||||
kind: ExprToParamErrorKind::UnsupportedExpr {
|
||||
summary: expr_summary(arg),
|
||||
},
|
||||
}),
|
||||
Expr::Function(Function {
|
||||
name: ObjectName(func_name_parts),
|
||||
args:
|
||||
FunctionArguments::List(FunctionArgumentList {
|
||||
args,
|
||||
duplicate_treatment: None,
|
||||
..
|
||||
}),
|
||||
..
|
||||
}) if is_sqlpage_func(func_name_parts) => Ok(func_call_to_param(
|
||||
sqlpage_func_name(func_name_parts),
|
||||
args.as_mut_slice(),
|
||||
ctx,
|
||||
)),
|
||||
Expr::Value(ValueWithSpan {
|
||||
value: Value::SingleQuotedString(param_value),
|
||||
..
|
||||
}) => Ok(StmtParam::Literal(std::mem::take(param_value))),
|
||||
Expr::Value(ValueWithSpan {
|
||||
value: Value::Number(param_value, _is_long),
|
||||
..
|
||||
}) => Ok(StmtParam::Literal(param_value.clone())),
|
||||
Expr::Value(ValueWithSpan {
|
||||
value: Value::Null, ..
|
||||
}) => Ok(StmtParam::Null),
|
||||
Expr::BinaryOp {
|
||||
left,
|
||||
op: BinaryOperator::StringConcat,
|
||||
right,
|
||||
} => {
|
||||
let left = expr_to_stmt_param(left, ctx)?;
|
||||
let right = expr_to_stmt_param(right, ctx)?;
|
||||
Ok(StmtParam::Concat(vec![left, right]))
|
||||
}
|
||||
Expr::Function(Function {
|
||||
name: ObjectName(func_name_parts),
|
||||
args:
|
||||
FunctionArguments::List(FunctionArgumentList {
|
||||
args,
|
||||
duplicate_treatment: None,
|
||||
..
|
||||
}),
|
||||
..
|
||||
}) if func_name_parts.len() == 1 => {
|
||||
let func_name = func_name_parts[0]
|
||||
.as_ident()
|
||||
.map(|ident| ident.value.as_str())
|
||||
.unwrap_or_default();
|
||||
emulated_func_args_to_param(func_name, args.as_mut_slice(), line)
|
||||
}
|
||||
_ => Err(ExprToParamError {
|
||||
line: Some(line),
|
||||
kind: ExprToParamErrorKind::UnsupportedExpr {
|
||||
summary: expr_summary(arg),
|
||||
},
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn function_arg_expr(arg: &mut FunctionArg) -> Option<&mut Expr> {
|
||||
match arg {
|
||||
FunctionArg::Unnamed(FunctionArgExpr::Expr(expr)) => Some(expr),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
#[must_use]
|
||||
pub(super) fn make_tmp_placeholder(kind: AnyKind, arg_number: usize) -> String {
|
||||
let prefix = if let Some((_, DbPlaceHolder::PrefixedNumber { prefix })) =
|
||||
DB_PLACEHOLDERS.iter().find(|(db_typ, _)| *db_typ == kind)
|
||||
{
|
||||
prefix
|
||||
} else {
|
||||
TEMP_PLACEHOLDER_PREFIX
|
||||
};
|
||||
format!("{prefix}{arg_number}")
|
||||
}
|
||||
|
||||
pub(super) fn extract_ident_param(Ident { value, .. }: &mut Ident) -> Option<StmtParam> {
|
||||
if value.starts_with('$') || value.starts_with(':') {
|
||||
let name = std::mem::take(value);
|
||||
Some(map_param(name))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn map_param(mut name: String) -> StmtParam {
|
||||
if name.is_empty() {
|
||||
return StmtParam::PostOrGet(name);
|
||||
}
|
||||
let prefix = name.remove(0);
|
||||
match prefix {
|
||||
'$' => StmtParam::PostOrGet(name),
|
||||
':' => StmtParam::Post(name),
|
||||
_ => StmtParam::Get(name),
|
||||
}
|
||||
}
|
||||
|
||||
impl VisitorMut for ParameterExtractor {
|
||||
type Break = ();
|
||||
fn pre_visit_expr(&mut self, value: &mut Expr) -> ControlFlow<Self::Break> {
|
||||
match value {
|
||||
Expr::Identifier(ident) => {
|
||||
if let Some(param) = extract_ident_param(ident) {
|
||||
self.replace_with_placeholder(value, param);
|
||||
}
|
||||
}
|
||||
Expr::Value(ValueWithSpan {
|
||||
value: Value::Placeholder(param),
|
||||
..
|
||||
}) if !self.is_own_placeholder(param) =>
|
||||
// this check is to avoid recursively replacing placeholders in the form of '?', or '$1', '$2', which we emit ourselves
|
||||
{
|
||||
let name = std::mem::take(param);
|
||||
self.replace_with_placeholder(value, map_param(name));
|
||||
}
|
||||
Expr::Function(Function {
|
||||
name: ObjectName(func_name_parts),
|
||||
args:
|
||||
FunctionArguments::List(FunctionArgumentList {
|
||||
args,
|
||||
duplicate_treatment: None,
|
||||
..
|
||||
}),
|
||||
filter: None,
|
||||
null_treatment: None,
|
||||
over: None,
|
||||
..
|
||||
}) if is_sqlpage_func(func_name_parts) => {
|
||||
let func_name = sqlpage_func_name(func_name_parts);
|
||||
log::trace!("Handling builtin function: {func_name}");
|
||||
let arguments = std::mem::take(args);
|
||||
let ctx = ParamExtractContext {
|
||||
parent_func: Some(func_name.to_string()),
|
||||
};
|
||||
let mut arguments_clone = arguments.clone();
|
||||
let param = func_call_to_param(func_name, &mut arguments_clone, &ctx);
|
||||
if let StmtParam::Error(msg) = ¶m {
|
||||
log::trace!("Skipping extraction of {func_name} due to: {msg}");
|
||||
*args = arguments;
|
||||
return ControlFlow::Continue(());
|
||||
}
|
||||
self.replace_with_placeholder(value, param);
|
||||
}
|
||||
// Replace 'str1' || 'str2' with CONCAT('str1', 'str2') for MSSQL
|
||||
Expr::BinaryOp {
|
||||
left,
|
||||
op: BinaryOperator::StringConcat,
|
||||
right,
|
||||
} if self.db_info.database_type == SupportedDatabase::Mssql => {
|
||||
let left = std::mem::replace(left.as_mut(), Expr::value(Value::Null));
|
||||
let right = std::mem::replace(right.as_mut(), Expr::value(Value::Null));
|
||||
*value = Expr::Function(Function {
|
||||
name: ObjectName(vec![ObjectNamePart::Identifier(Ident::new("CONCAT"))]),
|
||||
args: FunctionArguments::List(FunctionArgumentList {
|
||||
args: vec![
|
||||
FunctionArg::Unnamed(FunctionArgExpr::Expr(left)),
|
||||
FunctionArg::Unnamed(FunctionArgExpr::Expr(right)),
|
||||
],
|
||||
duplicate_treatment: None,
|
||||
clauses: Vec::new(),
|
||||
}),
|
||||
parameters: FunctionArguments::None,
|
||||
over: None,
|
||||
filter: None,
|
||||
null_treatment: None,
|
||||
within_group: Vec::new(),
|
||||
uses_odbc_syntax: false,
|
||||
});
|
||||
}
|
||||
Expr::Cast {
|
||||
kind: kind @ CastKind::DoubleColon,
|
||||
..
|
||||
} if ![
|
||||
SupportedDatabase::Postgres,
|
||||
SupportedDatabase::Duckdb,
|
||||
SupportedDatabase::Snowflake,
|
||||
SupportedDatabase::Generic,
|
||||
]
|
||||
.contains(&self.db_info.database_type) =>
|
||||
{
|
||||
log::warn!(
|
||||
"Casting with '::' is not supported on your database. \
|
||||
For backwards compatibility with older SQLPage versions, we will transform it to CAST(... AS ...)."
|
||||
);
|
||||
*kind = CastKind::Cast;
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
ControlFlow::<()>::Continue(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,712 @@
|
||||
use crate::utils::add_value_to_map;
|
||||
use crate::webserver::database::blob_to_data_url;
|
||||
use bigdecimal::BigDecimal;
|
||||
use chrono::{DateTime, FixedOffset, NaiveDate, NaiveDateTime};
|
||||
use serde_json::{self, Map, Value};
|
||||
use sqlx::any::{AnyColumn, AnyRow, AnyTypeInfo, AnyTypeInfoKind};
|
||||
use sqlx::postgres::PgValueRef;
|
||||
use sqlx::postgres::types::PgRange;
|
||||
use sqlx::{Column, Row, TypeInfo, ValueRef};
|
||||
use sqlx::{Decode, Type};
|
||||
|
||||
pub fn row_to_json(row: &AnyRow) -> Value {
|
||||
use Value::Object;
|
||||
|
||||
let columns = row.columns();
|
||||
let mut map = Map::new();
|
||||
for col in columns {
|
||||
let key = canonical_col_name(col);
|
||||
let value: Value = sql_to_json(row, col);
|
||||
map = add_value_to_map(map, (key, value));
|
||||
}
|
||||
Object(map)
|
||||
}
|
||||
|
||||
fn canonical_col_name(col: &AnyColumn) -> String {
|
||||
// Some databases fold all unquoted identifiers to uppercase but SQLPage uses lowercase property names
|
||||
if matches!(col.type_info().0, AnyTypeInfoKind::Odbc(_))
|
||||
&& col
|
||||
.name()
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_uppercase() || c == '_')
|
||||
{
|
||||
col.name().to_ascii_lowercase()
|
||||
} else {
|
||||
col.name().to_owned()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn sql_to_json(row: &AnyRow, col: &sqlx::any::AnyColumn) -> Value {
|
||||
let raw_value_result = row.try_get_raw(col.ordinal());
|
||||
match raw_value_result {
|
||||
Ok(raw_value) if !raw_value.is_null() => {
|
||||
let mut raw_value = Some(raw_value);
|
||||
let decoded = sql_nonnull_to_json(|| {
|
||||
raw_value
|
||||
.take()
|
||||
.unwrap_or_else(|| row.try_get_raw(col.ordinal()).unwrap())
|
||||
});
|
||||
log::trace!("Decoded value: {decoded:?}");
|
||||
decoded
|
||||
}
|
||||
Ok(_null) => Value::Null,
|
||||
Err(e) => {
|
||||
log::warn!("Unable to extract value from row: {e:?}");
|
||||
Value::Null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_raw<'a, T: Decode<'a, sqlx::any::Any> + Default>(
|
||||
raw_value: sqlx::any::AnyValueRef<'a>,
|
||||
) -> T {
|
||||
match T::decode(raw_value) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
let type_name = std::any::type_name::<T>();
|
||||
log::error!("Failed to decode {type_name} value: {e}");
|
||||
T::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_pg_range<'r, T>(raw_value: sqlx::any::AnyValueRef<'r>) -> Value
|
||||
where
|
||||
T: std::fmt::Display
|
||||
+ Type<sqlx::postgres::Postgres>
|
||||
+ for<'a> sqlx::Decode<'a, sqlx::postgres::Postgres>,
|
||||
{
|
||||
let Ok(pg_val): Result<PgValueRef<'r>, _> = raw_value.try_into() else {
|
||||
log::error!("Only postgres range values are supported");
|
||||
return Value::Null;
|
||||
};
|
||||
match <PgRange<T> as sqlx::Decode<'r, sqlx::postgres::Postgres>>::decode(pg_val) {
|
||||
Ok(pg_range) => pg_range.to_string().into(),
|
||||
Err(e) => {
|
||||
log::error!("Failed to decode postgres range value: {e}");
|
||||
Value::Null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn decimal_to_json(decimal: &BigDecimal) -> Value {
|
||||
// to_plain_string always returns a valid JSON string
|
||||
Value::Number(serde_json::Number::from_string_unchecked(
|
||||
decimal.normalized().to_plain_string(),
|
||||
))
|
||||
}
|
||||
|
||||
pub fn sql_nonnull_to_json<'r>(mut get_ref: impl FnMut() -> sqlx::any::AnyValueRef<'r>) -> Value {
|
||||
use AnyTypeInfoKind::{Mssql, MySql};
|
||||
let raw_value = get_ref();
|
||||
let type_info = raw_value.type_info();
|
||||
let type_name = type_info.name();
|
||||
log::trace!("Decoding a value of type {type_name:?} (type info: {type_info:?})");
|
||||
let AnyTypeInfo(ref db_type) = *type_info;
|
||||
match type_name {
|
||||
"REAL" | "FLOAT" | "FLOAT4" | "FLOAT8" | "DOUBLE" => decode_raw::<f64>(raw_value).into(),
|
||||
"NUMERIC" | "DECIMAL" => decimal_to_json(&decode_raw(raw_value)),
|
||||
"INT8" | "BIGINT" | "SERIAL8" | "BIGSERIAL" | "IDENTITY" | "INT64" | "INTEGER8"
|
||||
| "BIGINT SIGNED" => decode_raw::<i64>(raw_value).into(),
|
||||
"INT" | "INT4" | "INTEGER" | "MEDIUMINT" | "YEAR" => decode_raw::<i32>(raw_value).into(),
|
||||
"INT2" | "SMALLINT" | "TINYINT" => decode_raw::<i16>(raw_value).into(),
|
||||
"BIGINT UNSIGNED" => decode_raw::<u64>(raw_value).into(),
|
||||
"INT UNSIGNED" | "MEDIUMINT UNSIGNED" | "SMALLINT UNSIGNED" | "TINYINT UNSIGNED" => {
|
||||
decode_raw::<u32>(raw_value).into()
|
||||
}
|
||||
"BOOL" | "BOOLEAN" => decode_raw::<bool>(raw_value).into(),
|
||||
"BIT" if matches!(db_type, Mssql(_)) => decode_raw::<bool>(raw_value).into(),
|
||||
"BIT" if matches!(db_type, MySql(mysql_type) if mysql_type.max_size() == Some(1)) => {
|
||||
decode_raw::<bool>(raw_value).into()
|
||||
}
|
||||
"BIT" if matches!(db_type, MySql(_)) => decode_raw::<u64>(raw_value).into(),
|
||||
"DATE" => decode_raw::<chrono::NaiveDate>(raw_value)
|
||||
.to_string()
|
||||
.into(),
|
||||
"TIME" | "TIMETZ" => decode_raw::<chrono::NaiveTime>(raw_value)
|
||||
.to_string()
|
||||
.into(),
|
||||
"DATETIMEOFFSET" | "TIMESTAMP" | "TIMESTAMPTZ" => {
|
||||
decode_raw::<DateTime<FixedOffset>>(raw_value)
|
||||
.to_rfc3339()
|
||||
.into()
|
||||
}
|
||||
"DATETIME" | "DATETIME2" => decode_raw::<NaiveDateTime>(raw_value)
|
||||
.format("%FT%T%.f")
|
||||
.to_string()
|
||||
.into(),
|
||||
"MONEY" | "SMALLMONEY" if matches!(db_type, Mssql(_)) => {
|
||||
decode_raw::<f64>(raw_value).into()
|
||||
}
|
||||
"UUID" | "UNIQUEIDENTIFIER" => decode_raw::<sqlx::types::uuid::Uuid>(raw_value)
|
||||
.to_string()
|
||||
.into(),
|
||||
"JSON" | "JSON[]" | "JSONB" | "JSONB[]" => decode_raw::<Value>(raw_value),
|
||||
"BLOB" | "BYTEA" | "FILESTREAM" | "VARBINARY" | "BIGVARBINARY" | "BINARY" | "IMAGE" => {
|
||||
blob_to_data_url::vec_to_data_uri_value(&decode_raw::<Vec<u8>>(raw_value))
|
||||
}
|
||||
"INT4RANGE" => decode_pg_range::<i32>(raw_value),
|
||||
"INT8RANGE" => decode_pg_range::<i64>(raw_value),
|
||||
"NUMRANGE" => decode_pg_range::<BigDecimal>(raw_value),
|
||||
"DATERANGE" => decode_pg_range::<NaiveDate>(raw_value),
|
||||
"TSRANGE" => decode_pg_range::<NaiveDateTime>(raw_value),
|
||||
"TSTZRANGE" => decode_pg_range::<DateTime<FixedOffset>>(raw_value),
|
||||
// Deserialize as a string by default
|
||||
_ => decode_raw::<String>(raw_value).into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Takes the first column of a row and converts it to a string.
|
||||
pub fn row_to_string(row: &AnyRow) -> Option<String> {
|
||||
let col = row.columns().first()?;
|
||||
match sql_to_json(row, col) {
|
||||
serde_json::Value::String(s) => Some(s),
|
||||
serde_json::Value::Null => None,
|
||||
other => Some(other.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::app_config::tests::test_database_url;
|
||||
|
||||
use super::*;
|
||||
use sqlx::Connection;
|
||||
|
||||
fn setup_logging() {
|
||||
crate::telemetry::init_test_logging();
|
||||
}
|
||||
|
||||
fn db_specific_test(db_type: &str) -> Option<String> {
|
||||
setup_logging();
|
||||
let db_url = test_database_url();
|
||||
if db_url.starts_with(db_type) {
|
||||
Some(db_url)
|
||||
} else {
|
||||
log::warn!("Skipping test because DATABASE_URL is not set to a {db_type} database");
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[actix_web::test]
|
||||
async fn test_row_to_json() -> anyhow::Result<()> {
|
||||
use sqlx::Connection;
|
||||
let db_url = test_database_url();
|
||||
let mut c = sqlx::AnyConnection::connect(&db_url).await?;
|
||||
let row = sqlx::query(
|
||||
"SELECT
|
||||
123.456 as one_value,
|
||||
1 as two_values,
|
||||
2 as two_values,
|
||||
'x' as three_values,
|
||||
'y' as three_values,
|
||||
'z' as three_values
|
||||
",
|
||||
)
|
||||
.fetch_one(&mut c)
|
||||
.await?;
|
||||
expect_json_object_equal(
|
||||
&row_to_json(&row),
|
||||
&serde_json::json!({
|
||||
"one_value": 123.456,
|
||||
"two_values": [1,2],
|
||||
"three_values": ["x","y","z"],
|
||||
}),
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[actix_web::test]
|
||||
async fn test_postgres_types() -> anyhow::Result<()> {
|
||||
let Some(db_url) = db_specific_test("postgres") else {
|
||||
return Ok(());
|
||||
};
|
||||
let mut c = sqlx::AnyConnection::connect(&db_url).await?;
|
||||
let row = sqlx::query(
|
||||
"SELECT
|
||||
42::INT2 as small_int,
|
||||
42::INT4 as integer,
|
||||
42::INT8 as big_int,
|
||||
42.25::FLOAT4 as float4,
|
||||
42.25::FLOAT8 as float8,
|
||||
123456789123456789123456789::NUMERIC as numeric,
|
||||
TRUE as boolean,
|
||||
'2024-03-14'::DATE as date,
|
||||
'13:14:15'::TIME as time,
|
||||
'2024-03-14 13:14:15'::TIMESTAMP as timestamp,
|
||||
'2024-03-14 13:14:15+02:00'::TIMESTAMPTZ as timestamptz,
|
||||
INTERVAL '1 year 2 months 3 days' as complex_interval,
|
||||
INTERVAL '4 hours' as hour_interval,
|
||||
INTERVAL '1.5 days' as fractional_interval,
|
||||
'{\"key\": \"value\"}'::JSON as json,
|
||||
'{\"key\": \"value\"}'::JSONB as jsonb,
|
||||
age('2024-03-14'::timestamp, '2024-01-01'::timestamp) as age_interval,
|
||||
justify_interval(interval '1 year 2 months 3 days') as justified_interval,
|
||||
1234.56::MONEY as money_val,
|
||||
'\\x68656c6c6f20776f726c64'::BYTEA as blob_data,
|
||||
'550e8400-e29b-41d4-a716-446655440000'::UUID as uuid,
|
||||
'[1,5)'::INT4RANGE as int4range,
|
||||
'[1,5]'::INT8RANGE as int8range,
|
||||
'[1.5,4.5)'::NUMRANGE as numrange,
|
||||
-- '[2024-11-12 01:02:03,2024-11-12 23:00:00)'::TSRANGE as tsrange,
|
||||
-- '[2024-11-12 01:02:03+01:00,2024-11-12 23:00:00+00:00)'::TSTZRANGE as tstzrange,
|
||||
'[2024-11-12,2024-11-13)'::DATERANGE as daterange
|
||||
",
|
||||
)
|
||||
.fetch_one(&mut c)
|
||||
.await?;
|
||||
|
||||
expect_json_object_equal(
|
||||
&row_to_json(&row),
|
||||
&serde_json::json!({
|
||||
"small_int": 42,
|
||||
"integer": 42,
|
||||
"big_int": 42,
|
||||
"float4": 42.25,
|
||||
"float8": 42.25,
|
||||
"numeric": 123_456_789_123_456_789_123_456_789_u128,
|
||||
"boolean": true,
|
||||
"date": "2024-03-14",
|
||||
"time": "13:14:15",
|
||||
"timestamp": "2024-03-14T13:14:15+00:00",
|
||||
"timestamptz": "2024-03-14T11:14:15+00:00",
|
||||
"complex_interval": "1 year 2 mons 3 days",
|
||||
"hour_interval": "04:00:00",
|
||||
"fractional_interval": "1 day 12:00:00",
|
||||
"json": {"key": "value"},
|
||||
"jsonb": {"key": "value"},
|
||||
"age_interval": "2 mons 13 days",
|
||||
"justified_interval": "1 year 2 mons 3 days",
|
||||
"money_val": "$1,234.56",
|
||||
"blob_data": "data:application/octet-stream;base64,aGVsbG8gd29ybGQ=",
|
||||
"uuid": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"int4range": "[1,5)",
|
||||
"int8range": "[1,6)",
|
||||
"numrange": "[1.5,4.5)",
|
||||
//"tsrange": "[2024-11-12 01:02:03,2024-11-12 23:00:00)", // todo: bug in sqlx datetime range parsing
|
||||
//"tstzrange": "[\"2024-11-12 02:00:00 +01:00\",\"2024-11-12 23:00:00 +00:00\")", // todo: tz info is lost in sqlx
|
||||
"daterange": "[2024-11-12,2024-11-13)"
|
||||
}),
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Postgres encodes values differently in prepared statements and in "simple" queries
|
||||
/// <https://www.postgresql.org/docs/9.1/protocol-flow.html#PROTOCOL-FLOW-EXT-QUERY>
|
||||
#[actix_web::test]
|
||||
async fn test_postgres_prepared_types() -> anyhow::Result<()> {
|
||||
let Some(db_url) = db_specific_test("postgres") else {
|
||||
return Ok(());
|
||||
};
|
||||
let mut c = sqlx::AnyConnection::connect(&db_url).await?;
|
||||
let row = sqlx::query(
|
||||
"SELECT
|
||||
'2024-03-14'::DATE as date,
|
||||
'13:14:15'::TIME as time,
|
||||
'2024-03-14 13:14:15+02:00'::TIMESTAMPTZ as timestamptz,
|
||||
INTERVAL '-01:02:03' as time_interval,
|
||||
'{\"key\": \"value\"}'::JSON as json,
|
||||
1234.56::MONEY as money_val,
|
||||
'\\x74657374'::BYTEA as blob_data,
|
||||
'550e8400-e29b-41d4-a716-446655440000'::UUID as uuid
|
||||
where $1",
|
||||
)
|
||||
.bind(true)
|
||||
.fetch_one(&mut c)
|
||||
.await?;
|
||||
|
||||
expect_json_object_equal(
|
||||
&row_to_json(&row),
|
||||
&serde_json::json!({
|
||||
"date": "2024-03-14",
|
||||
"time": "13:14:15",
|
||||
"timestamptz": "2024-03-14T11:14:15+00:00",
|
||||
"time_interval": "-01:02:03",
|
||||
"json": {"key": "value"},
|
||||
"money_val": "", // TODO: fix this bug: https://github.com/sqlpage/SQLPage/issues/983
|
||||
"blob_data": "data:application/octet-stream;base64,dGVzdA==",
|
||||
"uuid": "550e8400-e29b-41d4-a716-446655440000",
|
||||
}),
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[actix_web::test]
|
||||
async fn test_postgres_prepared_range_types() -> anyhow::Result<()> {
|
||||
let Some(db_url) = db_specific_test("postgres") else {
|
||||
return Ok(());
|
||||
};
|
||||
let mut c = sqlx::AnyConnection::connect(&db_url).await?;
|
||||
let row = sqlx::query(
|
||||
"SELECT
|
||||
'[1,5)'::INT4RANGE as int4range,
|
||||
'[2024-11-12 01:02:03,2024-11-12 23:00:00)'::TSRANGE as tsrange,
|
||||
'[2024-11-12 01:02:03+01:00,2024-11-12 23:00:00+00:00)'::TSTZRANGE as tstzrange,
|
||||
'[2024-11-12,2024-11-13)'::DATERANGE as daterange
|
||||
where $1",
|
||||
)
|
||||
.bind(true)
|
||||
.fetch_one(&mut c)
|
||||
.await?;
|
||||
|
||||
expect_json_object_equal(
|
||||
&row_to_json(&row),
|
||||
&serde_json::json!({
|
||||
"int4range": "[1,5)",
|
||||
"tsrange": "[2024-11-12 01:02:03,2024-11-12 23:00:00)",
|
||||
"tstzrange": "[2024-11-12 00:02:03 +00:00,2024-11-12 23:00:00 +00:00)", // todo: tz info is lost in sqlx
|
||||
"daterange": "[2024-11-12,2024-11-13)"
|
||||
}),
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[actix_web::test]
|
||||
async fn test_mysql_types() -> anyhow::Result<()> {
|
||||
let db_url = db_specific_test("mysql").or_else(|| db_specific_test("mariadb"));
|
||||
let Some(db_url) = db_url else {
|
||||
return Ok(());
|
||||
};
|
||||
let mut c = sqlx::AnyConnection::connect(&db_url).await?;
|
||||
|
||||
sqlx::query(
|
||||
"CREATE TEMPORARY TABLE _sqlp_t (
|
||||
tiny_int TINYINT,
|
||||
small_int SMALLINT,
|
||||
medium_int MEDIUMINT,
|
||||
signed_int INTEGER,
|
||||
big_int BIGINT,
|
||||
unsigned_int INTEGER UNSIGNED,
|
||||
tiny_int_unsigned TINYINT UNSIGNED,
|
||||
small_int_unsigned SMALLINT UNSIGNED,
|
||||
medium_int_unsigned MEDIUMINT UNSIGNED,
|
||||
big_int_unsigned BIGINT UNSIGNED,
|
||||
decimal_num DECIMAL(10,2),
|
||||
float_num FLOAT,
|
||||
double_num DOUBLE,
|
||||
bit_val BIT(1),
|
||||
date_val DATE,
|
||||
time_val TIME,
|
||||
datetime_val DATETIME,
|
||||
timestamp_val TIMESTAMP,
|
||||
year_val YEAR,
|
||||
char_val CHAR(10),
|
||||
varchar_val VARCHAR(50),
|
||||
text_val TEXT,
|
||||
blob_val BLOB
|
||||
) AS
|
||||
SELECT
|
||||
127 as tiny_int,
|
||||
32767 as small_int,
|
||||
8388607 as medium_int,
|
||||
-1000000 as signed_int,
|
||||
9223372036854775807 as big_int,
|
||||
1000000 as unsigned_int,
|
||||
255 as tiny_int_unsigned,
|
||||
65535 as small_int_unsigned,
|
||||
16777215 as medium_int_unsigned,
|
||||
18446744073709551615 as big_int_unsigned,
|
||||
123.45 as decimal_num,
|
||||
42.25 as float_num,
|
||||
42.25 as double_num,
|
||||
1 as bit_val,
|
||||
'2024-03-14' as date_val,
|
||||
'13:14:15' as time_val,
|
||||
'2024-03-14 13:14:15' as datetime_val,
|
||||
'2024-03-14 13:14:15' as timestamp_val,
|
||||
2024 as year_val,
|
||||
'CHAR' as char_val,
|
||||
'VARCHAR' as varchar_val,
|
||||
'TEXT' as text_val,
|
||||
x'626c6f62' as blob_val",
|
||||
)
|
||||
.execute(&mut c)
|
||||
.await?;
|
||||
|
||||
let row = sqlx::query("SELECT * FROM _sqlp_t")
|
||||
.fetch_one(&mut c)
|
||||
.await?;
|
||||
|
||||
expect_json_object_equal(
|
||||
&row_to_json(&row),
|
||||
&serde_json::json!({
|
||||
"tiny_int": 127,
|
||||
"small_int": 32767,
|
||||
"medium_int": 8_388_607,
|
||||
"signed_int": -1_000_000,
|
||||
"big_int": 9_223_372_036_854_775_807_u64,
|
||||
"unsigned_int": 1_000_000,
|
||||
"tiny_int_unsigned": 255,
|
||||
"small_int_unsigned": 65_535,
|
||||
"medium_int_unsigned": 16_777_215,
|
||||
"big_int_unsigned": 18_446_744_073_709_551_615_u64,
|
||||
"decimal_num": 123.45,
|
||||
"float_num": 42.25,
|
||||
"double_num": 42.25,
|
||||
"bit_val": true,
|
||||
"date_val": "2024-03-14",
|
||||
"time_val": "13:14:15",
|
||||
"datetime_val": "2024-03-14T13:14:15",
|
||||
"timestamp_val": "2024-03-14T13:14:15+00:00",
|
||||
"year_val": 2024,
|
||||
"char_val": "CHAR",
|
||||
"varchar_val": "VARCHAR",
|
||||
"text_val": "TEXT",
|
||||
"blob_val": "data:application/octet-stream;base64,YmxvYg=="
|
||||
}),
|
||||
);
|
||||
|
||||
sqlx::query("DROP TABLE _sqlp_t").execute(&mut c).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[actix_web::test]
|
||||
async fn test_sqlite_types() -> anyhow::Result<()> {
|
||||
let Some(db_url) = db_specific_test("sqlite") else {
|
||||
return Ok(());
|
||||
};
|
||||
let mut c = sqlx::AnyConnection::connect(&db_url).await?;
|
||||
let row = sqlx::query(
|
||||
"SELECT
|
||||
42 as integer,
|
||||
42.25 as real,
|
||||
'xxx' as string,
|
||||
x'68656c6c6f20776f726c64' as blob",
|
||||
)
|
||||
.fetch_one(&mut c)
|
||||
.await?;
|
||||
|
||||
expect_json_object_equal(
|
||||
&row_to_json(&row),
|
||||
&serde_json::json!({
|
||||
"integer": 42,
|
||||
"real": 42.25,
|
||||
"string": "xxx",
|
||||
"blob": "data:application/octet-stream;base64,aGVsbG8gd29ybGQ=",
|
||||
}),
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[actix_web::test]
|
||||
async fn test_mssql_types() -> anyhow::Result<()> {
|
||||
let Some(db_url) = db_specific_test("mssql") else {
|
||||
return Ok(());
|
||||
};
|
||||
let mut c = sqlx::AnyConnection::connect(&db_url).await?;
|
||||
let row = sqlx::query(
|
||||
"SELECT
|
||||
CAST(1 AS BIT) as true_bit,
|
||||
CAST(0 AS BIT) as false_bit,
|
||||
CAST(NULL AS BIT) as null_bit,
|
||||
CAST(255 AS TINYINT) as tiny_int,
|
||||
CAST(42 AS SMALLINT) as small_int,
|
||||
CAST(42 AS INT) as integer,
|
||||
CAST(42 AS BIGINT) as big_int,
|
||||
CAST(42.25 AS REAL) as real,
|
||||
CAST(42.25 AS FLOAT) as float,
|
||||
CAST(42.25 AS DECIMAL(10,2)) as decimal,
|
||||
CAST('2024-03-14' AS DATE) as date,
|
||||
CAST('13:14:15' AS TIME) as time,
|
||||
CAST('2024-03-14 13:14:15' AS DATETIME) as datetime,
|
||||
CAST('2024-03-14 13:14:15' AS DATETIME2) as datetime2,
|
||||
CAST('2024-03-14 13:14:15 +02:00' AS DATETIMEOFFSET) as datetimeoffset,
|
||||
N'Unicode String' as nvarchar,
|
||||
'ASCII String' as varchar,
|
||||
CAST(1234.56 AS MONEY) as money_val,
|
||||
CAST(12.34 AS SMALLMONEY) as small_money_val,
|
||||
CAST(0x6D7373716C AS VARBINARY(10)) as blob_data,
|
||||
CONVERT(UNIQUEIDENTIFIER, '6F9619FF-8B86-D011-B42D-00C04FC964FF') as unique_identifier
|
||||
"
|
||||
)
|
||||
.fetch_one(&mut c)
|
||||
.await?;
|
||||
|
||||
expect_json_object_equal(
|
||||
&row_to_json(&row),
|
||||
&serde_json::json!({
|
||||
"true_bit": true,
|
||||
"false_bit": false,
|
||||
"null_bit": null,
|
||||
"tiny_int": 255,
|
||||
"small_int": 42,
|
||||
"integer": 42,
|
||||
"big_int": 42,
|
||||
"real": 42.25,
|
||||
"float": 42.25,
|
||||
"decimal": 42.25,
|
||||
"date": "2024-03-14",
|
||||
"time": "13:14:15",
|
||||
"datetime": "2024-03-14T13:14:15",
|
||||
"datetime2": "2024-03-14T13:14:15",
|
||||
"datetimeoffset": "2024-03-14T13:14:15+02:00",
|
||||
"nvarchar": "Unicode String",
|
||||
"varchar": "ASCII String",
|
||||
"money_val": 1234.56,
|
||||
"small_money_val": 12.34,
|
||||
"blob_data": "data:application/octet-stream;base64,bXNzcWw=",
|
||||
"unique_identifier": "6f9619ff-8b86-d011-b42d-00c04fc964ff"
|
||||
}),
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn expect_json_object_equal(actual: &Value, expected: &Value) {
|
||||
use std::fmt::Write;
|
||||
|
||||
if json_values_equal(actual, expected) {
|
||||
return;
|
||||
}
|
||||
let actual = actual.as_object().unwrap();
|
||||
let expected = expected.as_object().unwrap();
|
||||
|
||||
let all_keys: std::collections::BTreeSet<_> =
|
||||
actual.keys().chain(expected.keys()).collect();
|
||||
let max_key_len = all_keys.iter().map(|k| k.len()).max().unwrap_or(0);
|
||||
|
||||
let mut comparison_string = String::new();
|
||||
for key in all_keys {
|
||||
let actual_value = actual.get(key).unwrap_or(&Value::Null);
|
||||
let expected_value = expected.get(key).unwrap_or(&Value::Null);
|
||||
if json_values_equal(actual_value, expected_value) {
|
||||
continue;
|
||||
}
|
||||
writeln!(
|
||||
&mut comparison_string,
|
||||
"{key:<max_key_len$} actual : {actual_value:?}\n{key:max_key_len$} expected: {expected_value:?}\n"
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
assert_eq!(
|
||||
actual, expected,
|
||||
"JSON objects are not equal:\n\n{comparison_string}"
|
||||
);
|
||||
}
|
||||
|
||||
#[actix_web::test]
|
||||
async fn test_canonical_col_name_variations() -> anyhow::Result<()> {
|
||||
let db_url = test_database_url();
|
||||
let mut c = sqlx::AnyConnection::connect(&db_url).await?;
|
||||
|
||||
// Test various column name formats to ensure canonical_col_name works correctly
|
||||
let row = sqlx::query(
|
||||
r#"SELECT
|
||||
42 as "UPPERCASE_COL",
|
||||
42 as "lowercase_col",
|
||||
42 as "Mixed_Case_Col",
|
||||
42 as "COL_WITH_123_NUMBERS",
|
||||
42 as "col-with-dashes",
|
||||
42 as "col with spaces",
|
||||
42 as "_UNDERSCORE_PREFIX",
|
||||
42 as "123_NUMBER_PREFIX"
|
||||
"#,
|
||||
)
|
||||
.fetch_one(&mut c)
|
||||
.await?;
|
||||
|
||||
let json_result = row_to_json(&row);
|
||||
|
||||
// For ODBC databases, uppercase columns should be converted to lowercase
|
||||
// For other databases, names should remain as-is
|
||||
let expected_json = if c.kind() == sqlx::any::AnyKind::Odbc {
|
||||
// ODBC database - uppercase should be converted to lowercase
|
||||
serde_json::json!({
|
||||
"uppercase_col": 42,
|
||||
"lowercase_col": 42,
|
||||
"Mixed_Case_Col": 42,
|
||||
"COL_WITH_123_NUMBERS": 42,
|
||||
"col-with-dashes": 42,
|
||||
"col with spaces": 42,
|
||||
"_underscore_prefix": 42,
|
||||
"123_NUMBER_PREFIX": 42
|
||||
})
|
||||
} else {
|
||||
// Non-ODBC database - names remain as-is
|
||||
serde_json::json!({
|
||||
"UPPERCASE_COL": 42,
|
||||
"lowercase_col": 42,
|
||||
"Mixed_Case_Col": 42,
|
||||
"COL_WITH_123_NUMBERS": 42,
|
||||
"col-with-dashes": 42,
|
||||
"col with spaces": 42,
|
||||
"_UNDERSCORE_PREFIX": 42,
|
||||
"123_NUMBER_PREFIX": 42
|
||||
})
|
||||
};
|
||||
|
||||
expect_json_object_equal(&json_result, &expected_json);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[actix_web::test]
|
||||
async fn test_row_to_json_edge_cases() -> anyhow::Result<()> {
|
||||
let db_url = test_database_url();
|
||||
let mut c = sqlx::AnyConnection::connect(&db_url).await?;
|
||||
let dbms_name = c.dbms_name().await.expect("retrieve db name");
|
||||
|
||||
// Test edge cases for row_to_json
|
||||
let row = sqlx::query(
|
||||
"SELECT
|
||||
NULL as null_col,
|
||||
'' as empty_string,
|
||||
0 as zero_value,
|
||||
-42 as negative_int,
|
||||
1.23456 as my_float,
|
||||
'special_chars_!@#$%^&*()' as special_chars,
|
||||
'line1
|
||||
line2' as multiline_string
|
||||
",
|
||||
)
|
||||
.fetch_one(&mut c)
|
||||
.await?;
|
||||
|
||||
let json_result = row_to_json(&row);
|
||||
|
||||
// For Oracle databases, empty string is treated as NULL.
|
||||
let empty_str_is_null = dbms_name.to_lowercase().contains("oracle");
|
||||
|
||||
let expected_json = serde_json::json!({
|
||||
"null_col": null,
|
||||
"empty_string": if empty_str_is_null { serde_json::Value::Null } else { serde_json::Value::String(String::new()) },
|
||||
"zero_value": 0,
|
||||
"negative_int": -42,
|
||||
"my_float": 1.23456,
|
||||
"special_chars": "special_chars_!@#$%^&*()",
|
||||
"multiline_string": "line1\nline2"
|
||||
});
|
||||
|
||||
expect_json_object_equal(&json_result, &expected_json);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Compare JSON values, treating integers and floats that are numerically equal as equal
|
||||
fn json_values_equal(a: &Value, b: &Value) -> bool {
|
||||
use Value::*;
|
||||
|
||||
match (a, b) {
|
||||
(Null, Null) => true,
|
||||
(Bool(a), Bool(b)) => a == b,
|
||||
(Number(a), Number(b)) => {
|
||||
// Treat integers and floats as equal if they represent the same numerical value
|
||||
a.as_f64() == b.as_f64()
|
||||
}
|
||||
(String(a), String(b)) => a == b,
|
||||
(Array(a), Array(b)) => {
|
||||
a.len() == b.len() && a.iter().zip(b.iter()).all(|(a, b)| json_values_equal(a, b))
|
||||
}
|
||||
(Object(a), Object(b)) => {
|
||||
if a.len() != b.len() {
|
||||
return false;
|
||||
}
|
||||
a.iter().all(|(key, value)| {
|
||||
b.get(key)
|
||||
.is_some_and(|expected_value| json_values_equal(value, expected_value))
|
||||
})
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
# Built-in SQL functions
|
||||
|
||||
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
|
||||
use std::borrow::Cow;
|
||||
|
||||
use crate::webserver::http_request_info::RequestInfo;
|
||||
|
||||
pub(super) async fn example(request: &RequestInfo, value: Option<Cow<'_, str>>) -> Option<String> {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
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,
|
||||
}
|
||||
```
|
||||
|
||||
The [`sqlpage_functions!`](function_traits.rs) macro declares the modules and generates the
|
||||
`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 imported by name from sibling function modules.
|
||||
@@ -0,0 +1,264 @@
|
||||
//! Dispatch machinery for the built-in `sqlpage.*` SQL functions.
|
||||
//!
|
||||
//! Each function is a plain `async fn` in its own module under [`functions/`](super::functions).
|
||||
//! [`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<string>` 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 _;
|
||||
|
||||
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<Cow<'_, str>>) -> String {
|
||||
param.map_or_else(|| "NULL".into(), |x| format!("'{}'", x.replace('\'', "''")))
|
||||
}
|
||||
|
||||
/// 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<Option<Cow<'a, str>>>,
|
||||
}
|
||||
|
||||
impl<'a, 'c> FunctionContext<'a, 'c> {
|
||||
pub(crate) fn new(
|
||||
request: &'a ExecutionContext,
|
||||
db_connection: &'c mut DbConn,
|
||||
arguments: Vec<Option<Cow<'a, str>>>,
|
||||
) -> 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<Cow<'a, str>> {
|
||||
self.arguments.next().flatten()
|
||||
}
|
||||
|
||||
fn next_required(&mut self) -> anyhow::Result<Cow<'a, str>> {
|
||||
self.next_arg()
|
||||
.ok_or_else(|| anyhow::anyhow!("Unexpected NULL value"))
|
||||
}
|
||||
|
||||
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)
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<Self>;
|
||||
}
|
||||
|
||||
impl<'a, 'c> Extract<'a, 'c> for &'a RequestInfo {
|
||||
fn extract(ctx: &mut FunctionContext<'a, 'c>) -> anyhow::Result<Self> {
|
||||
Ok(ctx.request.into())
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a, 'c> Extract<'a, 'c> for &'a ExecutionContext {
|
||||
fn extract(ctx: &mut FunctionContext<'a, 'c>) -> anyhow::Result<Self> {
|
||||
Ok(ctx.request)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a, 'c> Extract<'a, 'c> for &'c mut DbConn {
|
||||
fn extract(ctx: &mut FunctionContext<'a, 'c>) -> anyhow::Result<Self> {
|
||||
ctx.db
|
||||
.take()
|
||||
.context("This function cannot be called in this context (no database connection)")
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a, 'c> Extract<'a, 'c> for Cow<'a, str> {
|
||||
fn extract(ctx: &mut FunctionContext<'a, 'c>) -> anyhow::Result<Self> {
|
||||
ctx.next_required()
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a, 'c> Extract<'a, 'c> for Option<Cow<'a, str>> {
|
||||
fn extract(ctx: &mut FunctionContext<'a, 'c>) -> anyhow::Result<Self> {
|
||||
Ok(ctx.next_arg())
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a, 'c> Extract<'a, 'c> for Option<String> {
|
||||
fn extract(ctx: &mut FunctionContext<'a, 'c>) -> anyhow::Result<Self> {
|
||||
Ok(ctx.next_arg().map(Cow::into_owned))
|
||||
}
|
||||
}
|
||||
|
||||
/// Collects the remaining arguments (dropping `NULL`s) for variadic functions.
|
||||
impl<'a, 'c> Extract<'a, 'c> for Vec<Cow<'a, str>> {
|
||||
fn extract(ctx: &mut FunctionContext<'a, 'c>) -> anyhow::Result<Self> {
|
||||
Ok(ctx.arguments.by_ref().flatten().collect())
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a, 'c> Extract<'a, 'c> for usize {
|
||||
fn extract(ctx: &mut FunctionContext<'a, 'c>) -> anyhow::Result<Self> {
|
||||
let arg = ctx.next_required()?;
|
||||
arg.parse()
|
||||
.with_context(|| format!("Unable to parse {arg:?} as a positive integer"))
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a, 'c> Extract<'a, 'c> for Option<HttpFetchRequest<'a>> {
|
||||
fn extract(ctx: &mut FunctionContext<'a, 'c>) -> anyhow::Result<Self> {
|
||||
ctx.next_arg()
|
||||
.map(HttpFetchRequest::borrow_from_str)
|
||||
.transpose()
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<Self>;
|
||||
}
|
||||
|
||||
/// 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<Output = anyhow::Result<Option<Cow<'a, str>>>>;
|
||||
}
|
||||
|
||||
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<Output = Ret>,
|
||||
Ret: IntoCowResult<'a>,
|
||||
$($arg: Extract<'a, 'c>,)*
|
||||
{
|
||||
#[allow(non_snake_case, unused_mut)]
|
||||
async fn call(self, mut ctx: FunctionContext<'a, 'c>) -> anyhow::Result<Option<Cow<'a, str>>> {
|
||||
$(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<Option<Cow<'a, str>>>;
|
||||
}
|
||||
|
||||
impl<'a, T: IntoCow<'a>> IntoCowResult<'a> for anyhow::Result<T> {
|
||||
fn into_cow_result(self) -> anyhow::Result<Option<Cow<'a, str>>> {
|
||||
self.map(IntoCow::into_cow)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a, T: IntoCow<'a>> IntoCowResult<'a> for T {
|
||||
fn into_cow_result(self) -> anyhow::Result<Option<Cow<'a, str>>> {
|
||||
Ok(self.into_cow())
|
||||
}
|
||||
}
|
||||
|
||||
trait IntoCow<'a> {
|
||||
fn into_cow(self) -> Option<Cow<'a, str>>;
|
||||
}
|
||||
|
||||
impl<'a> IntoCow<'a> for Cow<'a, str> {
|
||||
fn into_cow(self) -> Option<Cow<'a, str>> {
|
||||
Some(self)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> IntoCow<'a> for String {
|
||||
fn into_cow(self) -> Option<Cow<'a, str>> {
|
||||
Some(Cow::Owned(self))
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a, 'b: 'a> IntoCow<'a> for &'b str {
|
||||
fn into_cow(self) -> Option<Cow<'a, str>> {
|
||||
Some(Cow::Borrowed(self))
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a, T: IntoCow<'a>> IntoCow<'a> for Option<T> {
|
||||
fn into_cow(self) -> Option<Cow<'a, str>> {
|
||||
self.and_then(IntoCow::into_cow)
|
||||
}
|
||||
}
|
||||
|
||||
/// Declares the listed function modules and builds the [`SqlPageFunctionName`] dispatch enum from
|
||||
/// them.
|
||||
macro_rules! sqlpage_functions {
|
||||
($($func:ident),* $(,)?) => {
|
||||
$(
|
||||
mod $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 {
|
||||
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 $crate::webserver::http_request_info::ExecutionContext,
|
||||
db_connection: &'c mut $crate::webserver::database::execute_queries::DbConn,
|
||||
arguments: Vec<Option<::std::borrow::Cow<'a, str>>>,
|
||||
) -> anyhow::Result<Option<::std::borrow::Cow<'a, str>>>
|
||||
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),*
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
pub(crate) use sqlpage_functions;
|
||||
@@ -0,0 +1,83 @@
|
||||
//! Built-in `SQLPage` SQL functions.
|
||||
//!
|
||||
//! Every function is a plain `async fn` in its own module under [`functions/`](self). To add one,
|
||||
//! create `functions/<name>.rs` with an `async fn <name>` 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`].
|
||||
|
||||
use std::fmt::Write;
|
||||
|
||||
use super::function_traits::sqlpage_functions;
|
||||
|
||||
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<Self> {
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
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> {
|
||||
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")
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
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.
|
||||
pub(super) async fn basic_auth_username(request: &RequestInfo) -> anyhow::Result<&str> {
|
||||
Ok(extract_basic_auth(request)?.user_id())
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
use crate::webserver::http_request_info::RequestInfo;
|
||||
|
||||
pub(super) async fn client_ip(request: &RequestInfo) -> Option<String> {
|
||||
Some(request.client_ip?.to_string())
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
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 {
|
||||
request
|
||||
.app_state
|
||||
.config
|
||||
.configuration_directory
|
||||
.to_string_lossy()
|
||||
.into_owned()
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
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<Cow<'a, str>> {
|
||||
request.cookies.get(&*name).map(SingleOrVec::as_json_str)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
use anyhow::Context;
|
||||
|
||||
pub(super) async fn current_working_directory() -> anyhow::Result<String> {
|
||||
std::env::current_dir()
|
||||
.with_context(|| "unable to access the current working directory")
|
||||
.map(|x| x.to_string_lossy().into_owned())
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
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<Option<Cow<'_, str>>> {
|
||||
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:?}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
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>(
|
||||
request: &'a RequestInfo,
|
||||
program_name: Cow<'a, str>,
|
||||
args: Vec<Cow<'a, str>>,
|
||||
) -> anyhow::Result<String> {
|
||||
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())
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
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,
|
||||
http_request: &'a HttpFetchRequest<'_>,
|
||||
) -> anyhow::Result<awc::ClientRequest> {
|
||||
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<awc::SendClientRequest> {
|
||||
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<HttpFetchRequest<'_>>,
|
||||
) -> anyhow::Result<Option<String>> {
|
||||
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<u8>, encoding: Option<&str>) -> anyhow::Result<String> {
|
||||
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(),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
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,
|
||||
http_request: Option<HttpFetchRequest<'_>>,
|
||||
) -> anyhow::Result<Option<String>> {
|
||||
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::<std::collections::HashMap<_, _>>(),
|
||||
)?;
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
use anyhow::anyhow;
|
||||
|
||||
pub(super) async fn hash_password(password: Option<String>) -> anyhow::Result<Option<String>> {
|
||||
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"));
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
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<Cow<'a, str>> {
|
||||
let lower_name = name.to_ascii_lowercase();
|
||||
request
|
||||
.headers
|
||||
.get(&lower_name)
|
||||
.map(SingleOrVec::as_json_str)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
use crate::webserver::http_request_info::RequestInfo;
|
||||
|
||||
pub(super) async fn headers(request: &RequestInfo) -> String {
|
||||
serde_json::to_string(&request.headers).unwrap_or_default()
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
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.
|
||||
pub(super) async fn hmac<'a>(
|
||||
data: Cow<'a, str>,
|
||||
key: Cow<'a, str>,
|
||||
algorithm: Option<Cow<'a, str>>,
|
||||
) -> anyhow::Result<Option<String>> {
|
||||
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::<Sha256>::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::<Sha512>::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"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
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"},
|
||||
/// the result will be "index.sql?x=hello%20world".
|
||||
pub(super) async fn link<'a>(
|
||||
file: Cow<'a, str>,
|
||||
parameters: Option<Cow<'a, str>>,
|
||||
hash: Option<Cow<'a, str>>,
|
||||
) -> anyhow::Result<String> {
|
||||
let mut url = file.into_owned();
|
||||
if let Some(parameters) = parameters {
|
||||
let encoded = serde_json::from_str::<URLParameters>(¶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)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
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,
|
||||
redirect_uri: Option<Cow<'a, str>>,
|
||||
) -> anyhow::Result<Option<String>> {
|
||||
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))
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
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 {
|
||||
&request.path
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
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";
|
||||
|
||||
pub(super) async fn persist_uploaded_file<'a>(
|
||||
request: &'a RequestInfo,
|
||||
field_name: Cow<'a, str>,
|
||||
folder: Option<Cow<'a, str>>,
|
||||
allowed_extensions: Option<Cow<'a, str>>,
|
||||
mode: Option<Cow<'a, str>>,
|
||||
) -> anyhow::Result<Option<String>> {
|
||||
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::<Vec<_>>().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(())
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
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 {
|
||||
&request.protocol
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
|
||||
/// Returns a random string of the specified length.
|
||||
pub(super) async fn random_string(len: usize) -> anyhow::Result<String> {
|
||||
// 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);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
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<Vec<u8>, 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<Cow<'a, str>>,
|
||||
) -> Result<Option<Cow<'a, str>>, 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)))
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
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>(
|
||||
request: &'a RequestInfo,
|
||||
file_path: Option<Cow<'a, str>>,
|
||||
) -> Result<Option<Cow<'a, str>>, 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)))
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
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.
|
||||
/// For named capture groups (`(?<name>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<Cow<'a, str>>,
|
||||
) -> Result<Option<String>, 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"(?<word>foo)(bar)"),
|
||||
Some(Cow::Borrowed("_foobar_")),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
result.as_deref(),
|
||||
Some(r#"{"0":"foobar","word":"foo","2":"bar"}"#)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
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.
|
||||
/// 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<String> {
|
||||
let raw_body = request.raw_body.as_ref()?;
|
||||
Some(String::from_utf8_lossy(raw_body).to_string())
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
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
|
||||
/// 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<String> {
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
use crate::webserver::http_request_info::RequestInfo;
|
||||
|
||||
pub(super) async fn request_method(request: &RequestInfo) -> String {
|
||||
request.method.to_string()
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
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,
|
||||
db_connection: &mut DbConn,
|
||||
sql_file_path: Option<Cow<'a, str>>,
|
||||
variables: Option<Cow<'a, str>>,
|
||||
) -> anyhow::Result<Option<Cow<'a, str>>> {
|
||||
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)?)))
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
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,
|
||||
name: Cow<'a, str>,
|
||||
value: Option<Cow<'a, str>>,
|
||||
) -> anyhow::Result<String> {
|
||||
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())
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
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| {
|
||||
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<Cow<'a, str>> {
|
||||
let mime = request
|
||||
.uploaded_files
|
||||
.get(&*upload_name)?
|
||||
.content_type
|
||||
.as_ref()?;
|
||||
Some(Cow::Borrowed(mime.as_ref()))
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
use std::borrow::Cow;
|
||||
|
||||
use crate::webserver::http_request_info::RequestInfo;
|
||||
|
||||
pub(super) async fn uploaded_file_name<'a>(
|
||||
request: &'a RequestInfo,
|
||||
upload_name: Cow<'a, str>,
|
||||
) -> Option<Cow<'a, str>> {
|
||||
let fname = request
|
||||
.uploaded_files
|
||||
.get(&*upload_name)?
|
||||
.file_name
|
||||
.as_ref()?;
|
||||
Some(Cow::Borrowed(fname.as_str()))
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
use std::borrow::Cow;
|
||||
|
||||
use crate::webserver::http_request_info::RequestInfo;
|
||||
|
||||
pub(super) async fn uploaded_file_path<'a>(
|
||||
request: &'a RequestInfo,
|
||||
upload_name: Cow<'a, str>,
|
||||
) -> Option<Cow<'a, str>> {
|
||||
let uploaded_file = request.uploaded_files.get(&*upload_name)?;
|
||||
Some(uploaded_file.file.path().to_string_lossy())
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
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.
|
||||
/// 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<Cow<'_, str>>) -> Option<Cow<'_, str>> {
|
||||
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())
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
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>(
|
||||
request: &'a RequestInfo,
|
||||
claim: Cow<'a, str>,
|
||||
) -> anyhow::Result<Option<String>> {
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
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<Option<String>> {
|
||||
let Some(claims) = &request.oidc_claims else {
|
||||
return Ok(None);
|
||||
};
|
||||
Ok(Some(serde_json::to_string(claims)?))
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
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>(
|
||||
request: &'a ExecutionContext,
|
||||
get_or_post: Option<Cow<'a, str>>,
|
||||
) -> anyhow::Result<String> {
|
||||
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)?
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
|
||||
/// Returns the version of the sqlpage that is running.
|
||||
pub(super) async fn version() -> &'static str {
|
||||
env!("CARGO_PKG_VERSION")
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
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 {
|
||||
request
|
||||
.app_state
|
||||
.config
|
||||
.web_root
|
||||
.to_string_lossy()
|
||||
.into_owned()
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
use anyhow::Context;
|
||||
|
||||
use super::function_traits::BorrowFromStr;
|
||||
use std::borrow::Cow;
|
||||
|
||||
type HeaderVec<'a> = Vec<(Cow<'a, str>, Cow<'a, str>)>;
|
||||
|
||||
fn default_headers<'a>() -> HeaderVec<'a> {
|
||||
vec![
|
||||
(Cow::Borrowed("Accept"), Cow::Borrowed("*/*")),
|
||||
(
|
||||
Cow::Borrowed("User-Agent"),
|
||||
Cow::Borrowed(concat!(
|
||||
"SQLPage/v",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
" (+https://sql-page.com)"
|
||||
)),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize, Debug)]
|
||||
#[serde(expecting = "an http request object, e.g. '{\"url\":\"http://example.com\"}'")]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(super) struct HttpFetchRequest<'b> {
|
||||
#[serde(borrow)]
|
||||
pub url: Cow<'b, str>,
|
||||
#[serde(borrow)]
|
||||
pub method: Option<Cow<'b, str>>,
|
||||
#[serde(
|
||||
default = "default_headers",
|
||||
borrow,
|
||||
deserialize_with = "deserialize_map_to_vec_pairs"
|
||||
)]
|
||||
pub headers: HeaderVec<'b>,
|
||||
pub username: Option<Cow<'b, str>>,
|
||||
pub password: Option<Cow<'b, str>>,
|
||||
#[serde(borrow)]
|
||||
pub body: Option<Cow<'b, serde_json::value::RawValue>>,
|
||||
pub timeout_ms: Option<u64>,
|
||||
pub response_encoding: Option<Cow<'b, str>>,
|
||||
}
|
||||
|
||||
fn deserialize_map_to_vec_pairs<'de, D: serde::Deserializer<'de>>(
|
||||
deserializer: D,
|
||||
) -> Result<HeaderVec<'de>, D::Error> {
|
||||
struct Visitor;
|
||||
|
||||
impl<'de> serde::de::Visitor<'de> for Visitor {
|
||||
type Value = Vec<(Cow<'de, str>, Cow<'de, str>)>;
|
||||
|
||||
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
formatter.write_str("a map")
|
||||
}
|
||||
|
||||
fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
|
||||
where
|
||||
A: serde::de::MapAccess<'de>,
|
||||
{
|
||||
let mut vec = Vec::new();
|
||||
while let Some((key, value)) = map.next_entry()? {
|
||||
vec.push((key, value));
|
||||
}
|
||||
Ok(vec)
|
||||
}
|
||||
}
|
||||
|
||||
deserializer.deserialize_map(Visitor)
|
||||
}
|
||||
|
||||
impl<'a> BorrowFromStr<'a> for HttpFetchRequest<'a> {
|
||||
fn borrow_from_str(s: Cow<'a, str>) -> anyhow::Result<Self> {
|
||||
Ok(if s.starts_with("http") {
|
||||
HttpFetchRequest {
|
||||
url: s,
|
||||
method: None,
|
||||
headers: default_headers(),
|
||||
username: None,
|
||||
password: None,
|
||||
body: None,
|
||||
timeout_ms: None,
|
||||
response_encoding: None,
|
||||
}
|
||||
} else {
|
||||
match s {
|
||||
Cow::Borrowed(s) => serde_json::from_str(s),
|
||||
Cow::Owned(ref s) => serde_json::from_str::<HttpFetchRequest<'_>>(s)
|
||||
.map(HttpFetchRequest::into_owned),
|
||||
}
|
||||
.with_context(|| format!("Invalid http fetch request definition: {s}"))?
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl HttpFetchRequest<'_> {
|
||||
fn into_owned(self) -> HttpFetchRequest<'static> {
|
||||
HttpFetchRequest {
|
||||
url: Cow::Owned(self.url.into_owned()),
|
||||
method: self.method.map(Cow::into_owned).map(Cow::Owned),
|
||||
headers: self
|
||||
.headers
|
||||
.into_iter()
|
||||
.map(|(k, v)| (Cow::Owned(k.into_owned()), Cow::Owned(v.into_owned())))
|
||||
.collect(),
|
||||
body: self.body.map(Cow::into_owned).map(Cow::Owned),
|
||||
timeout_ms: self.timeout_ms,
|
||||
username: self.username.map(Cow::into_owned).map(Cow::Owned),
|
||||
password: self.password.map(Cow::into_owned).map(Cow::Owned),
|
||||
response_encoding: self.response_encoding.map(Cow::into_owned).map(Cow::Owned),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
mod function_traits;
|
||||
pub(super) mod functions;
|
||||
mod http_fetch_request;
|
||||
mod url_parameters;
|
||||
|
||||
use sqlparser::ast::FunctionArg;
|
||||
|
||||
use super::sql::ParamExtractContext;
|
||||
use super::syntax_tree::SqlPageFunctionCall;
|
||||
use super::syntax_tree::StmtParam;
|
||||
|
||||
pub(super) fn func_call_to_param(
|
||||
func_name: &str,
|
||||
arguments: &mut [FunctionArg],
|
||||
ctx: &ParamExtractContext,
|
||||
) -> StmtParam {
|
||||
SqlPageFunctionCall::from_func_call(func_name, arguments, ctx).map_or_else(
|
||||
|e| StmtParam::Error(format!("{e:#}")),
|
||||
StmtParam::FunctionCall,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
use crate::webserver::single_or_vec::SingleOrVec;
|
||||
use percent_encoding::{NON_ALPHANUMERIC, percent_encode};
|
||||
use serde::{Deserialize, Deserializer};
|
||||
use serde_json::Value;
|
||||
use std::borrow::Cow;
|
||||
use std::fmt;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct URLParameters(String);
|
||||
|
||||
impl URLParameters {
|
||||
pub fn new() -> Self {
|
||||
Self(String::new())
|
||||
}
|
||||
|
||||
fn encode_and_push(&mut self, v: &str) {
|
||||
let val: Cow<str> = percent_encode(v.as_bytes(), NON_ALPHANUMERIC).into();
|
||||
self.0.push_str(&val);
|
||||
}
|
||||
|
||||
fn start_new_pair(&mut self) {
|
||||
let char = if self.0.is_empty() { '?' } else { '&' };
|
||||
self.0.push(char);
|
||||
}
|
||||
|
||||
fn push_kv(&mut self, key: &str, value: &str) {
|
||||
self.start_new_pair();
|
||||
self.encode_and_push(key);
|
||||
self.0.push('=');
|
||||
self.encode_and_push(value);
|
||||
}
|
||||
|
||||
fn push_array_entry(&mut self, key: &str, value: &str) {
|
||||
self.start_new_pair();
|
||||
self.encode_and_push(key);
|
||||
self.0.push_str("[]=");
|
||||
self.encode_and_push(value);
|
||||
}
|
||||
|
||||
fn push_array(&mut self, key: &str, values: Vec<Value>) {
|
||||
for val in values {
|
||||
let val_str = match val {
|
||||
Value::String(s) => s,
|
||||
other => other.to_string(),
|
||||
};
|
||||
self.push_array_entry(key, &val_str);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn push_single_or_vec(&mut self, key: &str, val: SingleOrVec) {
|
||||
match val {
|
||||
SingleOrVec::Single(v) => self.push_kv(key, &v),
|
||||
SingleOrVec::Vec(v) => {
|
||||
for s in v {
|
||||
self.push_array_entry(key, &s);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn add_from_json(&mut self, key: &str, raw_json_value: &str) {
|
||||
if let Ok(str_val) = serde_json::from_str::<Option<Cow<str>>>(raw_json_value) {
|
||||
if let Some(str_val) = str_val {
|
||||
self.push_kv(key, &str_val);
|
||||
}
|
||||
} else if let Ok(vec_val) = serde_json::from_str::<Vec<Value>>(raw_json_value) {
|
||||
self.push_array(key, vec_val);
|
||||
} else {
|
||||
self.push_kv(key, raw_json_value);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_empty_path(self) -> String {
|
||||
if self.0.is_empty() {
|
||||
"?".to_string() // Link to the current page without parameters
|
||||
} else {
|
||||
self.0 // Link to the current page with specific parameters
|
||||
}
|
||||
}
|
||||
|
||||
pub fn append_to_path(self, url: &mut String) {
|
||||
if url.is_empty() {
|
||||
*url = self.with_empty_path();
|
||||
} else {
|
||||
url.push_str(&self.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for URLParameters {
|
||||
fn deserialize<D>(deserializer: D) -> Result<URLParameters, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
// Visit an object and append keys and values to the string
|
||||
struct URLParametersVisitor;
|
||||
|
||||
impl<'de> serde::de::Visitor<'de> for URLParametersVisitor {
|
||||
type Value = URLParameters;
|
||||
|
||||
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
|
||||
formatter.write_str("a sequence")
|
||||
}
|
||||
|
||||
fn visit_map<A>(self, mut map: A) -> Result<URLParameters, A::Error>
|
||||
where
|
||||
A: serde::de::MapAccess<'de>,
|
||||
{
|
||||
let mut out = URLParameters(String::new());
|
||||
while let Some((key, value)) =
|
||||
map.next_entry::<Cow<str>, Cow<serde_json::value::RawValue>>()?
|
||||
{
|
||||
out.add_from_json(&key, value.get());
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
deserializer.deserialize_map(URLParametersVisitor)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for URLParameters {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<URLParameters> for String {
|
||||
fn from(value: URLParameters) -> Self {
|
||||
value.0
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_url_parameters_deserializer() {
|
||||
use serde_json::json;
|
||||
let json = json!({
|
||||
"x": "hello world",
|
||||
"num": 123,
|
||||
"arr": [1, 2, 3],
|
||||
});
|
||||
|
||||
let url_parameters: URLParameters = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(
|
||||
url_parameters.0,
|
||||
"?x=hello%20world&num=123&arr[]=1&arr[]=2&arr[]=3"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_url_parameters_null() {
|
||||
use serde_json::json;
|
||||
let json = json!({
|
||||
"null_should_be_omitted": null,
|
||||
"x": "hello",
|
||||
});
|
||||
|
||||
let url_parameters: URLParameters = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(url_parameters.0, "?x=hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_url_parameters_deserializer_special_chars() {
|
||||
use serde_json::json;
|
||||
let json = json!({
|
||||
"chars": ["\n", " ", "\""],
|
||||
});
|
||||
|
||||
let url_parameters: URLParameters = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(url_parameters.0, "?chars[]=%0A&chars[]=%20&chars[]=%22");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_url_parameters_deserializer_issue_879() {
|
||||
use serde_json::json;
|
||||
let json = json!({
|
||||
"name": "John Doe & Son's",
|
||||
"items": [1, "item 2 & 3", true],
|
||||
"special_char": "%&=+ ",
|
||||
});
|
||||
|
||||
let url_parameters: URLParameters = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(
|
||||
url_parameters.0,
|
||||
"?name=John%20Doe%20%26%20Son%27s&items[]=1&items[]=item%202%20%26%203&items[]=true&special%5Fchar=%25%26%3D%2B%20"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_push_single_or_vec() {
|
||||
let mut params = URLParameters(String::new());
|
||||
params.push_single_or_vec("k", SingleOrVec::Single("v".to_string()));
|
||||
assert_eq!(params.to_string(), "?k=v");
|
||||
|
||||
let mut params = URLParameters(String::new());
|
||||
params.push_single_or_vec(
|
||||
"arr",
|
||||
SingleOrVec::Vec(vec!["a".to_string(), "b".to_string()]),
|
||||
);
|
||||
assert_eq!(params.to_string(), "?arr[]=a&arr[]=b");
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
/// This module contains the syntax tree for sqlpage statement parameters.
|
||||
/// In a query like `SELECT sqlpage.some_function($my_param)`,
|
||||
/// The stored database statement will be just `SELECT $1`,
|
||||
/// and the `StmtParam` will contain a the following tree:
|
||||
///
|
||||
/// ```text
|
||||
/// StmtParam::FunctionCall(
|
||||
/// SqlPageFunctionCall {
|
||||
/// function: SqlPageFunctionName::some_function,
|
||||
/// arguments: vec![StmtParam::Get("$my_param")]
|
||||
/// }
|
||||
/// )
|
||||
/// ```
|
||||
use std::borrow::Cow;
|
||||
use std::str::FromStr;
|
||||
|
||||
use sqlparser::ast::FunctionArg;
|
||||
|
||||
use crate::webserver::http_request_info::ExecutionContext;
|
||||
use crate::webserver::single_or_vec::SingleOrVec;
|
||||
|
||||
use super::{
|
||||
execute_queries::DbConn, sql::ParamExtractContext, sql::function_args_to_stmt_params,
|
||||
sqlpage_functions::functions::SqlPageFunctionName,
|
||||
};
|
||||
use anyhow::Context as _;
|
||||
|
||||
/// Represents a parameter to a SQL statement.
|
||||
/// Objects of this type are created during SQL parsing.
|
||||
/// Every time a SQL statement is executed, the parameters are evaluated to produce the actual values that are passed to the database.
|
||||
/// Parameter evaluation can involve asynchronous operations, and extracting values from the request.
|
||||
#[derive(Debug, PartialEq, Eq, Clone)]
|
||||
pub(crate) enum StmtParam {
|
||||
Get(String),
|
||||
Post(String),
|
||||
PostOrGet(String),
|
||||
Error(String),
|
||||
Literal(String),
|
||||
Null,
|
||||
Concat(Vec<StmtParam>),
|
||||
Coalesce(Vec<StmtParam>),
|
||||
JsonObject(Vec<StmtParam>),
|
||||
JsonArray(Vec<StmtParam>),
|
||||
FunctionCall(SqlPageFunctionCall),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for StmtParam {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
StmtParam::Get(name) => write!(f, "?{name}"),
|
||||
StmtParam::Post(name) => write!(f, ":{name}"),
|
||||
StmtParam::PostOrGet(name) => write!(f, "${name}"),
|
||||
StmtParam::Literal(x) => write!(f, "'{}'", x.replace('\'', "''")),
|
||||
StmtParam::Null => write!(f, "NULL"),
|
||||
StmtParam::Concat(items) => {
|
||||
write!(f, "CONCAT(")?;
|
||||
for item in items {
|
||||
write!(f, "{item}, ")?;
|
||||
}
|
||||
write!(f, ")")
|
||||
}
|
||||
StmtParam::Coalesce(items) => {
|
||||
write!(f, "COALESCE(")?;
|
||||
for item in items {
|
||||
write!(f, "{item}, ")?;
|
||||
}
|
||||
write!(f, ")")
|
||||
}
|
||||
StmtParam::JsonObject(items) => {
|
||||
write!(f, "JSON_OBJECT(")?;
|
||||
for item in items {
|
||||
write!(f, "{item}, ")?;
|
||||
}
|
||||
write!(f, ")")
|
||||
}
|
||||
StmtParam::JsonArray(items) => {
|
||||
write!(f, "JSON_ARRAY(")?;
|
||||
for item in items {
|
||||
write!(f, "{item}, ")?;
|
||||
}
|
||||
write!(f, ")")
|
||||
}
|
||||
StmtParam::FunctionCall(call) => write!(f, "{call}"),
|
||||
StmtParam::Error(x) => {
|
||||
if let Some((i, _)) = x.char_indices().nth(21) {
|
||||
write!(f, "## {}... ##", &x[..i])
|
||||
} else {
|
||||
write!(f, "## {x} ##")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents a call to a `sqlpage.` function.
|
||||
/// Objects of this type are created during SQL parsing and used to evaluate the function at runtime.
|
||||
#[derive(Debug, PartialEq, Eq, Clone)]
|
||||
pub struct SqlPageFunctionCall {
|
||||
pub function: SqlPageFunctionName,
|
||||
pub arguments: Vec<StmtParam>,
|
||||
}
|
||||
|
||||
impl SqlPageFunctionCall {
|
||||
pub fn from_func_call(
|
||||
func_name: &str,
|
||||
arguments: &mut [FunctionArg],
|
||||
ctx: &ParamExtractContext,
|
||||
) -> anyhow::Result<Self> {
|
||||
let function = SqlPageFunctionName::from_str(func_name)?;
|
||||
let arguments = function_args_to_stmt_params(arguments, ctx)?;
|
||||
Ok(Self {
|
||||
function,
|
||||
arguments,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn evaluate<'a>(
|
||||
&self,
|
||||
request: &'a ExecutionContext,
|
||||
db_connection: &mut DbConn,
|
||||
) -> anyhow::Result<Option<Cow<'a, str>>> {
|
||||
let mut params = Vec::with_capacity(self.arguments.len());
|
||||
for param in &self.arguments {
|
||||
params.push(Box::pin(extract_req_param(param, request, db_connection)).await?);
|
||||
}
|
||||
log::trace!("Starting function call to {self}");
|
||||
let result = self
|
||||
.function
|
||||
.evaluate(request, db_connection, params)
|
||||
.await?;
|
||||
log::trace!(
|
||||
"Function call to {self} returned: {}",
|
||||
result.as_deref().unwrap_or("NULL")
|
||||
);
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for SqlPageFunctionCall {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
write!(f, "{}(", self.function)?;
|
||||
// interleave the arguments with commas
|
||||
let mut it = self.arguments.iter();
|
||||
if let Some(x) = it.next() {
|
||||
write!(f, "{x}")?;
|
||||
}
|
||||
for x in it {
|
||||
write!(f, ", {x}")?;
|
||||
}
|
||||
write!(f, ")")
|
||||
}
|
||||
}
|
||||
|
||||
/// Extracts the value of a parameter from the request.
|
||||
/// Returns `Ok(None)` when NULL should be used as the parameter value.
|
||||
pub(super) async fn extract_req_param<'a>(
|
||||
param: &StmtParam,
|
||||
request: &'a ExecutionContext,
|
||||
db_connection: &mut DbConn,
|
||||
) -> anyhow::Result<Option<Cow<'a, str>>> {
|
||||
Ok(match param {
|
||||
// sync functions
|
||||
StmtParam::Get(x) => request.url_params.get(x).map(SingleOrVec::as_json_str),
|
||||
StmtParam::Post(x) => {
|
||||
if let Some(val) = request.set_variables.borrow().get(x) {
|
||||
val.as_ref()
|
||||
.map(|v| Cow::Owned(v.as_json_str().into_owned()))
|
||||
} else {
|
||||
request.post_variables.get(x).map(SingleOrVec::as_json_str)
|
||||
}
|
||||
}
|
||||
StmtParam::PostOrGet(x) => {
|
||||
if let Some(val) = request.set_variables.borrow().get(x) {
|
||||
val.as_ref()
|
||||
.map(|v| Cow::Owned(v.as_json_str().into_owned()))
|
||||
} else {
|
||||
let url_val = request.url_params.get(x);
|
||||
if request.post_variables.contains_key(x) {
|
||||
if url_val.is_some() {
|
||||
log::warn!(
|
||||
"Deprecation warning! There is both a URL parameter named '{x}' and a form field named '{x}'. \
|
||||
SQLPage is using the URL parameter for ${x}. Please use :{x} to reference the form field explicitly."
|
||||
);
|
||||
} else {
|
||||
log::warn!(
|
||||
"Deprecation warning! ${x} was used to reference a form field value (a POST variable). \
|
||||
This now uses only URL parameters. Please use :{x} instead."
|
||||
);
|
||||
}
|
||||
}
|
||||
url_val.map(SingleOrVec::as_json_str)
|
||||
}
|
||||
}
|
||||
StmtParam::Error(x) => anyhow::bail!("{x}"),
|
||||
StmtParam::Literal(x) => Some(Cow::Owned(x.clone())),
|
||||
StmtParam::Null => None,
|
||||
StmtParam::Concat(args) => concat_params(&args[..], request, db_connection).await?,
|
||||
StmtParam::JsonObject(args) => {
|
||||
json_object_params(&args[..], request, db_connection).await?
|
||||
}
|
||||
StmtParam::JsonArray(args) => json_array_params(&args[..], request, db_connection).await?,
|
||||
StmtParam::Coalesce(args) => coalesce_params(&args[..], request, db_connection).await?,
|
||||
StmtParam::FunctionCall(func) => {
|
||||
func.evaluate(request, db_connection)
|
||||
.await
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"Error in function call {func}.\nExpected {:#}",
|
||||
func.function
|
||||
)
|
||||
})?
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async fn concat_params<'a>(
|
||||
args: &[StmtParam],
|
||||
request: &'a ExecutionContext,
|
||||
db_connection: &mut DbConn,
|
||||
) -> anyhow::Result<Option<Cow<'a, str>>> {
|
||||
let mut result = String::new();
|
||||
for arg in args {
|
||||
let Some(arg) = Box::pin(extract_req_param(arg, request, db_connection)).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
result.push_str(&arg);
|
||||
}
|
||||
Ok(Some(Cow::Owned(result)))
|
||||
}
|
||||
|
||||
async fn coalesce_params<'a>(
|
||||
args: &[StmtParam],
|
||||
request: &'a ExecutionContext,
|
||||
db_connection: &mut DbConn,
|
||||
) -> anyhow::Result<Option<Cow<'a, str>>> {
|
||||
for arg in args {
|
||||
if let Some(arg) = Box::pin(extract_req_param(arg, request, db_connection)).await? {
|
||||
return Ok(Some(arg));
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn json_object_params<'a>(
|
||||
args: &[StmtParam],
|
||||
request: &'a ExecutionContext,
|
||||
db_connection: &mut DbConn,
|
||||
) -> anyhow::Result<Option<Cow<'a, str>>> {
|
||||
use serde::{Serializer, ser::SerializeMap};
|
||||
let mut result = Vec::new();
|
||||
let mut ser = serde_json::Serializer::new(&mut result);
|
||||
let mut map_ser = ser.serialize_map(Some(args.len()))?;
|
||||
let mut it = args.iter();
|
||||
while let Some(key) = it.next() {
|
||||
let key = Box::pin(extract_req_param(key, request, db_connection)).await?;
|
||||
map_ser.serialize_key(&key)?;
|
||||
let val = it
|
||||
.next()
|
||||
.ok_or_else(|| anyhow::anyhow!("Odd number of arguments in JSON_OBJECT"))?;
|
||||
|
||||
match val {
|
||||
StmtParam::JsonObject(args) => {
|
||||
let raw_json = Box::pin(json_object_params(args, request, db_connection)).await?;
|
||||
let obj = cow_to_raw_json(raw_json.as_ref());
|
||||
map_ser.serialize_value(&obj)?;
|
||||
}
|
||||
StmtParam::JsonArray(args) => {
|
||||
let raw_json = Box::pin(json_array_params(args, request, db_connection)).await?;
|
||||
let obj = cow_to_raw_json(raw_json.as_ref());
|
||||
map_ser.serialize_value(&obj)?;
|
||||
}
|
||||
val => {
|
||||
let evaluated = Box::pin(extract_req_param(val, request, db_connection)).await?;
|
||||
map_ser.serialize_value(&evaluated)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
map_ser.end()?;
|
||||
Ok(Some(Cow::Owned(String::from_utf8(result)?)))
|
||||
}
|
||||
|
||||
async fn json_array_params<'a>(
|
||||
args: &[StmtParam],
|
||||
request: &'a ExecutionContext,
|
||||
db_connection: &mut DbConn,
|
||||
) -> anyhow::Result<Option<Cow<'a, str>>> {
|
||||
use serde::{Serializer, ser::SerializeSeq};
|
||||
let mut result = Vec::new();
|
||||
let mut ser = serde_json::Serializer::new(&mut result);
|
||||
let mut seq_ser = ser.serialize_seq(Some(args.len()))?;
|
||||
for element in args {
|
||||
match element {
|
||||
StmtParam::JsonObject(args) => {
|
||||
let raw_json = json_object_params(args, request, db_connection).await?;
|
||||
let obj = cow_to_raw_json(raw_json.as_ref());
|
||||
seq_ser.serialize_element(&obj)?;
|
||||
}
|
||||
StmtParam::JsonArray(args) => {
|
||||
let raw_json = Box::pin(json_array_params(args, request, db_connection)).await?;
|
||||
let obj = cow_to_raw_json(raw_json.as_ref());
|
||||
seq_ser.serialize_element(&obj)?;
|
||||
}
|
||||
element => {
|
||||
let evaluated =
|
||||
Box::pin(extract_req_param(element, request, db_connection)).await?;
|
||||
seq_ser.serialize_element(&evaluated)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
seq_ser.end()?;
|
||||
Ok(Some(Cow::Owned(String::from_utf8(result)?)))
|
||||
}
|
||||
|
||||
fn cow_to_raw_json<'a>(
|
||||
raw_json: Option<&'a impl AsRef<str>>,
|
||||
) -> Option<&'a serde_json::value::RawValue> {
|
||||
raw_json
|
||||
.map(AsRef::as_ref)
|
||||
.map(serde_json::from_str::<&'a serde_json::value::RawValue>)
|
||||
.map(Result::unwrap)
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
//! HTTP error handling
|
||||
//!
|
||||
//! This module owns the single boundary where an internal [`anyhow::Error`]
|
||||
//! becomes the user-facing error representation. The [`ClientError`] type, built
|
||||
//! by [`ClientError::new`], is the *only* place that consults the environment
|
||||
//! (development vs production) to decide how much detail an error may expose.
|
||||
//! Every output format ([`crate::render`]) renders a `ClientError` and never
|
||||
//! inspects the environment itself, so no renderer can leak the SQL statement,
|
||||
//! the source path, the raw database error, environment values, or
|
||||
//! configuration. The full error is always logged server-side, independently of
|
||||
//! what the client receives.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::AppState;
|
||||
use crate::app_config::DevOrProd;
|
||||
use crate::webserver::ErrorWithStatus;
|
||||
use actix_web::HttpResponseBuilder;
|
||||
use actix_web::error::UrlencodedError;
|
||||
use actix_web::http::{StatusCode, header};
|
||||
use actix_web::{HttpRequest, HttpResponse};
|
||||
use handlebars::{Renderable, StringOutput};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// Generic message shown to end users in production instead of the detailed
|
||||
/// error, which would leak the source file path, the SQL statement, and the
|
||||
/// raw database error text.
|
||||
const PRODUCTION_ERROR_MESSAGE: &str =
|
||||
"Please contact the administrator for more information. The error has been logged.";
|
||||
|
||||
const DEV_ERROR_NOTE: &str = "You can hide error messages like this one from your users by setting the 'environment' configuration option to 'production'.";
|
||||
|
||||
/// An internal error reduced to the form that is safe to send to a client.
|
||||
///
|
||||
/// This is the single boundary where a raw [`anyhow::Error`] becomes a
|
||||
/// user-facing error. It is built once, from the error and the environment, by
|
||||
/// [`ClientError::new`] (the only place that consults the environment).
|
||||
/// Renderers (JSON, NDJSON, SSE, CSV, HTML) only ever receive a `ClientError`,
|
||||
/// never the raw error, so no output format can leak the source path, the SQL
|
||||
/// statement, the raw database error, environment values, or configuration: in
|
||||
/// production every `ClientError` holds nothing but the generic message. The
|
||||
/// full error is always logged server-side, independently of what the client
|
||||
/// receives.
|
||||
///
|
||||
/// Adding a new output format is therefore safe by construction: it can only
|
||||
/// render the fields of `ClientError`, all of which are already
|
||||
/// production-safe.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ClientError {
|
||||
/// One-line, client-safe message. Generic in production, detailed in
|
||||
/// development. Suitable for machine formats (JSON/NDJSON/SSE/CSV).
|
||||
message: String,
|
||||
/// Causes chain, for the human-facing HTML backtrace. Empty in production.
|
||||
backtrace: Vec<String>,
|
||||
/// The query that failed, shown to humans in development. `None` in
|
||||
/// production and when not applicable.
|
||||
query_number: Option<usize>,
|
||||
/// Hint shown to humans in development on how to hide errors. `None` in
|
||||
/// production.
|
||||
note: Option<&'static str>,
|
||||
/// `true` when this error was reduced to the generic production message.
|
||||
/// This is the only environment-derived flag callers may branch on, so the
|
||||
/// environment itself is never consulted outside [`ClientError::new`].
|
||||
is_generic: bool,
|
||||
}
|
||||
|
||||
impl ClientError {
|
||||
/// Reduces a raw error to the client-safe form for the given environment.
|
||||
/// In production, only the generic message survives; in development, the
|
||||
/// full detail (message, backtrace, hint) is kept.
|
||||
///
|
||||
/// This is the single location in the whole codebase that consults the
|
||||
/// environment (the only call to `DevOrProd::is_prod`) to decide how much
|
||||
/// of an error reaches a client. Renderers never make this decision; they
|
||||
/// only forward the configured environment here and then format the result.
|
||||
#[must_use]
|
||||
pub fn new(error: &anyhow::Error, environment: DevOrProd, query_number: Option<usize>) -> Self {
|
||||
if environment.is_prod() {
|
||||
Self {
|
||||
message: PRODUCTION_ERROR_MESSAGE.to_owned(),
|
||||
backtrace: Vec::new(),
|
||||
query_number: None,
|
||||
note: None,
|
||||
is_generic: true,
|
||||
}
|
||||
} else {
|
||||
Self {
|
||||
message: error.to_string(),
|
||||
backtrace: get_backtrace_as_strings(error),
|
||||
query_number,
|
||||
note: Some(DEV_ERROR_NOTE),
|
||||
is_generic: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The single-line, client-safe message used by machine-readable formats
|
||||
/// (JSON, NDJSON, SSE) and CSV.
|
||||
#[must_use]
|
||||
pub fn message(&self) -> &str {
|
||||
&self.message
|
||||
}
|
||||
|
||||
/// Whether this error carries only the generic production message (i.e. it
|
||||
/// was built in production). Lets the header path decide between rendering
|
||||
/// the error inline and bubbling up to a top-level error response, without
|
||||
/// itself looking at the environment.
|
||||
#[must_use]
|
||||
pub fn is_generic(&self) -> bool {
|
||||
self.is_generic
|
||||
}
|
||||
|
||||
/// The data passed to the `error` HTML component. Every field is already
|
||||
/// client-safe (empty/`None` in production).
|
||||
#[must_use]
|
||||
pub fn to_html_data(&self) -> Value {
|
||||
json!({
|
||||
"query_number": self.query_number,
|
||||
"description": self.message,
|
||||
"backtrace": self.backtrace,
|
||||
"note": self.note,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Collects the chain of error causes as strings, for the human-facing HTML
|
||||
/// backtrace shown in development.
|
||||
#[must_use]
|
||||
pub(crate) fn get_backtrace_as_strings(error: &anyhow::Error) -> Vec<String> {
|
||||
let mut backtrace = vec![];
|
||||
let mut source = error.source();
|
||||
while let Some(s) = source {
|
||||
backtrace.push(format!("{s}"));
|
||||
source = s.source();
|
||||
}
|
||||
backtrace
|
||||
}
|
||||
|
||||
fn error_to_html_string(app_state: &AppState, err: &anyhow::Error) -> anyhow::Result<String> {
|
||||
let mut out = StringOutput::new();
|
||||
let shell_template = app_state.all_templates.get_static_template("shell")?;
|
||||
let error_template = app_state.all_templates.get_static_template("error")?;
|
||||
let registry = &app_state.all_templates.handlebars;
|
||||
let shell_ctx = handlebars::Context::null();
|
||||
// Reduce the error to its client-safe form before rendering. In production
|
||||
// this hides the message, backtrace, and source detail behind the generic
|
||||
// text shown by the `error` template.
|
||||
let data = ClientError::new(err, app_state.config.environment, None).to_html_data();
|
||||
let err_ctx = handlebars::Context::wraps(data)?;
|
||||
let rc = &mut handlebars::RenderContext::new(None);
|
||||
|
||||
// Open the shell component
|
||||
shell_template
|
||||
.before_list
|
||||
.render(registry, &shell_ctx, rc, &mut out)?;
|
||||
|
||||
// Open the error component
|
||||
error_template
|
||||
.before_list
|
||||
.render(registry, &err_ctx, rc, &mut out)?;
|
||||
// Close the error component
|
||||
error_template
|
||||
.after_list
|
||||
.render(registry, &err_ctx, rc, &mut out)?;
|
||||
|
||||
// Close the shell component
|
||||
shell_template
|
||||
.after_list
|
||||
.render(registry, &shell_ctx, rc, &mut out)?;
|
||||
|
||||
Ok(out.into_string()?)
|
||||
}
|
||||
|
||||
pub(super) fn anyhow_err_to_actix_resp(e: &anyhow::Error, state: &AppState) -> HttpResponse {
|
||||
let mut resp = HttpResponseBuilder::new(StatusCode::INTERNAL_SERVER_ERROR);
|
||||
resp.insert_header((header::CONTENT_TYPE, header::ContentType::plaintext()));
|
||||
|
||||
if let Some(status) = anyhow_error_status(e) {
|
||||
resp.status(status);
|
||||
if status == StatusCode::UNAUTHORIZED {
|
||||
resp.append_header((
|
||||
header::WWW_AUTHENTICATE,
|
||||
"Basic realm=\"Authentication required\", charset=\"UTF-8\"",
|
||||
));
|
||||
}
|
||||
} else if let Some(sqlx::Error::PoolTimedOut) = e.downcast_ref() {
|
||||
use rand::RngExt;
|
||||
resp.status(StatusCode::TOO_MANY_REQUESTS).insert_header((
|
||||
header::RETRY_AFTER,
|
||||
header::HeaderValue::from(rand::rng().random_range(1..=15)),
|
||||
));
|
||||
}
|
||||
match error_to_html_string(state, e) {
|
||||
Ok(body) => {
|
||||
resp.insert_header((header::CONTENT_TYPE, header::ContentType::html()));
|
||||
resp.body(body)
|
||||
}
|
||||
Err(second_err) => {
|
||||
log::error!("Unable to render error: {e:#}");
|
||||
resp.body(format!(
|
||||
"A second error occurred while rendering the error page: \n\n\
|
||||
Initial error: \n\
|
||||
{e:#}\n\n\
|
||||
Second error: \n\
|
||||
{second_err:#}"
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn anyhow_error_status(e: &anyhow::Error) -> Option<StatusCode> {
|
||||
if let Some(&ErrorWithStatus { status }) = e.downcast_ref() {
|
||||
Some(status)
|
||||
} else if let Some(sqlx::Error::PoolTimedOut) = e.downcast_ref() {
|
||||
Some(StatusCode::TOO_MANY_REQUESTS)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn send_anyhow_error(
|
||||
e: &anyhow::Error,
|
||||
resp_send: tokio::sync::oneshot::Sender<HttpResponse>,
|
||||
state: &AppState,
|
||||
) {
|
||||
log::error!("An error occurred before starting to send the response body: {e:#}");
|
||||
resp_send
|
||||
.send(anyhow_err_to_actix_resp(e, state))
|
||||
.unwrap_or_else(|_| log::error!("could not send headers"));
|
||||
}
|
||||
|
||||
pub(super) fn anyhow_err_to_actix(e: anyhow::Error, state: &AppState) -> actix_web::Error {
|
||||
log::error!("{e:#}");
|
||||
let resp = anyhow_err_to_actix_resp(&e, state);
|
||||
actix_web::error::InternalError::from_response(e, resp).into()
|
||||
}
|
||||
|
||||
pub(super) fn handle_form_error(
|
||||
decode_err: UrlencodedError,
|
||||
_req: &HttpRequest,
|
||||
) -> actix_web::Error {
|
||||
match decode_err {
|
||||
actix_web::error::UrlencodedError::Overflow { size, limit } => {
|
||||
actix_web::error::ErrorPayloadTooLarge(format!(
|
||||
"The submitted form data size ({size} bytes) exceeds the maximum allowed upload size ({limit} bytes). \
|
||||
You can increase this limit by setting max_uploaded_file_size in the configuration file.",
|
||||
))
|
||||
}
|
||||
_ => actix_web::Error::from(decode_err),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn bind_error(e: std::io::Error, listen_on: std::net::SocketAddr) -> anyhow::Error {
|
||||
let (ip, port) = (listen_on.ip(), listen_on.port());
|
||||
// Let's try to give a more helpful error message in common cases
|
||||
let ctx = match e.kind() {
|
||||
std::io::ErrorKind::AddrInUse => format!(
|
||||
"Another program is already using port {port} (maybe {} ?). \
|
||||
You can either stop that program or change the port in the configuration file.",
|
||||
if port == 80 || port == 443 {
|
||||
"Apache or Nginx"
|
||||
} else {
|
||||
"another instance of SQLPage"
|
||||
},
|
||||
),
|
||||
std::io::ErrorKind::PermissionDenied => format!(
|
||||
"You do not have permission to bind to {ip} on port {port}. \
|
||||
You can either run SQLPage as root with sudo, give it the permission to bind to low ports with `sudo setcap cap_net_bind_service=+ep {executable_path}`, \
|
||||
or change the port in the configuration file.",
|
||||
executable_path = std::env::current_exe()
|
||||
.unwrap_or_else(|_| PathBuf::from("sqlpage.bin"))
|
||||
.display(),
|
||||
),
|
||||
std::io::ErrorKind::AddrNotAvailable => format!(
|
||||
"The IP address {ip} does not exist on this computer. \
|
||||
You can change the value of listen_on in the configuration file.",
|
||||
),
|
||||
_ => format!("Unable to bind to {ip} on port {port}"),
|
||||
};
|
||||
anyhow::anyhow!(e).context(ctx)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Structural guard for the production-leak invariant.
|
||||
///
|
||||
/// Every output format renders an error solely from the fields of
|
||||
/// [`ClientError`] (via [`ClientError::message`] for machine formats and
|
||||
/// [`ClientError::to_html_data`] for HTML). If, in production, neither of
|
||||
/// those exposes any sensitive substring of the original error, then no
|
||||
/// renderer can leak, including formats added in the future. This test
|
||||
/// asserts exactly that at the single boundary, so it does not need to be
|
||||
/// repeated per format.
|
||||
#[test]
|
||||
fn test_production_client_error_hides_all_detail() {
|
||||
let sensitive = anyhow::anyhow!("DB error near 'secret_table'")
|
||||
.context("The SQL statement sent by SQLPage was: SELECT * FROM secret_table")
|
||||
.context("Error in file /srv/www/private/admin.sql");
|
||||
|
||||
let client_error = ClientError::new(&sensitive, DevOrProd::Production, Some(3));
|
||||
|
||||
// Everything a renderer can read about the error, serialized together.
|
||||
let exposed = format!(
|
||||
"{}\n{}",
|
||||
client_error.message(),
|
||||
client_error.to_html_data()
|
||||
);
|
||||
|
||||
for needle in [
|
||||
"secret_table",
|
||||
"SELECT",
|
||||
"/srv/www/private/admin.sql",
|
||||
".sql",
|
||||
"DB error",
|
||||
] {
|
||||
assert!(
|
||||
!exposed.contains(needle),
|
||||
"production ClientError leaked {needle:?}: {exposed}"
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
exposed.to_lowercase().contains("administrator"),
|
||||
"production ClientError should carry the generic message: {exposed}"
|
||||
);
|
||||
// The query number is detail too: it must not survive into production.
|
||||
assert!(
|
||||
!exposed.contains('3'),
|
||||
"production ClientError leaked the query number: {exposed}"
|
||||
);
|
||||
assert!(
|
||||
client_error.is_generic(),
|
||||
"a production ClientError must report itself as generic"
|
||||
);
|
||||
}
|
||||
|
||||
/// In development, the full detail must be preserved so authors can debug.
|
||||
#[test]
|
||||
fn test_development_client_error_keeps_detail() {
|
||||
let error = anyhow::anyhow!("near 'secret_table': syntax error")
|
||||
.context("The SQL statement sent by SQLPage was: SELECT 1");
|
||||
let client_error = ClientError::new(&error, DevOrProd::Development, Some(2));
|
||||
assert!(client_error.message().contains("SELECT 1"));
|
||||
assert!(!client_error.is_generic());
|
||||
let html = client_error.to_html_data();
|
||||
assert_eq!(html["query_number"], json!(2));
|
||||
assert!(html["backtrace"].as_array().is_some_and(|b| !b.is_empty()));
|
||||
assert!(html["note"].is_string());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
use actix_web::{
|
||||
ResponseError,
|
||||
http::{
|
||||
StatusCode,
|
||||
header::{self, ContentType},
|
||||
},
|
||||
};
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub struct ErrorWithStatus {
|
||||
pub status: StatusCode,
|
||||
}
|
||||
impl std::fmt::Display for ErrorWithStatus {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.status)
|
||||
}
|
||||
}
|
||||
impl std::error::Error for ErrorWithStatus {}
|
||||
|
||||
impl ResponseError for ErrorWithStatus {
|
||||
fn status_code(&self) -> StatusCode {
|
||||
self.status
|
||||
}
|
||||
fn error_response(&self) -> actix_web::HttpResponse {
|
||||
let mut resp_builder = actix_web::HttpResponse::build(self.status);
|
||||
resp_builder.content_type(ContentType::plaintext());
|
||||
if self.status == StatusCode::UNAUTHORIZED {
|
||||
resp_builder.insert_header((
|
||||
header::WWW_AUTHENTICATE,
|
||||
header::HeaderValue::from_static(
|
||||
"Basic realm=\"Authentication required\", charset=\"UTF-8\"",
|
||||
),
|
||||
));
|
||||
resp_builder.body("Sorry, but you are not authorized to access this page.")
|
||||
} else {
|
||||
resp_builder.body(self.status.to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub trait StatusCodeResultExt<T, E> {
|
||||
fn with_status(self, status: StatusCode) -> anyhow::Result<T>;
|
||||
fn with_status_from(self, get_status: impl FnOnce(&E) -> StatusCode) -> anyhow::Result<T>;
|
||||
fn with_response_status(self) -> anyhow::Result<T>
|
||||
where
|
||||
Self: Sized,
|
||||
E: ResponseError;
|
||||
}
|
||||
|
||||
impl<T, E> StatusCodeResultExt<T, E> for Result<T, E>
|
||||
where
|
||||
E: std::fmt::Display,
|
||||
{
|
||||
fn with_status(self, status: StatusCode) -> anyhow::Result<T> {
|
||||
self.map_err(|err| anyhow::anyhow!(ErrorWithStatus { status }).context(err.to_string()))
|
||||
}
|
||||
|
||||
fn with_status_from(self, get_status: impl FnOnce(&E) -> StatusCode) -> anyhow::Result<T> {
|
||||
self.map_err(|err| {
|
||||
let status = get_status(&err);
|
||||
anyhow::anyhow!(ErrorWithStatus { status }).context(err.to_string())
|
||||
})
|
||||
}
|
||||
|
||||
fn with_response_status(self) -> anyhow::Result<T>
|
||||
where
|
||||
E: ResponseError,
|
||||
{
|
||||
self.with_status_from(ResponseError::status_code)
|
||||
}
|
||||
}
|
||||
|
||||
pub trait ActixErrorStatusExt<T> {
|
||||
fn with_actix_error_status(self) -> anyhow::Result<T>;
|
||||
}
|
||||
|
||||
impl<T> ActixErrorStatusExt<T> for Result<T, actix_web::Error> {
|
||||
fn with_actix_error_status(self) -> anyhow::Result<T> {
|
||||
// Snapshot the HTTP status before converting to anyhow, which does not preserve Actix's response mapping for later inspection.
|
||||
self.with_status_from(|e| e.as_response_error().status_code())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,763 @@
|
||||
//! This module handles HTTP requests and responses for the web server,
|
||||
//! including rendering SQL files, serving static content, and managing
|
||||
//! request contexts and response headers.
|
||||
|
||||
use crate::render::{AnyRenderBodyContext, HeaderContext, PageContext};
|
||||
use crate::webserver::ErrorWithStatus;
|
||||
use crate::webserver::content_security_policy::ContentSecurityPolicy;
|
||||
use crate::webserver::database::execute_queries::stop_at_first_error;
|
||||
use crate::webserver::database::{DbItem, execute_queries::stream_query_results_with_conn};
|
||||
use crate::webserver::http_request_info::extract_request_info;
|
||||
use crate::webserver::server_timing::ServerTiming;
|
||||
use crate::{AppConfig, AppState, DEFAULT_404_FILE, ParsedSqlFile};
|
||||
use actix_web::dev::{ServiceFactory, ServiceRequest, fn_service};
|
||||
use actix_web::error::{ErrorBadRequest, ErrorInternalServerError};
|
||||
use actix_web::http::header::Accept;
|
||||
use actix_web::http::header::{ContentType, Header, HttpDate, IfModifiedSince, LastModified};
|
||||
use actix_web::http::{StatusCode, header};
|
||||
use actix_web::web::PayloadConfig;
|
||||
use actix_web::{App, Error, HttpResponse, HttpServer, dev::ServiceResponse, middleware, web};
|
||||
use opentelemetry_semantic_conventions::attribute as otel;
|
||||
use tracing::{Instrument, Span};
|
||||
use tracing_actix_web::{DefaultRootSpanBuilder, RootSpanBuilder, TracingLogger};
|
||||
|
||||
use super::error::{anyhow_err_to_actix, bind_error, send_anyhow_error};
|
||||
use super::http_client::make_http_client;
|
||||
use super::https::make_auto_rustls_config;
|
||||
use super::oidc::OidcMiddleware;
|
||||
use super::response_writer::ResponseWriter;
|
||||
use super::static_content;
|
||||
use crate::filesystem::FileAccess;
|
||||
use crate::webserver::routing::RoutingAction::{
|
||||
CustomNotFound, Execute, NotFound, Redirect, Serve,
|
||||
};
|
||||
use crate::webserver::routing::{AppFileStore, calculate_route};
|
||||
use actix_web::body::MessageBody;
|
||||
use anyhow::{Context, bail};
|
||||
use chrono::{DateTime, Utc};
|
||||
use futures_util::StreamExt;
|
||||
use futures_util::stream::Stream;
|
||||
use std::borrow::Cow;
|
||||
use std::path::PathBuf;
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
use std::time::SystemTime;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum ResponseFormat {
|
||||
#[default]
|
||||
Html,
|
||||
Json,
|
||||
JsonLines,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct RequestContext {
|
||||
pub is_embedded: bool,
|
||||
pub source_path: PathBuf,
|
||||
pub content_security_policy: ContentSecurityPolicy,
|
||||
pub server_timing: Arc<ServerTiming>,
|
||||
pub response_format: ResponseFormat,
|
||||
}
|
||||
|
||||
impl ResponseFormat {
|
||||
#[must_use]
|
||||
pub fn from_accept_header(accept: &Accept) -> Self {
|
||||
for quality_item in accept.iter() {
|
||||
let mime = &quality_item.item;
|
||||
let type_ = mime.type_().as_str();
|
||||
let subtype = mime.subtype().as_str();
|
||||
|
||||
match (type_, subtype) {
|
||||
("application", "json") => return Self::Json,
|
||||
("application", "x-ndjson" | "jsonlines" | "x-jsonlines") => {
|
||||
return Self::JsonLines;
|
||||
}
|
||||
("text", "x-ndjson" | "jsonlines" | "x-jsonlines") => return Self::JsonLines,
|
||||
("text", "html") | ("*", "*") => return Self::Html,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Self::Html
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn content_type(self) -> &'static str {
|
||||
match self {
|
||||
Self::Html => "text/html; charset=utf-8",
|
||||
Self::Json => "application/json",
|
||||
Self::JsonLines => "application/x-ndjson",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn stream_response(stream: impl Stream<Item = DbItem>, mut renderer: AnyRenderBodyContext) {
|
||||
let mut stream = Box::pin(stream);
|
||||
|
||||
if let Err(e) = &renderer.flush().await {
|
||||
log::error!("Unable to flush initial data to client: {e}");
|
||||
return;
|
||||
}
|
||||
|
||||
while let Some(item) = stream.next().await {
|
||||
log::trace!("Received item from database: {item:?}");
|
||||
let render_result = match item {
|
||||
DbItem::FinishedQuery => renderer.finish_query().await,
|
||||
DbItem::Row(row) => renderer.handle_row(&row).await,
|
||||
DbItem::Error(e) => renderer.handle_error(&e).await,
|
||||
};
|
||||
if let Err(e) = render_result
|
||||
&& let Err(nested_err) = renderer.handle_error(&e).await
|
||||
{
|
||||
renderer
|
||||
.close()
|
||||
.await
|
||||
.close_with_error(nested_err.to_string())
|
||||
.await;
|
||||
log::error!(
|
||||
"An error occurred while trying to display an other error. \
|
||||
\nRoot error: {e}\n
|
||||
\nNested error: {nested_err}"
|
||||
);
|
||||
return;
|
||||
}
|
||||
if let Err(e) = &renderer.flush().await {
|
||||
log::error!(
|
||||
"Stopping rendering early because we were unable to flush data to client. \
|
||||
The user has probably closed the connection before we finished rendering the page: {e:#}"
|
||||
);
|
||||
// If we cannot write to the client anymore, there is nothing we can do, so we just stop rendering
|
||||
return;
|
||||
}
|
||||
}
|
||||
if let Err(e) = &renderer.close().await.async_flush().await {
|
||||
log::error!("Unable to flush data to client after rendering the page end: {e}");
|
||||
return;
|
||||
}
|
||||
log::debug!("Successfully finished rendering the page");
|
||||
}
|
||||
|
||||
async fn build_response_header_and_stream<S: Stream<Item = DbItem>>(
|
||||
app_state: Arc<AppState>,
|
||||
database_entries: S,
|
||||
request_context: RequestContext,
|
||||
) -> anyhow::Result<ResponseWithWriter<S>> {
|
||||
let chan_size = app_state.config.max_pending_rows;
|
||||
let (sender, receiver) = mpsc::channel(chan_size);
|
||||
let writer = ResponseWriter::new(sender);
|
||||
let mut head_context = HeaderContext::new(app_state, request_context, writer);
|
||||
let mut stream = Box::pin(database_entries);
|
||||
while let Some(item) = stream.next().await {
|
||||
let page_context = match item {
|
||||
DbItem::Row(data) => {
|
||||
head_context.request_context.server_timing.record("row");
|
||||
head_context.handle_row(data).await?
|
||||
}
|
||||
DbItem::FinishedQuery => {
|
||||
log::debug!("finished query");
|
||||
continue;
|
||||
}
|
||||
DbItem::Error(source_err)
|
||||
if matches!(
|
||||
source_err.downcast_ref(),
|
||||
Some(&ErrorWithStatus { status: _ })
|
||||
) =>
|
||||
{
|
||||
return Err(source_err);
|
||||
}
|
||||
DbItem::Error(source_err) => head_context.handle_error(source_err).await?,
|
||||
};
|
||||
match page_context {
|
||||
PageContext::Header(h) => {
|
||||
head_context = h;
|
||||
}
|
||||
PageContext::Body {
|
||||
mut http_response,
|
||||
renderer,
|
||||
} => {
|
||||
let body_stream = tokio_stream::wrappers::ReceiverStream::new(receiver);
|
||||
let result_stream = body_stream.map(Ok::<_, actix_web::Error>);
|
||||
let http_response = http_response.streaming(result_stream);
|
||||
return Ok(ResponseWithWriter::RenderStream {
|
||||
http_response,
|
||||
renderer,
|
||||
database_entries_stream: stream,
|
||||
});
|
||||
}
|
||||
PageContext::Close(http_response) => {
|
||||
return Ok(ResponseWithWriter::FinishedResponse { http_response });
|
||||
}
|
||||
}
|
||||
}
|
||||
log::debug!("No SQL statements left to execute for the body of the response");
|
||||
let http_response = head_context.close()?;
|
||||
Ok(ResponseWithWriter::FinishedResponse { http_response })
|
||||
}
|
||||
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
enum ResponseWithWriter<S> {
|
||||
RenderStream {
|
||||
http_response: HttpResponse,
|
||||
renderer: AnyRenderBodyContext,
|
||||
database_entries_stream: Pin<Box<S>>,
|
||||
},
|
||||
FinishedResponse {
|
||||
http_response: HttpResponse,
|
||||
},
|
||||
}
|
||||
|
||||
async fn render_sql(
|
||||
srv_req: &mut ServiceRequest,
|
||||
sql_file: Arc<ParsedSqlFile>,
|
||||
server_timing: ServerTiming,
|
||||
) -> actix_web::Result<HttpResponse> {
|
||||
let app_state = srv_req
|
||||
.app_data::<web::Data<AppState>>()
|
||||
.ok_or_else(|| ErrorInternalServerError("no state"))?
|
||||
.clone()
|
||||
.into_inner();
|
||||
|
||||
let response_format = Accept::parse(srv_req)
|
||||
.map(|accept| ResponseFormat::from_accept_header(&accept))
|
||||
.unwrap_or_default();
|
||||
|
||||
let exec_ctx = {
|
||||
let content_type = srv_req
|
||||
.headers()
|
||||
.get("content-type")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("");
|
||||
let content_length = srv_req
|
||||
.headers()
|
||||
.get("content-length")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.parse::<i64>().ok());
|
||||
let url_query = srv_req.query_string();
|
||||
let url_query = if url_query.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(url_query)
|
||||
};
|
||||
let parse_span = tracing::info_span!(
|
||||
"http.parse_request",
|
||||
http.request.method = %srv_req.method(),
|
||||
"http.request.header.content-type" = content_type,
|
||||
http.request.body.size = content_length,
|
||||
url.query = url_query,
|
||||
);
|
||||
extract_request_info(srv_req, Arc::clone(&app_state), server_timing)
|
||||
.instrument(parse_span)
|
||||
.await
|
||||
.map_err(|e| anyhow_err_to_actix(e, &app_state))?
|
||||
};
|
||||
log::debug!("Received a request with the following parameters: {exec_ctx:?}");
|
||||
|
||||
exec_ctx.request().server_timing.record("parse_req");
|
||||
|
||||
let (resp_send, resp_recv) = tokio::sync::oneshot::channel::<HttpResponse>();
|
||||
let source_path: PathBuf = sql_file.source_path.clone();
|
||||
let exec_span = tracing::info_span!(
|
||||
"sqlpage.file",
|
||||
otel.name = %sql_execution_span_name(&source_path),
|
||||
{ otel::CODE_FILE_PATH } = %source_path.display(),
|
||||
);
|
||||
actix_web::rt::spawn(tracing::Instrument::instrument(
|
||||
async move {
|
||||
let request_info = exec_ctx.request();
|
||||
let request_context = RequestContext {
|
||||
is_embedded: request_info.url_params.contains_key("_sqlpage_embed"),
|
||||
source_path,
|
||||
content_security_policy: ContentSecurityPolicy::with_random_nonce(),
|
||||
server_timing: Arc::clone(&request_info.server_timing),
|
||||
response_format,
|
||||
};
|
||||
let mut conn = None;
|
||||
let database_entries_stream =
|
||||
stream_query_results_with_conn(&sql_file, &exec_ctx, &mut conn);
|
||||
let database_entries_stream = stop_at_first_error(database_entries_stream);
|
||||
let response_with_writer = build_response_header_and_stream(
|
||||
Arc::clone(&app_state),
|
||||
database_entries_stream,
|
||||
request_context,
|
||||
)
|
||||
.await;
|
||||
match response_with_writer {
|
||||
Ok(ResponseWithWriter::RenderStream {
|
||||
http_response,
|
||||
renderer,
|
||||
database_entries_stream,
|
||||
}) => {
|
||||
resp_send
|
||||
.send(http_response)
|
||||
.unwrap_or_else(|e| log::error!("could not send headers {e:?}"));
|
||||
tracing::Instrument::instrument(
|
||||
stream_response(database_entries_stream, renderer),
|
||||
tracing::info_span!("render"),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Ok(ResponseWithWriter::FinishedResponse { http_response }) => {
|
||||
resp_send
|
||||
.send(http_response)
|
||||
.unwrap_or_else(|e| log::error!("could not send headers {e:?}"));
|
||||
}
|
||||
Err(err) => {
|
||||
send_anyhow_error(&err, resp_send, &app_state);
|
||||
}
|
||||
}
|
||||
},
|
||||
exec_span,
|
||||
));
|
||||
resp_recv.await.map_err(ErrorInternalServerError)
|
||||
}
|
||||
|
||||
fn request_span_route(request: &ServiceRequest) -> Cow<'_, str> {
|
||||
request
|
||||
.match_pattern()
|
||||
.map_or_else(|| request.path().to_owned().into(), Cow::from)
|
||||
}
|
||||
|
||||
fn request_span_name(request: &ServiceRequest) -> String {
|
||||
format!("{} {}", request.method(), request_span_route(request))
|
||||
}
|
||||
|
||||
fn sql_execution_span_name(source_path: &std::path::Path) -> String {
|
||||
format!("SQL {}", source_path.display())
|
||||
}
|
||||
|
||||
struct RequestHeaderCarrier<'a>(&'a actix_web::http::header::HeaderMap);
|
||||
|
||||
impl opentelemetry::propagation::Extractor for RequestHeaderCarrier<'_> {
|
||||
fn get(&self, key: &str) -> Option<&str> {
|
||||
self.0.get(key).and_then(|value| value.to_str().ok())
|
||||
}
|
||||
|
||||
fn keys(&self) -> Vec<&str> {
|
||||
self.0
|
||||
.keys()
|
||||
.map(actix_web::http::header::HeaderName::as_str)
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn set_otel_parent(request: &ServiceRequest, span: &Span) {
|
||||
use tracing_opentelemetry::OpenTelemetrySpanExt as _;
|
||||
|
||||
let parent_context = opentelemetry::global::get_text_map_propagator(|propagator| {
|
||||
propagator.extract(&RequestHeaderCarrier(request.headers()))
|
||||
});
|
||||
let _ = span.set_parent(parent_context);
|
||||
}
|
||||
|
||||
struct SqlPageRootSpanBuilder;
|
||||
|
||||
impl RootSpanBuilder for SqlPageRootSpanBuilder {
|
||||
fn on_request_start(request: &ServiceRequest) -> Span {
|
||||
let user_agent = request
|
||||
.headers()
|
||||
.get("User-Agent")
|
||||
.map_or("", |h| h.to_str().unwrap_or(""));
|
||||
let http_route = request_span_route(request);
|
||||
let http_method =
|
||||
tracing_actix_web::root_span_macro::private::http_method_str(request.method());
|
||||
let otel_name = request_span_name(request);
|
||||
let connection_info = request.connection_info();
|
||||
let request_id = tracing_actix_web::root_span_macro::private::get_request_id(request);
|
||||
|
||||
let span = tracing::span!(
|
||||
tracing::Level::INFO,
|
||||
"HTTP request",
|
||||
{ otel::HTTP_REQUEST_METHOD } = %http_method,
|
||||
{ otel::HTTP_ROUTE } = %http_route,
|
||||
{ otel::NETWORK_PROTOCOL_NAME } = "http",
|
||||
{ otel::NETWORK_PROTOCOL_VERSION } = %tracing_actix_web::root_span_macro::private::http_flavor(request.version()),
|
||||
{ otel::URL_SCHEME } = %tracing_actix_web::root_span_macro::private::http_scheme(connection_info.scheme()),
|
||||
{ otel::SERVER_ADDRESS } = %connection_info.host(),
|
||||
{ otel::CLIENT_ADDRESS } = %request.connection_info().realip_remote_addr().unwrap_or(""),
|
||||
{ otel::USER_AGENT_ORIGINAL } = %user_agent,
|
||||
{ otel::URL_PATH } = %request.path(),
|
||||
{ otel::URL_QUERY } = %request.query_string(),
|
||||
{ otel::HTTP_RESPONSE_STATUS_CODE } = tracing::field::Empty,
|
||||
"otel.name" = %otel_name,
|
||||
"otel.kind" = "server",
|
||||
{ otel::OTEL_STATUS_CODE } = tracing::field::Empty,
|
||||
request_id = %request_id,
|
||||
{ otel::EXCEPTION_MESSAGE } = tracing::field::Empty,
|
||||
"sqlpage.exception.details" = tracing::field::Empty,
|
||||
);
|
||||
std::mem::drop(connection_info);
|
||||
set_otel_parent(request, &span);
|
||||
span
|
||||
}
|
||||
|
||||
fn on_request_end<B: MessageBody>(span: Span, outcome: &Result<ServiceResponse<B>, Error>) {
|
||||
let span_ref = span.clone();
|
||||
DefaultRootSpanBuilder::on_request_end(span, outcome);
|
||||
|
||||
// Emit a single log event per completed request so it appears in logs.
|
||||
let _enter = span_ref.enter();
|
||||
if let Ok(response) = outcome {
|
||||
let status = response.response().status();
|
||||
let level = if status.is_server_error() {
|
||||
log::Level::Error
|
||||
} else if status.is_client_error() {
|
||||
log::Level::Warn
|
||||
} else {
|
||||
log::Level::Info
|
||||
};
|
||||
log::log!(target: crate::telemetry::ACCESS_LOG_TARGET, level, "{status}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn process_sql_request(
|
||||
req: &mut ServiceRequest,
|
||||
sql_path: PathBuf,
|
||||
) -> actix_web::Result<HttpResponse> {
|
||||
let app_state: &web::Data<AppState> = req.app_data().expect("app_state");
|
||||
let server_timing = ServerTiming::for_env(app_state.config.environment);
|
||||
|
||||
let access =
|
||||
FileAccess::unprivileged(&sql_path).map_err(|e| anyhow_err_to_actix(e, app_state))?;
|
||||
let sql_file = {
|
||||
let span = tracing::info_span!(
|
||||
"sqlpage.file.load",
|
||||
{ otel::CODE_FILE_PATH } = %sql_path.display(),
|
||||
);
|
||||
app_state
|
||||
.sql_file_cache
|
||||
.get(app_state, access)
|
||||
.instrument(span)
|
||||
.await
|
||||
.with_context(|| format!("Unable to read SQL file \"{}\"", sql_path.display()))
|
||||
.map_err(|e| anyhow_err_to_actix(e, app_state))?
|
||||
};
|
||||
server_timing.record("sql_file");
|
||||
|
||||
render_sql(req, sql_file, server_timing).await
|
||||
}
|
||||
|
||||
async fn serve_file(
|
||||
path: &str,
|
||||
state: &AppState,
|
||||
if_modified_since: Option<IfModifiedSince>,
|
||||
) -> actix_web::Result<HttpResponse> {
|
||||
let path = strip_site_prefix(path, state);
|
||||
let access =
|
||||
FileAccess::unprivileged(path.as_ref()).map_err(|e| anyhow_err_to_actix(e, state))?;
|
||||
if let Some(IfModifiedSince(date)) = if_modified_since {
|
||||
let since = DateTime::<Utc>::from(SystemTime::from(date));
|
||||
let modified = state
|
||||
.file_system
|
||||
.modified_since(state, access, since)
|
||||
.await
|
||||
.with_context(|| format!("Unable to get modification time of file {path:?}"))
|
||||
.map_err(|e| anyhow_err_to_actix(e, state))?;
|
||||
if !modified {
|
||||
return Ok(HttpResponse::NotModified().finish());
|
||||
}
|
||||
}
|
||||
state
|
||||
.file_system
|
||||
.read_file(state, access)
|
||||
.await
|
||||
.with_context(|| format!("Unable to read file {path:?}"))
|
||||
.map_err(|e| anyhow_err_to_actix(e, state))
|
||||
.map(|b| {
|
||||
HttpResponse::Ok()
|
||||
.insert_header(
|
||||
mime_guess::from_path(path)
|
||||
.first()
|
||||
.map_or_else(ContentType::octet_stream, ContentType),
|
||||
)
|
||||
.insert_header(LastModified(HttpDate::from(SystemTime::now())))
|
||||
.body(b)
|
||||
})
|
||||
}
|
||||
|
||||
/// Strips the site prefix from a path
|
||||
fn strip_site_prefix<'a>(path: &'a str, state: &AppState) -> &'a str {
|
||||
path.strip_prefix(&state.config.site_prefix).unwrap_or(path)
|
||||
}
|
||||
|
||||
pub async fn main_handler(
|
||||
mut service_request: ServiceRequest,
|
||||
) -> actix_web::Result<ServiceResponse> {
|
||||
let app_state: &web::Data<AppState> = service_request.app_data().expect("app_state");
|
||||
let store = AppFileStore::new(&app_state.sql_file_cache, &app_state.file_system, app_state);
|
||||
let path_and_query = service_request
|
||||
.uri()
|
||||
.path_and_query()
|
||||
.ok_or_else(|| ErrorBadRequest("expected valid path with query from request"))?;
|
||||
let routing_action = match calculate_route(path_and_query, &store, &app_state.config).await {
|
||||
Ok(action) => action,
|
||||
Err(e) => {
|
||||
let e = e.context(format!(
|
||||
"The server was unable to fulfill your request. \n\
|
||||
The following page is not accessible: {path_and_query:?}"
|
||||
));
|
||||
return Err(anyhow_err_to_actix(e, app_state));
|
||||
}
|
||||
};
|
||||
match routing_action {
|
||||
NotFound => {
|
||||
let accept_header =
|
||||
header::Accept::parse(&service_request).unwrap_or(header::Accept::star());
|
||||
let prefers_html = accept_header.iter().any(|h| h.item.subtype() == "html");
|
||||
|
||||
if prefers_html {
|
||||
let mut response =
|
||||
process_sql_request(&mut service_request, PathBuf::from(DEFAULT_404_FILE))
|
||||
.await?;
|
||||
*response.status_mut() = StatusCode::NOT_FOUND;
|
||||
Ok(response)
|
||||
} else {
|
||||
Ok(HttpResponse::NotFound()
|
||||
.content_type(ContentType::plaintext())
|
||||
.body("404 Not Found\n"))
|
||||
}
|
||||
}
|
||||
Execute(path) => process_sql_request(&mut service_request, path).await,
|
||||
CustomNotFound(path) => {
|
||||
// Currently, we do not set a 404 status when the user provides a fallback 404.sql file.
|
||||
process_sql_request(&mut service_request, path).await
|
||||
}
|
||||
Redirect(redirect_target) => Ok(HttpResponse::MovedPermanently()
|
||||
.insert_header((header::LOCATION, redirect_target))
|
||||
.finish()),
|
||||
Serve(path) => {
|
||||
let if_modified_since = IfModifiedSince::parse(&service_request).ok();
|
||||
let app_state: &web::Data<AppState> = service_request.app_data().expect("app_state");
|
||||
let path = path
|
||||
.as_os_str()
|
||||
.to_str()
|
||||
.ok_or_else(|| ErrorBadRequest("requested file path must be valid unicode"))?;
|
||||
serve_file(path, app_state, if_modified_since).await
|
||||
}
|
||||
}
|
||||
.map(|response| service_request.into_response(response))
|
||||
}
|
||||
|
||||
/// called when a request is made to a path outside of the sub-path we are serving the site from
|
||||
async fn default_prefix_redirect(
|
||||
service_request: ServiceRequest,
|
||||
) -> actix_web::Result<ServiceResponse> {
|
||||
let app_state: &web::Data<AppState> = service_request.app_data().expect("app_state");
|
||||
let original_path = service_request.path();
|
||||
let site_prefix = &app_state.config.site_prefix;
|
||||
let redirect_path = site_prefix.trim_end_matches('/').to_string() + original_path;
|
||||
log::info!(
|
||||
"Received request to {original_path} (outside of site prefix {site_prefix}), redirecting to {redirect_path}"
|
||||
);
|
||||
Ok(service_request.into_response(
|
||||
HttpResponse::PermanentRedirect()
|
||||
.insert_header((header::LOCATION, redirect_path))
|
||||
.finish(),
|
||||
))
|
||||
}
|
||||
|
||||
pub fn create_app(
|
||||
app_state: web::Data<AppState>,
|
||||
) -> App<
|
||||
impl ServiceFactory<
|
||||
ServiceRequest,
|
||||
Config = (),
|
||||
Response = ServiceResponse<
|
||||
impl MessageBody<Error = impl std::fmt::Display + std::fmt::Debug>,
|
||||
>,
|
||||
Error = actix_web::Error,
|
||||
InitError = (),
|
||||
>,
|
||||
> {
|
||||
let encoded_scope: &str = app_state.config.site_prefix.trim_end_matches('/');
|
||||
let decoded_scope = percent_encoding::percent_decode_str(encoded_scope).decode_utf8_lossy();
|
||||
App::new()
|
||||
.service(
|
||||
web::scope(&decoded_scope)
|
||||
.service(static_content::js())
|
||||
.service(static_content::apexcharts_js())
|
||||
.service(static_content::tomselect_js())
|
||||
.service(static_content::css())
|
||||
.service(static_content::favicon())
|
||||
.default_service(fn_service(main_handler)),
|
||||
)
|
||||
// when receiving a request outside of the prefix, redirect to the prefix
|
||||
.default_service(fn_service(default_prefix_redirect))
|
||||
.wrap(OidcMiddleware::new(&app_state))
|
||||
.wrap(super::http_metrics::HttpMetrics)
|
||||
.wrap(TracingLogger::<SqlPageRootSpanBuilder>::new())
|
||||
.wrap(default_headers())
|
||||
.wrap(middleware::Condition::new(
|
||||
app_state.config.compress_responses,
|
||||
middleware::Compress::default(),
|
||||
))
|
||||
.wrap(middleware::NormalizePath::new(
|
||||
middleware::TrailingSlash::MergeOnly,
|
||||
))
|
||||
.app_data(payload_config(&app_state))
|
||||
.app_data(make_http_client(&app_state.config))
|
||||
.app_data(form_config(&app_state))
|
||||
.app_data(app_state)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn form_config(app_state: &web::Data<AppState>) -> web::FormConfig {
|
||||
web::FormConfig::default()
|
||||
.limit(app_state.config.max_uploaded_file_size)
|
||||
.error_handler(super::error::handle_form_error)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn payload_config(app_state: &web::Data<AppState>) -> PayloadConfig {
|
||||
PayloadConfig::default().limit(app_state.config.max_uploaded_file_size * 2)
|
||||
}
|
||||
|
||||
fn default_headers() -> middleware::DefaultHeaders {
|
||||
let server_header = format!("{} v{}", env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION"));
|
||||
middleware::DefaultHeaders::new().add(("Server", server_header))
|
||||
}
|
||||
|
||||
pub async fn run_server(config: &AppConfig, state: AppState) -> anyhow::Result<()> {
|
||||
let listen_on = config.listen_on();
|
||||
let state = web::Data::new(state);
|
||||
let final_state = web::Data::clone(&state);
|
||||
let factory = move || create_app(web::Data::clone(&state));
|
||||
|
||||
#[cfg(feature = "lambda-web")]
|
||||
if lambda_web::is_running_on_lambda() {
|
||||
lambda_web::run_actix_on_lambda(factory)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Unable to start the lambda: {e}"))?;
|
||||
return Ok(());
|
||||
}
|
||||
let mut server = HttpServer::new(factory);
|
||||
#[cfg_attr(
|
||||
not(target_family = "unix"),
|
||||
expect(
|
||||
clippy::redundant_else,
|
||||
reason = "Conditional compilation produces redundant else when not on unix targets."
|
||||
)
|
||||
)]
|
||||
if let Some(unix_socket) = &config.unix_socket {
|
||||
log::info!(
|
||||
"Will start HTTP server on UNIX socket: \"{}\"",
|
||||
unix_socket.display()
|
||||
);
|
||||
#[cfg(target_family = "unix")]
|
||||
{
|
||||
server = server
|
||||
.bind_uds(unix_socket)
|
||||
.map_err(|e| bind_unix_socket_err(e, unix_socket))?;
|
||||
}
|
||||
#[cfg(not(target_family = "unix"))]
|
||||
anyhow::bail!(
|
||||
"Unix sockets are not supported on your operating system. Use listen_on instead of unix_socket."
|
||||
);
|
||||
} else {
|
||||
if let Some(domain) = &config.https_domain {
|
||||
let mut listen_on_https = listen_on;
|
||||
listen_on_https.set_port(443);
|
||||
log::debug!("Will start HTTPS server on {listen_on_https}");
|
||||
let config = make_auto_rustls_config(domain, config);
|
||||
server = server
|
||||
.bind_rustls_0_23(listen_on_https, config)
|
||||
.map_err(|e| bind_error(e, listen_on_https))?;
|
||||
} else if listen_on.port() == 443 {
|
||||
bail!(
|
||||
"Please specify a value for https_domain in the configuration file. This is required when using HTTPS (port 443)"
|
||||
);
|
||||
}
|
||||
if listen_on.port() != 443 {
|
||||
log::debug!("Will start HTTP server on {listen_on}");
|
||||
server = server
|
||||
.bind(listen_on)
|
||||
.map_err(|e| bind_error(e, listen_on))?;
|
||||
}
|
||||
}
|
||||
|
||||
log_welcome_message(config);
|
||||
server
|
||||
.run()
|
||||
.await
|
||||
.with_context(|| "Unable to start the application")?;
|
||||
|
||||
// We are done, we can close the database connection
|
||||
final_state.db.close().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn log_welcome_message(config: &AppConfig) {
|
||||
let address_message = if let Some(unix_socket) = &config.unix_socket {
|
||||
format!("unix socket \"{}\"", unix_socket.display())
|
||||
} else if let Some(domain) = &config.https_domain {
|
||||
format!("https://{domain}")
|
||||
} else {
|
||||
let listen_on = config.listen_on();
|
||||
let port = listen_on.port();
|
||||
let ip = listen_on.ip();
|
||||
if ip.is_unspecified() {
|
||||
format!(
|
||||
"http://localhost:{port}\n\
|
||||
(also accessible from other devices using your IP address)"
|
||||
)
|
||||
} else if ip.is_ipv6() {
|
||||
format!("http://[{ip}]:{port}")
|
||||
} else {
|
||||
format!("http://{ip}:{port}")
|
||||
}
|
||||
};
|
||||
|
||||
let (sparkle, link, computer, rocket) = if cfg!(target_os = "windows") {
|
||||
("", "", "", "")
|
||||
} else {
|
||||
("✨", "🔗", "💻", "🚀")
|
||||
};
|
||||
let version = env!("CARGO_PKG_VERSION");
|
||||
let web_root = config.web_root.display();
|
||||
|
||||
log::info!(
|
||||
"\n{sparkle} SQLPage v{version} started successfully! {sparkle}\n\n\
|
||||
View your website at:\n{link} {address_message}\n\n\
|
||||
Create your pages with SQL files in:\n{computer} {web_root}\n\n\
|
||||
Happy coding! {rocket}"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(target_family = "unix")]
|
||||
fn bind_unix_socket_err(e: std::io::Error, unix_socket: &std::path::Path) -> anyhow::Error {
|
||||
let ctx = if e.kind() == std::io::ErrorKind::PermissionDenied {
|
||||
format!(
|
||||
"You do not have permission to bind to the UNIX socket \"{}\". \
|
||||
You can change the socket path in the configuration file or check the permissions.",
|
||||
unix_socket.display()
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"Unable to bind to UNIX socket \"{}\" {e:?}",
|
||||
unix_socket.display()
|
||||
)
|
||||
};
|
||||
anyhow::anyhow!(e).context(ctx)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{request_span_name, sql_execution_span_name};
|
||||
use actix_web::test::TestRequest;
|
||||
use std::path::Path;
|
||||
|
||||
#[test]
|
||||
fn request_span_name_uses_request_path_when_no_matched_route_exists() {
|
||||
let request = TestRequest::with_uri("/todos/42?filter=open").to_srv_request();
|
||||
assert_eq!(request_span_name(&request), "GET /todos/42");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sql_execution_span_name_uses_sql_file_path() {
|
||||
assert_eq!(
|
||||
sql_execution_span_name(Path::new("website/todos.sql")),
|
||||
"SQL website/todos.sql"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
use actix_web::dev::ServiceRequest;
|
||||
use anyhow::{Context, anyhow};
|
||||
use rustls_native_certs::CertificateResult;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
static NATIVE_CERTS: OnceLock<anyhow::Result<rustls::RootCertStore>> = OnceLock::new();
|
||||
|
||||
pub fn make_http_client(config: &crate::app_config::AppConfig) -> anyhow::Result<awc::Client> {
|
||||
make_http_client_with_system_roots(config.system_root_ca_certificates)
|
||||
}
|
||||
|
||||
pub(crate) fn default_system_root_ca_certificates_from_env() -> bool {
|
||||
std::env::var("SSL_CERT_FILE").is_ok_and(|value| !value.is_empty())
|
||||
|| std::env::var("SSL_CERT_DIR").is_ok_and(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
pub(crate) fn make_http_client_with_system_roots(
|
||||
system_root_ca_certificates: bool,
|
||||
) -> anyhow::Result<awc::Client> {
|
||||
let connector = if system_root_ca_certificates {
|
||||
let roots = NATIVE_CERTS
|
||||
.get_or_init(|| {
|
||||
log::debug!("Loading native certificates because system_root_ca_certificates is enabled");
|
||||
let CertificateResult { certs, errors, .. } = rustls_native_certs::load_native_certs();
|
||||
log::debug!("Loaded {} native HTTPS client certificates", certs.len());
|
||||
for error in errors {
|
||||
log::error!("Unable to load native certificate: {error}");
|
||||
}
|
||||
let mut roots = rustls::RootCertStore::empty();
|
||||
for cert in certs {
|
||||
log::trace!("Adding native certificate to root store: {cert:?}");
|
||||
roots.add(cert.clone()).with_context(|| {
|
||||
format!("Unable to add certificate to root store: {cert:?}")
|
||||
})?;
|
||||
}
|
||||
Ok(roots)
|
||||
})
|
||||
.as_ref()
|
||||
.map_err(|e| anyhow!("Unable to load native certificates, make sure the system root CA certificates are available: {e}"))?;
|
||||
|
||||
log::trace!(
|
||||
"Creating HTTP client with custom TLS connector using native certificates. SSL_CERT_FILE={:?}, SSL_CERT_DIR={:?}",
|
||||
std::env::var("SSL_CERT_FILE").unwrap_or_default(),
|
||||
std::env::var("SSL_CERT_DIR").unwrap_or_default()
|
||||
);
|
||||
|
||||
let tls_conf = rustls::ClientConfig::builder()
|
||||
.with_root_certificates(roots.clone())
|
||||
.with_no_client_auth();
|
||||
|
||||
awc::Connector::new().rustls_0_23(std::sync::Arc::new(tls_conf))
|
||||
} else {
|
||||
log::debug!(
|
||||
"Using the default tls connector with builtin certs because system_root_ca_certificates is disabled"
|
||||
);
|
||||
awc::Connector::new()
|
||||
};
|
||||
let client = awc::Client::builder()
|
||||
.connector(connector)
|
||||
.add_default_header((awc::http::header::USER_AGENT, env!("CARGO_PKG_NAME")))
|
||||
.finish();
|
||||
log::debug!("Created HTTP client");
|
||||
Ok(client)
|
||||
}
|
||||
|
||||
pub(crate) fn get_http_client_from_appdata(
|
||||
request: &ServiceRequest,
|
||||
) -> anyhow::Result<&awc::Client> {
|
||||
if let Some(result) = request.app_data::<anyhow::Result<awc::Client>>() {
|
||||
result
|
||||
.as_ref()
|
||||
.map_err(|e| anyhow!("HTTP client initialization failed: {e}"))
|
||||
} else {
|
||||
Err(anyhow!("HTTP client not found in app data"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
use std::future::{Ready, ready};
|
||||
use std::time::Instant;
|
||||
|
||||
use actix_web::{
|
||||
Error,
|
||||
dev::{Service, ServiceRequest, ServiceResponse, Transform, forward_ready},
|
||||
web,
|
||||
};
|
||||
use futures_util::future::LocalBoxFuture;
|
||||
use opentelemetry::KeyValue;
|
||||
use opentelemetry_semantic_conventions::attribute as otel;
|
||||
use tracing_actix_web::root_span_macro::private::{http_method_str, http_scheme};
|
||||
|
||||
use crate::AppState;
|
||||
|
||||
pub struct HttpMetrics;
|
||||
|
||||
impl<S, B> Transform<S, ServiceRequest> for HttpMetrics
|
||||
where
|
||||
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error> + 'static,
|
||||
S::Future: 'static,
|
||||
{
|
||||
type Response = ServiceResponse<B>;
|
||||
type Error = Error;
|
||||
type Transform = HttpMetricsMiddleware<S>;
|
||||
type InitError = ();
|
||||
type Future = Ready<Result<Self::Transform, Self::InitError>>;
|
||||
|
||||
fn new_transform(&self, service: S) -> Self::Future {
|
||||
ready(Ok(HttpMetricsMiddleware { service }))
|
||||
}
|
||||
}
|
||||
|
||||
pub struct HttpMetricsMiddleware<S> {
|
||||
service: S,
|
||||
}
|
||||
|
||||
impl<S, B> Service<ServiceRequest> for HttpMetricsMiddleware<S>
|
||||
where
|
||||
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error> + 'static,
|
||||
S::Future: 'static,
|
||||
{
|
||||
type Response = ServiceResponse<B>;
|
||||
type Error = Error;
|
||||
type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
|
||||
|
||||
forward_ready!(service);
|
||||
|
||||
fn call(&self, req: ServiceRequest) -> Self::Future {
|
||||
let start_time = Instant::now();
|
||||
let method = http_method_str(req.method()).to_string();
|
||||
let connection_info = req.connection_info();
|
||||
let scheme = http_scheme(connection_info.scheme()).to_string();
|
||||
let host = connection_info.host().to_string();
|
||||
drop(connection_info);
|
||||
|
||||
// We get the route pattern. In Actix, req.match_pattern() returns the matched route
|
||||
let route = req
|
||||
.match_pattern()
|
||||
.unwrap_or_else(|| req.path().to_string());
|
||||
|
||||
let fut = self.service.call(req);
|
||||
|
||||
Box::pin(async move {
|
||||
let res = fut.await?;
|
||||
let duration = start_time.elapsed().as_secs_f64();
|
||||
let status = res.status().as_u16();
|
||||
|
||||
let mut attributes = vec![
|
||||
KeyValue::new(otel::HTTP_REQUEST_METHOD, method),
|
||||
KeyValue::new(otel::HTTP_RESPONSE_STATUS_CODE, status.to_string()),
|
||||
KeyValue::new(otel::HTTP_ROUTE, route),
|
||||
KeyValue::new(otel::URL_SCHEME, scheme),
|
||||
KeyValue::new(otel::SERVER_ADDRESS, host),
|
||||
];
|
||||
|
||||
if status >= 500 {
|
||||
attributes.push(KeyValue::new(otel::ERROR_TYPE, status.to_string()));
|
||||
}
|
||||
|
||||
if let Some(app_state) = res.request().app_data::<web::Data<AppState>>() {
|
||||
app_state
|
||||
.telemetry_metrics
|
||||
.http_request_duration
|
||||
.record(duration, &attributes);
|
||||
}
|
||||
|
||||
Ok(res)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
use crate::AppState;
|
||||
use crate::webserver::request_variables::SetVariablesMap;
|
||||
use crate::webserver::server_timing::ServerTiming;
|
||||
use actix_multipart::Multipart;
|
||||
use actix_multipart::form::FieldReader;
|
||||
use actix_multipart::form::Limits;
|
||||
use actix_multipart::form::bytes::Bytes;
|
||||
use actix_multipart::form::tempfile::TempFile;
|
||||
use actix_web::FromRequest;
|
||||
use actix_web::HttpMessage as _;
|
||||
use actix_web::HttpRequest;
|
||||
use actix_web::dev::ServiceRequest;
|
||||
use actix_web::http::header::CONTENT_TYPE;
|
||||
use actix_web::http::header::Header;
|
||||
use actix_web::web;
|
||||
use actix_web::web::Form;
|
||||
use actix_web_httpauth::headers::authorization::Authorization;
|
||||
use actix_web_httpauth::headers::authorization::Basic;
|
||||
use anyhow::Context;
|
||||
use anyhow::anyhow;
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
use std::net::IpAddr;
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
use tokio_stream::StreamExt;
|
||||
|
||||
use super::oidc::OidcClaims;
|
||||
use super::request_variables::ParamMap;
|
||||
use super::request_variables::param_map;
|
||||
use super::{ActixErrorStatusExt, StatusCodeResultExt};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct RequestInfo {
|
||||
pub method: actix_web::http::Method,
|
||||
pub path: String,
|
||||
pub protocol: String,
|
||||
pub url_params: ParamMap,
|
||||
pub post_variables: ParamMap,
|
||||
pub uploaded_files: Rc<HashMap<String, TempFile>>,
|
||||
pub headers: ParamMap,
|
||||
pub client_ip: Option<IpAddr>,
|
||||
pub cookies: ParamMap,
|
||||
pub basic_auth: Option<Basic>,
|
||||
pub app_state: Arc<AppState>,
|
||||
pub raw_body: Option<Vec<u8>>,
|
||||
pub oidc_claims: Option<OidcClaims>,
|
||||
pub server_timing: Arc<super::server_timing::ServerTiming>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ExecutionContext {
|
||||
pub request: Rc<RequestInfo>,
|
||||
pub set_variables: RefCell<SetVariablesMap>,
|
||||
pub clone_depth: u8,
|
||||
}
|
||||
|
||||
impl ExecutionContext {
|
||||
#[must_use]
|
||||
pub fn new(request: RequestInfo) -> Self {
|
||||
Self {
|
||||
request: Rc::new(request),
|
||||
set_variables: RefCell::new(SetVariablesMap::new()),
|
||||
clone_depth: 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn fork(&self) -> Self {
|
||||
Self {
|
||||
request: Rc::clone(&self.request),
|
||||
set_variables: RefCell::new(self.set_variables.borrow().clone()),
|
||||
clone_depth: self.clone_depth + 1,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn fork_with_variables(&self, variables: SetVariablesMap) -> Self {
|
||||
Self {
|
||||
request: Rc::clone(&self.request),
|
||||
set_variables: RefCell::new(variables),
|
||||
clone_depth: self.clone_depth + 1,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn request(&self) -> &RequestInfo {
|
||||
self.request.as_ref()
|
||||
}
|
||||
}
|
||||
|
||||
impl Clone for ExecutionContext {
|
||||
fn clone(&self) -> Self {
|
||||
self.fork()
|
||||
}
|
||||
}
|
||||
|
||||
impl std::ops::Deref for ExecutionContext {
|
||||
type Target = RequestInfo;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
self.request()
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> From<&'a ExecutionContext> for &'a RequestInfo {
|
||||
fn from(ctx: &'a ExecutionContext) -> Self {
|
||||
ctx.request()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn extract_request_info(
|
||||
req: &mut ServiceRequest,
|
||||
app_state: Arc<AppState>,
|
||||
server_timing: ServerTiming,
|
||||
) -> anyhow::Result<ExecutionContext> {
|
||||
let (http_req, payload) = req.parts_mut();
|
||||
let method = http_req.method().clone();
|
||||
let protocol = http_req.connection_info().scheme().to_string();
|
||||
let config = &app_state.config;
|
||||
let (post_variables, uploaded_files, raw_body) =
|
||||
extract_post_data(http_req, payload, config).await?;
|
||||
let headers = req.headers().iter().map(|(name, value)| {
|
||||
(
|
||||
name.to_string(),
|
||||
String::from_utf8_lossy(value.as_bytes()).to_string(),
|
||||
)
|
||||
});
|
||||
let get_variables = web::Query::<Vec<(String, String)>>::from_query(req.query_string())
|
||||
.map(web::Query::into_inner)
|
||||
.unwrap_or_default();
|
||||
let client_ip = req.peer_addr().map(|addr| addr.ip());
|
||||
|
||||
let raw_cookies = req.cookies();
|
||||
let cookies = raw_cookies
|
||||
.iter()
|
||||
.flat_map(|c| c.iter())
|
||||
.map(|cookie| (cookie.name().to_string(), cookie.value().to_string()));
|
||||
|
||||
let basic_auth = Authorization::<Basic>::parse(req)
|
||||
.ok()
|
||||
.map(Authorization::into_scheme);
|
||||
|
||||
let oidc_claims: Option<OidcClaims> = req.extensions().get::<OidcClaims>().cloned();
|
||||
|
||||
Ok(ExecutionContext::new(RequestInfo {
|
||||
method,
|
||||
path: req.path().to_string(),
|
||||
headers: param_map(headers),
|
||||
url_params: param_map(get_variables),
|
||||
post_variables: param_map(post_variables),
|
||||
uploaded_files: Rc::new(HashMap::from_iter(uploaded_files)),
|
||||
client_ip,
|
||||
cookies: param_map(cookies),
|
||||
basic_auth,
|
||||
app_state,
|
||||
protocol,
|
||||
raw_body,
|
||||
oidc_claims,
|
||||
server_timing: Arc::new(server_timing),
|
||||
}))
|
||||
}
|
||||
|
||||
async fn extract_post_data(
|
||||
http_req: &mut actix_web::HttpRequest,
|
||||
payload: &mut actix_web::dev::Payload,
|
||||
config: &crate::app_config::AppConfig,
|
||||
) -> anyhow::Result<(
|
||||
Vec<(String, String)>,
|
||||
Vec<(String, TempFile)>,
|
||||
Option<Vec<u8>>,
|
||||
)> {
|
||||
let content_type = http_req
|
||||
.headers()
|
||||
.get(&CONTENT_TYPE)
|
||||
.map(AsRef::as_ref)
|
||||
.unwrap_or_default();
|
||||
if content_type.starts_with(b"application/x-www-form-urlencoded") {
|
||||
let vars = extract_urlencoded_post_variables(http_req, payload).await?;
|
||||
Ok((vars, Vec::new(), None))
|
||||
} else if content_type.starts_with(b"multipart/form-data") {
|
||||
let (vars, files) = extract_multipart_post_data(http_req, payload, config).await?;
|
||||
Ok((vars, files, None))
|
||||
} else {
|
||||
let body = actix_web::web::Bytes::from_request(http_req, payload)
|
||||
.await
|
||||
.map(|bytes| bytes.to_vec())
|
||||
.unwrap_or_default();
|
||||
Ok((Vec::new(), Vec::new(), Some(body)))
|
||||
}
|
||||
}
|
||||
|
||||
async fn extract_urlencoded_post_variables(
|
||||
http_req: &mut actix_web::HttpRequest,
|
||||
payload: &mut actix_web::dev::Payload,
|
||||
) -> anyhow::Result<Vec<(String, String)>> {
|
||||
Form::<Vec<(String, String)>>::from_request(http_req, payload)
|
||||
.await
|
||||
.map(Form::into_inner)
|
||||
.with_actix_error_status()
|
||||
.context("could not parse request as urlencoded form data")
|
||||
}
|
||||
|
||||
async fn extract_multipart_post_data(
|
||||
http_req: &mut actix_web::HttpRequest,
|
||||
payload: &mut actix_web::dev::Payload,
|
||||
config: &crate::app_config::AppConfig,
|
||||
) -> anyhow::Result<(Vec<(String, String)>, Vec<(String, TempFile)>)> {
|
||||
let mut post_variables = Vec::new();
|
||||
let mut uploaded_files = Vec::new();
|
||||
|
||||
let mut multipart = Multipart::from_request(http_req, payload)
|
||||
.await
|
||||
.with_actix_error_status()
|
||||
.context("could not parse request as multipart form data")?;
|
||||
|
||||
let mut limits = Limits::new(config.max_uploaded_file_size, config.max_uploaded_file_size);
|
||||
log::trace!(
|
||||
"Parsing multipart form data with a {:?} KiB limit",
|
||||
limits.total_limit_remaining / 1024
|
||||
);
|
||||
|
||||
while let Some(part) = multipart.next().await {
|
||||
let field = part
|
||||
.with_response_status()
|
||||
.context("unable to read form field")?;
|
||||
let content_disposition = field
|
||||
.content_disposition()
|
||||
.ok_or_else(|| anyhow!("missing Content-Disposition in form field"))
|
||||
.with_status(actix_web::http::StatusCode::BAD_REQUEST)?;
|
||||
// test if field is a file
|
||||
let filename = content_disposition.get_filename();
|
||||
let field_name = content_disposition
|
||||
.get_name()
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
log::trace!("Parsing multipart field: {field_name}");
|
||||
if let Some(filename) = filename {
|
||||
log::debug!("Extracting file: {field_name} ({filename})");
|
||||
let extracted = extract_file(http_req, field, &mut limits)
|
||||
.await
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"Failed to extract file {field_name:?}. Max file size: {} kiB",
|
||||
config.max_uploaded_file_size / 1_024
|
||||
)
|
||||
})?;
|
||||
log::trace!(
|
||||
"Extracted file {field_name} to \"{}\"",
|
||||
extracted.file.path().display()
|
||||
);
|
||||
if is_file_field_empty(&extracted).await? {
|
||||
log::debug!("Ignoring empty file field: {field_name}");
|
||||
continue;
|
||||
}
|
||||
uploaded_files.push((field_name, extracted));
|
||||
} else {
|
||||
let text_contents = extract_text(http_req, field, &mut limits).await?;
|
||||
log::trace!("Extracted field as text: {field_name} = {text_contents:?}");
|
||||
post_variables.push((field_name, text_contents));
|
||||
}
|
||||
}
|
||||
Ok((post_variables, uploaded_files))
|
||||
}
|
||||
|
||||
async fn extract_text(
|
||||
req: &HttpRequest,
|
||||
field: actix_multipart::Field,
|
||||
limits: &mut Limits,
|
||||
) -> anyhow::Result<String> {
|
||||
// field is an async stream of Result<Bytes> objects, we collect them into a Vec<u8>
|
||||
let data = Bytes::read_field(req, field, limits)
|
||||
.await
|
||||
.map(|bytes| bytes.data)
|
||||
.with_response_status()
|
||||
.context("failed to read form field data")?;
|
||||
String::from_utf8(data.to_vec())
|
||||
.with_status(actix_web::http::StatusCode::BAD_REQUEST)
|
||||
.context("could not parse multipart form field as utf-8 text")
|
||||
}
|
||||
|
||||
async fn extract_file(
|
||||
req: &HttpRequest,
|
||||
field: actix_multipart::Field,
|
||||
limits: &mut Limits,
|
||||
) -> anyhow::Result<TempFile> {
|
||||
// extract a tempfile from the field
|
||||
let file = TempFile::read_field(req, field, limits)
|
||||
.await
|
||||
.with_response_status()
|
||||
.context("failed to save uploaded file")?;
|
||||
Ok(file)
|
||||
}
|
||||
|
||||
/// file upload form fields that are left blank result in the browser sending an empty file, with a mime type of application/octet-stream.
|
||||
/// We don't want to treat this the same as actual empty files, so we check for this case.
|
||||
async fn is_file_field_empty(
|
||||
uploaded_file: &actix_multipart::form::tempfile::TempFile,
|
||||
) -> anyhow::Result<bool> {
|
||||
Ok(
|
||||
uploaded_file.content_type == Some(mime_guess::mime::APPLICATION_OCTET_STREAM)
|
||||
&& uploaded_file.file_name.as_deref().is_none_or(str::is_empty)
|
||||
&& tokio::fs::metadata(&uploaded_file.file.path()).await?.len() == 0,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
use crate::webserver::single_or_vec::SingleOrVec;
|
||||
use crate::{app_config::AppConfig, webserver::server_timing::ServerTiming};
|
||||
use actix_web::{http::header::ContentType, test::TestRequest};
|
||||
|
||||
#[actix_web::test]
|
||||
async fn test_extract_empty_request() {
|
||||
let config =
|
||||
serde_json::from_str::<AppConfig>(r#"{"listen_on": "localhost:1234"}"#).unwrap();
|
||||
let mut service_request = TestRequest::default().to_srv_request();
|
||||
let app_data = Arc::new(AppState::init(&config).await.unwrap());
|
||||
let server_timing = ServerTiming::default();
|
||||
let request_ctx = extract_request_info(&mut service_request, app_data, server_timing)
|
||||
.await
|
||||
.unwrap();
|
||||
let request_info = request_ctx.request();
|
||||
assert_eq!(request_info.post_variables.len(), 0);
|
||||
assert_eq!(request_info.uploaded_files.len(), 0);
|
||||
assert_eq!(request_info.url_params.len(), 0);
|
||||
}
|
||||
|
||||
#[actix_web::test]
|
||||
async fn test_extract_urlencoded_request() {
|
||||
let config =
|
||||
serde_json::from_str::<AppConfig>(r#"{"listen_on": "localhost:1234"}"#).unwrap();
|
||||
let mut service_request = TestRequest::get()
|
||||
.uri("/?my_array[]=5")
|
||||
.insert_header(ContentType::form_url_encoded())
|
||||
.set_payload("my_array[]=3&my_array[]=Hello%20World&repeated=1&repeated=2")
|
||||
.to_srv_request();
|
||||
let app_data = Arc::new(AppState::init(&config).await.unwrap());
|
||||
let server_timing = ServerTiming::default();
|
||||
let request_ctx = extract_request_info(&mut service_request, app_data, server_timing)
|
||||
.await
|
||||
.unwrap();
|
||||
let request_info = request_ctx.request();
|
||||
assert_eq!(
|
||||
request_info.post_variables,
|
||||
vec![
|
||||
(
|
||||
"my_array".to_string(),
|
||||
SingleOrVec::Vec(vec!["3".to_string(), "Hello World".to_string()])
|
||||
),
|
||||
("repeated".to_string(), SingleOrVec::Single("2".to_string())), // without brackets, only the last value is kept
|
||||
]
|
||||
.into_iter()
|
||||
.collect::<ParamMap>()
|
||||
);
|
||||
assert_eq!(request_info.uploaded_files.len(), 0);
|
||||
assert_eq!(
|
||||
request_info.url_params,
|
||||
vec![(
|
||||
"my_array".to_string(),
|
||||
SingleOrVec::Vec(vec!["5".to_string()])
|
||||
)] // with brackets, even if there is only one value, it is kept as a vector
|
||||
.into_iter()
|
||||
.collect::<ParamMap>()
|
||||
);
|
||||
}
|
||||
|
||||
#[actix_web::test]
|
||||
async fn test_extract_multipart_form_data() {
|
||||
crate::telemetry::init_test_logging();
|
||||
let config =
|
||||
serde_json::from_str::<AppConfig>(r#"{"listen_on": "localhost:1234"}"#).unwrap();
|
||||
let mut service_request = TestRequest::get()
|
||||
.insert_header(("content-type", "multipart/form-data;boundary=xxx"))
|
||||
.set_payload(
|
||||
"--xxx\r\n\
|
||||
Content-Disposition: form-data; name=\"my_array[]\"\r\n\
|
||||
Content-Type: text/plain\r\n\
|
||||
\r\n\
|
||||
3\r\n\
|
||||
--xxx\r\n\
|
||||
Content-Disposition: form-data; name=\"my_uploaded_file\"; filename=\"test.txt\"\r\n\
|
||||
Content-Type: text/plain\r\n\
|
||||
\r\n\
|
||||
Hello World\r\n\
|
||||
--xxx--\r\n"
|
||||
)
|
||||
.to_srv_request();
|
||||
let app_data = Arc::new(AppState::init(&config).await.unwrap());
|
||||
let server_timing = ServerTiming::enabled(false);
|
||||
let request_ctx = extract_request_info(&mut service_request, app_data, server_timing)
|
||||
.await
|
||||
.unwrap();
|
||||
let request_info = request_ctx.request();
|
||||
assert_eq!(
|
||||
request_info.post_variables,
|
||||
vec![(
|
||||
"my_array".to_string(),
|
||||
SingleOrVec::Vec(vec!["3".to_string()])
|
||||
),]
|
||||
.into_iter()
|
||||
.collect::<ParamMap>()
|
||||
);
|
||||
assert_eq!(request_info.uploaded_files.len(), 1);
|
||||
let my_upload = &request_info.uploaded_files["my_uploaded_file"];
|
||||
assert_eq!(my_upload.file_name.as_ref().unwrap(), "test.txt");
|
||||
assert_eq!(request_info.url_params.len(), 0);
|
||||
assert_eq!(std::fs::read(&my_upload.file).unwrap(), b"Hello World");
|
||||
assert_eq!(request_info.url_params.len(), 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
use rustls_acme::{AcmeConfig, caches::DirCache, futures_rustls::rustls::ServerConfig};
|
||||
use tokio_stream::StreamExt;
|
||||
|
||||
use crate::app_config::AppConfig;
|
||||
|
||||
pub fn make_auto_rustls_config(domain: &str, config: &AppConfig) -> ServerConfig {
|
||||
log::info!("Starting HTTPS configuration for {domain}");
|
||||
let mut state = AcmeConfig::new([domain])
|
||||
.contact([if let Some(email) = &config.https_certificate_email {
|
||||
format!("mailto:{}", email.as_str())
|
||||
} else {
|
||||
format!("mailto:contact@{domain}")
|
||||
}])
|
||||
.cache_option(Some(DirCache::new(
|
||||
config.https_certificate_cache_dir.clone(),
|
||||
)))
|
||||
.directory(&config.https_acme_directory_url)
|
||||
.state();
|
||||
let rustls_config = state.challenge_rustls_config();
|
||||
|
||||
tokio::spawn(async move {
|
||||
while let Some(event) = state.next().await {
|
||||
match event {
|
||||
Ok(ok) => log::info!("ACME configuration event: {ok:?}"),
|
||||
Err(err) => log::error!("Unable to configure HTTPS: {err:?}"),
|
||||
}
|
||||
}
|
||||
log::error!("ACME configuration stream ended. This should never happen.");
|
||||
});
|
||||
|
||||
ServerConfig::clone(&rustls_config)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
//! Core HTTP server implementation handling SQL file execution and request processing.
|
||||
//!
|
||||
//! For more general information about perfomance in sqlite, read our
|
||||
//! [performance guide](https://sql-page.com/performance.sql).
|
||||
//!
|
||||
//! # Overview
|
||||
//!
|
||||
//! The webserver module is responsible for:
|
||||
//! - Processing incoming HTTP requests
|
||||
//! - Executing SQL files
|
||||
//! - Streaming query results to clients
|
||||
//! - Managing database connections
|
||||
//! - Handling file uploads and static content
|
||||
//!
|
||||
//! # Architecture
|
||||
//!
|
||||
//! Key components:
|
||||
//!
|
||||
//! - [`database`]: SQL execution engine and query processing
|
||||
//! - [`database::execute_queries`]: Streams query results from database
|
||||
//! - [`database::migrations`]: Database schema management
|
||||
//!
|
||||
//! - [`http`]: HTTP server implementation using actix-web
|
||||
//! - Request handling
|
||||
//! - Response streaming
|
||||
//! - [Content Security Policy](https://sql-page.com/safety.sql) enforcement
|
||||
//!
|
||||
//! - [`response_writer`]: Streaming response generation
|
||||
//! - [`static_content`]: Static asset handling (JS, CSS, icons)
|
||||
//!
|
||||
|
||||
pub mod content_security_policy;
|
||||
pub mod database;
|
||||
pub(crate) mod error;
|
||||
pub mod error_with_status;
|
||||
pub mod http;
|
||||
pub mod http_client;
|
||||
pub mod http_metrics;
|
||||
pub mod http_request_info;
|
||||
mod https;
|
||||
pub mod request_variables;
|
||||
pub mod server_timing;
|
||||
|
||||
pub use database::Database;
|
||||
pub use error_with_status::{ActixErrorStatusExt, ErrorWithStatus, StatusCodeResultExt};
|
||||
|
||||
pub use database::make_placeholder;
|
||||
pub use database::migrations::apply;
|
||||
pub mod oidc;
|
||||
pub mod response_writer;
|
||||
pub mod routing;
|
||||
mod single_or_vec;
|
||||
mod static_content;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
||||
use std::collections::{HashMap, hash_map::Entry};
|
||||
|
||||
use crate::webserver::single_or_vec::SingleOrVec;
|
||||
|
||||
pub type ParamMap = HashMap<String, SingleOrVec>;
|
||||
pub type SetVariablesMap = HashMap<String, Option<SingleOrVec>>;
|
||||
|
||||
pub fn param_map<PAIRS: IntoIterator<Item = (String, String)>>(values: PAIRS) -> ParamMap {
|
||||
values
|
||||
.into_iter()
|
||||
.fold(HashMap::new(), |mut map, (mut k, v)| {
|
||||
let entry = if k.ends_with("[]") {
|
||||
k.replace_range(k.len() - 2.., "");
|
||||
SingleOrVec::Vec(vec![v])
|
||||
} else {
|
||||
SingleOrVec::Single(v)
|
||||
};
|
||||
match map.entry(k) {
|
||||
Entry::Occupied(mut s) => {
|
||||
SingleOrVec::merge(s.get_mut(), entry);
|
||||
}
|
||||
Entry::Vacant(v) => {
|
||||
v.insert(entry);
|
||||
}
|
||||
}
|
||||
map
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
use actix_web::web::Bytes;
|
||||
use std::io::Write;
|
||||
use std::mem;
|
||||
use std::pin::Pin;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
/// The response writer is a buffered async writer that sends data to the client.
|
||||
/// Writing to it just appends to an in-memory buffer, which is flushed to the client asynchronously
|
||||
/// when `async_flush()` is called.
|
||||
/// This allows streaming data to the client without blocking, and has built-in back-pressure:
|
||||
/// if the client cannot keep up with the data, `async_flush()` will fill the sending queue,
|
||||
/// then block until the client has consumed some data.
|
||||
#[derive(Clone)]
|
||||
pub struct ResponseWriter {
|
||||
buffer: Vec<u8>,
|
||||
response_bytes: mpsc::Sender<Bytes>,
|
||||
}
|
||||
|
||||
impl ResponseWriter {
|
||||
#[must_use]
|
||||
pub fn new(response_bytes: mpsc::Sender<Bytes>) -> Self {
|
||||
Self {
|
||||
response_bytes,
|
||||
buffer: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn close_with_error(&mut self, mut msg: String) {
|
||||
if !self.response_bytes.is_closed() {
|
||||
if let Err(e) = self.async_flush().await {
|
||||
use std::fmt::Write;
|
||||
write!(&mut msg, "Unable to flush data: {e}").unwrap();
|
||||
}
|
||||
if let Err(e) = self.response_bytes.send(msg.into()).await {
|
||||
log::error!("Unable to send error back to client: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn async_flush(&mut self) -> std::io::Result<()> {
|
||||
if self.buffer.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
log::trace!(
|
||||
"Flushing data to client: {}",
|
||||
String::from_utf8_lossy(&self.buffer)
|
||||
);
|
||||
let sender = self
|
||||
.response_bytes
|
||||
.reserve()
|
||||
.await
|
||||
.map_err(|_| std::io::ErrorKind::WouldBlock)?;
|
||||
sender.send(std::mem::take(&mut self.buffer).into());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Write for ResponseWriter {
|
||||
#[inline]
|
||||
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
|
||||
self.buffer.extend_from_slice(buf);
|
||||
Ok(buf.len())
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
if self.buffer.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
log::trace!(
|
||||
"Flushing data to client: {}",
|
||||
String::from_utf8_lossy(&self.buffer)
|
||||
);
|
||||
self.response_bytes
|
||||
.try_send(mem::take(&mut self.buffer).into())
|
||||
.map_err(|e|
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::WouldBlock,
|
||||
format!("{e}: Row limit exceeded. The server cannot store more than {} pending messages in memory. Try again later or increase max_pending_rows in the configuration.", self.response_bytes.max_capacity())
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::module_name_repetitions)]
|
||||
pub struct AsyncResponseWriter {
|
||||
poll_sender: tokio_util::sync::PollSender<Bytes>,
|
||||
writer: ResponseWriter,
|
||||
}
|
||||
|
||||
impl AsyncResponseWriter {
|
||||
#[must_use]
|
||||
pub fn new(writer: ResponseWriter) -> Self {
|
||||
let sender = writer.response_bytes.clone();
|
||||
Self {
|
||||
poll_sender: tokio_util::sync::PollSender::new(sender),
|
||||
writer,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn into_inner(self) -> ResponseWriter {
|
||||
self.writer
|
||||
}
|
||||
}
|
||||
|
||||
impl tokio::io::AsyncWrite for AsyncResponseWriter {
|
||||
fn poll_write(
|
||||
mut self: Pin<&mut Self>,
|
||||
_cx: &mut std::task::Context<'_>,
|
||||
buf: &[u8],
|
||||
) -> std::task::Poll<std::io::Result<usize>> {
|
||||
std::task::Poll::Ready(self.as_mut().writer.write(buf))
|
||||
}
|
||||
|
||||
fn poll_flush(
|
||||
self: Pin<&mut Self>,
|
||||
cx: &mut std::task::Context<'_>,
|
||||
) -> std::task::Poll<std::io::Result<()>> {
|
||||
let Self {
|
||||
poll_sender,
|
||||
writer,
|
||||
} = self.get_mut();
|
||||
match poll_sender.poll_reserve(cx) {
|
||||
std::task::Poll::Ready(Ok(())) => {
|
||||
let res = poll_sender.send_item(std::mem::take(&mut writer.buffer).into());
|
||||
std::task::Poll::Ready(res.map_err(|_| std::io::ErrorKind::BrokenPipe.into()))
|
||||
}
|
||||
std::task::Poll::Pending => std::task::Poll::Pending,
|
||||
std::task::Poll::Ready(Err(_e)) => {
|
||||
std::task::Poll::Ready(Err(std::io::ErrorKind::BrokenPipe.into()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn poll_shutdown(
|
||||
self: Pin<&mut Self>,
|
||||
cx: &mut std::task::Context<'_>,
|
||||
) -> std::task::Poll<Result<(), std::io::Error>> {
|
||||
self.poll_flush(cx)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ResponseWriter {
|
||||
fn drop(&mut self) {
|
||||
if let Err(e) = std::io::Write::flush(self) {
|
||||
log::debug!("Could not flush data to client: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,755 @@
|
||||
//! This module determines how incoming HTTP requests are mapped to
|
||||
//! SQL files for execution, static assets for serving, or error pages.
|
||||
//!
|
||||
//! ## Routing Rules
|
||||
//!
|
||||
//! `SQLPage` follows a file-based routing system with the following precedence:
|
||||
//!
|
||||
//! ### 1. Site Prefix Handling
|
||||
//! - If a `site_prefix` is configured and the request path doesn't start with it, redirect to the prefixed path
|
||||
//! - All subsequent routing operates on the path after stripping the prefix
|
||||
//!
|
||||
//! ### 2. Path Resolution (in order of precedence)
|
||||
//!
|
||||
//! #### Paths ending with `/` (directories):
|
||||
//! - Look for `index.sql` in that directory
|
||||
//! - If found: **Execute** the SQL file
|
||||
//! - If not found: Look for custom 404 handlers (see Error Handling below)
|
||||
//!
|
||||
//! #### Paths with `.sql` extension:
|
||||
//! - If the file exists: **Execute** the SQL file
|
||||
//! - If not found: Look for custom 404 handlers (see Error Handling below)
|
||||
//!
|
||||
//! #### Paths with other extensions (assets):
|
||||
//! - If the file exists: **Serve** the static file
|
||||
//! - If not found but `{path}.sql` exists: **Execute** the SQL file
|
||||
//! - If neither found: Look for custom 404 handlers (see Error Handling below)
|
||||
//!
|
||||
//! #### Paths without extension:
|
||||
//! - First, try to find `{path}.sql` and **Execute** if found
|
||||
//! - If no SQL file found but `{path}/index.sql` exists: **Redirect** to `{path}/`
|
||||
//! - Otherwise: Look for custom 404 handlers (see Error Handling below)
|
||||
//!
|
||||
//! ### 3. Error Handling (404 cases)
|
||||
//!
|
||||
//! When a requested file is not found, `SQLPage` looks for custom 404 handlers:
|
||||
//!
|
||||
//! - Starting from the requested path's directory, walk up the directory tree
|
||||
//! - Look for `404.sql` in each parent directory
|
||||
//! - If found: **Execute** the custom 404 SQL file
|
||||
//! - If no custom 404 found anywhere: Return default **404 Not Found** response
|
||||
//!
|
||||
//! ## Examples
|
||||
//!
|
||||
//! ```text
|
||||
//! Request: GET /
|
||||
//! Result: Execute index.sql
|
||||
//!
|
||||
//! Request: GET /users
|
||||
//! - If users.sql exists: Execute users.sql
|
||||
//! - Else if users/index.sql exists: Redirect to /users/
|
||||
//! - Else if 404.sql exists: Execute 404.sql
|
||||
//! - Else: Default 404
|
||||
//!
|
||||
//! Request: GET /users/
|
||||
//! - If users/index.sql exists: Execute users/index.sql
|
||||
//! - Else if users/404.sql exists: Execute users/404.sql
|
||||
//! - Else if 404.sql exists: Execute 404.sql
|
||||
//! - Else: Default 404
|
||||
//!
|
||||
//! Request: GET /api/users.sql
|
||||
//! - If api/users.sql exists: Execute api/users.sql
|
||||
//! - Else if api/404.sql exists: Execute api/404.sql
|
||||
//! - Else if 404.sql exists: Execute 404.sql
|
||||
//! - Else: Default 404
|
||||
//!
|
||||
//! Request: GET /favicon.ico
|
||||
//! - If favicon.ico exists: Serve favicon.ico
|
||||
//! - Else if 404.sql exists: Execute 404.sql
|
||||
//! - Else: Default 404
|
||||
//!
|
||||
//! Request: GET /api/data.json
|
||||
//! - If api/data.json exists: Serve api/data.json
|
||||
//! - Else if api/data.json.sql exists: Execute api/data.json.sql
|
||||
//! - Else if api/404.sql exists: Execute api/404.sql
|
||||
//! - Else if 404.sql exists: Execute 404.sql
|
||||
//! - Else: Default 404
|
||||
//! ```
|
||||
|
||||
use crate::filesystem::{FileAccess, FileSystem};
|
||||
use crate::webserver::database::ParsedSqlFile;
|
||||
use crate::{AppState, file_cache::FileCache};
|
||||
use RoutingAction::{CustomNotFound, Execute, NotFound, Redirect, Serve};
|
||||
use awc::http::uri::PathAndQuery;
|
||||
use log::debug;
|
||||
use percent_encoding;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
const INDEX: &str = "index.sql";
|
||||
const NOT_FOUND: &str = "404.sql";
|
||||
const SQL_EXTENSION: &str = "sql";
|
||||
const FORWARD_SLASH: &str = "/";
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum RoutingAction {
|
||||
CustomNotFound(PathBuf),
|
||||
Execute(PathBuf),
|
||||
NotFound,
|
||||
Redirect(String),
|
||||
Serve(PathBuf),
|
||||
}
|
||||
|
||||
#[expect(async_fn_in_trait)]
|
||||
pub trait FileStore {
|
||||
async fn contains(&self, access: FileAccess<'_>) -> anyhow::Result<bool>;
|
||||
}
|
||||
|
||||
pub trait RoutingConfig {
|
||||
fn prefix(&self) -> &str;
|
||||
}
|
||||
|
||||
pub(crate) struct AppFileStore<'a> {
|
||||
cache: &'a FileCache<ParsedSqlFile>,
|
||||
filesystem: &'a FileSystem,
|
||||
app_state: &'a AppState,
|
||||
}
|
||||
|
||||
impl<'a> AppFileStore<'a> {
|
||||
pub fn new(
|
||||
cache: &'a FileCache<ParsedSqlFile>,
|
||||
filesystem: &'a FileSystem,
|
||||
app_state: &'a AppState,
|
||||
) -> Self {
|
||||
Self {
|
||||
cache,
|
||||
filesystem,
|
||||
app_state,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FileStore for AppFileStore<'_> {
|
||||
async fn contains(&self, access: FileAccess<'_>) -> anyhow::Result<bool> {
|
||||
if self.cache.contains(access).await? {
|
||||
Ok(true)
|
||||
} else {
|
||||
self.filesystem.file_exists(self.app_state, access).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn calculate_route<T, C>(
|
||||
path_and_query: &PathAndQuery,
|
||||
store: &T,
|
||||
config: &C,
|
||||
) -> anyhow::Result<RoutingAction>
|
||||
where
|
||||
T: FileStore,
|
||||
C: RoutingConfig,
|
||||
{
|
||||
let result = match check_path(path_and_query, config) {
|
||||
Ok(path) => match path.extension().and_then(|e| e.to_str()) {
|
||||
Some(SQL_EXTENSION) => find_file_or_not_found(&path, SQL_EXTENSION, store).await?,
|
||||
Some(extension) => match find_file(&path, extension, store).await? {
|
||||
Some(action) => action,
|
||||
None => calculate_route_without_extension(path_and_query, path, store).await?,
|
||||
},
|
||||
None => calculate_route_without_extension(path_and_query, path, store).await?,
|
||||
},
|
||||
Err(action) => action,
|
||||
};
|
||||
debug!("Route: [{path_and_query}] -> {result:?}");
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn check_path<C>(path_and_query: &PathAndQuery, config: &C) -> Result<PathBuf, RoutingAction>
|
||||
where
|
||||
C: RoutingConfig,
|
||||
{
|
||||
match path_and_query.path().strip_prefix(config.prefix()) {
|
||||
None => Err(Redirect(config.prefix().to_string())),
|
||||
Some(path) => {
|
||||
let decoded = percent_encoding::percent_decode_str(path);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::ffi::OsString;
|
||||
use std::os::unix::ffi::OsStringExt;
|
||||
|
||||
let decoded = decoded.collect::<Vec<u8>>();
|
||||
Ok(PathBuf::from(OsString::from_vec(decoded)))
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
Ok(PathBuf::from(decoded.decode_utf8_lossy().as_ref()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn calculate_route_without_extension<T>(
|
||||
path_and_query: &PathAndQuery,
|
||||
mut path: PathBuf,
|
||||
store: &T,
|
||||
) -> anyhow::Result<RoutingAction>
|
||||
where
|
||||
T: FileStore,
|
||||
{
|
||||
if path_and_query.path().ends_with(FORWARD_SLASH) {
|
||||
path.push(INDEX);
|
||||
find_file_or_not_found(&path, SQL_EXTENSION, store).await
|
||||
} else {
|
||||
let path_with_ext = PathBuf::from(format!("{}.{SQL_EXTENSION}", path.display()));
|
||||
match find_file_or_not_found(&path_with_ext, SQL_EXTENSION, store).await? {
|
||||
Execute(x) => Ok(Execute(x)),
|
||||
other_action => {
|
||||
let index_path = path.join(INDEX);
|
||||
if store
|
||||
.contains(FileAccess::unprivileged(&index_path)?)
|
||||
.await?
|
||||
{
|
||||
Ok(Redirect(append_to_path(path_and_query, FORWARD_SLASH)))
|
||||
} else {
|
||||
Ok(other_action)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn find_file_or_not_found<T>(
|
||||
path: &Path,
|
||||
extension: &str,
|
||||
store: &T,
|
||||
) -> anyhow::Result<RoutingAction>
|
||||
where
|
||||
T: FileStore,
|
||||
{
|
||||
match find_file(path, extension, store).await? {
|
||||
None => find_not_found(path, store).await,
|
||||
Some(execute) => Ok(execute),
|
||||
}
|
||||
}
|
||||
|
||||
async fn find_file<T>(
|
||||
path: &Path,
|
||||
extension: &str,
|
||||
store: &T,
|
||||
) -> anyhow::Result<Option<RoutingAction>>
|
||||
where
|
||||
T: FileStore,
|
||||
{
|
||||
if store.contains(FileAccess::unprivileged(path)?).await? {
|
||||
Ok(Some(if extension == SQL_EXTENSION {
|
||||
Execute(path.to_path_buf())
|
||||
} else {
|
||||
Serve(path.to_path_buf())
|
||||
}))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
async fn find_not_found<T>(path: &Path, store: &T) -> anyhow::Result<RoutingAction>
|
||||
where
|
||||
T: FileStore,
|
||||
{
|
||||
let mut parent = path.parent();
|
||||
while let Some(p) = parent {
|
||||
let target = p.join(NOT_FOUND);
|
||||
if store.contains(FileAccess::unprivileged(&target)?).await? {
|
||||
return Ok(CustomNotFound(target));
|
||||
}
|
||||
parent = p.parent();
|
||||
}
|
||||
|
||||
Ok(NotFound)
|
||||
}
|
||||
|
||||
fn append_to_path(path_and_query: &PathAndQuery, append: &str) -> String {
|
||||
let mut full_uri = path_and_query.to_string();
|
||||
full_uri.insert_str(path_and_query.path().len(), append);
|
||||
full_uri
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::RoutingAction::{CustomNotFound, Execute, NotFound, Redirect, Serve};
|
||||
use super::{FileAccess, FileStore, RoutingAction, RoutingConfig, calculate_route};
|
||||
use StoreConfig::{Custom, Default, Empty, File};
|
||||
use awc::http::uri::PathAndQuery;
|
||||
use std::default::Default as StdDefault;
|
||||
use std::path::PathBuf;
|
||||
use std::str::FromStr;
|
||||
|
||||
mod execute {
|
||||
use super::StoreConfig::{Default, File};
|
||||
use super::{do_route, execute};
|
||||
|
||||
#[tokio::test]
|
||||
async fn root_path_executes_index() {
|
||||
let actual = do_route("/", Default, None).await;
|
||||
let expected = execute("index.sql");
|
||||
|
||||
assert_eq!(expected, actual);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn root_path_and_site_prefix_executes_index() {
|
||||
let actual = do_route("/prefix/", Default, Some("/prefix/")).await;
|
||||
let expected = execute("index.sql");
|
||||
|
||||
assert_eq!(expected, actual);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn extension() {
|
||||
let actual = do_route("/index.sql", Default, None).await;
|
||||
let expected = execute("index.sql");
|
||||
|
||||
assert_eq!(expected, actual);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn extension_and_site_prefix() {
|
||||
let actual = do_route("/prefix/index.sql", Default, Some("/prefix/")).await;
|
||||
let expected = execute("index.sql");
|
||||
|
||||
assert_eq!(expected, actual);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn no_extension() {
|
||||
let actual = do_route("/path", File("path.sql"), None).await;
|
||||
let expected = execute("path.sql");
|
||||
|
||||
assert_eq!(expected, actual);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn no_extension_and_site_prefix() {
|
||||
let actual = do_route("/prefix/path", File("path.sql"), Some("/prefix/")).await;
|
||||
let expected = execute("path.sql");
|
||||
|
||||
assert_eq!(expected, actual);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn trailing_slash_executes_index_in_directory() {
|
||||
let actual = do_route("/folder/", File("folder/index.sql"), None).await;
|
||||
let expected = execute("folder/index.sql");
|
||||
|
||||
assert_eq!(expected, actual);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn trailing_slash_and_site_prefix_executes_index_in_directory() {
|
||||
let actual = do_route(
|
||||
"/prefix/folder/",
|
||||
File("folder/index.sql"),
|
||||
Some("/prefix/"),
|
||||
)
|
||||
.await;
|
||||
let expected = execute("folder/index.sql");
|
||||
|
||||
assert_eq!(expected, actual);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn path_with_non_sql_extension_executes_sql_file() {
|
||||
let actual = do_route("/abc.def", File("abc.def.sql"), None).await;
|
||||
let expected = execute("abc.def.sql");
|
||||
|
||||
assert_eq!(expected, actual);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn path_with_non_sql_extension_and_site_prefix_executes_sql_file() {
|
||||
let actual = do_route("/prefix/abc.def", File("abc.def.sql"), Some("/prefix/")).await;
|
||||
let expected = execute("abc.def.sql");
|
||||
|
||||
assert_eq!(expected, actual);
|
||||
}
|
||||
}
|
||||
|
||||
mod custom_not_found {
|
||||
use super::StoreConfig::{Default, File};
|
||||
use super::{custom_not_found, do_route};
|
||||
|
||||
#[tokio::test]
|
||||
async fn sql_extension() {
|
||||
let actual = do_route("/unknown.sql", Default, None).await;
|
||||
let expected = custom_not_found("404.sql");
|
||||
|
||||
assert_eq!(expected, actual);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sql_extension_and_site_prefix() {
|
||||
let actual = do_route("/prefix/unknown.sql", Default, Some("/prefix/")).await;
|
||||
let expected = custom_not_found("404.sql");
|
||||
|
||||
assert_eq!(expected, actual);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sql_extension_executes_deeper_not_found_file_if_exists() {
|
||||
let actual = do_route("/unknown/unknown.sql", File("unknown/404.sql"), None).await;
|
||||
let expected = custom_not_found("unknown/404.sql");
|
||||
|
||||
assert_eq!(expected, actual);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sql_extension_and_site_prefix_executes_deeper_not_found_file_if_exists() {
|
||||
let actual = do_route(
|
||||
"/prefix/unknown/unknown.sql",
|
||||
File("unknown/404.sql"),
|
||||
Some("/prefix/"),
|
||||
)
|
||||
.await;
|
||||
let expected = custom_not_found("unknown/404.sql");
|
||||
|
||||
assert_eq!(expected, actual);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sql_extension_executes_deepest_not_found_file_that_exists() {
|
||||
let actual = do_route(
|
||||
"/unknown/unknown/unknown.sql",
|
||||
File("unknown/404.sql"),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
let expected = custom_not_found("unknown/404.sql");
|
||||
|
||||
assert_eq!(expected, actual);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sql_extension_and_site_prefix_executes_deepest_not_found_file_that_exists() {
|
||||
let actual = do_route(
|
||||
"/prefix/unknown/unknown/unknown.sql",
|
||||
File("unknown/404.sql"),
|
||||
Some("/prefix/"),
|
||||
)
|
||||
.await;
|
||||
let expected = custom_not_found("unknown/404.sql");
|
||||
|
||||
assert_eq!(expected, actual);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn no_extension_path_that_would_result_in_404_does_not_redirect() {
|
||||
let actual = do_route("/nonexistent", Default, None).await;
|
||||
let expected = custom_not_found("404.sql");
|
||||
|
||||
assert_eq!(expected, actual);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn no_extension_path_that_would_result_in_404_does_not_redirect_with_site_prefix() {
|
||||
let actual = do_route("/prefix/nonexistent", Default, Some("/prefix/")).await;
|
||||
let expected = custom_not_found("404.sql");
|
||||
|
||||
assert_eq!(expected, actual);
|
||||
}
|
||||
}
|
||||
|
||||
mod not_found {
|
||||
use super::StoreConfig::Empty;
|
||||
use super::{default_not_found, do_route};
|
||||
|
||||
#[tokio::test]
|
||||
async fn default_404_when_no_not_found_file_available() {
|
||||
let actual = do_route("/unknown.sql", Empty, None).await;
|
||||
let expected = default_not_found();
|
||||
|
||||
assert_eq!(expected, actual);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn default_404_when_no_not_found_file_available_and_site_prefix() {
|
||||
let actual = do_route("/prefix/unknown.sql", Empty, Some("/prefix/")).await;
|
||||
let expected = default_not_found();
|
||||
|
||||
assert_eq!(expected, actual);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn asset_not_found() {
|
||||
let actual = do_route("/favicon.ico", Empty, None).await;
|
||||
let expected = default_not_found();
|
||||
|
||||
assert_eq!(expected, actual);
|
||||
}
|
||||
}
|
||||
|
||||
mod asset {
|
||||
use super::StoreConfig::File;
|
||||
use super::{do_route, serve};
|
||||
|
||||
#[tokio::test]
|
||||
async fn serves_corresponding_asset() {
|
||||
let actual = do_route("/favicon.ico", File("favicon.ico"), None).await;
|
||||
let expected = serve("favicon.ico");
|
||||
|
||||
assert_eq!(expected, actual);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn asset_trims_query() {
|
||||
let actual = do_route("/favicon.ico?version=10", File("favicon.ico"), None).await;
|
||||
let expected = serve("favicon.ico");
|
||||
|
||||
assert_eq!(expected, actual);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn asset_trims_fragment() {
|
||||
let actual = do_route("/favicon.ico#asset1", File("favicon.ico"), None).await;
|
||||
let expected = serve("favicon.ico");
|
||||
|
||||
assert_eq!(expected, actual);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn serves_corresponding_asset_given_site_prefix() {
|
||||
let actual =
|
||||
do_route("/prefix/favicon.ico", File("favicon.ico"), Some("/prefix/")).await;
|
||||
let expected = serve("favicon.ico");
|
||||
|
||||
assert_eq!(expected, actual);
|
||||
}
|
||||
}
|
||||
|
||||
mod redirect {
|
||||
use super::StoreConfig::{Default, Empty};
|
||||
use super::{custom_not_found, default_not_found, do_route, redirect};
|
||||
|
||||
#[tokio::test]
|
||||
async fn path_without_site_prefix_redirects_to_site_prefix() {
|
||||
let actual = do_route("/path", Default, Some("/prefix/")).await;
|
||||
let expected = redirect("/prefix/");
|
||||
|
||||
assert_eq!(expected, actual);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn no_extension_and_no_corresponding_file_with_custom_404_does_not_redirect() {
|
||||
let actual = do_route("/folder", Default, None).await;
|
||||
let expected = custom_not_found("404.sql");
|
||||
|
||||
assert_eq!(expected, actual);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn no_extension_no_corresponding_file_with_custom_404_does_not_redirect_with_query() {
|
||||
let actual = do_route("/folder?misc=1&foo=bar", Default, None).await;
|
||||
let expected = custom_not_found("404.sql");
|
||||
|
||||
assert_eq!(expected, actual);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn no_extension_site_prefix_and_no_corresponding_file_with_custom_404_does_not_redirect()
|
||||
{
|
||||
let actual = do_route("/prefix/folder", Default, Some("/prefix/")).await;
|
||||
let expected = custom_not_found("404.sql");
|
||||
|
||||
assert_eq!(expected, actual);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn no_extension_returns_404_when_no_404sql_available() {
|
||||
assert_eq!(do_route("/folder", Empty, None).await, default_not_found());
|
||||
}
|
||||
}
|
||||
|
||||
async fn do_route(path: &str, config: StoreConfig, prefix: Option<&str>) -> RoutingAction {
|
||||
let store = match config {
|
||||
Default => Store::with_default_contents(),
|
||||
Empty => Store::empty(),
|
||||
File(file) => Store::new(file),
|
||||
Custom(files) => Store::with_files(&files),
|
||||
};
|
||||
let config = match prefix {
|
||||
None => Config::default(),
|
||||
Some(value) => Config::new(value),
|
||||
};
|
||||
calculate_route(&PathAndQuery::from_str(path).unwrap(), &store, &config)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn default_not_found() -> RoutingAction {
|
||||
NotFound
|
||||
}
|
||||
|
||||
fn execute(path: &str) -> RoutingAction {
|
||||
Execute(PathBuf::from(path))
|
||||
}
|
||||
|
||||
fn custom_not_found(path: &str) -> RoutingAction {
|
||||
CustomNotFound(PathBuf::from(path))
|
||||
}
|
||||
|
||||
fn redirect(uri: &str) -> RoutingAction {
|
||||
Redirect(uri.to_string())
|
||||
}
|
||||
|
||||
fn serve(path: &str) -> RoutingAction {
|
||||
Serve(PathBuf::from(path))
|
||||
}
|
||||
|
||||
enum StoreConfig {
|
||||
Default,
|
||||
Empty,
|
||||
File(&'static str),
|
||||
Custom(Vec<&'static str>),
|
||||
}
|
||||
|
||||
struct Store {
|
||||
contents: Vec<String>,
|
||||
}
|
||||
|
||||
impl Store {
|
||||
const INDEX: &'static str = "index.sql";
|
||||
const NOT_FOUND: &'static str = "404.sql";
|
||||
fn new(path: &str) -> Self {
|
||||
let mut contents = Self::default_contents();
|
||||
contents.push(path.to_string());
|
||||
Self { contents }
|
||||
}
|
||||
|
||||
fn default_contents() -> Vec<String> {
|
||||
vec![Self::INDEX.to_string(), Self::NOT_FOUND.to_string()]
|
||||
}
|
||||
|
||||
fn with_default_contents() -> Self {
|
||||
Self {
|
||||
contents: Self::default_contents(),
|
||||
}
|
||||
}
|
||||
|
||||
fn empty() -> Self {
|
||||
Self { contents: vec![] }
|
||||
}
|
||||
|
||||
fn contains(&self, path: &str) -> bool {
|
||||
let normalized_path = path.replace('\\', "/");
|
||||
dbg!(&normalized_path, &self.contents);
|
||||
self.contents.contains(&normalized_path)
|
||||
}
|
||||
|
||||
fn with_files(files: &[&str]) -> Self {
|
||||
Self {
|
||||
contents: files.iter().map(|s| (*s).to_string()).collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FileStore for Store {
|
||||
async fn contains(&self, access: FileAccess<'_>) -> anyhow::Result<bool> {
|
||||
Ok(self.contains(access.path().to_string_lossy().to_string().as_str()))
|
||||
}
|
||||
}
|
||||
|
||||
struct Config {
|
||||
prefix: String,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
fn new(prefix: &str) -> Self {
|
||||
Self {
|
||||
prefix: prefix.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl RoutingConfig for Config {
|
||||
fn prefix(&self) -> &str {
|
||||
&self.prefix
|
||||
}
|
||||
}
|
||||
|
||||
impl StdDefault for Config {
|
||||
fn default() -> Self {
|
||||
Self::new("/")
|
||||
}
|
||||
}
|
||||
|
||||
mod specific_configuration {
|
||||
use crate::webserver::routing::tests::default_not_found;
|
||||
|
||||
use super::StoreConfig::Custom;
|
||||
use super::{RoutingAction, custom_not_found, do_route, execute, redirect};
|
||||
|
||||
async fn route_with_index_and_folder_404(path: &str) -> RoutingAction {
|
||||
do_route(
|
||||
path,
|
||||
Custom(vec![
|
||||
"index.sql",
|
||||
"folder/404.sql",
|
||||
"folder_with_index/index.sql",
|
||||
]),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn root_path_executes_index() {
|
||||
let actual = route_with_index_and_folder_404("/").await;
|
||||
let expected = execute("index.sql");
|
||||
assert_eq!(expected, actual);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn index_sql_path_executes_index() {
|
||||
let actual = route_with_index_and_folder_404("/index.sql").await;
|
||||
let expected = execute("index.sql");
|
||||
assert_eq!(expected, actual);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn folder_without_trailing_slash_redirects() {
|
||||
let actual = route_with_index_and_folder_404("/folder_with_index").await;
|
||||
let expected = redirect("/folder_with_index/");
|
||||
assert_eq!(expected, actual);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn folder_without_trailing_slash_without_index_does_not_redirect() {
|
||||
let actual = route_with_index_and_folder_404("/folder").await;
|
||||
let expected = default_not_found();
|
||||
assert_eq!(expected, actual);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn folder_with_trailing_slash_executes_custom_404() {
|
||||
let actual = route_with_index_and_folder_404("/folder/").await;
|
||||
let expected = custom_not_found("folder/404.sql");
|
||||
assert_eq!(expected, actual);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn folder_xxx_executes_custom_404() {
|
||||
let actual = route_with_index_and_folder_404("/folder/xxx").await;
|
||||
let expected = custom_not_found("folder/404.sql");
|
||||
assert_eq!(expected, actual);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn folder_xxx_with_query_executes_custom_404() {
|
||||
let actual = route_with_index_and_folder_404("/folder/xxx?x=1").await;
|
||||
let expected = custom_not_found("folder/404.sql");
|
||||
assert_eq!(expected, actual);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn folder_nested_path_executes_custom_404() {
|
||||
let actual = route_with_index_and_folder_404("/folder/xxx/yyy").await;
|
||||
let expected = custom_not_found("folder/404.sql");
|
||||
assert_eq!(expected, actual);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
use std::fmt::Write;
|
||||
use std::sync::Mutex;
|
||||
use std::time::Instant;
|
||||
|
||||
use crate::app_config::DevOrProd;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ServerTiming {
|
||||
enabled: bool,
|
||||
created_at: Instant,
|
||||
events: Mutex<Vec<PerfEvent>>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct PerfEvent {
|
||||
time: Instant,
|
||||
name: &'static str,
|
||||
}
|
||||
|
||||
impl Default for ServerTiming {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
created_at: Instant::now(),
|
||||
events: Mutex::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ServerTiming {
|
||||
#[must_use]
|
||||
pub fn enabled(enabled: bool) -> Self {
|
||||
Self {
|
||||
enabled,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn for_env(env: DevOrProd) -> Self {
|
||||
Self::enabled(!env.is_prod())
|
||||
}
|
||||
|
||||
pub fn record(&self, name: &'static str) {
|
||||
if self.enabled {
|
||||
self.events.lock().unwrap().push(PerfEvent {
|
||||
time: Instant::now(),
|
||||
name,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub fn header_value(&self) -> Option<String> {
|
||||
if !self.enabled {
|
||||
return None;
|
||||
}
|
||||
let evts = self.events.lock().unwrap();
|
||||
let mut s = String::with_capacity(evts.len() * 16);
|
||||
let mut last = self.created_at;
|
||||
for (i, PerfEvent { name, time }) in evts.iter().enumerate() {
|
||||
if i > 0 {
|
||||
s.push_str(", ");
|
||||
}
|
||||
let micros = time.saturating_duration_since(last).as_micros();
|
||||
let millis = micros / 1000;
|
||||
let micros = micros % 1000;
|
||||
write!(&mut s, "{name};dur={millis}.{micros:03}").ok()?;
|
||||
last = *time;
|
||||
}
|
||||
Some(s)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
use serde::de::Error;
|
||||
use std::borrow::Cow;
|
||||
use std::mem;
|
||||
|
||||
#[derive(Debug, serde::Serialize, PartialEq, Clone)]
|
||||
#[serde(untagged)]
|
||||
pub enum SingleOrVec {
|
||||
Single(String),
|
||||
Vec(Vec<String>),
|
||||
}
|
||||
|
||||
impl<'de> serde::Deserialize<'de> for SingleOrVec {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let value = serde_json::Value::deserialize(deserializer)?;
|
||||
match value {
|
||||
serde_json::Value::String(s) => Ok(SingleOrVec::Single(s)),
|
||||
serde_json::Value::Array(values) => {
|
||||
let mut strings = Vec::with_capacity(values.len());
|
||||
for (idx, item) in values.into_iter().enumerate() {
|
||||
match item {
|
||||
serde_json::Value::String(s) => strings.push(s),
|
||||
other => {
|
||||
return Err(D::Error::custom(format!(
|
||||
"expected an array of strings, but item at index {idx} is {other}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(SingleOrVec::Vec(strings))
|
||||
}
|
||||
other => Err(D::Error::custom(format!(
|
||||
"expected a string or an array of strings, but found {other}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for SingleOrVec {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
SingleOrVec::Single(x) => write!(f, "{x}"),
|
||||
SingleOrVec::Vec(v) => {
|
||||
write!(f, "[")?;
|
||||
let mut it = v.iter();
|
||||
if let Some(first) = it.next() {
|
||||
write!(f, "{first}")?;
|
||||
}
|
||||
for item in it {
|
||||
write!(f, ", {item}")?;
|
||||
}
|
||||
write!(f, "]")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SingleOrVec {
|
||||
pub(crate) fn merge(&mut self, other: Self) {
|
||||
match (self, other) {
|
||||
(Self::Single(old), Self::Single(new)) => *old = new,
|
||||
(old, mut new) => {
|
||||
let mut v = old.take_vec();
|
||||
v.extend_from_slice(&new.take_vec());
|
||||
*old = Self::Vec(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn take_vec(&mut self) -> Vec<String> {
|
||||
match self {
|
||||
SingleOrVec::Single(x) => vec![mem::take(x)],
|
||||
SingleOrVec::Vec(v) => mem::take(v),
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn as_json_str(&self) -> Cow<'_, str> {
|
||||
match self {
|
||||
SingleOrVec::Single(x) => Cow::Borrowed(x),
|
||||
SingleOrVec::Vec(v) => Cow::Owned(serde_json::to_string(v).unwrap()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the first value. This mirrors how `actix_web::HttpRequest::cookie`
|
||||
/// selects the first cookie of a given name, so callers that need to agree
|
||||
/// with that selection (such as OIDC logout session binding) stay consistent
|
||||
/// instead of accidentally using a JSON array of merged duplicate values.
|
||||
#[must_use]
|
||||
pub fn first_str(&self) -> &str {
|
||||
match self {
|
||||
SingleOrVec::Single(x) => x,
|
||||
SingleOrVec::Vec(v) => v.first().map_or("", String::as_str),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod single_or_vec_tests {
|
||||
use super::SingleOrVec;
|
||||
|
||||
#[test]
|
||||
fn deserializes_string_and_array_values() {
|
||||
let single: SingleOrVec = serde_json::from_str(r#""hello""#).unwrap();
|
||||
assert_eq!(single, SingleOrVec::Single("hello".to_string()));
|
||||
let array: SingleOrVec = serde_json::from_str(r#"["a","b"]"#).unwrap();
|
||||
assert_eq!(
|
||||
array,
|
||||
SingleOrVec::Vec(vec!["a".to_string(), "b".to_string()])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_non_string_items() {
|
||||
let err = serde_json::from_str::<SingleOrVec>(r#"["a", 1]"#).unwrap_err();
|
||||
assert!(
|
||||
err.to_string()
|
||||
.contains("expected an array of strings, but item at index 1 is 1"),
|
||||
"{err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_str_returns_first_value() {
|
||||
assert_eq!(SingleOrVec::Single("a".to_string()).first_str(), "a");
|
||||
// Duplicate values (e.g. two cookies of the same name) yield the first,
|
||||
// matching HttpRequest::cookie, not a JSON array.
|
||||
assert_eq!(
|
||||
SingleOrVec::Vec(vec!["a".to_string(), "b".to_string()]).first_str(),
|
||||
"a"
|
||||
);
|
||||
assert_eq!(SingleOrVec::Vec(vec![]).first_str(), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn displays_single_value() {
|
||||
let single = SingleOrVec::Single("hello".to_string());
|
||||
assert_eq!(single.to_string(), "hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn displays_array_values() {
|
||||
let array = SingleOrVec::Vec(vec!["a".to_string(), "b".to_string()]);
|
||||
assert_eq!(array.to_string(), "[a, b]");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
use crate::utils::static_filename;
|
||||
use actix_web::{
|
||||
HttpRequest, HttpResponse, Resource,
|
||||
http::header::{
|
||||
CacheControl, CacheDirective, ContentEncoding, ETag, EntityTag, Header, IfNoneMatch,
|
||||
},
|
||||
web,
|
||||
};
|
||||
|
||||
macro_rules! static_file_endpoint {
|
||||
($filestem:literal, $extension:literal, $mime:literal) => {{
|
||||
const FILENAME_WITH_TAG: &str = static_filename!(concat!($filestem, ".", $extension));
|
||||
web::resource(FILENAME_WITH_TAG).to(|req: HttpRequest| async move {
|
||||
let file_etag = EntityTag::new_strong(FILENAME_WITH_TAG.to_string());
|
||||
if matches!(IfNoneMatch::parse(&req), Ok(IfNoneMatch::Items(etags)) if etags.iter().any(|etag| etag.weak_eq(&file_etag))) {
|
||||
return HttpResponse::NotModified().finish();
|
||||
}
|
||||
HttpResponse::Ok()
|
||||
.content_type(concat!($mime, ";charset=UTF-8"))
|
||||
.insert_header(CacheControl(vec![
|
||||
CacheDirective::Public,
|
||||
CacheDirective::MaxAge(3600 * 24 * 7),
|
||||
CacheDirective::Extension("immutable".to_owned(), None),
|
||||
]))
|
||||
.insert_header(ETag(file_etag))
|
||||
.insert_header(ContentEncoding::Gzip)
|
||||
.body(
|
||||
&include_bytes!(concat!(env!("OUT_DIR"), "/", $filestem, ".", $extension))[..],
|
||||
)
|
||||
})
|
||||
}};
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn js() -> Resource {
|
||||
static_file_endpoint!("sqlpage", "js", "application/javascript")
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn apexcharts_js() -> Resource {
|
||||
static_file_endpoint!("apexcharts", "js", "application/javascript")
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn tomselect_js() -> Resource {
|
||||
static_file_endpoint!("tomselect", "js", "application/javascript")
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn css() -> Resource {
|
||||
static_file_endpoint!("sqlpage", "css", "text/css")
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn favicon() -> Resource {
|
||||
static_file_endpoint!("favicon", "svg", "image/svg+xml")
|
||||
}
|
||||
Reference in New Issue
Block a user