Skip to main content

mas_config/sections/
room_invites.rs

1// Copyright 2026 Element Creations Ltd.
2//
3// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
4// Please see LICENSE files in the repository root for full details.
5
6use schemars::JsonSchema;
7use serde::{Deserialize, Serialize, de::Error};
8use url::Url;
9
10use crate::ConfigurationSection;
11
12/// Configuration section for inviting email addresses to rooms
13#[derive(Clone, Debug, Default, Deserialize, JsonSchema, Serialize)]
14pub struct RoomInvitesConfig {
15    /// The client URL to send invitees to once they are signed in, with
16    /// `{room_id}` standing for the room ID, e.g.
17    /// `https://app.element.io/#/room/{room_id}`.
18    #[serde(skip_serializing_if = "Option::is_none")]
19    pub client_room_url: Option<String>,
20}
21
22impl RoomInvitesConfig {
23    /// Returns true if the configuration is the default one
24    pub(crate) fn is_default(&self) -> bool {
25        self.client_room_url.is_none()
26    }
27}
28
29impl ConfigurationSection for RoomInvitesConfig {
30    const PATH: Option<&'static str> = Some("room_invites");
31
32    fn validate(
33        &self,
34        figment: &figment::Figment,
35    ) -> Result<(), Box<dyn std::error::Error + Send + Sync + 'static>> {
36        let metadata = figment.find_metadata(Self::PATH.unwrap());
37
38        let error = |message: &str| -> Box<dyn std::error::Error + Send + Sync + 'static> {
39            let mut error = figment::error::Error::custom(message);
40            error.metadata = metadata.cloned();
41            error.profile = Some(figment::Profile::Default);
42            error.path = vec![Self::PATH.unwrap().to_owned(), "client_room_url".to_owned()];
43            error.into()
44        };
45
46        match &self.client_room_url {
47            Some(url) if !url.contains("{room_id}") => {
48                return Err(error("must contain `{room_id}`"));
49            }
50            Some(url)
51                if !Url::parse(&url.replace("{room_id}", "room")).is_ok_and(|url| {
52                    matches!(url.scheme(), "http" | "https") && url.has_host()
53                }) =>
54            {
55                return Err(error("must be an absolute HTTP or HTTPS URL"));
56            }
57            _ => {}
58        }
59
60        Ok(())
61    }
62}
63
64#[cfg(test)]
65mod tests {
66    // The closures passed to `Jail::expect_with` return `figment::Error`, which is
67    // large, and we can't change figment's API.
68    #![expect(clippy::result_large_err)]
69
70    use figment::{
71        Figment, Jail,
72        providers::{Format, Yaml},
73    };
74
75    use super::*;
76
77    fn load(jail: &mut Jail, yaml: &str) -> Result<RoomInvitesConfig, String> {
78        jail.create_file("config.yaml", yaml)
79            .map_err(|e| e.to_string())?;
80        let figment = Figment::new().merge(Yaml::file("config.yaml"));
81        RoomInvitesConfig::extract(&figment).map_err(|e| e.to_string())
82    }
83
84    #[test]
85    fn defaults() {
86        Jail::expect_with(|jail| {
87            let config = load(jail, "room_invites: {}").unwrap();
88            assert_eq!(config.client_room_url, None);
89            assert!(config.is_default());
90            Ok(())
91        });
92    }
93
94    #[test]
95    fn client_room_url_requires_room_id() {
96        Jail::expect_with(|jail| {
97            let error = load(
98                jail,
99                "room_invites: { client_room_url: 'https://app.example.com/' }",
100            )
101            .unwrap_err();
102            assert!(error.contains("{room_id}"), "{error}");
103            Ok(())
104        });
105    }
106
107    #[test]
108    fn client_room_url_must_be_absolute() {
109        Jail::expect_with(|jail| {
110            for url in [
111                "app.example.com/#/room/{room_id}",
112                "localhost:8080/#/room/{room_id}",
113                "ftp://app.example.com/#/room/{room_id}",
114                "https:/#/room/{room_id}",
115            ] {
116                let error = load(
117                    jail,
118                    &format!("room_invites: {{ client_room_url: '{url}' }}"),
119                )
120                .unwrap_err();
121                assert!(
122                    error.contains("absolute HTTP or HTTPS URL"),
123                    "{url}: {error}"
124                );
125            }
126            Ok(())
127        });
128    }
129}