Monorepo for wisp.place. A static site hosting service built on top of the AT Protocol. wisp.place
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}