use std::path::PathBuf;
use std::sync::Arc;
use anyhow::{Context, Result};
use axum::extract::State;
use axum::http::StatusCode;
use axum::response::{Html, IntoResponse, Response};
use axum::routing::{get, post};
use axum::Router;
use serde_json::json;
use crate::entry::SortOrder;
use crate::scan::HardlinkPolicy;
use crate::wire::entry::WireEntry;
use super::assets::HTML_TEMPLATE;
use super::reveal::reveal;
use super::state::{start_scan, AppState, Inner, STALE_DAYS};
pub(crate) async fn serve(
scan_root: PathBuf,
port: u16,
sort: SortOrder,
reverse: bool,
hardlinks: HardlinkPolicy,
) -> Result<()> {
let scan_root = scan_root
.canonicalize()
.with_context(|| format!("failed to canonicalize scan root: {}", scan_root.display()))?;
let app_state = Arc::new(AppState::new(scan_root, sort, reverse, hardlinks));
start_scan(Arc::clone(&app_state));
let app = build_router(app_state);
let (listener, bound_port) = bind_with_fallback(port).await?;
let url = format!("http://127.0.0.1:{bound_port}");
if bound_port != port {
println!("Port {port} is busy, falling back to {bound_port}.");
}
println!("Starting UI server at {url}");
println!("Press Ctrl+C to stop.");
let _ = open::that(&url);
axum::serve(listener, app).await?;
Ok(())
}
fn build_router(state: Arc<AppState>) -> Router {
Router::new()
.route("/", get(index))
.route("/data.json", get(data_json))
.route("/rescan", post(rescan))
.route("/reveal", post(reveal))
.with_state(state)
}
async fn bind_with_fallback(port: u16) -> Result<(tokio::net::TcpListener, u16)> {
match tokio::net::TcpListener::bind(("127.0.0.1", port)).await {
Ok(listener) => {
let actual = listener.local_addr()?.port();
Ok((listener, actual))
}
Err(e) if port != 0 && e.kind() == std::io::ErrorKind::AddrInUse => {
let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)).await?;
let actual = listener.local_addr()?.port();
Ok((listener, actual))
}
Err(e) => Err(e.into()),
}
}
async fn index() -> Html<String> {
let html = HTML_TEMPLATE.replacen("__DUVIS_DATA__", "null", 1);
Html(html)
}
async fn data_json(State(s): State<Arc<AppState>>) -> Response {
let state = s.state.lock().unwrap();
let scan_root = s.scan_root.display().to_string();
let body = match &state.inner {
Inner::Scanning => json!({
"status": "scanning",
"elapsed_ms": state.started_at.elapsed().as_millis() as u64,
"items_scanned": state.counts.scanned(),
"items_skipped": state.counts.skipped(),
"scan_root": scan_root,
}),
Inner::Ready {
tree,
scanned_in_ms,
} => json!({
"status": "ready",
"scanned_in_ms": scanned_in_ms,
"items_scanned": state.counts.scanned(),
"items_skipped": state.counts.skipped(),
"scan_root": scan_root,
"tree": WireEntry::from_entry(tree),
"meta": meta_block(),
}),
Inner::Error(msg) => json!({
"status": "error",
"message": msg,
"scan_root": scan_root,
}),
};
axum::Json(body).into_response()
}
fn meta_block() -> serde_json::Value {
json!({
"stale_days": STALE_DAYS,
})
}
async fn rescan(State(s): State<Arc<AppState>>) -> StatusCode {
start_scan(s);
StatusCode::ACCEPTED
}
#[cfg(test)]
mod tests {
use super::*;
use axum::body::Body;
use axum::http::{Request, StatusCode};
use http_body_util::BodyExt;
use serde_json::Value;
use tower::ServiceExt;
use crate::classify::Category;
use crate::entry::Entry;
fn fake_entry(name: &str) -> Entry {
Entry::dir(
name.to_string(),
Category::Other,
Some(1),
vec![Entry::file(
"file.txt".to_string(),
4096,
Category::Other,
Some(1),
)],
)
}
fn ready_state(scan_root: PathBuf) -> Arc<AppState> {
let state = Arc::new(AppState::new(
scan_root,
SortOrder::Size,
false,
HardlinkPolicy::CountOnce,
));
{
let mut s = state.state.lock().unwrap();
s.inner = Inner::Ready {
tree: fake_entry("root"),
scanned_in_ms: 42,
};
}
state
}
async fn body_to_json(response: axum::response::Response) -> Value {
let bytes = response.into_body().collect().await.unwrap().to_bytes();
serde_json::from_slice(&bytes).expect("response body is valid json")
}
async fn body_to_string(response: axum::response::Response) -> String {
let bytes = response.into_body().collect().await.unwrap().to_bytes();
String::from_utf8(bytes.to_vec()).unwrap()
}
#[tokio::test]
async fn data_json_returns_scanning_initially() {
let dir = tempfile::tempdir().unwrap();
let state = Arc::new(AppState::new(
dir.path().to_path_buf(),
SortOrder::Size,
false,
HardlinkPolicy::CountOnce,
));
let response = build_router(state)
.oneshot(
Request::builder()
.uri("/data.json")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let json = body_to_json(response).await;
assert_eq!(json["status"], "scanning");
assert!(json.get("items_scanned").is_some());
assert!(json.get("items_skipped").is_some());
assert!(json.get("scan_root").is_some());
}
#[tokio::test]
async fn data_json_returns_ready_with_meta() {
let dir = tempfile::tempdir().unwrap();
let state = ready_state(dir.path().to_path_buf());
let response = build_router(state)
.oneshot(
Request::builder()
.uri("/data.json")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let json = body_to_json(response).await;
assert_eq!(json["status"], "ready");
assert_eq!(json["scanned_in_ms"], 42);
assert_eq!(json["tree"]["name"], "root");
let meta = &json["meta"];
assert!(meta["stale_days"].is_number());
}
#[tokio::test]
async fn rescan_returns_accepted() {
let dir = tempfile::tempdir().unwrap();
let state = ready_state(dir.path().to_path_buf());
let response = build_router(state)
.oneshot(
Request::builder()
.method("POST")
.uri("/rescan")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::ACCEPTED);
}
#[tokio::test]
async fn reveal_rejects_path_traversal_segments() {
let dir = tempfile::tempdir().unwrap();
let state = ready_state(dir.path().to_path_buf());
let router = build_router(state);
for bad in [
r#"{"segments":[".."]}"#,
r#"{"segments":["."]}"#,
r#"{"segments":[""]}"#,
r#"{"segments":["a/b"]}"#,
r#"{"segments":["a\\b"]}"#,
] {
let response = router
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/reveal")
.header("content-type", "application/json")
.body(Body::from(bad))
.unwrap(),
)
.await
.unwrap();
assert_eq!(
response.status(),
StatusCode::BAD_REQUEST,
"expected 400 for payload {bad}",
);
let body = body_to_string(response).await;
assert!(body.contains("invalid segment"), "body was {body}");
}
}
#[tokio::test]
async fn reveal_rejects_nonexistent_segment() {
let dir = tempfile::tempdir().unwrap();
let state = ready_state(dir.path().to_path_buf());
let response = build_router(state)
.oneshot(
Request::builder()
.method("POST")
.uri("/reveal")
.header("content-type", "application/json")
.body(Body::from(r#"{"segments":["does-not-exist"]}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn index_serves_html_shell() {
let dir = tempfile::tempdir().unwrap();
let state = Arc::new(AppState::new(
dir.path().to_path_buf(),
SortOrder::Size,
false,
HardlinkPolicy::CountOnce,
));
let response = build_router(state)
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = body_to_string(response).await;
assert!(
body.contains("<html") || body.contains("<!DOCTYPE"),
"expected HTML shell, got: {}",
&body[..body.len().min(200)],
);
}
}