rama 0.3.0-rc1

modular service framework
//! [`Service`] that serves a file or directory using [`ServeFile`] or [`ServeDir`], or a placeholder page.

use crate::{
    Layer, Service,
    cli::ForwardKind,
    combinators::{Either, Either3},
    error::{BoxError, BoxErrorExt, ErrorExt},
    http::BodyLimitLayer,
    http::{
        Request, Response, Version,
        headers::exotic::XClacksOverhead,
        layer::set_header::SetResponseHeaderLayer,
        layer::{
            into_response::IntoResponseService, required_header::AddRequiredResponseHeadersLayer,
            trace::TraceLayer,
        },
        server::HttpServer,
        service::{
            fs::{DirectoryServeMode, ServeDir, ServeDirSymlinkPolicy, ServeFile},
            web::response::{Html, IntoResponse},
        },
    },
    layer::limit::policy::UnlimitedPolicy,
    layer::{ConsumeErrLayer, LimitLayer, TimeoutLayer, limit::policy::ConcurrentPolicy},
    proxy::haproxy::server::HaProxyLayer,
    rt::Executor,
    service::StaticOutput,
    tcp::TcpStream,
    telemetry::tracing,
    ua::layer::classifier::UserAgentClassifierLayer,
    utils::octets::mib,
};

use std::{convert::Infallible, path::PathBuf, sync::Arc, time::Duration};

core::cfg_select! {
    feature = "boring" => {
        use crate::tls::boring::server::TlsAcceptorLayer;
    }
    feature = "rustls" => {
        use crate::tls::rustls::server::TlsAcceptorLayer;
    }
    _ => {}
}

#[cfg(any(feature = "boring", feature = "rustls"))]
use crate::tls::server::TlsServerConfig;

#[derive(Debug, Clone)]
/// Builder that can be used to run your own serve [`Service`],
/// serving a file or directory, or a placeholder page.
pub struct FsServiceBuilder<H> {
    concurrent_limit: usize,
    body_limit: usize,
    timeout: Duration,
    forward: Option<ForwardKind>,

    #[cfg(any(feature = "rustls", feature = "boring"))]
    tls_server_config: Option<TlsServerConfig>,

    http_version: Option<Version>,

    http_service_builder: H,

    content_path: Option<PathBuf>,
    dir_serve_mode: DirectoryServeMode,
    html_as_default_extension: bool,
    symlink_policy: ServeDirSymlinkPolicy,
}

impl Default for FsServiceBuilder<()> {
    fn default() -> Self {
        Self {
            concurrent_limit: 0,
            body_limit: mib(1),
            timeout: Duration::ZERO,
            forward: None,

            #[cfg(any(feature = "rustls", feature = "boring"))]
            tls_server_config: None,

            http_version: None,

            http_service_builder: (),

            content_path: None,
            dir_serve_mode: DirectoryServeMode::HtmlFileList,
            html_as_default_extension: false,
            symlink_policy: ServeDirSymlinkPolicy::default(),
        }
    }
}

impl FsServiceBuilder<()> {
    /// Create a new [`FsServiceBuilder`].
    #[must_use]
    pub fn new() -> Self {
        Self::default()
    }
}

impl<H> FsServiceBuilder<H> {
    rama_utils::macros::generate_set_and_with! {
        /// set the number of concurrent connections to allow
        ///
        /// (0 = no limit)
        pub fn concurrent(mut self, limit: usize) -> Self {
            self.concurrent_limit = limit;
            self
        }
    }

    rama_utils::macros::generate_set_and_with! {
        /// set the body limit in bytes for each request
        pub fn body_limit(mut self, limit: usize) -> Self {
            self.body_limit = limit;
            self
        }
    }

    rama_utils::macros::generate_set_and_with! {
        /// set the timeout in seconds for each connection
        ///
        /// (0 = no timeout)
        pub fn timeout(mut self, timeout: Duration) -> Self {
            self.timeout = timeout;
            self
        }
    }

