use percent_encoding::{AsciiSet, CONTROLS, utf8_percent_encode};
use querystrong::QueryStrong;
use size::Size;
use std::{cmp::Ordering, fmt::Write, path::Path, time::SystemTime};
use trillium::{Conn, Handler, KnownHeaderName::ContentType};
use trillium_static::StaticConnExt;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SortKey {
Name,
Size,
Modified,
}
impl SortKey {
fn param(self) -> &'static str {
match self {
SortKey::Name => "name",
SortKey::Size => "size",
SortKey::Modified => "modified",
}
}
fn parse(value: Option<&str>) -> Self {
match value {
Some("size") => SortKey::Size,
Some("modified" | "date") => SortKey::Modified,
_ => SortKey::Name,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Order {
Asc,
Desc,
}
impl Order {
fn param(self) -> &'static str {
match self {
Order::Asc => "asc",
Order::Desc => "desc",
}
}
fn parse(value: Option<&str>) -> Self {
match value {
Some("desc") => Order::Desc,
_ => Order::Asc,
}
}
fn flipped(self) -> Self {
match self {
Order::Asc => Order::Desc,
Order::Desc => Order::Asc,
}
}
}
#[derive(Debug, Clone, Copy)]
struct Sort {
key: SortKey,
order: Order,
}
impl Sort {
fn from_query(querystring: &str) -> Self {
let qs = QueryStrong::parse(querystring);
Self {
key: SortKey::parse(qs.get_str("sort")),
order: Order::parse(qs.get_str("order")),
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct DirectoryListing;
impl Handler for DirectoryListing {
async fn run(&self, conn: Conn) -> Conn {
let Some((fs_path, url_path)) = conn
.resolved_directory()
.map(|dir| (dir.path().to_path_buf(), conn.path().to_string()))
else {
return conn;
};
let sort = Sort::from_query(conn.querystring());
let entries = match blocking::unblock(move || read_entries(&fs_path)).await {
Ok(mut entries) => {
sort_entries(&mut entries, sort);
entries
}
Err(error) => {
log::warn!("could not list {url_path}: {error}");
return conn; }
};
let body = render(&url_path, &entries, sort);
conn.with_response_header(ContentType, "text/html; charset=utf-8")
.ok(body)
.halt()
}
}
struct Entry {
name: String,
is_dir: bool,
len: Option<u64>,
modified: Option<SystemTime>,
}
fn read_entries(dir: &Path) -> std::io::Result<Vec<Entry>> {
let mut entries = Vec::new();
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
let meta = entry.metadata().ok();
let is_dir = meta.as_ref().is_some_and(std::fs::Metadata::is_dir);
entries.push(Entry {
name: entry.file_name().to_string_lossy().into_owned(),
is_dir,
len: meta
.as_ref()
.filter(|_| !is_dir)
.map(std::fs::Metadata::len),
modified: meta.as_ref().and_then(|m| m.modified().ok()),
});
}
Ok(entries)
}
fn sort_entries(entries: &mut [Entry], sort: Sort) {
entries.sort_by(|a, b| {
if a.is_dir != b.is_dir {
return b.is_dir.cmp(&a.is_dir);
}
let within = match sort.key {
SortKey::Name => Ordering::Equal,
SortKey::Size => a.len.cmp(&b.len),
SortKey::Modified => a.modified.cmp(&b.modified),
};
let within = match sort.order {
Order::Asc => within,
Order::Desc => within.reverse(),
};
let by_name = a.name.to_lowercase().cmp(&b.name.to_lowercase());
within.then(match sort.order {
Order::Asc => by_name,
Order::Desc => by_name.reverse(),
})
});
}
const SEGMENT: &AsciiSet = &CONTROLS
.add(b' ')
.add(b'"')
.add(b'#')
.add(b'%')
.add(b'&')
.add(b'\'')
.add(b'<')
.add(b'>')
.add(b'?')
.add(b'`')
.add(b'{')
.add(b'}');
fn encode_segment(segment: &str) -> impl std::fmt::Display + '_ {
utf8_percent_encode(segment, SEGMENT)
}
fn escape(text: &str) -> String {
let mut out = String::with_capacity(text.len());
for ch in text.chars() {
match ch {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
_ => out.push(ch),
}
}
out
}
fn format_modified(modified: Option<SystemTime>) -> String {
match modified {
Some(time) => humantime::format_rfc3339_seconds(time)
.to_string()
.replace('T', " ")
.trim_end_matches('Z')
.to_string(),
None => "\u{2014}".to_string(),
}
}
fn header_cell(label: &str, key: SortKey, sort: Sort, extra_class: &str) -> String {
let active = sort.key == key;
let order = if active {
sort.order.flipped()
} else {
Order::Asc
};
let (aria, arrow) = match (active, sort.order) {
(true, Order::Asc) => (" aria-sort=\"ascending\"", " \u{2193}"),
(true, Order::Desc) => (" aria-sort=\"descending\"", " \u{2191}"),
(false, _) => ("", ""),
};
let class = if active {
format!("sortable active{extra_class}")
} else {
format!("sortable{extra_class}")
};
format!(
"<th class=\"{class}\"{aria}><a href=\"?sort={}&order={}\">{label}{arrow}</a></th>",
key.param(),
order.param(),
)
}
fn render(url_path: &str, entries: &[Entry], sort: Sort) -> String {
let base = if url_path.ends_with('/') {
url_path.to_string()
} else {
format!("{url_path}/")
};
let title = format!("Index of {}", escape(url_path));
let head = format!(
"{}{}{}",
header_cell("Name", SortKey::Name, sort, ""),
header_cell("Size", SortKey::Size, sort, " size"),
header_cell("Last modified", SortKey::Modified, sort, " modified"),
);
let mut rows = String::new();
if base != "/" {
let parent = parent_path(&base);
let _ = write!(
rows,
"<tr><td class=\"name\"><a \
href=\"{parent}\">{FOLDER_ICON}<span>../</span></a></td><td class=\"size\"></td><td \
class=\"modified\"></td></tr>"
);
}
for entry in entries {
let slash = if entry.is_dir { "/" } else { "" };
let href = format!("{base}{}{slash}", encode_segment(&entry.name));
let icon = if entry.is_dir { FOLDER_ICON } else { FILE_ICON };
let size = match entry.len {
Some(len) => Size::from_bytes(len).to_string(),
None => "\u{2014}".to_string(),
};
let _ = write!(
rows,
"<tr><td class=\"name\"><a \
href=\"{href}\">{icon}<span>{name}{slash}</span></a></td><td \
class=\"size\">{size}</td><td class=\"modified\">{modified}</td></tr>",
name = escape(&entry.name),
modified = format_modified(entry.modified),
);
}
format!(
"<!DOCTYPE html>\n\
<html lang=\"en\">\n\
<head>\n\
<meta charset=\"utf-8\">\n\
<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n\
<title>{title}</title>\n\
<style>{STYLE}</style>\n\
</head>\n\
<body>\n\
<main>\n\
<h1>{title}</h1>\n\
<table>\n\
<thead><tr>{head}</tr></thead>\n\
<tbody>\n{rows}</tbody>\n\
</table>\n\
<footer>served by <a href=\"https://trillium.rs\">trillium</a></footer>\n\
</main>\n\
</body>\n\
</html>\n"
)
}
fn parent_path(base: &str) -> String {
let trimmed = base.trim_end_matches('/');
match trimmed.rfind('/') {
Some(idx) => trimmed[..=idx].to_string(),
None => "/".to_string(),
}
}
const FOLDER_ICON: &str = "<svg viewBox=\"0 0 16 16\" aria-hidden=\"true\"><path d=\"M1.5 \
2.5h4l1.5 1.5h7.5v9h-13z\"/></svg>";
const FILE_ICON: &str =
"<svg viewBox=\"0 0 16 16\" aria-hidden=\"true\"><path d=\"M3 1.5h6L13 5v9.5H3z\"/></svg>";
const STYLE: &str =
"\
:root{color-scheme:light \
dark;--fg:#1a1a1a;--muted:#6b7280;--bg:#ffffff;--row:#f3f4f6;--border:#e5e7eb;--accent:#\
2563eb;--icon:#9ca3af;}@media(prefers-color-scheme:dark){:root{--fg:#e5e7eb;--muted:#9ca3af;\
--bg:#0b0d12;--row:#161a22;--border:#262b36;--accent:#60a5fa;--icon:#6b7280;}}*{box-sizing:\
border-box;}body{margin:0;background:var(--bg);color:var(--fg);font:15px/1.5 \
-apple-system,BlinkMacSystemFont,'Segoe \
UI',Roboto,Helvetica,Arial,sans-serif;}main{max-width:880px;margin:0 auto;padding:2.5rem \
1.25rem 4rem;}h1{font-size:1.15rem;font-weight:600;margin:0 0 \
1.25rem;word-break:break-all;}table{width:100%;border-collapse:collapse;}th{text-align:left;\
font-size:.75rem;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);\
font-weight:600;padding:0 .75rem .5rem;border-bottom:1px solid var(--border);}th.sortable \
a{display:inline-flex;align-items:center;gap:.2rem;color:inherit;text-decoration:none;font:\
inherit;}th.sortable a:hover{color:var(--fg);}th.active{color:var(--fg);}td{padding:.45rem \
.75rem;border-bottom:1px solid var(--border);white-space:nowrap;}tr:hover \
td{background:var(--row);}td.name{width:100%;}td.size,th.size{text-align:right;\
font-variant-numeric:tabular-nums;color:var(--muted);}td.modified,th.modified{color:\
var(--muted);font-variant-numeric:tabular-nums;}a{display:flex;align-items:center;gap:.5rem;\
color:var(--accent);text-decoration:none;overflow:hidden;}a:hover \
span{text-decoration:underline;}a \
span{overflow:hidden;text-overflow:ellipsis;}svg{flex:none;width:1rem;height:1rem;fill:\
var(--icon);}footer{margin-top:1.5rem;font-size:.8rem;color:var(--muted);}footer \
a{display:inline;color:var(--muted);text-decoration:underline;}@media(max-width:520px){td.\
modified,th.modified{display:none;}}";