itsi 0.1.14 → 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 (169) hide show
  1. checksums.yaml +4 -4
  2. data/Cargo.lock +124 -109
  3. data/Cargo.toml +6 -0
  4. data/crates/itsi_error/Cargo.toml +1 -0
  5. data/crates/itsi_error/src/lib.rs +100 -10
  6. data/crates/itsi_scheduler/src/itsi_scheduler.rs +1 -1
  7. data/crates/itsi_server/Cargo.toml +8 -10
  8. data/crates/itsi_server/src/default_responses/html/401.html +68 -0
  9. data/crates/itsi_server/src/default_responses/html/403.html +68 -0
  10. data/crates/itsi_server/src/default_responses/html/404.html +68 -0
  11. data/crates/itsi_server/src/default_responses/html/413.html +71 -0
  12. data/crates/itsi_server/src/default_responses/html/429.html +68 -0
  13. data/crates/itsi_server/src/default_responses/html/500.html +71 -0
  14. data/crates/itsi_server/src/default_responses/html/502.html +71 -0
  15. data/crates/itsi_server/src/default_responses/html/503.html +68 -0
  16. data/crates/itsi_server/src/default_responses/html/504.html +69 -0
  17. data/crates/itsi_server/src/default_responses/html/index.html +238 -0
  18. data/crates/itsi_server/src/default_responses/json/401.json +6 -0
  19. data/crates/itsi_server/src/default_responses/json/403.json +6 -0
  20. data/crates/itsi_server/src/default_responses/json/404.json +6 -0
  21. data/crates/itsi_server/src/default_responses/json/413.json +6 -0
  22. data/crates/itsi_server/src/default_responses/json/429.json +6 -0
  23. data/crates/itsi_server/src/default_responses/json/500.json +6 -0
  24. data/crates/itsi_server/src/default_responses/json/502.json +6 -0
  25. data/crates/itsi_server/src/default_responses/json/503.json +6 -0
  26. data/crates/itsi_server/src/default_responses/json/504.json +6 -0
  27. data/crates/itsi_server/src/default_responses/mod.rs +11 -0
  28. data/crates/itsi_server/src/lib.rs +58 -26
  29. data/crates/itsi_server/src/prelude.rs +2 -0
  30. data/crates/itsi_server/src/ruby_types/README.md +21 -0
  31. data/crates/itsi_server/src/ruby_types/itsi_body_proxy/mod.rs +8 -6
  32. data/crates/itsi_server/src/ruby_types/itsi_grpc_call.rs +344 -0
  33. data/crates/itsi_server/src/ruby_types/{itsi_grpc_stream → itsi_grpc_response_stream}/mod.rs +121 -73
  34. data/crates/itsi_server/src/ruby_types/itsi_http_request.rs +103 -40
  35. data/crates/itsi_server/src/ruby_types/itsi_http_response.rs +8 -5
  36. data/crates/itsi_server/src/ruby_types/itsi_server/file_watcher.rs +4 -4
  37. data/crates/itsi_server/src/ruby_types/itsi_server/itsi_server_config.rs +37 -17
  38. data/crates/itsi_server/src/ruby_types/itsi_server.rs +4 -3
  39. data/crates/itsi_server/src/ruby_types/mod.rs +6 -13
  40. data/crates/itsi_server/src/server/{bind.rs → binds/bind.rs} +23 -4
  41. data/crates/itsi_server/src/server/{listener.rs → binds/listener.rs} +24 -10
  42. data/crates/itsi_server/src/server/binds/mod.rs +4 -0
  43. data/crates/itsi_server/src/server/{tls.rs → binds/tls.rs} +9 -4
  44. data/crates/itsi_server/src/server/http_message_types.rs +97 -0
  45. data/crates/itsi_server/src/server/io_stream.rs +2 -1
  46. data/crates/itsi_server/src/server/middleware_stack/middleware.rs +28 -16
  47. data/crates/itsi_server/src/server/middleware_stack/middlewares/allow_list.rs +17 -8
  48. data/crates/itsi_server/src/server/middleware_stack/middlewares/auth_api_key.rs +47 -18
  49. data/crates/itsi_server/src/server/middleware_stack/middlewares/auth_basic.rs +13 -9
  50. data/crates/itsi_server/src/server/middleware_stack/middlewares/auth_jwt.rs +50 -29
  51. data/crates/itsi_server/src/server/middleware_stack/middlewares/cache_control.rs +5 -2
  52. data/crates/itsi_server/src/server/middleware_stack/middlewares/compression.rs +37 -48
  53. data/crates/itsi_server/src/server/middleware_stack/middlewares/cors.rs +25 -20
  54. data/crates/itsi_server/src/server/middleware_stack/middlewares/deny_list.rs +14 -7
  55. data/crates/itsi_server/src/server/middleware_stack/middlewares/error_response/default_responses.rs +190 -0
  56. data/crates/itsi_server/src/server/middleware_stack/middlewares/error_response.rs +125 -95
  57. data/crates/itsi_server/src/server/middleware_stack/middlewares/etag.rs +9 -5
  58. data/crates/itsi_server/src/server/middleware_stack/middlewares/header_interpretation.rs +1 -4
  59. data/crates/itsi_server/src/server/middleware_stack/middlewares/intrusion_protection.rs +25 -19
  60. data/crates/itsi_server/src/server/middleware_stack/middlewares/log_requests.rs +4 -4
  61. data/crates/itsi_server/src/server/middleware_stack/middlewares/max_body.rs +47 -0
  62. data/crates/itsi_server/src/server/middleware_stack/middlewares/mod.rs +9 -4
  63. data/crates/itsi_server/src/server/middleware_stack/middlewares/proxy.rs +260 -62
  64. data/crates/itsi_server/src/server/middleware_stack/middlewares/rate_limit.rs +29 -22
  65. data/crates/itsi_server/src/server/middleware_stack/middlewares/redirect.rs +6 -6
  66. data/crates/itsi_server/src/server/middleware_stack/middlewares/request_headers.rs +6 -5
  67. data/crates/itsi_server/src/server/middleware_stack/middlewares/response_headers.rs +4 -2
  68. data/crates/itsi_server/src/server/middleware_stack/middlewares/ruby_app.rs +51 -18
  69. data/crates/itsi_server/src/server/middleware_stack/middlewares/static_assets.rs +31 -13
  70. data/crates/itsi_server/src/server/middleware_stack/middlewares/static_response.rs +55 -0
  71. data/crates/itsi_server/src/server/middleware_stack/middlewares/string_rewrite.rs +13 -8
  72. data/crates/itsi_server/src/server/middleware_stack/mod.rs +101 -69
  73. data/crates/itsi_server/src/server/mod.rs +3 -9
  74. data/crates/itsi_server/src/server/process_worker.rs +21 -3
  75. data/crates/itsi_server/src/server/request_job.rs +2 -2
  76. data/crates/itsi_server/src/server/serve_strategy/cluster_mode.rs +8 -3
  77. data/crates/itsi_server/src/server/serve_strategy/single_mode.rs +26 -26
  78. data/crates/itsi_server/src/server/signal.rs +24 -41
  79. data/crates/itsi_server/src/server/size_limited_incoming.rs +101 -0
  80. data/crates/itsi_server/src/server/thread_worker.rs +59 -28
  81. data/crates/itsi_server/src/services/itsi_http_service.rs +239 -0
  82. data/crates/itsi_server/src/services/mime_types.rs +1416 -0
  83. data/crates/itsi_server/src/services/mod.rs +6 -0
  84. data/crates/itsi_server/src/services/password_hasher.rs +83 -0
  85. data/crates/itsi_server/src/{server → services}/rate_limiter.rs +35 -31
  86. data/crates/itsi_server/src/{server → services}/static_file_server.rs +521 -181
  87. data/crates/itsi_tracing/src/lib.rs +145 -55
  88. data/{Itsi.rb → foo/Itsi.rb} +6 -9
  89. data/gems/scheduler/Cargo.lock +7 -0
  90. data/gems/scheduler/lib/itsi/scheduler/version.rb +1 -1
  91. data/gems/scheduler/test/helpers/test_helper.rb +0 -1
  92. data/gems/scheduler/test/test_address_resolve.rb +0 -1
  93. data/gems/scheduler/test/test_network_io.rb +1 -1
  94. data/gems/scheduler/test/test_process_wait.rb +0 -1
  95. data/gems/server/Cargo.lock +124 -109
  96. data/gems/server/exe/itsi +65 -19
  97. data/gems/server/itsi-server.gemspec +4 -3
  98. data/gems/server/lib/itsi/http_request/response_status_shortcodes.rb +74 -0
  99. data/gems/server/lib/itsi/http_request.rb +116 -17
  100. data/gems/server/lib/itsi/http_response.rb +2 -0
  101. data/gems/server/lib/itsi/passfile.rb +109 -0
  102. data/gems/server/lib/itsi/server/config/dsl.rb +160 -101
  103. data/gems/server/lib/itsi/server/config.rb +58 -23
  104. data/gems/server/lib/itsi/server/default_app/default_app.rb +25 -29
  105. data/gems/server/lib/itsi/server/default_app/index.html +113 -89
  106. data/gems/server/lib/itsi/server/{Itsi.rb → default_config/Itsi-rackup.rb} +1 -1
  107. data/gems/server/lib/itsi/server/default_config/Itsi.rb +107 -0
  108. data/gems/server/lib/itsi/server/grpc/grpc_call.rb +246 -0
  109. data/gems/server/lib/itsi/server/grpc/grpc_interface.rb +100 -0
  110. data/gems/server/lib/itsi/server/grpc/reflection/v1/reflection_pb.rb +26 -0
  111. data/gems/server/lib/itsi/server/grpc/reflection/v1/reflection_services_pb.rb +122 -0
  112. data/gems/server/lib/itsi/server/route_tester.rb +107 -0
  113. data/gems/server/lib/itsi/server/typed_handlers/param_parser.rb +200 -0
  114. data/gems/server/lib/itsi/server/typed_handlers/source_parser.rb +55 -0
  115. data/gems/server/lib/itsi/server/typed_handlers.rb +17 -0
  116. data/gems/server/lib/itsi/server/version.rb +1 -1
  117. data/gems/server/lib/itsi/server.rb +82 -12
  118. data/gems/server/lib/ruby_lsp/itsi/addon.rb +111 -0
  119. data/gems/server/lib/shell_completions/completions.rb +26 -0
  120. data/gems/server/test/helpers/test_helper.rb +2 -1
  121. data/lib/itsi/version.rb +1 -1
  122. data/sandbox/README.md +5 -0
  123. data/sandbox/itsi_file/Gemfile +4 -2
  124. data/sandbox/itsi_file/Gemfile.lock +48 -6
  125. data/sandbox/itsi_file/Itsi.rb +326 -129
  126. data/sandbox/itsi_file/call.json +1 -0
  127. data/sandbox/itsi_file/echo_client/Gemfile +10 -0
  128. data/sandbox/itsi_file/echo_client/Gemfile.lock +27 -0
  129. data/sandbox/itsi_file/echo_client/README.md +95 -0
  130. data/sandbox/itsi_file/echo_client/echo_client.rb +164 -0
  131. data/sandbox/itsi_file/echo_client/gen_proto.sh +17 -0
  132. data/sandbox/itsi_file/echo_client/lib/echo_pb.rb +16 -0
  133. data/sandbox/itsi_file/echo_client/lib/echo_services_pb.rb +29 -0
  134. data/sandbox/itsi_file/echo_client/run_client.rb +64 -0
  135. data/sandbox/itsi_file/echo_client/test_compressions.sh +20 -0
  136. data/sandbox/itsi_file/echo_service_nonitsi/Gemfile +10 -0
  137. data/sandbox/itsi_file/echo_service_nonitsi/Gemfile.lock +79 -0
  138. data/sandbox/itsi_file/echo_service_nonitsi/echo.proto +26 -0
  139. data/sandbox/itsi_file/echo_service_nonitsi/echo_pb.rb +16 -0
  140. data/sandbox/itsi_file/echo_service_nonitsi/echo_services_pb.rb +29 -0
  141. data/sandbox/itsi_file/echo_service_nonitsi/server.rb +52 -0
  142. data/sandbox/itsi_sandbox_async/config.ru +0 -1
  143. data/sandbox/itsi_sandbox_rack/Gemfile.lock +2 -2
  144. data/sandbox/itsi_sandbox_rails/Gemfile +2 -2
  145. data/sandbox/itsi_sandbox_rails/Gemfile.lock +76 -2
  146. data/sandbox/itsi_sandbox_rails/app/controllers/home_controller.rb +15 -0
  147. data/sandbox/itsi_sandbox_rails/config/environments/development.rb +1 -0
  148. data/sandbox/itsi_sandbox_rails/config/environments/production.rb +1 -0
  149. data/sandbox/itsi_sandbox_rails/config/routes.rb +2 -0
  150. data/sandbox/itsi_sinatra/app.rb +0 -1
  151. data/sandbox/static_files/.env +1 -0
  152. data/sandbox/static_files/404.html +25 -0
  153. data/sandbox/static_files/_DSC0102.NEF.jpg +0 -0
  154. data/sandbox/static_files/about.html +68 -0
  155. data/sandbox/static_files/tiny.html +1 -0
  156. data/sandbox/static_files/writebook.zip +0 -0
  157. data/tasks.txt +28 -33
  158. metadata +87 -26
  159. data/crates/itsi_error/src/from.rs +0 -68
  160. data/crates/itsi_server/src/ruby_types/itsi_grpc_request.rs +0 -147
  161. data/crates/itsi_server/src/ruby_types/itsi_grpc_response.rs +0 -19
  162. data/crates/itsi_server/src/server/itsi_service.rs +0 -172
  163. data/crates/itsi_server/src/server/middleware_stack/middlewares/grpc_service.rs +0 -72
  164. data/crates/itsi_server/src/server/types.rs +0 -43
  165. data/gems/server/lib/itsi/server/grpc_interface.rb +0 -213
  166. data/sandbox/itsi_file/public/assets/index.html +0 -1
  167. /data/crates/itsi_server/src/server/{bind_protocol.rs → binds/bind_protocol.rs} +0 -0
  168. /data/crates/itsi_server/src/server/{tls → binds/tls}/locked_dir_cache.rs +0 -0
  169. /data/crates/itsi_server/src/{server → services}/cache_store.rs +0 -0
