ordinary-api 0.10.2

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

use crate::client::{OrdinaryApiClient, compress_zstd, traverse};
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD as b64};
use ordinary_config::{AssetsConfig, OrdinaryConfig};
use parking_lot::Mutex;

use anyhow::bail;
use std::path::{Component, Path, PathBuf};
use std::sync::Arc;

fn get_post_process_path(
    proj_path: &str,
    asset_path: &str,
    assets: &AssetsConfig,
    path: &Path,
) -> anyhow::Result<PathBuf> {
    if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
        let use_gen_path = match ext {
            "css" => assets.minify_css == Some(true),
            "html" => assets.minify_html == Some(true),
            "js" => assets.minify_js == Some(true),
            "png" | "jpg" | "jpeg" | "tif" | "tiff" | "webp" | "avif" => {
                assets.preserve_exif != Some(true)
            }
            _ => false,
        };

        let Some(assets_dir_path) = &assets.dir_path else {
            bail!("assets.dir_path cannot be unset");
        };

        if use_gen_path {
            let gen_path = Path::new(proj_path)
                .join(".ordinary")
                .join("gen")
                .join(assets_dir_path)
                .join(asset_path);

            if gen_path.exists() {
                return Ok(gen_path);
            }
        }
    }

    Ok(path.into())
}

async fn make_write_req(
    api_client: &OrdinaryApiClient<'_>,
    config: &OrdinaryConfig,
    correlation_id: Option<String>,
    access_token: &[u8],
    real_path: &PathBuf,
    src_path: &Path,
) -> anyhow::Result<()> {
    if let Some(assets_config) = &config.assets {
        if src_path.ends_with(".DS_Store") {
            tracing::warn!("ignoring .DS_Store");
            return Ok(());
        }

        let Some(assets_dir_path) = &assets_config.dir_path else {
            bail!("assets.dir_path cannot be unset");
        };

        let dir_path_components = Path::new(assets_dir_path)
            .components()
            .filter(|c| matches!(c, Component::Normal(_)))
            .collect::<Vec<_>>();
        let src_path_components = src_path
            .components()
            .filter(|c| matches!(c, Component::Normal(_)))
            .collect::<Vec<_>>();

        let mut path = String::new();
        let last = src_path_components.len() - 1;

        for (i, component) in src_path_components.iter().enumerate() {
            if let Some(dir_path_component) = dir_path_components.get(i)
                && dir_path_component == component
            {
                continue;
            }

            if let Component::Normal(str) = component
                && let Some(str) = str.to_str()
            {
                let escaped: String =
                    url::form_urlencoded::byte_serialize(str.as_bytes()).collect();

                if str != escaped {
                    bail!("path component '{str}' is not URL safe and has been skipped.");
                }

                if i == last {
                    path = format!("{path}{str}");
                } else {
                    path = format!("{path}{str}/");
                }
            }
        }

        tracing::info!(path = %real_path.display(), "writing...");

        let content = fs_err::read(real_path)?;

        let no_compress = if let Some(ext) = real_path.extension()
            && let Some(ext) = ext.to_str()
        {
            matches!(
                ext,
                "otf"
                    | "ttf"
                    | "woff"
                    | "woff2"
                    | "png"
                    | "apng"
                    | "gif"
                    | "jpg"
                    | "jpeg"
                    | "bmp"
                    | "tif"
                    | "tiff"
                    | "webp"
                    | "avif"
                    | "ico"
                    | "pdf"
            )
        } else {
            false
        };

        let mut request = api_client
            .client
            .put(format!("{}/v1/assets", api_client.addr))
            .query(&[("d", config.domain.as_str()), ("p", &path)])
            .header(
                "Authorization",
                format!("Bearer {}", b64.encode(access_token)),
            );

        if let Some(correlation_id) = correlation_id {
            request = request.header("x-correlation-id", correlation_id);
        }

        if no_compress {
            request = request.body(content);
        } else {
            request = request
                .body(compress_zstd(&content[..])?)
                .header("Content-Encoding", "zstd");
        }

        let status = request.send().await?.status();

        let route = if assets_config.base_route == "/" {
            format!("/{path}")
        } else {
            format!("{}/{path}", assets_config.base_route)
        };

        if status.is_success() {
            tracing::info!(%route, "write complete.");
        } else {
            tracing::error!(%status, %route, "failed to write.");
        }
    }

    Ok(())
}

pub async fn write(
    api_client: &OrdinaryApiClient<'_>,
    proj_path: &str,
    asset_path: &str,
) -> anyhow::Result<()> {
    let config = OrdinaryConfig::get(proj_path, true)?;

    if let Some(assets) = &config.assets {
        let Some(assets_dir_path) = &assets.dir_path else {
            bail!("assets.dir_path cannot be unset");
        };

        let path = Path::new(proj_path).join(assets_dir_path).join(asset_path);

        if path.ends_with(".DS_Store") {
            tracing::warn!("ignoring .DS_Store");
            return Ok(());
        }

        let real_path = get_post_process_path(proj_path, asset_path, assets, &path)?;
        let correlation_id = api_client.correlation_id.map(|id| id.to_string());
        let access_token = api_client.get_access(None, correlation_id.clone()).await?;

        if let Err(err) = make_write_req(
            api_client,
            &config,
            correlation_id,
            &access_token,
            &real_path,
            &path,
        )
        .await
        {
            tracing::error!(%err);
        }
    } else {
        tracing::warn!("no \"assets\" field in ordinary.json");
    }

    Ok(())
}

pub async fn write_all(api_client: &OrdinaryApiClient<'_>, proj_path: &str) -> anyhow::Result<()> {
    let config = OrdinaryConfig::get(proj_path, true)?;
    let paths: Arc<Mutex<Vec<(PathBuf, PathBuf)>>> = Arc::new(Mutex::new(vec![]));

    if let Some(assets) = &config.assets {
        let Some(assets_dir_path) = &assets.dir_path else {
            bail!("assets.dir_path cannot be unset");
        };

        tracing::info!("gathering asset paths from {}...", assets_dir_path);

        let parent_path = Path::new(proj_path).join(assets_dir_path);

        traverse(&parent_path, &|entry| {
            if let Ok(path) = entry.path().strip_prefix(&parent_path) {
                // todo: check against all valid asset extensions in limits
                if path.extension().is_some()
                    && let Some(path_str) = path.to_str()
                {
                    let paths = Arc::clone(&paths);
                    let mut paths = paths.lock();

                    if let Ok(real_path) =
                        get_post_process_path(proj_path, path_str, assets, &entry.path())
                    {
                        paths.push((path.to_path_buf(), real_path));
                    }
                }
            }
        })?;

        let correlation_id = api_client
            .correlation_id
            .unwrap_or(uuid::Uuid::new_v4())
            .to_string();

        let access_token = api_client
            .get_access(Some(30), Some(correlation_id.clone()))
            .await?;

        let paths = Arc::clone(&paths);
        let paths = paths.lock().clone();

        for (src_path, real_path) in paths {
            if let Err(err) = make_write_req(
                api_client,
                &config,
                Some(correlation_id.clone()),
                &access_token,
                &real_path,
                &src_path,
            )
            .await
            {
                tracing::error!(%err);
            }
        }
    } else {
        tracing::warn!("no \"assets\" field in ordinary.json");
    }

    Ok(())
}