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 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 #[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 #[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}