Anforderungen  |   Konzepte  |   Entwurf  |   Entwicklung  |   Qualitätssicherung  |   Lebenszyklus  |   Steuerung
 
 
 
 


Quelle  config.rs   Sprache: unbekannt

 
Spracherkennung für: .rs vermutete Sprache: Unknown {[0] [0] [0]} [Methode: Schwerpunktbildung, einfache Gewichte, sechs Dimensionen]

//! Configuration for the various protocols and endpoints.
use core::fmt;
use std::{collections::HashMap, sync::LazyLock};

#[cfg(any(test, feature = "cli"))]
use anyhow;
use data_encoding::BASE64;
use duplicate::duplicate_item;
use educe::Educe;
use regex::{Captures, Regex};
use serde::Deserialize;
use strum::EnumString;
use tracing::warn;

use super::{
    BlobId, ChatServerGroup,
    keys::{DeviceGroupPathKey, PublicKey},
};
use crate::{
    common::ThreemaId,
    crypto::ed25519,
    model::contact::PredefinedContact,
    utils::{
        debug::debug_str_redacted,
        serde::{base64, string},
        time::Duration,
    },
};

/// Work variant credentials.
#[derive(Clone, Educe, PartialEq, Eq)]
#[educe(Debug)]
#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
#[cfg_attr(not(feature = "uniffi"), derive(zeroize::ZeroizeOnDrop))]
#[cfg_attr(
    feature = "wasm",
    derive(tsify::Tsify, serde::Deserialize),
    serde(rename_all = "camelCase"),
    tsify(from_wasm_abi)
)]
pub struct WorkCredentials {
    /// Work username
    #[educe(Debug(method(debug_str_redacted)))]
    pub username: String,

    /// Work password
    #[educe(Debug(method(debug_str_redacted)))]
    pub password: String,
}
impl WorkCredentials {
    /// Convert a string to [`WorkCredentials`]. Must be in the following format: `<username>:<password>`.
    ///
    /// # Errors
    ///
    /// Returns a string describing the error.
    #[cfg(any(test, feature = "cli"))]
    pub fn from_colon_separated_str(string: &str) -> anyhow::Result<Self> {
        use anyhow::Context as _;

        let (username, password) = string.split_once(':').context("Invalid work credentials")?;
        Ok(Self {
            username: username.to_owned(),
            password: password.to_owned(),
        })
    }
}

/// Work flavour of the application.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
#[cfg_attr(
    feature = "wasm",
    derive(tsify::Tsify, serde::Deserialize),
    serde(rename_all = "kebab-case"),
    tsify(from_wasm_abi)
)]
pub enum WorkFlavor {
    /// (Normal) Work application.
    Work,

    /// OnPrem application.
    OnPrem,
}

/// Work-related context information.
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
#[cfg_attr(
    feature = "wasm",
    derive(tsify::Tsify, serde::Deserialize),
    serde(rename_all = "camelCase"),
    tsify(from_wasm_abi)
)]
pub struct WorkContext {
    /// Work variant credentials.
    pub credentials: WorkCredentials,

    /// Work flavour of the application.
    pub flavor: WorkFlavor,
}

/// General flavour of the application.
#[derive(Clone, PartialEq, Eq)]
pub enum Flavor {
    /// Consumer application.
    Consumer,

    /// Work (or OnPrem) application.
    Work(WorkContext),
}

/// URL error.
#[derive(Debug, thiserror::Error)]
#[cfg_attr(test, derive(PartialEq))]
pub enum UrlError {
    /// URL is invalid.
    #[error("Invalid URL")]
    InvalidUrl(&'static str),

    /// URL is not a valid base URL (must end with a trailing slash)
    #[error("Invalid base URL")]
    InvalidBaseUrl(&'static str),

    /// URL uses an unexpected scheme.
    #[error("Unexpected scheme: Expected {expected} but got {actual}")]
    UnexpectedScheme {
        /// Expected scheme.
        expected: &'static str,
        /// Actual scheme.
        actual: String,
    },
}

static URL_REGEX: LazyLock<Regex> = LazyLock::new(|| {
    Regex::new(r"^(?<scheme>[a-zA-Z]+)://(?<host>[^\s?#&/]+)/(?<path>[^\s?#&]+)?$")
        .expect("URL regex compilation failed")
});

static TEMPLATE_REGEX: LazyLock<Regex> =
    LazyLock::new(|| Regex::new(r"\{(.*?)\}").expect("Template regex compilation failed"));

fn map_placeholders<F: Fn(&'_ str) -> Option<String>>(string: &str, map_fn: F) -> String {
    TEMPLATE_REGEX
        .replace_all(string, |captures: &Captures<'_>| {
            let key = captures
                .get(1)
                .expect("Template regex should have one unnamed capture group")
                .as_str();
            map_fn(key).unwrap_or_else(|| {
                warn!(string, placeholder = key, "Ignoring unknown placeholder");
                format!("{{{key}}}")
            })
        })
        .into_owned()
}

/// A URL has the following syntax: `<scheme>://<host>/<path>`
#[derive(Debug, Clone, PartialEq, Eq)]
struct Url(String);
impl Url {
    pub(crate) fn new(url: String, expected_scheme: &'static str) -> Result<Self, UrlError> {
        let captures = URL_REGEX
            .captures(&url)
            .ok_or(UrlError::InvalidUrl("Invalid format"))?;

        // Validate scheme
        let scheme = captures
            .name("scheme")
            .expect("Capture group 'scheme' should exist");
        if scheme.as_str() != expected_scheme {
            return Err(UrlError::UnexpectedScheme {
                expected: expected_scheme,
                actual: scheme.as_str().to_owned(),
            });
        }

        Ok(Self(url))
    }

    #[inline]
    fn as_str(&self) -> &str {
        &self.0
    }

    #[inline]
    fn path(&self, path: fmt::Arguments) -> String {
        format!("{}{}", self.0, path)
    }

    #[inline]
    fn map_placeholders<F: Fn(&'_ str) -> Option<String>>(&self, map_fn: F) -> String {
        map_placeholders(&self.0, map_fn)
    }
}

/// A base URL has the following syntax: `<scheme>://<host>/<path>/`
///
/// The trailing slash is required!
#[derive(Debug, Clone, PartialEq, Eq)]
struct BaseUrl(Url);
impl BaseUrl {
    pub(crate) fn new(url: String, expected_scheme: &'static str) -> Result<Self, UrlError> {
        let url = Url::new(url, expected_scheme)?;
        if !url.0.ends_with('/') {
            return Err(UrlError::InvalidBaseUrl("Missing trailing slash"));
        }
        Ok(BaseUrl(url))
    }

    #[inline]
    fn as_str(&self) -> &str {
        &self.0.0
    }

    #[inline]
    fn path(&self, path: fmt::Arguments) -> String {
        self.0.path(path)
    }

    #[inline]
    fn map_placeholders<F: Fn(&'_ str) -> Option<String>>(&self, map_fn: F) -> String {
        self.0.map_placeholders(map_fn)
    }
}

/// An HTTPS URL has the following syntax: `https://<host>/<path>`
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(try_from = "String")]
pub struct HttpsUrl(Url);
impl TryFrom<String> for HttpsUrl {
    type Error = UrlError;

    fn try_from(url: String) -> Result<Self, Self::Error> {
        let url = Url::new(url, "https")?;
        Ok(Self(url))
    }
}

/// An HTTPS base URL has the following syntax: `https://<host>/<path>/`
///
/// The trailing slash is required!
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HttpsBaseUrl(BaseUrl);
impl TryFrom<String> for HttpsBaseUrl {
    type Error = UrlError;

    fn try_from(url: String) -> Result<Self, Self::Error> {
        let url = BaseUrl::new(url, "https")?;
        Ok(Self(url))
    }
}

/// A (secure) WebSocket base URL has the following syntax: `wss://<host>/<path>/`
///
/// The trailing slash is required!
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WssBaseUrl(BaseUrl);
impl TryFrom<String> for WssBaseUrl {
    type Error = UrlError;

    fn try_from(url: String) -> Result<Self, Self::Error> {
        let url = BaseUrl::new(url, "wss")?;
        Ok(Self(url))
    }
}