@@ -1,25 +1,42 @@
1
- use super::{
2
- middleware_stack::ErrorResponse,
3
- types::{HttpRequest, HttpResponse},
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
+ },
4
8
  };
9
+ use base64::{engine::general_purpose, Engine};
5
10
  use bytes::Bytes;
6
11
  use chrono::{DateTime, Utc};
7
- use http::{header, Response, StatusCode};
12
+ use http::{
13
+ header::{
14
+ self, CONTENT_ENCODING, CONTENT_LENGTH, CONTENT_RANGE, CONTENT_TYPE, ETAG, LAST_MODIFIED,
15
+ },
16
+ HeaderValue, Response, StatusCode,
17
+ };
8
18
  use http_body_util::{combinators::BoxBody, Full};
9
19
  use itsi_error::Result;
10
20
  use moka::sync::Cache;
21
+ use percent_encoding::{percent_decode_str, utf8_percent_encode, NON_ALPHANUMERIC};
11
22
  use serde::Deserialize;
23
+ use serde_json::json;
24
+ use sha2::{Digest, Sha256};
12
25
  use std::{
26
+ borrow::Cow,
27
+ cmp::Ordering,
13
28
  collections::HashMap,
14
29
  convert::Infallible,
15
30
  fs::Metadata,
31
+ ops::Deref,
16
32
  path::{Path, PathBuf},
17
33
  sync::{Arc, LazyLock},
18
34
  time::{Duration, Instant, SystemTime},
19
35
  };
