itsi-server 0.1.1 → 0.1.13
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.
Potentially problematic release.
This version of itsi-server might be problematic. Click here for more details.
- checksums.yaml +4 -4
- data/CHANGELOG.md +5 -0
- data/CODE_OF_CONDUCT.md +7 -0
- data/Cargo.lock +4417 -0
- data/Cargo.toml +7 -0
- data/README.md +4 -0
- data/Rakefile +8 -1
- data/_index.md +6 -0
- data/exe/itsi +94 -45
- data/ext/itsi_error/Cargo.toml +2 -0
- data/ext/itsi_error/src/from.rs +68 -0
- data/ext/itsi_error/src/lib.rs +18 -34
- data/ext/itsi_error/target/debug/build/clang-sys-da71b0344e568175/out/common.rs +355 -0
- data/ext/itsi_error/target/debug/build/clang-sys-da71b0344e568175/out/dynamic.rs +276 -0
- data/ext/itsi_error/target/debug/build/clang-sys-da71b0344e568175/out/macros.rs +49 -0
- data/ext/itsi_error/target/debug/build/rb-sys-49f554618693db24/out/bindings-0.9.110-mri-arm64-darwin23-3.4.2.rs +8865 -0
- data/ext/itsi_error/target/debug/incremental/itsi_error-1mmt5sux7jb0i/s-h510z7m8v9-0bxu7yd.lock +0 -0
- data/ext/itsi_error/target/debug/incremental/itsi_error-2vn3jey74oiw0/s-h5113n0e7e-1v5qzs6.lock +0 -0
- data/ext/itsi_error/target/debug/incremental/itsi_error-37uv9dicz7awp/s-h510ykifhe-0tbnep2.lock +0 -0
- data/ext/itsi_error/target/debug/incremental/itsi_error-37uv9dicz7awp/s-h510yyocpj-0tz7ug7.lock +0 -0
- data/ext/itsi_error/target/debug/incremental/itsi_error-37uv9dicz7awp/s-h510z0xc8g-14ol18k.lock +0 -0
- data/ext/itsi_error/target/debug/incremental/itsi_error-3g5qf4y7d54uj/s-h5113n0e7d-1trk8on.lock +0 -0
- data/ext/itsi_error/target/debug/incremental/itsi_error-3lpfftm45d3e2/s-h510z7m8r3-1pxp20o.lock +0 -0
- data/ext/itsi_error/target/debug/incremental/itsi_error-3o4qownhl3d7n/s-h510ykifek-1uxasnk.lock +0 -0
- data/ext/itsi_error/target/debug/incremental/itsi_error-3o4qownhl3d7n/s-h510yyocki-11u37qm.lock +0 -0
- data/ext/itsi_error/target/debug/incremental/itsi_error-3o4qownhl3d7n/s-h510z0xc93-0pmy0zm.lock +0 -0
- data/ext/itsi_instrument_entry/Cargo.toml +15 -0
- data/ext/itsi_instrument_entry/src/lib.rs +31 -0
- data/ext/itsi_rb_helpers/Cargo.toml +3 -0
- data/ext/itsi_rb_helpers/src/heap_value.rs +139 -0
- data/ext/itsi_rb_helpers/src/lib.rs +140 -10
- data/ext/itsi_rb_helpers/target/debug/build/clang-sys-da71b0344e568175/out/common.rs +355 -0
- data/ext/itsi_rb_helpers/target/debug/build/clang-sys-da71b0344e568175/out/dynamic.rs +276 -0
- data/ext/itsi_rb_helpers/target/debug/build/clang-sys-da71b0344e568175/out/macros.rs +49 -0
- data/ext/itsi_rb_helpers/target/debug/build/rb-sys-eb9ed4ff3a60f995/out/bindings-0.9.110-mri-arm64-darwin23-3.4.2.rs +8865 -0
- data/ext/itsi_rb_helpers/target/debug/incremental/itsi_rb_helpers-040pxg6yhb3g3/s-h5113n7a1b-03bwlt4.lock +0 -0
- data/ext/itsi_rb_helpers/target/debug/incremental/itsi_rb_helpers-131g1u4dzkt1a/s-h51113xnh3-1eik1ip.lock +0 -0
- data/ext/itsi_rb_helpers/target/debug/incremental/itsi_rb_helpers-131g1u4dzkt1a/s-h5111704jj-0g4rj8x.lock +0 -0
- data/ext/itsi_rb_helpers/target/debug/incremental/itsi_rb_helpers-1q2d3drtxrzs5/s-h5113n79yl-0bxcqc5.lock +0 -0
- data/ext/itsi_rb_helpers/target/debug/incremental/itsi_rb_helpers-374a9h7ovycj0/s-h51113xoox-10de2hp.lock +0 -0
- data/ext/itsi_rb_helpers/target/debug/incremental/itsi_rb_helpers-374a9h7ovycj0/s-h5111704w7-0vdq7gq.lock +0 -0
- data/ext/itsi_scheduler/Cargo.toml +24 -0
- data/ext/itsi_scheduler/src/itsi_scheduler/io_helpers.rs +56 -0
- data/ext/itsi_scheduler/src/itsi_scheduler/io_waiter.rs +44 -0
- data/ext/itsi_scheduler/src/itsi_scheduler/timer.rs +44 -0
- data/ext/itsi_scheduler/src/itsi_scheduler.rs +308 -0
- data/ext/itsi_scheduler/src/lib.rs +38 -0
- data/ext/itsi_server/Cargo.lock +2956 -0
- data/ext/itsi_server/Cargo.toml +73 -13
- data/ext/itsi_server/extconf.rb +1 -1
- data/ext/itsi_server/src/env.rs +43 -0
- data/ext/itsi_server/src/lib.rs +100 -40
- data/ext/itsi_server/src/ruby_types/itsi_body_proxy/big_bytes.rs +109 -0
- data/ext/itsi_server/src/ruby_types/itsi_body_proxy/mod.rs +141 -0
- data/ext/itsi_server/src/ruby_types/itsi_grpc_request.rs +147 -0
- data/ext/itsi_server/src/ruby_types/itsi_grpc_response.rs +19 -0
- data/ext/itsi_server/src/ruby_types/itsi_grpc_stream/mod.rs +216 -0
- data/ext/itsi_server/src/ruby_types/itsi_http_request.rs +282 -0
- data/ext/itsi_server/src/ruby_types/itsi_http_response.rs +388 -0
- data/ext/itsi_server/src/ruby_types/itsi_server/file_watcher.rs +225 -0
- data/ext/itsi_server/src/ruby_types/itsi_server/itsi_server_config.rs +355 -0
- data/ext/itsi_server/src/ruby_types/itsi_server.rs +82 -0
- data/ext/itsi_server/src/ruby_types/mod.rs +55 -0
- data/ext/itsi_server/src/server/bind.rs +75 -31
- data/ext/itsi_server/src/server/bind_protocol.rs +37 -0
- data/ext/itsi_server/src/server/byte_frame.rs +32 -0
- data/ext/itsi_server/src/server/cache_store.rs +74 -0
- data/ext/itsi_server/src/server/io_stream.rs +104 -0
- data/ext/itsi_server/src/server/itsi_service.rs +172 -0
- data/ext/itsi_server/src/server/lifecycle_event.rs +12 -0
- data/ext/itsi_server/src/server/listener.rs +332 -132
- data/ext/itsi_server/src/server/middleware_stack/middleware.rs +153 -0
- data/ext/itsi_server/src/server/middleware_stack/middlewares/allow_list.rs +47 -0
- data/ext/itsi_server/src/server/middleware_stack/middlewares/auth_api_key.rs +58 -0
- data/ext/itsi_server/src/server/middleware_stack/middlewares/auth_basic.rs +82 -0
- data/ext/itsi_server/src/server/middleware_stack/middlewares/auth_jwt.rs +321 -0
- data/ext/itsi_server/src/server/middleware_stack/middlewares/cache_control.rs +139 -0
- data/ext/itsi_server/src/server/middleware_stack/middlewares/compression.rs +300 -0
- data/ext/itsi_server/src/server/middleware_stack/middlewares/cors.rs +287 -0
- data/ext/itsi_server/src/server/middleware_stack/middlewares/deny_list.rs +48 -0
- data/ext/itsi_server/src/server/middleware_stack/middlewares/error_response.rs +127 -0
- data/ext/itsi_server/src/server/middleware_stack/middlewares/etag.rs +191 -0
- data/ext/itsi_server/src/server/middleware_stack/middlewares/grpc_service.rs +72 -0
- data/ext/itsi_server/src/server/middleware_stack/middlewares/header_interpretation.rs +85 -0
- data/ext/itsi_server/src/server/middleware_stack/middlewares/intrusion_protection.rs +195 -0
- data/ext/itsi_server/src/server/middleware_stack/middlewares/log_requests.rs +82 -0
- data/ext/itsi_server/src/server/middleware_stack/middlewares/mod.rs +82 -0
- data/ext/itsi_server/src/server/middleware_stack/middlewares/proxy.rs +216 -0
- data/ext/itsi_server/src/server/middleware_stack/middlewares/rate_limit.rs +124 -0
- data/ext/itsi_server/src/server/middleware_stack/middlewares/redirect.rs +76 -0
- data/ext/itsi_server/src/server/middleware_stack/middlewares/request_headers.rs +43 -0
- data/ext/itsi_server/src/server/middleware_stack/middlewares/response_headers.rs +34 -0
- data/ext/itsi_server/src/server/middleware_stack/middlewares/ruby_app.rs +93 -0
- data/ext/itsi_server/src/server/middleware_stack/middlewares/static_assets.rs +162 -0
- data/ext/itsi_server/src/server/middleware_stack/middlewares/string_rewrite.rs +158 -0
- data/ext/itsi_server/src/server/middleware_stack/middlewares/token_source.rs +12 -0
- data/ext/itsi_server/src/server/middleware_stack/mod.rs +315 -0
- data/ext/itsi_server/src/server/mod.rs +15 -2
- data/ext/itsi_server/src/server/process_worker.rs +229 -0
- data/ext/itsi_server/src/server/rate_limiter.rs +565 -0
- data/ext/itsi_server/src/server/request_job.rs +11 -0
- data/ext/itsi_server/src/server/serve_strategy/cluster_mode.rs +337 -0
- data/ext/itsi_server/src/server/serve_strategy/mod.rs +30 -0
- data/ext/itsi_server/src/server/serve_strategy/single_mode.rs +421 -0
- data/ext/itsi_server/src/server/signal.rs +93 -0
- data/ext/itsi_server/src/server/static_file_server.rs +984 -0
- data/ext/itsi_server/src/server/thread_worker.rs +444 -0
- data/ext/itsi_server/src/server/tls/locked_dir_cache.rs +132 -0
- data/ext/itsi_server/src/server/tls.rs +187 -60
- data/ext/itsi_server/src/server/types.rs +43 -0
- data/ext/itsi_tracing/Cargo.toml +5 -0
- data/ext/itsi_tracing/src/lib.rs +225 -7
- data/ext/itsi_tracing/target/debug/incremental/itsi_tracing-0994n8rpvvt9m/s-h510hfz1f6-1kbycmq.lock +0 -0
- data/ext/itsi_tracing/target/debug/incremental/itsi_tracing-0bob7bf4yq34i/s-h5113125h5-0lh4rag.lock +0 -0
- data/ext/itsi_tracing/target/debug/incremental/itsi_tracing-2fcodulrxbbxo/s-h510h2infk-0hp5kjw.lock +0 -0
- data/ext/itsi_tracing/target/debug/incremental/itsi_tracing-2iak63r1woi1l/s-h510h2in4q-0kxfzw1.lock +0 -0
- data/ext/itsi_tracing/target/debug/incremental/itsi_tracing-2kk4qj9gn5dg2/s-h5113124kv-0enwon2.lock +0 -0
- data/ext/itsi_tracing/target/debug/incremental/itsi_tracing-2mwo0yas7dtw4/s-h510hfz1ha-1udgpei.lock +0 -0
- data/lib/itsi/http_request.rb +87 -0
- data/lib/itsi/http_response.rb +39 -0
- data/lib/itsi/server/Itsi.rb +119 -0
- data/lib/itsi/server/config/dsl.rb +506 -0
- data/lib/itsi/server/config.rb +131 -0
- data/lib/itsi/server/default_app/default_app.rb +38 -0
- data/lib/itsi/server/default_app/index.html +91 -0
- data/lib/itsi/server/grpc_interface.rb +213 -0
- data/lib/itsi/server/rack/handler/itsi.rb +27 -0
- data/lib/itsi/server/rack_interface.rb +94 -0
- data/lib/itsi/server/scheduler_interface.rb +21 -0
- data/lib/itsi/server/scheduler_mode.rb +10 -0
- data/lib/itsi/server/signal_trap.rb +29 -0
- data/lib/itsi/server/version.rb +1 -1
- data/lib/itsi/server.rb +90 -9
- data/lib/itsi/standard_headers.rb +86 -0
- metadata +122 -31
- data/ext/itsi_server/src/request/itsi_request.rs +0 -143
- data/ext/itsi_server/src/request/mod.rs +0 -1
- data/ext/itsi_server/src/server/itsi_ca/itsi_ca.crt +0 -32
- data/ext/itsi_server/src/server/itsi_ca/itsi_ca.key +0 -52
- data/ext/itsi_server/src/server/itsi_server.rs +0 -182
- data/ext/itsi_server/src/server/transfer_protocol.rs +0 -23
- data/ext/itsi_server/src/stream_writer/mod.rs +0 -21
- data/lib/itsi/request.rb +0 -39
| @@ -0,0 +1,287 @@ | |
| 1 | 
            +
            use super::{FromValue, MiddlewareLayer};
         | 
