hashtree-cli 0.2.37

Hashtree daemon and CLI - content-addressed storage with P2P sync
Documentation
use super::resolve_virtual_tree_host;
use axum::http::{header, HeaderMap};

#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) enum VirtualTreeRoot {
    Immutable { nhash: String },
    Mutable { npub: String, treename: String },
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct ParsedMutableHtreeRequestPath {
    pub npub: String,
    pub treename: String,
    pub path: Option<String>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct ParsedTreeRequestPath {
    pub pubkey: String,
    pub treename: String,
    pub path: Option<String>,
}

pub(super) fn request_virtual_tree_root(headers: &HeaderMap) -> Option<String> {
    headers
        .get(header::HOST)
        .and_then(|value| value.to_str().ok())
        .and_then(resolve_virtual_tree_host)
}

pub(super) fn parse_virtual_tree_root(root: &str) -> Option<VirtualTreeRoot> {
    if let Some(parsed) = parse_mutable_htree_request_path(root) {
        if parsed.path.is_none() {
            return Some(VirtualTreeRoot::Mutable {
                npub: parsed.npub,
                treename: parsed.treename,
            });
        }
    }

    let parsed = reqwest::Url::parse(&format!("http://virtual-host{}", root)).ok()?;
    let segments: Vec<String> = parsed
        .path_segments()?
        .map(|segment| segment.to_string())
        .collect();

    match segments.as_slice() {
        [prefix, nhash] if prefix == "htree" && nhash.starts_with("nhash1") => {
            Some(VirtualTreeRoot::Immutable {
                nhash: nhash.clone(),
            })
        }
        [prefix, npub, treename]
            if prefix == "htree" && npub.starts_with("npub1") && !treename.is_empty() =>
        {
            Some(VirtualTreeRoot::Mutable {
                npub: npub.clone(),
                treename: treename.clone(),
            })
        }
        _ => None,
    }
}

pub(super) fn should_fallback_to_virtual_host_index(
    requested_path: Option<&str>,
    headers: &HeaderMap,
) -> bool {
    let accepts_html = headers
        .get(header::ACCEPT)
        .and_then(|value| value.to_str().ok())
        .map(|value| value.contains("text/html"))
        .unwrap_or(false);

    if !accepts_html {
        return false;
    }

    let Some(path) = requested_path else {
        return true;
    };

    let tail = path.rsplit('/').next().unwrap_or(path);
    !tail.contains('.')
}

fn decode_uri_path_segment(segment: &str) -> String {
    let bytes = segment.as_bytes();
    let mut decoded = Vec::with_capacity(bytes.len());
    let mut index = 0;

    while index < bytes.len() {
        if bytes[index] == b'%' && index + 2 < bytes.len() {
            let hi = bytes[index + 1] as char;
            let lo = bytes[index + 2] as char;
            if let (Some(hi), Some(lo)) = (hi.to_digit(16), lo.to_digit(16)) {
                decoded.push(((hi << 4) | lo) as u8);
                index += 3;
                continue;
            }
        }

        decoded.push(bytes[index]);
        index += 1;
    }

    String::from_utf8(decoded).unwrap_or_else(|_| segment.to_string())
}

fn parse_request_path_segments(raw_path: &str) -> Option<Vec<String>> {
    let parsed = reqwest::Url::parse(&format!("http://htree{}", raw_path)).ok()?;
    Some(
        parsed
            .path_segments()?
            .map(decode_uri_path_segment)
            .collect(),
    )
}

fn parse_tree_request_segments(
    segments: &[String],
    prefix: &[&str],
) -> Option<ParsedTreeRequestPath> {
    if segments.len() < prefix.len() + 2 {
        return None;
    }
    if !segments
        .iter()
        .zip(prefix.iter())
        .all(|(segment, expected)| segment == expected)
    {
        return None;
    }

    let pubkey = segments.get(prefix.len())?.clone();
    let treename = segments.get(prefix.len() + 1)?.clone();
    if pubkey.is_empty() || treename.is_empty() {
        return None;
    }

    let path = (segments.len() > prefix.len() + 2)
        .then(|| segments[prefix.len() + 2..].join("/"))
        .filter(|value| !value.is_empty());

    Some(ParsedTreeRequestPath {
        pubkey,
        treename,
        path,
    })
}

fn parse_tree_request(raw_path: &str, prefix: &[&str]) -> Option<ParsedTreeRequestPath> {
    let segments = parse_request_path_segments(raw_path)?;
    parse_tree_request_segments(&segments, prefix)
}

pub(super) fn parse_mutable_htree_request_path(
    raw_path: &str,
) -> Option<ParsedMutableHtreeRequestPath> {
    let parsed = parse_tree_request(raw_path, &["htree"])?;
    if !parsed.pubkey.starts_with("npub1") {
        return None;
    }

    Some(ParsedMutableHtreeRequestPath {
        npub: parsed.pubkey,
        treename: parsed.treename,
        path: parsed.path,
    })
}

pub(super) fn parse_resolve_request_path(raw_path: &str) -> Option<ParsedTreeRequestPath> {
    parse_tree_request(raw_path, &["n"])
}

pub(super) fn parse_api_resolve_request_path(raw_path: &str) -> Option<ParsedTreeRequestPath> {
    let segments = parse_request_path_segments(raw_path)?;
    parse_tree_request_segments(&segments, &["api", "resolve"])
        .or_else(|| parse_tree_request_segments(&segments, &["api", "nostr", "resolve"]))
}

pub(super) fn parse_bare_npub_request_path(
    raw_path: &str,
) -> Option<ParsedMutableHtreeRequestPath> {
    let parsed = parse_tree_request(raw_path, &[])?;
    if !parsed.pubkey.starts_with("npub1") {
        return None;
    }

    Some(ParsedMutableHtreeRequestPath {
        npub: parsed.pubkey,
        treename: parsed.treename,
        path: parsed.path,
    })
}

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

    #[test]
    fn parse_mutable_htree_request_path_decodes_tree_names_with_slashes() {
        assert_eq!(
            parse_mutable_htree_request_path(
                "/htree/npub1example/releases%2Fnostr-vpn/v0.3.0/assets/nostr-vpn-v0.3.0-macos-arm64.zip"
            ),
            Some(ParsedMutableHtreeRequestPath {
                npub: "npub1example".to_string(),
                treename: "releases/nostr-vpn".to_string(),
                path: Some("v0.3.0/assets/nostr-vpn-v0.3.0-macos-arm64.zip".to_string()),
            })
        );
    }

    #[test]
    fn parse_api_resolve_request_path_decodes_tree_names_with_slashes() {
        assert_eq!(
            parse_api_resolve_request_path("/api/resolve/npub1example/releases%2Fnostr-vpn"),
            Some(ParsedTreeRequestPath {
                pubkey: "npub1example".to_string(),
                treename: "releases/nostr-vpn".to_string(),
                path: None,
            })
        );
    }

    #[test]
    fn parse_bare_npub_request_path_decodes_tree_names_with_slashes() {
        assert_eq!(
            parse_bare_npub_request_path("/npub1example/releases%2Fnostr-vpn/latest"),
            Some(ParsedMutableHtreeRequestPath {
                npub: "npub1example".to_string(),
                treename: "releases/nostr-vpn".to_string(),
                path: Some("latest".to_string()),
            })
        );
    }

    #[test]
    fn parse_virtual_tree_root_accepts_immutable_and_mutable_roots() {
        assert_eq!(
            parse_virtual_tree_root("/htree/nhash1example"),
            Some(VirtualTreeRoot::Immutable {
                nhash: "nhash1example".to_string(),
            })
        );

        assert_eq!(
            parse_virtual_tree_root("/htree/npub1example/releases"),
            Some(VirtualTreeRoot::Mutable {
                npub: "npub1example".to_string(),
                treename: "releases".to_string(),
            })
        );
    }

    #[test]
    fn should_fallback_to_virtual_host_index_requires_html_and_extensionless_paths() {
        let mut headers = HeaderMap::new();
        headers.insert(header::ACCEPT, "text/html".parse().unwrap());

        assert!(should_fallback_to_virtual_host_index(None, &headers));
        assert!(should_fallback_to_virtual_host_index(
            Some("/users/npub1example"),
            &headers
        ));
        assert!(!should_fallback_to_virtual_host_index(
            Some("/assets/main.js"),
            &headers
        ));
        headers.insert(header::ACCEPT, "application/json".parse().unwrap());
        assert!(!should_fallback_to_virtual_host_index(None, &headers));
    }
}