a3s-boot 0.1.3

Adapter-first modular Rust web framework for A3S inspired by Nest.js
Documentation
//! iLink client identity, authentication, and protocol errors.

use std::fmt;
use std::time::Duration;

use reqwest::header::{HeaderMap, HeaderName, HeaderValue, AUTHORIZATION, CONTENT_TYPE};
use thiserror::Error;

use super::auth::{pack_client_version, random_wechat_uin, ClientVersionError, SecretValue};
use super::url_policy::{IlinkUrlError, ValidatedBaseUrl};

pub(super) const STALE_TOKEN_ERROR_CODE: i64 = -14;
pub(super) const DEFAULT_API_TIMEOUT: Duration = Duration::from_secs(15);
pub(super) const DEFAULT_CONFIG_TIMEOUT: Duration = Duration::from_secs(10);
pub(super) const DEFAULT_QR_POLL_TIMEOUT: Duration = Duration::from_secs(35);
pub(super) const MAX_LONG_POLL_TIMEOUT: Duration = Duration::from_secs(60);
pub(super) const MAX_RESPONSE_BYTES: usize = 1024 * 1024;

const AUTHORIZATION_TYPE: HeaderName = HeaderName::from_static("authorizationtype");
const WECHAT_UIN: HeaderName = HeaderName::from_static("x-wechat-uin");
const ILINK_APP_ID: HeaderName = HeaderName::from_static("ilink-app-id");
const ILINK_APP_CLIENT_VERSION: HeaderName = HeaderName::from_static("ilink-app-clientversion");

#[derive(Clone, Debug)]
pub(super) struct IlinkClientIdentity {
    app_id: String,
    packed_client_version: u32,
    pub(super) bot_type: String,
    pub(super) channel_version: String,
    pub(super) bot_agent: String,
}

impl IlinkClientIdentity {
    pub(super) fn new(
        app_id: impl Into<String>,
        bot_type: impl Into<String>,
        client_version: &str,
        bot_agent: impl Into<String>,
    ) -> Result<Self, IlinkError> {
        let app_id = bounded_ascii(app_id.into(), "app id", 128)?;
        let bot_type = bounded_ascii(bot_type.into(), "bot type", 16)?;
        let channel_version = bounded_ascii(client_version.to_string(), "client version", 32)?;
        let bot_agent = bounded_ascii(bot_agent.into(), "bot agent", 256)?;
        let packed_client_version = pack_client_version(client_version)?;
        Ok(Self {
            app_id,
            packed_client_version,
            bot_type,
            channel_version,
            bot_agent,
        })
    }

    pub(super) fn base_info(&self) -> super::types::BaseInfo {
        super::types::BaseInfo {
            channel_version: Some(self.channel_version.clone()),
            bot_agent: Some(self.bot_agent.clone()),
        }
    }

    pub(super) fn application_headers(&self) -> Result<HeaderMap, IlinkError> {
        let mut headers = HeaderMap::new();
        headers.insert(ILINK_APP_ID, safe_header_value(&self.app_id, "app id")?);
        headers.insert(
            ILINK_APP_CLIENT_VERSION,
            safe_header_value(&self.packed_client_version.to_string(), "client version")?,
        );
        Ok(headers)
    }

    pub(super) fn post_headers(
        &self,
        token: Option<&SecretValue>,
    ) -> Result<HeaderMap, IlinkError> {
        let mut headers = self.application_headers()?;
        headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
        headers.insert(
            AUTHORIZATION_TYPE,
            HeaderValue::from_static("ilink_bot_token"),
        );
        headers.insert(
            WECHAT_UIN,
            safe_header_value(&random_wechat_uin(), "Weixin UIN")?,
        );
        if let Some(token) = token {
            headers.insert(
                AUTHORIZATION,
                safe_header_value(&format!("Bearer {}", token.expose()), "authorization")?,
            );
        }
        Ok(headers)
    }
}

#[derive(Clone)]
pub struct IlinkAuth {
    pub(super) base_url: ValidatedBaseUrl,
    pub(super) bot_token: SecretValue,
}

impl IlinkAuth {
    pub fn new(base_url: ValidatedBaseUrl, bot_token: SecretValue) -> Self {
        Self {
            base_url,
            bot_token,
        }
    }
}

impl fmt::Debug for IlinkAuth {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter
            .debug_struct("IlinkAuth")
            .field("base_url", &self.base_url)
            .field("bot_token", &self.bot_token)
            .finish()
    }
}

fn bounded_ascii(value: String, field: &'static str, max: usize) -> Result<String, IlinkError> {
    if value.is_empty()
        || value.len() > max
        || !value.is_ascii()
        || value.chars().any(char::is_control)
    {
        return Err(IlinkError::InvalidConfiguration(field));
    }
    Ok(value)
}

fn safe_header_value(value: &str, field: &'static str) -> Result<HeaderValue, IlinkError> {
    HeaderValue::from_str(value).map_err(|_| IlinkError::InvalidConfiguration(field))
}

pub(super) fn ensure_api_success(
    operation: &'static str,
    ret: Option<i64>,
    errcode: Option<i64>,
) -> Result<(), IlinkError> {
    let code = errcode
        .filter(|code| *code != 0)
        .or_else(|| ret.filter(|code| *code != 0));
    match code {
        None => Ok(()),
        Some(STALE_TOKEN_ERROR_CODE) => Err(IlinkError::StaleCredential),
        Some(code) => Err(IlinkError::Protocol { operation, code }),
    }
}

#[derive(Clone, Debug, Error, PartialEq, Eq)]
pub enum IlinkError {
    #[error("iLink configuration field is invalid: {0}")]
    InvalidConfiguration(&'static str),
    #[error("iLink URL policy rejected the request")]
    Url(#[from] IlinkUrlError),
    #[error("iLink client version is invalid")]
    ClientVersion(#[from] ClientVersionError),
    #[error("iLink request timed out")]
    Timeout,
    #[error("iLink transport failed")]
    Transport,
    #[error("iLink returned HTTP status {0}")]
    HttpStatus(u16),
    #[error("iLink response exceeds the size limit")]
    ResponseTooLarge,
    #[error("iLink returned an invalid response for {0}")]
    InvalidResponse(&'static str),
    #[error("iLink credential is stale")]
    StaleCredential,
    #[error("iLink operation {operation} failed with code {code}")]
    Protocol { operation: &'static str, code: i64 },
}