    rama_utils::macros::generate_set_and_with! {
        /// enable support for one of the following "forward" headers or protocols
        ///
        /// Supported headers:
        ///
        /// Forwarded ("for="), X-Forwarded-For
        ///
        /// X-Client-IP Client-IP, X-Real-IP
        ///
        /// CF-Connecting-IP, True-Client-IP
        ///
        /// Or using HaProxy protocol.
        pub fn forward(mut self, kind: Option<ForwardKind>) -> Self {
            self.forward = kind;
            self
        }
    }

    #[cfg(any(feature = "rustls", feature = "boring"))]
    rama_utils::macros::generate_set_and_with! {
        /// define a tls server cert config to be used for tls terminaton
        /// by the serve service.
        pub fn tls_server_config(mut self, cfg: Option<TlsServerConfig>) -> Self {
            self.tls_server_config = cfg;
            self
        }
    }

    rama_utils::macros::generate_set_and_with! {
        /// set the http version to use for the http server (auto by default)
        pub fn http_version(mut self, version: Option<Version>) -> Self {
            self.http_version = version;
            self
        }
    }

    /// add a custom http layer which will be applied to the existing http layers
    #[must_use]
    pub fn with_http_layer<H2>(self, layer: H2) -> FsServiceBuilder<(H, H2)> {
        FsServiceBuilder {
            concurrent_limit: self.concurrent_limit,
            body_limit: self.body_limit,
            timeout: self.timeout,
            forward: self.forward,

            #[cfg(any(feature = "rustls", feature = "boring"))]
            tls_server_config: self.tls_server_config,

            http_version: self.http_version,

            http_service_builder: (self.http_service_builder, layer),

            content_path: self.content_path,
            dir_serve_mode: self.dir_serve_mode,
            html_as_default_extension: self.html_as_default_extension,
            symlink_policy: self.symlink_policy,
        }
    }

    rama_utils::macros::generate_set_and_with! {
        /// Set the content path to serve (by default it will serve the rama homepage).
        pub fn content_path(mut self, path: impl Into<PathBuf>) -> Self {
            self.content_path = Some(path.into());
            self
        }
    }

    /// Maybe set the content path to serve (by default it will serve the rama homepage).
    #[must_use]
    pub fn maybe_with_content_path<P: Into<PathBuf>>(mut self, path: Option<P>) -> Self {
        self.content_path = path.map(Into::into);
        self
    }

    /// Maybe set the content path to serve (by default it will serve the rama homepage).
    pub fn maybe_set_content_path<P: Into<PathBuf>>(&mut self, path: Option<P>) -> &mut Self {
        self.content_path = path.map(Into::into);
        self
    }

    rama_utils::macros::generate_set_and_with! {
        /// Set the [`DirectoryServeMode`] which defines how to serve directories.
        ///
        /// By default it will use [`DirectoryServeMode::HtmlFileList`].
        ///
        /// Note that this is only used in case the content path is defined
        /// (e.g. using [`Self::content_path`])
        /// and that path points to a valid directory.
        pub fn directory_serve_mode(mut self, mode: DirectoryServeMode) -> Self {
            self.dir_serve_mode = mode;
            self
        }
    }

    rama_utils::macros::generate_set_and_with! {
        /// If true, requests for a path without a file extension that
        /// doesn't resolve to anything will be retried with `.html` appended
        /// (e.g. `/about` will serve `/about.html`).
        ///
        /// Only takes effect when the content path points to a directory.
        ///
        /// Defaults to `false`.
        pub fn html_as_default_extension(mut self, html_as_default_extension: bool) -> Self {
            self.html_as_default_extension = html_as_default_extension;
            self
        }
    }

