// Copyright 2024 RustFS Team // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //! Static Website Hosting for Swift Containers //! //! This module implements static website hosting, allowing Swift containers //! to serve static websites directly without requiring an external web server. //! //! # Features //! //! - **Index Documents**: Serve default index.html for directory requests //! - **Error Documents**: Custom 404 error pages //! - **Directory Listings**: Auto-generated HTML listings (optional) //! - **Custom CSS**: Style directory listings with custom CSS //! //! # Configuration //! //! Static website hosting is configured via container metadata: //! //! - `X-Container-Meta-Web-Index` - Index document name (e.g., "index.html") //! - `X-Container-Meta-Web-Error` - Error document name (e.g., "404.html") //! - `X-Container-Meta-Web-Listings` - Enable directory listings ("true"/"false") //! - `X-Container-Meta-Web-Listings-CSS` - CSS file path for listings styling //! //! # Examples //! //! ```bash //! # Enable static website hosting //! swift post my-website \ //! -m "web-index:index.html" \ //! -m "web-error:404.html" \ //! -m "web-listings:true" //! //! # Upload website files //! swift upload my-website index.html //! swift upload my-website 404.html //! swift upload my-website style.css //! //! # Access website //! curl http://swift.example.com/v1/AUTH_account/my-website/ //! # Returns: index.html //! ``` //! //! # Path Resolution //! //! 1. **Root or directory path** (ends with `/`): //! - Serve index document if configured //! - Otherwise, generate directory listing if enabled //! - Otherwise, return 404 //! //! 2. **File path**: //! - Serve the file if it exists //! - If not found and error document configured, serve error document //! - Otherwise, return standard 404 //! //! 3. **Directory without trailing slash**: //! - Redirect to path with trailing slash (301) use super::{SwiftError, SwiftResult, container, object}; use axum::http::{Response, StatusCode}; use rustfs_credentials::Credentials; use s3s::Body; use tracing::debug; const LOG_COMPONENT_PROTOCOLS: &str = "protocols"; const LOG_SUBSYSTEM_SWIFT_STATICWEB: &str = "swift_staticweb"; const EVENT_SWIFT_STATICWEB_STATE: &str = "swift_staticweb_state"; /// Static website configuration for a container #[derive(Debug, Clone, Default)] pub struct StaticWebConfig { /// Index document name (e.g., "index.html") pub index: Option, /// Error document name (e.g., "404.html") pub error: Option, /// Enable directory listings pub listings: bool, /// CSS file path for directory listings pub listings_css: Option, } impl StaticWebConfig { /// Check if static web is enabled (has index document configured) pub fn is_enabled(&self) -> bool { self.index.is_some() } /// Get index document name pub fn index_document(&self) -> Option<&str> { self.index.as_deref() } /// Get error document name pub fn error_document(&self) -> Option<&str> { self.error.as_deref() } /// Check if directory listings are enabled pub fn listings_enabled(&self) -> bool { self.listings } /// Get listings CSS path pub fn listings_css_path(&self) -> Option<&str> { self.listings_css.as_deref() } } /// Load static website configuration from container metadata pub async fn load_config(account: &str, container: &str, credentials: &Credentials) -> SwiftResult { let metadata = container::get_container_metadata(account, container, credentials).await?; let index = metadata.custom_metadata.get("web-index").cloned(); let error = metadata.custom_metadata.get("web-error").cloned(); let listings = metadata .custom_metadata .get("web-listings") .map(|v| v.to_lowercase() == "true") .unwrap_or(false); let listings_css = metadata.custom_metadata.get("web-listings-css").cloned(); Ok(StaticWebConfig { index, error, listings, listings_css, }) } /// Check if static website hosting is enabled for a container pub async fn is_enabled(account: &str, container: &str, credentials: &Credentials) -> SwiftResult { let config = load_config(account, container, credentials).await?; Ok(config.is_enabled()) } /// Detect MIME type from file extension pub fn detect_content_type(path: &str) -> &'static str { let extension = path.rsplit('.').next().unwrap_or(""); match extension.to_lowercase().as_str() { // HTML/XML "html" | "htm" => "text/html; charset=utf-8", "xml" => "application/xml; charset=utf-8", "xhtml" => "application/xhtml+xml; charset=utf-8", // CSS/JavaScript "css" => "text/css; charset=utf-8", "js" => "application/javascript; charset=utf-8", "mjs" => "application/javascript; charset=utf-8", "json" => "application/json; charset=utf-8", // Images "png" => "image/png", "jpg" | "jpeg" => "image/jpeg", "gif" => "image/gif", "svg" => "image/svg+xml", "webp" => "image/webp", "ico" => "image/x-icon", // Fonts "woff" => "font/woff", "woff2" => "font/woff2", "ttf" => "font/ttf", "otf" => "font/otf", "eot" => "application/vnd.ms-fontobject", // Documents "pdf" => "application/pdf", "txt" => "text/plain; charset=utf-8", "md" => "text/markdown; charset=utf-8", // Media "mp4" => "video/mp4", "webm" => "video/webm", "mp3" => "audio/mpeg", "wav" => "audio/wav", "ogg" => "audio/ogg", // Archives "zip" => "application/zip", "gz" => "application/gzip", "tar" => "application/x-tar", // Default _ => "application/octet-stream", } } /// Normalize path for static web serving /// /// - Remove leading slash /// - Treat empty path as root "/" /// - Preserve trailing slash pub fn normalize_path(path: &str) -> String { let path = path.trim_start_matches('/'); if path.is_empty() { String::new() } else { path.to_string() } } /// Check if path represents a directory (ends with /) pub fn is_directory_path(path: &str) -> bool { path.is_empty() || path.ends_with('/') } /// Resolve the actual object path to serve /// /// Returns (object_path, is_index, is_listing) pub fn resolve_path(path: &str, config: &StaticWebConfig) -> (String, bool, bool) { let normalized = normalize_path(path); if is_directory_path(&normalized) { // Directory path if let Some(index) = config.index_document() { // Serve index document let index_path = if normalized.is_empty() { index.to_string() } else { format!("{}{}", normalized, index) }; (index_path, true, false) } else if config.listings_enabled() { // Generate directory listing (normalized, false, true) } else { // No index, no listings - 404 (normalized, false, false) } } else { // File path - serve directly (normalized, false, false) } } /// Generate breadcrumb navigation HTML fn generate_breadcrumbs(path: &str, container: &str) -> String { let mut html = String::from("
\n"); html.push_str(&format!(" /{}\n", container)); if !path.is_empty() { let parts: Vec<&str> = path.trim_end_matches('/').split('/').collect(); let mut current_path = String::new(); for (i, part) in parts.iter().enumerate() { current_path.push_str(part); if i < parts.len() - 1 { current_path.push('/'); html.push_str(&format!(" / {}\n", current_path, part)); } else { html.push_str(&format!(" / {}\n", part)); } } } html.push_str("
\n"); html } /// Generate HTML directory listing pub fn generate_directory_listing( container: &str, path: &str, objects: &[super::types::Object], css_path: Option<&str>, ) -> String { let mut html = String::from("\n\n\n"); html.push_str(" \n"); html.push_str(&format!(" Index of /{}\n", path)); if let Some(css) = css_path { html.push_str(&format!(" \n", css)); } else { // Default inline CSS html.push_str(" \n"); } html.push_str("\n\n"); html.push_str(&format!("