20
36
  use tokio::sync::Mutex;
21
37
  use tokio::{fs::File, io::AsyncReadExt};
22
- use tracing::{info, warn};
38
+
39
+ use super::mime_types::get_mime_type;
23
40
 
24
41
  pub static ROOT_STATIC_FILE_SERVER: LazyLock<StaticFileServer> = LazyLock::new(|| {
25
42
  StaticFileServer::new(StaticFileServerConfig {
@@ -29,7 +46,9 @@ pub static ROOT_STATIC_FILE_SERVER: LazyLock<StaticFileServer> = LazyLock::new(|
29
46
  recheck_interval: Duration::from_secs(1),
30
47
  try_html_extension: true,
31
48
  auto_index: true,
32
- not_found_behavior: NotFoundBehavior::Error(ErrorResponse::default()),
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()],
33
52
  })
34
53
  });
35
54
 
@@ -61,6 +80,8 @@ pub struct StaticFileServerConfig {
61
80
  pub try_html_extension: bool,
62
81
  pub auto_index: bool,
63
82
  pub not_found_behavior: NotFoundBehavior,
83
+ pub serve_hidden_files: bool,
84
+ pub allowed_extensions: Vec<String>,
64
85
  }
65
86
 
66
87
  #[derive(Debug, Clone)]