| 2 | 
            +
            use crate::server::{
         | 
| 3 | 
            +
                itsi_service::RequestContext,
         | 
| 4 | 
            +
                types::{HttpRequest, HttpResponse, RequestExt},
         | 
| 5 | 
            +
            };
         | 
| 6 | 
            +
            use async_trait::async_trait;
         | 
| 7 | 
            +
            use http::{HeaderMap, Method, Response};
         | 
| 8 | 
            +
            use http_body_util::{combinators::BoxBody, Empty};
         | 
| 9 | 
            +
            use itsi_error::ItsiError;
         | 
| 10 | 
            +
            use magnus::error::Result;
         | 
| 11 | 
            +
            use serde::Deserialize;
         | 
| 12 | 
            +
             | 
| 13 | 
            +
            #[derive(Debug, Clone, Deserialize)]
         | 
| 14 | 
            +
            pub struct Cors {
         | 
| 15 | 
            +
                pub allowed_origins: Vec<String>,
         | 
| 16 | 
            +
                pub allowed_methods: Vec<HttpMethod>,
         | 
| 17 | 
            +
                pub allowed_headers: Vec<String>,
         | 
| 18 | 
            +
                pub exposed_headers: Vec<String>,
         | 
| 19 | 
            +
                pub allow_credentials: bool,
         | 
| 20 | 
            +
                pub max_age: Option<u64>,
         | 
| 21 | 
            +
            }
         | 
