Skip to main content

rush_sync_server/server/handlers/web/
server.rs

1use super::ServerDataWithConfig;
2use crate::core::helpers::html_escape;
3use actix_web::{web, HttpRequest, HttpResponse, Result as ActixResult};
4
5pub async fn serve_fallback_or_inject(
6    req: HttpRequest,
7    data: web::Data<ServerDataWithConfig>,
8) -> ActixResult<HttpResponse> {
9    let path = req.path();
10    log::info!("Requested path: {}", path);
11
12    let base_dir = crate::core::helpers::get_base_dir().map_err(|e| {
13        log::error!("Failed to get base directory: {}", e);
14        actix_web::error::ErrorInternalServerError("Internal server error")
15    })?;
16    let server_dir = base_dir
17        .join("www")
18        .join(format!("{}-[{}]", data.server.name, data.server.port));
19
20    let mut file_path = if path == "/" {
21        server_dir.join("index.html")
22    } else {
23        server_dir.join(path.trim_start_matches('/'))
24    };
25
26    // Resolve directory paths to index.html
27    if file_path.is_dir() {
28        file_path = file_path.join("index.html");
29    }
30
31    // Path traversal protection: ensure resolved path stays within server_dir
32    let canonical_server_dir = server_dir
33        .canonicalize()
34        .unwrap_or_else(|_| server_dir.clone());
35    if let Ok(canonical_file) = file_path.canonicalize() {
36        if !canonical_file.starts_with(&canonical_server_dir) {
37            log::warn!("Path traversal attempt blocked: {}", path);
38            return Ok(HttpResponse::Forbidden()
39                .content_type("text/plain")
40                .body("Forbidden"));
41        }
42    }
43
44    log::info!("Looking for file: {:?}", file_path);
45
46    if file_path.exists() {
47        if let Some(extension) = file_path.extension() {
48            if extension == "html" {
49                log::info!("Loading custom HTML file");
50                match tokio::fs::read_to_string(&file_path).await {
51                    Ok(mut html_content) => {
52                        if !html_content.contains("/rss.js") {
53                            html_content = inject_rss_script(html_content);
54                        }
55
56                        return Ok(HttpResponse::Ok()
57                            .content_type("text/html; charset=utf-8")
58                            .body(html_content));
59                    }
60                    Err(e) => {
61                        log::error!("Failed to read HTML file: {}", e);
62                    }
63                }
64            } else {
65                log::info!("Serving static file: {:?}", file_path);
66                match tokio::fs::read(&file_path).await {
67                    Ok(content) => {
68                        let content_type = match extension.to_str() {
69                            Some("css") => "text/css",
70                            Some("js") => "application/javascript",
71                            Some("png") => "image/png",
72                            Some("jpg" | "jpeg") => "image/jpeg",
73                            Some("svg") => "image/svg+xml",
74                            Some("gif") => "image/gif",
75                            Some("ico") => "image/x-icon",
76                            Some("webp") => "image/webp",
77                            Some("webm") => "video/webm",
78                            Some("mp4") => "video/mp4",
79                            Some("woff2") => "font/woff2",
80                            Some("woff") => "font/woff",
81                            Some("json") => "application/json",
82                            Some("xml") => "application/xml",
83                            Some("pdf") => "application/pdf",
84                            Some("txt" | "md") => "text/plain; charset=utf-8",
85                            _ => "application/octet-stream",
86                        };
87
88                        return Ok(HttpResponse::Ok().content_type(content_type).body(content));
89                    }
90                    Err(e) => {
91                        log::error!("Failed to read file: {}", e);
92                    }
93                }
94            }
95        }
96    }
97
98    // Check for custom 404 page (works for both "/" and other paths)
99    let settings = crate::server::settings::ServerSettings::load(&server_dir);
100    if settings.custom_404_enabled {
101        let custom_404 = server_dir.join(&settings.custom_404_path);
102        if custom_404.exists() {
103            if let Ok(html) = tokio::fs::read_to_string(&custom_404).await {
104                let html = if !html.contains("/rss.js") {
105                    inject_rss_script(html)
106                } else {
107                    html
108                };
109                let mut status = if path == "/" {
110                    HttpResponse::Ok()
111                } else {
112                    HttpResponse::NotFound()
113                };
114                return Ok(status
115                    .content_type("text/html; charset=utf-8")
116                    .body(html));
117            }
118        }
119    }
120
121    if path == "/" {
122        log::info!("Serving system fallback dashboard");
123        serve_system_fallback(&req, data).await
124    } else {
125        log::info!("File not found: {}", path);
126        Ok(HttpResponse::NotFound()
127            .content_type("text/plain")
128            .body("File not found"))
129    }
130}
131
132async fn serve_system_fallback(
133    req: &HttpRequest,
134    data: web::Data<ServerDataWithConfig>,
135) -> ActixResult<HttpResponse> {
136    // PIN check for dashboard fallback at /
137    let server_dir =
138        crate::server::settings::ServerSettings::get_server_dir(&data.server.name, data.server.port);
139    if let Some(ref dir) = server_dir {
140        let settings = crate::server::settings::ServerSettings::load(dir);
141        if settings.pin_enabled && !settings.pin_code.is_empty() {
142            let expected_token = format!("rss-pin-{}-{}", data.server.name, data.server.port);
143            let has_valid_cookie = req
144                .cookie("rss_pin")
145                .map(|c| c.value() == expected_token)
146                .unwrap_or(false);
147            if !has_valid_cookie {
148                return Ok(HttpResponse::Found()
149                    .insert_header(("Location", "/.rss/"))
150                    .finish());
151            }
152        }
153    }
154
155    let template = include_str!("../templates/rss/dashboard.html");
156
157    let html_content = template
158        .replace("{{SERVER_NAME}}", &html_escape(&data.server.name))
159        .replace("{{PORT}}", &data.server.port.to_string())
160        .replace("{{PROXY_PORT}}", &data.proxy_http_port.to_string())
161        .replace("{{PROXY_HTTPS_PORT}}", &data.proxy_https_port.to_string())
162        .replace("{{VERSION}}", crate::server::config::get_server_version())
163        .replace("{{CREATION_TIME}}", &chrono::Local::now().to_rfc3339());
164
165    let html_with_script = inject_rss_script(html_content);
166
167    Ok(HttpResponse::Ok()
168        .content_type("text/html; charset=utf-8")
169        .body(html_with_script))
170}
171
172pub fn inject_rss_script(html: String) -> String {
173    // ES6 module script injection
174    let script_tag = r#"<script defer src="/rss.js"></script>"#;
175    let css_link = r#"<link rel="stylesheet" href="/.rss/_reset.css">"#;
176
177    // Insert CSS into <head>
178    let html_with_css = if let Some(head_end) = html.find("</head>") {
179        let (before, after) = html.split_at(head_end);
180        format!("{}\n    {}\n{}", before, css_link, after)
181    } else {
182        format!("{}\n{}", css_link, html)
183    };
184
185    // Insert JS module before </body>
186    if let Some(body_end) = html_with_css.rfind("</body>") {
187        let (before, after) = html_with_css.split_at(body_end);
188        format!("{}\n    {}\n{}", before, script_tag, after)
189    } else if let Some(html_end) = html_with_css.rfind("</html>") {
190        let (before, after) = html_with_css.split_at(html_end);
191        format!("{}\n{}\n{}", before, script_tag, after)
192    } else {
193        format!("{}\n{}", html_with_css, script_tag)
194    }
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200
201    // --- html_escape tests ---
202
203    #[test]
204    fn test_html_escape_basic() {
205        assert_eq!(html_escape("hello"), "hello");
206    }
207
208    #[test]
209    fn test_html_escape_ampersand() {
210        assert_eq!(html_escape("a&b"), "a&amp;b");
211    }
212
213    #[test]
214    fn test_html_escape_tags() {
215        assert_eq!(html_escape("<script>"), "&lt;script&gt;");
216    }
217
218    #[test]
219    fn test_html_escape_quotes() {
220        assert_eq!(html_escape(r#"a"b'c"#), "a&quot;b&#x27;c");
221    }
222
223    #[test]
224    fn test_html_escape_xss_payload() {
225        let payload = r#"<img onerror="alert('xss')" src=x>"#;
226        let escaped = html_escape(payload);
227        assert!(!escaped.contains('<'));
228        assert!(!escaped.contains('>'));
229        assert!(!escaped.contains('"'));
230    }
231
232    #[test]
233    fn test_html_escape_empty() {
234        assert_eq!(html_escape(""), "");
235    }
236
237    #[test]
238    fn test_html_escape_safe_names() {
239        assert_eq!(html_escape("my-server-01"), "my-server-01");
240        assert_eq!(html_escape("test_server"), "test_server");
241    }
242
243    // --- inject_rss_script tests ---
244
245    #[test]
246    fn test_inject_script_with_head_and_body() {
247        let html = "<html><head><title>Test</title></head><body><h1>Hi</h1></body></html>";
248        let result = inject_rss_script(html.to_string());
249        assert!(result.contains(r#"<link rel="stylesheet" href="/.rss/_reset.css">"#));
250        assert!(result.contains(r#"<script defer src="/rss.js"></script>"#));
251    }
252
253    #[test]
254    fn test_inject_script_css_before_head_close() {
255        let html = "<html><head></head><body></body></html>";
256        let result = inject_rss_script(html.to_string());
257        let css_pos = result.find("_reset.css").unwrap();
258        let head_end_pos = result.find("</head>").unwrap();
259        assert!(css_pos < head_end_pos);
260    }
261
262    #[test]
263    fn test_inject_script_js_before_body_close() {
264        let html = "<html><head></head><body><p>content</p></body></html>";
265        let result = inject_rss_script(html.to_string());
266        let js_pos = result.find("rss.js").unwrap();
267        let body_end_pos = result.find("</body>").unwrap();
268        assert!(js_pos < body_end_pos);
269    }
270
271    #[test]
272    fn test_inject_script_no_body_tag() {
273        let html = "<html><head></head></html>";
274        let result = inject_rss_script(html.to_string());
275        assert!(result.contains("rss.js"));
276        let js_pos = result.find("rss.js").unwrap();
277        let html_end_pos = result.find("</html>").unwrap();
278        assert!(js_pos < html_end_pos);
279    }
280
281    #[test]
282    fn test_inject_script_minimal_html() {
283        let html = "<h1>Hello</h1>";
284        let result = inject_rss_script(html.to_string());
285        assert!(result.contains("rss.js"));
286        assert!(result.contains("_reset.css"));
287    }
288
289    #[test]
290    fn test_inject_script_no_double_inject() {
291        let html = "<html><head></head><body></body></html>";
292        let result = inject_rss_script(html.to_string());
293        assert_eq!(result.matches("rss.js").count(), 1);
294        assert_eq!(result.matches("_reset.css").count(), 1);
295    }
296}