wavefunk-ui 0.1.5

Askama and htmx UI component base for Wave Funk Rust applications.
Documentation
use rust_embed::RustEmbed;
use std::borrow::Cow;
use std::fmt::Write as _;

pub const DEFAULT_BASE_PATH: &str = "/static/wavefunk";
pub const STYLESHEET_PATH: &str = "css/wavefunk.css";
pub const SCRIPT_PATH: &str = "js/wavefunk.js";
pub const HTMX_SCRIPT_PATH: &str = "js/htmx.min.js";
pub const HTMX_SSE_SCRIPT_PATH: &str = "js/htmx-sse.js";
pub const FONT_SANS_PATH: &str = "css/fonts/MartianGrotesk-VF.woff2";
pub const FONT_MONO_PATH: &str = "css/fonts/MartianMono-VF.woff2";
pub const CACHE_CONTROL: &str = "public, max-age=0, must-revalidate";

#[derive(RustEmbed)]
#[folder = "static/wavefunk"]
struct EmbeddedAssets;

#[derive(Clone, Debug)]
pub struct Asset {
    pub path: String,
    pub bytes: Cow<'static, [u8]>,
    pub content_type: &'static str,
}

pub fn get(path: &str) -> Option<Asset> {
    let path = normalize_path(path)?;
    EmbeddedAssets::get(path).map(|file| Asset {
        path: path.to_owned(),
        bytes: file.data,
        content_type: content_type(path),
    })
}

pub fn etag(path: &str) -> Option<String> {
    let path = normalize_path(path)?;
    EmbeddedAssets::get(path).map(|file| format_etag(file.metadata.sha256_hash()))
}

pub fn iter() -> impl Iterator<Item = Cow<'static, str>> {
    EmbeddedAssets::iter()
}

pub fn content_type(path: &str) -> &'static str {
    match path.rsplit_once('.').map(|(_, ext)| ext) {
        Some("css") => "text/css; charset=utf-8",
        Some("js") => "text/javascript; charset=utf-8",
        Some("html") => "text/html; charset=utf-8",
        Some("svg") => "image/svg+xml",
        Some("png") => "image/png",
        Some("jpg" | "jpeg") => "image/jpeg",
        Some("webp") => "image/webp",
        Some("woff2") => "font/woff2",
        Some("txt") => "text/plain; charset=utf-8",
        _ => "application/octet-stream",
    }
}

pub fn normalize_path(path: &str) -> Option<&str> {
    let path = path.strip_prefix('/').unwrap_or(path);
    let default_base_path = DEFAULT_BASE_PATH.trim_start_matches('/');
    let path = path
        .strip_prefix(default_base_path)
        .and_then(|path| path.strip_prefix('/'))
        .unwrap_or(path);

    if path.is_empty()
        || path.starts_with('/')
        || path.contains('\\')
        || path
            .split('/')
            .any(|part| part.is_empty() || part == "." || part == "..")
    {
        return None;
    }

    Some(path)
}

fn format_etag(hash: [u8; 32]) -> String {
    let mut etag = String::with_capacity(66);
    etag.push('"');
    for byte in hash {
        write!(&mut etag, "{byte:02x}").expect("writing a hash to a string should not fail");
    }
    etag.push('"');
    etag
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn serves_key_runtime_assets_with_content_types() {
        let cases = [
            (STYLESHEET_PATH, "text/css; charset=utf-8"),
            (SCRIPT_PATH, "text/javascript; charset=utf-8"),
            (HTMX_SCRIPT_PATH, "text/javascript; charset=utf-8"),
            (HTMX_SSE_SCRIPT_PATH, "text/javascript; charset=utf-8"),
            (FONT_SANS_PATH, "font/woff2"),
            (FONT_MONO_PATH, "font/woff2"),
        ];

        for (path, content_type) in cases {
            let asset = get(path).unwrap_or_else(|| panic!("{path} should be embedded"));
            assert_eq!(asset.path, path);
            assert_eq!(asset.content_type, content_type);
            assert!(!asset.bytes.is_empty());
        }
    }

    #[test]
    fn accepts_default_mount_paths() {
        let asset = get("/static/wavefunk/js/htmx.min.js").expect("public mount path should work");
        assert_eq!(asset.path, HTMX_SCRIPT_PATH);
    }

    #[test]
    fn lists_packaged_runtime_assets() {
        let paths = iter().collect::<Vec<_>>();

        assert!(paths.iter().any(|path| path.as_ref() == STYLESHEET_PATH));
        assert!(paths.iter().any(|path| path.as_ref() == SCRIPT_PATH));
        assert!(paths.iter().any(|path| path.as_ref() == HTMX_SCRIPT_PATH));
        assert!(
            paths
                .iter()
                .any(|path| path.as_ref() == HTMX_SSE_SCRIPT_PATH)
        );
        assert!(paths.iter().any(|path| path.as_ref() == FONT_SANS_PATH));
        assert!(paths.iter().any(|path| path.as_ref() == FONT_MONO_PATH));
    }

    #[test]
    fn exposes_shared_cache_policy() {
        assert_eq!(CACHE_CONTROL, "public, max-age=0, must-revalidate");
    }

    #[test]
    fn exposes_stable_entity_tags_for_runtime_assets() {
        let stylesheet_etag = etag(STYLESHEET_PATH).expect("stylesheet should have an entity tag");

        assert_eq!(stylesheet_etag.len(), 66);
        assert!(stylesheet_etag.starts_with('"'));
        assert!(stylesheet_etag.ends_with('"'));
        assert_eq!(
            stylesheet_etag,
            etag(format!("{DEFAULT_BASE_PATH}/{STYLESHEET_PATH}").as_str()).unwrap()
        );
        assert!(etag("../Cargo.toml").is_none());
    }

    #[test]
    fn rejects_path_traversal() {
        assert!(get("../Cargo.toml").is_none());
        assert!(get("css/../Cargo.toml").is_none());
        assert!(get("css//wavefunk.css").is_none());
        assert!(get("/static/wavefunk/../Cargo.toml").is_none());
        assert!(get(r"css\wavefunk.css").is_none());
    }
}