itsi-server 0.1.1 → 0.1.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +5 -0
- data/CODE_OF_CONDUCT.md +7 -0
- data/Cargo.lock +3937 -0
- data/Cargo.toml +7 -0
- data/README.md +4 -0
- data/Rakefile +8 -1
- data/_index.md +6 -0
- data/exe/itsi +141 -46
- data/ext/itsi_error/Cargo.toml +3 -0
- data/ext/itsi_error/src/lib.rs +98 -24
- data/ext/itsi_error/target/debug/build/clang-sys-da71b0344e568175/out/common.rs +355 -0
- data/ext/itsi_error/target/debug/build/clang-sys-da71b0344e568175/out/dynamic.rs +276 -0
- data/ext/itsi_error/target/debug/build/clang-sys-da71b0344e568175/out/macros.rs +49 -0
- data/ext/itsi_error/target/debug/build/rb-sys-49f554618693db24/out/bindings-0.9.110-mri-arm64-darwin23-3.4.2.rs +8865 -0
- data/ext/itsi_error/target/debug/incremental/itsi_error-1mmt5sux7jb0i/s-h510z7m8v9-0bxu7yd.lock +0 -0
- data/ext/itsi_error/target/debug/incremental/itsi_error-2vn3jey74oiw0/s-h5113n0e7e-1v5qzs6.lock +0 -0
- data/ext/itsi_error/target/debug/incremental/itsi_error-37uv9dicz7awp/s-h510ykifhe-0tbnep2.lock +0 -0
- data/ext/itsi_error/target/debug/incremental/itsi_error-37uv9dicz7awp/s-h510yyocpj-0tz7ug7.lock +0 -0
- data/ext/itsi_error/target/debug/incremental/itsi_error-37uv9dicz7awp/s-h510z0xc8g-14ol18k.lock +0 -0
- data/ext/itsi_error/target/debug/incremental/itsi_error-3g5qf4y7d54uj/s-h5113n0e7d-1trk8on.lock +0 -0
- data/ext/itsi_error/target/debug/incremental/itsi_error-3lpfftm45d3e2/s-h510z7m8r3-1pxp20o.lock +0 -0
- data/ext/itsi_error/target/debug/incremental/itsi_error-3o4qownhl3d7n/s-h510ykifek-1uxasnk.lock +0 -0
- data/ext/itsi_error/target/debug/incremental/itsi_error-3o4qownhl3d7n/s-h510yyocki-11u37qm.lock +0 -0
- data/ext/itsi_error/target/debug/incremental/itsi_error-3o4qownhl3d7n/s-h510z0xc93-0pmy0zm.lock +0 -0
- data/ext/itsi_instrument_entry/Cargo.toml +15 -0
- data/ext/itsi_instrument_entry/src/lib.rs +31 -0
- data/ext/itsi_rb_helpers/Cargo.toml +3 -0
- data/ext/itsi_rb_helpers/src/heap_value.rs +139 -0
- data/ext/itsi_rb_helpers/src/lib.rs +140 -10
- data/ext/itsi_rb_helpers/target/debug/build/clang-sys-da71b0344e568175/out/common.rs +355 -0
- data/ext/itsi_rb_helpers/target/debug/build/clang-sys-da71b0344e568175/out/dynamic.rs +276 -0
- data/ext/itsi_rb_helpers/target/debug/build/clang-sys-da71b0344e568175/out/macros.rs +49 -0
- data/ext/itsi_rb_helpers/target/debug/build/rb-sys-eb9ed4ff3a60f995/out/bindings-0.9.110-mri-arm64-darwin23-3.4.2.rs +8865 -0
- data/ext/itsi_rb_helpers/target/debug/incremental/itsi_rb_helpers-040pxg6yhb3g3/s-h5113n7a1b-03bwlt4.lock +0 -0
- data/ext/itsi_rb_helpers/target/debug/incremental/itsi_rb_helpers-131g1u4dzkt1a/s-h51113xnh3-1eik1ip.lock +0 -0
- data/ext/itsi_rb_helpers/target/debug/incremental/itsi_rb_helpers-131g1u4dzkt1a/s-h5111704jj-0g4rj8x.lock +0 -0
- data/ext/itsi_rb_helpers/target/debug/incremental/itsi_rb_helpers-1q2d3drtxrzs5/s-h5113n79yl-0bxcqc5.lock +0 -0
- data/ext/itsi_rb_helpers/target/debug/incremental/itsi_rb_helpers-374a9h7ovycj0/s-h51113xoox-10de2hp.lock +0 -0
- data/ext/itsi_rb_helpers/target/debug/incremental/itsi_rb_helpers-374a9h7ovycj0/s-h5111704w7-0vdq7gq.lock +0 -0
- data/ext/itsi_scheduler/Cargo.toml +24 -0
- data/ext/itsi_scheduler/src/itsi_scheduler/io_helpers.rs +56 -0
- data/ext/itsi_scheduler/src/itsi_scheduler/io_waiter.rs +44 -0
- data/ext/itsi_scheduler/src/itsi_scheduler/timer.rs +44 -0
- data/ext/itsi_scheduler/src/itsi_scheduler.rs +308 -0
- data/ext/itsi_scheduler/src/lib.rs +38 -0
- data/ext/itsi_server/Cargo.lock +2956 -0
- data/ext/itsi_server/Cargo.toml +72 -14
- data/ext/itsi_server/extconf.rb +1 -1
- data/ext/itsi_server/src/default_responses/html/401.html +68 -0
- data/ext/itsi_server/src/default_responses/html/403.html +68 -0
- data/ext/itsi_server/src/default_responses/html/404.html +68 -0
- data/ext/itsi_server/src/default_responses/html/413.html +71 -0
- data/ext/itsi_server/src/default_responses/html/429.html +68 -0
- data/ext/itsi_server/src/default_responses/html/500.html +71 -0
- data/ext/itsi_server/src/default_responses/html/502.html +71 -0
- data/ext/itsi_server/src/default_responses/html/503.html +68 -0
- data/ext/itsi_server/src/default_responses/html/504.html +69 -0
- data/ext/itsi_server/src/default_responses/html/index.html +238 -0
- data/ext/itsi_server/src/default_responses/json/401.json +6 -0
- data/ext/itsi_server/src/default_responses/json/403.json +6 -0
- data/ext/itsi_server/src/default_responses/json/404.json +6 -0
- data/ext/itsi_server/src/default_responses/json/413.json +6 -0
- data/ext/itsi_server/src/default_responses/json/429.json +6 -0
- data/ext/itsi_server/src/default_responses/json/500.json +6 -0
- data/ext/itsi_server/src/default_responses/json/502.json +6 -0
- data/ext/itsi_server/src/default_responses/json/503.json +6 -0
- data/ext/itsi_server/src/default_responses/json/504.json +6 -0
- data/ext/itsi_server/src/default_responses/mod.rs +11 -0
- data/ext/itsi_server/src/env.rs +43 -0
- data/ext/itsi_server/src/lib.rs +132 -40
- data/ext/itsi_server/src/prelude.rs +2 -0
- data/ext/itsi_server/src/ruby_types/itsi_body_proxy/big_bytes.rs +109 -0
- data/ext/itsi_server/src/ruby_types/itsi_body_proxy/mod.rs +143 -0
- data/ext/itsi_server/src/ruby_types/itsi_grpc_call.rs +344 -0
- data/ext/itsi_server/src/ruby_types/itsi_grpc_response_stream/mod.rs +264 -0
- data/ext/itsi_server/src/ruby_types/itsi_http_request.rs +345 -0
- data/ext/itsi_server/src/ruby_types/itsi_http_response.rs +391 -0
- data/ext/itsi_server/src/ruby_types/itsi_server/file_watcher.rs +225 -0
- data/ext/itsi_server/src/ruby_types/itsi_server/itsi_server_config.rs +375 -0
- data/ext/itsi_server/src/ruby_types/itsi_server.rs +83 -0
- data/ext/itsi_server/src/ruby_types/mod.rs +48 -0
- data/ext/itsi_server/src/server/binds/bind.rs +201 -0
- data/ext/itsi_server/src/server/binds/bind_protocol.rs +37 -0
- data/ext/itsi_server/src/server/binds/listener.rs +432 -0
- data/ext/itsi_server/src/server/binds/mod.rs +4 -0
- data/ext/itsi_server/src/server/binds/tls/locked_dir_cache.rs +132 -0
- data/ext/itsi_server/src/server/binds/tls.rs +270 -0
- data/ext/itsi_server/src/server/byte_frame.rs +32 -0
- data/ext/itsi_server/src/server/http_message_types.rs +97 -0
- data/ext/itsi_server/src/server/io_stream.rs +105 -0
- data/ext/itsi_server/src/server/lifecycle_event.rs +12 -0
- data/ext/itsi_server/src/server/middleware_stack/middleware.rs +165 -0
- data/ext/itsi_server/src/server/middleware_stack/middlewares/allow_list.rs +56 -0
- data/ext/itsi_server/src/server/middleware_stack/middlewares/auth_api_key.rs +87 -0
- data/ext/itsi_server/src/server/middleware_stack/middlewares/auth_basic.rs +86 -0
- data/ext/itsi_server/src/server/middleware_stack/middlewares/auth_jwt.rs +285 -0
- data/ext/itsi_server/src/server/middleware_stack/middlewares/cache_control.rs +142 -0
- data/ext/itsi_server/src/server/middleware_stack/middlewares/compression.rs +289 -0
- data/ext/itsi_server/src/server/middleware_stack/middlewares/cors.rs +292 -0
- data/ext/itsi_server/src/server/middleware_stack/middlewares/deny_list.rs +55 -0
- data/ext/itsi_server/src/server/middleware_stack/middlewares/error_response/default_responses.rs +190 -0
- data/ext/itsi_server/src/server/middleware_stack/middlewares/error_response.rs +157 -0
- data/ext/itsi_server/src/server/middleware_stack/middlewares/etag.rs +195 -0
- data/ext/itsi_server/src/server/middleware_stack/middlewares/header_interpretation.rs +82 -0
- data/ext/itsi_server/src/server/middleware_stack/middlewares/intrusion_protection.rs +201 -0
- data/ext/itsi_server/src/server/middleware_stack/middlewares/log_requests.rs +82 -0
- data/ext/itsi_server/src/server/middleware_stack/middlewares/max_body.rs +47 -0
- data/ext/itsi_server/src/server/middleware_stack/middlewares/mod.rs +87 -0
- data/ext/itsi_server/src/server/middleware_stack/middlewares/proxy.rs +414 -0
- data/ext/itsi_server/src/server/middleware_stack/middlewares/rate_limit.rs +131 -0
- data/ext/itsi_server/src/server/middleware_stack/middlewares/redirect.rs +76 -0
- data/ext/itsi_server/src/server/middleware_stack/middlewares/request_headers.rs +44 -0
- data/ext/itsi_server/src/server/middleware_stack/middlewares/response_headers.rs +36 -0
- data/ext/itsi_server/src/server/middleware_stack/middlewares/ruby_app.rs +126 -0
- data/ext/itsi_server/src/server/middleware_stack/middlewares/static_assets.rs +180 -0
- data/ext/itsi_server/src/server/middleware_stack/middlewares/static_response.rs +55 -0
- data/ext/itsi_server/src/server/middleware_stack/middlewares/string_rewrite.rs +163 -0
- data/ext/itsi_server/src/server/middleware_stack/middlewares/token_source.rs +12 -0
- data/ext/itsi_server/src/server/middleware_stack/mod.rs +347 -0
- data/ext/itsi_server/src/server/mod.rs +12 -5
- data/ext/itsi_server/src/server/process_worker.rs +247 -0
- data/ext/itsi_server/src/server/request_job.rs +11 -0
- data/ext/itsi_server/src/server/serve_strategy/cluster_mode.rs +342 -0
- data/ext/itsi_server/src/server/serve_strategy/mod.rs +30 -0
- data/ext/itsi_server/src/server/serve_strategy/single_mode.rs +421 -0
- data/ext/itsi_server/src/server/signal.rs +76 -0
- data/ext/itsi_server/src/server/size_limited_incoming.rs +101 -0
- data/ext/itsi_server/src/server/thread_worker.rs +475 -0
- data/ext/itsi_server/src/services/cache_store.rs +74 -0
- data/ext/itsi_server/src/services/itsi_http_service.rs +239 -0
- data/ext/itsi_server/src/services/mime_types.rs +1416 -0
- data/ext/itsi_server/src/services/mod.rs +6 -0
- data/ext/itsi_server/src/services/password_hasher.rs +83 -0
- data/ext/itsi_server/src/services/rate_limiter.rs +569 -0
- data/ext/itsi_server/src/services/static_file_server.rs +1324 -0
- data/ext/itsi_tracing/Cargo.toml +5 -0
- data/ext/itsi_tracing/src/lib.rs +315 -7
- data/ext/itsi_tracing/target/debug/incremental/itsi_tracing-0994n8rpvvt9m/s-h510hfz1f6-1kbycmq.lock +0 -0
- data/ext/itsi_tracing/target/debug/incremental/itsi_tracing-0bob7bf4yq34i/s-h5113125h5-0lh4rag.lock +0 -0
- data/ext/itsi_tracing/target/debug/incremental/itsi_tracing-2fcodulrxbbxo/s-h510h2infk-0hp5kjw.lock +0 -0
- data/ext/itsi_tracing/target/debug/incremental/itsi_tracing-2iak63r1woi1l/s-h510h2in4q-0kxfzw1.lock +0 -0
- data/ext/itsi_tracing/target/debug/incremental/itsi_tracing-2kk4qj9gn5dg2/s-h5113124kv-0enwon2.lock +0 -0
- data/ext/itsi_tracing/target/debug/incremental/itsi_tracing-2mwo0yas7dtw4/s-h510hfz1ha-1udgpei.lock +0 -0
- data/lib/itsi/http_request/response_status_shortcodes.rb +74 -0
- data/lib/itsi/http_request.rb +186 -0
- data/lib/itsi/http_response.rb +41 -0
- data/lib/itsi/passfile.rb +109 -0
- data/lib/itsi/server/config/dsl.rb +565 -0
- data/lib/itsi/server/config.rb +166 -0
- data/lib/itsi/server/default_app/default_app.rb +34 -0
- data/lib/itsi/server/default_app/index.html +115 -0
- data/lib/itsi/server/default_config/Itsi-rackup.rb +119 -0
- data/lib/itsi/server/default_config/Itsi.rb +107 -0
- data/lib/itsi/server/grpc/grpc_call.rb +246 -0
- data/lib/itsi/server/grpc/grpc_interface.rb +100 -0
- data/lib/itsi/server/grpc/reflection/v1/reflection_pb.rb +26 -0
- data/lib/itsi/server/grpc/reflection/v1/reflection_services_pb.rb +122 -0
- data/lib/itsi/server/rack/handler/itsi.rb +27 -0
- data/lib/itsi/server/rack_interface.rb +94 -0
- data/lib/itsi/server/route_tester.rb +107 -0
- data/lib/itsi/server/scheduler_interface.rb +21 -0
- data/lib/itsi/server/scheduler_mode.rb +10 -0
- data/lib/itsi/server/signal_trap.rb +29 -0
- data/lib/itsi/server/typed_handlers/param_parser.rb +200 -0
- data/lib/itsi/server/typed_handlers/source_parser.rb +55 -0
- data/lib/itsi/server/typed_handlers.rb +17 -0
- data/lib/itsi/server/version.rb +1 -1
- data/lib/itsi/server.rb +160 -9
- data/lib/itsi/standard_headers.rb +86 -0
- data/lib/ruby_lsp/itsi/addon.rb +111 -0
- data/lib/shell_completions/completions.rb +26 -0
- metadata +182 -25
- data/ext/itsi_server/src/request/itsi_request.rs +0 -143
- data/ext/itsi_server/src/request/mod.rs +0 -1
- data/ext/itsi_server/src/server/bind.rs +0 -138
- data/ext/itsi_server/src/server/itsi_ca/itsi_ca.crt +0 -32
- data/ext/itsi_server/src/server/itsi_ca/itsi_ca.key +0 -52
- data/ext/itsi_server/src/server/itsi_server.rs +0 -182
- data/ext/itsi_server/src/server/listener.rs +0 -218
- data/ext/itsi_server/src/server/tls.rs +0 -138
- data/ext/itsi_server/src/server/transfer_protocol.rs +0 -23
- data/ext/itsi_server/src/stream_writer/mod.rs +0 -21
- data/lib/itsi/request.rb +0 -39
@@ -0,0 +1,1324 @@
|
|
1
|
+
use crate::{
|
2
|
+
default_responses::{INTERNAL_SERVER_ERROR_RESPONSE, NOT_FOUND_RESPONSE},
|
3
|
+
prelude::*,
|
4
|
+
server::{
|
5
|
+
http_message_types::{HttpRequest, HttpResponse, RequestExt, ResponseFormat},
|
6
|
+
middleware_stack::ErrorResponse,
|
7
|
+
},
|
8
|
+
};
|
9
|
+
use base64::{engine::general_purpose, Engine};
|
10
|
+
use bytes::Bytes;
|
11
|
+
use chrono::{DateTime, Utc};
|
12
|
+
use http::{
|
13
|
+
header::{
|
14
|
+
self, CONTENT_ENCODING, CONTENT_LENGTH, CONTENT_RANGE, CONTENT_TYPE, ETAG, LAST_MODIFIED,
|
15
|
+
},
|
16
|
+
HeaderValue, Response, StatusCode,
|
17
|
+
};
|
18
|
+
use http_body_util::{combinators::BoxBody, Full};
|
19
|
+
use itsi_error::Result;
|
20
|
+
use moka::sync::Cache;
|
21
|
+
use percent_encoding::{percent_decode_str, utf8_percent_encode, NON_ALPHANUMERIC};
|
22
|
+
use serde::Deserialize;
|
23
|
+
use serde_json::json;
|
24
|
+
use sha2::{Digest, Sha256};
|
25
|
+
use std::{
|
26
|
+
borrow::Cow,
|
27
|
+
cmp::Ordering,
|
28
|
+
collections::HashMap,
|
29
|
+
convert::Infallible,
|
30
|
+
fs::Metadata,
|
31
|
+
ops::Deref,
|
32
|
+
path::{Path, PathBuf},
|
33
|
+
sync::{Arc, LazyLock},
|
34
|
+
time::{Duration, Instant, SystemTime},
|
35
|
+
};
|
36
|
+
use tokio::sync::Mutex;
|
37
|
+
use tokio::{fs::File, io::AsyncReadExt};
|
38
|
+
|
39
|
+
use super::mime_types::get_mime_type;
|
40
|
+
|
41
|
+
pub static ROOT_STATIC_FILE_SERVER: LazyLock<StaticFileServer> = LazyLock::new(|| {
|
42
|
+
StaticFileServer::new(StaticFileServerConfig {
|
43
|
+
root_dir: Path::new("./").to_path_buf(),
|
44
|
+
max_file_size: 4096,
|
45
|
+
max_entries: 1024 * 1024 * 10,
|
46
|
+
recheck_interval: Duration::from_secs(1),
|
47
|
+
try_html_extension: true,
|
48
|
+
auto_index: true,
|
49
|
+
not_found_behavior: NotFoundBehavior::Error(ErrorResponse::not_found()),
|
50
|
+
serve_hidden_files: false,
|
51
|
+
allowed_extensions: vec!["html".to_string(), "css".to_string(), "js".to_string()],
|
52
|
+
})
|
53
|
+
});
|
54
|
+
|
55
|
+
#[derive(Debug, Clone, Deserialize)]
|
56
|
+
pub struct Redirect {
|
57
|
+
pub to: String,
|
58
|
+
}
|
59
|
+
|
60
|
+
#[derive(Debug, Clone, Deserialize)]
|
61
|
+
pub enum NotFoundBehavior {
|
62
|
+
#[serde(rename = "error")]
|
63
|
+
Error(ErrorResponse),
|
64
|
+
#[serde(rename = "fallthrough")]
|
65
|
+
FallThrough,
|
66
|
+
#[serde(rename = "index")]
|
67
|
+
IndexFile(PathBuf),
|
68
|
+
#[serde(rename = "redirect")]
|
69
|
+
Redirect(Redirect),
|
70
|
+
#[serde(rename = "internal_server_error")]
|
71
|
+
InternalServerError,
|
72
|
+
}
|
73
|
+
|
74
|
+
#[derive(Debug, Clone)]
|
75
|
+
pub struct StaticFileServerConfig {
|
76
|
+
pub root_dir: PathBuf,
|
77
|
+
pub max_file_size: u64,
|
78
|
+
pub max_entries: u64,
|
79
|
+
pub recheck_interval: Duration,
|
80
|
+
pub try_html_extension: bool,
|
81
|
+
pub auto_index: bool,
|
82
|
+
pub not_found_behavior: NotFoundBehavior,
|
83
|
+
pub serve_hidden_files: bool,
|
84
|
+
pub allowed_extensions: Vec<String>,
|
85
|
+
}
|
86
|
+
|
87
|
+
#[derive(Debug, Clone)]
|
88
|
+
pub struct StaticFileServer {
|
89
|
+
config: Arc<StaticFileServerConfig>,
|
90
|
+
key_to_path: Arc<Mutex<HashMap<String, PathBuf>>>,
|
91
|
+
cache: Cache<PathBuf, CacheEntry>,
|
92
|
+
}
|
93
|
+
|
94
|
+
impl Deref for StaticFileServer {
|
95
|
+
type Target = StaticFileServerConfig;
|
96
|
+
|
97
|
+
fn deref(&self) -> &Self::Target {
|
98
|
+
&self.config
|
99
|
+
}
|
100
|
+
}
|
101
|
+
|
102
|
+
#[derive(Clone, Debug)]
|
103
|
+
struct CacheEntry {
|
104
|
+
content: Arc<Bytes>,
|
105
|
+
br_encoded: Option<Arc<Bytes>>,
|
106
|
+
zstd_encoded: Option<Arc<Bytes>>,
|
107
|
+
gzip_encoded: Option<Arc<Bytes>>,
|
108
|
+
deflate_encoded: Option<Arc<Bytes>>,
|
109
|
+
etag: String,
|
110
|
+
last_modified: SystemTime,
|
111
|
+
last_checked: Instant,
|
112
|
+
}
|
113
|
+
|
114
|
+
impl CacheEntry {
|
115
|
+
pub fn suggest_content_for(&self, supported_encodings: &[HeaderValue]) -> (Arc<Bytes>, &str) {
|
116
|
+
for encoding_header in supported_encodings {
|
117
|
+
if let Ok(header_value) = encoding_header.to_str() {
|
118
|
+
for header_value in header_value.split(",").map(|hv| hv.trim()) {
|
119
|
+
for algo in header_value.split(";").map(|hv| hv.trim()) {
|
120
|
+
match algo {
|
121
|
+
"zstd" if self.zstd_encoded.is_some() => {
|
122
|
+
return (self.zstd_encoded.clone().unwrap(), "zstd")
|
123
|
+
}
|
124
|
+
"gzip" if self.gzip_encoded.is_some() => {
|
125
|
+
return (self.gzip_encoded.clone().unwrap(), "gzip")
|
126
|
+
}
|
127
|
+
"br" if self.br_encoded.is_some() => {
|
128
|
+
return (self.br_encoded.clone().unwrap(), "br")
|
129
|
+
}
|
130
|
+
"deflate" if self.deflate_encoded.is_some() => {
|
131
|
+
return (self.deflate_encoded.clone().unwrap(), "deflate")
|
132
|
+
}
|
133
|
+
_ => {}
|
134
|
+
}
|
135
|
+
}
|
136
|
+
}
|
137
|
+
}
|
138
|
+
}
|
139
|
+
(self.content.clone(), "identity")
|
140
|
+
}
|
141
|
+
}
|
142
|
+
|
143
|
+
#[derive(Debug, Clone)]
|
144
|
+
pub enum ServeRange {
|
145
|
+
Range(u64, u64),
|
146
|
+
Full,
|
147
|
+
}
|
148
|
+
|
149
|
+
impl CacheEntry {
|
150
|
+
async fn new(path: PathBuf) -> Result<Self> {
|
151
|
+
let (bytes, last_modified) = read_entire_file(&path).await?;
|
152
|
+
let etag = {
|
153
|
+
let mut hasher = Sha256::new();
|
154
|
+
hasher.update(&bytes);
|
155
|
+
let result = hasher.finalize();
|
156
|
+
general_purpose::STANDARD.encode(result)
|
157
|
+
};
|
158
|
+
Ok(CacheEntry {
|
159
|
+
content: Arc::new(bytes),
|
160
|
+
gzip_encoded: read_variant(&path, "gz").await.map(Arc::new),
|
161
|
+
br_encoded: read_variant(&path, "br").await.map(Arc::new),
|
162
|
+
zstd_encoded: read_variant(&path, "zstd").await.map(Arc::new),
|
163
|
+
deflate_encoded: read_variant(&path, "deflate").await.map(Arc::new),
|
164
|
+
last_modified,
|
165
|
+
etag,
|
166
|
+
last_checked: Instant::now(),
|
167
|
+
})
|
168
|
+
}
|
169
|
+
|
170
|
+
async fn new_virtual_listing(
|
171
|
+
path: PathBuf,
|
172
|
+
config: &StaticFileServerConfig,
|
173
|
+
accept: ResponseFormat,
|
174
|
+
) -> Self {
|
175
|
+
let directory_listing: Bytes =
|
176
|
+
generate_directory_listing(path.parent().unwrap(), config, accept)
|
177
|
+
.await
|
178
|
+
.unwrap_or("".to_owned())
|
179
|
+
.into();
|
180
|
+
let etag = {
|
181
|
+
let mut hasher = Sha256::new();
|
182
|
+
hasher.update(&directory_listing);
|
183
|
+
let result = hasher.finalize();
|
184
|
+
general_purpose::STANDARD.encode(result)
|
185
|
+
};
|
186
|
+
CacheEntry {
|
187
|
+
content: Arc::new(directory_listing),
|
188
|
+
gzip_encoded: None,
|
189
|
+
br_encoded: None,
|
190
|
+
zstd_encoded: None,
|
191
|
+
deflate_encoded: None,
|
192
|
+
last_modified: SystemTime::now(),
|
193
|
+
etag,
|
194
|
+
last_checked: Instant::now(),
|
195
|
+
}
|
196
|
+
}
|
197
|
+
}
|
198
|
+
|
199
|
+
struct ServeStreamArgs(PathBuf, Metadata, u64, u64, bool, Option<SystemTime>, bool);
|
200
|
+
struct ServeCacheArgs<'a>(
|
201
|
+
&'a CacheEntry,
|
202
|
+
u64,
|
203
|
+
u64,
|
204
|
+
bool,
|
205
|
+
Option<SystemTime>,
|
206
|
+
bool,
|
207
|
+
&'a Path,
|
208
|
+
&'a [HeaderValue],
|
209
|
+
);
|
210
|
+
|
211
|
+
impl StaticFileServer {
|
212
|
+
pub fn new(config: StaticFileServerConfig) -> Self {
|
213
|
+
let cache = Cache::builder().max_capacity(config.max_entries).build();
|
214
|
+
|
215
|
+
StaticFileServer {
|
216
|
+
config: Arc::new(config),
|
217
|
+
cache,
|
218
|
+
key_to_path: Arc::new(Mutex::new(HashMap::new())),
|
219
|
+
}
|
220
|
+
}
|
221
|
+
|
222
|
+
#[allow(clippy::too_many_arguments)]
|
223
|
+
pub async fn serve(
|
224
|
+
&self,
|
225
|
+
request: &HttpRequest,
|
226
|
+
path: &str,
|
227
|
+
abs_path: &str,
|
228
|
+
serve_range: ServeRange,
|
229
|
+
if_modified_since: Option<SystemTime>,
|
230
|
+
is_head_request: bool,
|
231
|
+
supported_encodings: &[HeaderValue],
|
232
|
+
) -> Option<HttpResponse> {
|
233
|
+
let accept: ResponseFormat = request.accept().into();
|
234
|
+
let resolved = self.resolve(path, abs_path, accept.clone()).await;
|
235
|
+
|
236
|
+
Some(match resolved {
|
237
|
+
Ok(ResolvedAsset {
|
238
|
+
path,
|
239
|
+
cache_entry,
|
240
|
+
metadata,
|
241
|
+
redirect_to: None,
|
242
|
+
}) => {
|
243
|
+
let (start, end) = match serve_range {
|
244
|
+
ServeRange::Full => (0, u64::MAX),
|
245
|
+
ServeRange::Range(start, end) => (start, end),
|
246
|
+
};
|
247
|
+
let is_range_request = matches!(serve_range, ServeRange::Range { .. });
|
248
|
+
|
249
|
+
if let Some(cache_entry) = cache_entry {
|
250
|
+
self.serve_cached_content(ServeCacheArgs(
|
251
|
+
&cache_entry,
|
252
|
+
start,
|
253
|
+
end,
|
254
|
+
is_range_request,
|
255
|
+
if_modified_since,
|
256
|
+
is_head_request,
|
257
|
+
&path,
|
258
|
+
supported_encodings,
|
259
|
+
))
|
260
|
+
} else {
|
261
|
+
self.serve_stream_content(ServeStreamArgs(
|
262
|
+
path,
|
263
|
+
metadata.unwrap(),
|
264
|
+
start,
|
265
|
+
end,
|
266
|
+
is_range_request,
|
267
|
+
if_modified_since,
|
268
|
+
is_head_request,
|
269
|
+
))
|
270
|
+
.await
|
271
|
+
}
|
272
|
+
}
|
273
|
+
Ok(ResolvedAsset {
|
274
|
+
redirect_to: Some(redirect_to),
|
275
|
+
..
|
276
|
+
}) => Response::builder()
|
277
|
+
.status(StatusCode::MOVED_PERMANENTLY)
|
278
|
+
.header(header::LOCATION, redirect_to)
|
279
|
+
.body(BoxBody::new(Full::new(Bytes::new())))
|
280
|
+
.unwrap(),
|
281
|
+
Err(not_found_behavior) => match not_found_behavior {
|
282
|
+
NotFoundBehavior::Error(error_response) => {
|
283
|
+
error_response
|
284
|
+
.to_http_response(request.accept().into())
|
285
|
+
.await
|
286
|
+
}
|
287
|
+
NotFoundBehavior::FallThrough => return None,
|
288
|
+
NotFoundBehavior::IndexFile(index_file) => {
|
289
|
+
self.serve_single(index_file.to_str().unwrap(), accept, supported_encodings)
|
290
|
+
.await
|
291
|
+
}
|
292
|
+
NotFoundBehavior::Redirect(redirect) => Response::builder()
|
293
|
+
.status(StatusCode::MOVED_PERMANENTLY)
|
294
|
+
.header(header::LOCATION, redirect.to)
|
295
|
+
.body(BoxBody::new(Full::new(Bytes::new())))
|
296
|
+
.unwrap(),
|
297
|
+
NotFoundBehavior::InternalServerError => {
|
298
|
+
INTERNAL_SERVER_ERROR_RESPONSE
|
299
|
+
.to_http_response(request.accept().into())
|
300
|
+
.await
|
301
|
+
}
|
302
|
+
},
|
303
|
+
})
|
304
|
+
}
|
305
|
+
|
306
|
+
pub async fn serve_single_abs(
|
307
|
+
&self,
|
308
|
+
path: &str,
|
309
|
+
accept: ResponseFormat,
|
310
|
+
supported_encodings: &[HeaderValue],
|
311
|
+
) -> HttpResponse {
|
312
|
+
if let (Ok(root), Ok(path_buf)) = (
|
313
|
+
self.root_dir.canonicalize(),
|
314
|
+
PathBuf::from(path).canonicalize(),
|
315
|
+
) {
|
316
|
+
// Check that the path is under root.
|
317
|
+
if let Ok(stripped) = path_buf.strip_prefix(root) {
|
318
|
+
if let Some(stripped_str) = stripped.to_str() {
|
319
|
+
return self
|
320
|
+
.serve_single(stripped_str, accept, supported_encodings)
|
321
|
+
.await;
|
322
|
+
}
|
323
|
+
}
|
324
|
+
}
|
325
|
+
NOT_FOUND_RESPONSE.to_http_response(accept).await
|
326
|
+
}
|
327
|
+
|
328
|
+
pub async fn serve_single(
|
329
|
+
&self,
|
330
|
+
path: &str,
|
331
|
+
accept: ResponseFormat,
|
332
|
+
supported_encodings: &[HeaderValue],
|
333
|
+
) -> HttpResponse {
|
334
|
+
let resolved = self.resolve(path, path, accept).await;
|
335
|
+
if let Ok(ResolvedAsset {
|
336
|
+
path,
|
337
|
+
cache_entry: Some(cache_entry),
|
338
|
+
..
|
339
|
+
}) = resolved
|
340
|
+
{
|
341
|
+
return self.serve_cached_content(ServeCacheArgs(
|
342
|
+
&cache_entry,
|
343
|
+
0,
|
344
|
+
u64::MAX,
|
345
|
+
false,
|
346
|
+
None,
|
347
|
+
false,
|
348
|
+
&path,
|
349
|
+
supported_encodings,
|
350
|
+
));
|
351
|
+
} else if let Ok(ResolvedAsset { path, metadata, .. }) = resolved {
|
352
|
+
return self
|
353
|
+
.serve_stream_content(ServeStreamArgs(
|
354
|
+
path,
|
355
|
+
metadata.unwrap(),
|
356
|
+
0,
|
357
|
+
u64::MAX,
|
358
|
+
false,
|
359
|
+
None,
|
360
|
+
false,
|
361
|
+
))
|
362
|
+
.await;
|
363
|
+
}
|
364
|
+
|
365
|
+
Response::builder()
|
366
|
+
.status(StatusCode::NOT_FOUND)
|
367
|
+
.body(BoxBody::new(Full::new(Bytes::new())))
|
368
|
+
.unwrap()
|
369
|
+
}
|
370
|
+
|
371
|
+
/// Resolves a request key to an actual file path and determines if it needs to be cached
|
372
|
+
async fn resolve(
|
373
|
+
&self,
|
374
|
+
key: &str,
|
375
|
+
abs_path: &str,
|
376
|
+
accept: ResponseFormat,
|
377
|
+
) -> std::result::Result<ResolvedAsset, NotFoundBehavior> {
|
378
|
+
// First check if we have a cached mapping for this key
|
379
|
+
if let Some(path) = self.key_to_path.lock().await.get(key) {
|
380
|
+
// Check if the cached entry is still valid
|
381
|
+
if let Some(entry) = self.cache.get(path) {
|
382
|
+
let last_check_elapsed = entry.last_checked.elapsed();
|
383
|
+
if last_check_elapsed < self.config.recheck_interval {
|
384
|
+
// Entry is still fresh, use it
|
385
|
+
return Ok(ResolvedAsset {
|
386
|
+
path: path.clone(),
|
387
|
+
cache_entry: Some(entry.clone()),
|
388
|
+
metadata: None,
|
389
|
+
redirect_to: None,
|
390
|
+
});
|
391
|
+
}
|
392
|
+
|
393
|
+
// Entry is stale, check if file has changed
|
394
|
+
if let Ok(metadata) = tokio::fs::metadata(path).await {
|
395
|
+
if metadata
|
396
|
+
.modified()
|
397
|
+
.is_ok_and(|modified| modified == entry.last_modified)
|
398
|
+
{
|
399
|
+
// File hasn't changed, just update last_checked
|
400
|
+
let mut entry = entry;
|
401
|
+
entry.last_checked = Instant::now();
|
402
|
+
self.cache.insert(path.clone(), entry.clone());
|
403
|
+
return Ok(ResolvedAsset {
|
404
|
+
path: path.clone(),
|
405
|
+
cache_entry: Some(entry.clone()),
|
406
|
+
metadata: None,
|
407
|
+
redirect_to: None,
|
408
|
+
});
|
409
|
+
}
|
410
|
+
|
411
|
+
// File has changed, check if it's still cacheable
|
412
|
+
if metadata.len() > self.config.max_file_size {
|
413
|
+
// File is now too large, remove from cache
|
414
|
+
self.cache.invalidate(path);
|
415
|
+
self.key_to_path.lock().await.remove(key);
|
416
|
+
}
|
417
|
+
}
|
418
|
+
}
|
419
|
+
}
|
420
|
+
|
421
|
+
// No valid cached entry, resolve the key to a file path
|
422
|
+
let decoded_key = percent_decode_str(key).decode_utf8_lossy();
|
423
|
+
let normalized_path =
|
424
|
+
normalize_path(decoded_key).ok_or(NotFoundBehavior::InternalServerError)?;
|
425
|
+
|
426
|
+
if !self.config.serve_hidden_files
|
427
|
+
&& normalized_path
|
428
|
+
.file_name()
|
429
|
+
.and_then(|f| f.to_str())
|
430
|
+
.unwrap_or("")
|
431
|
+
.starts_with('.')
|
432
|
+
{
|
433
|
+
return Err(self.config.not_found_behavior.clone());
|
434
|
+
}
|
435
|
+
|
436
|
+
let mut full_path = self.config.root_dir.clone();
|
437
|
+
full_path.push(normalized_path);
|
438
|
+
// Check if path exists and is a file
|
439
|
+
match tokio::fs::metadata(&full_path).await {
|
440
|
+
Ok(metadata) => {
|
441
|
+
if metadata.is_file() {
|
442
|
+
let cache_entry = if metadata.len() <= self.config.max_file_size {
|
443
|
+
self.key_to_path
|
444
|
+
.lock()
|
445
|
+
.await
|
446
|
+
.insert(key.to_string(), full_path.clone());
|
447
|
+
let cache_entry = CacheEntry::new(full_path.clone()).await.unwrap();
|
448
|
+
self.cache.insert(full_path.clone(), cache_entry.clone());
|
449
|
+
Some(cache_entry)
|
450
|
+
} else {
|
451
|
+
None
|
452
|
+
};
|
453
|
+
return Ok(ResolvedAsset {
|
454
|
+
path: full_path,
|
455
|
+
cache_entry,
|
456
|
+
metadata: Some(metadata),
|
457
|
+
redirect_to: None,
|
458
|
+
});
|
459
|
+
} else if metadata.is_dir() {
|
460
|
+
if !abs_path.ends_with("/") {
|
461
|
+
return Ok(ResolvedAsset {
|
462
|
+
path: full_path,
|
463
|
+
cache_entry: None,
|
464
|
+
metadata: Some(metadata),
|
465
|
+
redirect_to: Some(format!("{}/", abs_path)),
|
466
|
+
});
|
467
|
+
}
|
468
|
+
let mut index_file = None;
|
469
|
+
|
470
|
+
let index_path = full_path.join("index.html");
|
471
|
+
if let Ok(idx_meta) = tokio::fs::metadata(&index_path).await {
|
472
|
+
if idx_meta.is_file() {
|
473
|
+
index_file = Some(index_path);
|
474
|
+
}
|
475
|
+
}
|
476
|
+
|
477
|
+
if index_file.is_none() {
|
478
|
+
// Check for case insensitive index.html
|
479
|
+
let entries = match tokio::fs::read_dir(&full_path).await {
|
480
|
+
Ok(entries) => entries,
|
481
|
+
Err(_) => return Err(NotFoundBehavior::InternalServerError),
|
482
|
+
};
|
483
|
+
|
484
|
+
tokio::pin!(entries);
|
485
|
+
while let Some(entry) = entries.next_entry().await.unwrap_or(None) {
|
486
|
+
if entry
|
487
|
+
.file_name()
|
488
|
+
.to_str()
|
489
|
+
.is_some_and(|name| name.eq_ignore_ascii_case("index.html"))
|
490
|
+
&& entry.metadata().await.unwrap().is_file()
|
491
|
+
{
|
492
|
+
index_file = Some(entry.path());
|
493
|
+
break;
|
494
|
+
}
|
495
|
+
}
|
496
|
+
}
|
497
|
+
if index_file.is_some() {
|
498
|
+
let index_path = index_file.unwrap();
|
499
|
+
self.key_to_path
|
500
|
+
.lock()
|
501
|
+
.await
|
502
|
+
.insert(key.to_string(), index_path.clone());
|
503
|
+
let cache_entry = CacheEntry::new(index_path.clone()).await.unwrap();
|
504
|
+
self.cache.insert(index_path.clone(), cache_entry.clone());
|
505
|
+
return Ok(ResolvedAsset {
|
506
|
+
path: index_path,
|
507
|
+
cache_entry: Some(cache_entry),
|
508
|
+
metadata: None,
|
509
|
+
redirect_to: None,
|
510
|
+
});
|
511
|
+
}
|
512
|
+
|
513
|
+
if self.config.auto_index {
|
514
|
+
let virtual_path = if matches!(accept, ResponseFormat::JSON) {
|
515
|
+
full_path.join(".directory_listing.dir_list_json")
|
516
|
+
} else {
|
517
|
+
full_path.join(".directory_listing.dir_list")
|
518
|
+
};
|
519
|
+
|
520
|
+
let cache_entry = CacheEntry::new_virtual_listing(
|
521
|
+
virtual_path.clone(),
|
522
|
+
&self.config,
|
523
|
+
accept,
|
524
|
+
)
|
525
|
+
.await;
|
526
|
+
self.key_to_path
|
527
|
+
.lock()
|
528
|
+
.await
|
529
|
+
.insert(key.to_string(), virtual_path.clone());
|
530
|
+
self.cache.insert(virtual_path.clone(), cache_entry.clone());
|
531
|
+
return Ok(ResolvedAsset {
|
532
|
+
path: virtual_path.clone(),
|
533
|
+
cache_entry: Some(cache_entry.clone()),
|
534
|
+
metadata: None,
|
535
|
+
redirect_to: None,
|
536
|
+
});
|
537
|
+
}
|
538
|
+
}
|
539
|
+
}
|
540
|
+
Err(_) => {
|
541
|
+
// Path doesn't exist, try with .html extension if configured
|
542
|
+
if self.config.try_html_extension {
|
543
|
+
let mut html_path = full_path.clone();
|
544
|
+
html_path.set_extension("html");
|
545
|
+
|
546
|
+
if let Ok(html_meta) = tokio::fs::metadata(&html_path).await {
|
547
|
+
if html_meta.is_file() {
|
548
|
+
self.key_to_path
|
549
|
+
.lock()
|
550
|
+
.await
|
551
|
+
.insert(key.to_string(), html_path.clone());
|
552
|
+
let cache_entry = if html_meta.len() <= self.config.max_file_size {
|
553
|
+
let cache_entry = CacheEntry::new(html_path.clone()).await.unwrap();
|
554
|
+
self.cache.insert(html_path.clone(), cache_entry.clone());
|
555
|
+
Some(cache_entry)
|
556
|
+
} else {
|
557
|
+
None
|
558
|
+
};
|
559
|
+
return Ok(ResolvedAsset {
|
560
|
+
path: html_path,
|
561
|
+
cache_entry,
|
562
|
+
metadata: Some(html_meta),
|
563
|
+
redirect_to: None,
|
564
|
+
});
|
565
|
+
}
|
566
|
+
}
|
567
|
+
}
|
568
|
+
}
|
569
|
+
}
|
570
|
+
|
571
|
+
// If we get here, we couldn't resolve the key to a file
|
572
|
+
Err(self.config.not_found_behavior.clone())
|
573
|
+
}
|
574
|
+
|
575
|
+
async fn stream_file_range(
|
576
|
+
&self,
|
577
|
+
path: PathBuf,
|
578
|
+
start: u64,
|
579
|
+
end: u64,
|
580
|
+
) -> Option<BoxBody<Bytes, Infallible>> {
|
581
|
+
use futures::TryStreamExt;
|
582
|
+
use http_body_util::StreamBody;
|
583
|
+
use hyper::body::Frame;
|
584
|
+
use tokio::io::AsyncSeekExt;
|
585
|
+
use tokio_util::io::ReaderStream;
|
586
|
+
|
587
|
+
let mut file = match File::open(&path).await {
|
588
|
+
Ok(f) => f,
|
589
|
+
Err(e) => {
|
590
|
+
warn!(
|
591
|
+
"Failed to open file for streaming: {}: {}",
|
592
|
+
path.display(),
|
593
|
+
e
|
594
|
+
);
|
595
|
+
return None;
|
596
|
+
}
|
597
|
+
};
|
598
|
+
|
599
|
+
// Seek to the start position
|
600
|
+
if let Err(e) = file.seek(std::io::SeekFrom::Start(start)).await {
|
601
|
+
warn!(
|
602
|
+
"Failed to seek to position {} in file {}: {}",
|
603
|
+
start,
|
604
|
+
path.display(),
|
605
|
+
e
|
606
|
+
);
|
607
|
+
return None;
|
608
|
+
}
|
609
|
+
|
610
|
+
// Create a limited reader that will only read up to range_length bytes
|
611
|
+
let range_length = end - start + 1;
|
612
|
+
let limited_reader = tokio::io::AsyncReadExt::take(file, range_length);
|
613
|
+
let path_clone = path.clone();
|
614
|
+
let stream = ReaderStream::new(limited_reader)
|
615
|
+
.map_ok(Frame::data)
|
616
|
+
.map_err(move |e| {
|
617
|
+
warn!("Error streaming file {}: {}", path_clone.display(), e);
|
618
|
+
unreachable!("We handle IO errors above")
|
619
|
+
});
|
620
|
+
|
621
|
+
Some(BoxBody::new(StreamBody::new(stream)))
|
622
|
+
}
|
623
|
+
|
624
|
+
async fn stream_file(&self, path: PathBuf) -> Option<BoxBody<Bytes, Infallible>> {
|
625
|
+
use futures::TryStreamExt;
|
626
|
+
use http_body_util::StreamBody;
|
627
|
+
use hyper::body::Frame;
|
628
|
+
use tokio_util::io::ReaderStream;
|
629
|
+
|
630
|
+
match File::open(&path).await {
|
631
|
+
Ok(file) => {
|
632
|
+
let path_clone = path.clone();
|
633
|
+
let stream = ReaderStream::new(file)
|
634
|
+
.map_ok(Frame::data)
|
635
|
+
.map_err(move |e| {
|
636
|
+
warn!("Error streaming file {}: {}", path_clone.display(), e);
|
637
|
+
unreachable!("We handle IO errors above")
|
638
|
+
});
|
639
|
+
Some(BoxBody::new(StreamBody::new(stream)))
|
640
|
+
}
|
641
|
+
Err(e) => {
|
642
|
+
warn!(
|
643
|
+
"Failed to open file for streaming: {}: {}",
|
644
|
+
path.display(),
|
645
|
+
e
|
646
|
+
);
|
647
|
+
None
|
648
|
+
}
|
649
|
+
}
|
650
|
+
}
|
651
|
+
|
652
|
+
async fn serve_stream_content(&self, stream_args: ServeStreamArgs) -> HttpResponse {
|
653
|
+
let ServeStreamArgs(
|
654
|
+
file,
|
655
|
+
metadata,
|
656
|
+
start,
|
657
|
+
end,
|
658
|
+
is_range_request,
|
659
|
+
if_modified_since,
|
660
|
+
is_head_request,
|
661
|
+
) = stream_args;
|
662
|
+
|
663
|
+
let content_length = metadata.len();
|
664
|
+
let last_modified = metadata.modified().unwrap();
|
665
|
+
|
666
|
+
// Handle If-Modified-Since header
|
667
|
+
if is_not_modified(last_modified, if_modified_since) {
|
668
|
+
return build_not_modified_response();
|
669
|
+
}
|
670
|
+
|
671
|
+
// For range requests, validate the range bounds
|
672
|
+
if is_range_request && start >= content_length {
|
673
|
+
return Response::builder()
|
674
|
+
.status(StatusCode::RANGE_NOT_SATISFIABLE)
|
675
|
+
.header("Content-Range", format!("bytes */{}", content_length))
|
676
|
+
.body(BoxBody::new(Full::new(Bytes::new())))
|
677
|
+
.unwrap();
|
678
|
+
}
|
679
|
+
|
680
|
+
// Adjust end bound for open-ended ranges or to not exceed file size
|
681
|
+
let adjusted_end = if end == u64::MAX {
|
682
|
+
content_length - 1
|
683
|
+
} else {
|
684
|
+
std::cmp::min(end, content_length - 1)
|
685
|
+
};
|
686
|
+
|
687
|
+
// Create response based on request type
|
688
|
+
let status = if is_range_request {
|
689
|
+
StatusCode::PARTIAL_CONTENT
|
690
|
+
} else {
|
691
|
+
StatusCode::OK
|
692
|
+
};
|
693
|
+
|
694
|
+
let content_range = if is_range_request {
|
695
|
+
Some(format!(
|
696
|
+
"bytes {}-{}/{}",
|
697
|
+
start, adjusted_end, content_length
|
698
|
+
))
|
699
|
+
} else {
|
700
|
+
None
|
701
|
+
};
|
702
|
+
|
703
|
+
// For HEAD requests, return just the headers
|
704
|
+
if is_head_request {
|
705
|
+
let mut builder = Response::builder()
|
706
|
+
.status(status)
|
707
|
+
.header("Content-Type", get_mime_type(&file))
|
708
|
+
.header(
|
709
|
+
"Content-Length",
|
710
|
+
if is_range_request {
|
711
|
+
(adjusted_end - start + 1).to_string()
|
712
|
+
} else {
|
713
|
+
content_length.to_string()
|
714
|
+
},
|
715
|
+
)
|
716
|
+
.header("Last-Modified", format_http_date(last_modified));
|
717
|
+
|
718
|
+
if let Some(range) = content_range {
|
719
|
+
builder = builder.header("Content-Range", range);
|
720
|
+
}
|
721
|
+
|
722
|
+
return builder.body(BoxBody::new(Full::new(Bytes::new()))).unwrap();
|
723
|
+
}
|
724
|
+
|
725
|
+
// For GET requests, prepare the actual content
|
726
|
+
if is_range_request {
|
727
|
+
// Extract the requested range from the cached content
|
728
|
+
let end_idx = std::cmp::min((adjusted_end + 1) as u64, content_length);
|
729
|
+
|
730
|
+
build_file_response(
|
731
|
+
status,
|
732
|
+
None,
|
733
|
+
None,
|
734
|
+
get_mime_type(&file),
|
735
|
+
(end_idx - start) as usize,
|
736
|
+
last_modified,
|
737
|
+
content_range,
|
738
|
+
self.stream_file_range(file, start, end_idx).await.unwrap(),
|
739
|
+
)
|
740
|
+
} else {
|
741
|
+
build_file_response(
|
742
|
+
status,
|
743
|
+
None,
|
744
|
+
None,
|
745
|
+
get_mime_type(&file),
|
746
|
+
content_length as usize,
|
747
|
+
last_modified,
|
748
|
+
content_range,
|
749
|
+
self.stream_file(file).await.unwrap(),
|
750
|
+
)
|
751
|
+
}
|
752
|
+
}
|
753
|
+
|
754
|
+
fn serve_cached_content(
|
755
|
+
&self,
|
756
|
+
serve_cache_args: ServeCacheArgs,
|
757
|
+
) -> http::Response<BoxBody<Bytes, Infallible>> {
|
758
|
+
let ServeCacheArgs(
|
759
|
+
cache_entry,
|
760
|
+
start,
|
761
|
+
end,
|
762
|
+
is_range_request,
|
763
|
+
if_modified_since,
|
764
|
+
is_head_request,
|
765
|
+
path,
|
766
|
+
supported_encodings,
|
767
|
+
) = serve_cache_args;
|
768
|
+
|
769
|
+
let content_length = cache_entry.content.len() as u64;
|
770
|
+
|
771
|
+
if is_not_modified(cache_entry.last_modified, if_modified_since) {
|
772
|
+
return build_not_modified_response();
|
773
|
+
}
|
774
|
+
|
775
|
+
// For range requests, validate the range bounds
|
776
|
+
if is_range_request && start >= content_length {
|
777
|
+
return Response::builder()
|
778
|
+
.status(StatusCode::RANGE_NOT_SATISFIABLE)
|
779
|
+
.header("Content-Range", format!("bytes */{}", content_length))
|
780
|
+
.body(BoxBody::new(Full::new(Bytes::new())))
|
781
|
+
.unwrap();
|
782
|
+
}
|
783
|
+
|
784
|
+
// Adjust end bound for open-ended ranges or to not exceed file size
|
785
|
+
let adjusted_end = if end == u64::MAX {
|
786
|
+
content_length.saturating_sub(1)
|
787
|
+
} else {
|
788
|
+
std::cmp::min(end, content_length.saturating_sub(1))
|
789
|
+
};
|
790
|
+
|
791
|
+
// Create response based on request type
|
792
|
+
let status = if is_range_request {
|
793
|
+
StatusCode::PARTIAL_CONTENT
|
794
|
+
} else {
|
795
|
+
StatusCode::OK
|
796
|
+
};
|
797
|
+
|
798
|
+
let content_range = if is_range_request {
|
799
|
+
Some(format!(
|
800
|
+
"bytes {}-{}/{}",
|
801
|
+
start, adjusted_end, content_length
|
802
|
+
))
|
803
|
+
} else {
|
804
|
+
None
|
805
|
+
};
|
806
|
+
|
807
|
+
// For HEAD requests, return just the headers
|
808
|
+
if is_head_request {
|
809
|
+
let mut builder = Response::builder()
|
810
|
+
.status(status)
|
811
|
+
.header("Content-Type", get_mime_type(path))
|
812
|
+
.header(
|
813
|
+
"Content-Length",
|
814
|
+
if is_range_request {
|
815
|
+
(adjusted_end - start + 1).to_string()
|
816
|
+
} else {
|
817
|
+
content_length.to_string()
|
818
|
+
},
|
819
|
+
)
|
820
|
+
.header("Last-Modified", format_http_date(cache_entry.last_modified));
|
821
|
+
|
822
|
+
if let Some(range) = content_range {
|
823
|
+
builder = builder.header("Content-Range", range);
|
824
|
+
}
|
825
|
+
|
826
|
+
return builder.body(BoxBody::new(Full::new(Bytes::new()))).unwrap();
|
827
|
+
}
|
828
|
+
|
829
|
+
if is_range_request {
|
830
|
+
let start_idx = start as usize;
|
831
|
+
let end_idx = std::cmp::min((adjusted_end + 1) as usize, cache_entry.content.len());
|
832
|
+
let range_bytes = cache_entry.content.slice(start_idx..end_idx);
|
833
|
+
let etag = {
|
834
|
+
let mut hasher = Sha256::new();
|
835
|
+
hasher.update(&range_bytes);
|
836
|
+
let result = hasher.finalize();
|
837
|
+
general_purpose::STANDARD.encode(result)
|
838
|
+
};
|
839
|
+
build_file_response(
|
840
|
+
status,
|
841
|
+
None,
|
842
|
+
Some(&etag),
|
843
|
+
get_mime_type(path),
|
844
|
+
range_bytes.len(),
|
845
|
+
cache_entry.last_modified,
|
846
|
+
content_range,
|
847
|
+
BoxBody::new(Full::new(range_bytes)),
|
848
|
+
)
|
849
|
+
} else {
|
850
|
+
// Return the full content
|
851
|
+
let (content, encoding) = cache_entry.suggest_content_for(supported_encodings);
|
852
|
+
let body = build_ok_body(content);
|
853
|
+
build_file_response(
|
854
|
+
status,
|
855
|
+
Some(encoding),
|
856
|
+
Some(&cache_entry.etag),
|
857
|
+
get_mime_type(path),
|
858
|
+
content_length as usize,
|
859
|
+
cache_entry.last_modified,
|
860
|
+
content_range,
|
861
|
+
body,
|
862
|
+
)
|
863
|
+
}
|
864
|
+
}
|
865
|
+
|
866
|
+
pub async fn invalidate_cache(&self, path: &Path) {
|
867
|
+
if let Ok(path_buf) = path.to_path_buf().canonicalize() {
|
868
|
+
self.cache.invalidate(&path_buf);
|
869
|
+
}
|
870
|
+
}
|
871
|
+
}
|
872
|
+
|
873
|
+
fn format_http_date(last_modified: SystemTime) -> String {
|
874
|
+
let datetime = DateTime::<Utc>::from(last_modified);
|
875
|
+
datetime.format("%a, %d %b %Y %H:%M:%S GMT").to_string()
|
876
|
+
}
|
877
|
+
|
878
|
+
async fn read_entire_file(path: &Path) -> std::io::Result<(Bytes, SystemTime)> {
|
879
|
+
let metadata = tokio::fs::metadata(path).await?;
|
880
|
+
let last_modified = metadata.modified()?;
|
881
|
+
let mut file = File::open(path).await?;
|
882
|
+
let mut buf = Vec::with_capacity(metadata.len().try_into().unwrap_or(4096));
|
883
|
+
file.read_to_end(&mut buf).await?;
|
884
|
+
Ok((Bytes::from(buf), last_modified))
|
885
|
+
}
|
886
|
+
|
887
|
+
fn with_added_extension(path: &Path, ext: &str) -> PathBuf {
|
888
|
+
let mut new_path = path.to_path_buf();
|
889
|
+
if new_path.file_name().is_some() {
|
890
|
+
// Append the dot and extension in place.
|
891
|
+
new_path.as_mut_os_string().push(".");
|
892
|
+
new_path.as_mut_os_string().push(ext);
|
893
|
+
}
|
894
|
+
new_path
|
895
|
+
}
|
896
|
+
|
897
|
+
async fn read_variant(path: &Path, ext: &str) -> Option<Bytes> {
|
898
|
+
let variant = with_added_extension(path, ext);
|
899
|
+
if let Ok(metadata) = tokio::fs::metadata(&variant).await {
|
900
|
+
if let Ok(mut file) = File::open(&variant).await {
|
901
|
+
let mut buf = Vec::with_capacity(metadata.len().try_into().unwrap_or(4096));
|
902
|
+
if file.read_to_end(&mut buf).await.is_ok() {
|
903
|
+
return Some(Bytes::from(buf));
|
904
|
+
}
|
905
|
+
}
|
906
|
+
}
|
907
|
+
None
|
908
|
+
}
|
909
|
+
|
910
|
+
fn build_ok_body(bytes: Arc<Bytes>) -> BoxBody<Bytes, Infallible> {
|
911
|
+
BoxBody::new(Full::new(bytes.as_ref().clone()))
|
912
|
+
}
|
913
|
+
|
914
|
+
// Helper function to handle not modified responses
|
915
|
+
fn build_not_modified_response() -> http::Response<BoxBody<Bytes, Infallible>> {
|
916
|
+
Response::builder()
|
917
|
+
.status(StatusCode::NOT_MODIFIED)
|
918
|
+
.body(BoxBody::new(Full::new(Bytes::new())))
|
919
|
+
.unwrap()
|
920
|
+
}
|
921
|
+
|
922
|
+
#[allow(clippy::too_many_arguments)]
|
923
|
+
fn build_file_response(
|
924
|
+
status: StatusCode,
|
925
|
+
content_encoding: Option<&str>,
|
926
|
+
etag: Option<&str>,
|
927
|
+
content_type: &str,
|
928
|
+
content_length: usize,
|
929
|
+
last_modified: SystemTime,
|
930
|
+
range_header: Option<String>,
|
931
|
+
body: BoxBody<Bytes, Infallible>,
|
932
|
+
) -> http::Response<BoxBody<Bytes, Infallible>> {
|
933
|
+
let mut builder = Response::builder()
|
934
|
+
.status(status)
|
935
|
+
.header(CONTENT_TYPE, content_type)
|
936
|
+
.header(CONTENT_LENGTH, content_length)
|
937
|
+
.header(LAST_MODIFIED, format_http_date(last_modified));
|
938
|
+
|
939
|
+
if let Some(etag) = etag {
|
940
|
+
builder = builder.header(ETAG, etag);
|
941
|
+
}
|
942
|
+
|
943
|
+
if let Some(content_encoding) = content_encoding {
|
944
|
+
builder = builder.header(CONTENT_ENCODING, content_encoding);
|
945
|
+
}
|
946
|
+
|
947
|
+
if let Some(range) = range_header {
|
948
|
+
builder = builder.header(CONTENT_RANGE, range);
|
949
|
+
}
|
950
|
+
|
951
|
+
builder.body(body).unwrap()
|
952
|
+
}
|
953
|
+
|
954
|
+
// Helper function to check if a file is too old based on If-Modified-Since
|
955
|
+
fn is_not_modified(last_modified: SystemTime, if_modified_since: Option<SystemTime>) -> bool {
|
956
|
+
if let Some(ims) = if_modified_since {
|
957
|
+
if ims >= last_modified {
|
958
|
+
return true;
|
959
|
+
}
|
960
|
+
}
|
961
|
+
false
|
962
|
+
}
|
963
|
+
|
964
|
+
fn normalize_path(path: Cow<'_, str>) -> Option<PathBuf> {
|
965
|
+
let mut normalized = PathBuf::new();
|
966
|
+
let path = path.trim_start_matches('/');
|
967
|
+
|
968
|
+
for segment in path.split('/') {
|
969
|
+
if segment.is_empty() || segment == "." {
|
970
|
+
continue;
|
971
|
+
}
|
972
|
+
|
973
|
+
if segment == ".." {
|
974
|
+
return None;
|
975
|
+
}
|
976
|
+
|
977
|
+
// Reject Windows-style backslash separators just in case
|
978
|
+
if segment.contains('\\') {
|
979
|
+
return None;
|
980
|
+
}
|
981
|
+
|
982
|
+
normalized.push(segment);
|
983
|
+
}
|
984
|
+
|
985
|
+
Some(normalized)
|
986
|
+
}
|
987
|
+
|
988
|
+
#[derive(Debug)]
|
989
|
+
struct ResolvedAsset {
|
990
|
+
path: PathBuf,
|
991
|
+
cache_entry: Option<CacheEntry>,
|
992
|
+
metadata: Option<Metadata>,
|
993
|
+
redirect_to: Option<String>,
|
994
|
+
}
|
995
|
+
|
996
|
+
impl std::fmt::Display for StaticFileServer {
|
997
|
+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
998
|
+
write!(f, "StaticFileServer(root_dir: {:?})", self.config.root_dir)
|
999
|
+
}
|
1000
|
+
}
|
1001
|
+
|
1002
|
+
impl Default for StaticFileServer {
|
1003
|
+
fn default() -> Self {
|
1004
|
+
let config = StaticFileServerConfig {
|
1005
|
+
root_dir: "public".into(),
|
1006
|
+
max_file_size: 10 * 1024 * 1024,
|
1007
|
+
max_entries: 100,
|
1008
|
+
recheck_interval: Duration::from_secs(60),
|
1009
|
+
try_html_extension: true,
|
1010
|
+
auto_index: true,
|
1011
|
+
not_found_behavior: NotFoundBehavior::Error(ErrorResponse::not_found()),
|
1012
|
+
serve_hidden_files: false,
|
1013
|
+
allowed_extensions: vec!["html".to_string(), "css".to_string(), "js".to_string()],
|
1014
|
+
};
|
1015
|
+
Self::new(config)
|
1016
|
+
}
|
1017
|
+
}
|
1018
|
+
|
1019
|
+
async fn generate_directory_listing(
|
1020
|
+
dir_path: &Path,
|
1021
|
+
config: &StaticFileServerConfig,
|
1022
|
+
accept: ResponseFormat,
|
1023
|
+
) -> std::io::Result<String> {
|
1024
|
+
match accept {
|
1025
|
+
ResponseFormat::JSON => {
|
1026
|
+
let directory_display = {
|
1027
|
+
let display = dir_path
|
1028
|
+
.strip_prefix(&config.root_dir)
|
1029
|
+
.unwrap_or(Path::new(""))
|
1030
|
+
.to_string_lossy();
|
1031
|
+
if display.is_empty() {
|
1032
|
+
Cow::Borrowed(".")
|
1033
|
+
} else {
|
1034
|
+
display
|
1035
|
+
}
|
1036
|
+
};
|
1037
|
+
|
1038
|
+
let mut items = Vec::new();
|
1039
|
+
|
1040
|
+
// Add a parent directory entry if not at the root.
|
1041
|
+
if dir_path != config.root_dir {
|
1042
|
+
items.push(json!({
|
1043
|
+
"name": "..",
|
1044
|
+
"path": "..",
|
1045
|
+
"is_dir": true,
|
1046
|
+
"size": null,
|
1047
|
+
"modified": null,
|
1048
|
+
}));
|
1049
|
+
}
|
1050
|
+
|
1051
|
+
// Read directory entries.
|
1052
|
+
let mut entries = tokio::fs::read_dir(dir_path).await?;
|
1053
|
+
let mut dirs = Vec::new();
|
1054
|
+
let mut files = Vec::new();
|
1055
|
+
|
1056
|
+
while let Some(entry) = entries.next_entry().await? {
|
1057
|
+
let entry_path = entry.path();
|
1058
|
+
let metadata = entry.metadata().await?;
|
1059
|
+
let name = entry_path
|
1060
|
+
.file_name()
|
1061
|
+
.unwrap()
|
1062
|
+
.to_string_lossy()
|
1063
|
+
.into_owned();
|
1064
|
+
|
1065
|
+
if !config.serve_hidden_files && name.starts_with('.') {
|
1066
|
+
continue;
|
1067
|
+
}
|
1068
|
+
|
1069
|
+
let ext = entry_path
|
1070
|
+
.extension()
|
1071
|
+
.and_then(|s| s.to_str())
|
1072
|
+
.unwrap_or("");
|
1073
|
+
|
1074
|
+
if metadata.is_dir() {
|
1075
|
+
dirs.push((name, metadata));
|
1076
|
+
} else if config.allowed_extensions.is_empty()
|
1077
|
+
|| config.allowed_extensions.iter().any(|e| e == ext)
|
1078
|
+
{
|
1079
|
+
files.push((name, metadata));
|
1080
|
+
}
|
1081
|
+
}
|
1082
|
+
|
1083
|
+
// Sort directories alphabetically with dot directories pushed to the bottom.
|
1084
|
+
dirs.sort_by(|(name_a, _), (name_b, _)| {
|
1085
|
+
let a_is_dot = name_a.starts_with('.');
|
1086
|
+
let b_is_dot = name_b.starts_with('.');
|
1087
|
+
if a_is_dot != b_is_dot {
|
1088
|
+
if a_is_dot {
|
1089
|
+
Ordering::Greater
|
1090
|
+
} else {
|
1091
|
+
Ordering::Less
|
1092
|
+
}
|
1093
|
+
} else {
|
1094
|
+
name_a.cmp(name_b)
|
1095
|
+
}
|
1096
|
+
});
|
1097
|
+
|
1098
|
+
// Sort files so that dot files appear last.
|
1099
|
+
files.sort_by(|(name_a, _), (name_b, _)| {
|
1100
|
+
let a_is_dot = name_a.starts_with('.');
|
1101
|
+
let b_is_dot = name_b.starts_with('.');
|
1102
|
+
if a_is_dot != b_is_dot {
|
1103
|
+
if a_is_dot {
|
1104
|
+
Ordering::Greater
|
1105
|
+
} else {
|
1106
|
+
Ordering::Less
|
1107
|
+
}
|
1108
|
+
} else {
|
1109
|
+
name_a.cmp(name_b)
|
1110
|
+
}
|
1111
|
+
});
|
1112
|
+
|
1113
|
+
// Generate JSON entries for directories.
|
1114
|
+
for (name, metadata) in dirs {
|
1115
|
+
let encoded = utf8_percent_encode(&name, NON_ALPHANUMERIC).to_string();
|
1116
|
+
let modified = metadata
|
1117
|
+
.modified()
|
1118
|
+
.ok()
|
1119
|
+
.map(|m| {
|
1120
|
+
DateTime::<Utc>::from(m)
|
1121
|
+
.format("%Y-%m-%d %H:%M:%S")
|
1122
|
+
.to_string()
|
1123
|
+
})
|
1124
|
+
.unwrap_or_else(|| "-".to_string());
|
1125
|
+
|
1126
|
+
items.push(json!({
|
1127
|
+
"name": format!("{}/", name),
|
1128
|
+
"path": format!("{}/", encoded),
|
1129
|
+
"is_dir": true,
|
1130
|
+
"size": null,
|
1131
|
+
"modified": modified,
|
1132
|
+
}));
|
1133
|
+
}
|
1134
|
+
|
1135
|
+
// Generate JSON entries for files.
|
1136
|
+
for (name, metadata) in files {
|
1137
|
+
let encoded = utf8_percent_encode(&name, NON_ALPHANUMERIC).to_string();
|
1138
|
+
let file_size = metadata.len();
|
1139
|
+
let formatted_size = if file_size < 1024 {
|
1140
|
+
format!("{} B", file_size)
|
1141
|
+
} else if file_size < 1024 * 1024 {
|
1142
|
+
format!("{:.1} KB", file_size as f64 / 1024.0)
|
1143
|
+
} else if file_size < 1024 * 1024 * 1024 {
|
1144
|
+
format!("{:.1} MB", file_size as f64 / (1024.0 * 1024.0))
|
1145
|
+
} else {
|
1146
|
+
format!("{:.1} GB", file_size as f64 / (1024.0 * 1024.0 * 1024.0))
|
1147
|
+
};
|
1148
|
+
|
1149
|
+
let modified_str = metadata
|
1150
|
+
.modified()
|
1151
|
+
.ok()
|
1152
|
+
.map(|m| {
|
1153
|
+
DateTime::<Utc>::from(m)
|
1154
|
+
.format("%Y-%m-%d %H:%M:%S")
|
1155
|
+
.to_string()
|
1156
|
+
})
|
1157
|
+
.unwrap_or_else(|| "-".to_string());
|
1158
|
+
|
1159
|
+
items.push(json!({
|
1160
|
+
"name": name,
|
1161
|
+
"path": encoded,
|
1162
|
+
"is_dir": false,
|
1163
|
+
"size": formatted_size,
|
1164
|
+
"modified": modified_str,
|
1165
|
+
}));
|
1166
|
+
}
|
1167
|
+
|
1168
|
+
// Build the final JSON object.
|
1169
|
+
let json_obj = json!({
|
1170
|
+
"title": format!("Directory listing for {}", directory_display),
|
1171
|
+
"directory": directory_display,
|
1172
|
+
"items": items,
|
1173
|
+
});
|
1174
|
+
|
1175
|
+
// Serialize the JSON object to a pretty-printed string.
|
1176
|
+
let json_string = serde_json::to_string_pretty(&json_obj)
|
1177
|
+
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?;
|
1178
|
+
|
1179
|
+
Ok(json_string)
|
1180
|
+
}
|
1181
|
+
ResponseFormat::HTML | ResponseFormat::TEXT | ResponseFormat::UNKNOWN => {
|
1182
|
+
let template = include_str!("../default_responses/html/index.html");
|
1183
|
+
|
1184
|
+
let directory_display = {
|
1185
|
+
let display = dir_path
|
1186
|
+
.strip_prefix(&config.root_dir)
|
1187
|
+
.unwrap_or(Path::new(""))
|
1188
|
+
.to_string_lossy();
|
1189
|
+
if display.is_empty() {
|
1190
|
+
Cow::Borrowed(".")
|
1191
|
+
} else {
|
1192
|
+
display
|
1193
|
+
}
|
1194
|
+
};
|
1195
|
+
|
1196
|
+
let mut rows = String::new();
|
1197
|
+
if dir_path != config.root_dir {
|
1198
|
+
rows.push_str(
|
1199
|
+
r#"<tr><td><a href="..">..</a></td><td class="size">-</td><td class="date">-</td></tr>"#,
|
1200
|
+
);
|
1201
|
+
rows.push('\n');
|
1202
|
+
}
|
1203
|
+
|
1204
|
+
// Read directory entries.
|
1205
|
+
let mut entries = tokio::fs::read_dir(dir_path).await?;
|
1206
|
+
let mut dirs = Vec::new();
|
1207
|
+
let mut files = Vec::new();
|
1208
|
+
|
1209
|
+
while let Some(entry) = entries.next_entry().await? {
|
1210
|
+
let entry_path = entry.path();
|
1211
|
+
let metadata = entry.metadata().await?;
|
1212
|
+
let name = entry_path
|
1213
|
+
.file_name()
|
1214
|
+
.unwrap()
|
1215
|
+
.to_string_lossy()
|
1216
|
+
.into_owned();
|
1217
|
+
|
1218
|
+
if !config.serve_hidden_files && name.starts_with('.') {
|
1219
|
+
continue;
|
1220
|
+
}
|
1221
|
+
|
1222
|
+
let ext = entry_path
|
1223
|
+
.extension()
|
1224
|
+
.and_then(|s| s.to_str())
|
1225
|
+
.unwrap_or("");
|
1226
|
+
|
1227
|
+
if metadata.is_dir() {
|
1228
|
+
dirs.push((name, metadata));
|
1229
|
+
} else if config.allowed_extensions.is_empty()
|
1230
|
+
|| config.allowed_extensions.iter().any(|e| e == ext)
|
1231
|
+
{
|
1232
|
+
files.push((name, metadata));
|
1233
|
+
}
|
1234
|
+
}
|
1235
|
+
|
1236
|
+
// Sort directories and files alphabetically.
|
1237
|
+
dirs.sort_by(|(name_a, _), (name_b, _)| {
|
1238
|
+
let a_is_dot = name_a.starts_with('.');
|
1239
|
+
let b_is_dot = name_b.starts_with('.');
|
1240
|
+
if a_is_dot != b_is_dot {
|
1241
|
+
if a_is_dot {
|
1242
|
+
Ordering::Greater
|
1243
|
+
} else {
|
1244
|
+
Ordering::Less
|
1245
|
+
}
|
1246
|
+
} else {
|
1247
|
+
name_a.cmp(name_b)
|
1248
|
+
}
|
1249
|
+
});
|
1250
|
+
|
1251
|
+
// Sort files so that dot files are at the bottom.
|
1252
|
+
files.sort_by(|(name_a, _), (name_b, _)| {
|
1253
|
+
let a_is_dot = name_a.starts_with('.');
|
1254
|
+
let b_is_dot = name_b.starts_with('.');
|
1255
|
+
if a_is_dot != b_is_dot {
|
1256
|
+
if a_is_dot {
|
1257
|
+
Ordering::Greater
|
1258
|
+
} else {
|
1259
|
+
Ordering::Less
|
1260
|
+
}
|
1261
|
+
} else {
|
1262
|
+
name_a.cmp(name_b)
|
1263
|
+
}
|
1264
|
+
});
|
1265
|
+
|
1266
|
+
// Generate rows for directories.
|
1267
|
+
for (name, metadata) in dirs {
|
1268
|
+
let encoded = utf8_percent_encode(&name, NON_ALPHANUMERIC).to_string();
|
1269
|
+
|
1270
|
+
rows.push_str(&format!(
|
1271
|
+
r#"<tr><td><a href="{0}/">{1}/</a></td><td class="size">-</td><td class="date">{2}</td></tr>"#,
|
1272
|
+
encoded,
|
1273
|
+
name,
|
1274
|
+
metadata.modified().ok().map(|m| DateTime::<Utc>::from(m).format("%Y-%m-%d %H:%M:%S").to_string())
|
1275
|
+
.unwrap_or_else(|| "-".to_string())
|
1276
|
+
));
|
1277
|
+
rows.push('\n');
|
1278
|
+
}
|
1279
|
+
|
1280
|
+
// Generate rows for files.
|
1281
|
+
for (name, metadata) in files {
|
1282
|
+
let encoded = utf8_percent_encode(&name, NON_ALPHANUMERIC).to_string();
|
1283
|
+
|
1284
|
+
let file_size = metadata.len();
|
1285
|
+
let formatted_size = if file_size < 1024 {
|
1286
|
+
format!("{} B", file_size)
|
1287
|
+
} else if file_size < 1024 * 1024 {
|
1288
|
+
format!("{:.1} KB", file_size as f64 / 1024.0)
|
1289
|
+
} else if file_size < 1024 * 1024 * 1024 {
|
1290
|
+
format!("{:.1} MB", file_size as f64 / (1024.0 * 1024.0))
|
1291
|
+
} else {
|
1292
|
+
format!("{:.1} GB", file_size as f64 / (1024.0 * 1024.0 * 1024.0))
|
1293
|
+
};
|
1294
|
+
|
1295
|
+
let modified_str = metadata
|
1296
|
+
.modified()
|
1297
|
+
.ok()
|
1298
|
+
.map(|m| {
|
1299
|
+
DateTime::<Utc>::from(m)
|
1300
|
+
.format("%Y-%m-%d %H:%M:%S")
|
1301
|
+
.to_string()
|
1302
|
+
})
|
1303
|
+
.unwrap_or_else(|| "-".to_string());
|
1304
|
+
|
1305
|
+
rows.push_str(&format!(
|
1306
|
+
r#"<tr><td><a href="{0}">{1}</a></td><td class="size">{2}</td><td class="date">{3}</td></tr>"#,
|
1307
|
+
encoded, name, formatted_size, modified_str
|
1308
|
+
));
|
1309
|
+
rows.push('\n');
|
1310
|
+
}
|
1311
|
+
|
1312
|
+
// Replace the placeholders in our template.
|
1313
|
+
let html = template
|
1314
|
+
.replace(
|
1315
|
+
"{{title}}",
|
1316
|
+
&format!("Directory listing for {}", directory_display),
|
1317
|
+
)
|
1318
|
+
.replace("{{directory}}", &directory_display)
|
1319
|
+
.replace("{{rows}}", &rows);
|
1320
|
+
|
1321
|
+
Ok(html)
|
1322
|
+
}
|
1323
|
+
}
|
1324
|
+
}
|