@@ -70,13 +91,55 @@ pub struct StaticFileServer {
70
91
  cache: Cache<PathBuf, CacheEntry>,
71
92
  }
72
93
 
94
+ impl Deref for StaticFileServer {
95
+ type Target = StaticFileServerConfig;
96
+
97
+ fn deref(&self) -> &Self::Target {
98
+ &self.config
99
+ }
100
+ }
101
+
73
102
  #[derive(Clone, Debug)]
74
103
  struct CacheEntry {
75
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,
76
110
  last_modified: SystemTime,
77
111
  last_checked: Instant,
78
112
  }
79
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
+
80
143
  #[derive(Debug, Clone)]
81
144
  pub enum ServeRange {
82
145
  Range(u64, u64),
@@ -86,21 +149,48 @@ pub enum ServeRange {
86
149
  impl CacheEntry {
87
150
  async fn new(path: PathBuf) -> Result<Self> {
88
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
+ };
89
158
  Ok(CacheEntry {
90
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),
91
164
  last_modified,
165
+ etag,
92
166
  last_checked: Instant::now(),
93
167
  })
94
168
  }
95
169
 
96
- async fn new_virtual_listing(path: PathBuf, root_dir: &Path) -> Self {
97
- let directory_listing: Bytes = generate_directory_listing(path.parent().unwrap(), root_dir)
98
- .await
99
- .unwrap_or("".to_owned())
100
- .into();
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
+ };
101
186
  CacheEntry {
102
187
  content: Arc::new(directory_listing),
188
+ gzip_encoded: None,
189
+ br_encoded: None,
190
+ zstd_encoded: None,
191
+ deflate_encoded: None,
103
192
  last_modified: SystemTime::now(),
193
+ etag,
104
194
  last_checked: Instant::now(),
105
195
  }
106
196
  }