#[duplicate_item(
    url_type;
    [ HttpsBaseUrl ];
    [ WssBaseUrl ];
)]
impl<'de> Deserialize<'de> for url_type {
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where
        D: serde::Deserializer<'de>,
    {
        struct StringVisitor;
        impl serde::de::Visitor<'_> for StringVisitor {
            type Value = url_type;

            fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
                write!(formatter, "a base URL string")
            }

            fn visit_string<E: serde::de::Error>(self, url: String) -> Result<Self::Value, E> {
                Self::Value::try_from(if url.ends_with('/') {
                    url
                } else {
                    format!("{url}/")
                })
                .map_err(serde::de::Error::custom)
            }

            fn visit_str<E: serde::de::Error>(self, url: &str) -> Result<Self::Value, E> {
                Self::visit_string(self, url.to_owned())
            }
        }
        deserializer.deserialize_string(StringVisitor)
    }
}

static ON_PREM_LICENSE_URL_REGEX: LazyLock<Regex> = LazyLock::new(|| {
    Regex::new(r"^(?<scheme>[a-zA-Z]+)://license\?(?<query>.+)$").expect("URL regex compilation failed")
});

/// OnPrem configuration URL (aka URL to the OPPF file).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OnPremConfigUrl(HttpsUrl);

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct OnPremLicense {
    pub(crate) config_url: OnPremConfigUrl,
    pub(crate) work_context: WorkContext,
}
impl OnPremLicense {
    const SCHEME: &'static str = "threemaonprem";

    pub(crate) fn from_url(url: &str) -> Result<Self, UrlError> {
        let captures = ON_PREM_LICENSE_URL_REGEX
            .captures(url)
            .ok_or(UrlError::InvalidUrl("Invalid format"))?;

        // Validate scheme
        let scheme = captures
            .name("scheme")
            .expect("Capture group 'scheme' should exist");
        if scheme.as_str() != Self::SCHEME {
            return Err(UrlError::UnexpectedScheme {
                expected: Self::SCHEME,
                actual: scheme.as_str().to_owned(),
            });
        }

        // Extract server URL and credentials from query string
        let mut config_url: Option<OnPremConfigUrl> = None;
        let mut username: Option<String> = None;
        let mut password: Option<String> = None;
        let query = form_urlencoded::parse(
            captures
                .name("query")
                .ok_or(UrlError::InvalidUrl("Capture group 'query' should exist"))?
                .as_str()
                .as_bytes(),
        );
        for (name, value) in query {
            match name.as_ref() {
                "server" => {
                    let _ = config_url.replace(OnPremConfigUrl::try_from(if value.ends_with(".oppf") {
                        value.to_string()
                    } else {
                        format!("{}/prov/config.oppf", value.trim_end_matches('/'))
                    })?);
                },

                "username" => {
                    let _ = username.replace(value.into_owned());
                },

                "password" => {
                    let _ = password.replace(value.into_owned());
                },

                _ => {},
            }
        }
        Ok(Self {
            config_url: config_url.ok_or(UrlError::InvalidUrl("Missing configuration URL"))?,
            work_context: WorkContext {
                credentials: WorkCredentials {
                    username: username.ok_or(UrlError::InvalidUrl("Missing username"))?,
                    password: password.ok_or(UrlError::InvalidUrl("Missing password"))?,
                },
                flavor: WorkFlavor::OnPrem,
            },
        })
    }
}

mod device_group_id_template_url {
    use core::fmt;

    use crate::common::{config::Url, keys::DeviceGroupPathKey};

    // IMPORTANT: These template strings must be stable because they're used in the OPPF file!
    pub(super) const PREFIX_4: &str = "deviceGroupIdPrefix4";
    pub(super) const PREFIX_8: &str = "deviceGroupIdPrefix8";

    pub(super) fn path(
        url: &Url,
        device_group_path_key: &DeviceGroupPathKey,
        path: Option<fmt::Arguments>,
    ) -> String {
        let prefix = *device_group_path_key
            .public_key()
            .0
            .as_bytes()
            .first()
            .expect("Device Group Path Key should contain at least one byte");
        let base_url = url.map_placeholders(|key| match key {
            PREFIX_4 => Some(format!("{:x}", prefix >> 4_u8)),
            PREFIX_8 => Some(format!("{prefix:x}")),
            _ => None,
        });
        match path {
            None => base_url,
            Some(path) => format!("{base_url}{path}"),
        }
    }
}

mod blob_id_template_url {
    use data_encoding::HEXLOWER;

    use crate::common::{BlobId, config::Url};

    // IMPORTANT: These template strings must be stable because they're used in the OPPF file!
    pub(super) const FULL: &str = "blobId";
    pub(super) const PREFIX_8: &str = "blobIdPrefix";

    pub(crate) fn path(url: &Url, blob_id: BlobId) -> String {
        let prefix = blob_id
            .0
            .first()
            .expect("Blob ID should contain at least one byte");
        url.map_placeholders(|key| match key {
            PREFIX_8 => Some(format!("{prefix:x}")),
            FULL => Some(HEXLOWER.encode(&blob_id.0)),
            _ => None,
        })
    }
}

mod device_group_id_and_blob_id_template_url {
    use data_encoding::HEXLOWER;

    use crate::common::{
        BlobId,
        config::{Url, blob_id_template_url, device_group_id_template_url},
        keys::DeviceGroupPathKey,
    };

    pub(crate) fn path(url: &Url, device_group_path_key: &DeviceGroupPathKey, blob_id: BlobId) -> String {
        let device_group_path_key_prefix = *device_group_path_key
            .public_key()
            .0
            .as_bytes()
            .first()
            .expect("Device Group Path Key should contain at least one byte");
        let blob_id_prefix = blob_id
            .0
            .first()
            .expect("Blob ID should contain at least one byte");
        url.map_placeholders(|key| match key {
            device_group_id_template_url::PREFIX_4 => {
                Some(format!("{:x}", device_group_path_key_prefix >> 4_u8))
            },
            device_group_id_template_url::PREFIX_8 => Some(format!("{device_group_path_key_prefix:x}")),
            blob_id_template_url::PREFIX_8 => Some(format!("{blob_id_prefix:x}")),
            blob_id_template_url::FULL => Some(HEXLOWER.encode(&blob_id.0)),
            _ => None,
        })
    }
}

/// Chat server address (for non multi-device/legacy connections) with placeholders.
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
pub struct ChatServerAddress {
    /// Hostname of the chat server
    pub hostname: String,

    /// Available ports the chat server is listening on
    pub ports: Vec<u16>,
}
impl ChatServerAddress {
    // IMPORTANT: This template string must be stable because it is used in the OPPF file!
    const PREFIX_8: &'static str = "serverGroupPrefix8";

    /// Retrieve all valid CSP addresses (hostname + port combinations).
    ///
    /// # Panics
    ///
    /// If the internal placeholder could not be replaced.
    #[must_use]
    pub fn addresses(&self, server_group: ChatServerGroup) -> Vec<(String, u16)> {
        let hostname = map_placeholders(&self.hostname, |key| match key {
            Self::PREFIX_8 => Some(format!("{:x}", server_group.0)),
            _ => None,
        });
        self.ports.iter().map(|port| (hostname.clone(), *port)).collect()
    }
}

/// Directory server base URL.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DirectoryServerBaseUrl(HttpsBaseUrl);
impl DirectoryServerBaseUrl {
    pub(crate) fn create_identity_path(&self) -> String {
        self.0.0.path(format_args!("identity/create"))
    }

    pub(crate) fn update_work_properties_path(&self) -> String {
        self.0.0.path(format_args!("identity/update_work_info"))
    }

    pub(crate) fn request_identities_path(&self) -> String {
        self.0.0.path(format_args!("identity/fetch_bulk"))
    }
}

/// Blob server upload URL.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BlobServerUploadUrl(HttpsUrl);
impl BlobServerUploadUrl {
    #[expect(dead_code, reason = "Will use later")]
    pub(crate) fn path(&self) -> &str {
        &self.0.0.0
    }
}

/// Blob server download URL.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BlobServerDownloadUrl(HttpsUrl);
impl BlobServerDownloadUrl {
    #[expect(dead_code, reason = "Will use later")]
    pub(crate) fn path(&self, blob_id: BlobId) -> String {
        blob_id_template_url::path(&self.0.0, blob_id)
    }
}

