actix-web icon indicating copy to clipboard operation
actix-web copied to clipboard

actix-files: HTML template for directories

Open antonengelhardt opened this issue 6 months ago • 0 comments

Hello,

is it possible to have directories rendered using an HTML template with placeholders instead of the standard (non-styled) Headers and list items? Something like

.service(
                fs::Files::new("/", "./static")
                    .html_template(include_str!("../templates/index.html"))
                    .use_last_modified(true)
                    .use_etag(true)
                    .prefer_utf8(true)
                    .disable_content_disposition(),

Alternatively, something like directory_renderer would be great, which takes a function that renders the directory and all its items.

We wrote our own directory page renderer (i will attach it below), but i think a template would be much cleaner.

src/main.rs:

.service(web::resource("/{path:.*}").to(html::directory::directory_listing))
.service(
    fs::Files::new("/", "./static")
        .use_last_modified(true)
        .use_etag(true)
        .prefer_utf8(true)
        .disable_content_disposition(),

src/html/directory.rs:

use actix_web::{web, HttpResponse, Result};
use std::path::Path;

/// Custom directory listing handler
pub(crate) async fn directory_listing(path: web::Path<String>) -> Result<HttpResponse> {
    let base_path = Path::new("./static");
    let requested_path = base_path.join(&*path);

    // Security check: ensure the path is within the static directory
    if !requested_path.starts_with(base_path) {
        return Ok(HttpResponse::Forbidden().body("Access denied"));
    }

    if requested_path.is_dir() {
        // Read directory contents
        let mut entries = Vec::new();

        if let Ok(read_dir) = std::fs::read_dir(&requested_path) {
            for entry in read_dir.flatten() {
                if entry.file_name() == ".DS_Store" {
                    continue;
                }

                let file_name = entry.file_name().to_string_lossy().to_string();
                let file_path = entry.path();
                let is_dir = file_path.is_dir();

                // Create URL for the entry
                let current_path = if path.is_empty() {
                    String::new()
                } else {
                    format!("/{}", path)
                };

                let url = if current_path.is_empty() {
                    format!("/{}", file_name)
                } else {
                    format!("{}/{}", current_path, file_name)
                };

                // Get file metadata
                let metadata = entry.metadata();
                let size = if is_dir {
                    "Directory".to_string()
                } else {
                    metadata
                        .as_ref()
                        .map(|m| format_file_size(m.len()))
                        .unwrap_or_else(|_| "Unknown".to_string())
                };

                let modified = if let Ok(meta) = metadata {
                    meta.modified()
                        .map(|time| {
                            let datetime = chrono::DateTime::<chrono::Utc>::from(time);
                            datetime.format("%Y-%m-%d %H:%M:%S").to_string()
                        })
                        .unwrap_or_else(|_| "Unknown".to_string())
                } else {
                    "Unknown".to_string()
                };

                entries.push(DirectoryEntry {
                    name: file_name,
                    url,
                    is_dir,
                    size,
                    modified,
                });
            }
        }

        // Sort entries: directories first, then files
        entries.sort_by(|a, b| match (a.is_dir, b.is_dir) {
            (true, false) => std::cmp::Ordering::Less,
            (false, true) => std::cmp::Ordering::Greater,
            _ => a.name.cmp(&b.name),
        });

        // Generate HTML
        let html = generate_directory_html(&path, &entries);
        Ok(HttpResponse::Ok().content_type("text/html").body(html))
    } else {
        // Serve the file
        Ok(HttpResponse::NotFound().body("File not found"))
    }
}

#[derive(Debug)]
struct DirectoryEntry {
    name: String,
    url: String,
    is_dir: bool,
    size: String,
    modified: String,
}

fn format_file_size(bytes: u64) -> String {
    const UNITS: &[&str] = &["B", "KB", "MB", "GB", "TB"];
    let mut size = bytes as f64;
    let mut unit_index = 0;

    while size >= 1024.0 && unit_index < UNITS.len() - 1 {
        size /= 1024.0;
        unit_index += 1;
    }

    if unit_index == 0 {
        format!("{} {}", size as u64, UNITS[unit_index])
    } else {
        format!("{:.1} {}", size, UNITS[unit_index])
    }
}

fn generate_directory_html(path: &str, entries: &[DirectoryEntry]) -> String {
    let title = if path.is_empty() {
        "Root Directory"
    } else {
        path
    };

    // Create breadcrumb
    let path_parts: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
    let mut breadcrumb = String::new();
    let mut current_path = String::new();

    breadcrumb.push_str(r#"<a href="/">🏠 Home</a>"#);

    for part in &path_parts {
        current_path.push('/');
        current_path.push_str(part);
        breadcrumb.push_str(&format!(
            r#"<span> / </span><a href="{}">{}</a>"#,
            current_path, part
        ));
    }

    // Generate file list
    let mut file_list = String::new();

    if entries.is_empty() {
        file_list.push_str(
            r#"
            <div class="empty-state">
                <h3>📂 Empty Directory</h3>
                <p>This directory is empty</p>
            </div>
        "#,
        );
    } else {
        for entry in entries {
            let icon = if entry.is_dir {
                "📁"
            } else if entry.name.ends_with(".pdf") {
                "📄"
            } else if entry.name.ends_with(".png")
                || entry.name.ends_with(".jpg")
                || entry.name.ends_with(".jpeg")
            {
                "🖼️"
            } else {
                "📄"
            };

            let file_type = if entry.is_dir { "folder" } else { "file" };

            file_list.push_str(&format!(
                r#"
                <a href="{}" class="file-item">
                    <div class="file-icon {}">
                        {}
                    </div>
                    <div class="file-info">
                        <div class="file-name">{}</div>
                        <div class="file-meta">
                            <span class="file-size">{}</span>
                            <span>{}</span>
                        </div>
                    </div>
                </a>
            "#,
                entry.url, file_type, icon, entry.name, entry.size, entry.modified
            ));
        }
    }

    // Get current timestamp
    let timestamp = chrono::Utc::now()
        .format("%Y-%m-%d %H:%M:%S UTC")
        .to_string();

    format!(
        r#"
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>{}</title>
    <style>
        * {{
            margin: 0;
            padding: 0;
            box-sizing: border-box;
        }}

        body {{
            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
            background: linear-gradient(135deg, #88BEF9 0%, #88BEF9 100%);
            min-height: 100vh;
            padding: 20px;
        }}

        .container {{
            max-width: 1200px;
            margin: 0 auto;
            background: white;
            border-radius: 12px;
            box-shadow: 0 20px 40px rgba(0,0,0,0.1);
            overflow: hidden;
        }}

        .header {{
            background: linear-gradient(135deg, #88BEF9 0%, #88BEF9 100%);
            color: white;
            padding: 30px;
            text-align: center;
        }}

        .header h1 {{
            font-size: 2.5rem;
            margin-bottom: 10px;
            font-weight: 300;
        }}

        .header p {{
            opacity: 0.9;
            font-size: 1.1rem;
        }}

        .breadcrumb {{
            background: #f8f9fa;
            padding: 15px 30px;
            border-bottom: 1px solid #e9ecef;
        }}

        .breadcrumb a {{
            color: #667eea;
            text-decoration: none;
            font-weight: 500;
        }}

        .breadcrumb a:hover {{
            text-decoration: underline;
        }}

        .file-list {{
            padding: 0;
        }}

        .file-item {{
            display: flex;
            align-items: center;
            padding: 20px 30px;
            border-bottom: 1px solid #f0f0f0;
            transition: all 0.2s ease;
            text-decoration: none;
            color: inherit;
        }}

        .file-item:hover {{
            background: #f8f9fa;
            transform: translateX(5px);
        }}

        .file-item:last-child {{
            border-bottom: none;
        }}

        .file-icon {{
            width: 40px;
            height: 40px;
            margin-right: 20px;
            display: flex;
            align-items: center;
            justify-content: center;
            border-radius: 8px;
            font-size: 1.5rem;
        }}

        .file-icon.folder {{
            background: #e3f2fd;
            color: #1976d2;
        }}

        .file-icon.file {{
            background: #f3e5f5;
            color: #88BEF9;
        }}

        .file-info {{
            flex: 1;
        }}

        .file-name {{
            font-size: 1.1rem;
            font-weight: 500;
            margin-bottom: 5px;
            color: #333;
        }}

        .file-meta {{
            font-size: 0.9rem;
            color: #666;
            display: flex;
            gap: 20px;
        }}

        .file-size {{
            font-weight: 500;
        }}

        .empty-state {{
            text-align: center;
            padding: 60px 30px;
            color: #666;
        }}

        .empty-state h3 {{
            font-size: 1.5rem;
            margin-bottom: 10px;
            color: #333;
        }}

        .footer {{
            background: #f8f9fa;
            padding: 20px 30px;
            text-align: center;
            color: #666;
            font-size: 0.9rem;
        }}

        @media (max-width: 768px) {{
            .header h1 {{
                font-size: 2rem;
            }}

            .file-item {{
                padding: 15px 20px;
            }}

            .file-meta {{
                flex-direction: column;
                gap: 5px;
            }}
        }}
    </style>
</head>
<body>
    <div class="container">

        <div class="breadcrumb">
            {}
        </div>

        <div class="file-list">
            {}
        </div>

        <div class="footer">
            <p>Powered by Actix Web • {}</p>
        </div>
    </div>
</body>
</html>
    "#,
        title, breadcrumb, file_list, timestamp
    )
}

antonengelhardt avatar Sep 08 '25 10:09 antonengelhardt