| 22 | 
            +
             | 
| 23 | 
            +
            #[derive(Debug, Clone, Deserialize)]
         | 
| 24 | 
            +
            pub enum HttpMethod {
         | 
| 25 | 
            +
                #[serde(rename(deserialize = "GET"))]
         | 
| 26 | 
            +
                Get,
         | 
| 27 | 
            +
                #[serde(rename(deserialize = "POST"))]
         | 
| 28 | 
            +
                Post,
         | 
| 29 | 
            +
                #[serde(rename(deserialize = "PUT"))]
         | 
| 30 | 
            +
                Put,
         | 
| 31 | 
            +
                #[serde(rename(deserialize = "DELETE"))]
         | 
| 32 | 
            +
                Delete,
         | 
| 33 | 
            +
                #[serde(rename(deserialize = "OPTIONS"))]
         | 
| 34 | 
            +
                Options,
         | 
| 35 | 
            +
                #[serde(rename(deserialize = "HEAD"))]
         | 
| 36 | 
            +
                Head,
         | 
| 37 | 
            +
                #[serde(rename(deserialize = "PATCH"))]
         | 
| 38 | 
            +
                Patch,
         | 
| 39 | 
            +
            }
         | 
| 40 | 
            +
             | 
| 41 | 
            +
            impl HttpMethod {
         | 
| 42 | 
            +
                pub fn matches(&self, other: &str) -> bool {
         | 
| 43 | 
            +
                    match self {
         | 
| 44 | 
            +
                        HttpMethod::Get => other.eq_ignore_ascii_case("GET"),
         | 
| 45 | 
            +
                        HttpMethod::Post => other.eq_ignore_ascii_case("POST"),
         | 
| 46 | 
            +
                        HttpMethod::Put => other.eq_ignore_ascii_case("PUT"),
         | 
| 47 | 
            +
                        HttpMethod::Delete => other.eq_ignore_ascii_case("DELETE"),
         | 
| 48 | 
            +
                        HttpMethod::Options => other.eq_ignore_ascii_case("OPTIONS"),
         | 
| 49 | 
            +
                        HttpMethod::Head => other.eq_ignore_ascii_case("HEAD"),
         | 
| 50 | 
            +
                        HttpMethod::Patch => other.eq_ignore_ascii_case("PATCH"),
         | 
| 51 | 
            +
                    }
         | 
| 52 | 
            +
                }
         | 
| 53 | 
            +
             | 
| 54 | 
            +
                pub fn to_str(&self) -> &str {
         | 
| 55 | 
            +
                    match self {
         | 
| 56 | 
            +
                        HttpMethod::Get => "GET",
         | 
| 57 | 
            +
                        HttpMethod::Post => "POST",
         | 
| 58 | 
            +
                        HttpMethod::Put => "PUT",
         | 
| 59 | 
            +
                        HttpMethod::Delete => "DELETE",
         | 
| 60 | 
            +
                        HttpMethod::Options => "OPTIONS",
         | 
| 61 | 
            +
                        HttpMethod::Head => "HEAD",
         | 
| 62 | 
            +
                        HttpMethod::Patch => "PATCH",
         | 
| 63 | 
            +
                    }
         | 
| 64 | 
            +
                }
         | 
| 65 | 
            +
            }
         | 
| 66 | 
            +
             | 