/// Blob server URL to mark a blob as _downloaded_.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BlobServerDoneUrl(HttpsUrl);
impl BlobServerDoneUrl {
    #[expect(dead_code, reason = "Will use later")]
    pub(crate) fn path(&self, blob_id: BlobId) -> String {
        blob_id_template_url::path(&self.0.0, blob_id)
    }
}

/// Blob server configuration
#[derive(Debug, Clone, PartialEq, Eq)]
#[expect(clippy::struct_field_names, reason = "All fields intentionally end with URL")]
pub struct BlobServerConfig {
    /// Blob server upload URL.
    pub upload_url: BlobServerUploadUrl,

    /// Blob server download URL.
    pub download_url: BlobServerDownloadUrl,

    /// Blob server URL to mark a blob as _downloaded_.
    pub done_url: BlobServerDoneUrl,
}

/// Work directory server base URL.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorkServerBaseUrl(HttpsBaseUrl);
impl WorkServerBaseUrl {
    pub(crate) fn remote_secret_path(&self) -> String {
        self.0.0.path(format_args!("api-client/v1/remote-secret"))
    }

    pub(crate) fn request_contacts_path(&self) -> String {
        self.0.0.path(format_args!("identities"))
    }
}

/// Avatar base server URL for Threema Gateway IDs.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GatewayAvatarBaseServerUrl(HttpsBaseUrl);
impl GatewayAvatarBaseServerUrl {
    #[expect(dead_code, reason = "Will use later")]
    pub(crate) fn path(&self, threema_id: ThreemaId) -> String {
        self.0.0.path(format_args!("{threema_id}"))
    }
}

/// Threema Safe server URL.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SafeServerBaseUrl(HttpsBaseUrl);
impl SafeServerBaseUrl {
    // IMPORTANT: This template string must be stable because it is used in the OPPF file!
    const PREFIX_8: &'static str = "backupIdPrefix8";

    #[expect(dead_code, reason = "Will use later")]
    pub(crate) fn path(&self, backup_id: &[u8], path: fmt::Arguments) -> String {
        let prefix = *backup_id
            .first()
            .expect("Backup ID should contain at least one byte");
        let base_url = self.0.0.map_placeholders(|key| match key {
            Self::PREFIX_8 => Some(format!("{prefix:x}")),
            _ => None,
        });
        format!("{base_url}{path}")
    }
}

/// Rendezvous server URL with placeholders.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RendezvousServerBaseUrl(WssBaseUrl);
impl RendezvousServerBaseUrl {
    // IMPORTANT: These template strings must be stable because they're used in the OPPF file!
    const PREFIX_4: &'static str = "rendezvousPathPrefix4";
    const PREFIX_8: &'static str = "rendezvousPathPrefix8";

    #[expect(dead_code, reason = "Will use later")]
    pub(crate) fn path(&self, rendezvous_path: &[u8], path: fmt::Arguments) -> String {
        let prefix = *rendezvous_path
            .first()
            .expect("Rendezvous Path should contain at least one byte");
        let base_url = self.0.0.map_placeholders(|key| match key {
            Self::PREFIX_4 => Some(format!("{:x}", prefix >> 4_u8)),
            Self::PREFIX_8 => Some(format!("{prefix:x}")),
            _ => None,
        });
        format!("{base_url}{path}")
    }
}

/// Mediator server base URL.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MediatorServerBaseUrl(WssBaseUrl);
impl MediatorServerBaseUrl {
    pub(crate) fn path(&self, device_group_path_key: &DeviceGroupPathKey, path: fmt::Arguments) -> String {
        device_group_id_template_url::path(&self.0.0.0, device_group_path_key, Some(path))
    }
}

/// Blob mirror server upload URL.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BlobMirrorServerUploadUrl(HttpsUrl);
impl BlobMirrorServerUploadUrl {
    #[expect(dead_code, reason = "Will use later")]
    pub(crate) fn path(&self, device_group_path_key: &DeviceGroupPathKey) -> String {
        device_group_id_template_url::path(&self.0.0, device_group_path_key, None)
    }
}

/// Blob mirror server download URL.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BlobMirrorServerDownloadUrl(HttpsUrl);
impl BlobMirrorServerDownloadUrl {
    #[expect(dead_code, reason = "Will use later")]
    pub(crate) fn path(&self, device_group_path_key: &DeviceGroupPathKey, blob_id: BlobId) -> String {
        device_group_id_and_blob_id_template_url::path(&self.0.0, device_group_path_key, blob_id)
    }
}

/// Blob mirror server URL to mark a blob as _downloaded_.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BlobMirrorServerDoneUrl(HttpsUrl);
impl BlobMirrorServerDoneUrl {
    #[expect(dead_code, reason = "Will use later")]
    pub(crate) fn path(&self, device_group_path_key: &DeviceGroupPathKey, blob_id: BlobId) -> String {
        device_group_id_and_blob_id_template_url::path(&self.0.0, device_group_path_key, blob_id)
    }
}

/// Blob mirror server configuration
#[derive(Debug, Clone, PartialEq, Eq)]
#[expect(clippy::struct_field_names, reason = "All fields intentionally end with URL")]
pub struct BlobMirrorServerConfig {
    /// Blob mirror server upload URL.
    pub upload_url: BlobMirrorServerUploadUrl,

    /// Blob mirror server download URL.
    pub download_url: BlobMirrorServerDownloadUrl,

    /// Blob mirror server URL to mark a blob as _downloaded_.
    pub done_url: BlobMirrorServerDoneUrl,
}

/// Multi-device configuration
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MultiDeviceConfig {
    /// Rendezvous server base URL.
    pub rendezvous_server_url: RendezvousServerBaseUrl,

    /// Mediator server base URL.
    pub mediator_server_url: MediatorServerBaseUrl,

    /// Blob mirror server configuration
    pub blob_mirror_server: BlobMirrorServerConfig,
}

#[duplicate_item(
    url_type;
    [ OnPremConfigUrl ];
    [ DirectoryServerBaseUrl ];
    [ BlobServerUploadUrl ];
    [ BlobServerDownloadUrl ];
    [ BlobServerDoneUrl ];
    [ WorkServerBaseUrl ];
    [ GatewayAvatarBaseServerUrl ];
    [ SafeServerBaseUrl ];
    [ RendezvousServerBaseUrl ];
    [ MediatorServerBaseUrl ];
    [ BlobMirrorServerUploadUrl ];
    [ BlobMirrorServerDownloadUrl ];
    [ BlobMirrorServerDoneUrl ];
)]
impl url_type {
    /// String representation of the URL.
    #[must_use]
    pub fn as_str(&self) -> &str {
        self.0.0.as_str()
    }
}

#[duplicate_item(
    url_type                        inner_url_type;
    [ OnPremConfigUrl ]             [ HttpsUrl ];
    [ DirectoryServerBaseUrl ]      [ HttpsBaseUrl ];
    [ BlobServerUploadUrl ]         [ HttpsUrl ];
    [ BlobServerDownloadUrl ]       [ HttpsUrl ];
    [ BlobServerDoneUrl ]           [ HttpsUrl ];
    [ WorkServerBaseUrl ]           [ HttpsBaseUrl ];
    [ GatewayAvatarBaseServerUrl ]  [ HttpsBaseUrl ];
    [ SafeServerBaseUrl ]           [ HttpsBaseUrl ];
    [ RendezvousServerBaseUrl ]     [ WssBaseUrl ];
    [ MediatorServerBaseUrl ]       [ WssBaseUrl ];
    [ BlobMirrorServerUploadUrl ]   [ HttpsUrl ];
    [ BlobMirrorServerDownloadUrl ] [ HttpsUrl ];
    [ BlobMirrorServerDoneUrl ]     [ HttpsUrl ];
)]
impl TryFrom<String> for url_type {
    type Error = UrlError;

    fn try_from(url: String) -> Result<Self, Self::Error> {
        let url = inner_url_type::try_from(url)?;
        Ok(Self(url))
    }
}

