Skip to content

actix-files: HTML template for directories #3763

@antonengelhardt

Description

@antonengelhardt

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
    )
}

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions