-
-
Couldn't load subscription status.
- Fork 1.8k
Open
Description
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
Labels
No labels