/// Version of an OnPrem configuration.
#[derive(Debug, Clone, Copy, PartialEq, Eq, EnumString)]
#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
pub enum OnPremConfigVersion {
    /// Initial version... kinda. Historically, backwards compatible changes have not triggered a minor
    /// version bump.
    #[strum(serialize = "1.0")]
    V1_0,
}

#[derive(Deserialize)]
struct RawOnPremChatServerConfig {
    #[serde(rename = "publicKey", with = "base64::fixed_length")]
    public_key: [u8; PublicKey::LENGTH],

    #[serde(rename = "hostname")]
    hostname: String,

    #[serde(rename = "ports")]
    ports: Vec<u16>,
}

#[derive(Deserialize)]
struct RawOnPremDirectoryServerConfig {
    #[serde(rename = "url")]
    base_url: HttpsBaseUrl,
}

#[derive(Deserialize)]
#[expect(clippy::struct_field_names, reason = "All fields intentionally end with URL")]
struct RawOnPremBlobServerConfig {
    #[serde(rename = "uploadUrl")]
    upload_url: HttpsUrl,

    #[serde(rename = "downloadUrl")]
    download_url: HttpsUrl,

    #[serde(rename = "doneUrl")]
    done_url: HttpsUrl,
}

#[derive(Deserialize)]
struct RawOnPremWorkServerConfig {
    #[serde(rename = "url")]
    base_url: HttpsBaseUrl,
}

#[derive(Deserialize)]
struct RawOnPremGatewayAvatarServerConfig {
    #[serde(rename = "url")]
    base_url: HttpsBaseUrl,
}

#[derive(Deserialize)]
struct RawOnPremSafeServerConfig {
    #[serde(rename = "url")]
    base_url: HttpsBaseUrl,
}

#[derive(Deserialize)]
struct RawOnPremRendezvousServerConfig {
    #[serde(rename = "url")]
    base_url: WssBaseUrl,
}

#[derive(Deserialize)]
struct RawOnPremMediatorAndBlobMirrorServerConfig {
    #[serde(rename = "url")]
    mediator_server_base_url: WssBaseUrl,

    #[serde(rename = "blob")]
    blob_mirror_server: RawOnPremBlobServerConfig,
}

#[derive(Deserialize)]
struct RawOnPremConfig {
    #[serde(rename = "version", deserialize_with = "string::from_str::deserialize")]
    version: OnPremConfigVersion,

    #[serde(rename = "signatureKey", with = "base64::fixed_length")]
    signature_key: [u8; ed25519::PUBLIC_KEY_LENGTH],

    #[serde(rename = "refresh")]
    refresh_interval_s: u32,

    #[serde(rename = "chat")]
    chat_server: RawOnPremChatServerConfig,

    #[serde(rename = "directory")]
    directory_server: RawOnPremDirectoryServerConfig,

    #[serde(rename = "blob")]
    blob_server: RawOnPremBlobServerConfig,

    #[serde(rename = "work")]
    work_server: RawOnPremWorkServerConfig,

    #[serde(rename = "avatar")]
    gateway_avatar_server: RawOnPremGatewayAvatarServerConfig,

    #[serde(rename = "safe")]
    safe_server: RawOnPremSafeServerConfig,

    #[serde(rename = "rendezvous")]
    rendezvous_server: Option<RawOnPremRendezvousServerConfig>,

    #[serde(rename = "mediator")]
    mediator_and_blob_mirror_server: Option<RawOnPremMediatorAndBlobMirrorServerConfig>,
}

/// Possible errors when parsing an OnPrem configuration.
#[derive(Debug, thiserror::Error)]
pub enum OnPremConfigError {
    /// Decoding failed.
    #[error("Decoding failed: {0}")]
    DecodingFailed(String),

    /// Signature key does not match one of the accepted ones.
    #[error("Signature key mismatch")]
    SignatureKeyMismatch,

    /// Signature is invalid.
    #[error("Invalid signature")]
    InvalidSignature,
}

/// OnPrem configuration, fetched from the OPPF endpoint.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OnPremConfig {
    /// Configuration version.
    pub version: OnPremConfigVersion,

    /// Configuration refresh interval
    pub refresh_interval: Duration,

    /// Chat server address (for non multi-device/legacy connections) with placeholders.
    pub chat_server_address: ChatServerAddress,

    /// Chat server public keys.
    ///
    /// Note: The first public key is considered primary. All following public keys are fallbacks.
    pub chat_server_public_keys: Vec<PublicKey>,

    /// Directory server URL.
    pub directory_server_url: DirectoryServerBaseUrl,

    /// Blob server configuration
    pub blob_server: BlobServerConfig,

    /// Work server URL.
    pub work_server_url: WorkServerBaseUrl,

    /// Avatar server URL for Threema Gateway IDs.
    pub gateway_avatar_server_url: GatewayAvatarBaseServerUrl,

    /// Threema Safe server URL.
    pub safe_server_url: SafeServerBaseUrl,

    /// Multi-device configuration
    pub multi_device: Option<MultiDeviceConfig>,
}
impl OnPremConfig {
    #[rustfmt::skip]
    const SIGNATURE_VERIFICATION_KEYS: [[u8; ed25519::PUBLIC_KEY_LENGTH]; 3] = [
        [
            0x7a, 0x4d, 0x6a, 0x06, 0x9e, 0x03, 0xc9, 0x19,
            0x8b, 0x2f, 0xd2, 0x79, 0xb0, 0x29, 0xac, 0x29,
            0x27, 0xf0, 0x6e, 0xc8, 0x86, 0x34, 0x1e, 0x2f,
            0x78, 0x30, 0x0e, 0x0e, 0x39, 0x30, 0x7b, 0xf9,
        ], [
            0x1e, 0xb9, 0x3c, 0x68, 0x28, 0xf0, 0x2a, 0x45,
            0xf2, 0x4a, 0xe6, 0xc8, 0xec, 0x26, 0x77, 0xcb,
            0xd4, 0xb1, 0xfa, 0x84, 0xe8, 0x10, 0x78, 0xcd,
            0x90, 0x6c, 0x3d, 0xf1, 0x64, 0x91, 0x9d, 0xe5,
        ], [
            0xe6, 0x91, 0x27, 0xd5, 0x3f, 0xf9, 0x6e, 0x17,
            0x9c, 0x35, 0x6a, 0xe9, 0xf4, 0xd8, 0x14, 0x43,
            0x07, 0x91, 0x7e, 0x05, 0x6d, 0xbb, 0xf2, 0x3c,
            0x81, 0x16, 0xf7, 0x57, 0x11, 0x8f, 0xee, 0x4e,
        ],
    ];

    /// Decode the OnPrem configuration from bytes and validate its signature.
    ///
    /// # Errors
    ///
    /// Returns [`OnPremConfigError`] when the configuration could not be decoded or the signature is
    /// invalid.
    pub fn decode_and_verify(config_and_signature: &[u8]) -> Result<Self, OnPremConfigError> {
        Self::verify_with_keys(
            &Self::SIGNATURE_VERIFICATION_KEYS,
            Self::decode(config_and_signature)?,
        )
    }

    fn decode(
        config_and_signature: &[u8],
    ) -> Result<(ed25519::Signature, &str, RawOnPremConfig), OnPremConfigError> {
        // Split configuration from signature
        let (raw_config, signature) = str::from_utf8(config_and_signature)
            .map_err(|error| OnPremConfigError::DecodingFailed(error.to_string()))?
            .trim_end_matches('\n')
            .rsplit_once('\n')
            .ok_or_else(|| {
                OnPremConfigError::DecodingFailed("Unable to split configuration from signature".to_owned())
            })?;

        // Decode signature
        let signature: [u8; ed25519::SIGNATURE_LENGTH] = BASE64
            .decode(signature.as_bytes())
            .map_err(|_| OnPremConfigError::DecodingFailed("Invalid base64 signature".to_owned()))?
            .try_into()
            .map_err(|_| OnPremConfigError::DecodingFailed("Invalid signature length".to_owned()))?;
        let signature = ed25519::Signature::from_bytes(&signature);

        // Decode JSON
        let config: RawOnPremConfig = serde_json::from_slice(raw_config.as_bytes())
            .map_err(|error| OnPremConfigError::DecodingFailed(error.to_string()))?;

        // Done
        Ok((signature, raw_config, config))
    }

