ordinary-app 0.10.0

Application server for Ordinary
// Copyright (C) 2026 Ordinary Labs, LLC.
//
// SPDX-License-Identifier: AGPL-3.0-only

use crate::server::{GMT_FORMAT, OrdinaryAppServerState};
use async_recursion::async_recursion;
use axum::Extension;
use axum::extract::{Path, State};
use axum::http::header;
use axum::http::header::CONTENT_TYPE;
use axum::http::{StatusCode, Uri};
use axum::response::{IntoResponse, Response};
use hyper::HeaderMap;
use hyper::http::HeaderValue;
use ordinary_config::CompressionAlgorithm;
use ordinary_template::TemplateResult;
use ordinary_utils::get_host_fwd;
use ordinary_utils::middleware::check_if_none_match;
use ordinary_utils::tcp::Sni;
use std::sync::Arc;
use time::{Duration, UtcDateTime};
use tracing::Instrument;

pub async fn get(
    State(state): State<Arc<OrdinaryAppServerState>>,
    path: Option<Path<String>>,
    uri: Uri,
    headers: HeaderMap,
    sni: Option<Extension<Sni>>,
) -> impl IntoResponse {
    get_asset_to_res(
        state,
        path.map(|p| p.0),
        &uri,
        &headers,
        sni.map(|e| e.0).as_ref(),
        false,
    )
    .await
    .into_response()
}

#[allow(clippy::needless_pass_by_value, clippy::too_many_lines)]
#[async_recursion]
pub async fn get_asset_to_res(
    state: Arc<OrdinaryAppServerState>,
    path: Option<String>,
    uri: &Uri,
    headers: &HeaderMap,
    sni: Option<&Sni>,
    bail: bool,
) -> impl IntoResponse {
    let span = tracing::info_span!("storage");
    let span = span.in_scope(|| tracing::info_span!("assets"));

    async {
        let Some(assets_config) = &state.config.assets else {
            tracing::warn!("no assets config");
            return StatusCode::NOT_FOUND.into_response();
        };

        let append_index_html = assets_config.append_index_html.unwrap_or(false);
        let append_html_ext = assets_config.append_html_ext.unwrap_or(false);

        let mut path = path.unwrap_or_default();

        let mut ext = path.rsplit_once('.').map(|(_, ext)| ext);

        if append_index_html {
            if path.is_empty() || path.ends_with('/') {
                path.push_str("index.html");
                ext = Some("html");
            } else if ext.is_none() {
                path.push_str("/index.html");
                ext = Some("html");
            }
        }

        let ext = if let Some(ext) = ext {
            ext
        } else if append_html_ext {
            path.push_str(".html");

            "html"
        } else {
            ""
        };

        let no_compress = matches!(
            ext,
            "otf"
                | "ttf"
                | "woff"
                | "woff2"
                | "png"
                | "apng"
                | "gif"
                | "jpg"
                | "jpeg"
                | "bmp"
                | "tif"
                | "tiff"
                | "webp"
                | "avif"
                | "ico"
                | "pdf"
        );

        let mut skip_check = false;

        if !no_compress
            && let Some(precompression) = &assets_config.internal_precompression
            && let Some(compressions) = headers.get(header::ACCEPT_ENCODING)
            && let Ok(compressions_str) = compressions.to_str()
        {
            for alg in precompression {
                if compressions_str.contains(alg.as_str()) {
                    if let Some(value) =
                        get_asset_with_compression(&state, &path, headers, Some(alg), ext == "html")
                    {
                        return value;
                    }

                    skip_check = true;
                    break;
                }
            }
        }

        if !skip_check
            && let Some(value) =
                get_asset_with_compression(&state, &path, headers, None, ext == "html")
        {
            return value;
        }

        if let Some(idx) = state.error_template_idx
            && let Some(err_template) = &state.templates.get(idx as usize)
        {
            let Some(host) = get_host_fwd(headers, uri, sni) else {
                tracing::error!("no host");
                return StatusCode::BAD_REQUEST.into_response();
            };

            match err_template
                .render(
                    host.as_str(),
                    "/404",
                    None,
                    None,
                    None,
                    Some(("Not found".into(), 404)),
                    None,
                    None,
                )
                .await
            {
                Ok(res) => {
                    if let TemplateResult::Result(bytes) = res {
                        return (
                            StatusCode::NOT_FOUND,
                            [(CONTENT_TYPE, err_template.mime.clone())],
                            bytes,
                        )
                            .into_response();
                    }
                }
                Err(err) => tracing::warn!("{err}"),
            }
        } else if !bail
            && let Some(error_config) = &state.config.error
            && let Some(asset_name) = &error_config.asset
        {
            return get_asset_to_res(
                state.clone(),
                Some(asset_name.clone()),
                uri,
                headers,
                sni,
                true,
            )
            .await
            .into_response();
        }

        StatusCode::NOT_FOUND.into_response()
    }
    .instrument(span)
    .await
}