@@ -115,6 +205,7 @@ struct ServeCacheArgs<'a>(
115
205
  Option<SystemTime>,
116
206
  bool,
117
207
  &'a Path,
208
+ &'a [HeaderValue],
118
209
  );
119
210
 
120
211
  impl StaticFileServer {
@@ -128,6 +219,7 @@ impl StaticFileServer {
128
219
  }
129
220
  }
130
221
 
222
+ #[allow(clippy::too_many_arguments)]
131
223
  pub async fn serve(
132
224
  &self,
133
225
  request: &HttpRequest,
@@ -136,8 +228,11 @@ impl StaticFileServer {
136
228
  serve_range: ServeRange,
137
229
  if_modified_since: Option<SystemTime>,
138
230
  is_head_request: bool,
231
+ supported_encodings: &[HeaderValue],
139
232
  ) -> Option<HttpResponse> {
140
- let resolved = self.resolve(path, abs_path).await;
233
+ let accept: ResponseFormat = request.accept().into();
234
+ let resolved = self.resolve(path, abs_path, accept.clone()).await;
235
+
141
236
  Some(match resolved {
142
237
  Ok(ResolvedAsset {
143
238
  path,
@@ -160,6 +255,7 @@ impl StaticFileServer {
160
255
  if_modified_since,
161
256
  is_head_request,
162
257
  &path,
258
+ supported_encodings,
163
259
  ))
164
260
  } else {
165
261
  self.serve_stream_content(ServeStreamArgs(
@@ -184,27 +280,58 @@ impl StaticFileServer {
184
280
  .unwrap(),
185
281
  Err(not_found_behavior) => match not_found_behavior {
186
282
  NotFoundBehavior::Error(error_response) => {
187
- error_response.to_http_response(request).await
283
+ error_response
284
+ .to_http_response(request.accept().into())
285
+ .await
188
286
  }
189
287
  NotFoundBehavior::FallThrough => return None,
190
288
  NotFoundBehavior::IndexFile(index_file) => {
191
- self.serve_single(index_file.to_str().unwrap()).await
289
+ self.serve_single(index_file.to_str().unwrap(), accept, supported_encodings)
290
+ .await
192
291
  }
193
292
  NotFoundBehavior::Redirect(redirect) => Response::builder()
194
293
  .status(StatusCode::MOVED_PERMANENTLY)
195
294
  .header(header::LOCATION, redirect.to)
196
295
  .body(BoxBody::new(Full::new(Bytes::new())))
197
296
  .unwrap(),
198
- NotFoundBehavior::InternalServerError => Response::builder()
199
- .status(StatusCode::INTERNAL_SERVER_ERROR)
200
- .body(BoxBody::new(Full::new(Bytes::new())))
201
- .unwrap(),
297
+ NotFoundBehavior::InternalServerError => {
298
+ INTERNAL_SERVER_ERROR_RESPONSE
299
+ .to_http_response(request.accept().into())
300
+ .await
301
+ }
202
302
  },
203
303
  })
204
304
  }
205
305
 
206
- pub async fn serve_single(&self, path: &str) -> HttpResponse {
207
- let resolved = self.resolve(path, path).await;
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;
208
335
  if let Ok(ResolvedAsset {
209
336
  path,
210
337
  cache_entry: Some(cache_entry),
@@ -219,6 +346,7 @@ impl StaticFileServer {
219
346
  None,
220
347
  false,
221
348
  &path,
349
+ supported_encodings,
222
350
  ));
223
351
  } else if let Ok(ResolvedAsset { path, metadata, .. }) = resolved {
224
352
  return self
@@ -245,6 +373,7 @@ impl StaticFileServer {
245
373
  &self,
246
374
  key: &str,
247
375
  abs_path: &str,
376
+ accept: ResponseFormat,
248
377
  ) -> std::result::Result<ResolvedAsset, NotFoundBehavior> {
249
378
  // First check if we have a cached mapping for this key
250
379
  if let Some(path) = self.key_to_path.lock().await.get(key) {
@@ -290,7 +419,20 @@ impl StaticFileServer {
290
419
  }
291
420
 
292
421
  // No valid cached entry, resolve the key to a file path
293
- let normalized_path = normalize_path(key).ok_or(NotFoundBehavior::InternalServerError)?;
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
+
294
436
  let mut full_path = self.config.root_dir.clone();
295
437
  full_path.push(normalized_path);
296
438
  // Check if path exists and is a file
@@ -368,14 +510,17 @@ impl StaticFileServer {
368
510
  });
369
511
  }
370
512
 
371
- // No index.html, check if auto_index is enabled
372
513
  if self.config.auto_index {
373
- // Create a virtual path for the directory listing
374
- let virtual_path = full_path.join(".directory_listing.dir_list");
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
+ };
375
519
 
376
520
  let cache_entry = CacheEntry::new_virtual_listing(
377
521
  virtual_path.clone(),
378
- &self.config.root_dir,
522
+ &self.config,
523
+ accept,
379
524
  )
380
525
  .await;
381
526
  self.key_to_path
@@ -584,6 +729,8 @@ impl StaticFileServer {
584
729
 
585
730
  build_file_response(
586
731
  status,
732
+ None,
733
+ None,
587
734
  get_mime_type(&file),
588
735
  (end_idx - start) as usize,
589
736
  last_modified,
@@ -593,6 +740,8 @@ impl StaticFileServer {
593
740
  } else {
594
741
  build_file_response(
595
742
  status,
743
+ None,
744
+ None,
596
745
  get_mime_type(&file),
597
746
  content_length as usize,
598
747
  last_modified,
@@ -614,11 +763,11 @@ impl StaticFileServer {
614
763
  if_modified_since,
615
764
  is_head_request,
616
765
  path,
766
+ supported_encodings,
617
767
  ) = serve_cache_args;
618
768
 
619
769
  let content_length = cache_entry.content.len() as u64;
620
770
 
621
- // Handle If-Modified-Since header
622
771
  if is_not_modified(cache_entry.last_modified, if_modified_since) {
623
772
  return build_not_modified_response();
624
773
  }
@@ -677,15 +826,20 @@ impl StaticFileServer {
677
826
  return builder.body(BoxBody::new(Full::new(Bytes::new()))).unwrap();
678
827
  }
679
828
 
680
- // For GET requests, prepare the actual content
681
829
  if is_range_request {
682
- // Extract the requested range from the cached content
683
830
  let start_idx = start as usize;
684
831
  let end_idx = std::cmp::min((adjusted_end + 1) as usize, cache_entry.content.len());
685
832
  let range_bytes = cache_entry.content.slice(start_idx..end_idx);
686
-
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
+ };
687
839
  build_file_response(
688
840
  status,
841
+ None,
842
+ Some(&etag),
689
843
  get_mime_type(path),
690
844
  range_bytes.len(),
691
845
  cache_entry.last_modified,
@@ -694,10 +848,12 @@ impl StaticFileServer {
694
848
  )
695
849
  } else {
696
850
  // Return the full content
697
- let content_clone = cache_entry.content.clone();
698
- let body = build_ok_body(content_clone);
851
+ let (content, encoding) = cache_entry.suggest_content_for(supported_encodings);
852
+ let body = build_ok_body(content);
699
853
  build_file_response(
700
854
  status,
855
+ Some(encoding),
856
+ Some(&cache_entry.etag),
701
857
  get_mime_type(path),
702
858
  content_length as usize,
703
859
  cache_entry.last_modified,
@@ -728,38 +884,31 @@ async fn read_entire_file(path: &Path) -> std::io::Result<(Bytes, SystemTime)> {
728
884
  Ok((Bytes::from(buf), last_modified))
729
885
  }
730
886
 
731
- fn build_ok_body(bytes: Arc<Bytes>) -> BoxBody<Bytes, Infallible> {
732
- BoxBody::new(Full::new(bytes.as_ref().clone()))
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
733
895
  }
734
896
 
735
- // Helper for mime type mapping from file extension
736
- fn get_mime_type(path: &Path) -> &'static str {
737
- match path.extension().and_then(|e| e.to_str()) {
738
- Some("html") | Some("htm") => "text/html",
739
- Some("css") => "text/css",
740
- Some("js") => "application/javascript",
741
- Some("json") => "application/json",
742
- Some("txt") => "text/plain",
743
- Some("png") => "image/png",
744
- Some("jpg") | Some("jpeg") => "image/jpeg",
745
- Some("gif") => "image/gif",
746
- Some("svg") => "image/svg+xml",
747
- Some("ico") => "image/x-icon",
748
- Some("webp") => "image/webp",
749
- Some("pdf") => "application/pdf",
750
- Some("xml") => "application/xml",
751
- Some("zip") => "application/zip",
752
- Some("gz") => "application/gzip",
753
- Some("mp3") => "audio/mpeg",
754
- Some("mp4") => "video/mp4",
755
- Some("webm") => "video/webm",
756
- Some("woff") => "font/woff",
757
- Some("woff2") => "font/woff2",
758
- Some("ttf") => "font/ttf",
759
- Some("otf") => "font/otf",
760
- Some("dir_list") => "text/html",
761
- _ => "application/octet-stream",
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
+ }
762
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()))
763
912
  }
764
913
 
765
914
  // Helper function to handle not modified responses
@@ -770,9 +919,11 @@ fn build_not_modified_response() -> http::Response<BoxBody<Bytes, Infallible>> {
770
919
  .unwrap()
771
920
  }
772
921
 
773
- // Helper function to build a file response with common headers
922
+ #[allow(clippy::too_many_arguments)]
774
923
  fn build_file_response(
775
924
  status: StatusCode,
925
+ content_encoding: Option<&str>,
926
+ etag: Option<&str>,
776
927
  content_type: &str,
777
928
  content_length: usize,
778
929
  last_modified: SystemTime,
@@ -781,12 +932,20 @@ fn build_file_response(
781
932
  ) -> http::Response<BoxBody<Bytes, Infallible>> {
782
933
  let mut builder = Response::builder()
783
934
  .status(status)
784
- .header("Content-Type", content_type)
785
- .header("Content-Length", content_length)
786
- .header("Last-Modified", format_http_date(last_modified));
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
+ }
787
946
 
788
947
  if let Some(range) = range_header {
789
- builder = builder.header("Content-Range", range);
948
+ builder = builder.header(CONTENT_RANGE, range);
790
949
  }
791
950
 
792
951
  builder.body(body).unwrap()
@@ -802,7 +961,7 @@ fn is_not_modified(last_modified: SystemTime, if_modified_since: Option<SystemTi
802
961
  false
803
962
  }
804
963
 
805
- fn normalize_path(path: &str) -> Option<PathBuf> {
964
+ fn normalize_path(path: Cow<'_, str>) -> Option<PathBuf> {
806
965
  let mut normalized = PathBuf::new();
807
966
  let path = path.trim_start_matches('/');
808
967
 
@@ -849,136 +1008,317 @@ impl Default for StaticFileServer {
849
1008
  recheck_interval: Duration::from_secs(60),
850
1009
  try_html_extension: true,
851
1010
  auto_index: true,
852
- not_found_behavior: NotFoundBehavior::Error(ErrorResponse::default()),
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()],
853
1014
  };
854
1015
  Self::new(config)
855
1016
  }
856
1017
  }
857
1018
 
858
- async fn generate_directory_listing(dir_path: &Path, root_dir: &Path) -> std::io::Result<String> {
859
- let mut html = String::new();
860
-
861
- html.push_str("<!DOCTYPE html>\n<html>\n<head>\n");
862
- html.push_str("<title>Directory listing for ");
863
- html.push_str(
864
- dir_path
865
- .strip_prefix(root_dir)
866
- .unwrap_or(Path::new(""))
867
- .to_str()
868
- .unwrap_or(""),
869
- );
870
- html.push_str("</title>\n");
871
- html.push_str("<meta charset=\"UTF-8\">\n");
872
- html.push_str("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n");
873
- html.push_str("<style>\n");
874
- html.push_str(" body { font-family: sans-serif; margin: 0; padding: 20px; }\n");
875
- html.push_str(" h1 { border-bottom: 1px solid #ccc; margin-top: 0; padding-bottom: 5px; }\n");
876
- html.push_str(" table { border-collapse: collapse; width: 100%; }\n");
877
- html.push_str(" th, td { text-align: left; padding: 8px; }\n");
878
- html.push_str(" tr:nth-child(even) { background-color: #f2f2f2; }\n");
879
- html.push_str(" th { background-color: #4CAF50; color: white; }\n");
880
- html.push_str(" a { text-decoration: none; color: #0366d6; }\n");
881
- html.push_str(" a:hover { text-decoration: underline; }\n");
882
- html.push_str(" .size { text-align: right; }\n");
883
- html.push_str(" .date { white-space: nowrap; }\n");
884
- html.push_str("</style>\n");
885
- html.push_str("</head>\n<body>\n");
886
-
887
- html.push_str("<h1>Directory listing for ");
888
- html.push_str(&dir_path.display().to_string());
889
- html.push_str("</h1>\n");
890
-
891
- html.push_str("<table>\n");
892
- html.push_str("<tr><th>Name</th><th>Size</th><th>Last Modified</th></tr>\n");
893
-
894
- // Add parent directory link if not in root
895
-
896
- if dir_path != root_dir {
897
- info!("{} != {}", dir_path.display(), root_dir.display());
898
- html.push_str("<tr><td><a href=\"..\">..</a></td><td class=\"size\">-</td><td class=\"date\">-</td></tr>\n");
899
- }
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
+ }
900
1050
 
901
- // Read the directory entries
902
- let mut entries = tokio::fs::read_dir(dir_path).await.unwrap();
903
- let mut files = Vec::new();
904
- let mut dirs = Vec::new();
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
+ }
905
1068
 
906
- while let Some(entry) = entries.next_entry().await? {
907
- let entry_path = entry.path();
908
- let metadata = entry.metadata().await?;
909
- let name = entry_path.file_name().unwrap().to_string_lossy();
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
+ }
910
1082
 
911
- if metadata.is_dir() {
912
- dirs.push((name.to_string(), metadata));
913
- } else {
914
- files.push((name.to_string(), metadata));
915
- }
916
- }
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
+ });
917
1097
 
918
- // Sort directories and files
919
- dirs.sort_by(|a, b| a.0.cmp(&b.0));
920
- files.sort_by(|a, b| a.0.cmp(&b.0));
921
-
922
- // Add directories to HTML
923
- for (name, metadata) in dirs {
924
- html.push_str("<tr>");
925
- html.push_str("<td><a href=\"");
926
- html.push_str(&name);
927
- html.push_str("/\">");
928
- html.push_str(&name);
929
- html.push_str("/</a></td>");
930
- html.push_str("<td class=\"size\">-</td>");
931
-
932
- if let Ok(modified) = metadata.modified() {
933
- let formatted_time = DateTime::<Utc>::from(modified)
934
- .format("%Y-%m-%d %H:%M:%S")
935
- .to_string();
936
- html.push_str(&format!("<td class=\"date\">{}</td>", formatted_time));
937
- } else {
938
- html.push_str("<td class=\"date\">-</td>");
939
- }
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
+ });
940
1112
 
941
- html.push_str("</tr>\n");
942
- }
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
+ }
943
1134
 
944
- // Add files to HTML
945
- for (name, metadata) in files {
946
- html.push_str("<tr>");
947
- html.push_str("<td><a href=\"");
948
- html.push_str(&name);
949
- html.push_str("\">");
950
- html.push_str(&name);
951
- html.push_str("</a></td>");
952
-
953
- // Format file size
954
- let file_size = metadata.len();
955
- let formatted_size = if file_size < 1024 {
956
- format!("{} B", file_size)
957
- } else if file_size < 1024 * 1024 {
958
- format!("{:.1} KB", file_size as f64 / 1024.0)
959
- } else if file_size < 1024 * 1024 * 1024 {
960
- format!("{:.1} MB", file_size as f64 / (1024.0 * 1024.0))
961
- } else {
962
- format!("{:.1} GB", file_size as f64 / (1024.0 * 1024.0 * 1024.0))
963
- };
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
+ };
964
1148
 
965
- html.push_str(&format!("<td class=\"size\">{}</td>", formatted_size));
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
+ }
966
1167
 
967
- if let Ok(modified) = metadata.modified() {
968
- let formatted_time = DateTime::<Utc>::from(modified)
969
- .format("%Y-%m-%d %H:%M:%S")
970
- .to_string();
971
- html.push_str(&format!("<td class=\"date\">{}</td>", formatted_time));
972
- } else {
973
- html.push_str("<td class=\"date\">-</td>");
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)
974
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
+ }
975
1203
 
976
- html.push_str("</tr>\n");
977
- }
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
+ }
978
1235
 
979
- html.push_str("</table>\n");
980
- html.push_str("<hr><p>Served by Itsi Static Assets</p>\n");
981
- html.push_str("</body>\n</html>");
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
+ });
982
1250
 
983
- Ok(html)
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
+ }
984
1324
  }