Monorepo for wisp.place. A static site hosting service built on top of the AT Protocol. wisp.place
96
fork

Configure Feed

Select the types of activity you want to include in your feed.

at a58efefd2c4e3a448a9955bedc734f0f98be4fe3 375 lines 12 kB view raw
1use regex::Regex; 2use std::collections::HashMap; 3use std::fs; 4use std::path::Path; 5 6/// Maximum number of redirect rules to prevent DoS attacks 7const MAX_REDIRECT_RULES: usize = 1000; 8 9#[derive(Debug, Clone)] 10pub struct RedirectRule { 11 #[allow(dead_code)] 12 pub from: String, 13 pub to: String, 14 pub status: u16, 15 #[allow(dead_code)] 16 pub force: bool, 17 pub from_pattern: Regex, 18 pub from_params: Vec<String>, 19 pub query_params: Option<HashMap<String, String>>, 20} 21 22#[derive(Debug)] 23pub struct RedirectMatch { 24 pub target_path: String, 25 pub status: u16, 26 pub force: bool, 27} 28 29/// Parse a _redirects file into an array of redirect rules 30pub fn parse_redirects_file(content: &str) -> Vec<RedirectRule> { 31 let lines = content.lines(); 32 let mut rules = Vec::new(); 33 34 for (line_num, line_raw) in lines.enumerate() { 35 if line_raw.trim().is_empty() || line_raw.trim().starts_with('#') { 36 continue; 37 } 38 39 // Enforce max rules limit 40 if rules.len() >= MAX_REDIRECT_RULES { 41 eprintln!( 42 "Redirect rules limit reached ({}), ignoring remaining rules", 43 MAX_REDIRECT_RULES 44 ); 45 break; 46 } 47 48 match parse_redirect_line(line_raw.trim()) { 49 Ok(Some(rule)) => rules.push(rule), 50 Ok(None) => continue, 51 Err(e) => { 52 eprintln!( 53 "Failed to parse redirect rule on line {}: {} ({})", 54 line_num + 1, 55 line_raw, 56 e 57 ); 58 } 59 } 60 } 61 62 rules 63} 64 65/// Parse a single redirect rule line 66/// Format: /from [query_params] /to [status] [conditions] 67fn parse_redirect_line(line: &str) -> Result<Option<RedirectRule>, String> { 68 let parts: Vec<&str> = line.split_whitespace().collect(); 69 70 if parts.len() < 2 { 71 return Ok(None); 72 } 73 74 let mut idx = 0; 75 let from = parts[idx]; 76 idx += 1; 77 78 let mut status = 301; // Default status 79 let mut force = false; 80 let mut query_params: HashMap<String, String> = HashMap::new(); 81 82 // Parse query parameters that come before the destination path 83 while idx < parts.len() { 84 let part = parts[idx]; 85 86 // If it starts with / or http, it's the destination path 87 if part.starts_with('/') || part.starts_with("http://") || part.starts_with("https://") { 88 break; 89 } 90 91 // If it contains = and comes before the destination, it's a query param 92 if part.contains('=') { 93 let split_index = part.find('=').unwrap(); 94 let key = &part[..split_index]; 95 let value = &part[split_index + 1..]; 96 97 if !key.is_empty() && !value.is_empty() { 98 query_params.insert(key.to_string(), value.to_string()); 99 } 100 idx += 1; 101 } else { 102 break; 103 } 104 } 105 106 // Next part should be the destination 107 if idx >= parts.len() { 108 return Ok(None); 109 } 110 111 let to = parts[idx]; 112 idx += 1; 113 114 // Parse remaining parts for status code 115 for part in parts.iter().skip(idx) { 116 // Check for status code (with optional ! for force) 117 if let Some(stripped) = part.strip_suffix('!') { 118 if let Ok(s) = stripped.parse::<u16>() { 119 force = true; 120 status = s; 121 } 122 } else if let Ok(s) = part.parse::<u16>() { 123 status = s; 124 } 125 // Note: We're ignoring conditional redirects (Country, Language, Cookie, Role) for now 126 // They can be added later if needed 127 } 128 129 // Parse the 'from' pattern 130 let (pattern, params) = convert_path_to_regex(from)?; 131 132 Ok(Some(RedirectRule { 133 from: from.to_string(), 134 to: to.to_string(), 135 status, 136 force, 137 from_pattern: pattern, 138 from_params: params, 139 query_params: if query_params.is_empty() { 140 None 141 } else { 142 Some(query_params) 143 }, 144 })) 145} 146 147/// Convert a path pattern with placeholders and splats to a regex 148/// Examples: 149/// /blog/:year/:month/:day -> captures year, month, day 150/// /news/* -> captures splat 151fn convert_path_to_regex(pattern: &str) -> Result<(Regex, Vec<String>), String> { 152 let mut params = Vec::new(); 153 let mut regex_str = String::from("^"); 154 155 // Split by query string if present 156 let path_part = pattern.split('?').next().unwrap_or(pattern); 157 158 // Escape special regex characters except * and : 159 let mut escaped = String::new(); 160 for ch in path_part.chars() { 161 match ch { 162 '.' | '+' | '^' | '$' | '{' | '}' | '(' | ')' | '|' | '[' | ']' | '\\' => { 163 escaped.push('\\'); 164 escaped.push(ch); 165 } 166 _ => escaped.push(ch), 167 } 168 } 169 170 // Replace :param with named capture groups 171 let param_regex = Regex::new(r":([a-zA-Z_][a-zA-Z0-9_]*)").map_err(|e| e.to_string())?; 172 let mut last_end = 0; 173 let mut result = String::new(); 174 175 for cap in param_regex.captures_iter(&escaped) { 176 let m = cap.get(0).unwrap(); 177 result.push_str(&escaped[last_end..m.start()]); 178 result.push_str("([^/?]+)"); 179 params.push(cap[1].to_string()); 180 last_end = m.end(); 181 } 182 result.push_str(&escaped[last_end..]); 183 escaped = result; 184 185 // Replace * with splat capture 186 if escaped.contains('*') { 187 escaped = escaped.replace('*', "(.*)"); 188 params.push("splat".to_string()); 189 } 190 191 regex_str.push_str(&escaped); 192 193 // Make trailing slash optional 194 if !regex_str.ends_with(".*") { 195 regex_str.push_str("/?"); 196 } 197 198 regex_str.push('$'); 199 200 let pattern = Regex::new(&regex_str).map_err(|e| e.to_string())?; 201 202 Ok((pattern, params)) 203} 204 205/// Match a request path against redirect rules 206pub fn match_redirect_rule( 207 request_path: &str, 208 rules: &[RedirectRule], 209 query_params: Option<&HashMap<String, String>>, 210) -> Option<RedirectMatch> { 211 // Normalize path: ensure leading slash 212 let normalized_path = if request_path.starts_with('/') { 213 request_path.to_string() 214 } else { 215 format!("/{}", request_path) 216 }; 217 218 for rule in rules { 219 // Check query parameter conditions first (if any) 220 if let Some(required_params) = &rule.query_params { 221 if let Some(actual_params) = query_params { 222 let query_matches = required_params.iter().all(|(key, expected_value)| { 223 if let Some(actual_value) = actual_params.get(key) { 224 // If expected value is a placeholder (:name), any value is acceptable 225 if expected_value.starts_with(':') { 226 return true; 227 } 228 // Otherwise it must match exactly 229 actual_value == expected_value 230 } else { 231 false 232 } 233 }); 234 235 if !query_matches { 236 continue; 237 } 238 } else { 239 // Rule requires query params but none provided 240 continue; 241 } 242 } 243 244 // Match the path pattern 245 if let Some(captures) = rule.from_pattern.captures(&normalized_path) { 246 let mut target_path = rule.to.clone(); 247 248 // Replace captured parameters 249 for (i, param_name) in rule.from_params.iter().enumerate() { 250 if let Some(param_value) = captures.get(i + 1) { 251 let value = param_value.as_str(); 252 253 if param_name == "splat" { 254 target_path = target_path.replace(":splat", value); 255 } else { 256 target_path = target_path.replace(&format!(":{}", param_name), value); 257 } 258 } 259 } 260 261 // Handle query parameter replacements 262 if let Some(required_params) = &rule.query_params { 263 if let Some(actual_params) = query_params { 264 for (key, placeholder) in required_params { 265 if placeholder.starts_with(':') { 266 if let Some(actual_value) = actual_params.get(key) { 267 let param_name = &placeholder[1..]; 268 target_path = target_path.replace( 269 &format!(":{}", param_name), 270 actual_value, 271 ); 272 } 273 } 274 } 275 } 276 } 277 278 // Preserve query string for 200, 301, 302 redirects (unless target already has one) 279 if [200, 301, 302].contains(&rule.status) 280 && query_params.is_some() 281 && !target_path.contains('?') 282 { 283 if let Some(params) = query_params { 284 if !params.is_empty() { 285 let query_string: String = params 286 .iter() 287 .map(|(k, v)| format!("{}={}", k, v)) 288 .collect::<Vec<_>>() 289 .join("&"); 290 target_path = format!("{}?{}", target_path, query_string); 291 } 292 } 293 } 294 295 return Some(RedirectMatch { 296 target_path, 297 status: rule.status, 298 force: rule.force, 299 }); 300 } 301 } 302 303 None 304} 305 306/// Load redirect rules from a _redirects file 307pub fn load_redirect_rules(directory: &Path) -> Vec<RedirectRule> { 308 let redirects_path = directory.join("_redirects"); 309 310 if !redirects_path.exists() { 311 return Vec::new(); 312 } 313 314 match fs::read_to_string(&redirects_path) { 315 Ok(content) => parse_redirects_file(&content), 316 Err(e) => { 317 eprintln!("Failed to load _redirects file: {}", e); 318 Vec::new() 319 } 320 } 321} 322 323#[cfg(test)] 324mod tests { 325 use super::*; 326 327 #[test] 328 fn test_parse_simple_redirect() { 329 let content = "/old-path /new-path"; 330 let rules = parse_redirects_file(content); 331 assert_eq!(rules.len(), 1); 332 assert_eq!(rules[0].from, "/old-path"); 333 assert_eq!(rules[0].to, "/new-path"); 334 assert_eq!(rules[0].status, 301); 335 assert!(!rules[0].force); 336 } 337 338 #[test] 339 fn test_parse_with_status() { 340 let content = "/temp /target 302"; 341 let rules = parse_redirects_file(content); 342 assert_eq!(rules[0].status, 302); 343 } 344 345 #[test] 346 fn test_parse_force_redirect() { 347 let content = "/force /target 301!"; 348 let rules = parse_redirects_file(content); 349 assert!(rules[0].force); 350 } 351 352 #[test] 353 fn test_match_exact_path() { 354 let rules = parse_redirects_file("/old-path /new-path"); 355 let m = match_redirect_rule("/old-path", &rules, None); 356 assert!(m.is_some()); 357 assert_eq!(m.unwrap().target_path, "/new-path"); 358 } 359 360 #[test] 361 fn test_match_splat() { 362 let rules = parse_redirects_file("/news/* /blog/:splat"); 363 let m = match_redirect_rule("/news/2024/01/15/post", &rules, None); 364 assert!(m.is_some()); 365 assert_eq!(m.unwrap().target_path, "/blog/2024/01/15/post"); 366 } 367 368 #[test] 369 fn test_match_placeholders() { 370 let rules = parse_redirects_file("/blog/:year/:month/:day /posts/:year-:month-:day"); 371 let m = match_redirect_rule("/blog/2024/01/15", &rules, None); 372 assert!(m.is_some()); 373 assert_eq!(m.unwrap().target_path, "/posts/2024-01-15"); 374 } 375}