From 9b15738fef3c87ecee92ff5d44feee43817e736f Mon Sep 17 00:00:00 2001 From: Firstero Date: Thu, 9 May 2024 16:03:50 +0800 Subject: [PATCH] FeatureAdd: finished homework https://u.geekbang.org/lesson/610?article=768417 --- Cargo.lock | 159 ++++++++++++++++++- Cargo.toml | 13 +- README.md | 101 +++++++++--- src/process/http_serve.rs | 322 +++++++++++++++++++++++++++++++++----- templates/base.html | 71 +++++++++ templates/error.html | 16 ++ templates/index.html | 87 ++++++++++ 7 files changed, 705 insertions(+), 64 deletions(-) create mode 100644 templates/base.html create mode 100644 templates/error.html create mode 100644 templates/index.html diff --git a/Cargo.lock b/Cargo.lock index 9c5a069..602459c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -117,6 +117,50 @@ version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96d30a06541fbafbc7f82ed10c06164cfbd2c401138f6addd8404629c4b16711" +[[package]] +name = "askama" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b79091df18a97caea757e28cd2d5fda49c6cd4bd01ddffd7ff01ace0c0ad2c28" +dependencies = [ + "askama_derive", + "askama_escape", + "humansize", + "num-traits", + "percent-encoding", +] + +[[package]] +name = "askama_derive" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19fe8d6cb13c4714962c072ea496f3392015f0989b1a2847bb4b2d9effd71d83" +dependencies = [ + "askama_parser", + "basic-toml", + "mime", + "mime_guess", + "proc-macro2", + "quote", + "serde", + "syn 2.0.60", +] + +[[package]] +name = "askama_escape" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "619743e34b5ba4e9703bba34deac3427c72507c7159f5fd030aea8cac0cfe341" + +[[package]] +name = "askama_parser" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acb1161c6b64d1c3d83108213c2a2533a342ac225aabd0bda218278c2ddb00c0" +dependencies = [ + "nom", +] + [[package]] name = "async-compression" version = "0.4.9" @@ -205,6 +249,18 @@ dependencies = [ "tracing", ] +[[package]] +name = "axum-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00c055ee2d014ae5981ce1016374e8213682aa14d9bf40e48ab48b5f3ef20eaa" +dependencies = [ + "heck 0.4.1", + "proc-macro2", + "quote", + "syn 2.0.60", +] + [[package]] name = "backtrace" version = "0.3.71" @@ -238,6 +294,15 @@ version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b" +[[package]] +name = "basic-toml" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "823388e228f614e9558c6804262db37960ec8821856535f5c3f59913140558f8" +dependencies = [ + "serde", +] + [[package]] name = "bit-set" version = "0.5.3" @@ -394,7 +459,7 @@ version = "4.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "528131438037fd55894f62d6e9f068b8f45ac57ffa77517819645d10aed04f64" dependencies = [ - "heck", + "heck 0.5.0", "proc-macro2", "quote", "syn 2.0.60", @@ -646,6 +711,16 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" +[[package]] +name = "errno" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "534c5cf6194dfab3db3242765c03bbe257cf92f22b38f6bc0c58d59108a820ba" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + [[package]] name = "fancy-regex" version = "0.11.0" @@ -656,6 +731,12 @@ dependencies = [ "regex", ] +[[package]] +name = "fastrand" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fc0510504f03c51ada170672ac806f1f105a88aa97a5281117e1ddc3368e51a" + [[package]] name = "fiat-crypto" version = "0.2.8" @@ -780,6 +861,12 @@ version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "290f1a1d9242c78d09ce40a5e87e7554ee637af1351968159f4952f028f75604" +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + [[package]] name = "heck" version = "0.5.0" @@ -844,6 +931,15 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "humansize" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb51c9a029ddc91b07a787f1d86b53ccfa49b0e86688c946ebe8d3555685dd7" +dependencies = [ + "libm", +] + [[package]] name = "hyper" version = "1.3.1" @@ -965,6 +1061,18 @@ version = "0.2.153" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c198f91728a82281a64e1f4f9eeb25d82cb32a5de251c6bd1b5154d63a8e7bd" +[[package]] +name = "libm" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ec2a862134d2a7d32d7983ddcdd1c4923530833c9f2ea1a44fc5fa473989058" + +[[package]] +name = "linux-raw-sys" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01cda141df6706de531b6c46c3a33ecca755538219bd484262fa09410c13539c" + [[package]] name = "log" version = "0.4.21" @@ -1008,6 +1116,12 @@ dependencies = [ "unicase", ] +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "miniz_oxide" version = "0.7.2" @@ -1028,6 +1142,16 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "nu-ansi-term" version = "0.46.0" @@ -1262,7 +1386,9 @@ name = "rcli" version = "0.1.0" dependencies = [ "anyhow", + "askama", "axum", + "axum-macros", "base64 0.22.0", "blake3", "chacha20poly1305", @@ -1271,14 +1397,20 @@ dependencies = [ "ed25519", "ed25519-dalek", "enum_dispatch", + "hyper", "jsonwebtoken", + "mime_guess", + "percent-encoding", "rand", "regex", "ring", "serde", "serde_json", "serde_yaml", + "tempfile", + "time", "tokio", + "tower", "tower-http", "tracing", "tracing-subscriber", @@ -1359,6 +1491,19 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "0.38.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70dc5ec042f7a43c4a73241207cecc9873a06d45debb38b329f8541d85c2730f" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.52.0", +] + [[package]] name = "rustversion" version = "1.0.15" @@ -1577,6 +1722,18 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7065abeca94b6a8a577f9bd45aa0867a2238b74e8eb67cf10d492bc39351394" +[[package]] +name = "tempfile" +version = "3.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85b77fafb263dd9d05cbeac119526425676db3784113aa9295c88498cbf8bff1" +dependencies = [ + "cfg-if", + "fastrand", + "rustix", + "windows-sys 0.52.0", +] + [[package]] name = "thiserror" version = "1.0.59" diff --git a/Cargo.toml b/Cargo.toml index b89ab91..0bb9509 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,7 +9,9 @@ license = "MIT" [dependencies] anyhow = "1.0.82" +askama = "0.12.1" axum = { version = "0.7.5", features = ["http2", "query", "tracing"] } +axum-macros = "0.4.1" base64 = "0.22.0" blake3 = "1.5.1" chacha20poly1305 = "0.10.1" @@ -18,15 +20,24 @@ csv = "1.3.0" ed25519 = "2.2.3" ed25519-dalek = { version = "2.1.1", features = ["rand_core"] } enum_dispatch = "0.3.13" +hyper = "1.3.1" jsonwebtoken = "9.3.0" +mime_guess = "2.0.4" +percent-encoding = "2.3.1" rand = "0.8.5" regex = "1.10.4" ring = "0.17.8" serde = { version = "1.0.198", features = ["derive"] } serde_json = "1.0.116" serde_yaml = "0.9.34" +tempfile = "3.10.1" +time = "0.3.36" tokio = { version = "1.37.0", features = ["rt", "rt-multi-thread", "macros", "net", "fs"] } -tower-http = { version = "0.5.2", features = ["compression-full", "cors", "trace", "fs"] } +tower = "0.4.13" +tower-http = { version = "0.5.2", features = ["compression-full", "cors", "trace", "fs", "normalize-path"] } tracing = "0.1.40" tracing-subscriber = { version = "0.3.18", features = ["env-filter"] } zxcvbn = "2.2.2" + +[dev-dependencies] +tempfile = "3.10.1" diff --git a/README.md b/README.md index bcc5f5d..4ca53d2 100644 --- a/README.md +++ b/README.md @@ -1,35 +1,92 @@ -# Geektime Rust 语言训练营 +# Geektime Rust 训练营 -## Rcli 开发 +## rcli: 基于Rust实现的命令行工具 -### V1-7 rcli csv 添加 csv 转换 +### 作业一 - - ```rcli csv --input in.csv --output out.json --format json``` - - ```rcli csv --input in.csv --output out.yaml --format yaml``` - - ```rcli csv --header --delimiter , --input in.csv --output out.yaml --format yaml``` +#### 作业要求 -### V1-8 rcli genpass +阅读 chacha20poly1305 文档,了解其使用方法并构建 CLI 对输入文本进行加密 / 解密 CLI接口示例: - - ```rcli genpass -l 32 --no-lower --no-lower --no-symbol --no-number``` +- rcli text encrypt -key"xxx"> 加密并输出 base64 +- rcli text decrypt -key"XXX" >base64 > binary> 解密文本 -### V1-9 rcli base64 +#### 实现说明 +具体实现,参考 cli/text.rs,运行命令如下 +1. 使用chacha20poly1305 crate 进行包装 +2. 增加 nonce 参数,用于每次的使用, 参考 chacha20poly1305 +- ```rcli text encrypt --key 'keyfile/or stdin' --input textfile --nonce noncefile``` +- ```rcli text decrypt --key 'keyfile/or stdin' --input textfile --nonce noncefile``` - - ```rcli base64 encode --format nopadding/standard/urlsafe --input textfile``` - - ```rcli base64 decode --format nopadding/standard/urlsafe --input textfile``` +### 作业二 -### V1-10/11 rcli text 加密解密 +#### 作业要求 - - ```rcli text sign --format blake3 --key keyfile --input textfile``` - - ```rcli text verify --format blake3 --key keyfile --input textfile --sig signature``` - - ```rcli text generate --format blake3 --output keyfile``` - - ```rcli text encrypt --key keyfile --input textfile --nonce noncefile``` - - ```rcli text decrypt --key keyfile --input textfile --nonce noncefile``` +json web token(jwt) 在用户验证领域经常被用到。请构建一个 CLI 来为给定 sub/aud/exp/… 生成一个 jwt。要求生成的 jwt 可以通过 jwt.io 的验证。 +CLI接口示例: +- rcli jwt sign --sub acme --aud device1 --exp 14d +- rcli jwt verify -t -### V1-12/13 rcli http serve(default dir is current dir, default port is 8080) +#### 实现说明 - - ```rcli http serve``` - - ```rcli http serve --dir /tmp --port 8080``` +1. 使用 jsonwebtoken crate 来实现 sign/decode +2. 直接使用 serde_json 来作为 Claims 结构, 参考 jsonwebtoken Validation 结构体, 支持所有标准规范可选字段的定制 -### V1-14 重构 CLI +#### 使用指南 +- rcli jwt sign +``` +Usage: rcli jwt sign [OPTIONS] +Options: + --alg claims algorithm [default: HS384] + --key sign key file path, or '-' for stdin [default: -] + --sub subject + --aud audience + --iss jwt issuer + --exp jwt expiration time [default: 1d] + --nbf jwt nbf time [default: 1d] + --iat generate jwt iat or not + -h, --help Print help +``` +- rcli jwt verify +``` +Usage: rcli jwt verify [OPTIONS] --token +Options: + -t, --token jwt token + --key key file path, or '-' for stdin [default: -] + --required-claims required claims + --aud audiences + --iss issuers + --sub jwt subject + --validate-exp validate expiration time [possible values: true, false] + --validate-nbf validate nbf time [possible values: true, false] + --validate-aud validate audience [possible values: true, false] + --silent show token claims, if verified successfully + --show-self show self options + -h, --help Print help +``` -### V1-15 作业 +### 作业三 +#### 作业要求 + +给课程里的 HTTP 文件服务器添加对 directory index 的支持。 + +#### 实现说明 + +1. Service 实现: 使用 CustomServiceDir 包装原始 ServeDir + - 使用 axum handler 来实现自定义的 CustomServiceDir service + - handler 实现中基于 tower::ServiceExt 的 oneshot 来包装,实现自定义代理 + - 由于 [ServeDir in nested route causes invalid redirects #1731]( https://github.com/tokio-rs/axum/issues/1731 ), 手动处理了 StatusCode::TEMPORARY_REDIRECT +2. 目录索引生成实现: 使用 tokio::fs 实现目录的一级遍历 +3. 前端渲染: 使用 askama 模板引擎来实现目录页面及错误页面渲染,参考 https://github.com/ttys3/static-server + +#### 使用指南 +- rcli http +```Usage: rcli http + +Commands: + serve Serve a directory via HTTP + help Print this message or the help of the given subcommand(s) + +Options: + -h, --help Print help +``` diff --git a/src/process/http_serve.rs b/src/process/http_serve.rs index ec31cd2..5534300 100644 --- a/src/process/http_serve.rs +++ b/src/process/http_serve.rs @@ -1,64 +1,306 @@ -use std::{net::SocketAddr, path::PathBuf, sync::Arc}; +use std::{ffi::OsStr, net::SocketAddr, path::PathBuf, time::SystemTime}; use anyhow::Result; +use askama::Template; use axum::{ - extract::{Path, State}, - http::StatusCode, + body::Body, + extract, + http::{header, HeaderValue, Request, Response, StatusCode}, + response::{Html, IntoResponse}, routing::get, Router, }; -use tokio::fs; -use tower_http::services::{ServeDir, ServeFile}; -use tracing::info; +use base64::Engine; +use tokio::{fs, io}; +use tower::util::ServiceExt; +use tower_http::{normalize_path::NormalizePath, services::ServeDir}; -#[derive(Debug)] -struct HttpServeState { - path: PathBuf, -} - -pub async fn process_http_serve(path: PathBuf, port: u16) -> Result<()> { +pub async fn process_http_serve(root_dir: PathBuf, port: u16) -> Result<()> { let addr = SocketAddr::from(([0, 0, 0, 0], port)); - info!("Serving {:?} on addr {}", path, addr); - let dir_service = - ServeDir::new(path.clone()).not_found_service(ServeFile::new("assets/not_found.html")); - let state = HttpServeState { path }; + tracing::info!( + "Rcli Static File Serving directory {:?} on addr {}", + root_dir, + addr + ); + let serve_base_dir = root_dir.to_string_lossy().to_string(); + // let state = HttpServeState { root_dir }; + let listener = tokio::net::TcpListener::bind(addr).await?; let app = Router::new() - .route("/*path", get(file_handler)) - .with_state(Arc::new(state)) - .nest_service("/tower", dir_service); - let listener = tokio::net::TcpListener::bind(addr).await?; - axum::serve(listener, app).await?; + .nest_service("/", get(move |req| custom_serve_dir(req, serve_base_dir))) + .route("/favicon.ico", get(favicon)) + .route("/health", get(health_check)); + + let app = NormalizePath::trim_trailing_slash(app); + axum::serve( + listener, + axum::ServiceExt::::into_make_service(app), + ) + .await?; Ok(()) } -async fn file_handler( - State(state): State>, - Path(path): Path, -) -> (StatusCode, String) { - //TODO: improve file_handler to handle more file types - let path = state.path.join(path); - if path.exists() { - match fs::read_to_string(path).await { - Ok(content) => (StatusCode::OK, content), - Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()), +/// health check for K8s liveness and readiness probe +async fn health_check() -> impl IntoResponse { + "ok" +} + +async fn favicon() -> impl IntoResponse { + // one pixel favicon generated from https://png-pixel.com/ + let one_pixel_favicon = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mPk+89QDwADvgGOSHzRgAAAAABJRU5ErkJggg=="; + let pixel_favicon = base64::prelude::BASE64_STANDARD + .decode(one_pixel_favicon) + .unwrap(); + ( + [(header::CONTENT_TYPE, HeaderValue::from_static("image/png"))], + pixel_favicon, + ) +} + +/// Serve a directory, Because the ServeDir service does not support listing directories, we need to implement it ourselves. +/// If the path is a directory, list its contents. +/// If the path is a file, use ServiDir to serve it. +/// Because ServeDir in nested route causes invalid redirects #1731 https://github.com/tokio-rs/axum/issues/1731, handle StatusCode::TEMPORARY_REDIRECT manually here to avoid the problem. +async fn custom_serve_dir( + req: Request, + serve_base_dir: String, +) -> Result { + let mut path = req.uri().path().trim_start_matches('/').to_owned(); + if path.is_empty() { + path = ".".to_string(); + } + let full_path = PathBuf::from(&serve_base_dir).join(&path); + tracing::debug!( + "custom_serve_dir get req_path is {:?} and serve path={:?}", + path, + full_path + ); + + let service = ServeDir::new(&serve_base_dir); + let result = service.oneshot(req).await; + match result { + Ok(res) => match res.status() { + StatusCode::NOT_FOUND | StatusCode::TEMPORARY_REDIRECT + if PathBuf::from(&full_path).is_dir() => + { + let rs = visit_dir_one_level(&full_path, &serve_base_dir).await; + match rs { + Ok(files) => Ok(DirListTemplate { + lister: DirLister { files }, + cur_path: path.to_string(), + } + .into_response()), + Err(e) => Ok(ErrorTemplate { + err: ResponseError::InternalError(e.to_string()), + cur_path: path.to_string(), + message: e.to_string(), + } + .into_response()), + } + } + StatusCode::NOT_FOUND => Ok(ErrorTemplate { + err: ResponseError::FileNotFound("File Not Found".to_string()), + cur_path: path.to_string(), + message: "File Not Found".to_string(), + } + .into_response()), + StatusCode::BAD_REQUEST => Ok(ErrorTemplate { + err: ResponseError::BadRequest("Bad Request".to_string()), + cur_path: path.to_string(), + message: "Bad Request".to_string(), + } + .into_response()), + StatusCode::INTERNAL_SERVER_ERROR => Ok(ErrorTemplate { + err: ResponseError::BadRequest("Internal Server Error".to_string()), + cur_path: path.to_string(), + message: "Internal Server Error".to_string(), + } + .into_response()), + _ => Ok(res.into_response()), + }, + Err(err) => Err(( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Something went wrong: {}", err), + )), + } +} + +async fn visit_dir_one_level(dir_path: &PathBuf, prefix: &str) -> io::Result> { + let mut dir = fs::read_dir(dir_path).await?; + let mut files: Vec = Vec::new(); + + while let Some(child) = dir.next_entry().await? { + let the_path = child.path().to_string_lossy().to_string(); + let the_uri_path: String; + if !prefix.is_empty() && !the_path.starts_with(prefix) { + tracing::error!("visit_dir_one_level skip invalid path={}", the_path); + continue; + } else if prefix != "/" { + the_uri_path = the_path.strip_prefix(prefix).unwrap().to_string(); + } else { + the_uri_path = the_path; } - } else { - (StatusCode::NOT_FOUND, "Not Found".to_string()) + files.push(FileInfo { + name: child.file_name().to_string_lossy().to_string(), + ext: PathBuf::from(child.file_name()) + .extension() + .and_then(OsStr::to_str) + .unwrap_or_default() + .to_string(), + mime_type: mime_guess::from_path(child.path()) + .first_or_octet_stream() + .type_() + .to_string(), + path_uri: the_uri_path, + is_file: child.file_type().await?.is_file(), + last_modified: child + .metadata() + .await? + .modified()? + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap() + .as_secs() as i64, + }); } + + Ok(files) } +/// Custom filters for askama template +mod filters { + pub(crate) fn datetime(ts: &i64) -> ::askama::Result { + if let Ok(format) = + time::format_description::parse("[year]-[month]-[day] [hour]:[minute]:[second] UTC") + { + return Ok(time::OffsetDateTime::from_unix_timestamp(*ts) + .unwrap() + .format(&format) + .unwrap()); + } + Err(askama::Error::Fmt(std::fmt::Error)) + } +} + +const FAIL_REASON_HEADER_NAME: &str = "Rcli-Http-Serve-Fail-Reason"; + +pub(crate) enum ResponseError { + BadRequest(String), + FileNotFound(String), + InternalError(String), +} + +#[derive(Template)] +#[template(path = "index.html")] +struct DirListTemplate { + lister: DirLister, + cur_path: String, +} + +struct FileInfo { + name: String, + ext: String, + mime_type: String, + path_uri: String, + is_file: bool, + last_modified: i64, +} + +struct DirLister { + files: Vec, +} + +#[derive(Template)] +#[template(path = "error.html")] +struct ErrorTemplate { + err: ResponseError, + cur_path: String, + message: String, +} + +impl IntoResponse for ErrorTemplate { + fn into_response(self) -> Response { + let t = self; + match t.render() { + Ok(html) => { + let mut resp = Html(html).into_response(); + match t.err { + ResponseError::FileNotFound(reason) => { + *resp.status_mut() = StatusCode::NOT_FOUND; + resp.headers_mut() + .insert(FAIL_REASON_HEADER_NAME, reason.parse().unwrap()); + } + ResponseError::BadRequest(reason) => { + *resp.status_mut() = StatusCode::BAD_REQUEST; + resp.headers_mut() + .insert(FAIL_REASON_HEADER_NAME, reason.parse().unwrap()); + } + ResponseError::InternalError(reason) => { + *resp.status_mut() = StatusCode::INTERNAL_SERVER_ERROR; + resp.headers_mut() + .insert(FAIL_REASON_HEADER_NAME, reason.parse().unwrap()); + } + } + resp + } + Err(err) => { + tracing::error!("template render failed, err={}", err); + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to render template. Error: {}", err), + ) + .into_response() + } + } + } +} + +impl IntoResponse for DirListTemplate { + fn into_response(self) -> Response { + let t = self; + match t.render() { + Ok(html) => Html(html).into_response(), + Err(err) => { + tracing::error!("template render failed, err={}", err); + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to render template. Error: {}", err), + ) + .into_response() + } + } + } +} + +// 添加单元测试,测试visit_dir_one_level函数 #[cfg(test)] mod tests { + use super::*; + use std::fs; #[tokio::test] - async fn test_file_handler() { - let state = Arc::new(HttpServeState { - path: PathBuf::from("."), - }); - let (status, content) = file_handler(State(state), Path("Cargo.toml".to_string())).await; - assert_eq!(status, StatusCode::OK); - assert!(content.trim().starts_with("[package]")); + async fn test_visit_dir_one_level() { + let dir = tempfile::tempdir().unwrap(); + let dir_path = dir.path(); + // create a file + fs::File::create(dir_path.join("a.txt")).unwrap(); + // create a sub directory + fs::create_dir(dir_path.join("b_dir")).unwrap(); + let mut files = visit_dir_one_level(&dir_path.to_path_buf(), "") + .await + .unwrap(); + files.sort_by(|a, b| a.name.cmp(&b.name)); + assert_eq!(files.len(), 2); + assert_eq!(files[0].name, "a.txt"); + assert_eq!(files[0].ext, "txt"); + assert_eq!(files[0].mime_type, "text"); + assert!(files[0].is_file); + assert!(files[0].path_uri.ends_with("a.txt")); + + assert_eq!(files[1].name, "b_dir"); + assert_eq!(files[1].ext, ""); + assert_eq!(files[1].mime_type, "application"); + assert!(!files[1].is_file); + assert!(files[1].path_uri.ends_with("b_dir")); } } diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..b1dd5b6 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,71 @@ + + + + + + {% block title %}{{ title }}{% endblock %} + + + {% block head %}{% endblock %} + + + + +
+ {% block content %}{% endblock %} +
+ + + + {% block foot %}{% endblock %} + + + + diff --git a/templates/error.html b/templates/error.html new file mode 100644 index 0000000..690d1dc --- /dev/null +++ b/templates/error.html @@ -0,0 +1,16 @@ +{% extends "base.html" %} + +{% block title %}Rcli-http-serve{% endblock %} + +{% block content %} +

Error Occurred when listing for {{ cur_path }}

+ +
+ +
+
Request Path:
/{{ cur_path }}
+
Error Message:
{{ message }}
+
+ +
+{% endblock %} diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..894b229 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,87 @@ +{% extends "base.html" %} + +{% block title %}Rcli-http-serve{% endblock %} + +{% block head %} + +{% endblock %} + + +{% block content %} +

Directory listing for {{ cur_path }}

+
+ +
    + + {% if cur_path != "" %} + +
  1. ../
  2. + + {% endif %} + + {% for file in lister.files %} + + {% if file.is_file %} +
  3. + + + {{file.name}} + + +
  4. + + {% else %} +
  5. {{file.name}}/
  6. + {% endif %} + + {% endfor %} +
+ +
+ +{% endblock %} + + +{% block foot %} + + + + + + + + + + + + + + + +{% endblock %}