diff --git a/examples/official-site/sqlpage/migrations/66_log_component.sql b/examples/official-site/sqlpage/migrations/66_log_component.sql new file mode 100644 index 00000000..8df38618 --- /dev/null +++ b/examples/official-site/sqlpage/migrations/66_log_component.sql @@ -0,0 +1,58 @@ +INSERT INTO component(name, icon, description) VALUES +('log', 'logs', 'A Component to log a message to the Servers STDOUT or Log file on page load'); + +INSERT INTO parameter(component, name, description, type, top_level, optional) SELECT 'log', * FROM (VALUES + -- top level + ('message', 'The message that needs to be logged', 'TEXT', TRUE, FALSE), + ('priority', 'The priority which the message should be logged with. Possible values are [''trace'', ''debug'', ''info'', ''warn'', ''error''] and are not case sensitive. If this value is missing or not matching any possible values, the default priority will be ''info''.', 'TEXT', TRUE, TRUE) +) x; + +INSERT INTO example(component, description) VALUES +('log', ' +### Hello World + +Log a simple ''Hello, World!'' message on page load. + +```sql +SELECT ''log'' as component, + ''Hello, World!'' as message +``` + +Output example: + +``` +[2025-09-12T08:33:48.228Z INFO sqlpage::log from file "index.sql" in statement 3] Hello, World! +``` + +### Priority + +Change the priority to error. + +```sql +SELECT ''log'' as component, + ''This is a error message'' as message, + ''error'' as priority +``` + +Output example: + +``` +[2025-09-12T08:33:48.228Z ERROR sqlpage::log from file "index.sql" in header] This is a error message +``` + +### Retrieve user data + +```sql +set username = ''user'' -- (retrieve username from somewhere) + +select ''log'' as component, + ''403 - failed for '' || coalesce($username, ''None'') as output, + ''error'' as priority; +``` + +Output example: + +``` +[2025-09-12T08:33:48.228Z ERROR sqlpage::log from file "403.sql" in statement 7] 403 - failed for user +``` +') \ No newline at end of file diff --git a/src/render.rs b/src/render.rs index 197c0002..0040ac86 100644 --- a/src/render.rs +++ b/src/render.rs @@ -57,7 +57,10 @@ use serde::Serialize; use serde_json::{json, Value}; use std::borrow::Cow; use std::convert::TryFrom; +use std::fmt::Write as _; use std::io::Write; +use std::path::Path; +use std::str::FromStr; use std::sync::Arc; pub enum PageContext { @@ -119,6 +122,7 @@ impl HeaderContext { Some(HeaderComponent::Cookie) => self.add_cookie(&data).map(PageContext::Header), Some(HeaderComponent::Authentication) => self.authentication(data).await, Some(HeaderComponent::Download) => self.download(&data), + Some(HeaderComponent::Log) => self.log(&data), None => self.start_body(data).await, } } @@ -360,6 +364,11 @@ impl HeaderContext { )) } + fn log(self, data: &JsonValue) -> anyhow::Result { + handle_log_component(&self.request_context.source_path, Option::None, data)?; + Ok(PageContext::Header(self)) + } + async fn start_body(self, data: JsonValue) -> anyhow::Result { let html_renderer = HtmlRenderContext::new(self.app_state, self.request_context, self.writer, data) @@ -721,27 +730,43 @@ impl HtmlRenderContext { component.starts_with(PAGE_SHELL_COMPONENT) } + async fn handle_component( + &mut self, + component_name: &str, + data: &JsonValue, + ) -> anyhow::Result<()> { + if Self::is_shell_component(component_name) { + bail!("There cannot be more than a single shell per page. You are trying to open the {} component, but a shell component is already opened for the current page. You can fix this by removing the extra shell component, or by moving this component to the top of the SQL file, before any other component that displays data.", component_name); + } + + if component_name == "log" { + return handle_log_component( + &self.request_context.source_path, + Some(self.current_statement), + data, + ); + } + + match self.open_component_with_data(component_name, &data).await { + Ok(_) => Ok(()), + Err(err) => match HeaderComponent::try_from(component_name) { + Ok(_) => bail!("The {component_name} component cannot be used after data has already been sent to the client's browser. \n\ + This component must be used before any other component. \n\ + To fix this, either move the call to the '{component_name}' component to the top of the SQL file, \n\ + or create a new SQL file where '{component_name}' is the first component."), + Err(()) => Err(err), + }, + } + } + pub async fn handle_row(&mut self, data: &JsonValue) -> anyhow::Result<()> { let new_component = get_object_str(data, "component"); let current_component = self .current_component .as_ref() .map(SplitTemplateRenderer::name); - if let Some(comp_str) = new_component { - if Self::is_shell_component(comp_str) { - bail!("There cannot be more than a single shell per page. You are trying to open the {} component, but a shell component is already opened for the current page. You can fix this by removing the extra shell component, or by moving this component to the top of the SQL file, before any other component that displays data.", comp_str); - } - - match self.open_component_with_data(comp_str, &data).await { - Ok(_) => (), - Err(err) => match HeaderComponent::try_from(comp_str) { - Ok(_) => bail!("The {comp_str} component cannot be used after data has already been sent to the client's browser. \n\ - This component must be used before any other component. \n\ - To fix this, either move the call to the '{comp_str}' component to the top of the SQL file, \n\ - or create a new SQL file where '{comp_str}' is the first component."), - Err(()) => return Err(err), - }, - } + if let Some(component_name) = new_component { + self.handle_component(component_name, data).await?; } else if current_component.is_none() { self.open_component_with_data(DEFAULT_COMPONENT, &JsonValue::Null) .await?; @@ -885,6 +910,24 @@ impl HtmlRenderContext { } } +fn handle_log_component( + source_path: &Path, + current_statement: Option, + data: &JsonValue, +) -> anyhow::Result<()> { + let priority = get_object_str(data, "priority").unwrap_or("info"); + let log_level = log::Level::from_str(priority).with_context(|| "Invalid log priority value")?; + + let mut target = format!("sqlpage::log from \"{}\"", source_path.display()); + if let Some(current_statement) = current_statement { + write!(&mut target, " statement {current_statement}")?; + } + + let message = get_object_str(data, "message").context("log: missing property 'message'")?; + log::log!(target: &target, log_level, "{message}"); + Ok(()) +} + pub(super) fn get_backtrace_as_strings(error: &anyhow::Error) -> Vec { let mut backtrace = vec![]; let mut source = error.source(); @@ -1108,6 +1151,7 @@ enum HeaderComponent { Cookie, Authentication, Download, + Log, } impl TryFrom<&str> for HeaderComponent { @@ -1122,6 +1166,7 @@ impl TryFrom<&str> for HeaderComponent { "cookie" => Ok(Self::Cookie), "authentication" => Ok(Self::Authentication), "download" => Ok(Self::Download), + "log" => Ok(Self::Log), _ => Err(()), } } diff --git a/src/webserver/database/sql.rs b/src/webserver/database/sql.rs index 1417aa4b..97631b02 100644 --- a/src/webserver/database/sql.rs +++ b/src/webserver/database/sql.rs @@ -25,7 +25,7 @@ use std::str::FromStr; #[derive(Default)] pub struct ParsedSqlFile { pub(super) statements: Vec, - pub(super) source_path: PathBuf, + pub source_path: PathBuf, } impl ParsedSqlFile { diff --git a/src/webserver/http.rs b/src/webserver/http.rs index 7ebce97c..f5543fff 100644 --- a/src/webserver/http.rs +++ b/src/webserver/http.rs @@ -44,6 +44,7 @@ use tokio::sync::mpsc; #[derive(Clone)] pub struct RequestContext { pub is_embedded: bool, + pub source_path: PathBuf, pub content_security_policy: ContentSecurityPolicy, } @@ -147,6 +148,7 @@ async fn build_response_header_and_stream>( Ok(ResponseWithWriter::FinishedResponse { http_response }) } +#[allow(clippy::large_enum_variant)] enum ResponseWithWriter { RenderStream { http_response: HttpResponse, @@ -174,9 +176,11 @@ async fn render_sql( log::debug!("Received a request with the following parameters: {req_param:?}"); let (resp_send, resp_recv) = tokio::sync::oneshot::channel::(); + let source_path: PathBuf = sql_file.source_path.clone(); actix_web::rt::spawn(async move { let request_context = RequestContext { is_embedded: req_param.get_variables.contains_key("_sqlpage_embed"), + source_path, content_security_policy: ContentSecurityPolicy::with_random_nonce(), }; let mut conn = None; diff --git a/tests/sql_test_files/it_works_log.sql b/tests/sql_test_files/it_works_log.sql new file mode 100644 index 00000000..4d3794c7 --- /dev/null +++ b/tests/sql_test_files/it_works_log.sql @@ -0,0 +1,6 @@ +select 'log' as component, + 'Hello, World!' as message, + 'info' as priority; + +select 'text' as component, + 'It works !' as contents; \ No newline at end of file