Skip to main content

shared/settings/
app.rs

1use crate::prelude::StringExt;
2
3use super::{
4    ExtensionSettings, SettingsDeserializeExt, SettingsDeserializer, SettingsSerializeExt,
5    SettingsSerializer,
6};
7use compact_str::ToCompactString;
8use serde::{Deserialize, Serialize};
9use utoipa::ToSchema;
10
11#[derive(ToSchema, Serialize, Deserialize, Clone, Copy)]
12#[serde(rename_all = "snake_case")]
13pub enum TwoFactorRequirement {
14    Admins,
15    AllUsers,
16    None,
17}
18
19#[derive(ToSchema, Serialize, Deserialize, Clone, Copy, PartialEq, Eq)]
20#[serde(rename_all = "snake_case")]
21pub enum TwoFactorMethod {
22    Totp,
23    SecurityKey,
24    Email,
25}
26
27impl TwoFactorMethod {
28    pub const ALL: &'static [TwoFactorMethod] = &[
29        TwoFactorMethod::Totp,
30        TwoFactorMethod::SecurityKey,
31        TwoFactorMethod::Email,
32    ];
33    pub const DEFAULT_ACCEPTED: &'static [TwoFactorMethod] =
34        &[TwoFactorMethod::Totp, TwoFactorMethod::SecurityKey];
35}
36
37#[derive(Clone, ToSchema, Serialize, Deserialize)]
38pub struct AppSettingsApp {
39    pub name: compact_str::CompactString,
40    pub icon: compact_str::CompactString,
41    pub icon_light: Option<compact_str::CompactString>,
42    pub banner: Option<compact_str::CompactString>,
43    pub banner_light: Option<compact_str::CompactString>,
44    pub url: compact_str::CompactString,
45    pub additional_urls: Vec<compact_str::CompactString>,
46    pub language: compact_str::CompactString,
47    pub two_factor_requirement: TwoFactorRequirement,
48    pub email_two_factor_enabled: bool,
49    pub two_factor_accepted_methods: Vec<TwoFactorMethod>,
50
51    pub email_verification_required: bool,
52
53    pub session_cookie: compact_str::CompactString,
54    pub session_duration_seconds: u64,
55
56    pub telemetry_enabled: bool,
57    pub registration_enabled: bool,
58    pub password_login_enabled: bool,
59}
60
61impl AppSettingsApp {
62    pub fn urls(&self) -> impl Iterator<Item = &str> {
63        std::iter::once(&*self.url).chain(self.additional_urls.iter().map(|url| &**url))
64    }
65
66    /// Resolves the configured url whose authority matches the request host, falling back to
67    /// the primary url. Only ever returns configured values, so a spoofed host cannot leak into
68    /// generated links.
69    pub fn url_for_host(&self, host: Option<&str>) -> &str {
70        let Some((host, port)) = host.and_then(split_host_port) else {
71            return &self.url;
72        };
73
74        self.urls()
75            .find(|url| {
76                reqwest::Url::parse(url).is_ok_and(|url| {
77                    url.host_str()
78                        .is_some_and(|url_host| url_host.eq_ignore_ascii_case(host))
79                        && port.or_else(|| default_port(url.scheme()))
80                            == url.port_or_known_default()
81                })
82            })
83            .unwrap_or(&self.url)
84    }
85}
86
87fn default_port(scheme: &str) -> Option<u16> {
88    match scheme {
89        "http" => Some(80),
90        "https" => Some(443),
91        _ => None,
92    }
93}
94
95fn split_host_port(authority: &str) -> Option<(&str, Option<u16>)> {
96    let (host, port) = if authority.starts_with('[') {
97        let end = authority.find(']')?;
98        (&authority[..=end], authority[end + 1..].strip_prefix(':'))
99    } else {
100        match authority.rsplit_once(':') {
101            Some((host, port)) => (host, Some(port)),
102            None => (authority, None),
103        }
104    };
105
106    let port = match port {
107        Some(port) => Some(port.parse().ok()?),
108        None => None,
109    };
110
111    Some((host, port))
112}
113
114#[async_trait::async_trait]
115impl SettingsSerializeExt for AppSettingsApp {
116    async fn serialize(
117        &self,
118        serializer: SettingsSerializer,
119    ) -> Result<SettingsSerializer, anyhow::Error> {
120        Ok(serializer
121            .write_raw_setting("name", &*self.name)
122            .write_raw_setting("icon", &*self.icon)
123            .write_raw_setting("icon_light", self.icon_light.as_deref().unwrap_or(""))
124            .write_raw_setting("banner", self.banner.as_deref().unwrap_or(""))
125            .write_raw_setting("banner_light", self.banner_light.as_deref().unwrap_or(""))
126            .write_raw_setting("url", &*self.url)
127            .write_serde_setting("additional_urls", &self.additional_urls)?
128            .write_raw_setting("language", &*self.language)
129            .write_raw_setting(
130                "two_factor_requirement",
131                match self.two_factor_requirement {
132                    TwoFactorRequirement::Admins => "admins",
133                    TwoFactorRequirement::AllUsers => "all_users",
134                    TwoFactorRequirement::None => "none",
135                },
136            )
137            .write_raw_setting(
138                "email_two_factor_enabled",
139                self.email_two_factor_enabled.to_compact_string(),
140            )
141            .write_serde_setting(
142                "two_factor_accepted_methods",
143                &self.two_factor_accepted_methods,
144            )?
145            .write_raw_setting(
146                "email_verification_required",
147                self.email_verification_required.to_compact_string(),
148            )
149            .write_raw_setting("session_cookie", &*self.session_cookie)
150            .write_raw_setting(
151                "session_duration_seconds",
152                self.session_duration_seconds.to_compact_string(),
153            )
154            .write_raw_setting(
155                "telemetry_enabled",
156                self.telemetry_enabled.to_compact_string(),
157            )
158            .write_raw_setting(
159                "registration_enabled",
160                self.registration_enabled.to_compact_string(),
161            )
162            .write_raw_setting(
163                "password_login_enabled",
164                self.password_login_enabled.to_compact_string(),
165            ))
166    }
167}
168
169pub struct AppSettingsAppDeserializer;
170
171#[async_trait::async_trait]
172impl SettingsDeserializeExt for AppSettingsAppDeserializer {
173    async fn deserialize_boxed(
174        &self,
175        mut deserializer: SettingsDeserializer<'_>,
176    ) -> Result<ExtensionSettings, anyhow::Error> {
177        Ok(Box::new(AppSettingsApp {
178            name: deserializer
179                .take_raw_setting("name")
180                .unwrap_or_else(|| "Calagopus".into()),
181            icon: deserializer
182                .take_raw_setting("icon")
183                .unwrap_or_else(|| "/icon.svg".into()),
184            icon_light: deserializer
185                .take_raw_setting("icon_light")
186                .and_then(|s| s.into_optional()),
187            banner: deserializer
188                .take_raw_setting("banner")
189                .and_then(|s| s.into_optional()),
190            banner_light: deserializer
191                .take_raw_setting("banner_light")
192                .and_then(|s| s.into_optional()),
193            url: deserializer
194                .take_raw_setting("url")
195                .unwrap_or_else(|| "http://localhost:8000".into()),
196            additional_urls: deserializer
197                .read_serde_setting("additional_urls")
198                .unwrap_or_default(),
199            language: deserializer
200                .take_raw_setting("language")
201                .unwrap_or_else(|| "en".into()),
202            two_factor_requirement: match deserializer
203                .take_raw_setting("two_factor_requirement")
204                .as_deref()
205            {
206                Some("admins") => TwoFactorRequirement::Admins,
207                Some("all_users") => TwoFactorRequirement::AllUsers,
208                _ => TwoFactorRequirement::None,
209            },
210            email_two_factor_enabled: deserializer
211                .take_raw_setting("email_two_factor_enabled")
212                .map(|s| s == "true")
213                .unwrap_or(false),
214            two_factor_accepted_methods: deserializer
215                .read_serde_setting("two_factor_accepted_methods")
216                .unwrap_or_else(|_| TwoFactorMethod::DEFAULT_ACCEPTED.to_vec()),
217            email_verification_required: deserializer
218                .take_raw_setting("email_verification_required")
219                .map(|s| s == "true")
220                .unwrap_or(false),
221            session_cookie: deserializer
222                .take_raw_setting("session_cookie")
223                .unwrap_or_else(|| "calagopus_session".into()),
224            session_duration_seconds: deserializer
225                .take_raw_setting("session_duration_seconds")
226                .and_then(|s| s.parse::<u64>().ok())
227                .unwrap_or(7 * 24 * 3600),
228            telemetry_enabled: deserializer
229                .take_raw_setting("telemetry_enabled")
230                .map(|s| s == "true")
231                .unwrap_or(true),
232            registration_enabled: deserializer
233                .take_raw_setting("registration_enabled")
234                .map(|s| s == "true")
235                .unwrap_or(true),
236            password_login_enabled: deserializer
237                .take_raw_setting("password_login_enabled")
238                .map(|s| s == "true")
239                .unwrap_or(true),
240        }))
241    }
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247
248    fn app(url: &str, additional: &[&str]) -> AppSettingsApp {
249        AppSettingsApp {
250            name: "Panel".into(),
251            icon: "/icon.svg".into(),
252            icon_light: None,
253            banner: None,
254            banner_light: None,
255            url: url.into(),
256            additional_urls: additional.iter().map(|u| (*u).into()).collect(),
257            language: "en".into(),
258            two_factor_requirement: TwoFactorRequirement::None,
259            email_two_factor_enabled: false,
260            two_factor_accepted_methods: Vec::new(),
261            email_verification_required: false,
262            session_cookie: "session".into(),
263            session_duration_seconds: 3600,
264            telemetry_enabled: false,
265            registration_enabled: true,
266            password_login_enabled: true,
267        }
268    }
269
270    // AppSettingsApp::urls
271    #[test]
272    fn urls_yields_primary_then_additional_in_order() {
273        let settings = app("https://a.com", &["https://b.com", "https://c.com"]);
274
275        assert_eq!(
276            settings.urls().collect::<Vec<_>>(),
277            ["https://a.com", "https://b.com", "https://c.com"]
278        );
279    }
280
281    // AppSettingsApp::url_for_host
282    #[test]
283    fn url_for_host_matches_additional_url() {
284        let settings = app("https://a.com", &["https://b.com"]);
285
286        assert_eq!(settings.url_for_host(Some("b.com")), "https://b.com");
287    }
288
289    #[test]
290    fn url_for_host_applies_default_port_when_url_omits_it() {
291        let settings = app("https://a.com", &["https://b.com"]);
292
293        assert_eq!(settings.url_for_host(Some("b.com:443")), "https://b.com");
294    }
295
296    #[test]
297    fn url_for_host_applies_default_port_when_host_omits_it() {
298        let settings = app("https://a.com", &["https://b.com:8443"]);
299
300        assert_eq!(settings.url_for_host(Some("b.com")), "https://a.com");
301    }
302
303    #[test]
304    fn url_for_host_rejects_explicit_port_mismatch() {
305        let settings = app("https://a.com", &["http://b.com:8080"]);
306
307        assert_eq!(settings.url_for_host(Some("b.com:8081")), "https://a.com");
308        assert_eq!(
309            settings.url_for_host(Some("b.com:8080")),
310            "http://b.com:8080"
311        );
312    }
313
314    #[test]
315    fn url_for_host_compares_hosts_case_insensitively() {
316        let settings = app("https://a.com", &["https://Panel.Example.COM"]);
317
318        assert_eq!(
319            settings.url_for_host(Some("panel.example.com")),
320            "https://Panel.Example.COM"
321        );
322    }
323
324    #[test]
325    fn url_for_host_matches_bracketed_ipv6_host() {
326        let settings = app("https://a.com", &["http://[::1]:8000"]);
327
328        assert_eq!(
329            settings.url_for_host(Some("[::1]:8000")),
330            "http://[::1]:8000"
331        );
332    }
333
334    #[test]
335    fn url_for_host_picks_first_configured_when_schemes_share_host() {
336        let settings = app(
337            "https://a.com",
338            &["http://b.com:8000", "https://b.com:8000"],
339        );
340
341        assert_eq!(
342            settings.url_for_host(Some("b.com:8000")),
343            "http://b.com:8000"
344        );
345    }
346
347    #[test]
348    fn url_for_host_falls_back_to_primary_without_match() {
349        let settings = app("https://a.com", &["https://b.com"]);
350
351        assert_eq!(settings.url_for_host(None), "https://a.com");
352        assert_eq!(settings.url_for_host(Some("evil.com")), "https://a.com");
353        assert_eq!(
354            settings.url_for_host(Some("b.com:notaport")),
355            "https://a.com"
356        );
357        assert_eq!(settings.url_for_host(Some("b.com:99999")), "https://a.com");
358    }
359
360    #[test]
361    fn url_for_host_matches_configured_url_with_trailing_slash() {
362        let settings = app("https://a.com", &["https://b.com/"]);
363
364        assert_eq!(settings.url_for_host(Some("b.com")), "https://b.com/");
365    }
366}