Skip to content

Commit f4e9d4b

Browse files
committed
✨ Expose user attributes to notification templates
1 parent 132d71f commit f4e9d4b

4 files changed

Lines changed: 113 additions & 11 deletions

File tree

packages/Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/sequent-core/Cargo.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ phf = { version = "0.11", features = ["macros"], default-features = false }
8181
handlebars = { version = "6.1", optional = true }
8282
handlebars-chrono = { version = "0.2", features = [ "locale", "timezone" ], optional = true }
8383
num-format = { version = "0.4", optional = true }
84+
urlencoding = { version = "2.1", optional = true }
8485
headless_chrome = { version = "1.0", optional = true }
8586
reqwest = { version="0.12", features = ["blocking", "json", "rustls-tls"], optional = true, default-features = false }
8687
reqwest-retry = { version = "0.7", optional = true }
@@ -138,7 +139,7 @@ wasm = ["dep:wasm-bindgen", "areas", "dep:serde-wasm-bindgen"]
138139
signatures=["dep:tracing"]
139140
default = []
140141
areas = []
141-
reports = ["dep:handlebars", "dep:handlebars-chrono", "dep:headless_chrome", "dep:reqwest", "dep:tracing", "dep:tokio", "dep:num-format", "dep:aws-config", "dep:aws-sdk-sesv2", "dep:aws-sdk-sns", "dep:aws-sdk-s3", "dep:aws-smithy-types", "dep:sha256", "dep:ammonia"]
142+
reports = ["dep:handlebars", "dep:handlebars-chrono", "dep:headless_chrome", "dep:reqwest", "dep:tracing", "dep:tokio", "dep:num-format", "dep:urlencoding", "dep:aws-config", "dep:aws-sdk-sesv2", "dep:aws-sdk-sns", "dep:aws-sdk-s3", "dep:aws-smithy-types", "dep:sha256", "dep:ammonia"]
142143
reports_sync = []
143144
s3 = ["dep:reqwest", "dep:aws-config", "dep:aws-sdk-sesv2", "dep:aws-sdk-sns", "dep:aws-sdk-s3", "dep:aws-smithy-types"]
144145
keycloak = ["jwt", "dep:openid", "dep:reqwest", "dep:reqwest-retry", "dep:reqwest-middleware", "dep:serde_urlencoded", "dep:rocket", "dep:keycloak", "dep:tracing", "dep:uuid", "dep:regex", "dep:jsonwebtoken", "dep:tokio-postgres", "dep:time"]

