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.
Files changed (184) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +5 -0
  3. data/CODE_OF_CONDUCT.md +7 -0
  4. data/Cargo.lock +3937 -0
  5. data/Cargo.toml +7 -0
  6. data/README.md +4 -0
  7. data/Rakefile +8 -1
  8. data/_index.md +6 -0
  9. data/exe/itsi +141 -46
  10. data/ext/itsi_error/Cargo.toml +3 -0
  11. data/ext/itsi_error/src/lib.rs +98 -24
  12. data/ext/itsi_error/target/debug/build/clang-sys-da71b0344e568175/out/common.rs +355 -0
  13. data/ext/itsi_error/target/debug/build/clang-sys-da71b0344e568175/out/dynamic.rs +276 -0
  14. data/ext/itsi_error/target/debug/build/clang-sys-da71b0344e568175/out/macros.rs +49 -0
  15. data/ext/itsi_error/target/debug/build/rb-sys-49f554618693db24/out/bindings-0.9.110-mri-arm64-darwin23-3.4.2.rs +8865 -0
  16. data/ext/itsi_error/target/debug/incremental/itsi_error-1mmt5sux7jb0i/s-h510z7m8v9-0bxu7yd.lock +0 -0
  17. data/ext/itsi_error/target/debug/incremental/itsi_error-2vn3jey74oiw0/s-h5113n0e7e-1v5qzs6.lock +0 -0
  18. data/ext/itsi_error/target/debug/incremental/itsi_error-37uv9dicz7awp/s-h510ykifhe-0tbnep2.lock +0 -0
  19. data/ext/itsi_error/target/debug/incremental/itsi_error-37uv9dicz7awp/s-h510yyocpj-0tz7ug7.lock +0 -0
  20. data/ext/itsi_error/target/debug/incremental/itsi_error-37uv9dicz7awp/s-h510z0xc8g-14ol18k.lock +0 -0
  21. data/ext/itsi_error/target/debug/incremental/itsi_error-3g5qf4y7d54uj/s-h5113n0e7d-1trk8on.lock +0 -0
  22. data/ext/itsi_error/target/debug/incremental/itsi_error-3lpfftm45d3e2/s-h510z7m8r3-1pxp20o.lock +0 -0
  23. data/ext/itsi_error/target/debug/incremental/itsi_error-3o4qownhl3d7n/s-h510ykifek-1uxasnk.lock +0 -0
  24. data/ext/itsi_error/target/debug/incremental/itsi_error-3o4qownhl3d7n/s-h510yyocki-11u37qm.lock +0 -0
  25. data/ext/itsi_error/target/debug/incremental/itsi_error-3o4qownhl3d7n/s-h510z0xc93-0pmy0zm.lock +0 -0
  26. data/ext/itsi_instrument_entry/Cargo.toml +15 -0
  27. data/ext/itsi_instrument_entry/src/lib.rs +31 -0
  28. data/ext/itsi_rb_helpers/Cargo.toml +3 -0
  29. data/ext/itsi_rb_helpers/src/heap_value.rs +139 -0
  30. data/ext/itsi_rb_helpers/src/lib.rs +140 -10
  31. data/ext/itsi_rb_helpers/target/debug/build/clang-sys-da71b0344e568175/out/common.rs +355 -0
  32. data/ext/itsi_rb_helpers/target/debug/build/clang-sys-da71b0344e568175/out/dynamic.rs +276 -0
  33. data/ext/itsi_rb_helpers/target/debug/build/clang-sys-da71b0344e568175/out/macros.rs +49 -0
  34. 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
  35. data/ext/itsi_rb_helpers/target/debug/incremental/itsi_rb_helpers-040pxg6yhb3g3/s-h5113n7a1b-03bwlt4.lock +0 -0
  36. data/ext/itsi_rb_helpers/target/debug/incremental/itsi_rb_helpers-131g1u4dzkt1a/s-h51113xnh3-1eik1ip.lock +0 -0
  37. data/ext/itsi_rb_helpers/target/debug/incremental/itsi_rb_helpers-131g1u4dzkt1a/s-h5111704jj-0g4rj8x.lock +0 -0
  38. data/ext/itsi_rb_helpers/target/debug/incremental/itsi_rb_helpers-1q2d3drtxrzs5/s-h5113n79yl-0bxcqc5.lock +0 -0
  39. data/ext/itsi_rb_helpers/target/debug/incremental/itsi_rb_helpers-374a9h7ovycj0/s-h51113xoox-10de2hp.lock +0 -0
  40. data/ext/itsi_rb_helpers/target/debug/incremental/itsi_rb_helpers-374a9h7ovycj0/s-h5111704w7-0vdq7gq.lock +0 -0
  41. data/ext/itsi_scheduler/Cargo.toml +24 -0
  42. data/ext/itsi_scheduler/src/itsi_scheduler/io_helpers.rs +56 -0
  43. data/ext/itsi_scheduler/src/itsi_scheduler/io_waiter.rs +44 -0
  44. data/ext/itsi_scheduler/src/itsi_scheduler/timer.rs +44 -0
  45. data/ext/itsi_scheduler/src/itsi_scheduler.rs +308 -0
  46. data/ext/itsi_scheduler/src/lib.rs +38 -0
  47. data/ext/itsi_server/Cargo.lock +2956 -0
  48. data/ext/itsi_server/Cargo.toml +72 -14
  49. data/ext/itsi_server/extconf.rb +1 -1
  50. data/ext/itsi_server/src/default_responses/html/401.html +68 -0
  51. data/ext/itsi_server/src/default_responses/html/403.html +68 -0
  52. data/ext/itsi_server/src/default_responses/html/404.html +68 -0
  53. data/ext/itsi_server/src/default_responses/html/413.html +71 -0
  54. data/ext/itsi_server/src/default_responses/html/429.html +68 -0
  55. data/ext/itsi_server/src/default_responses/html/500.html +71 -0
  56. data/ext/itsi_server/src/default_responses/html/502.html +71 -0
  57. data/ext/itsi_server/src/default_responses/html/503.html +68 -0
  58. data/ext/itsi_server/src/default_responses/html/504.html +69 -0
  59. data/ext/itsi_server/src/default_responses/html/index.html +238 -0
  60. data/ext/itsi_server/src/default_responses/json/401.json +6 -0
  61. data/ext/itsi_server/src/default_responses/json/403.json +6 -0
  62. data/ext/itsi_server/src/default_responses/json/404.json +6 -0
  63. data/ext/itsi_server/src/default_responses/json/413.json +6 -0
  64. data/ext/itsi_server/src/default_responses/json/429.json +6 -0
  65. data/ext/itsi_server/src/default_responses/json/500.json +6 -0
  66. data/ext/itsi_server/src/default_responses/json/502.json +6 -0
  67. data/ext/itsi_server/src/default_responses/json/503.json +6 -0
  68. data/ext/itsi_server/src/default_responses/json/504.json +6 -0
  69. data/ext/itsi_server/src/default_responses/mod.rs +11 -0
  70. data/ext/itsi_server/src/env.rs +43 -0
  71. data/ext/itsi_server/src/lib.rs +132 -40
  72. data/ext/itsi_server/src/prelude.rs +2 -0
  73. data/ext/itsi_server/src/ruby_types/itsi_body_proxy/big_bytes.rs +109 -0
  74. data/ext/itsi_server/src/ruby_types/itsi_body_proxy/mod.rs +143 -0
  75. data/ext/itsi_server/src/ruby_types/itsi_grpc_call.rs +344 -0
  76. data/ext/itsi_server/src/ruby_types/itsi_grpc_response_stream/mod.rs +264 -0
  77. data/ext/itsi_server/src/ruby_types/itsi_http_request.rs +345 -0
  78. data/ext/itsi_server/src/ruby_types/itsi_http_response.rs +391 -0
  79. data/ext/itsi_server/src/ruby_types/itsi_server/file_watcher.rs +225 -0
  80. data/ext/itsi_server/src/ruby_types/itsi_server/itsi_server_config.rs +375 -0
  81. data/ext/itsi_server/src/ruby_types/itsi_server.rs +83 -0
  82. data/ext/itsi_server/src/ruby_types/mod.rs +48 -0
  83. data/ext/itsi_server/src/server/binds/bind.rs +201 -0
  84. data/ext/itsi_server/src/server/binds/bind_protocol.rs +37 -0
  85. data/ext/itsi_server/src/server/binds/listener.rs +432 -0
  86. data/ext/itsi_server/src/server/binds/mod.rs +4 -0
  87. data/ext/itsi_server/src/server/binds/tls/locked_dir_cache.rs +132 -0
  88. data/ext/itsi_server/src/server/binds/tls.rs +270 -0
  89. data/ext/itsi_server/src/server/byte_frame.rs +32 -0
  90. data/ext/itsi_server/src/server/http_message_types.rs +97 -0
  91. data/ext/itsi_server/src/server/io_stream.rs +105 -0
  92. data/ext/itsi_server/src/server/lifecycle_event.rs +12 -0
  93. data/ext/itsi_server/src/server/middleware_stack/middleware.rs +165 -0
  94. data/ext/itsi_server/src/server/middleware_stack/middlewares/allow_list.rs +56 -0
  95. data/ext/itsi_server/src/server/middleware_stack/middlewares/auth_api_key.rs +87 -0
  96. data/ext/itsi_server/src/server/middleware_stack/middlewares/auth_basic.rs +86 -0
  97. data/ext/itsi_server/src/server/middleware_stack/middlewares/auth_jwt.rs +285 -0
  98. data/ext/itsi_server/src/server/middleware_stack/middlewares/cache_control.rs +142 -0
  99. data/ext/itsi_server/src/server/middleware_stack/middlewares/compression.rs +289 -0
  100. data/ext/itsi_server/src/server/middleware_stack/middlewares/cors.rs +292 -0
  101. data/ext/itsi_server/src/server/middleware_stack/middlewares/deny_list.rs +55 -0
  102. data/ext/itsi_server/src/server/middleware_stack/middlewares/error_response/default_responses.rs +190 -0
  103. data/ext/itsi_server/src/server/middleware_stack/middlewares/error_response.rs +157 -0
  104. data/ext/itsi_server/src/server/middleware_stack/middlewares/etag.rs +195 -0
  105. data/ext/itsi_server/src/server/middleware_stack/middlewares/header_interpretation.rs +82 -0
  106. data/ext/itsi_server/src/server/middleware_stack/middlewares/intrusion_protection.rs +201 -0
  107. data/ext/itsi_server/src/server/middleware_stack/middlewares/log_requests.rs +82 -0
  108. data/ext/itsi_server/src/server/middleware_stack/middlewares/max_body.rs +47 -0
  109. data/ext/itsi_server/src/server/middleware_stack/middlewares/mod.rs +87 -0
  110. data/ext/itsi_server/src/server/middleware_stack/middlewares/proxy.rs +414 -0
  111. data/ext/itsi_server/src/server/middleware_stack/middlewares/rate_limit.rs +131 -0
  112. data/ext/itsi_server/src/server/middleware_stack/middlewares/redirect.rs +76 -0
  113. data/ext/itsi_server/src/server/middleware_stack/middlewares/request_headers.rs +44 -0
  114. data/ext/itsi_server/src/server/middleware_stack/middlewares/response_headers.rs +36 -0
  115. data/ext/itsi_server/src/server/middleware_stack/middlewares/ruby_app.rs +126 -0
  116. data/ext/itsi_server/src/server/middleware_stack/middlewares/static_assets.rs +180 -0
  117. data/ext/itsi_server/src/server/middleware_stack/middlewares/static_response.rs +55 -0
  118. data/ext/itsi_server/src/server/middleware_stack/middlewares/string_rewrite.rs +163 -0
  119. data/ext/itsi_server/src/server/middleware_stack/middlewares/token_source.rs +12 -0
  120. data/ext/itsi_server/src/server/middleware_stack/mod.rs +347 -0
  121. data/ext/itsi_server/src/server/mod.rs +12 -5
  122. data/ext/itsi_server/src/server/process_worker.rs +247 -0
  123. data/ext/itsi_server/src/server/request_job.rs +11 -0
  124. data/ext/itsi_server/src/server/serve_strategy/cluster_mode.rs +342 -0
  125. data/ext/itsi_server/src/server/serve_strategy/mod.rs +30 -0
  126. data/ext/itsi_server/src/server/serve_strategy/single_mode.rs +421 -0
  127. data/ext/itsi_server/src/server/signal.rs +76 -0
  128. data/ext/itsi_server/src/server/size_limited_incoming.rs +101 -0
  129. data/ext/itsi_server/src/server/thread_worker.rs +475 -0
  130. data/ext/itsi_server/src/services/cache_store.rs +74 -0
  131. data/ext/itsi_server/src/services/itsi_http_service.rs +239 -0
  132. data/ext/itsi_server/src/services/mime_types.rs +1416 -0
  133. data/ext/itsi_server/src/services/mod.rs +6 -0
  134. data/ext/itsi_server/src/services/password_hasher.rs +83 -0
  135. data/ext/itsi_server/src/services/rate_limiter.rs +569 -0
  136. data/ext/itsi_server/src/services/static_file_server.rs +1324 -0
  137. data/ext/itsi_tracing/Cargo.toml +5 -0
  138. data/ext/itsi_tracing/src/lib.rs +315 -7
  139. data/ext/itsi_tracing/target/debug/incremental/itsi_tracing-0994n8rpvvt9m/s-h510hfz1f6-1kbycmq.lock +0 -0
  140. data/ext/itsi_tracing/target/debug/incremental/itsi_tracing-0bob7bf4yq34i/s-h5113125h5-0lh4rag.lock +0 -0
  141. data/ext/itsi_tracing/target/debug/incremental/itsi_tracing-2fcodulrxbbxo/s-h510h2infk-0hp5kjw.lock +0 -0
  142. data/ext/itsi_tracing/target/debug/incremental/itsi_tracing-2iak63r1woi1l/s-h510h2in4q-0kxfzw1.lock +0 -0
  143. data/ext/itsi_tracing/target/debug/incremental/itsi_tracing-2kk4qj9gn5dg2/s-h5113124kv-0enwon2.lock +0 -0
  144. data/ext/itsi_tracing/target/debug/incremental/itsi_tracing-2mwo0yas7dtw4/s-h510hfz1ha-1udgpei.lock +0 -0
  145. data/lib/itsi/http_request/response_status_shortcodes.rb +74 -0
  146. data/lib/itsi/http_request.rb +186 -0
  147. data/lib/itsi/http_response.rb +41 -0
  148. data/lib/itsi/passfile.rb +109 -0
  149. data/lib/itsi/server/config/dsl.rb +565 -0
  150. data/lib/itsi/server/config.rb +166 -0
  151. data/lib/itsi/server/default_app/default_app.rb +34 -0
  152. data/lib/itsi/server/default_app/index.html +115 -0
  153. data/lib/itsi/server/default_config/Itsi-rackup.rb +119 -0
  154. data/lib/itsi/server/default_config/Itsi.rb +107 -0
  155. data/lib/itsi/server/grpc/grpc_call.rb +246 -0
  156. data/lib/itsi/server/grpc/grpc_interface.rb +100 -0
  157. data/lib/itsi/server/grpc/reflection/v1/reflection_pb.rb +26 -0
  158. data/lib/itsi/server/grpc/reflection/v1/reflection_services_pb.rb +122 -0
  159. data/lib/itsi/server/rack/handler/itsi.rb +27 -0
  160. data/lib/itsi/server/rack_interface.rb +94 -0
  161. data/lib/itsi/server/route_tester.rb +107 -0
  162. data/lib/itsi/server/scheduler_interface.rb +21 -0
  163. data/lib/itsi/server/scheduler_mode.rb +10 -0
  164. data/lib/itsi/server/signal_trap.rb +29 -0
  165. data/lib/itsi/server/typed_handlers/param_parser.rb +200 -0
  166. data/lib/itsi/server/typed_handlers/source_parser.rb +55 -0
  167. data/lib/itsi/server/typed_handlers.rb +17 -0
  168. data/lib/itsi/server/version.rb +1 -1
  169. data/lib/itsi/server.rb +160 -9
  170. data/lib/itsi/standard_headers.rb +86 -0
  171. data/lib/ruby_lsp/itsi/addon.rb +111 -0
  172. data/lib/shell_completions/completions.rb +26 -0
  173. metadata +182 -25
  174. data/ext/itsi_server/src/request/itsi_request.rs +0 -143
  175. data/ext/itsi_server/src/request/mod.rs +0 -1
  176. data/ext/itsi_server/src/server/bind.rs +0 -138
  177. data/ext/itsi_server/src/server/itsi_ca/itsi_ca.crt +0 -32
  178. data/ext/itsi_server/src/server/itsi_ca/itsi_ca.key +0 -52
  179. data/ext/itsi_server/src/server/itsi_server.rs +0 -182
  180. data/ext/itsi_server/src/server/listener.rs +0 -218
  181. data/ext/itsi_server/src/server/tls.rs +0 -138
  182. data/ext/itsi_server/src/server/transfer_protocol.rs +0 -23
  183. data/ext/itsi_server/src/stream_writer/mod.rs +0 -21
  184. 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
+ }