chore: import upstream snapshot with attribution
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 / hurl (${{ matrix.example }}) (push) Has been skipped
CI / docker_push (duckdb) (push) Has been cancelled
CI / docker_push (minimal) (push) Has been cancelled
CI / windows_test (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:31:57 +08:00
commit d718c5a372
986 changed files with 74597 additions and 0 deletions
+107
View File
@@ -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"
);
}
}
+189
View File
@@ -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"),
}
}
}
+275
View File
@@ -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");
}
}
+387
View File
@@ -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);
}
+961
View File
@@ -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"));
}
}
+85
View File
@@ -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."
)
}
+142
View File
@@ -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) = &param {
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(())
}
}
+712
View File
@@ -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>(&parameters)
.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");
}
+321
View File
@@ -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)
}
+351
View File
@@ -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());
}
}
+82
View File
@@ -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())
}
}
+763
View File
@@ -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"
);
}
}
+76
View File
@@ -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"))
}
}
+91
View File
@@ -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)
})
}
}
+411
View File
@@ -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);
}
}
+32
View File
@@ -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)
}
+53
View File
@@ -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
+28
View File
@@ -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
})
}
+149
View File
@@ -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}");
}
}
}
+755
View File
@@ -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);
}
}
}
+72
View File
@@ -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)
}
}
+148
View File
@@ -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]");
}
}
+57
View File
@@ -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")
}