actix / actix/actix-web

actix-files: HTML template for directories

未關閉
#3,763 0 則留言 0 個 reaction 已指派 0 人 在 GitHub 檢視
主要語言
Rust
星號
24.8k
分支
1.9k
平均合併
23 小時 10 分鐘
30 天內合併 PR
26

描述

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`:

```rust
.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`:

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

/// Custom directory listing handler
pub(crate) async fn directory_listing(path: web::Path) -> Result {
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::::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#"🏠 Home"#);

for part in &path_parts {
current_path.push('/');
current_path.push_str(part);
breadcrumb.push_str(&format!(
r#" / {}"#,
current_path, part
));
}

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

if entries.is_empty() {
file_list.push_str(
r#"


📂 Empty Directory


This directory is empty



"#,
);
} 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#"



{}


{}
{}



"#,
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#"



{}

* {{
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;
}}
}}


{}


"#,
title, breadcrumb, file_list, timestamp
)
}
```

貢獻指南

開啟貢獻指南

研究方向

The payload names only the reporter's src/main.rs and src/html/directory.rs, not an actix-web source file or test. Read those examples to understand the requested template or renderer behavior, then locate the actix-files directory-listing entry point and its tests. Done means a supported customization API renders directory listings with coverage for the chosen behavior.

由索引模型根據 Issue 內容生成。

評估

技術堆疊
rust
領域
backend, web-dev
Issue 類型
功能
難度
5/5
預估耗時
一週以上
活躍度
停滯
描述清晰度
基本清楚
新手友好度
35/100

把新 issue 寄到你的電子郵件信箱

精選適合新手參與的 GitHub issue 摘要。