fn get_asset_with_compression(
    state: &Arc<OrdinaryAppServerState>,
    path: &str,
    headers: &HeaderMap,
    compression: Option<&CompressionAlgorithm>,
    html_csp: bool,
) -> Option<Response> {
    if let Ok(asset) = state.storage.asset.get(path, compression)
        && let Ok(reader) = flexbuffers::Reader::get_root(asset.as_ref())
    {
        let vec = reader.as_vector();

        let etag = vec.idx(2).as_str();
        let last_modified = vec.idx(3).as_str();

        let mut header_map = HeaderMap::with_capacity(11);

        if html_csp {
            header_map.insert(
                header::CONTENT_SECURITY_POLICY,
                state.html_asset_csp.clone(),
            );
            header_map.insert(
                state.html_asset_reporting_endpoints.0.clone(),
                state.html_asset_reporting_endpoints.1.clone(),
            );
        }

        header_map.insert(
            header::VARY,
            HeaderValue::from_static(header::ACCEPT_ENCODING.as_str()),
        );

        if let Ok(etag) = HeaderValue::from_str(etag) {
            header_map.insert(header::ETAG, etag);
        }
        if let Ok(last_modified) = HeaderValue::from_str(last_modified) {
            header_map.insert(header::LAST_MODIFIED, last_modified);
        }
        if let Some(assets) = &state.config.assets {
            if let Some(http_cache_control) = &assets.internal_cache_control_header_value
                && let Ok(cache_control) = HeaderValue::from_str(http_cache_control.as_str())
            {
                header_map.insert(header::CACHE_CONTROL, cache_control);
            }

            if let Some(http_cache) = &assets.http
                && let Some(expires_s) = http_cache.expires
            {
                let future = UtcDateTime::now() + Duration::seconds(expires_s.cast_signed());

                if let Ok(formatted) = future.format(&GMT_FORMAT)
                    && let Ok(expires) = HeaderValue::from_str(formatted.as_str())
                {
                    header_map.insert(header::EXPIRES, expires);
                }
            }
        }

        if let Some(etag) = check_if_none_match(headers, etag)
            && let Ok(etag_header) = HeaderValue::from_str(etag)
        {
            header_map.insert(header::ETAG, etag_header);

            return Some((StatusCode::NOT_MODIFIED, header_map).into_response());
        } else if let Some(if_modified_since) = headers.get(header::IF_MODIFIED_SINCE)
            && let Ok(if_modified_since_str) = if_modified_since.to_str()
            && let Ok(if_modified_since) = UtcDateTime::parse(if_modified_since_str, &GMT_FORMAT)
            && let Ok(last_modified) = UtcDateTime::parse(last_modified, &GMT_FORMAT)
            && if_modified_since >= last_modified
        {
            return Some((StatusCode::NOT_MODIFIED, header_map).into_response());
        }

        let mime = vec.idx(0).as_str();

        if let Ok(mime) = HeaderValue::from_str(mime) {
            header_map.insert(CONTENT_TYPE, mime);
        }

        if let Some(compression) = compression {
            header_map.insert(
                header::CONTENT_ENCODING,
                HeaderValue::from_static(compression.as_str()),
            );
        }

        return Some(
            (
                StatusCode::OK,
                header_map,
                bytes::Bytes::copy_from_slice(vec.idx(1).as_blob().0),
            )
                .into_response(),
        );
    }

    None
}