    fn verify_with_keys(
        verification_keys: &[[u8; ed25519::PUBLIC_KEY_LENGTH]],
        (signature, raw_config, config): (ed25519::Signature, &str, RawOnPremConfig),
    ) -> Result<Self, OnPremConfigError> {
        // Ensure chosen signature key is valid
        if !verification_keys.contains(&config.signature_key) {
            return Err(OnPremConfigError::SignatureKeyMismatch);
        }
        let signature_key = ed25519::VerifyingKey::from_bytes(&config.signature_key)
            .map_err(|_| OnPremConfigError::DecodingFailed("Invalid signature key".to_owned()))?;

        // Convert
        let config = {
            let multi_device_config = match (config.rendezvous_server, config.mediator_and_blob_mirror_server)
            {
                (Some(rendezvous_server), Some(mediator_and_blob_mirror_server)) => Some(MultiDeviceConfig {
                    rendezvous_server_url: RendezvousServerBaseUrl(rendezvous_server.base_url),
                    mediator_server_url: MediatorServerBaseUrl(
                        mediator_and_blob_mirror_server.mediator_server_base_url,
                    ),
                    blob_mirror_server: BlobMirrorServerConfig {
                        upload_url: BlobMirrorServerUploadUrl(
                            mediator_and_blob_mirror_server.blob_mirror_server.upload_url,
                        ),
                        download_url: BlobMirrorServerDownloadUrl(
                            mediator_and_blob_mirror_server.blob_mirror_server.download_url,
                        ),
                        done_url: BlobMirrorServerDoneUrl(
                            mediator_and_blob_mirror_server.blob_mirror_server.done_url,
                        ),
                    },
                }),
                (None, None) => None,
                _ => {
                    return Err(OnPremConfigError::DecodingFailed(
                        "Incomplete multi-device configuration".to_owned(),
                    ));
                },
            };
            Self {
                version: config.version,
                refresh_interval: Duration::from_secs(config.refresh_interval_s.into()),
                chat_server_address: ChatServerAddress {
                    hostname: config.chat_server.hostname,
                    ports: config.chat_server.ports,
                },
                chat_server_public_keys: vec![PublicKey::from(config.chat_server.public_key)],
                directory_server_url: DirectoryServerBaseUrl(config.directory_server.base_url),
                blob_server: BlobServerConfig {
                    upload_url: BlobServerUploadUrl(config.blob_server.upload_url),
                    download_url: BlobServerDownloadUrl(config.blob_server.download_url),
                    done_url: BlobServerDoneUrl(config.blob_server.done_url),
                },
                work_server_url: WorkServerBaseUrl(config.work_server.base_url),
                gateway_avatar_server_url: GatewayAvatarBaseServerUrl(config.gateway_avatar_server.base_url),
                safe_server_url: SafeServerBaseUrl(config.safe_server.base_url),
                multi_device: multi_device_config,
            }
        };

        // Verify signature
        signature_key
            .verify_strict(raw_config.as_bytes(), &signature)
            .map_err(|_| OnPremConfigError::InvalidSignature)?;

        // Done
        Ok(config)
    }
}

/// Configuration environment.
#[derive(Clone)]
pub enum ConfigEnvironment {
    /// Sandbox environment
    Sandbox,

    /// Production environment
    Production,

    /// OnPrem environment
    OnPrem(Box<OnPremConfig>),
}

/// Configuration.
#[derive(Clone)]
pub struct Config {
    /// Chat server address (for non multi-device/legacy connections) with placeholders.
    pub chat_server_address: ChatServerAddress,

    /// Chat server public keys.
    ///
    /// Note: The first public key is considered primary. All following public keys are fallbacks.
    pub chat_server_public_keys: Vec<PublicKey>,

    /// Directory server URL.
    pub directory_server_url: DirectoryServerBaseUrl,

    /// Blob server configuration
    pub blob_server: BlobServerConfig,

    /// Work server URL.
    pub work_server_url: WorkServerBaseUrl,

    /// Avatar server URL for Threema Gateway IDs.
    pub gateway_avatar_server_url: GatewayAvatarBaseServerUrl,

    /// Threema Safe server URL.
    pub safe_server_url: SafeServerBaseUrl,

    /// Multi-device configuration
    pub multi_device: Option<MultiDeviceConfig>,

    /// Predefined contacts.
    pub predefined_contacts: HashMap<ThreemaId, PredefinedContact>,
}

impl Config {
    pub(crate) fn production() -> Self {
        Self {
            chat_server_address: ChatServerAddress {
                hostname: format!("ds.g-{{{}}}.0.threema.ch", ChatServerAddress::PREFIX_8),
                ports: vec![5222, 443],
            },

            #[rustfmt::skip]
            chat_server_public_keys: vec![
                PublicKey::from([
                    0x45, 0x0b, 0x97, 0x57, 0x35, 0x27, 0x9f, 0xde,
                    0xcb, 0x33, 0x13, 0x64, 0x8f, 0x5f, 0xc6, 0xee,
                    0x9f, 0xf4, 0x36, 0x0e, 0xa9, 0x2a, 0x8c, 0x17,
                    0x51, 0xc6, 0x61, 0xe4, 0xc0, 0xd8, 0xc9, 0x09,
                ]),
                PublicKey::from([
                    0xda, 0x7c, 0x73, 0x79, 0x8f, 0x97, 0xd5, 0x87,
                    0xc3, 0xa2, 0x5e, 0xbe, 0x0a, 0x91, 0x41, 0x7f,
                    0x76, 0xdb, 0xcc, 0xcd, 0xda, 0x29, 0x30, 0xe6,
                    0xa9, 0x09, 0x0a, 0xf6, 0x2e, 0xba, 0x6f, 0x15,
                ]),
            ],

            directory_server_url: DirectoryServerBaseUrl(
                HttpsBaseUrl::try_from("https://ds-apip.threema.ch/".to_owned())
                    .expect("Production Directory Server URL invalid"),
            ),

            blob_server: BlobServerConfig {
                upload_url: BlobServerUploadUrl(
                    HttpsUrl::try_from("https://ds-blobp-upload.threema.ch/upload".to_owned())
                        .expect("Production Blob Server upload URL invalid"),
                ),

                download_url: BlobServerDownloadUrl(
                    HttpsUrl::try_from(format!(
                        "https://ds-blobp-{{{}}}.threema.ch/{{{}}}",
                        blob_id_template_url::PREFIX_8,
                        blob_id_template_url::FULL,
                    ))
                    .expect("Production Blob Server download URL invalid"),
                ),

                done_url: BlobServerDoneUrl(
                    HttpsUrl::try_from(format!(
                        "https://ds-blobp-{{{}}}.threema.ch/{{{}}}/done",
                        blob_id_template_url::PREFIX_8,
                        blob_id_template_url::FULL,
                    ))
                    .expect("Production Blob Server download URL invalid"),
                ),
            },

            work_server_url: WorkServerBaseUrl(
                HttpsBaseUrl::try_from("https://ds-apip-work.threema.ch/".to_owned())
                    .expect("Production Work Server URL invalid"),
            ),

            gateway_avatar_server_url: GatewayAvatarBaseServerUrl(
                HttpsBaseUrl::try_from("https://avatar.threema.ch/".to_owned())
                    .expect("Production Gateway Avatar Server URL invalid"),
            ),

            safe_server_url: SafeServerBaseUrl(
                HttpsBaseUrl::try_from(format!(
                    "https://safe-{{{}}}.threema.ch/",
                    SafeServerBaseUrl::PREFIX_8
                ))
                .expect("Production Safe Server URL invalid"),
            ),

            multi_device: Some(MultiDeviceConfig {
                rendezvous_server_url: RendezvousServerBaseUrl(
                    WssBaseUrl::try_from(format!(
                        "wss://rendezvous-{{{}}}.threema.ch/{{{}}}/",
                        RendezvousServerBaseUrl::PREFIX_4,
                        RendezvousServerBaseUrl::PREFIX_8,
                    ))
                    .expect("Production Rendezvous Server URL invalid"),
                ),

                mediator_server_url: MediatorServerBaseUrl(
                    WssBaseUrl::try_from(format!(
                        "wss://mediator-{{{}}}.threema.ch/{{{}}}/",
                        device_group_id_template_url::PREFIX_4,
                        device_group_id_template_url::PREFIX_8,
                    ))
                    .expect("Production Mediator Server URL invalid"),
                ),

                blob_mirror_server: BlobMirrorServerConfig {
                    upload_url: BlobMirrorServerUploadUrl(
                        HttpsUrl::try_from(format!(
                            "https://blob-mirror-{{{}}}.threema.ch/{{{}}}/upload",
                            device_group_id_template_url::PREFIX_4,
                            device_group_id_template_url::PREFIX_8,
                        ))
                        .expect("Production Blob Mirror Server upload URL invalid"),
                    ),

                    download_url: BlobMirrorServerDownloadUrl(
                        HttpsUrl::try_from(format!(
                            "https://blob-mirror-{{{}}}.threema.ch/{{{}}}/{{{}}}",
                            device_group_id_template_url::PREFIX_4,
                            device_group_id_template_url::PREFIX_8,
                            blob_id_template_url::FULL,
                        ))
                        .expect("Production Blob Server download URL invalid"),
                    ),

                    done_url: BlobMirrorServerDoneUrl(
                        HttpsUrl::try_from(format!(
                            "https://blob-mirror-{{{}}}.threema.ch/{{{}}}/{{{}}}/done",
                            device_group_id_template_url::PREFIX_4,
                            device_group_id_template_url::PREFIX_8,
                            blob_id_template_url::FULL,
                        ))
                        .expect("Production Blob Server download URL invalid"),
                    ),
                },
            }),

            predefined_contacts: PredefinedContact::production(),
        }
    }