| 67 | 
            +
            impl Cors {
         | 
| 68 | 
            +
                /// Generate the simple CORS headers (used in normal responses)
         | 
| 69 | 
            +
                fn cors_headers(&self, origin: &str) -> Result<HeaderMap> {
         | 
| 70 | 
            +
                    let mut headers = HeaderMap::new();
         | 
| 71 | 
            +
             | 
| 72 | 
            +
                    headers.insert("Vary", "Origin".parse().map_err(ItsiError::default)?);
         | 
| 73 | 
            +
             | 
| 74 | 
            +
                    if origin.is_empty() {
         | 
| 75 | 
            +
                        // When credentials are allowed, you cannot return "*".
         | 
| 76 | 
            +
                        if !self.allow_credentials {
         | 
| 77 | 
            +
                            headers.insert(
         | 
| 78 | 
            +
                                "Access-Control-Allow-Origin",
         | 
| 79 | 
            +
                                "*".parse().map_err(ItsiError::default)?,
         | 
| 80 | 
            +
                            );
         | 
| 81 | 
            +
                        }
         | 
| 82 | 
            +
                        return Ok(headers);
         | 
| 83 | 
            +
                    }
         | 
| 84 | 
            +
             | 
| 85 | 
            +
                    // Only return a header if the origin is allowed.
         | 
| 86 | 
            +
                    if self.allowed_origins.iter().any(|o| o == origin || o == "*") {
         | 
| 87 | 
            +
                        // If credentials are allowed, we must echo back the exact origin.
         | 
| 88 | 
            +
                        let value = if self.allow_credentials {
         | 
| 89 | 
            +
                            origin
         | 
| 90 | 
            +
                        } else {
         | 
| 91 | 
            +
                            // If not, and if "*" is allowed, you can still use "*".
         | 
| 92 | 
            +
                            if self.allowed_origins.iter().any(|o| o == "*") {
         | 
| 93 | 
            +
                                "*"
         | 
| 94 | 
            +
                            } else {
         | 
| 95 | 
            +
                                origin
         | 
| 96 | 
            +
                            }
         | 
| 97 | 
            +
                        };
         | 
| 98 | 
            +
                        headers.insert(
         | 
| 99 | 
            +
                            "Access-Control-Allow-Origin",
         | 
| 100 | 
            +
                            value.parse().map_err(ItsiError::default)?,
         | 
| 101 | 
            +
                        );
         | 
| 102 | 
            +
                    }
         | 
| 103 | 
            +
             | 
| 104 | 
            +
                    if !self.allowed_methods.is_empty() {
         | 
| 105 | 
            +
                        headers.insert(
         | 
| 106 | 
            +
                            "Access-Control-Allow-Methods",
         | 
| 107 | 
            +
                            self.allowed_methods
         | 
| 108 | 
            +
                                .iter()
         | 
| 109 | 
            +
                                .map(HttpMethod::to_str)
         | 
| 110 | 
            +
                                .collect::<Vec<&str>>()
         | 
| 111 | 
            +
                                .join(", ")
         | 
| 112 | 
            +
                                .parse()
         | 
| 113 | 
            +
                                .map_err(ItsiError::default)?,
         | 
| 114 | 
            +
                        );
         | 
| 115 | 
            +
                    }
         | 
| 116 | 
            +
                    if !self.allowed_headers.is_empty() {
         | 
| 117 | 
            +
                        headers.insert(
         | 
| 118 | 
            +
                            "Access-Control-Allow-Headers",
         | 
| 119 | 
            +
                            self.allowed_headers
         | 
| 120 | 
            +
                                .join(", ")
         | 
| 121 | 
            +
                                .parse()
         | 
| 122 | 
            +
                                .map_err(ItsiError::default)?,
         | 
| 123 | 
            +
                        );
         | 
| 124 | 
            +
                    }
         | 
| 125 | 
            +
                    if self.allow_credentials {
         | 
| 126 | 
            +
                        headers.insert(
         | 
| 127 | 
            +
                            "Access-Control-Allow-Credentials",
         | 
| 128 | 
            +
                            "true".parse().map_err(ItsiError::default)?,
         | 
| 129 | 
            +
                        );
         | 
| 130 | 
            +
                    }
         | 
| 131 | 
            +
                    if let Some(max_age) = self.max_age {
         | 
| 132 | 
            +
                        headers.insert(
         | 
| 133 | 
            +
                            "Access-Control-Max-Age",
         | 
| 134 | 
            +
                            max_age.to_string().parse().map_err(ItsiError::default)?,
         | 
| 135 | 
            +
                        );
         | 
| 136 | 
            +
                    }
         | 
| 137 | 
            +
                    if !self.exposed_headers.is_empty() {
         | 
| 138 | 
            +
                        headers.insert(
         | 
| 139 | 
            +
                            "Access-Control-Expose-Headers",
         | 
| 140 | 
            +
                            self.exposed_headers
         | 
| 141 | 
            +
                                .join(", ")
         | 
| 142 | 
            +
                                .parse()
         | 
| 143 | 
            +
                                .map_err(ItsiError::default)?,
         | 
| 144 | 
            +
                        );
         | 
| 145 | 
            +
                    }
         | 
| 146 | 
            +
                    Ok(headers)
         | 
| 147 | 
            +
                }
         | 
| 148 | 
            +
             | 
| 149 | 
            +
                fn preflight_headers(
         | 
| 150 | 
            +
                    &self,
         | 
| 151 | 
            +
                    origin: Option<&str>,
         | 
| 152 | 
            +
                    req_method: Option<&str>,
         | 
| 153 | 
            +
                    req_headers: Option<&str>,
         | 
| 154 | 
            +
                ) -> Result<HeaderMap> {
         | 
| 155 | 
            +
                    let mut headers = HeaderMap::new();
         | 
| 156 | 
            +
             | 
| 157 | 
            +
                    headers.insert("Vary", "Origin".parse().map_err(ItsiError::default)?);
         | 
| 158 | 
            +
             | 
| 159 | 
            +
                    let origin = match origin {
         | 
| 160 | 
            +
                        Some(o) if !o.is_empty() => o,
         | 
| 161 | 
            +
                        _ => return Ok(headers), // Missing Origin – preflight fails
         | 
| 162 | 
            +
                    };
         | 
| 163 | 
            +
             | 
| 164 | 
            +
                    if !self
         | 
| 165 | 
            +
                        .allowed_origins
         | 
| 166 | 
            +
                        .iter()
         | 
| 167 | 
            +
                        .any(|allowed| allowed == "*" || allowed == origin)
         | 
| 168 | 
            +
                    {
         | 
| 169 | 
            +
                        return Ok(headers);
         | 
| 170 | 
            +
                    }
         | 
| 171 | 
            +
             | 
| 172 | 
            +
                    let request_method = match req_method {
         | 
| 173 | 
            +
                        Some(m) if !m.is_empty() => m,
         | 
| 174 | 
            +
                        _ => return Ok(headers), // Missing request method – preflight fails
         | 
| 175 | 
            +
                    };
         | 
| 176 | 
            +
             | 
| 177 | 
            +
                    if !self
         | 
| 178 | 
            +
                        .allowed_methods
         | 
| 179 | 
            +
                        .iter()
         | 
| 180 | 
            +
                        .any(|m| m.matches(request_method))
         | 
| 181 | 
            +
                    {
         | 
| 182 | 
            +
                        return Ok(headers);
         | 
| 183 | 
            +
                    }
         | 
| 184 | 
            +
             | 
| 185 | 
            +
                    if let Some(request_headers) = req_headers {
         | 
| 186 | 
            +
                        let req_headers_list: Vec<&str> = request_headers
         | 
| 187 | 
            +
                            .split(',')
         | 
| 188 | 
            +
                            .map(|s| s.trim())
         | 
| 189 | 
            +
                            .filter(|s| !s.is_empty())
         | 
| 190 | 
            +
                            .collect();
         | 
| 191 | 
            +
                        for header in req_headers_list {
         | 
| 192 | 
            +
                            if !self
         | 
| 193 | 
            +
                                .allowed_headers
         | 
| 194 | 
            +
                                .iter()
         | 
| 195 | 
            +
                                .any(|allowed| allowed.eq_ignore_ascii_case(header))
         | 
| 196 | 
            +
                            {
         | 
| 197 | 
            +
                                return Ok(headers);
         | 
| 198 | 
            +
                            }
         | 
| 199 | 
            +
                        }
         | 
| 200 | 
            +
                    }
         | 
| 201 | 
            +
             | 
| 202 | 
            +
                    headers.insert("Access-Control-Allow-Origin", origin.parse().unwrap());
         | 
| 203 | 
            +
                    headers.insert(
         | 
| 204 | 
            +
                        "Access-Control-Allow-Methods",
         | 
| 205 | 
            +
                        self.allowed_methods
         | 
| 206 | 
            +
                            .iter()
         | 
| 207 | 
            +
                            .map(HttpMethod::to_str)
         | 
| 208 | 
            +
                            .collect::<Vec<&str>>()
         | 
| 209 | 
            +
                            .join(", ")
         | 
| 210 | 
            +
                            .parse()
         | 
| 211 | 
            +
                            .map_err(ItsiError::default)?,
         | 
| 212 | 
            +
                    );
         | 
| 213 | 
            +
                    headers.insert(
         | 
| 214 | 
            +
                        "Access-Control-Allow-Headers",
         | 
| 215 | 
            +
                        self.allowed_headers
         | 
| 216 | 
            +
                            .join(", ")
         | 
| 217 | 
            +
                            .parse()
         | 
| 218 | 
            +
                            .map_err(ItsiError::default)?,
         | 
| 219 | 
            +
                    );
         | 
| 220 | 
            +
                    if self.allow_credentials {
         | 
| 221 | 
            +
                        headers.insert(
         | 
| 222 | 
            +
                            "Access-Control-Allow-Credentials",
         | 
| 223 | 
            +
                            "true".parse().map_err(ItsiError::default)?,
         | 
| 224 | 
            +
                        );
         | 
| 225 | 
            +
                    }
         | 
| 226 | 
            +
                    if let Some(max_age) = self.max_age {
         | 
| 227 | 
            +
                        headers.insert(
         | 
| 228 | 
            +
                            "Access-Control-Max-Age",
         | 
| 229 | 
            +
                            max_age.to_string().parse().map_err(ItsiError::default)?,
         | 
| 230 | 
            +
                        );
         | 
| 231 | 
            +
                    }
         | 
| 232 | 
            +
                    if !self.exposed_headers.is_empty() {
         | 
| 233 | 
            +
                        headers.insert(
         | 
| 234 | 
            +
                            "Access-Control-Expose-Headers",
         | 
| 235 | 
            +
                            self.exposed_headers
         | 
| 236 | 
            +
                                .join(", ")
         | 
| 237 | 
            +
                                .parse()
         | 
| 238 | 
            +
                                .map_err(ItsiError::default)?,
         | 
| 239 | 
            +
                        );
         | 
| 240 | 
            +
                    }
         | 
| 241 | 
            +
             | 
| 242 | 
            +
                    Ok(headers)
         | 
| 243 | 
            +
                }
         | 
| 244 | 
            +
            }
         | 
