forked from
smokesignal.events/smokesignal
i18n+filtering fork - fluent-templates v2
1use crate::{
2 atproto::lexicon::{
3 community::lexicon::calendar::event::NSID,
4 events::smokesignal::calendar::event::NSID as LegacyNSID,
5 },
6 http::errors::UrlError,
7};
8use regex::Regex;
9use std::sync::LazyLock;
10
11pub type QueryParam<'a> = (&'a str, &'a str);
12pub type QueryParams<'a> = Vec<QueryParam<'a>>;
13
14pub fn stringify(query: QueryParams) -> String {
15 query.iter().fold(String::new(), |acc, &tuple| {
16 acc + tuple.0 + "=" + tuple.1 + "&"
17 })
18}
19
20pub struct URLBuilder {
21 host: String,
22 path: String,
23 params: Vec<(String, String)>,
24}
25
26pub fn build_url(host: &str, path: &str, params: Vec<Option<(&str, &str)>>) -> String {
27 let mut url_builder = URLBuilder::new(host);
28 url_builder.path(path);
29
30 for (key, value) in params.iter().filter_map(|x| *x) {
31 url_builder.param(key, value);
32 }
33
34 url_builder.build()
35}
36
37impl URLBuilder {
38 pub fn new(host: &str) -> URLBuilder {
39 let host = if host.starts_with("https://") {
40 host.to_string()
41 } else {
42 format!("https://{}", host)
43 };
44
45 let host = if let Some(trimmed) = host.strip_suffix('/') {
46 trimmed.to_string()
47 } else {
48 host
49 };
50
51 URLBuilder {
52 host: host.to_string(),
53 params: vec![],
54 path: "/".to_string(),
55 }
56 }
57
58 pub fn param(&mut self, key: &str, value: &str) -> &mut Self {
59 self.params
60 .push((key.to_owned(), urlencoding::encode(value).to_string()));
61 self
62 }
63
64 pub fn path(&mut self, path: &str) -> &mut Self {
65 path.clone_into(&mut self.path);
66 self
67 }
68
69 pub fn build(self) -> String {
70 let mut url_params = String::new();
71
72 if !self.params.is_empty() {
73 url_params.push('?');
74
75 let qs_args = self.params.iter().map(|(k, v)| (&**k, &**v)).collect();
76 url_params.push_str(stringify(qs_args).as_str());
77 }
78
79 format!("{}{}{}", self.host, self.path, url_params)
80 }
81}
82
83pub fn url_from_aturi(external_base: &str, aturi: &str) -> Result<String, UrlError> {
84 let aturi = aturi.strip_prefix("at://").unwrap_or(aturi);
85 let parts = aturi.split("/").collect::<Vec<_>>();
86 if parts.len() == 3 && parts[1] == NSID {
87 let path = format!("/{}/{}", parts[0], parts[2]);
88 return Ok(build_url(external_base, &path, vec![]));
89 }
90 if parts.len() == 3 && parts[1] == LegacyNSID {
91 let path = format!("/{}/{}", parts[0], parts[2]);
92 return Ok(build_url(external_base, &path, vec![]));
93 }
94 Err(UrlError::UnsupportedCollection)
95}
96
97fn find_char_bytes_len(ch: &char) -> i32 {
98 let mut b = [0; 4];
99 ch.encode_utf8(&mut b);
100 let mut clen = 0;
101 for a in b.iter() {
102 clen += match a {
103 0 => 0,
104 _ => 1,
105 }
106 }
107 clen
108}
109
110pub fn truncate_text(text: &str, tlen: usize, suffix: Option<String>) -> String {
111 if text.len() <= tlen {
112 return text.to_string();
113 }
114
115 let c = text.chars().nth(tlen);
116 let ret = match c {
117 Some(s) => match char::is_whitespace(s) {
118 true => text.split_at(tlen).0,
119 false => {
120 let chars: Vec<_> = text.chars().collect();
121 let truncated = chars.split_at(tlen);
122 let mut first_len = 0;
123 for ch in truncated.0.iter() {
124 first_len += find_char_bytes_len(ch);
125 }
126
127 let mut prev_ws = first_len - 1;
128 for ch in truncated.0.iter().rev() {
129 if char::is_whitespace(*ch) {
130 break;
131 }
132 prev_ws -= find_char_bytes_len(ch);
133 }
134
135 let mut next_ws = first_len + 1;
136 for ch in truncated.1.iter() {
137 let mut b = [0; 4];
138 ch.encode_utf8(&mut b);
139 if char::is_whitespace(*ch) {
140 break;
141 }
142 next_ws += find_char_bytes_len(ch);
143 }
144
145 match next_ws > prev_ws && prev_ws > 0 {
146 true => text.split_at(prev_ws as usize).0,
147 false => text.split_at(next_ws as usize).1,
148 }
149 }
150 },
151 None => text,
152 };
153
154 if ret.len() < text.len() {
155 if let Some(suffix) = suffix {
156 return format!("{} {}", ret, suffix.clone());
157 }
158 }
159 ret.to_string()
160}
161
162/// Convert a handle to a URL-safe slug format
163///
164/// This function takes a handle (which may be in various formats like `example.com`,
165/// `@example.com`, `did:web:example.com`, or `did:plc:abc123`) and converts it to
166/// a URL-safe slug that can be used in URL paths.
167///
168/// # Arguments
169/// * `handle` - The handle to convert to a slug
170///
171/// # Returns
172/// * A URL-safe slug string
173pub fn slug_from_handle(handle: &str) -> String {
174 // Strip common prefixes to get the core handle
175 let trimmed = if let Some(value) = handle.strip_prefix("at://") {
176 value
177 } else if let Some(value) = handle.strip_prefix('@') {
178 value
179 } else {
180 handle
181 };
182
183 // For DID formats, we need to handle them specially
184 if let Some(web_handle) = trimmed.strip_prefix("did:web:") {
185 // For did:web: format, use the domain part
186 web_handle.to_string()
187 } else if trimmed.starts_with("did:plc:") {
188 // For did:plc: format, use the full DID as the slug
189 trimmed.to_string()
190 } else {
191 // For regular handles, use as-is (they're already domain-like)
192 trimmed.to_string()
193 }
194}
195
196/// Regular expression for matching URLs
197static 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
211pub 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}