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}