rush_sync_server/server/handlers/web/
server.rs1use 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 if file_path.is_dir() {
28 file_path = file_path.join("index.html");
29 }
30
31 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 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 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 let script_tag = r#"<script defer src="/rss.js"></script>"#;
175 let css_link = r#"<link rel="stylesheet" href="/.rss/_reset.css">"#;
176
177 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 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 #[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&b");
211 }
212
213 #[test]
214 fn test_html_escape_tags() {
215 assert_eq!(html_escape("<script>"), "<script>");
216 }
217
218 #[test]
219 fn test_html_escape_quotes() {
220 assert_eq!(html_escape(r#"a"b'c"#), "a"b'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 #[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}