+3
-1
Cargo.toml
+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
+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
+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
+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
+
}