iris-chat 0.1.61

Iris Chat command line client and shared encrypted chat core
Documentation
use super::*;

pub(super) const PERMISSIONS: &str = "sign_event:37368";
const MAX_RELAYS: usize = 8;

pub(super) struct SignerConnection {
    pub(super) signer: PublicKey,
    pub(super) relays: Vec<RelayUrl>,
    pub(super) secret: String,
}

pub(super) fn parse_signer_connection(input: &str) -> anyhow::Result<SignerConnection> {
    anyhow::ensure!(input.len() <= 8192, "Invalid signer link.");
    let url = url::Url::parse(input.trim()).map_err(|_| anyhow::anyhow!("Invalid signer link."))?;
    anyhow::ensure!(
        url.scheme() == "bunker"
            && url.username().is_empty()
            && url.password().is_none()
            && url.port().is_none()
            && url.path().is_empty()
            && url.fragment().is_none(),
        "Paste a signer link starting with bunker://."
    );
    let signer = PublicKey::from_hex(url.host_str().unwrap_or_default())
        .map_err(|_| anyhow::anyhow!("Invalid signer user ID."))?;
    let mut secret = None;
    let mut relays = Vec::new();
    for (name, value) in url.query_pairs() {
        match name.as_ref() {
            "relay" => relays.push(value.into_owned()),
            "secret" => {
                anyhow::ensure!(
                    secret.is_none() && value.len() <= 1024,
                    "Invalid signer link."
                );
                secret = Some(value.into_owned());
            }
            _ => {}
        }
    }
    Ok(SignerConnection {
        signer,
        relays: validate_signer_relays(&relays)?,
        secret: secret.unwrap_or_default(),
    })
}

pub(super) fn validate_signer_relays(inputs: &[String]) -> anyhow::Result<Vec<RelayUrl>> {
    anyhow::ensure!(
        !inputs.is_empty() && inputs.len() <= MAX_RELAYS,
        "Invalid signer message servers."
    );
    inputs
        .iter()
        .map(|input| {
            let url = url::Url::parse(input)?;
            anyhow::ensure!(
                input.len() <= 2048
                    && matches!(url.scheme(), "ws" | "wss")
                    && url.username().is_empty()
                    && url.password().is_none()
                    && url.fragment().is_none(),
                "Invalid signer message server."
            );
            Ok(RelayUrl::parse(input)?)
        })
        .collect()
}

pub(super) fn client_connection_uri(keys: &Keys, relays: &[RelayUrl], secret: &str) -> String {
    let mut query = url::form_urlencoded::Serializer::new(String::new());
    for relay in relays {
        query.append_pair("relay", relay.as_str());
    }
    query.append_pair("secret", secret);
    query.append_pair("perms", PERMISSIONS);
    query.append_pair("name", "Iris Chat");
    query.append_pair("url", "https://iris.to");
    format!(
        "nostrconnect://{}?{}",
        keys.public_key().to_hex(),
        query.finish().replace('+', "%20")
    )
}

pub(super) fn safe_auth_url(input: &str) -> Option<String> {
    let url = url::Url::parse(input).ok()?;
    (input.len() <= 4096
        && matches!(url.scheme(), "https" | "http")
        && url.host_str().is_some()
        && url.username().is_empty()
        && url.password().is_none())
    .then(|| url.to_string())
}

// Inbound client requests share relay validation with the existing signer client.
pub(super) fn parse_device_link_connection(input: &str) -> anyhow::Result<SignerConnection> {
    anyhow::ensure!(
        input.trim().starts_with("nostrconnect://"),
        "Invalid device link."
    );
    anyhow::ensure!(input.len() <= 8192, "Invalid device link.");
    let url = url::Url::parse(input.trim())?;
    let secret_count = url
        .query_pairs()
        .filter(|(name, _)| name == "secret")
        .count();
    anyhow::ensure!(secret_count == 1, "Device link requires a secret.");
    anyhow::ensure!(
        url.query_pairs()
            .filter(|(name, _)| name == "perms")
            .count()
            <= 1,
        "Invalid device link."
    );
    let permissions = url
        .query_pairs()
        .find(|(name, _)| name == "perms")
        .map(|(_, value)| value.into_owned());
    anyhow::ensure!(
        permissions
            .as_deref()
            .is_none_or(|value| value == PERMISSIONS),
        "Unsupported device permissions."
    );
    let converted = input.trim().replacen("nostrconnect://", "bunker://", 1);
    let connection = parse_signer_connection(&converted)?;
    anyhow::ensure!(
        !connection.secret.is_empty(),
        "Device link requires a secret."
    );
    Ok(connection)
}