| 245 | 
            +
             | 
| 246 | 
            +
            #[async_trait]
         | 
| 247 | 
            +
            impl MiddlewareLayer for Cors {
         | 
| 248 | 
            +
                // For OPTIONS (preflight) requests we:
         | 
| 249 | 
            +
                // 1. Extract Origin, Access-Control-Request-Method, and Access-Control-Request-Headers.
         | 
| 250 | 
            +
                // 2. Validate them using our hardened preflight_headers function.
         | 
| 251 | 
            +
                // 3. If validations pass (i.e. headers is non-empty), return a 204 response with those headers.
         | 
| 252 | 
            +
                // Otherwise, the absence of headers indicates the request doesn’t meet the CORS policy.
         | 
| 253 | 
            +
                async fn before(
         | 
| 254 | 
            +
                    &self,
         | 
| 255 | 
            +
                    req: HttpRequest,
         | 
| 256 | 
            +
                    context: &mut RequestContext,
         | 
| 257 | 
            +
                ) -> Result<either::Either<HttpRequest, HttpResponse>> {
         | 
| 258 | 
            +
                    let origin = req.header("Origin");
         | 
| 259 | 
            +
                    if req.method() == Method::OPTIONS {
         | 
| 260 | 
            +
                        let ac_request_method = req.header("Access-Control-Request-Method");
         | 
| 261 | 
            +
                        let ac_request_headers = req.header("Access-Control-Request-Headers");
         | 
| 262 | 
            +
                        let headers = self.preflight_headers(origin, ac_request_method, ac_request_headers)?;
         | 
| 263 | 
            +
             | 
| 264 | 
            +
                        let mut response_builder = Response::builder().status(204);
         | 
| 265 | 
            +
                        *response_builder.headers_mut().unwrap() = headers;
         | 
| 266 | 
            +
                        let response = response_builder
         | 
| 267 | 
            +
                            .body(BoxBody::new(Empty::new()))
         | 
| 268 | 
            +
                            .map_err(ItsiError::default)?;
         | 
| 269 | 
            +
                        return Ok(either::Either::Right(response));
         | 
| 270 | 
            +
                    }
         | 
| 271 | 
            +
                    context.set_origin(origin.map(|s| s.to_string()));
         | 
| 272 | 
            +
                    Ok(either::Either::Left(req))
         | 
| 273 | 
            +
                }
         | 
| 274 | 
            +
             | 
| 275 | 
            +
                // The after hook can be used to inject CORS headers into non-preflight responses.
         | 
| 276 | 
            +
                async fn after(&self, mut resp: HttpResponse, context: &mut RequestContext) -> HttpResponse {
         | 
| 277 | 
            +
                    if let Some(Some(origin)) = context.origin.get() {
         | 
| 278 | 
            +
                        if let Ok(cors_headers) = self.cors_headers(origin) {
         | 
| 279 | 
            +
                            for (key, value) in cors_headers.iter() {
         | 
| 280 | 
            +
                                resp.headers_mut().insert(key.clone(), value.clone());
         | 
| 281 | 
            +
                            }
         | 
| 282 | 
            +
                        }
         | 
| 283 | 
            +
                    }
         | 
| 284 | 
            +
                    resp
         | 
| 285 | 
            +
                }
         | 
| 286 | 
            +
            }
         | 
| 287 | 
            +
            impl FromValue for Cors {}
         | 
| @@ -0,0 +1,48 @@ | |
| 1 | 
            +
            use crate::server::{
         | 
| 2 | 
            +
                itsi_service::RequestContext,
         | 
| 3 | 
            +
                types::{HttpRequest, HttpResponse},
         | 
| 4 | 
            +
            };
         | 
| 5 | 
            +
             | 
| 6 | 
            +
            use super::{ErrorResponse, FromValue, MiddlewareLayer};
         | 
| 7 | 
            +
            use async_trait::async_trait;
         | 
| 8 | 
            +
            use either::Either;
         | 
| 9 | 
            +
            use itsi_error::ItsiError;
         | 
| 10 | 
            +
            use magnus::error::Result;
         | 
| 11 | 
            +
            use regex::RegexSet;
         | 
| 12 | 
            +
            use serde::Deserialize;
         | 
| 13 | 
            +
            use std::sync::OnceLock;
         | 
| 14 | 
            +
             | 
| 15 | 
            +
            #[derive(Debug, Clone, Deserialize)]
         | 
| 16 | 
            +
            pub struct DenyList {
         | 
| 17 | 
            +
                #[serde(skip_deserializing)]
         | 
| 18 | 
            +
                pub denied_ips: OnceLock<RegexSet>,
         | 
| 19 | 
            +
                pub denied_patterns: Vec<String>,
         | 
| 20 | 
            +
                pub error_response: ErrorResponse,
         | 
| 21 | 
            +
            }
         | 
| 22 | 
            +
             | 
| 23 | 
            +
            #[async_trait]
         | 
| 24 | 
            +
            impl MiddlewareLayer for DenyList {
         | 
| 25 | 
            +
                async fn initialize(&self) -> Result<()> {
         | 
| 26 | 
            +
                    let denied_ips = RegexSet::new(&self.denied_patterns).map_err(ItsiError::default)?;
         | 
| 27 | 
            +
                    self.denied_ips
         | 
| 28 | 
            +
                        .set(denied_ips)
         | 
| 29 | 
            +
                        .map_err(|e| ItsiError::default(format!("Failed to set allowed IPs: {:?}", e)))?;
         | 
| 30 | 
            +
                    Ok(())
         | 
| 31 | 
            +
                }
         | 
| 32 | 
            +
             | 
| 33 | 
            +
                async fn before(
         | 
| 34 | 
            +
                    &self,
         | 
| 35 | 
            +
                    req: HttpRequest,
         | 
| 36 | 
            +
                    context: &mut RequestContext,
         | 
| 37 | 
            +
                ) -> Result<Either<HttpRequest, HttpResponse>> {
         | 
| 38 | 
            +
                    if let Some(denied_ips) = self.denied_ips.get() {
         | 
| 39 | 
            +
                        if denied_ips.is_match(&context.addr) {
         | 
| 40 | 
            +
                            return Ok(Either::Right(
         | 
| 41 | 
            +
                                self.error_response.to_http_response(&req).await,
         | 
| 42 | 
            +
                            ));
         | 
| 43 | 
            +
                        }
         | 
| 44 | 
            +
                    }
         | 
| 45 | 
            +
                    Ok(Either::Left(req))
         | 
| 46 | 
            +
                }
         | 
| 47 | 
            +
            }
         | 
