1use anyhow::Result;
12use once_cell::sync::Lazy;
13use regex::{Captures, Regex};
14use tracing::{debug, warn};
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
18pub enum RewriteStrategy {
19 None,
21 BaseTag,
23 #[default]
25 FullRewrite,
26}
27
28pub fn should_rewrite_content(content_type: &str) -> bool {
30 let content_type_lower = content_type.to_lowercase();
31 matches!(
32 content_type_lower.split(';').next().unwrap_or("").trim(),
33 "text/html"
34 | "text/css"
35 | "application/javascript"
36 | "text/javascript"
37 | "application/json"
38 )
39}
40
41pub fn rewrite_response_content(
43 body: &str,
44 content_type: &str,
45 tunnel_id: &str,
46 strategy: RewriteStrategy,
47) -> Result<(String, bool)> {
48 if !should_rewrite_content(content_type) {
49 return Ok((body.to_string(), false));
50 }
51
52 let prefix = format!("/{}", tunnel_id);
53 let content_type_lower = content_type.to_lowercase();
54 let mime_type = content_type_lower.split(';').next().unwrap_or("").trim();
55
56 let result = match (mime_type, strategy) {
57 (_, RewriteStrategy::None) => {
58 debug!("Content rewriting disabled by strategy");
59 return Ok((body.to_string(), false));
60 }
61 ("text/html", RewriteStrategy::BaseTag) => inject_base_tag(body, &prefix),
62 ("text/html", RewriteStrategy::FullRewrite) => rewrite_html(body, &prefix),
63 ("text/css", _) => rewrite_css(body, &prefix),
64 ("application/javascript" | "text/javascript", _) => {
65 debug!("Skipping JavaScript rewriting (not implemented)");
67 return Ok((body.to_string(), false));
68 }
69 ("application/json", _) => rewrite_json(body, &prefix),
70 _ => {
71 return Ok((body.to_string(), false));
72 }
73 };
74
75 let rewritten = result?;
76 let was_rewritten = rewritten != body;
77
78 if was_rewritten {
79 debug!(
80 "Rewrote {} content: {} bytes -> {} bytes",
81 mime_type,
82 body.len(),
83 rewritten.len()
84 );
85 }
86
87 Ok((rewritten, was_rewritten))
88}
89
90static HTML_HREF_REGEX: Lazy<Regex> =
92 Lazy::new(|| Regex::new(r#"href="(/[^"]*)""#).expect("Invalid regex"));
93static HTML_SRC_REGEX: Lazy<Regex> =
94 Lazy::new(|| Regex::new(r#"src="(/[^"]*)""#).expect("Invalid regex"));
95static HTML_ACTION_REGEX: Lazy<Regex> =
96 Lazy::new(|| Regex::new(r#"action="(/[^"]*)""#).expect("Invalid regex"));
97
98static CSS_URL_SINGLE_QUOTE: Lazy<Regex> =
100 Lazy::new(|| Regex::new(r#"url\('(/[^']+)'\)"#).expect("Invalid regex"));
101static CSS_URL_DOUBLE_QUOTE: Lazy<Regex> =
102 Lazy::new(|| Regex::new(r#"url\("(/[^"]+)"\)"#).expect("Invalid regex"));
103static CSS_URL_NO_QUOTE: Lazy<Regex> =
104 Lazy::new(|| Regex::new(r#"url\((/[^)]+)\)"#).expect("Invalid regex"));
105
106static JSON_PATH_REGEX: Lazy<Regex> =
107 Lazy::new(|| Regex::new(r#""(/[a-zA-Z0-9/_-]+)""#).expect("Invalid regex"));
108
109fn inject_base_tag(html: &str, prefix: &str) -> Result<String> {
112 let head_regex = Regex::new(r"(?i)<head[^>]*>")?;
114
115 if let Some(mat) = head_regex.find(html) {
116 let insert_pos = mat.end();
117 let base_tag = format!(r#"<base href="{}/""#, prefix);
118 let mut result = html.to_string();
119 result.insert_str(insert_pos, &base_tag);
120 return Ok(result);
121 }
122
123 let html_regex = Regex::new(r"(?i)<html[^>]*>")?;
125 if let Some(mat) = html_regex.find(html) {
126 let insert_pos = mat.end();
127 let base_tag = format!(r#"<head><base href="{}/""></head>"#, prefix);
128 let mut result = html.to_string();
129 result.insert_str(insert_pos, &base_tag);
130 return Ok(result);
131 }
132
133 warn!("Could not find <head> or <html> tag for base tag injection");
134 Ok(html.to_string())
135}
136
137fn inject_tunnel_context(html: &str, tunnel_id: &str) -> Result<String> {
140 let head_regex = Regex::new(r"(?i)<head[^>]*>")?;
141
142 let context_script = format!(
144 r#"<script>
145// HTTP Tunnel Context - provides tunnel ID for dynamic URL construction
146window.__TUNNEL_CONTEXT__ = {{
147 tunnelId: '{}',
148 basePath: '{}',
149 // Helper function to construct URLs with tunnel prefix
150 url: function(path) {{
151 if (!path) return this.basePath;
152 // Remove leading slash if present
153 const cleanPath = path.startsWith('/') ? path.substring(1) : path;
154 return this.basePath + '/' + cleanPath;
155 }},
156 // Get the full base URL including tunnel prefix
157 getBaseUrl: function() {{
158 return window.location.origin + this.basePath;
159 }}
160}};
161// Also set base path as a simple variable for backwards compatibility
162window.__TUNNEL_BASE_PATH__ = '{}';
163</script>"#,
164 tunnel_id, tunnel_id, tunnel_id
165 );
166
167 if let Some(mat) = head_regex.find(html) {
168 let insert_pos = mat.end();
169 let mut result = html.to_string();
170 result.insert_str(insert_pos, &context_script);
171 return Ok(result);
172 }
173
174 let html_regex = Regex::new(r"(?i)<html[^>]*>")?;
176 if let Some(mat) = html_regex.find(html) {
177 let insert_pos = mat.end();
178 let script_with_head = format!("<head>{}</head>", context_script);
179 let mut result = html.to_string();
180 result.insert_str(insert_pos, &script_with_head);
181 return Ok(result);
182 }
183
184 Ok(format!("{}{}", context_script, html))
186}
187
188fn rewrite_html(body: &str, prefix: &str) -> Result<String> {
190 let should_rewrite_path = |path: &str| -> bool {
192 if path.is_empty() || path.starts_with('#') {
200 return false;
201 }
202 if path.starts_with("http://")
203 || path.starts_with("https://")
204 || path.starts_with("//")
205 || path.starts_with("data:")
206 {
207 return false;
208 }
209 if path.starts_with(&format!("{}/", prefix)) || path == prefix {
211 return false;
212 }
213 true
214 };
215
216 let result = HTML_HREF_REGEX.replace_all(body, |caps: &Captures| {
218 let path = &caps[1];
219 if should_rewrite_path(path) {
220 format!(r#"href="{}{}""#, prefix, path)
221 } else {
222 caps[0].to_string()
223 }
224 });
225
226 let result = HTML_SRC_REGEX.replace_all(&result, |caps: &Captures| {
228 let path = &caps[1];
229 if should_rewrite_path(path) {
230 format!(r#"src="{}{}""#, prefix, path)
231 } else {
232 caps[0].to_string()
233 }
234 });
235
236 let result = HTML_ACTION_REGEX.replace_all(&result, |caps: &Captures| {
238 let path = &caps[1];
239 if should_rewrite_path(path) {
240 format!(r#"action="{}{}""#, prefix, path)
241 } else {
242 caps[0].to_string()
243 }
244 });
245
246 let result = rewrite_inline_javascript(&result, prefix)?;
249
250 let tunnel_id = prefix.trim_start_matches('/');
253 let result = inject_tunnel_context(&result, tunnel_id)?;
254
255 Ok(result)
256}
257
258fn rewrite_inline_javascript(html: &str, prefix: &str) -> Result<String> {
261 let js_single_quote = Regex::new(r#"'(/[a-zA-Z0-9/_\-\.]+)'"#)?;
264 let js_double_quote = Regex::new(r#""(/[a-zA-Z0-9/_\-\.]+)""#)?;
265
266 let should_rewrite_js_path = |path: &str| -> bool {
267 if path.len() < 2 {
270 return false;
271 }
272 if path.starts_with(&format!("{}/", prefix)) || path == prefix {
274 return false;
275 }
276 path.starts_with("/api")
278 || path.starts_with("/docs")
279 || path.starts_with("/openapi")
280 || path.starts_with("/swagger")
281 || path.starts_with("/v1")
282 || path.starts_with("/v2")
283 || path.starts_with("/v3")
284 || path.ends_with(".json")
285 || path.ends_with(".yaml")
286 || path.ends_with(".yml")
287 };
288
289 let result = js_single_quote.replace_all(html, |caps: &Captures| {
291 let path = &caps[1];
292 if should_rewrite_js_path(path) {
293 format!("'{}{}'", prefix, path)
294 } else {
295 caps[0].to_string()
296 }
297 });
298
299 let result = js_double_quote.replace_all(&result, |caps: &Captures| {
301 let path = &caps[1];
302 if should_rewrite_js_path(path) {
303 format!("\"{}{}\"", prefix, path)
304 } else {
305 caps[0].to_string()
306 }
307 });
308
309 Ok(result.into_owned())
310}
311
312fn rewrite_css(body: &str, prefix: &str) -> Result<String> {
314 let should_rewrite = |path: &str| -> bool {
315 !path.starts_with("http://")
316 && !path.starts_with("https://")
317 && !path.starts_with("//")
318 && !path.starts_with("data:")
319 && !path.starts_with(&format!("{}/", prefix))
320 };
321
322 let result = CSS_URL_SINGLE_QUOTE.replace_all(body, |caps: &Captures| {
324 let path = &caps[1];
325 if should_rewrite(path) {
326 format!("url('{}{}')", prefix, path)
327 } else {
328 caps[0].to_string()
329 }
330 });
331
332 let result = CSS_URL_DOUBLE_QUOTE.replace_all(&result, |caps: &Captures| {
334 let path = &caps[1];
335 if should_rewrite(path) {
336 format!("url(\"{}{}\")", prefix, path)
337 } else {
338 caps[0].to_string()
339 }
340 });
341
342 let result = CSS_URL_NO_QUOTE.replace_all(&result, |caps: &Captures| {
344 let path = caps[1].trim();
345 if path.starts_with('\'') || path.starts_with('"') || !should_rewrite(path) {
347 return caps[0].to_string();
348 }
349 format!("url({}{})", prefix, path)
350 });
351
352 Ok(result.into_owned())
353}
354
355fn rewrite_json(body: &str, prefix: &str) -> Result<String> {
359 let servers_regex = Regex::new(r#""servers"\s*:\s*\[\s*\{\s*"url"\s*:\s*"([^"]*)""#)?;
362
363 let result = servers_regex.replace_all(body, |caps: &Captures| {
364 let url = &caps[1];
365
366 if url.starts_with('/') && !url.starts_with(&format!("{}/", prefix)) {
368 format!(r#""servers": [{{"url": "{}{}""#, prefix, url)
369 } else if url.starts_with("http://") || url.starts_with("https://") {
370 caps[0].to_string()
372 } else {
373 caps[0].to_string()
374 }
375 });
376
377 let result = JSON_PATH_REGEX.replace_all(&result, |caps: &Captures| {
379 let path = &caps[1];
380
381 if path.len() < 2 {
386 return caps[0].to_string();
387 }
388 if path.starts_with(&format!("{}/", prefix)) || path == prefix {
389 return caps[0].to_string();
390 }
391 if path.contains("://") {
393 return caps[0].to_string();
394 }
395
396 let path_lower = path.to_lowercase();
399 if path_lower.starts_with("/api")
400 || path_lower.starts_with("/v1")
401 || path_lower.starts_with("/v2")
402 || path_lower.starts_with("/v3")
403 || path_lower.starts_with("/docs")
404 || path_lower.starts_with("/openapi")
405 || path_lower.starts_with("/swagger")
406 || path_lower.starts_with("/todos")
407 {
409 format!(r#""{}{}""#, prefix, path)
410 } else {
411 caps[0].to_string()
412 }
413 });
414
415 Ok(result.into_owned())
416}
417
418#[cfg(test)]
419mod tests {
420 use super::*;
421
422 #[test]
423 fn test_should_rewrite_content() {
424 assert!(should_rewrite_content("text/html"));
425 assert!(should_rewrite_content("text/html; charset=utf-8"));
426 assert!(should_rewrite_content("text/css"));
427 assert!(should_rewrite_content("application/json"));
428 assert!(should_rewrite_content("application/javascript"));
429 assert!(should_rewrite_content("text/javascript"));
430
431 assert!(!should_rewrite_content("image/png"));
432 assert!(!should_rewrite_content("application/octet-stream"));
433 assert!(!should_rewrite_content("video/mp4"));
434 }
435
436 #[test]
437 fn test_inject_base_tag() {
438 let html = r#"<html><head><title>Test</title></head><body></body></html>"#;
439 let result = inject_base_tag(html, "/abc123").unwrap();
440 assert!(result.contains(r#"<base href="/abc123/""#));
441 assert!(result.contains("<title>Test</title>"));
442 }
443
444 #[test]
445 fn test_inject_base_tag_no_head() {
446 let html = r#"<html><body>No head tag</body></html>"#;
447 let result = inject_base_tag(html, "/abc123").unwrap();
448 assert!(result.contains(r#"<base href="/abc123/""#));
449 }
450
451 #[test]
452 fn test_rewrite_html_href() {
453 let html = r#"<a href="/api/users">Users</a>"#;
454 let result = rewrite_html(html, "/abc123").unwrap();
455 assert!(result.contains(r#"<a href="/abc123/api/users">Users</a>"#));
456 assert!(result.contains("window.__TUNNEL_CONTEXT__"));
457 }
458
459 #[test]
460 fn test_rewrite_html_src() {
461 let html = r#"<img src="/images/logo.png">"#;
462 let result = rewrite_html(html, "/abc123").unwrap();
463 assert!(result.contains(r#"<img src="/abc123/images/logo.png">"#));
464 }
465
466 #[test]
467 fn test_rewrite_html_action() {
468 let html = r#"<form action="/submit">...</form>"#;
469 let result = rewrite_html(html, "/abc123").unwrap();
470 assert!(result.contains(r#"<form action="/abc123/submit">...</form>"#));
471 }
472
473 #[test]
474 fn test_dont_rewrite_external_url() {
475 let html = r#"<a href="https://example.com/page">External</a>"#;
476 let result = rewrite_html(html, "/abc123").unwrap();
477 assert!(result.contains(r#"href="https://example.com/page""#));
479 }
480
481 #[test]
482 fn test_dont_rewrite_protocol_relative_url() {
483 let html = r#"<script src="//cdn.example.com/script.js"></script>"#;
484 let result = rewrite_html(html, "/abc123").unwrap();
485 assert!(result.contains(r#"src="//cdn.example.com/script.js""#));
487 }
488
489 #[test]
490 fn test_dont_rewrite_data_url() {
491 let html = r#"<img src="data:image/png;base64,iVBOR...">"#;
492 let result = rewrite_html(html, "/abc123").unwrap();
493 assert!(result.contains(r#"src="data:image/png;base64,iVBOR...""#));
495 }
496
497 #[test]
498 fn test_dont_rewrite_anchor() {
499 let html = "<a href=\"#section\">Jump</a>";
500 let result = rewrite_html(html, "/abc123").unwrap();
501 assert!(result.contains("href=\"#section\""));
503 }
504
505 #[test]
506 fn test_dont_double_prefix() {
507 let html = r#"<a href="/abc123/api/users">Already prefixed</a>"#;
508 let result = rewrite_html(html, "/abc123").unwrap();
509 assert!(result.contains(r#"href="/abc123/api/users""#));
511 assert!(!result.contains(r#"href="/abc123/abc123/api/users""#));
512 }
513
514 #[test]
515 fn test_rewrite_css_url() {
516 let css = r#"background: url('/images/bg.png');"#;
517 let result = rewrite_css(css, "/abc123").unwrap();
518 assert_eq!(result, r#"background: url('/abc123/images/bg.png');"#);
519 }
520
521 #[test]
522 fn test_rewrite_css_url_no_quotes() {
523 let css = r#"background: url(/images/bg.png);"#;
524 let result = rewrite_css(css, "/abc123").unwrap();
525 assert_eq!(result, r#"background: url(/abc123/images/bg.png);"#);
526 }
527
528 #[test]
529 fn test_rewrite_css_url_double_quotes() {
530 let css = r#"background: url("/images/bg.png");"#;
531 let result = rewrite_css(css, "/abc123").unwrap();
532 assert_eq!(result, r#"background: url("/abc123/images/bg.png");"#);
533 }
534
535 #[test]
536 fn test_dont_rewrite_css_external_url() {
537 let css = r#"background: url('https://cdn.example.com/bg.png');"#;
538 let result = rewrite_css(css, "/abc123").unwrap();
539 assert_eq!(result, css);
540 }
541
542 #[test]
543 fn test_rewrite_json_api_path() {
544 let json = r#"{"url": "/api/users"}"#;
545 let result = rewrite_json(json, "/abc123").unwrap();
546 assert_eq!(result, r#"{"url": "/abc123/api/users"}"#);
547 }
548
549 #[test]
550 fn test_rewrite_json_versioned_api() {
551 let json = r#"{"baseUrl": "/v1/resources"}"#;
552 let result = rewrite_json(json, "/abc123").unwrap();
553 assert_eq!(result, r#"{"baseUrl": "/abc123/v1/resources"}"#);
554 }
555
556 #[test]
557 fn test_dont_rewrite_json_arbitrary_path() {
558 let json = r#"{"path": "/some/random/path"}"#;
559 let result = rewrite_json(json, "/abc123").unwrap();
560 assert_eq!(result, json);
562 }
563
564 #[test]
565 fn test_dont_rewrite_json_url_scheme() {
566 let json = r#"{"url": "https://example.com/api"}"#;
567 let result = rewrite_json(json, "/abc123").unwrap();
568 assert_eq!(result, json);
569 }
570
571 #[test]
572 fn test_rewrite_response_content_html_full() {
573 let html = r#"<html><head></head><body><a href="/api">API</a></body></html>"#;
574 let (result, rewritten) =
575 rewrite_response_content(html, "text/html", "abc123", RewriteStrategy::FullRewrite)
576 .unwrap();
577 assert!(rewritten);
578 assert!(result.contains(r#"href="/abc123/api""#));
579 }
580
581 #[test]
582 fn test_rewrite_response_content_html_base_tag() {
583 let html = r#"<html><head></head><body><a href="/api">API</a></body></html>"#;
584 let (result, rewritten) =
585 rewrite_response_content(html, "text/html", "abc123", RewriteStrategy::BaseTag)
586 .unwrap();
587 assert!(rewritten);
588 assert!(result.contains(r#"<base href="/abc123/""#));
589 }
590
591 #[test]
592 fn test_rewrite_response_content_no_rewrite_strategy() {
593 let html = r#"<a href="/api">API</a>"#;
594 let (result, rewritten) =
595 rewrite_response_content(html, "text/html", "abc123", RewriteStrategy::None).unwrap();
596 assert!(!rewritten);
597 assert_eq!(result, html);
598 }
599
600 #[test]
601 fn test_rewrite_response_content_css() {
602 let css = r#"div { background: url('/img/bg.png'); }"#;
603 let (result, rewritten) =
604 rewrite_response_content(css, "text/css", "abc123", RewriteStrategy::FullRewrite)
605 .unwrap();
606 assert!(rewritten);
607 assert!(result.contains("/abc123/img/bg.png"));
608 }
609
610 #[test]
611 fn test_rewrite_response_content_non_rewritable() {
612 let content = "binary data";
613 let (result, rewritten) =
614 rewrite_response_content(content, "image/png", "abc123", RewriteStrategy::FullRewrite)
615 .unwrap();
616 assert!(!rewritten);
617 assert_eq!(result, content);
618 }
619
620 #[test]
621 fn test_content_type_with_charset() {
622 assert!(should_rewrite_content("text/html; charset=utf-8"));
623 assert!(should_rewrite_content("application/json; charset=utf-8"));
624 assert!(should_rewrite_content(
625 "text/html; charset=utf-8; boundary=something"
626 ));
627 }
628
629 #[test]
630 fn test_rewrite_inline_javascript() {
631 let html = "<script>\nconst ui = { url: '/openapi.json', path: '/api/v1' };\n</script>";
632 let result = rewrite_html(html, "/abc123").unwrap();
633 assert!(result.contains("'/abc123/openapi.json'"));
634 assert!(result.contains("'/abc123/api/v1'"));
635 }
636
637 #[test]
638 fn test_rewrite_swagger_config() {
639 let html = r#"<script>
640 const ui = SwaggerUIBundle({
641 url: '/openapi.json',
642 oauth2RedirectUrl: window.location.origin + '/docs/oauth2-redirect',
643 })
644 </script>"#;
645 let result = rewrite_html(html, "/abc123").unwrap();
646 assert!(result.contains("url: '/abc123/openapi.json'"));
647 assert!(result.contains("+ '/abc123/docs/oauth2-redirect'"));
648 }
649
650 #[test]
651 fn test_dont_rewrite_short_js_paths() {
652 let html = "<script>const x = '/';</script>";
653 let result = rewrite_html(html, "/abc123").unwrap();
654 assert!(result.contains("const x = '/';"));
656 }
657
658 #[test]
659 fn test_inject_tunnel_context() {
660 let html = "<html><head></head><body></body></html>";
661 let result = rewrite_html(html, "/abc123").unwrap();
662 assert!(result.contains("window.__TUNNEL_CONTEXT__"));
664 assert!(result.contains("tunnelId: 'abc123'"));
665 assert!(result.contains("basePath: 'abc123'"));
666 assert!(result.contains("window.__TUNNEL_BASE_PATH__"));
667 }
668
669 #[test]
670 fn test_complex_html_document() {
671 let html = "<!DOCTYPE html>\n<html>\n<head>\n <title>Test Page</title>\n <link rel=\"stylesheet\" href=\"/static/style.css\">\n <script src=\"/static/app.js\"></script>\n</head>\n<body>\n <a href=\"/api/users\">Users</a>\n <a href=\"https://external.com\">External</a>\n <a href=\"#section\">Anchor</a>\n <img src=\"/images/logo.png\">\n <form action=\"/submit\" method=\"POST\">\n <input type=\"submit\">\n </form>\n</body>\n</html>";
672
673 let result = rewrite_html(html, "/abc123").unwrap();
674
675 assert!(result.contains("href=\"/abc123/static/style.css\""));
677 assert!(result.contains("src=\"/abc123/static/app.js\""));
678 assert!(result.contains("href=\"/abc123/api/users\""));
679 assert!(result.contains("src=\"/abc123/images/logo.png\""));
680 assert!(result.contains("action=\"/abc123/submit\""));
681
682 assert!(result.contains("href=\"https://external.com\""));
684 assert!(result.contains("href=\"#section\""));
685 }
686}