    pub(crate) fn sandbox() -> Self {
        Self {
            chat_server_address: ChatServerAddress {
                hostname: format!("ds.g-{{{}}}.0.test.threema.ch", ChatServerAddress::PREFIX_8),
                ports: vec![5222, 443],
            },

            #[rustfmt::skip]
            chat_server_public_keys: vec![PublicKey::from([
                0x5a, 0x98, 0xf2, 0x3d, 0xe6, 0x56, 0x05, 0xd0,
                0x50, 0xdc, 0x00, 0x64, 0xbe, 0x07, 0xdd, 0xdd,
                0x81, 0x1d, 0xa1, 0x16, 0xa5, 0x43, 0xce, 0x43,
                0xaa, 0x26, 0x87, 0xd1, 0x9f, 0x20, 0xaf, 0x3c,
            ])],

            directory_server_url: DirectoryServerBaseUrl(
                HttpsBaseUrl::try_from("https://ds-apip.test.threema.ch/".to_owned())
                    .expect("Sandbox Directory Server URL invalid"),
            ),

            blob_server: BlobServerConfig {
                upload_url: BlobServerUploadUrl(
                    HttpsUrl::try_from("https://ds-blobp-upload.test.threema.ch/upload".to_owned())
                        .expect("Sandbox Blob Server upload URL invalid"),
                ),

                download_url: BlobServerDownloadUrl(
                    HttpsUrl::try_from(format!(
                        "https://ds-blobp-{{{}}}.test.threema.ch/{{{}}}",
                        blob_id_template_url::PREFIX_8,
                        blob_id_template_url::FULL,
                    ))
                    .expect("Sandbox Blob Server download URL invalid"),
                ),

                done_url: BlobServerDoneUrl(
                    HttpsUrl::try_from(format!(
                        "https://ds-blobp-{{{}}}.test.threema.ch/{{{}}}/done",
                        blob_id_template_url::PREFIX_8,
                        blob_id_template_url::FULL,
                    ))
                    .expect("Sandbox Blob Server download URL invalid"),
                ),
            },

            work_server_url: WorkServerBaseUrl(
                HttpsBaseUrl::try_from("https://ds-apip-work.test.threema.ch/".to_owned())
                    .expect("Sandbox Work Server URL invalid"),
            ),

            gateway_avatar_server_url: GatewayAvatarBaseServerUrl(
                HttpsBaseUrl::try_from("https://avatar.test.threema.ch/".to_owned())
                    .expect("Sandbox Gateway Avatar Server URL invalid"),
            ),

            safe_server_url: SafeServerBaseUrl(
                HttpsBaseUrl::try_from(format!(
                    "https://safe-{{{}}}.test.threema.ch/",
                    SafeServerBaseUrl::PREFIX_8
                ))
                .expect("Sandbox Safe Server URL invalid"),
            ),

            multi_device: Some(MultiDeviceConfig {
                rendezvous_server_url: RendezvousServerBaseUrl(
                    WssBaseUrl::try_from(format!(
                        "wss://rendezvous-{{{}}}.test.threema.ch/{{{}}}/",
                        RendezvousServerBaseUrl::PREFIX_4,
                        RendezvousServerBaseUrl::PREFIX_8,
                    ))
                    .expect("Sandbox Rendezvous Server URL invalid"),
                ),

                mediator_server_url: MediatorServerBaseUrl(
                    WssBaseUrl::try_from(format!(
                        "wss://mediator-{{{}}}.test.threema.ch/{{{}}}/",
                        device_group_id_template_url::PREFIX_4,
                        device_group_id_template_url::PREFIX_8,
                    ))
                    .expect("Sandbox Mediator Server URL invalid"),
                ),

                blob_mirror_server: BlobMirrorServerConfig {
                    upload_url: BlobMirrorServerUploadUrl(
                        HttpsUrl::try_from(format!(
                            "https://blob-mirror-{{{}}}.test.threema.ch/{{{}}}/upload",
                            device_group_id_template_url::PREFIX_4,
                            device_group_id_template_url::PREFIX_8,
                        ))
                        .expect("Sandbox Blob Mirror Server upload URL invalid"),
                    ),

                    download_url: BlobMirrorServerDownloadUrl(
                        HttpsUrl::try_from(format!(
                            "https://blob-mirror-{{{}}}.test.threema.ch/{{{}}}/{{{}}}",
                            device_group_id_template_url::PREFIX_4,
                            device_group_id_template_url::PREFIX_8,
                            blob_id_template_url::FULL,
                        ))
                        .expect("Sandbox Blob Server download URL invalid"),
                    ),

                    done_url: BlobMirrorServerDoneUrl(
                        HttpsUrl::try_from(format!(
                            "https://blob-mirror-{{{}}}.test.threema.ch/{{{}}}/{{{}}}/done",
                            device_group_id_template_url::PREFIX_4,
                            device_group_id_template_url::PREFIX_8,
                            blob_id_template_url::FULL,
                        ))
                        .expect("Sandbox Blob Server download URL invalid"),
                    ),
                },
            }),

            predefined_contacts: PredefinedContact::sandbox(),
        }
    }

