actix / actix/actix-web

actix-files: HTML template for directories

Aperta
#3,763 0 commenti 0 reazioni 0 assegnatari Vedi su GitHub
Lingua principale
Rust
Stelle
24.8k
Fork
1.9k
Merge medio
23h 10m
PR unite (30g)
26

Descrizione

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

Guida per i contributori

Apri la guida per i contributori

Direzione di ricerca

Il payload nomina solo src/main.rs e src/html/directory.rs del segnalatore, non un file sorgente o un test di actix-web. Leggi questi esempi per comprendere il comportamento richiesto del template o del renderer, quindi individua il punto di ingresso di actix-files per l’elenco delle directory e i relativi test. Il lavoro è completo quando un’API di personalizzazione supportata esegue il rendering degli elenchi delle directory e il comportamento scelto dispone di test di copertura.

Scritto dal modello di indicizzazione a partire dal testo della issue.

Valutazione

Stack tecnologico
rust
Ambito
backend, web-dev
Tipo di issue
Funzionalità
Difficoltà
5/5
Tempo stimato
Più di una settimana
Stato di attività
Ferma
Chiarezza
Abbastanza chiara
Idoneità per principianti
35/100

Ricevi le nuove issue nella tua casella

Un breve riepilogo di issue GitHub adatte ai principianti.