#![cfg(feature = "admin-ui")]
use std::path::{Path, PathBuf};
use std::sync::Arc;
use axum::body::Body;
use axum::extract::Request;
use axum::http::{StatusCode, header};
use axum::response::Response;
use include_dir::{Dir, include_dir};
use sha2::{Digest, Sha256};
pub static ADMIN_UI_DIR: Dir<'_> = include_dir!("$OUT_DIR/admin-ui-dist");
pub static MEMBER_UI_DIR: Dir<'_> = include_dir!("$OUT_DIR/member-ui-dist");
#[derive(Debug, Clone)]
pub struct AdminUiInfo {
pub index_sha256: Arc<String>,
pub file_count: u32,
pub mode: Arc<String>,
}
impl AdminUiInfo {
pub fn from_embedded(mode: &str) -> Self {
let index_bytes = ADMIN_UI_DIR
.get_file("index.html")
.map(|f| f.contents())
.unwrap_or_default();
let index_sha256 = hex::encode(Sha256::digest(index_bytes));
let file_count = count_files(&ADMIN_UI_DIR);
Self {
index_sha256: Arc::new(index_sha256),
file_count,
mode: Arc::new(mode.to_string()),
}
}
}
impl AdminUiInfo {
pub fn for_config(config: &crate::config::AdminUiConfig) -> Self {
match config.serve_dir() {
Some(dir) => Self::from_directory(dir, &config.mode),
None => Self::from_embedded(&config.mode),
}
}
pub fn from_directory(dir: &Path, mode: &str) -> Self {
let index_sha256 = std::fs::read(dir.join("index.html"))
.map(|b| hex::encode(Sha256::digest(b)))
.unwrap_or_default();
Self {
index_sha256: Arc::new(index_sha256),
file_count: count_files_on_disk(dir),
mode: Arc::new(mode.to_string()),
}
}
}
fn count_files_on_disk(dir: &Path) -> u32 {
let Ok(entries) = std::fs::read_dir(dir) else {
return 0;
};
let mut total: u32 = 0;
for entry in entries.flatten() {
match entry.file_type() {
Ok(t) if t.is_file() => total = total.saturating_add(1),
Ok(t) if t.is_dir() => total = total.saturating_add(count_files_on_disk(&entry.path())),
_ => {}
}
}
total
}
pub fn check_serve_dir(dir: &Path) -> Result<(), String> {
let index = dir.join("index.html");
let meta = std::fs::metadata(&index).map_err(|e| {
format!(
"{}: cannot read index.html ({e}) — `vtc admin-ui export [--members] {}` \
writes the built-in one there to start from",
dir.display(),
dir.display()
)
})?;
if !meta.is_file() {
return Err(format!("{} is not a file", index.display()));
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
for path in [dir, index.as_path()] {
let mode = std::fs::metadata(path)
.map_err(|e| format!("{}: {e}", path.display()))?
.permissions()
.mode();
if mode & 0o002 != 0 {
return Err(format!(
"{} is world-writable — anyone on this host could replace the \
admin console; run `chmod o-w {}`",
path.display(),
path.display()
));
}
}
}
Ok(())
}
pub fn export(dir: &Path, bundle: &'static Dir<'static>) -> Result<(), String> {
if let Ok(mut entries) = std::fs::read_dir(dir)
&& entries.next().is_some()
{
return Err(format!(
"{} is not empty — export into a new or empty directory so nothing \
already there is overwritten",
dir.display()
));
}
std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
bundle
.extract(dir)
.map_err(|e| format!("writing to {}: {e}", dir.display()))
}
fn count_files(dir: &Dir<'_>) -> u32 {
let mut total: u32 = 0;
for f in dir.files() {
let _ = f;
total = total.saturating_add(1);
}
for sub in dir.dirs() {
total = total.saturating_add(count_files(sub));
}
total
}
pub fn lookup(rel_path: &str) -> Option<&'static [u8]> {
lookup_in(&ADMIN_UI_DIR, rel_path)
}
fn lookup_in(dir: &'static Dir<'static>, rel_path: &str) -> Option<&'static [u8]> {
let trimmed = rel_path.trim_start_matches('/');
dir.get_file(trimmed).map(|f| f.contents())
}
fn cache_control_for(rel: &str, served_shell: bool) -> &'static str {
if !served_shell && rel.starts_with("/assets/") {
"public, max-age=300"
} else {
"no-cache"
}
}
pub async fn serve(req: Request<Body>) -> Response {
serve_from(&ADMIN_UI_DIR, "/admin", "admin UX", req)
}
pub async fn serve_dir(dir: &Path, req: Request<Body>) -> Response {
serve_dir_at(dir, "/admin", "admin UX", req).await
}
pub async fn serve_members_dir(dir: &Path, req: Request<Body>) -> Response {
serve_dir_at(dir, "/members", "member portal", req).await
}
async fn serve_dir_at(dir: &Path, mount: &str, what: &str, req: Request<Body>) -> Response {
let rel = shell_relative(req.uri().path(), mount);
let (path, served_shell) = match resolve_in_dir(dir, rel) {
Some(p) => (p, rel == "/index.html"),
None => match resolve_in_dir(dir, "/index.html") {
Some(p) => (p, true),
None => return (StatusCode::NOT_FOUND, format!("{what} not found")).into_response(),
},
};
let bytes = match tokio::fs::read(&path).await {
Ok(b) => b,
Err(_) => return (StatusCode::NOT_FOUND, format!("{what} not found")).into_response(),
};
let mime = if served_shell {
"text/html; charset=utf-8".to_string()
} else {
mime_guess::from_path(&path)
.first_or_octet_stream()
.to_string()
};
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, mime)
.header(header::CACHE_CONTROL, cache_control_for(rel, served_shell))
.body(Body::from(bytes))
.unwrap_or_else(|_| (StatusCode::INTERNAL_SERVER_ERROR, "response build").into_response())
}
fn resolve_in_dir(dir: &Path, rel: &str) -> Option<PathBuf> {
let path = crate::website::paths::canonical_within_root(dir, rel, &[]).ok()?;
path.is_file().then_some(path)
}
fn shell_relative<'a>(path: &'a str, mount: &str) -> &'a str {
let rel = path.strip_prefix(mount).unwrap_or(path);
if rel.is_empty() || rel == "/" {
"/index.html"
} else {
rel
}
}
pub async fn serve_members(req: Request<Body>) -> Response {
serve_from(&MEMBER_UI_DIR, "/members", "member portal", req)
}
fn serve_from(dir: &'static Dir<'static>, mount: &str, what: &str, req: Request<Body>) -> Response {
let lookup = |p: &str| lookup_in(dir, p);
let rel = shell_relative(req.uri().path(), mount);
let (bytes, mime, served_shell) = match lookup(rel) {
Some(b) => (
b,
mime_guess::from_path(rel)
.first_or_octet_stream()
.to_string(),
rel == "/index.html",
),
None => match lookup("/index.html") {
Some(b) => (b, "text/html; charset=utf-8".to_string(), true),
None => {
return (StatusCode::NOT_FOUND, format!("{what} not embedded")).into_response();
}
},
};
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, mime)
.header(header::CACHE_CONTROL, cache_control_for(rel, served_shell))
.body(Body::from(bytes))
.unwrap_or_else(|_| (StatusCode::INTERNAL_SERVER_ERROR, "response build").into_response())
}
use axum::response::IntoResponse;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn embedded_dir_has_index() {
assert!(
ADMIN_UI_DIR.get_file("index.html").is_some(),
"index.html missing from embedded admin-ui — did the source dir get deleted?"
);
}
#[test]
fn cache_control_no_cache_for_shell_long_ttl_for_assets() {
assert_eq!(cache_control_for("/index.html", true), "no-cache");
assert_eq!(cache_control_for("/install", true), "no-cache"); assert_eq!(cache_control_for("/favicon.ico", false), "no-cache");
assert_eq!(
cache_control_for("/assets/index-a1b2c3.js", false),
"public, max-age=300"
);
assert_eq!(cache_control_for("/assets/missing.js", true), "no-cache");
}
#[test]
fn lookup_returns_bytes_for_known_files() {
let bytes = lookup("/index.html").expect("index.html");
let body = std::str::from_utf8(bytes).unwrap();
assert!(
body.contains("<title>VTC Admin</title>"),
"index.html title drifted: {body}"
);
assert!(
body.contains("id=\"root\""),
"index.html missing React mount point: {body}"
);
}
#[test]
fn member_portal_is_embedded_as_its_own_bundle() {
let index = MEMBER_UI_DIR
.get_file("index.html")
.expect("member portal index.html missing — did build:members run?");
let body = std::str::from_utf8(index.contents()).unwrap();
assert!(body.contains("<title>VTC Members</title>"), "{body}");
assert!(body.contains("/members/assets/"), "{body}");
}
#[test]
fn lookup_returns_none_for_unknown() {
assert!(lookup("/missing.html").is_none());
}
#[test]
fn assets_dir_is_embedded() {
let assets = ADMIN_UI_DIR
.get_dir("assets")
.expect("assets/ missing from dist — Vite build did not emit chunks");
let js_present = assets
.files()
.any(|f| f.path().extension().is_some_and(|e| e == "js"));
let css_present = assets
.files()
.any(|f| f.path().extension().is_some_and(|e| e == "css"));
assert!(js_present, "no .js asset in dist/assets/");
assert!(css_present, "no .css asset in dist/assets/");
}
fn get(path: &str) -> Request<Body> {
Request::builder().uri(path).body(Body::empty()).unwrap()
}
async fn body_of(resp: Response) -> String {
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
String::from_utf8(bytes.to_vec()).unwrap()
}
fn console_dir() -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("index.html"), "<title>Ours</title>").unwrap();
std::fs::create_dir(dir.path().join("assets")).unwrap();
std::fs::write(dir.path().join("assets/app.css"), "body{}").unwrap();
dir
}
#[tokio::test]
async fn directory_console_serves_its_files_and_falls_back_to_its_shell() {
let dir = console_dir();
let resp = serve_dir(dir.path(), get("/admin/assets/app.css")).await;
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(resp.headers()[header::CONTENT_TYPE], "text/css");
assert_eq!(resp.headers()[header::CACHE_CONTROL], "public, max-age=300");
assert_eq!(body_of(resp).await, "body{}");
for path in ["/admin", "/admin/", "/admin/members/approve"] {
let resp = serve_dir(dir.path(), get(path)).await;
assert_eq!(resp.status(), StatusCode::OK, "{path}");
assert_eq!(
resp.headers()[header::CONTENT_TYPE],
"text/html; charset=utf-8"
);
assert_eq!(resp.headers()[header::CACHE_CONTROL], "no-cache");
assert_eq!(body_of(resp).await, "<title>Ours</title>", "{path}");
}
std::fs::write(dir.path().join("index.html"), "<title>Edited</title>").unwrap();
assert_eq!(
body_of(serve_dir(dir.path(), get("/admin/")).await).await,
"<title>Edited</title>"
);
}
#[tokio::test]
async fn directory_console_never_serves_hidden_or_escaping_files() {
let outside = tempfile::tempdir().unwrap();
std::fs::write(outside.path().join("secret.txt"), "secret").unwrap();
let dir = console_dir();
std::fs::write(dir.path().join(".env"), "secret").unwrap();
#[cfg(unix)]
std::os::unix::fs::symlink(
outside.path().join("secret.txt"),
dir.path().join("link.txt"),
)
.unwrap();
for path in ["/admin/.env", "/admin/link.txt", "/admin/../secret.txt"] {
let body = body_of(serve_dir(dir.path(), get(path)).await).await;
assert_eq!(body, "<title>Ours</title>", "{path} must get the shell");
}
}
#[tokio::test]
async fn directory_without_a_shell_is_not_found() {
let dir = tempfile::tempdir().unwrap();
let resp = serve_dir(dir.path(), get("/admin/")).await;
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[test]
fn serve_dir_check_requires_index_and_refuses_world_writable() {
let empty = tempfile::tempdir().unwrap();
let err = check_serve_dir(empty.path()).unwrap_err();
assert!(err.contains("vtc admin-ui export"), "{err}");
let dir = console_dir();
check_serve_dir(dir.path()).unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let index = dir.path().join("index.html");
std::fs::set_permissions(&index, std::fs::Permissions::from_mode(0o666)).unwrap();
let err = check_serve_dir(dir.path()).unwrap_err();
assert!(err.contains("world-writable"), "{err}");
}
}
#[test]
fn export_writes_the_baked_console_and_never_overwrites() {
let root = tempfile::tempdir().unwrap();
let target = root.path().join("console");
export(&target, &ADMIN_UI_DIR).unwrap();
check_serve_dir(&target).unwrap();
let exported = AdminUiInfo::from_directory(&target, "directory");
let baked = AdminUiInfo::from_embedded("embedded");
assert_eq!(exported.index_sha256, baked.index_sha256);
assert_eq!(exported.file_count, baked.file_count);
let err = export(&target, &ADMIN_UI_DIR).unwrap_err();
assert!(err.contains("not empty"), "{err}");
}
#[tokio::test]
async fn exported_member_portal_serves_under_its_own_mount() {
let root = tempfile::tempdir().unwrap();
let target = root.path().join("members");
export(&target, &MEMBER_UI_DIR).unwrap();
check_serve_dir(&target).unwrap();
for path in ["/members", "/members/", "/members/profile"] {
let body = body_of(serve_members_dir(&target, get(path)).await).await;
assert!(
body.contains("<title>VTC Members</title>"),
"{path}: {body}"
);
}
}
#[test]
fn info_carries_sha_of_index_html() {
let info = AdminUiInfo::from_embedded("embedded");
assert_eq!(info.index_sha256.len(), 64, "hex sha256 = 64 chars");
assert!(
info.file_count >= 3,
"expect index + at least one js + one css"
);
assert_eq!(info.mode.as_str(), "embedded");
}
}