Index of /{}

\n", path)); html.push_str(&generate_breadcrumbs(path, container)); html.push_str(" \n"); html.push_str(" \n"); html.push_str(" \n"); html.push_str(" \n"); html.push_str(" \n"); html.push_str(" \n"); html.push_str(" \n"); html.push_str(" \n"); html.push_str(" \n"); // Add parent directory link if not at root if !path.is_empty() { let parent_path = if path.contains('/') { let parts: Vec<&str> = path.trim_end_matches('/').split('/').collect(); parts[..parts.len() - 1].join("/") + "/" } else { String::from("") }; html.push_str(" \n"); html.push_str(&format!(" \n", parent_path)); html.push_str(" \n"); html.push_str(" \n"); html.push_str(" \n"); } // List objects for obj in objects { let name = if let Some(stripped) = obj.name.strip_prefix(path) { stripped } else { &obj.name }; // Format size let size = if obj.bytes < 1024 { format!("{} B", obj.bytes) } else if obj.bytes < 1024 * 1024 { format!("{:.1} KB", obj.bytes as f64 / 1024.0) } else if obj.bytes < 1024 * 1024 * 1024 { format!("{:.1} MB", obj.bytes as f64 / (1024.0 * 1024.0)) } else { format!("{:.1} GB", obj.bytes as f64 / (1024.0 * 1024.0 * 1024.0)) }; // Format date - last_modified is a String already let date = &obj.last_modified; html.push_str(" \n"); html.push_str(&format!(" \n", obj.name, name)); html.push_str(&format!(" \n", size)); html.push_str(&format!(" \n", date)); html.push_str(" \n"); } html.push_str(" \n"); html.push_str("
NameSizeLast Modified
..--
{}{}{}
\n"); html.push_str("\n\n"); html } /// Handle static website GET request pub async fn handle_static_web_get( account: &str, container: &str, path: &str, credentials: &Credentials, ) -> SwiftResult> { // Load configuration let config = load_config(account, container, credentials).await?; if !config.is_enabled() { return Err(SwiftError::InternalServerError("Static web not enabled for this container".to_string())); } debug!( event = EVENT_SWIFT_STATICWEB_STATE, component = LOG_COMPONENT_PROTOCOLS, subsystem = LOG_SUBSYSTEM_SWIFT_STATICWEB, state = "requested", container = %container, path = %path, listings_enabled = config.listings_enabled(), index_document = config.index_document().unwrap_or_default(), error_document = config.error_document().unwrap_or_default(), "swift staticweb state changed" ); // Resolve path let (object_path, _is_index, is_listing) = resolve_path(path, &config); if is_listing { // Generate directory listing debug!( event = EVENT_SWIFT_STATICWEB_STATE, component = LOG_COMPONENT_PROTOCOLS, subsystem = LOG_SUBSYSTEM_SWIFT_STATICWEB, state = "listing_generated", container = %container, path = %object_path, "swift staticweb state changed" ); // List objects with prefix let prefix = if object_path.is_empty() { None } else { Some(object_path.to_string()) }; let objects = container::list_objects( account, container, credentials, None, // limit (i32) None, // marker prefix, None, // delimiter ) .await?; // Generate HTML let html = generate_directory_listing(container, &object_path, &objects, config.listings_css_path()); // Build response let trans_id = super::handler::generate_trans_id(); return Response::builder() .status(StatusCode::OK) .header("content-type", "text/html; charset=utf-8") .header("content-length", html.len().to_string()) .header("x-trans-id", trans_id.clone()) .header("x-openstack-request-id", trans_id) .body(Body::from(html)) .map_err(|e| SwiftError::InternalServerError(format!("Failed to build response: {}", e))); } // Try to serve the object debug!( event = EVENT_SWIFT_STATICWEB_STATE, component = LOG_COMPONENT_PROTOCOLS, subsystem = LOG_SUBSYSTEM_SWIFT_STATICWEB, state = "serving_object", container = %container, path = %object_path, "swift staticweb state changed" ); match object::get_object(account, container, &object_path, credentials, None).await { Ok(reader) => { // Object exists - serve it let content_type = detect_content_type(&object_path); let trans_id = super::handler::generate_trans_id(); let response = Response::builder() .status(StatusCode::OK) .header("content-type", content_type) .header("x-trans-id", trans_id.clone()) .header("x-openstack-request-id", trans_id); // Convert reader to body use tokio_util::io::ReaderStream; let stream = ReaderStream::new(reader.stream); let axum_body = axum::body::Body::from_stream(stream); let body = Body::http_body_unsync(axum_body); response .body(body) .map_err(|e| SwiftError::InternalServerError(format!("Failed to build response: {}", e))) } Err(SwiftError::NotFound(_)) => { // Object not found - try to serve error document if let Some(error_doc) = config.error_document() { debug!( event = EVENT_SWIFT_STATICWEB_STATE, component = LOG_COMPONENT_PROTOCOLS, subsystem = LOG_SUBSYSTEM_SWIFT_STATICWEB, state = "serving_error_document", container = %container, path = %object_path, error_document = %error_doc, "swift staticweb state changed" ); match object::get_object(account, container, error_doc, credentials, None).await { Ok(reader) => { let content_type = detect_content_type(error_doc); let trans_id = super::handler::generate_trans_id(); let response = Response::builder() .status(StatusCode::NOT_FOUND) .header("content-type", content_type) .header("x-trans-id", trans_id.clone()) .header("x-openstack-request-id", trans_id); use tokio_util::io::ReaderStream; let stream = ReaderStream::new(reader.stream); let axum_body = axum::body::Body::from_stream(stream); let body = Body::http_body_unsync(axum_body); return response .body(body) .map_err(|e| SwiftError::InternalServerError(format!("Failed to build response: {}", e))); } Err(_) => { // Error document also not found - return standard 404 debug!( event = EVENT_SWIFT_STATICWEB_STATE, component = LOG_COMPONENT_PROTOCOLS, subsystem = LOG_SUBSYSTEM_SWIFT_STATICWEB, result = "error_document_missing", container = %container, error_document = %error_doc, "swift staticweb state changed" ); } } } // Return standard 404 let trans_id = super::handler::generate_trans_id(); Response::builder() .status(StatusCode::NOT_FOUND) .header("content-type", "text/plain; charset=utf-8") .header("x-trans-id", trans_id.clone()) .header("x-openstack-request-id", trans_id) .body(Body::from("404 Not Found\n".to_string())) .map_err(|e| SwiftError::InternalServerError(format!("Failed to build response: {}", e))) } Err(e) => Err(e), } } #[cfg(test)] mod tests { use super::*; #[test] fn test_static_web_config_enabled() { let config = StaticWebConfig { index: Some("index.html".to_string()), error: None, listings: false, listings_css: None, }; assert!(config.is_enabled()); assert_eq!(config.index_document(), Some("index.html")); assert_eq!(config.error_document(), None); assert!(!config.listings_enabled()); } #[test] fn test_static_web_config_disabled() { let config = StaticWebConfig::default(); assert!(!config.is_enabled()); } #[test] fn test_detect_content_type() { assert_eq!(detect_content_type("index.html"), "text/html; charset=utf-8"); assert_eq!(detect_content_type("style.css"), "text/css; charset=utf-8"); assert_eq!(detect_content_type("app.js"), "application/javascript; charset=utf-8"); assert_eq!(detect_content_type("image.png"), "image/png"); assert_eq!(detect_content_type("image.jpg"), "image/jpeg"); assert_eq!(detect_content_type("font.woff2"), "font/woff2"); assert_eq!(detect_content_type("data.json"), "application/json; charset=utf-8"); assert_eq!(detect_content_type("unknown.xyz"), "application/octet-stream"); } #[test] fn test_normalize_path() { assert_eq!(normalize_path(""), ""); assert_eq!(normalize_path("/"), ""); assert_eq!(normalize_path("/index.html"), "index.html"); assert_eq!(normalize_path("index.html"), "index.html"); assert_eq!(normalize_path("/docs/"), "docs/"); assert_eq!(normalize_path("docs/"), "docs/"); } #[test] fn test_is_directory_path() { assert!(is_directory_path("")); assert!(is_directory_path("/")); assert!(is_directory_path("docs/")); assert!(is_directory_path("/docs/")); assert!(!is_directory_path("index.html")); assert!(!is_directory_path("/index.html")); assert!(!is_directory_path("docs")); } #[test] fn test_resolve_path_root_with_index() { let config = StaticWebConfig { index: Some("index.html".to_string()), error: None, listings: false, listings_css: None, }; let (path, is_index, is_listing) = resolve_path("", &config); assert_eq!(path, "index.html"); assert!(is_index); assert!(!is_listing); let (path, is_index, is_listing) = resolve_path("/", &config); assert_eq!(path, "index.html"); assert!(is_index); assert!(!is_listing); } #[test] fn test_resolve_path_directory_with_index() { let config = StaticWebConfig { index: Some("index.html".to_string()), error: None, listings: false, listings_css: None, }; let (path, is_index, is_listing) = resolve_path("docs/", &config); assert_eq!(path, "docs/index.html"); assert!(is_index); assert!(!is_listing); } #[test] fn test_resolve_path_file() { let config = StaticWebConfig { index: Some("index.html".to_string()), error: None, listings: false, listings_css: None, }; let (path, is_index, is_listing) = resolve_path("style.css", &config); assert_eq!(path, "style.css"); assert!(!is_index); assert!(!is_listing); let (path, is_index, is_listing) = resolve_path("/docs/readme.txt", &config); assert_eq!(path, "docs/readme.txt"); assert!(!is_index); assert!(!is_listing); } #[test] fn test_resolve_path_directory_with_listings() { let config = StaticWebConfig { index: None, error: None, listings: true, listings_css: None, }; let (path, is_index, is_listing) = resolve_path("docs/", &config); assert_eq!(path, "docs/"); assert!(!is_index); assert!(is_listing); } #[test] fn test_resolve_path_directory_no_index_no_listings() { let config = StaticWebConfig { index: None, error: None, listings: false, listings_css: None, }; let (path, is_index, is_listing) = resolve_path("docs/", &config); assert_eq!(path, "docs/"); assert!(!is_index); assert!(!is_listing); } #[test] fn test_generate_breadcrumbs() { let html = generate_breadcrumbs("", "my-website"); assert!(html.contains("/my-website")); let html = generate_breadcrumbs("docs/", "my-website"); assert!(html.contains("/my-website")); assert!(html.contains("docs")); let html = generate_breadcrumbs("docs/api/", "my-website"); assert!(html.contains("/my-website")); assert!(html.contains("docs")); assert!(html.contains("api")); } // Additional comprehensive tests #[test] fn test_content_type_comprehensive() { // Text formats assert_eq!(detect_content_type("file.txt"), "text/plain; charset=utf-8"); assert_eq!(detect_content_type("README.md"), "text/markdown; charset=utf-8"); assert_eq!(detect_content_type("data.xml"), "application/xml; charset=utf-8"); // Web formats assert_eq!(detect_content_type("page.xhtml"), "application/xhtml+xml; charset=utf-8"); assert_eq!(detect_content_type("module.mjs"), "application/javascript; charset=utf-8"); // Images assert_eq!(detect_content_type("logo.svg"), "image/svg+xml"); assert_eq!(detect_content_type("photo.webp"), "image/webp"); assert_eq!(detect_content_type("favicon.ico"), "image/x-icon"); // Fonts assert_eq!(detect_content_type("font.ttf"), "font/ttf"); assert_eq!(detect_content_type("font.otf"), "font/otf"); assert_eq!(detect_content_type("font.eot"), "application/vnd.ms-fontobject"); // Media assert_eq!(detect_content_type("video.webm"), "video/webm"); assert_eq!(detect_content_type("audio.ogg"), "audio/ogg"); assert_eq!(detect_content_type("audio.wav"), "audio/wav"); // Archives assert_eq!(detect_content_type("archive.gz"), "application/gzip"); assert_eq!(detect_content_type("backup.tar"), "application/x-tar"); } #[test] fn test_path_normalization_edge_cases() { assert_eq!(normalize_path("///"), ""); assert_eq!(normalize_path("///index.html"), "index.html"); assert_eq!(normalize_path("/a/b/c/"), "a/b/c/"); } #[test] fn test_directory_path_detection() { // Directories assert!(is_directory_path("a/")); assert!(is_directory_path("a/b/")); assert!(is_directory_path("a/b/c/")); // Files assert!(!is_directory_path("a")); assert!(!is_directory_path("a/b")); assert!(!is_directory_path("a/b/file.html")); } #[test] fn test_resolve_path_nested_directories() { let config = StaticWebConfig { index: Some("index.html".to_string()), error: None, listings: false, listings_css: None, }; // Nested directory with index let (path, is_index, is_listing) = resolve_path("docs/api/v1/", &config); assert_eq!(path, "docs/api/v1/index.html"); assert!(is_index); assert!(!is_listing); } #[test] fn test_resolve_path_with_custom_index() { let config = StaticWebConfig { index: Some("default.htm".to_string()), error: None, listings: false, listings_css: None, }; let (path, is_index, is_listing) = resolve_path("/", &config); assert_eq!(path, "default.htm"); assert!(is_index); assert!(!is_listing); } #[test] fn test_config_with_all_features() { let config = StaticWebConfig { index: Some("index.html".to_string()), error: Some("404.html".to_string()), listings: true, listings_css: Some("style.css".to_string()), }; assert!(config.is_enabled()); assert_eq!(config.index_document(), Some("index.html")); assert_eq!(config.error_document(), Some("404.html")); assert!(config.listings_enabled()); assert_eq!(config.listings_css_path(), Some("style.css")); } #[test] fn test_config_minimal() { let config = StaticWebConfig { index: Some("index.html".to_string()), error: None, listings: false, listings_css: None, }; assert!(config.is_enabled()); assert!(config.error_document().is_none()); assert!(!config.listings_enabled()); assert!(config.listings_css_path().is_none()); } #[test] fn test_breadcrumbs_root() { let html = generate_breadcrumbs("", "container"); assert!(html.contains("breadcrumbs")); assert!(html.contains("/container")); assert!(!html.contains("")); // No subdirectories } #[test] fn test_breadcrumbs_single_level() { let html = generate_breadcrumbs("docs/", "my-site"); assert!(html.contains("/my-site")); assert!(html.contains("docs")); } #[test] fn test_breadcrumbs_multiple_levels() { let html = generate_breadcrumbs("a/b/c/", "container"); assert!(html.contains("/container")); assert!(html.contains("href=\"/a/\"")); assert!(html.contains("href=\"/a/b/\"")); assert!(html.contains("c")); } #[test] fn test_directory_listing_structure() { use super::super::types::Object; let objects = vec![ Object { name: "docs/file1.txt".to_string(), hash: "abc".to_string(), bytes: 1024, content_type: "text/plain".to_string(), last_modified: "2024-01-01T00:00:00Z".to_string(), }, Object { name: "docs/file2.txt".to_string(), hash: "def".to_string(), bytes: 2048, content_type: "text/plain".to_string(), last_modified: "2024-01-02T00:00:00Z".to_string(), }, ]; let html = generate_directory_listing("container", "docs/", &objects, None); // Check structure assert!(html.contains("")); assert!(html.contains("")); assert!(html.contains("")); assert!(html.contains("")); // Check content assert!(html.contains("file1.txt")); assert!(html.contains("file2.txt")); assert!(html.contains("1.0 KB")); assert!(html.contains("2.0 KB")); } #[test] fn test_directory_listing_with_custom_css() { let objects = vec![]; let html = generate_directory_listing("container", "", &objects, Some("custom.css")); assert!(html.contains("custom.css")); assert!(html.contains("