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(®ex_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}