packages/sequent-core/src/services/reports.rs

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ fn get_registry<'reg>() -> Handlebars<'reg> {
6262
helper_wrapper_or(Box::new(inc2), String::from("-")),
6363
);
6464
reg.register_helper("to_json", helper_wrapper(Box::new(to_json)));
65+
reg.register_helper("url_encode", helper_wrapper(Box::new(url_encode)));
6566
reg.register_helper(
6667
"parse_i64",
6768
helper_wrapper_or(Box::new(parse_i64), String::from("-")),
@@ -283,6 +284,31 @@ pub fn sanitize_html(
283284
Ok(())
284285
}
285286

287+
/// Percent-encodes a string for use as one dynamic URL query value.
288+
pub fn url_encode(
289+
helper: &Helper,
290+
_: &Handlebars,
291+
_: &Context,
292+
_: &mut RenderContext,
293+
out: &mut dyn Output,
294+
) -> HelperResult {
295+
let value = helper
296+
.param(0)
297+
.ok_or(RenderErrorReason::ParamNotFoundForIndex("url_encode", 0))?
298+
.value()
299+
.as_str()
300+
.ok_or_else(|| {
301+
RenderErrorReason::ParamTypeMismatchForName(
302+
"url_encode",
303+
"0".to_string(),
304+
"string".to_string(),
305+
)
306+
})?;
307+
308+
out.write(urlencoding::encode(value).as_ref())?;
309+
Ok(())
310+
}
311+
286312
fn parse_u64_value(value: &JsonValue) -> Result<u64, RenderError> {
287313
match value {
288314
JsonValue::Number(n) => n.as_u64().ok_or_else(|| {
@@ -802,3 +828,27 @@ impl HelperDef for is_some {
802828
Ok(())
803829
}
804830
}
831+
832+
#[cfg(test)]
833+
mod tests {
834+
use super::render_template_text;
835+
use serde_json::{json, Map};
836+
837+
#[test]
838+
fn url_encode_keeps_dynamic_data_in_one_query_value() {
839+
let mut variables = Map::new();
840+
variables
841+
.insert("value".to_string(), json!("a&admin=true 50% \"Málaga\""));
842+
843+
let rendered = render_template_text(
844+
"https://vote.example/login?login_hint__reference={{url_encode value}}",
845+
variables,
846+
)
847+
.expect("template should render");
848+
849+
assert_eq!(
850+
rendered,
851+
"https://vote.example/login?login_hint__reference=a%26admin%3Dtrue%2050%25%20%22M%C3%A1laga%22"
852+
);
853+
}
854+
}

packages/windmill/src/tasks/send_template.rs

Lines changed: 60 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -50,16 +50,22 @@ fn get_variables(
5050
auth_action: AuthAction,
5151
) -> Result<Map<String, Value>> {
5252
let mut variables: Map<String, Value> = Default::default();
53-
variables.insert(
54-
"user".to_string(),
55-
json!({
56-
"first_name": user.first_name.clone(),
57-
"last_name": user.last_name.clone(),
58-
"username": user.username.clone(),
59-
"first_name": user.first_name.clone(),
60-
"email": user.email.clone(),
61-
}),
62-
);
53+
let mut user_variables = Map::new();
54+
user_variables.insert("first_name".to_string(), json!(user.first_name));
55+
user_variables.insert("last_name".to_string(), json!(user.last_name));
56+
user_variables.insert("username".to_string(), json!(user.username));
57+
user_variables.insert("email".to_string(), json!(user.email));
58+
59+
let attributes = user.attributes.clone().unwrap_or_default();
60+
for (attribute_name, values) in &attributes {
61+
if let Some(first_value) = values.first() {
62+
user_variables
63+
.entry(attribute_name.clone())
64+
.or_insert_with(|| json!(first_value));
65+
}
66+
}
67+
user_variables.insert("attributes".to_string(), json!(attributes));
68+
variables.insert("user".to_string(), Value::Object(user_variables));
6369
variables.insert("tenant_id".to_string(), json!(tenant_id.clone()));
6470
if let Some(ref election_event) = election_event {
6571
let default_language = election_event.get_default_language();
@@ -644,3 +650,47 @@ pub async fn send_template_email_or_sms(
644650
}
645651
}
646652
}
653+
654+
#[cfg(test)]
655+
mod tests {
656+
use super::get_variables;
657+
use sequent_core::services::generate_urls::AuthAction;
658+
use sequent_core::types::keycloak::User;
659+
use serde_json::json;
660+
use std::collections::HashMap;
661+
662+
#[test]
663+
fn get_variables_exposes_dynamic_and_multivalued_user_attributes() {
664+
let user = User {
665+
username: Some("canonical-user".to_string()),
666+
attributes: Some(HashMap::from([
667+
(
668+
"dateOfBirth".to_string(),
669+
vec!["2000-01-01".to_string(), "ignored-first-value".to_string()],
670+
),
671+
(
672+
"username".to_string(),
673+
vec!["untrusted-collision".to_string()],
674+
),
675+
("empty".to_string(), Vec::new()),
676+
])),
677+
..User::default()
678+
};
679+
680+
let variables = get_variables(&user, None, "tenant-id".to_string(), AuthAction::Login)
681+
.expect("variables should be generated");
682+
683+
assert_eq!(variables["user"]["username"], json!("canonical-user"));
684+
assert_eq!(variables["user"]["dateOfBirth"], json!("2000-01-01"));
685+
assert!(variables["user"].get("empty").is_none());
686+
assert_eq!(
687+
variables["user"]["attributes"]["dateOfBirth"],
688+
json!(["2000-01-01", "ignored-first-value"])
689+
);
690+
assert_eq!(
691+
variables["user"]["attributes"]["username"],
692+
json!(["untrusted-collision"])
693+
);
694+
assert_eq!(variables["user"]["attributes"]["empty"], json!([]));
695+
}
696+
}

0 commit comments

Comments
 (0)