    rama_utils::macros::generate_set_and_with! {
        /// Set the [`ServeDirSymlinkPolicy`] used when serving a file or directory.
        ///
        /// Defaults to [`ServeDirSymlinkPolicy::RejectAll`].
        pub fn symlink_policy(mut self, policy: ServeDirSymlinkPolicy) -> Self {
            self.symlink_policy = policy;
            self
        }
    }
}

impl<H> FsServiceBuilder<H>
where
    H: Layer<ServeService, Service: Service<Request, Output = Response, Error: Into<BoxError>>>,
{
    /// build a tcp service ready to serve files
    pub fn build(
        self,
        executor: Executor,
    ) -> Result<impl Service<TcpStream, Output = (), Error = Infallible>, BoxError> {
        let tcp_forwarded_layer = match &self.forward {
            Some(ForwardKind::HaProxy) => Some(HaProxyLayer::default()),
            _ => None,
        };

        let http_service = Arc::new(self.build_http()?);

        let tcp_service_builder = (
            ConsumeErrLayer::trace_as(tracing::Level::DEBUG),
            LimitLayer::new(if self.concurrent_limit > 0 {
                Either::A(ConcurrentPolicy::max(self.concurrent_limit))
            } else {
                Either::B(UnlimitedPolicy::new())
            }),
            if !self.timeout.is_zero() {
                TimeoutLayer::new(self.timeout)
            } else {
                TimeoutLayer::never()
            },
            tcp_forwarded_layer,
            BodyLimitLayer::request_only(self.body_limit),
            #[cfg(any(feature = "rustls", feature = "boring"))]
            self.tls_server_config
                .map(|cfg| TlsAcceptorLayer::new(cfg).with_store_client_hello(true)),
        );

        let http_transport_service = match self.http_version {
            Some(Version::HTTP_2) => Either3::A(HttpServer::new_h2(executor).service(http_service)),
            Some(Version::HTTP_11 | Version::HTTP_10 | Version::HTTP_09) => {
                Either3::B(HttpServer::new_http1(executor).service(http_service))
            }
            Some(version) => {
                return Err(BoxError::from_static_str("unsupported http version")
                    .context_debug_field("version", version));
            }
            None => Either3::C(HttpServer::auto(executor).service(http_service)),
        };

        Ok(tcp_service_builder.into_layer(http_transport_service))
    }

    /// build an http service ready to serve files
    pub fn build_http(
        &self,
    ) -> Result<impl Service<Request, Output: IntoResponse, Error = Infallible> + use<H>, BoxError>
    {
        let http_forwarded_layer = super::http_forwarded_layer(self.forward.as_ref());

        let serve_service = match &self.content_path {
            None => Either3::A(IntoResponseService::new(StaticOutput::new(Html(
                include_str!("../../../docs/index.html"),
            )))),
            Some(path) if path.is_file() => {
                Either3::B(ServeFile::new(path.clone()).with_symlink_policy(self.symlink_policy))
            }
            Some(path) if path.is_dir() => Either3::C(
                ServeDir::new(path)
                    .with_directory_serve_mode(self.dir_serve_mode)
                    .with_html_as_default_extension(self.html_as_default_extension)
                    .with_symlink_policy(self.symlink_policy),
            ),
            Some(path) => {
                return Err(
                    BoxError::from_static_str("invalid path: no such file or directory")
                        .with_context_debug_field("path", || path.clone()),
                );
            }
        };

        let http_service = (
            TraceLayer::new_for_http(),
            SetResponseHeaderLayer::<XClacksOverhead>::if_not_present_default_typed(),
            AddRequiredResponseHeadersLayer::default(),
            UserAgentClassifierLayer::new(),
            ConsumeErrLayer::default(),
            http_forwarded_layer,
        )
            .into_layer(self.http_service_builder.layer(serve_service));

        Ok(http_service)
    }
}

type ServeStaticHtml = IntoResponseService<StaticOutput<Html<&'static str>>>;
type ServeService = Either3<ServeStaticHtml, ServeFile, ServeDir>;