| 48 | 
            +
            impl FromValue for DenyList {}
         | 
| @@ -0,0 +1,127 @@ | |
| 1 | 
            +
            use crate::server::static_file_server::ROOT_STATIC_FILE_SERVER;
         | 
| 2 | 
            +
            use crate::server::types::RequestExt;
         | 
| 3 | 
            +
            use crate::server::{
         | 
| 4 | 
            +
                itsi_service::RequestContext,
         | 
| 5 | 
            +
                types::{HttpRequest, HttpResponse},
         | 
| 6 | 
            +
            };
         | 
| 7 | 
            +
             | 
| 8 | 
            +
            use bytes::Bytes;
         | 
| 9 | 
            +
            use either::Either;
         | 
| 10 | 
            +
            use http::Response;
         | 
| 11 | 
            +
            use http_body_util::{combinators::BoxBody, Full};
         | 
| 12 | 
            +
            use itsi_error::ItsiError;
         | 
| 13 | 
            +
            use serde::Deserialize;
         | 
| 14 | 
            +
            use std::path::{Path, PathBuf};
         | 
| 15 | 
            +
             | 
| 16 | 
            +
            #[derive(Debug, Clone, Deserialize)]
         | 
| 17 | 
            +
            /// Filters can each have a customizable error response.
         | 
| 18 | 
            +
            /// They can:
         | 
| 19 | 
            +
            /// * Return Plain-text
         | 
| 20 | 
            +
            /// * Return HTML
         | 
| 21 | 
            +
            /// * Return JSON
         | 
| 22 | 
            +
            pub struct ErrorResponse {
         | 
| 23 | 
            +
                code: u16,
         | 
| 24 | 
            +
                plaintext: Option<String>,
         | 
| 25 | 
            +
                html: Option<PathBuf>,
         | 
| 26 | 
            +
                json: Option<serde_json::Value>,
         | 
| 27 | 
            +
                default: ErrorFormat,
         | 
| 28 | 
            +
            }
         | 
| 29 | 
            +
             | 
| 30 | 
            +
            #[derive(Debug, Clone, Deserialize, Default)]
         | 
| 31 | 
            +
            enum ErrorFormat {
         | 
| 32 | 
            +
                #[default]
         | 
| 33 | 
            +
                #[serde(rename(deserialize = "plaintext"))]
         | 
| 34 | 
            +
                Plaintext,
         | 
| 35 | 
            +
                #[serde(rename(deserialize = "html"))]
         | 
| 36 | 
            +
                Html,
         | 
| 37 | 
            +
                #[serde(rename(deserialize = "json"))]
         | 
| 38 | 
            +
                Json,
         | 
| 39 | 
            +
            }
         | 
| 40 | 
            +
             | 
| 41 | 
            +
            impl Default for ErrorResponse {
         | 
| 42 | 
            +
                fn default() -> Self {
         | 
| 43 | 
            +
                    ErrorResponse {
         | 
| 44 | 
            +
                        code: 500,
         | 
| 45 | 
            +
                        plaintext: Some("Error".to_owned()),
         | 
| 46 | 
            +
                        html: None,
         | 
| 47 | 
            +
                        json: None,
         | 
| 48 | 
            +
                        default: ErrorFormat::Plaintext,
         | 
| 49 | 
            +
                    }
         | 
| 50 | 
            +
                }
         | 
| 51 | 
            +
            }
         | 
| 52 | 
            +
             | 
| 53 | 
            +
            impl ErrorResponse {
         | 
| 54 | 
            +
                pub(crate) async fn to_http_response(&self, request: &HttpRequest) -> HttpResponse {
         | 
| 55 | 
            +
                    let accept = request.accept();
         | 
| 56 | 
            +
                    let body = match accept {
         | 
| 57 | 
            +
                        Some(accept) if accept.contains("text/plain") => BoxBody::new(Full::new(Bytes::from(
         | 
| 58 | 
            +
                            self.plaintext.clone().unwrap_or_else(|| "Error".to_owned()),
         | 
| 59 | 
            +
                        ))),
         | 
| 60 | 
            +
                        Some(accept) if accept.contains("text/html") => {
         | 
| 61 | 
            +
                            if let Some(path) = &self.html {
         | 
| 62 | 
            +
                                let path = path.to_str().unwrap();
         | 
| 63 | 
            +
                                let response = ROOT_STATIC_FILE_SERVER.serve_single(path).await;
         | 
| 64 | 
            +
             | 
| 65 | 
            +
                                if response.status().is_success() {
         | 
| 66 | 
            +
                                    response.into_body()
         | 
| 67 | 
            +
                                } else {
         | 
| 68 | 
            +
                                    BoxBody::new(Full::new(Bytes::from("Error")))
         | 
| 69 | 
            +
                                }
         | 
| 70 | 
            +
                            } else {
         | 
| 71 | 
            +
                                BoxBody::new(Full::new(Bytes::from("Error")))
         | 
| 72 | 
            +
                            }
         | 
| 73 | 
            +
                        }
         | 
| 74 | 
            +
                        Some(accept) if accept.contains("application/json") => {
         | 
| 75 | 
            +
                            BoxBody::new(Full::new(Bytes::from(
         | 
| 76 | 
            +
                                self.json
         | 
| 77 | 
            +
                                    .as_ref()
         | 
| 78 | 
            +
                                    .map(|json| json.to_string())
         | 
| 79 | 
            +
                                    .unwrap_or_else(|| "Error".to_owned()),
         | 
| 80 | 
            +
                            )))
         | 
| 81 | 
            +
                        }
         | 
| 82 | 
            +
                        _ => match self.default {
         | 
| 83 | 
            +
                            ErrorFormat::Plaintext => BoxBody::new(Full::new(Bytes::from(
         | 
| 84 | 
            +
                                self.plaintext.clone().unwrap_or_else(|| "Error".to_owned()),
         | 
| 85 | 
            +
                            ))),
         | 
| 86 | 
            +
                            ErrorFormat::Html => {
         | 
| 87 | 
            +
                                if let Some(path) = &self.html {
         | 
| 88 | 
            +
                                    let path = path.to_str().unwrap();
         | 
| 89 | 
            +
                                    let response = ROOT_STATIC_FILE_SERVER.serve_single(path).await;
         | 
| 90 | 
            +
             | 
| 91 | 
            +
                                    if response.status().is_success() {
         | 
| 92 | 
            +
                                        response.into_body()
         | 
| 93 | 
            +
                                    } else {
         | 
| 94 | 
            +
                                        BoxBody::new(Full::new(Bytes::from("Error")))
         | 
| 95 | 
            +
                                    }
         | 
| 96 | 
            +
                                } else {
         | 
| 97 | 
            +
                                    BoxBody::new(Full::new(Bytes::from("Error")))
         | 
| 98 | 
            +
                                }
         | 
| 99 | 
            +
                            }
         | 
| 100 | 
            +
                            ErrorFormat::Json => BoxBody::new(Full::new(Bytes::from(
         | 
| 101 | 
            +
                                self.json
         | 
| 102 | 
            +
                                    .as_ref()
         | 
| 103 | 
            +
                                    .map(|json| json.to_string())
         | 
| 104 | 
            +
                                    .unwrap_or_else(|| "Error".to_owned()),
         | 
| 105 | 
            +
                            ))),
         | 
| 106 | 
            +
                        },
         | 
| 107 | 
            +
                    };
         | 
| 108 | 
            +
             | 
| 109 | 
            +
                    Response::builder().status(self.code).body(body).unwrap()
         | 
| 110 | 
            +
                }
         | 
| 111 | 
            +
             | 
| 112 | 
            +
                pub async fn before(
         | 
| 113 | 
            +
                    &self,
         | 
| 114 | 
            +
                    req: HttpRequest,
         | 
| 115 | 
            +
                    _context: &mut RequestContext,
         | 
| 116 | 
            +
                ) -> Result<Either<HttpRequest, HttpResponse>, ItsiError> {
         | 
| 117 | 
            +
                    if let Some(path) = req.uri().path().strip_prefix("/error/") {
         | 
| 118 | 
            +
                        let path = Path::new(path);
         | 
| 119 | 
            +
                        if path.exists() {
         | 
| 120 | 
            +
                            let path = path.to_str().unwrap();
         | 
| 121 | 
            +
                            let response = ROOT_STATIC_FILE_SERVER.serve_single(path).await;
         | 
| 122 | 
            +
                            return Ok(Either::Right(response));
         | 
| 123 | 
            +
                        }
         | 
| 124 | 
            +
                    }
         | 
| 125 | 
            +
                    Ok(Either::Left(req))
         | 
| 126 | 
            +
                }
         | 
| 127 | 
            +
            }
         | 
