|
| 1 | +use crate::model::{Book, Link, Quote, Sponsor}; |
1 | 2 | use anyhow::Result; |
| 3 | +use regex::Regex; |
2 | 4 | use serde::Serialize; |
3 | | -use tera::{Context, Tera}; |
4 | | - |
5 | | -use crate::model::{Book, Link, Quote, Sponsor}; |
| 5 | +use std::collections::HashMap; |
| 6 | +use std::sync::LazyLock; |
| 7 | +use tera::{Context, Tera, Value}; |
| 8 | + |
| 9 | +static JAVASCRIPT_PROTOCOL_RE: LazyLock<Regex> = |
| 10 | + LazyLock::new(|| Regex::new(r"(?i)javascript:").unwrap()); |
| 11 | + |
| 12 | +/// Tera filter that sanitizes text by replacing "javascript:" (case insensitive) with "JavaScript - ". |
| 13 | +/// |
| 14 | +/// This filter is needed because Buttondown's API rejects content containing "javascript:" |
| 15 | +/// even in Markdown body text, returning the error: |
| 16 | +/// `{"body": ["JavaScript in attributes is not allowed. (You added one with the `javascript:` attribute.)"]}` |
| 17 | +fn sanitize_js_filter(value: &Value, _args: &HashMap<String, Value>) -> tera::Result<Value> { |
| 18 | + match value.as_str() { |
| 19 | + Some(s) => { |
| 20 | + let result = JAVASCRIPT_PROTOCOL_RE.replace_all(s, "JavaScript - "); |
| 21 | + Ok(Value::String(result.into_owned())) |
| 22 | + } |
| 23 | + None => Ok(value.clone()), |
| 24 | + } |
| 25 | +} |
6 | 26 |
|
7 | 27 | /// Enhanced link with action text for template rendering |
8 | 28 | #[derive(Serialize, Debug)] |
@@ -201,9 +221,13 @@ impl TemplateRenderer { |
201 | 221 | context.insert("closing_title", &closing_title); |
202 | 222 | context.insert("closing_message", &closing_message); |
203 | 223 |
|
204 | | - // Use Tera's one-off rendering function with embedded template |
205 | | - // autoescape=false since we're rendering Markdown, not HTML |
206 | | - let rendered = Tera::one_off(NEWSLETTER_TEMPLATE, &context, false)?; |
| 224 | + // Create Tera instance with custom filter for sanitizing javascript: protocol |
| 225 | + let mut tera = Tera::default(); |
| 226 | + tera.add_raw_template("newsletter", NEWSLETTER_TEMPLATE)?; |
| 227 | + tera.register_filter("sanitize_js", sanitize_js_filter); |
| 228 | + tera.autoescape_on(vec![]); // Disable autoescape since we're rendering Markdown |
| 229 | + |
| 230 | + let rendered = tera.render("newsletter", &context)?; |
207 | 231 | Ok(rendered) |
208 | 232 | } |
209 | 233 | } |
@@ -296,6 +320,70 @@ mod tests { |
296 | 320 | ) |
297 | 321 | } |
298 | 322 |
|
| 323 | + #[test] |
| 324 | + fn test_sanitize_js_filter_lowercase() { |
| 325 | + let args = HashMap::new(); |
| 326 | + // Note: "javascript:" is replaced with "JavaScript - ", so original space after colon remains |
| 327 | + let input = Value::String("Check out javascript: protocol".to_string()); |
| 328 | + let result = sanitize_js_filter(&input, &args).unwrap(); |
| 329 | + assert_eq!(result.as_str().unwrap(), "Check out JavaScript - protocol"); |
| 330 | + } |
| 331 | + |
| 332 | + #[test] |
| 333 | + fn test_sanitize_js_filter_uppercase() { |
| 334 | + let args = HashMap::new(); |
| 335 | + let input = Value::String("Check out JAVASCRIPT: protocol".to_string()); |
| 336 | + let result = sanitize_js_filter(&input, &args).unwrap(); |
| 337 | + assert_eq!(result.as_str().unwrap(), "Check out JavaScript - protocol"); |
| 338 | + } |
| 339 | + |
| 340 | + #[test] |
| 341 | + fn test_sanitize_js_filter_mixed_case() { |
| 342 | + let args = HashMap::new(); |
| 343 | + let input = Value::String("Check out JaVaScRiPt: protocol".to_string()); |
| 344 | + let result = sanitize_js_filter(&input, &args).unwrap(); |
| 345 | + assert_eq!(result.as_str().unwrap(), "Check out JavaScript - protocol"); |
| 346 | + } |
| 347 | + |
| 348 | + #[test] |
| 349 | + fn test_sanitize_js_filter_multiple_occurrences() { |
| 350 | + let args = HashMap::new(); |
| 351 | + let input = Value::String("javascript: and JavaScript: both".to_string()); |
| 352 | + let result = sanitize_js_filter(&input, &args).unwrap(); |
| 353 | + assert_eq!( |
| 354 | + result.as_str().unwrap(), |
| 355 | + "JavaScript - and JavaScript - both" |
| 356 | + ); |
| 357 | + } |
| 358 | + |
| 359 | + #[test] |
| 360 | + fn test_sanitize_js_filter_no_trailing_space() { |
| 361 | + let args = HashMap::new(); |
| 362 | + // When there's no space after the colon in the original, the replacement is clean |
| 363 | + let input = Value::String("javascript:void(0)".to_string()); |
| 364 | + let result = sanitize_js_filter(&input, &args).unwrap(); |
| 365 | + assert_eq!(result.as_str().unwrap(), "JavaScript - void(0)"); |
| 366 | + } |
| 367 | + |
| 368 | + #[test] |
| 369 | + fn test_sanitize_js_filter_no_match() { |
| 370 | + let args = HashMap::new(); |
| 371 | + let input = Value::String("Just regular text without the pattern".to_string()); |
| 372 | + let result = sanitize_js_filter(&input, &args).unwrap(); |
| 373 | + assert_eq!( |
| 374 | + result.as_str().unwrap(), |
| 375 | + "Just regular text without the pattern" |
| 376 | + ); |
| 377 | + } |
| 378 | + |
| 379 | + #[test] |
| 380 | + fn test_sanitize_js_filter_non_string() { |
| 381 | + let args = HashMap::new(); |
| 382 | + let input = Value::Number(42.into()); |
| 383 | + let result = sanitize_js_filter(&input, &args).unwrap(); |
| 384 | + assert_eq!(result, Value::Number(42.into())); |
| 385 | + } |
| 386 | + |
299 | 387 | #[test] |
300 | 388 | fn test_get_link_action_text() { |
301 | 389 | // Test GitHub URLs |
|
0 commit comments