    #[cfg(test)]
    pub(crate) fn testing() -> Self {
        Self {
            chat_server_address: ChatServerAddress {
                hostname: format!("ds.g-{{{}}}.0.example.threema.ch", ChatServerAddress::PREFIX_8),
                ports: vec![5222, 443],
            },

            #[rustfmt::skip]
            chat_server_public_keys: vec![PublicKey::from([
                0x5a, 0x98, 0xf2, 0x3d, 0xe6, 0x56, 0x05, 0xd0,
                0x50, 0xdc, 0x00, 0x64, 0xbe, 0x07, 0xdd, 0xdd,
                0x81, 0x1d, 0xa1, 0x16, 0xa5, 0x43, 0xce, 0x43,
                0xaa, 0x26, 0x87, 0xd1, 0x9f, 0x20, 0xaf, 0x3c,
            ])],

            directory_server_url: DirectoryServerBaseUrl(
                HttpsBaseUrl::try_from("https://ds-apip.example.threema.ch/".to_owned())
                    .expect("Testing Directory Server URL invalid"),
            ),

            blob_server: BlobServerConfig {
                upload_url: BlobServerUploadUrl(
                    HttpsUrl::try_from("https://ds-blobp-upload.example.threema.ch/upload".to_owned())
                        .expect("Testing Blob Server upload URL invalid"),
                ),

                download_url: BlobServerDownloadUrl(
                    HttpsUrl::try_from(format!(
                        "https://ds-blobp-{{{}}}.example.threema.ch/{{{}}}",
                        blob_id_template_url::PREFIX_8,
                        blob_id_template_url::FULL,
                    ))
                    .expect("Testing Blob Server download URL invalid"),
                ),

                done_url: BlobServerDoneUrl(
                    HttpsUrl::try_from(format!(
                        "https://ds-blobp-{{{}}}.example.threema.ch/{{{}}}/done",
                        blob_id_template_url::PREFIX_8,
                        blob_id_template_url::FULL,
                    ))
                    .expect("Testing Blob Server download URL invalid"),
                ),
            },

            work_server_url: WorkServerBaseUrl(
                HttpsBaseUrl::try_from("https://ds-apip-work.example.threema.ch/".to_owned())
                    .expect("Testing Work Server URL invalid"),
            ),

            gateway_avatar_server_url: GatewayAvatarBaseServerUrl(
                HttpsBaseUrl::try_from("https://avatar.example.threema.ch/".to_owned())
                    .expect("Testing Gateway Avatar Server URL invalid"),
            ),

            safe_server_url: SafeServerBaseUrl(
                HttpsBaseUrl::try_from(format!(
                    "https://safe-{{{}}}.example.threema.ch/",
                    SafeServerBaseUrl::PREFIX_8
                ))
                .expect("Testing Safe Server URL invalid"),
            ),

            multi_device: Some(MultiDeviceConfig {
                rendezvous_server_url: RendezvousServerBaseUrl(
                    WssBaseUrl::try_from(format!(
                        "wss://rendezvous-{{{}}}.example.threema.ch/{{{}}}/",
                        RendezvousServerBaseUrl::PREFIX_4,
                        RendezvousServerBaseUrl::PREFIX_8,
                    ))
                    .expect("Testing Rendezvous Server URL invalid"),
                ),

                mediator_server_url: MediatorServerBaseUrl(
                    WssBaseUrl::try_from(format!(
                        "wss://mediator-{{{}}}.example.threema.ch/{{{}}}/",
                        device_group_id_template_url::PREFIX_4,
                        device_group_id_template_url::PREFIX_8,
                    ))
                    .expect("Testing Mediator Server URL invalid"),
                ),

                blob_mirror_server: BlobMirrorServerConfig {
                    upload_url: BlobMirrorServerUploadUrl(
                        HttpsUrl::try_from(format!(
                            "https://blob-mirror-{{{}}}.example.threema.ch/{{{}}}/upload",
                            device_group_id_template_url::PREFIX_4,
                            device_group_id_template_url::PREFIX_8,
                        ))
                        .expect("Testing Blob Mirror Server upload URL invalid"),
                    ),

                    download_url: BlobMirrorServerDownloadUrl(
                        HttpsUrl::try_from(format!(
                            "https://blob-mirror-{{{}}}.example.threema.ch/{{{}}}/{{{}}}",
                            device_group_id_template_url::PREFIX_4,
                            device_group_id_template_url::PREFIX_8,
                            blob_id_template_url::FULL,
                        ))
                        .expect("Testing Blob Server download URL invalid"),
                    ),

                    done_url: BlobMirrorServerDoneUrl(
                        HttpsUrl::try_from(format!(
                            "https://blob-mirror-{{{}}}.example.threema.ch/{{{}}}/{{{}}}/done",
                            device_group_id_template_url::PREFIX_4,
                            device_group_id_template_url::PREFIX_8,
                            blob_id_template_url::FULL,
                        ))
                        .expect("Testing Blob Server download URL invalid"),
                    ),
                },
            }),

            predefined_contacts: HashMap::new(),
        }
    }
}
impl From<OnPremConfig> for Config {
    fn from(config: OnPremConfig) -> Self {
        Self {
            chat_server_address: config.chat_server_address,
            chat_server_public_keys: config.chat_server_public_keys,
            directory_server_url: config.directory_server_url,
            blob_server: config.blob_server,
            work_server_url: config.work_server_url,
            gateway_avatar_server_url: config.gateway_avatar_server_url,
            safe_server_url: config.safe_server_url,
            multi_device: config.multi_device,
            predefined_contacts: HashMap::default(),
        }
    }
}
impl From<ConfigEnvironment> for Config {
    fn from(environment: ConfigEnvironment) -> Self {
        match environment {
            ConfigEnvironment::Sandbox => Self::sandbox(),
            ConfigEnvironment::Production => Self::production(),
            ConfigEnvironment::OnPrem(config) => Config::from(*config),
        }
    }
}

/// Implementations for the CLI
#[cfg(feature = "cli")]
pub mod cli {
    use anyhow::bail;

    use crate::{
        common::{ClientInfo, config::OnPremLicense},
        https,
    };

    impl OnPremLicense {
        pub(crate) async fn download_configuration(
            &self,
            http_client: &reqwest::Client,
        ) -> anyhow::Result<Vec<u8>> {
            let request = https::HttpsRequest {
                timeout: https::endpoint::TIMEOUT,
                url: self.config_url.as_str().to_owned(),
                method: https::HttpsMethod::Get,
                headers: https::endpoint::https_headers_with_authentication(&self.work_context)
                    .accept("application/json")
                    .build(&ClientInfo::Libthreema),
                body: vec![],
            };
            let response = request.send(http_client).await?;
            if response.status != 200 {
                bail!(https::endpoint::HttpsEndpointError::UnexpectedStatus(
                    response.status,
                ));
            }
            Ok(response.body)
        }
    }
}

#[cfg(test)]
mod tests {
    use assert_matches::assert_matches;
    use rstest::rstest;

    use super::*;

    mod on_prem {
        use super::*;

        // The corresponding private key is: 7b30ca0627bde879eedfd8293368a2218c1f13a7115f338ddf62bf29b2eeb189
        #[rustfmt::skip]
        const VALID_SIGNATURE_VERIFICATION_KEYS: [[u8; ed25519::PUBLIC_KEY_LENGTH]; 1] = [[
            0x8d, 0xa7, 0xb5, 0x96, 0x0c, 0x11, 0xdd, 0x6e,
            0xd8, 0xc8, 0xa8, 0x86, 0x42, 0x5b, 0x1b, 0x76,
            0xa3, 0x9b, 0x1b, 0x5d, 0xc5, 0x47, 0x51, 0x2f,
            0x8d, 0x57, 0x22, 0xd9, 0xa0, 0xcd, 0x22, 0x2f,
        ]];

        #[rustfmt::skip]
        const INVALID_SIGNATURE_VERIFICATION_KEYS: [[u8; ed25519::PUBLIC_KEY_LENGTH]; 1] = [[
            0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
            0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
            0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
            0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
        ]];