| @@ -0,0 +1,191 @@ | |
| 1 | 
            +
            use super::{FromValue, MiddlewareLayer};
         | 
| 2 | 
            +
            use crate::server::{itsi_service::RequestContext, types::HttpResponse};
         | 
| 3 | 
            +
            use async_trait::async_trait;
         | 
| 4 | 
            +
            use base64::{engine::general_purpose, Engine as _};
         | 
| 5 | 
            +
            use bytes::{Bytes, BytesMut};
         | 
| 6 | 
            +
            use either::Either;
         | 
| 7 | 
            +
            use futures::TryStreamExt;
         | 
| 8 | 
            +
            use http::{header, HeaderValue, Response, StatusCode};
         | 
| 9 | 
            +
            use http_body_util::{combinators::BoxBody, BodyExt, Empty, Full};
         | 
| 10 | 
            +
            use hyper::body::Body;
         | 
| 11 | 
            +
            use magnus::error::Result;
         | 
| 12 | 
            +
            use serde::Deserialize;
         | 
| 13 | 
            +
            use sha2::{Digest, Sha256};
         | 
| 14 | 
            +
             | 
| 15 | 
            +
            #[derive(Debug, Clone, Copy, Deserialize, Default)]
         | 
| 16 | 
            +
            pub enum ETagType {
         | 
| 17 | 
            +
                #[serde(rename = "strong")]
         | 
| 18 | 
            +
                #[default]
         | 
| 19 | 
            +
                Strong,
         | 
| 20 | 
            +
                #[serde(rename = "weak")]
         | 
| 21 | 
            +
                Weak,
         | 
| 22 | 
            +
            }
         | 
| 23 | 
            +
             | 
| 24 | 
            +
            #[derive(Debug, Clone, Copy, Deserialize, Default)]
         | 
| 25 | 
            +
            pub enum HashAlgorithm {
         | 
| 26 | 
            +
                #[serde(rename = "sha256")]
         | 
| 27 | 
            +
                #[default]
         | 
| 28 | 
            +
                Sha256,
         | 
| 29 | 
            +
                #[serde(rename = "md5")]
         | 
| 30 | 
            +
                Md5,
         | 
| 31 | 
            +
            }
         | 
| 32 | 
            +
             | 
| 33 | 
            +
            #[derive(Debug, Clone, Deserialize)]
         | 
| 34 | 
            +
            pub struct ETag {
         | 
| 35 | 
            +
                #[serde(default)]
         | 
| 36 | 
            +
                pub r#type: ETagType,
         | 
| 37 | 
            +
                #[serde(default)]
         | 
| 38 | 
            +
                pub algorithm: HashAlgorithm,
         | 
| 39 | 
            +
                #[serde(default)]
         | 
| 40 | 
            +
                pub min_body_size: usize,
         | 
| 41 | 
            +
                #[serde(default = "default_true")]
         | 
| 42 | 
            +
                pub handle_if_none_match: bool,
         | 
| 43 | 
            +
            }
         | 
| 44 | 
            +
             | 
| 45 | 
            +
            fn default_true() -> bool {
         | 
| 46 | 
            +
                true
         | 
| 47 | 
            +
            }
         | 
| 48 | 
            +
             | 
| 49 | 
            +
            #[async_trait]
         | 
