i18n+filtering fork - fluent-templates v2

feat: convert URLs to clickable links in event descriptions

- Add convert_urls_to_links utility function with regex URL detection
- Apply URL conversion to full event descriptions in both individual and list views
- Keep short descriptions as plain text for better readability in list views
- Add proper HTML escaping and security attributes (target="_blank", rel="noopener noreferrer")

+3 -1
Cargo.toml
··· 41 41 chrono = { version = "0.4", default-features = false, features = ["std", "alloc", "now", "serde"] } 42 42 futures-util = { version = "0.3", features = ["sink"] } 43 43 headers = "0.4" 44 + html-escape = "0.2" 44 45 http = "1.1" 46 + regex = "1.10" 45 47 serde_json = { version = "1.0", features = ["alloc"] } 46 48 serde = { version = "1.0", features = ["alloc", "derive"] } 47 49 thiserror = "2.0" ··· 93 95 metrohash = "1.0.7" 94 96 fluent-templates = { version = "0.13.0", features = ["handlebars"] } 95 97 serde_urlencoded = "0.7.1" 96 - ics = "0.5.8" 98 + ics = "0.5" 97 99 98 100 [profile.release] 99 101 opt-level = 3
+4 -3
src/http/handle_filter_events.rs
··· 53 53 use crate::http::errors::{CommonError, WebError}; 54 54 use crate::http::middleware_filter::{FilterCriteriaExtension, FilterQueryParams}; 55 55 use crate::http::pagination::PaginationView; 56 + use crate::http::utils::convert_urls_to_links; 56 57 use crate::storage::{StoragePool, event::{get_user_rsvp, extract_event_details}}; 57 58 58 59 /// Template-compatible event data with flattened structure ··· 867 868 collection, // Set collection for legacy detection 868 869 869 870 name: event_details.name.to_string(), 870 - description: Some(event_details.description.to_string()).filter(|s| !s.is_empty()), 871 + description: Some(event_details.description.to_string()).filter(|s| !s.is_empty()).map(|desc| convert_urls_to_links(&desc)), 871 872 description_short: Some(event_details.description.to_string()).filter(|s| !s.is_empty()).map(|desc| { 872 - // Truncate description for short version 873 + // Truncate description for short version, but don't convert URLs to links 873 874 if desc.len() > 200 { 874 875 format!("{}...", &desc[..200]) 875 876 } else { ··· 952 953 // Use EventView data if available for better formatting (overrides the basic data) 953 954 if let Some(ref event_view) = hydrated.event_view { 954 955 template_event.name = event_view.name.clone(); 955 - template_event.description = event_view.description.clone(); 956 + template_event.description = event_view.description.as_ref().map(|desc| convert_urls_to_links(desc)); 956 957 template_event.description_short = event_view.description_short.clone(); 957 958 template_event.organizer_display_name = event_view.organizer_display_name.clone(); 958 959 template_event.starts_at_machine = event_view.starts_at_machine.clone();
+9 -1
src/http/handle_view_event.rs
··· 21 21 use crate::http::event_view::EventView; 22 22 use crate::http::pagination::Pagination; 23 23 use crate::http::tab_selector::TabSelector; 24 - use crate::http::utils::url_from_aturi; 24 + use crate::http::utils::{convert_urls_to_links, url_from_aturi}; 25 25 use crate::resolve::parse_input; 26 26 use crate::resolve::InputType; 27 27 use crate::storage::event::count_event_rsvps; ··· 432 432 event_with_counts.count_going = going_count; 433 433 event_with_counts.count_interested = interested_count; 434 434 event_with_counts.count_notgoing = notgoing_count; 435 + 436 + // Convert URLs to clickable links in event descriptions 437 + if let Some(ref mut description) = event_with_counts.description { 438 + *description = convert_urls_to_links(description); 439 + } 440 + if let Some(ref mut description_short) = event_with_counts.description_short { 441 + *description_short = convert_urls_to_links(description_short); 442 + } 435 443 436 444 Ok(renderer.render_template( 437 445 "view_event",
+53
src/http/utils.rs
··· 5 5 }, 6 6 http::errors::UrlError, 7 7 }; 8 + use regex::Regex; 9 + use std::sync::LazyLock; 8 10 9 11 pub type QueryParam<'a> = (&'a str, &'a str); 10 12 pub type QueryParams<'a> = Vec<QueryParam<'a>>; ··· 190 192 trimmed.to_string() 191 193 } 192 194 } 195 + 196 + /// Regular expression for matching URLs 197 + static URL_REGEX: LazyLock<Regex> = LazyLock::new(|| { 198 + Regex::new(r"https?://[^\s<>\[\]{}|\\^`]+").expect("Failed to compile URL regex") 199 + }); 200 + 201 + /// Convert URLs in text to clickable HTML links while escaping other HTML content 202 + /// 203 + /// This function finds URLs in the input text and converts them to clickable anchor tags 204 + /// while properly escaping any other HTML content to prevent XSS attacks. 205 + /// 206 + /// # Arguments 207 + /// * `text` - The input text that may contain URLs 208 + /// 209 + /// # Returns 210 + /// * A string with URLs converted to HTML anchor tags and other content HTML-escaped 211 + pub fn convert_urls_to_links(text: &str) -> String { 212 + tracing::debug!("convert_urls_to_links called with text: {}", text); 213 + 214 + let mut result = String::new(); 215 + let mut last_end = 0; 216 + 217 + for url_match in URL_REGEX.find_iter(text) { 218 + // Add the text before this URL (HTML escaped) 219 + let before_url = &text[last_end..url_match.start()]; 220 + result.push_str(&html_escape::encode_text(before_url)); 221 + 222 + // Add the URL as a clickable link 223 + let url = url_match.as_str(); 224 + let href = if url.starts_with("http://") || url.starts_with("https://") { 225 + url.to_string() 226 + } else { 227 + format!("https://{}", url) 228 + }; 229 + 230 + result.push_str(&format!( 231 + r#"<a href="{}" target="_blank" rel="noopener noreferrer">{}</a>"#, 232 + html_escape::encode_quoted_attribute(&href), 233 + html_escape::encode_text(url) 234 + )); 235 + 236 + last_end = url_match.end(); 237 + } 238 + 239 + // Add any remaining text after the last URL (HTML escaped) 240 + let remaining = &text[last_end..]; 241 + result.push_str(&html_escape::encode_text(remaining)); 242 + 243 + tracing::debug!("convert_urls_to_links result: {}", result); 244 + result 245 + }