        #[rstest]
        #[case("", UrlError::InvalidUrl("Invalid format"))]
        #[case("dflgjhdshlki", UrlError::InvalidUrl("Invalid format"))]
        #[case(
            "threemaonprem://license&username=a&password=b&server=https%3A%2F%2Ffoo",
            UrlError::InvalidUrl("Invalid format")
        )]
        #[case(
            "dreimaonprem://license?username=a&password=b&server=https%3A%2F%2Ffoo",
            UrlError::UnexpectedScheme { expected: "threemaonprem", actual: "dreimaonprem".to_owned() }
        )]
        #[case(
            "threemaonprem://license?password=b&server=https%3A%2F%2Ffoo",
            UrlError::InvalidUrl("Missing username")
        )]
        #[case(
            "threemaonprem://license?username=a&server=https%3A%2F%2Ffoo",
            UrlError::InvalidUrl("Missing password")
        )]
        #[case(
            "threemaonprem://license?username=a&password=b",
            UrlError::InvalidUrl("Missing configuration URL")
        )]
        #[case(
            "threemaonprem://license?username=a&password=b&server=http%3A%2F%2Ffoo",
            UrlError::UnexpectedScheme { expected: "https", actual: "http".to_owned() }
        )]
        fn license_url_invalid(#[case] url: &'static str, #[case] error: UrlError) {
            let result: Result<OnPremLicense, UrlError> = Err(error);
            assert_eq!(OnPremLicense::from_url(url), result);
        }

        #[rstest]
        #[case(
            "threemaonprem://license?username=n%C3%B6&password=%C3%A4%C3%B6%C3%BC%21%C2%A7%24%25%26%2F%28%\
             29%3D%3F&server=https%3A%2F%2Fonprem.example.threema.ch",
            "truncated configuration URL"
        )]
        #[case(
            "threemaonprem://license?username=n%C3%B6&password=%C3%A4%C3%B6%C3%BC%21%C2%A7%24%25%26%2F%28%\
             29%3D%3F&server=https%3A%2F%2Fonprem.example.threema.ch%2F",
            "truncated configuration URL (with non-conformant trailing slash)"
        )]
        #[case(
            "threemaonprem://license?username=n%C3%B6&password=%C3%A4%C3%B6%C3%BC%21%C2%A7%24%25%26%2F%28%\
             29%3D%3F&server=https%3A%2F%2Fonprem.example.threema.ch%2Fprov%2Fconfig.oppf",
            "explicit configuration URL"
        )]
        fn license_url_valid(#[case] url: &'static str, #[case] description: &'static str) {
            assert_eq!(
                OnPremLicense::from_url(url).unwrap(),
                OnPremLicense {
                    config_url: OnPremConfigUrl::try_from(
                        "https://onprem.example.threema.ch/prov/config.oppf".to_owned(),
                    )
                    .unwrap(),
                    work_context: WorkContext {
                        credentials: WorkCredentials {
                            username: "nö".to_owned(),
                            password: "äöü!§$%&/()=?".to_owned(),
                        },
                        flavor: WorkFlavor::OnPrem,
                    },
                },
                "with {description}",
            );
        }

        #[test]
        fn configuration_unknown_signature_key() {
            assert_matches!(
                OnPremConfig::verify_with_keys(
                    &INVALID_SIGNATURE_VERIFICATION_KEYS,
                    OnPremConfig::decode(include_bytes!("../../resources/test/on-prem/minimal.oppf"))
                        .unwrap(),
                ),
                Err(OnPremConfigError::SignatureKeyMismatch)
            );
        }

        #[test]
        fn configuration_invalid_signature() {
            let (_, raw_configuration, configuration) =
                OnPremConfig::decode(include_bytes!("../../resources/test/on-prem/minimal.oppf")).unwrap();
            let invalid_signature = ed25519::Signature::from_bytes(&[0_u8; ed25519::SIGNATURE_LENGTH]);
            assert_matches!(
                OnPremConfig::verify_with_keys(
                    &VALID_SIGNATURE_VERIFICATION_KEYS,
                    (invalid_signature, raw_configuration, configuration),
                ),
                Err(OnPremConfigError::InvalidSignature)
            );
        }

        #[test]
        fn minimal_configuration_valid() -> anyhow::Result<()> {
            let config = OnPremConfig::verify_with_keys(
                &VALID_SIGNATURE_VERIFICATION_KEYS,
                OnPremConfig::decode(include_bytes!("../../resources/test/on-prem/minimal.oppf"))?,
            )?;
            assert_eq!(
                config,
                OnPremConfig {
                    version: OnPremConfigVersion::V1_0,
                    refresh_interval: Duration::from_hours(24),
                    chat_server_address: ChatServerAddress {
                        hostname: "chat.onprem.example.threema.ch".to_owned(),
                        ports: vec![5222, 443]
                    },
                    chat_server_public_keys: vec![PublicKey::from_hex(
                        "afdbad20737d9e0a36d6af4e959728b6c42ed5fd87c005b65a2faee8fb29e167"
                    )?],
                    directory_server_url: DirectoryServerBaseUrl::try_from(
                        "https://onprem.example.threema.ch/directory/".to_owned()
                    )?,
                    blob_server: BlobServerConfig {
                        upload_url: BlobServerUploadUrl::try_from(
                            "https://blob.onprem.example.threema.ch/blob/upload".to_owned()
                        )?,
                        download_url: BlobServerDownloadUrl::try_from(
                            "https://blob-{blobIdPrefix}.onprem.example.threema.ch/blob/{blobId}".to_owned()
                        )?,
                        done_url: BlobServerDoneUrl::try_from(
                            "https://blob-{blobIdPrefix}.onprem.example.threema.ch/blob/{blobId}/done"
                                .to_owned()
                        )?,
                    },
                    work_server_url: WorkServerBaseUrl::try_from(
                        "https://work.onprem.example.threema.ch/".to_owned()
                    )?,
                    gateway_avatar_server_url: GatewayAvatarBaseServerUrl::try_from(
                        "https://avatar.onprem.example.threema.ch/".to_owned()
                    )?,
                    safe_server_url: SafeServerBaseUrl::try_from(
                        "https://safe.onprem.example.threema.ch/".to_owned()
                    )?,
                    multi_device: None,
                }
            );
            Ok(())
        }

        #[test]
        fn full_configuration_valid() -> anyhow::Result<()> {
            let config = OnPremConfig::verify_with_keys(
                &VALID_SIGNATURE_VERIFICATION_KEYS,
                OnPremConfig::decode(include_bytes!("../../resources/test/on-prem/full.oppf"))?,
            )?;
            assert_eq!(
                config,
                OnPremConfig {
                    version: OnPremConfigVersion::V1_0,
                    refresh_interval: Duration::from_hours(24),
                    chat_server_address: ChatServerAddress {
                        hostname: "chat.onprem.example.threema.ch".to_owned(),
                        ports: vec![5222, 443]
                    },
                    chat_server_public_keys: vec![PublicKey::from_hex(
                        "afdbad20737d9e0a36d6af4e959728b6c42ed5fd87c005b65a2faee8fb29e167"
                    )?],
                    directory_server_url: DirectoryServerBaseUrl::try_from(
                        "https://onprem.example.threema.ch/directory/".to_owned()
                    )?,
                    blob_server: BlobServerConfig {
                        upload_url: BlobServerUploadUrl::try_from(
                            "https://blob.onprem.example.threema.ch/blob/upload".to_owned()
                        )?,
                        download_url: BlobServerDownloadUrl::try_from(
                            "https://blob-{blobIdPrefix}.onprem.example.threema.ch/blob/{blobId}".to_owned()
                        )?,
                        done_url: BlobServerDoneUrl::try_from(
                            "https://blob-{blobIdPrefix}.onprem.example.threema.ch/blob/{blobId}/done"
                                .to_owned()
                        )?,
                    },
                    work_server_url: WorkServerBaseUrl::try_from(
                        "https://work.onprem.example.threema.ch/".to_owned()
                    )?,
                    gateway_avatar_server_url: GatewayAvatarBaseServerUrl::try_from(
                        "https://avatar.onprem.example.threema.ch/".to_owned()
                    )?,
                    safe_server_url: SafeServerBaseUrl::try_from(
                        "https://safe.onprem.example.threema.ch/".to_owned()
                    )?,
                    multi_device: Some(MultiDeviceConfig {
                        rendezvous_server_url: RendezvousServerBaseUrl::try_from(
                            "wss://rendezvous.onprem.example.threema.ch/".to_owned()
                        )?,
                        mediator_server_url: MediatorServerBaseUrl::try_from(
                            "wss://mediator.onprem.example.threema.ch/".to_owned()
                        )?,
                        blob_mirror_server: BlobMirrorServerConfig {
                            upload_url: BlobMirrorServerUploadUrl::try_from(
                                "https://blob-mirror.onprem.example.threema.ch/blob/upload".to_owned()
                            )?,
                            download_url: BlobMirrorServerDownloadUrl::try_from(
                                "https://blob-mirror.onprem.example.threema.ch/blob/{blobId}".to_owned()
                            )?,
                            done_url: BlobMirrorServerDoneUrl::try_from(
                                "https://blob-mirror.onprem.example.threema.ch/blob/{blobId}/done".to_owned()
                            )?,
                        },
                    }),
                }
            );
            Ok(())
        }
    }
}

[Dauer der Verarbeitung: 0.39 Sekunden, vorverarbeitet 2026-04-27]

                                                                                                                                                                                                                                                                                                                                                                                                     


Neuigkeiten

     Aktuelles
     Motto des Tages

Software

     Produkte
     Quellcodebibliothek

Aktivitäten

     Artikel über Sicherheit
     Anleitung zur Aktivierung von SSL

Muße

     Gedichte
     Musik
     Bilder

Jenseits des Üblichen ....
    

Besucherstatistik

Besucherstatistik

Monitoring

Montastic status badge