wsforge_core/static_files.rs
1//! Static file serving for hybrid HTTP/WebSocket servers.
2//!
3//! This module provides functionality to serve static files (HTML, CSS, JavaScript, images, etc.)
4//! alongside WebSocket connections on the same port. This allows you to build complete web
5//! applications where the frontend UI and WebSocket backend share a single server.
6//!
7//! # Overview
8//!
9//! The static file handler:
10//! - Serves files from a specified directory
11//! - Automatically detects MIME types
12//! - Handles index files (e.g., `index.html` for directory requests)
13//! - Prevents path traversal attacks
14//! - Supports percent-encoded URLs
15//! - Returns proper HTTP responses with status codes
16//!
17//! # Security
18//!
19//! The handler includes built-in security features:
20//! - **Path traversal prevention**: Attempts to access `../` or similar patterns are blocked
21//! - **Canonical path validation**: All paths are canonicalized and checked against the root
22//! - **Access control**: Only files within the configured root directory can be served
23//!
24//! # MIME Type Detection
25//!
26//! File types are automatically detected based on file extensions:
27//!
28//! | Extension | MIME Type | Use Case |
29//! |-----------|-----------|----------|
30//! | `.html` | `text/html` | Web pages |
31//! | `.css` | `text/css` | Stylesheets |
32//! | `.js` | `application/javascript` | JavaScript |
33//! | `.json` | `application/json` | JSON data |
34//! | `.png` | `image/png` | PNG images |
35//! | `.jpg`, `.jpeg` | `image/jpeg` | JPEG images |
36//! | `.svg` | `image/svg+xml` | SVG graphics |
37//! | `.wasm` | `application/wasm` | WebAssembly |
38//!
39//! # Architecture
40//!
41//! ```
42//! ┌─────────────────┐
43//! │ HTTP Request │
44//! └────────┬────────┘
45//! │
46//! ├──→ Parse URL path
47//! │
48//! ├──→ Decode percent-encoding
49//! │
50//! ├──→ Validate against root directory
51//! │
52//! ├──→ Check if directory → serve index.html
53//! │
54//! ├──→ Read file contents
55//! │
56//! └──→ Return HTTP response with MIME type
57//! ```
58//!
59//! # Examples
60//!
61//! ## Basic Static File Serving
62//!
63//! ```
64//! use wsforge::prelude::*;
65//!
66//! async fn ws_handler(msg: Message) -> Result<Message> {
67//! Ok(msg)
68//! }
69//!
70//! # async fn example() -> Result<()> {
71//! let router = Router::new()
72//! .serve_static("public") // Serve files from ./public directory
73//! .default_handler(handler(ws_handler));
74//!
75//! // Now accessible:
76//! // http://localhost:8080/ -> public/index.html
77//! // http://localhost:8080/app.js -> public/app.js
78//! // http://localhost:8080/style.css -> public/style.css
79//! // ws://localhost:8080 -> WebSocket handler
80//!
81//! router.listen("127.0.0.1:8080").await?;
82//! # Ok(())
83//! # }
84//! ```
85//!
86//! ## Web Chat Application
87//!
88//! ```
89//! use wsforge::prelude::*;
90//! use std::sync::Arc;
91//!
92//! async fn chat_handler(
93//! msg: Message,
94//! State(manager): State<Arc<ConnectionManager>>,
95//! ) -> Result<()> {
96//! manager.broadcast(msg);
97//! Ok(())
98//! }
99//!
100//! # async fn example() -> Result<()> {
101//! let router = Router::new()
102//! .serve_static("chat-ui") // Serve chat UI from ./chat-ui
103//! .default_handler(handler(chat_handler));
104//!
105//! // Directory structure:
106//! // chat-ui/
107//! // ├── index.html <- Main chat page
108//! // ├── app.js <- WebSocket client logic
109//! // ├── style.css <- Chat styling
110//! // └── assets/
111//! // └── logo.png <- Static assets
112//!
113//! router.listen("127.0.0.1:8080").await?;
114//! # Ok(())
115//! # }
116//! ```
117//!
118//! ## Single Page Application
119//!
120//! ```
121//! use wsforge::prelude::*;
122//!
123//! async fn api_handler(msg: Message) -> Result<Message> {
124//! // Handle API WebSocket messages
125//! Ok(msg)
126//! }
127//!
128//! # async fn example() -> Result<()> {
129//! let router = Router::new()
130//! .serve_static("dist") // Serve built SPA from ./dist
131//! .default_handler(handler(api_handler));
132//!
133//! // Typical SPA structure:
134//! // dist/
135//! // ├── index.html
136//! // ├── bundle.js
137//! // ├── styles.css
138//! // └── assets/
139//!
140//! router.listen("0.0.0.0:3000").await?;
141//! # Ok(())
142//! # }
143//! ```
144
145use crate::error::{Error, Result};
146use std::path::PathBuf;
147use tokio::fs::File;
148use tokio::io::AsyncReadExt;
149use tracing::{debug, warn};
150
151/// Handler for serving static files from a directory.
152///
153/// `StaticFileHandler` provides secure, efficient static file serving with
154/// automatic MIME type detection and directory index support.
155///
156/// # Security Features
157///
158/// - **Path traversal protection**: Prevents access to files outside the root directory
159/// - **Canonicalization**: All paths are resolved to their canonical form
160/// - **Access validation**: Only files under the configured root can be accessed
161///
162/// # Performance
163///
164/// - Files are read asynchronously using tokio's `AsyncReadExt`
165/// - No buffering overhead for small files
166/// - Efficient path resolution and validation
167///
168/// # Examples
169///
170/// ## Basic Usage
171///
172/// ```
173/// use wsforge::static_files::StaticFileHandler;
174/// use std::path::PathBuf;
175///
176/// # fn example() {
177/// let handler = StaticFileHandler::new(PathBuf::from("public"));
178///
179/// // Serve files from ./public directory
180/// // Handler will automatically serve index.html for directories
181/// # }
182/// ```
183///
184/// ## Custom Index File
185///
186/// ```
187/// use wsforge::static_files::StaticFileHandler;
188/// use std::path::PathBuf;
189///
190/// # fn example() {
191/// let handler = StaticFileHandler::new(PathBuf::from("public"))
192/// .with_index("default.html");
193///
194/// // Now directories will serve default.html instead of index.html
195/// # }
196/// ```
197///
198/// ## Serving Files
199///
200/// ```
201/// use wsforge::static_files::StaticFileHandler;
202/// use std::path::PathBuf;
203///
204/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
205/// let handler = StaticFileHandler::new(PathBuf::from("public"));
206///
207/// // Serve a specific file
208/// let (content, mime_type) = handler.serve("/app.js").await?;
209/// println!("Served {} bytes of {}", content.len(), mime_type);
210/// # Ok(())
211/// # }
212/// ```
213#[derive(Debug, Clone)]
214pub struct StaticFileHandler {
215 /// The root directory from which files are served
216 root: PathBuf,
217 /// The default file to serve for directory requests (e.g., "index.html")
218 index_file: String,
219}
220
221impl StaticFileHandler {
222 /// Creates a new static file handler for the given root directory.
223 ///
224 /// The root directory path should be relative to the current working directory
225 /// or an absolute path. Files will only be served from within this directory
226 /// and its subdirectories.
227 ///
228 /// # Arguments
229 ///
230 /// * `root` - Path to the root directory containing static files
231 ///
232 /// # Default Configuration
233 ///
234 /// - Index file: `index.html`
235 /// - MIME detection: Automatic based on file extension
236 ///
237 /// # Examples
238 ///
239 /// ## Relative Path
240 ///
241 /// ```
242 /// use wsforge::static_files::StaticFileHandler;
243 ///
244 /// # fn example() {
245 /// let handler = StaticFileHandler::new("public");
246 /// // Serves files from ./public
247 /// # }
248 /// ```
249 ///
250 /// ## Absolute Path
251 ///
252 /// ```
253 /// use wsforge::static_files::StaticFileHandler;
254 /// use std::path::PathBuf;
255 ///
256 /// # fn example() {
257 /// let handler = StaticFileHandler::new(PathBuf::from("/var/www/html"));
258 /// // Serves files from /var/www/html
259 /// # }
260 /// ```
261 ///
262 /// ## With PathBuf
263 ///
264 /// ```
265 /// use wsforge::static_files::StaticFileHandler;
266 /// use std::path::PathBuf;
267 ///
268 /// # fn example() {
269 /// let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
270 /// path.push("static");
271 /// let handler = StaticFileHandler::new(path);
272 /// # }
273 /// ```
274 pub fn new(root: impl Into<PathBuf>) -> Self {
275 Self {
276 root: root.into(),
277 index_file: "index.html".to_string(),
278 }
279 }
280
281 /// Sets the name of the index file to serve for directory requests.
282 ///
283 /// By default, this is `index.html`. Change this if your application uses
284 /// a different default file name.
285 ///
286 /// # Arguments
287 ///
288 /// * `index` - The name of the index file
289 ///
290 /// # Examples
291 ///
292 /// ## Custom Index
293 ///
294 /// ```
295 /// use wsforge::static_files::StaticFileHandler;
296 ///
297 /// # fn example() {
298 /// let handler = StaticFileHandler::new("public")
299 /// .with_index("default.html");
300 /// # }
301 /// ```
302 ///
303 /// ## Home Page
304 ///
305 /// ```
306 /// use wsforge::static_files::StaticFileHandler;
307 ///
308 /// # fn example() {
309 /// let handler = StaticFileHandler::new("public")
310 /// .with_index("home.html");
311 /// # }
312 /// ```
313 pub fn with_index(mut self, index: impl Into<String>) -> Self {
314 self.index_file = index.into();
315 self
316 }
317
318 /// Serves a file at the given path.
319 ///
320 /// This method:
321 /// 1. Decodes percent-encoded URLs
322 /// 2. Validates the path is within the root directory
323 /// 3. Checks if the path is a directory (serves index file if so)
324 /// 4. Reads the file contents
325 /// 5. Detects and returns the MIME type
326 ///
327 /// # Arguments
328 ///
329 /// * `path` - The requested path (e.g., "/app.js", "/images/logo.png")
330 ///
331 /// # Returns
332 ///
333 /// Returns a tuple of `(content, mime_type)` where:
334 /// - `content` is the raw file bytes
335 /// - `mime_type` is the detected MIME type as a string
336 ///
337 /// # Errors
338 ///
339 /// Returns an error if:
340 /// - The path is invalid or contains illegal characters
341 /// - The path escapes the root directory (security violation)
342 /// - The file does not exist
343 /// - The file cannot be read (permissions, etc.)
344 ///
345 /// # Security
346 ///
347 /// This method prevents path traversal attacks by:
348 /// - Canonicalizing both the requested path and root path
349 /// - Ensuring the canonical file path starts with the canonical root path
350 /// - Rejecting any path that would escape the root directory
351 ///
352 /// # Examples
353 ///
354 /// ## Basic File Serving
355 ///
356 /// ```
357 /// use wsforge::static_files::StaticFileHandler;
358 ///
359 /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
360 /// let handler = StaticFileHandler::new("public");
361 ///
362 /// let (content, mime_type) = handler.serve("/app.js").await?;
363 /// assert_eq!(mime_type, "application/javascript");
364 /// println!("Served {} bytes", content.len());
365 /// # Ok(())
366 /// # }
367 /// ```
368 ///
369 /// ## Directory Request
370 ///
371 /// ```
372 /// use wsforge::static_files::StaticFileHandler;
373 ///
374 /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
375 /// let handler = StaticFileHandler::new("public");
376 ///
377 /// // Request to "/" serves public/index.html
378 /// let (content, mime_type) = handler.serve("/").await?;
379 /// assert_eq!(mime_type, "text/html");
380 /// # Ok(())
381 /// # }
382 /// ```
383 ///
384 /// ## Error Handling
385 ///
386 /// ```
387 /// use wsforge::static_files::StaticFileHandler;
388 ///
389 /// # async fn example() {
390 /// let handler = StaticFileHandler::new("public");
391 ///
392 /// match handler.serve("/nonexistent.html").await {
393 /// Ok((content, mime_type)) => {
394 /// println!("Served {}", mime_type);
395 /// }
396 /// Err(e) => {
397 /// eprintln!("File not found: {}", e);
398 /// // Send 404 response
399 /// }
400 /// }
401 /// # }
402 /// ```
403 ///
404 /// ## Percent-Encoded Paths
405 ///
406 /// ```
407 /// use wsforge::static_files::StaticFileHandler;
408 ///
409 /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
410 /// let handler = StaticFileHandler::new("public");
411 ///
412 /// // Handles percent-encoded characters
413 /// let (content, _) = handler.serve("/my%20file.html").await?;
414 /// // Serves "public/my file.html"
415 /// # Ok(())
416 /// # }
417 /// ```
418 pub async fn serve(&self, path: &str) -> Result<(Vec<u8>, String)> {
419 let mut file_path = self.root.clone();
420
421 // Remove leading slash and decode percent-encoding
422 let clean_path = path.trim_start_matches('/');
423 let decoded = percent_encoding::percent_decode_str(clean_path)
424 .decode_utf8()
425 .map_err(|e| Error::custom(format!("Invalid path encoding: {}", e)))?;
426
427 file_path.push(decoded.as_ref());
428
429 // Security: prevent path traversal
430 let canonical = tokio::fs::canonicalize(&file_path)
431 .await
432 .map_err(|_| Error::custom("File not found"))?;
433
434 let root_canonical = tokio::fs::canonicalize(&self.root)
435 .await
436 .map_err(|e| Error::custom(format!("Invalid root directory: {}", e)))?;
437
438 if !canonical.starts_with(&root_canonical) {
439 warn!("Path traversal attempt: {:?}", path);
440 return Err(Error::custom("Access denied"));
441 }
442
443 // If it's a directory, serve index.html
444 if canonical.is_dir() {
445 file_path.push(&self.index_file);
446 }
447
448 debug!("Serving file: {:?}", file_path);
449
450 // Read file
451 let mut file = File::open(&file_path)
452 .await
453 .map_err(|_| Error::custom("File not found"))?;
454
455 let mut contents = Vec::new();
456 file.read_to_end(&mut contents)
457 .await
458 .map_err(|e| Error::custom(format!("Failed to read file: {}", e)))?;
459
460 // Determine MIME type
461 let mime_type = mime_guess::from_path(&file_path)
462 .first_or_octet_stream()
463 .to_string();
464
465 Ok((contents, mime_type))
466 }
467}
468
469/// Constructs an HTTP response with the given status, content type, and body.
470///
471/// This is a utility function for creating properly formatted HTTP/1.1 responses.
472/// The response includes standard headers and follows HTTP protocol conventions.
473///
474/// # Arguments
475///
476/// * `status` - HTTP status code (e.g., 200, 404, 500)
477/// * `content_type` - MIME type of the response body
478/// * `body` - The response body as bytes
479///
480/// # Returns
481///
482/// Returns a complete HTTP response as a byte vector, ready to be written to a socket.
483///
484/// # Response Format
485///
486/// ```
487/// HTTP/1.1 {status} {status_text}\r\n
488/// Content-Type: {content_type}\r\n
489/// Content-Length: {body_length}\r\n
490/// Connection: close\r\n
491/// \r\n
492/// {body}
493/// ```
494///
495/// # Status Codes
496///
497/// Common status codes:
498/// - `200 OK`: Successful request
499/// - `404 Not Found`: File not found
500/// - `500 Internal Server Error`: Server error
501///
502/// # Examples
503///
504/// ## Success Response
505///
506/// ```
507/// use wsforge::static_files::http_response;
508///
509/// # fn example() {
510/// let html = b"<html><body>Hello!</body></html>".to_vec();
511/// let response = http_response(200, "text/html", html);
512///
513/// // Response will be:
514/// // HTTP/1.1 200 OK
515/// // Content-Type: text/html
516/// // Content-Length: 32
517/// // Connection: close
518/// //
519/// // <html><body>Hello!</body></html>
520/// # }
521/// ```
522///
523/// ## 404 Not Found
524///
525/// ```
526/// use wsforge::static_files::http_response;
527///
528/// # fn example() {
529/// let html = b"<html><body><h1>404 Not Found</h1></body></html>".to_vec();
530/// let response = http_response(404, "text/html", html);
531/// # }
532/// ```
533///
534/// ## JSON Response
535///
536/// ```
537/// use wsforge::static_files::http_response;
538///
539/// # fn example() {
540/// let json = br#"{"error":"Not found"}"#.to_vec();
541/// let response = http_response(404, "application/json", json);
542/// # }
543/// ```
544///
545/// ## Binary Response
546///
547/// ```
548/// use wsforge::static_files::http_response;
549///
550/// # fn example() {
551/// let image_data = vec![0x89, 0x50, 0x4E, 0x47]; // PNG header
552/// let response = http_response(200, "image/png", image_data);
553/// # }
554/// ```
555///
556/// ## Server Error
557///
558/// ```
559/// use wsforge::static_files::http_response;
560///
561/// # fn example() {
562/// let error_html = b"<html><body><h1>500 Internal Server Error</h1></body></html>".to_vec();
563/// let response = http_response(500, "text/html", error_html);
564/// # }
565/// ```
566pub fn http_response(status: u16, content_type: &str, body: Vec<u8>) -> Vec<u8> {
567 let status_text = match status {
568 200 => "OK",
569 404 => "Not Found",
570 500 => "Internal Server Error",
571 _ => "Unknown",
572 };
573
574 let response = format!(
575 "HTTP/1.1 {} {}\r\n\
576 Content-Type: {}\r\n\
577 Content-Length: {}\r\n\
578 Connection: close\r\n\
579 \r\n",
580 status,
581 status_text,
582 content_type,
583 body.len()
584 );
585
586 let mut result = response.into_bytes();
587 result.extend_from_slice(&body);
588 result
589}