| 50 | 
            +
            impl MiddlewareLayer for ETag {
         | 
| 51 | 
            +
                async fn before(
         | 
| 52 | 
            +
                    &self,
         | 
| 53 | 
            +
                    req: crate::server::types::HttpRequest,
         | 
| 54 | 
            +
                    context: &mut RequestContext,
         | 
| 55 | 
            +
                ) -> Result<Either<crate::server::types::HttpRequest, HttpResponse>> {
         | 
| 56 | 
            +
                    // Store if-none-match header in context if present for later use in after hook
         | 
| 57 | 
            +
                    if self.handle_if_none_match {
         | 
| 58 | 
            +
                        if let Some(if_none_match) = req.headers().get(header::IF_NONE_MATCH) {
         | 
| 59 | 
            +
                            if let Ok(etag_value) = if_none_match.to_str() {
         | 
| 60 | 
            +
                                context.set_if_none_match(Some(etag_value.to_string()));
         | 
| 61 | 
            +
                            }
         | 
| 62 | 
            +
                        }
         | 
| 63 | 
            +
                    }
         | 
| 64 | 
            +
                    Ok(Either::Left(req))
         | 
| 65 | 
            +
                }
         | 
| 66 | 
            +
             | 
| 67 | 
            +
                async fn after(&self, resp: HttpResponse, context: &mut RequestContext) -> HttpResponse {
         | 
| 68 | 
            +
                    // Skip for error responses or responses that shouldn't have ETags
         | 
| 69 | 
            +
                    match resp.status() {
         | 
| 70 | 
            +
                        StatusCode::OK
         | 
| 71 | 
            +
                        | StatusCode::CREATED
         | 
| 72 | 
            +
                        | StatusCode::ACCEPTED
         | 
| 73 | 
            +
                        | StatusCode::NON_AUTHORITATIVE_INFORMATION
         | 
| 74 | 
            +
                        | StatusCode::NO_CONTENT
         | 
| 75 | 
            +
                        | StatusCode::PARTIAL_CONTENT => {}
         | 
| 76 | 
            +
                        _ => return resp,
         | 
| 77 | 
            +
                    }
         | 
| 78 | 
            +
             | 
| 79 | 
            +
                    // Skip if already has an ETag
         | 
| 80 | 
            +
                    if resp.headers().contains_key(header::ETAG) {
         | 
| 81 | 
            +
                        return resp;
         | 
| 82 | 
            +
                    }
         | 
| 83 | 
            +
             | 
| 84 | 
            +
                    // Skip if Cache-Control: no-store is present
         | 
| 85 | 
            +
                    if let Some(cache_control) = resp.headers().get(header::CACHE_CONTROL) {
         | 
| 86 | 
            +
                        if let Ok(cache_control_str) = cache_control.to_str() {
         | 
| 87 | 
            +
                            if cache_control_str.contains("no-store") {
         | 
| 88 | 
            +
                                return resp;
         | 
| 89 | 
            +
                            }
         | 
| 90 | 
            +
                        }
         | 
| 91 | 
            +
                    }
         | 
| 92 | 
            +
             | 
| 93 | 
            +
                    // Check if body is a stream or fixed size using size_hint (similar to compression.rs)
         | 
| 94 | 
            +
                    let body_size = resp.size_hint().exact();
         | 
| 95 | 
            +
             | 
| 96 | 
            +
                    // Skip streaming bodies
         | 
| 97 | 
            +
                    if body_size.is_none() {
         | 
| 98 | 
            +
                        return resp;
         | 
| 99 | 
            +
                    }
         | 
| 100 | 
            +
             | 
| 101 | 
            +
                    // Skip if body is too small
         | 
| 102 | 
            +
                    if body_size.unwrap_or(0) < self.min_body_size as u64 {
         | 
| 103 | 
            +
                        return resp;
         | 
| 104 | 
            +
                    }
         | 
| 105 | 
            +
             | 
| 106 | 
            +
                    let (mut parts, mut body) = resp.into_parts();
         | 
| 107 | 
            +
                    let etag_value = if let Some(existing_etag) = parts.headers.get(header::ETAG) {
         | 
| 108 | 
            +
                        existing_etag.to_str().unwrap_or("").to_string()
         | 
| 109 | 
            +
                    } else {
         | 
| 110 | 
            +
                        // Get the full bytes from the body
         | 
| 111 | 
            +
                        let full_bytes: Bytes = match body
         | 
| 112 | 
            +
                            .into_data_stream()
         | 
| 113 | 
            +
                            .try_fold(BytesMut::new(), |mut acc, chunk| async move {
         | 
| 114 | 
            +
                                acc.extend_from_slice(&chunk);
         | 
| 115 | 
            +
                                Ok(acc)
         | 
| 116 | 
            +
                            })
         | 
| 117 | 
            +
                            .await
         | 
| 118 | 
            +
                        {
         | 
| 119 | 
            +
                            Ok(bytes_mut) => bytes_mut.freeze(),
         | 
| 120 | 
            +
                            Err(_) => return Response::from_parts(parts, BoxBody::new(Empty::new())),
         | 
| 121 | 
            +
                        };
         | 
| 122 | 
            +
             | 
| 123 | 
            +
                        let computed_etag = match self.algorithm {
         | 
| 124 | 
            +
                            HashAlgorithm::Sha256 => {
         | 
| 125 | 
            +
                                let mut hasher = Sha256::new();
         | 
| 126 | 
            +
                                hasher.update(&full_bytes);
         | 
| 127 | 
            +
                                let result = hasher.finalize();
         | 
| 128 | 
            +
                                general_purpose::STANDARD.encode(result)
         | 
| 129 | 
            +
                            }
         | 
| 130 | 
            +
                            HashAlgorithm::Md5 => {
         | 
| 131 | 
            +
                                let digest = md5::compute(&full_bytes);
         | 
| 132 | 
            +
                                format!("{:x}", digest)
         | 
| 133 | 
            +
                            }
         | 
| 134 | 
            +
                        };
         | 
| 135 | 
            +
             | 
| 136 | 
            +
                        let formatted_etag = match self.r#type {
         | 
| 137 | 
            +
                            ETagType::Strong => format!("\"{}\"", computed_etag),
         | 
| 138 | 
            +
                            ETagType::Weak => format!("W/\"{}\"", computed_etag),
         | 
| 139 | 
            +
                        };
         | 
| 140 | 
            +
             | 
| 141 | 
            +
                        if let Ok(value) = HeaderValue::from_str(&formatted_etag) {
         | 
| 142 | 
            +
                            parts.headers.insert(header::ETAG, value);
         | 
| 143 | 
            +
                        }
         | 
| 144 | 
            +
             | 
| 145 | 
            +
                        body = Full::new(full_bytes).boxed();
         | 
| 146 | 
            +
                        formatted_etag
         | 
| 147 | 
            +
                    };
         | 
| 148 | 
            +
             | 
| 149 | 
            +
                    // Handle 304 Not Modified if we have an If-None-Match header and it matches
         | 
| 150 | 
            +
                    if self.handle_if_none_match {
         | 
| 151 | 
            +
                        if let Some(if_none_match) = context.get_if_none_match() {
         | 
| 152 | 
            +
                            if if_none_match == etag_value || if_none_match == "*" {
         | 
| 153 | 
            +
                                // Return 304 Not Modified without the body
         | 
| 154 | 
            +
                                let mut not_modified = Response::new(BoxBody::new(Empty::new()));
         | 
| 155 | 
            +
                                *not_modified.status_mut() = StatusCode::NOT_MODIFIED;
         | 
| 156 | 
            +
                                // Copy headers we want to preserve
         | 
| 157 | 
            +
                                for (name, value) in parts.headers.iter() {
         | 
| 158 | 
            +
                                    if matches!(
         | 
| 159 | 
            +
                                        name,
         | 
| 160 | 
            +
                                        &header::CACHE_CONTROL
         | 
| 161 | 
            +
                                            | &header::CONTENT_LOCATION
         | 
| 162 | 
            +
                                            | &header::DATE
         | 
| 163 | 
            +
                                            | &header::ETAG
         | 
| 164 | 
            +
                                            | &header::EXPIRES
         | 
| 165 | 
            +
                                            | &header::VARY
         | 
| 166 | 
            +
                                    ) {
         | 
| 167 | 
            +
                                        not_modified.headers_mut().insert(name, value.clone());
         | 
| 168 | 
            +
                                    }
         | 
| 169 | 
            +
                                }
         | 
| 170 | 
            +
                                return not_modified;
         | 
| 171 | 
            +
                            }
         | 
| 172 | 
            +
                        }
         | 
| 173 | 
            +
                    }
         | 
| 174 | 
            +
             | 
| 175 | 
            +
                    // Recreate response with the original body and the ETag header
         | 
| 176 | 
            +
                    Response::from_parts(parts, body)
         | 
| 177 | 
            +
                }
         | 
| 178 | 
            +
            }
         | 
| 179 | 
            +
             | 
| 180 | 
            +
            impl Default for ETag {
         | 
| 181 | 
            +
                fn default() -> Self {
         | 
| 182 | 
            +
                    Self {
         | 
| 183 | 
            +
                        r#type: ETagType::Strong,
         | 
| 184 | 
            +
                        algorithm: HashAlgorithm::Sha256,
         | 
| 185 | 
            +
                        min_body_size: 0,
         | 
| 186 | 
            +
                        handle_if_none_match: true,
         | 
| 187 | 
            +
                    }
         | 
| 188 | 
            +
                }
         | 
| 189 | 
            +
            }
         | 
| 190 | 
            +
             | 
| 191 | 
            +
            impl FromValue for ETag {}
         |