Skip to main content

shared/settings/
mod.rs

1use crate::{
2    cap::CapFilesystem,
3    censor::{CENSORED_PLACEHOLDER, Censor},
4    extensions::settings::{
5        ExtensionSettings, ExtensionSettingsDeserializer, SettingsDeserializeExt,
6        SettingsDeserializer, SettingsSerializeExt, SettingsSerializer,
7    },
8    prelude::{AsyncOptionExt, StringExt},
9};
10use compact_str::ToCompactString;
11use garde::Validate;
12use serde::{Deserialize, Serialize};
13use std::{
14    collections::{BTreeMap, HashMap},
15    ops::{Deref, DerefMut},
16    path::Path,
17    str::FromStr,
18    sync::{
19        Arc, LazyLock,
20        atomic::{AtomicUsize, Ordering},
21    },
22};
23use tokio::sync::{RwLock, RwLockReadGuard, RwLockWriteGuard, Semaphore, SemaphorePermit};
24use utoipa::ToSchema;
25
26pub mod activity;
27pub mod app;
28pub mod metadata;
29pub mod ratelimits;
30pub mod server;
31pub mod user;
32pub mod webauthn;
33
34pub const OOBE_STEPS: &[&str] = &[
35    "register",
36    "configuration",
37    "repositories",
38    "location",
39    "node",
40    "nodeconfiguration",
41    "server",
42    "finished",
43];
44
45#[derive(ToSchema, Serialize, Deserialize, Clone)]
46#[serde(tag = "type", rename_all = "snake_case")]
47pub enum RouteOrderItem {
48    Route {
49        path: compact_str::CompactString,
50    },
51    Divider {
52        name: Option<compact_str::CompactString>,
53        name_translations: BTreeMap<compact_str::CompactString, compact_str::CompactString>,
54    },
55    Redirect {
56        name: compact_str::CompactString,
57        name_translations: BTreeMap<compact_str::CompactString, compact_str::CompactString>,
58        destination: compact_str::CompactString,
59    },
60}
61
62#[derive(ToSchema, Validate, Serialize, Deserialize, Clone, Copy)]
63#[serde(rename_all = "snake_case")]
64pub enum TlsMode {
65    None,
66    StartTls,
67    ImplicitTls,
68}
69
70#[derive(ToSchema, Validate, Serialize, Deserialize, Clone)]
71#[serde(tag = "type", rename_all = "snake_case")]
72pub enum StorageDriver {
73    Filesystem {
74        #[garde(length(chars, min = 1, max = 255))]
75        path: compact_str::CompactString,
76    },
77    S3 {
78        #[garde(length(chars, min = 1, max = 255), url)]
79        public_url: compact_str::CompactString,
80        #[garde(length(chars, min = 1, max = 512))]
81        access_key: compact_str::CompactString,
82        #[garde(length(chars, min = 1, max = 512))]
83        secret_key: compact_str::CompactString,
84        #[garde(length(chars, min = 1, max = 63))]
85        bucket: compact_str::CompactString,
86        #[garde(length(chars, min = 1, max = 63))]
87        region: compact_str::CompactString,
88        #[garde(length(chars, min = 1, max = 255))]
89        endpoint: compact_str::CompactString,
90        #[garde(skip)]
91        path_style: bool,
92    },
93}
94
95impl StorageDriver {
96    pub async fn get_cap_filesystem(
97        &self,
98        relative_path: impl AsRef<Path>,
99    ) -> Option<Result<CapFilesystem, std::io::Error>> {
100        match self {
101            StorageDriver::Filesystem { path } => {
102                Some(CapFilesystem::async_new(Path::new(path).join(relative_path.as_ref())).await)
103            }
104            _ => None,
105        }
106    }
107}
108
109impl Censor for StorageDriver {
110    fn censor(&mut self) {
111        match self {
112            StorageDriver::Filesystem { .. } => {}
113            StorageDriver::S3 {
114                access_key,
115                secret_key,
116                ..
117            } => {
118                *access_key = CENSORED_PLACEHOLDER.into();
119                *secret_key = CENSORED_PLACEHOLDER.into();
120            }
121        }
122    }
123}
124
125#[derive(ToSchema, Validate, Serialize, Deserialize, Clone)]
126#[serde(tag = "type", rename_all = "snake_case")]
127pub enum MailMode {
128    None,
129    Smtp {
130        #[garde(length(chars, min = 1, max = 255))]
131        host: compact_str::CompactString,
132        #[garde(skip)]
133        port: u16,
134        #[garde(length(chars, min = 1, max = 255))]
135        username: Option<compact_str::CompactString>,
136        #[garde(length(chars, min = 1, max = 255))]
137        password: Option<compact_str::CompactString>,
138        #[garde(skip)]
139        tls_mode: TlsMode,
140        #[garde(skip)]
141        #[serde(default)]
142        skip_cert_validation: bool,
143        #[garde(length(chars, min = 1, max = 255))]
144        #[serde(default)]
145        helo_domain: Option<compact_str::CompactString>,
146
147        #[garde(length(chars, min = 1, max = 255), email)]
148        from_address: compact_str::CompactString,
149        #[garde(length(chars, min = 1, max = 255))]
150        from_name: Option<compact_str::CompactString>,
151    },
152    Sendmail {
153        #[garde(length(chars, min = 1, max = 255))]
154        command: compact_str::CompactString,
155
156        #[garde(length(chars, min = 1, max = 255), email)]
157        from_address: compact_str::CompactString,
158        #[garde(length(chars, min = 1, max = 255))]
159        from_name: Option<compact_str::CompactString>,
160    },
161    Filesystem {
162        #[garde(length(chars, min = 1, max = 255))]
163        path: compact_str::CompactString,
164
165        #[garde(length(chars, min = 1, max = 255), email)]
166        from_address: compact_str::CompactString,
167        #[garde(length(chars, min = 1, max = 255))]
168        from_name: Option<compact_str::CompactString>,
169    },
170}
171
172impl Censor for MailMode {
173    fn censor(&mut self) {
174        match self {
175            MailMode::None | MailMode::Sendmail { .. } | MailMode::Filesystem { .. } => {}
176            MailMode::Smtp {
177                username, password, ..
178            } => {
179                if let Some(username) = username {
180                    *username = CENSORED_PLACEHOLDER.into();
181                }
182                if let Some(password) = password {
183                    *password = CENSORED_PLACEHOLDER.into();
184                }
185            }
186        }
187    }
188}
189
190#[derive(ToSchema, Validate, Serialize, Deserialize, Clone)]
191#[serde(tag = "type", rename_all = "snake_case")]
192pub enum CaptchaProvider {
193    None,
194    Turnstile {
195        #[garde(length(chars, min = 1, max = 255))]
196        site_key: compact_str::CompactString,
197        #[garde(length(chars, min = 1, max = 255))]
198        secret_key: compact_str::CompactString,
199    },
200    Recaptcha {
201        #[garde(skip)]
202        v3: bool,
203        #[serde(default = "default_recaptcha_threshold")]
204        #[garde(custom(validate_recaptcha_threshold))]
205        threshold: f64,
206        #[garde(length(chars, min = 1, max = 255))]
207        site_key: compact_str::CompactString,
208        #[garde(length(chars, min = 1, max = 255))]
209        secret_key: compact_str::CompactString,
210    },
211    Hcaptcha {
212        #[garde(length(chars, min = 1, max = 255))]
213        site_key: compact_str::CompactString,
214        #[garde(length(chars, min = 1, max = 255))]
215        secret_key: compact_str::CompactString,
216    },
217    FriendlyCaptcha {
218        #[garde(length(chars, min = 1, max = 255))]
219        site_key: compact_str::CompactString,
220        #[garde(length(chars, min = 1, max = 255))]
221        api_key: compact_str::CompactString,
222    },
223    Cap {
224        #[garde(length(chars, min = 1, max = 255), custom(validate_cap_api_url))]
225        api_url: compact_str::CompactString,
226        #[garde(length(chars, min = 1, max = 255), pattern(r"^[a-zA-Z0-9_-]+$"))]
227        site_key: compact_str::CompactString,
228        #[garde(length(chars, min = 1, max = 255))]
229        secret_key: compact_str::CompactString,
230    },
231}
232
233fn default_recaptcha_threshold() -> f64 {
234    0.5
235}
236
237fn validate_recaptcha_threshold(value: &f64, _context: &()) -> Result<(), garde::Error> {
238    if !(0.0..=1.0).contains(value) {
239        return Err(garde::Error::new("must be between 0 and 1"));
240    }
241
242    Ok(())
243}
244
245fn validate_cap_api_url(value: &str, context: &()) -> Result<(), garde::Error> {
246    if value.trim() != value {
247        return Err(garde::Error::new("must not contain surrounding whitespace"));
248    }
249    let url = reqwest::Url::parse(value).map_err(|err| garde::Error::new(err.to_string()))?;
250    crate::utils::validate_http_url(&url, context)?;
251    if !url.username().is_empty()
252        || url.password().is_some()
253        || url.query().is_some()
254        || url.fragment().is_some()
255    {
256        return Err(garde::Error::new(
257            "must not contain credentials, a query, or a fragment",
258        ));
259    }
260
261    Ok(())
262}
263
264impl CaptchaProvider {
265    pub fn to_public_provider<'a>(&'a self) -> PublicCaptchaProvider<'a> {
266        match &self {
267            CaptchaProvider::None => PublicCaptchaProvider::None,
268            CaptchaProvider::Turnstile { site_key, .. } => PublicCaptchaProvider::Turnstile {
269                site_key: site_key.as_str(),
270            },
271            CaptchaProvider::Recaptcha { v3, site_key, .. } => PublicCaptchaProvider::Recaptcha {
272                v3: *v3,
273                site_key: site_key.as_str(),
274            },
275            CaptchaProvider::Hcaptcha { site_key, .. } => PublicCaptchaProvider::Hcaptcha {
276                site_key: site_key.as_str(),
277            },
278            CaptchaProvider::FriendlyCaptcha { site_key, .. } => {
279                PublicCaptchaProvider::FriendlyCaptcha {
280                    site_key: site_key.as_str(),
281                }
282            }
283            CaptchaProvider::Cap {
284                api_url, site_key, ..
285            } => PublicCaptchaProvider::Cap {
286                api_url: api_url.as_str(),
287                site_key: site_key.as_str(),
288            },
289        }
290    }
291
292    pub fn to_csp_script_src(&self) -> &'static str {
293        match self {
294            CaptchaProvider::None => "",
295            CaptchaProvider::Turnstile { .. } => "https://challenges.cloudflare.com",
296            CaptchaProvider::Recaptcha { .. } => {
297                "https://www.google.com/recaptcha/ https://www.gstatic.com/recaptcha/"
298            }
299            CaptchaProvider::Hcaptcha { .. } => "https://hcaptcha.com https://*.hcaptcha.com",
300            CaptchaProvider::FriendlyCaptcha { .. } => "",
301            CaptchaProvider::Cap { .. } => "'wasm-unsafe-eval'",
302        }
303    }
304
305    pub fn to_csp_style_src(&self) -> &'static str {
306        match self {
307            CaptchaProvider::None => "",
308            CaptchaProvider::Turnstile { .. } => "",
309            CaptchaProvider::Recaptcha { .. } => "",
310            CaptchaProvider::Hcaptcha { .. } => "https://hcaptcha.com https://*.hcaptcha.com",
311            CaptchaProvider::FriendlyCaptcha { .. } => "",
312            CaptchaProvider::Cap { .. } => "",
313        }
314    }
315}
316
317impl Censor for CaptchaProvider {
318    fn censor(&mut self) {
319        match self {
320            CaptchaProvider::None => {}
321            CaptchaProvider::Turnstile { secret_key, .. }
322            | CaptchaProvider::Recaptcha { secret_key, .. }
323            | CaptchaProvider::Hcaptcha { secret_key, .. }
324            | CaptchaProvider::Cap { secret_key, .. } => {
325                *secret_key = CENSORED_PLACEHOLDER.into();
326            }
327            CaptchaProvider::FriendlyCaptcha { api_key, .. } => {
328                *api_key = CENSORED_PLACEHOLDER.into();
329            }
330        }
331    }
332}
333
334#[derive(ToSchema, Serialize, Deserialize, Clone)]
335#[serde(tag = "type", rename_all = "snake_case")]
336pub enum PublicCaptchaProvider<'a> {
337    None,
338    Turnstile { site_key: &'a str },
339    Recaptcha { v3: bool, site_key: &'a str },
340    Hcaptcha { site_key: &'a str },
341    FriendlyCaptcha { site_key: &'a str },
342    Cap { api_url: &'a str, site_key: &'a str },
343}
344
345#[derive(ToSchema, Serialize, Deserialize, Clone, Default, PartialEq)]
346pub struct ExtensionPermissions {
347    pub user: Vec<compact_str::CompactString>,
348    pub admin: Vec<compact_str::CompactString>,
349    pub server: Vec<compact_str::CompactString>,
350}
351
352#[derive(ToSchema, Serialize, Deserialize)]
353pub struct AppSettings {
354    pub telemetry_uuid: Option<uuid::Uuid>,
355    #[schema(value_type = Option<String>)]
356    pub telemetry_cron_schedule: Option<croner::Cron>,
357    pub oobe_step: Option<compact_str::CompactString>,
358
359    pub storage_driver: StorageDriver,
360    pub mail_mode: MailMode,
361    pub captcha_provider: CaptchaProvider,
362
363    #[schema(inline)]
364    pub app: app::AppSettingsApp,
365    #[schema(inline)]
366    pub metadata: metadata::AppSettingsMetadata,
367    #[schema(inline)]
368    pub webauthn: webauthn::AppSettingsWebauthn,
369    #[schema(inline)]
370    pub server: server::AppSettingsServer,
371    #[schema(inline)]
372    pub user: user::AppSettingsUser,
373    #[schema(inline)]
374    pub activity: activity::AppSettingsActivity,
375    #[schema(inline)]
376    pub ratelimits: ratelimits::AppSettingsRatelimits,
377
378    #[serde(skip)]
379    pub disabled_extensions: Vec<compact_str::CompactString>,
380    #[serde(skip)]
381    pub extension_permissions: BTreeMap<compact_str::CompactString, ExtensionPermissions>,
382
383    #[serde(skip)]
384    pub extensions: HashMap<&'static str, ExtensionSettings>,
385}
386
387impl AppSettings {
388    pub fn get_extension_settings<T: 'static>(
389        &self,
390        ext_identifier: &str,
391    ) -> Result<&T, anyhow::Error> {
392        let ext_settings = self
393            .extensions
394            .get(ext_identifier)
395            .ok_or_else(|| anyhow::anyhow!("failed to find extension settings"))?;
396
397        (&**ext_settings as &dyn std::any::Any)
398            .downcast_ref::<T>()
399            .ok_or_else(|| anyhow::anyhow!("failed to downcast extension settings"))
400    }
401
402    pub fn get_mut_extension_settings<T: 'static>(
403        &mut self,
404        ext_identifier: &str,
405    ) -> Result<&mut T, anyhow::Error> {
406        let ext_settings = self
407            .extensions
408            .get_mut(ext_identifier)
409            .ok_or_else(|| anyhow::anyhow!("failed to find extension settings"))?;
410
411        (&mut **ext_settings as &mut dyn std::any::Any)
412            .downcast_mut::<T>()
413            .ok_or_else(|| anyhow::anyhow!("failed to downcast extension settings"))
414    }
415
416    pub fn find_extension_settings<T: 'static>(&self) -> Result<&T, anyhow::Error> {
417        for ext_settings in self.extensions.values() {
418            if let Some(downcasted) = (&**ext_settings as &dyn std::any::Any).downcast_ref::<T>() {
419                return Ok(downcasted);
420            }
421        }
422
423        Err(anyhow::anyhow!("failed to find extension settings"))
424    }
425
426    pub fn find_mut_extension_settings<T: 'static>(&mut self) -> Result<&mut T, anyhow::Error> {
427        for ext_settings in self.extensions.values_mut() {
428            if let Some(downcasted) =
429                (&mut **ext_settings as &mut dyn std::any::Any).downcast_mut::<T>()
430            {
431                return Ok(downcasted);
432            }
433        }
434
435        Err(anyhow::anyhow!("failed to find extension settings"))
436    }
437}
438
439#[async_trait::async_trait]
440impl SettingsSerializeExt for AppSettings {
441    async fn serialize(
442        &self,
443        mut serializer: SettingsSerializer,
444    ) -> Result<SettingsSerializer, anyhow::Error> {
445        let database = serializer.database.clone();
446
447        serializer = serializer
448            .write_raw_setting(
449                "telemetry_uuid",
450                self.telemetry_uuid
451                    .as_ref()
452                    .map(|u| u.to_compact_string())
453                    .unwrap_or_default(),
454            )
455            .write_raw_setting(
456                "telemetry_cron_schedule",
457                self.telemetry_cron_schedule
458                    .as_ref()
459                    .map(|s| s.to_compact_string())
460                    .unwrap_or_default(),
461            );
462        // Intentionally not writing oobe_step, persisting 'register' in a write will cause a lot of weird issues
463        // The seperate settings set_oobe_step should be used to manage this setting instead
464        // disabled_extensions and extension_permissions are left out for the same reason, they are
465        // written by set_disabled_extensions/set_extension_permissions
466
467        match &self.storage_driver {
468            StorageDriver::Filesystem { path } => {
469                serializer = serializer
470                    .write_raw_setting("storage_driver", "filesystem")
471                    .write_raw_setting("storage_filesystem_path", &**path);
472            }
473            StorageDriver::S3 {
474                public_url,
475                access_key,
476                secret_key,
477                bucket,
478                region,
479                endpoint,
480                path_style,
481            } => {
482                serializer = serializer
483                    .write_raw_setting("storage_driver", "s3")
484                    .write_raw_setting("storage_s3_public_url", &**public_url)
485                    .write_raw_setting(
486                        "storage_s3_access_key",
487                        base32::encode(
488                            base32::Alphabet::Z,
489                            &database.encrypt(access_key.clone()).await?,
490                        ),
491                    )
492                    .write_raw_setting(
493                        "storage_s3_secret_key",
494                        base32::encode(
495                            base32::Alphabet::Z,
496                            &database.encrypt(secret_key.clone()).await?,
497                        ),
498                    )
499                    .write_raw_setting("storage_s3_bucket", &**bucket)
500                    .write_raw_setting("storage_s3_region", &**region)
501                    .write_raw_setting("storage_s3_endpoint", &**endpoint)
502                    .write_raw_setting("storage_s3_path_style", path_style.to_compact_string());
503            }
504        }
505
506        match &self.mail_mode {
507            MailMode::None => {
508                serializer = serializer.write_raw_setting("mail_mode", "none");
509            }
510            MailMode::Smtp {
511                host,
512                port,
513                username,
514                password,
515                tls_mode,
516                skip_cert_validation,
517                helo_domain,
518                from_address,
519                from_name,
520            } => {
521                serializer = serializer
522                    .write_raw_setting("mail_mode", "smtp")
523                    .write_raw_setting("mail_smtp_host", &**host)
524                    .write_raw_setting("mail_smtp_port", port.to_compact_string())
525                    .write_raw_setting(
526                        "mail_smtp_username",
527                        if let Some(u) = username {
528                            base32::encode(base32::Alphabet::Z, &database.encrypt(u.clone()).await?)
529                        } else {
530                            "".into()
531                        },
532                    )
533                    .write_raw_setting(
534                        "mail_smtp_password",
535                        if let Some(p) = password {
536                            base32::encode(base32::Alphabet::Z, &database.encrypt(p.clone()).await?)
537                        } else {
538                            "".into()
539                        },
540                    )
541                    .write_raw_setting(
542                        "mail_smtp_tls_mode",
543                        match tls_mode {
544                            TlsMode::None => "none",
545                            TlsMode::StartTls => "starttls",
546                            TlsMode::ImplicitTls => "implicit_tls",
547                        },
548                    )
549                    .write_raw_setting(
550                        "mail_smtp_skip_cert_validation",
551                        skip_cert_validation.to_compact_string(),
552                    )
553                    .write_raw_setting(
554                        "mail_smtp_helo_domain",
555                        helo_domain.clone().unwrap_or_default(),
556                    )
557                    .write_raw_setting("mail_smtp_from_address", &**from_address)
558                    .write_raw_setting(
559                        "mail_smtp_from_name",
560                        from_name.clone().unwrap_or_default(),
561                    );
562            }
563            MailMode::Sendmail {
564                command,
565                from_address,
566                from_name,
567            } => {
568                serializer = serializer
569                    .write_raw_setting("mail_mode", "sendmail")
570                    .write_raw_setting("mail_sendmail_command", &**command)
571                    .write_raw_setting("mail_sendmail_from_address", &**from_address)
572                    .write_raw_setting(
573                        "mail_sendmail_from_name",
574                        from_name.clone().unwrap_or_default(),
575                    );
576            }
577            MailMode::Filesystem {
578                path,
579                from_address,
580                from_name,
581            } => {
582                serializer = serializer
583                    .write_raw_setting("mail_mode", "filesystem")
584                    .write_raw_setting("mail_filesystem_path", &**path)
585                    .write_raw_setting("mail_filesystem_from_address", &**from_address)
586                    .write_raw_setting(
587                        "mail_filesystem_from_name",
588                        from_name.clone().unwrap_or_default(),
589                    );
590            }
591        }
592
593        match &self.captcha_provider {
594            CaptchaProvider::None => {
595                serializer = serializer.write_raw_setting("captcha_provider", "none");
596            }
597            CaptchaProvider::Turnstile {
598                site_key,
599                secret_key,
600            } => {
601                serializer = serializer
602                    .write_raw_setting("captcha_provider", "turnstile")
603                    .write_raw_setting("turnstile_site_key", &**site_key)
604                    .write_raw_setting("turnstile_secret_key", &**secret_key);
605            }
606            CaptchaProvider::Recaptcha {
607                v3,
608                threshold,
609                site_key,
610                secret_key,
611            } => {
612                serializer = serializer
613                    .write_raw_setting("captcha_provider", "recaptcha")
614                    .write_raw_setting("recaptcha_v3", v3.to_compact_string())
615                    .write_raw_setting("recaptcha_threshold", threshold.to_compact_string())
616                    .write_raw_setting("recaptcha_site_key", &**site_key)
617                    .write_raw_setting("recaptcha_secret_key", &**secret_key);
618            }
619            CaptchaProvider::Hcaptcha {
620                site_key,
621                secret_key,
622            } => {
623                serializer = serializer
624                    .write_raw_setting("captcha_provider", "hcaptcha")
625                    .write_raw_setting("hcaptcha_site_key", &**site_key)
626                    .write_raw_setting("hcaptcha_secret_key", &**secret_key);
627            }
628            CaptchaProvider::FriendlyCaptcha { site_key, api_key } => {
629                serializer = serializer
630                    .write_raw_setting("captcha_provider", "friendlycaptcha")
631                    .write_raw_setting("friendlycaptcha_site_key", &**site_key)
632                    .write_raw_setting("friendlycaptcha_api_key", &**api_key);
633            }
634            CaptchaProvider::Cap {
635                api_url,
636                site_key,
637                secret_key,
638            } => {
639                serializer = serializer
640                    .write_raw_setting("captcha_provider", "cap")
641                    .write_raw_setting("cap_api_url", &**api_url)
642                    .write_raw_setting("cap_site_key", &**site_key)
643                    .write_raw_setting("cap_secret_key", &**secret_key);
644            }
645        }
646
647        serializer = serializer
648            .nest("app", &self.app)
649            .await?
650            .nest("metadata", &self.metadata)
651            .await?
652            .nest("webauthn", &self.webauthn)
653            .await?
654            .nest("server", &self.server)
655            .await?
656            .nest("user", &self.user)
657            .await?
658            .nest("activity", &self.activity)
659            .await?
660            .nest("ratelimits", &self.ratelimits)
661            .await?;
662
663        for (ext_identifier, ext_settings) in self.extensions.iter() {
664            serializer = serializer.nest(ext_identifier, ext_settings).await?;
665        }
666
667        Ok(serializer)
668    }
669}
670
671pub(crate) static SETTINGS_DESER_EXTENSIONS: LazyLock<
672    parking_lot::RwLock<HashMap<&'static str, ExtensionSettingsDeserializer>>,
673> = LazyLock::new(|| parking_lot::RwLock::new(HashMap::new()));
674
675pub struct AppSettingsDeserializer;
676
677#[async_trait::async_trait]
678impl SettingsDeserializeExt for AppSettingsDeserializer {
679    async fn deserialize_boxed(
680        &self,
681        mut deserializer: SettingsDeserializer<'_>,
682    ) -> Result<ExtensionSettings, anyhow::Error> {
683        let mut extensions = HashMap::new();
684
685        let extension_deserializers = {
686            let ext_deser_lock = SETTINGS_DESER_EXTENSIONS.read();
687
688            ext_deser_lock
689                .iter()
690                .map(|(k, v)| (*k, v.clone()))
691                .collect::<Vec<_>>()
692        };
693
694        for (ext_identifier, ext_deserializer) in extension_deserializers {
695            let settings_deserializer = SettingsDeserializer::new(
696                deserializer.database.clone(),
697                deserializer.nest_prefix(ext_identifier),
698                deserializer.settings,
699            );
700
701            match ext_deserializer
702                .deserialize_boxed(settings_deserializer)
703                .await
704            {
705                Ok(ext_settings) => {
706                    extensions.insert(ext_identifier, ext_settings);
707                }
708                Err(err) => tracing::error!(
709                    extension = %ext_identifier,
710                    "failed to deserialize extension settings, skipping: {err:#}"
711                ),
712            }
713        }
714
715        Ok(Box::new(AppSettings {
716            telemetry_uuid: deserializer
717                .take_raw_setting("telemetry_uuid")
718                .and_then(|s| uuid::Uuid::from_str(&s).ok()),
719            telemetry_cron_schedule: deserializer
720                .take_raw_setting("telemetry_cron_schedule")
721                .and_then(|s| croner::Cron::from_str(&s).ok()),
722            oobe_step: match deserializer.take_raw_setting("oobe_step") {
723                Some(step) if step.is_empty() => None,
724                Some(step) => Some(step),
725                None => {
726                    if crate::models::user::User::count(&deserializer.database).await > 0 {
727                        None
728                    } else {
729                        Some("register".into())
730                    }
731                }
732            },
733            storage_driver: match deserializer.take_raw_setting("storage_driver").as_deref() {
734                Some("s3") => StorageDriver::S3 {
735                    public_url: deserializer
736                        .take_raw_setting("storage_s3_public_url")
737                        .unwrap_or_else(|| "https://your-s3-bucket.s3.amazonaws.com".into()),
738                    access_key: if let Some(access_key) =
739                        deserializer.take_raw_setting("storage_s3_access_key")
740                    {
741                        base32::decode(base32::Alphabet::Z, &access_key)
742                            .map(|encrypted| deserializer.database.decrypt(encrypted))
743                            .awaited()
744                            .await
745                            .transpose()?
746                            .unwrap_or_else(|| "your-access-key".into())
747                    } else {
748                        "your-access-key".into()
749                    },
750                    secret_key: if let Some(secret_key) =
751                        deserializer.take_raw_setting("storage_s3_secret_key")
752                    {
753                        base32::decode(base32::Alphabet::Z, &secret_key)
754                            .map(|encrypted| deserializer.database.decrypt(encrypted))
755                            .awaited()
756                            .await
757                            .transpose()?
758                            .unwrap_or_else(|| "your-secret-key".into())
759                    } else {
760                        "your-secret-key".into()
761                    },
762                    bucket: deserializer
763                        .take_raw_setting("storage_s3_bucket")
764                        .unwrap_or_else(|| "your-s3-bucket".into()),
765                    region: deserializer
766                        .take_raw_setting("storage_s3_region")
767                        .unwrap_or_else(|| "us-east-1".into()),
768                    endpoint: deserializer
769                        .take_raw_setting("storage_s3_endpoint")
770                        .unwrap_or_else(|| "https://s3.amazonaws.com".into()),
771                    path_style: deserializer
772                        .take_raw_setting("storage_s3_path_style")
773                        .map(|s| s == "true")
774                        .unwrap_or(false),
775                },
776                _ => StorageDriver::Filesystem {
777                    path: deserializer
778                        .take_raw_setting("storage_filesystem_path")
779                        .unwrap_or_else(|| {
780                            if std::env::consts::OS == "windows" {
781                                "C:\\calagopus_data".into()
782                            } else {
783                                "/var/lib/calagopus".into()
784                            }
785                        }),
786                },
787            },
788            mail_mode: match deserializer.take_raw_setting("mail_mode").as_deref() {
789                Some("smtp") => MailMode::Smtp {
790                    host: deserializer
791                        .take_raw_setting("mail_smtp_host")
792                        .unwrap_or_else(|| "smtp.example.com".into()),
793                    port: deserializer
794                        .take_raw_setting("mail_smtp_port")
795                        .and_then(|s| s.parse().ok())
796                        .unwrap_or(587),
797                    username: if let Some(username) = deserializer
798                        .take_raw_setting("mail_smtp_username")
799                        .and_then(|s| s.into_optional())
800                    {
801                        base32::decode(base32::Alphabet::Z, &username)
802                            .map(|encrypted| deserializer.database.decrypt(encrypted))
803                            .awaited()
804                            .await
805                            .transpose()?
806                    } else {
807                        None
808                    },
809                    password: if let Some(password) = deserializer
810                        .take_raw_setting("mail_smtp_password")
811                        .and_then(|s| s.into_optional())
812                    {
813                        base32::decode(base32::Alphabet::Z, &password)
814                            .map(|encrypted| deserializer.database.decrypt(encrypted))
815                            .awaited()
816                            .await
817                            .transpose()?
818                    } else {
819                        None
820                    },
821                    tls_mode: match deserializer
822                        .take_raw_setting("mail_smtp_tls_mode")
823                        .as_deref()
824                    {
825                        Some("none") => TlsMode::None,
826                        Some("starttls") => TlsMode::StartTls,
827                        Some("implicit_tls") => TlsMode::ImplicitTls,
828                        _ => TlsMode::StartTls,
829                    },
830                    skip_cert_validation: deserializer
831                        .take_raw_setting("mail_smtp_skip_cert_validation")
832                        .map(|s| s == "true")
833                        .unwrap_or(false),
834                    helo_domain: deserializer
835                        .take_raw_setting("mail_smtp_helo_domain")
836                        .and_then(|s| s.into_optional()),
837                    from_address: deserializer
838                        .take_raw_setting("mail_smtp_from_address")
839                        .unwrap_or_else(|| "noreply@example.com".into()),
840                    from_name: deserializer.take_raw_setting("mail_smtp_from_name"),
841                },
842                Some("sendmail") => MailMode::Sendmail {
843                    command: deserializer
844                        .take_raw_setting("mail_sendmail_command")
845                        .unwrap_or_else(|| "sendmail".into()),
846                    from_address: deserializer
847                        .take_raw_setting("mail_sendmail_from_address")
848                        .unwrap_or_else(|| "noreply@example.com".into()),
849                    from_name: deserializer.take_raw_setting("mail_sendmail_from_name"),
850                },
851                Some("filesystem") => MailMode::Filesystem {
852                    path: deserializer
853                        .take_raw_setting("mail_filesystem_path")
854                        .unwrap_or_else(|| "/var/lib/calagopus/mail".into()),
855                    from_address: deserializer
856                        .take_raw_setting("mail_filesystem_from_address")
857                        .unwrap_or_else(|| "noreply@example.com".into()),
858                    from_name: deserializer.take_raw_setting("mail_filesystem_from_name"),
859                },
860                _ => MailMode::None,
861            },
862            captcha_provider: match deserializer.take_raw_setting("captcha_provider").as_deref() {
863                Some("turnstile") => CaptchaProvider::Turnstile {
864                    site_key: deserializer
865                        .take_raw_setting("turnstile_site_key")
866                        .unwrap_or_default(),
867                    secret_key: deserializer
868                        .take_raw_setting("turnstile_secret_key")
869                        .unwrap_or_default(),
870                },
871                Some("recaptcha") => CaptchaProvider::Recaptcha {
872                    threshold: deserializer
873                        .take_raw_setting("recaptcha_threshold")
874                        .and_then(|s| s.parse::<f64>().ok())
875                        .filter(|threshold| (0.0..=1.0).contains(threshold))
876                        .unwrap_or_else(default_recaptcha_threshold),
877                    v3: deserializer
878                        .take_raw_setting("recaptcha_v3")
879                        .map(|s| s == "true")
880                        .unwrap_or(false),
881                    site_key: deserializer
882                        .take_raw_setting("recaptcha_site_key")
883                        .unwrap_or_default(),
884                    secret_key: deserializer
885                        .take_raw_setting("recaptcha_secret_key")
886                        .unwrap_or_default(),
887                },
888                Some("hcaptcha") => CaptchaProvider::Hcaptcha {
889                    site_key: deserializer
890                        .take_raw_setting("hcaptcha_site_key")
891                        .unwrap_or_default(),
892                    secret_key: deserializer
893                        .take_raw_setting("hcaptcha_secret_key")
894                        .unwrap_or_default(),
895                },
896                Some("friendlycaptcha") => CaptchaProvider::FriendlyCaptcha {
897                    site_key: deserializer
898                        .take_raw_setting("friendlycaptcha_site_key")
899                        .unwrap_or_default(),
900                    api_key: deserializer
901                        .take_raw_setting("friendlycaptcha_api_key")
902                        .unwrap_or_default(),
903                },
904                Some("cap") => CaptchaProvider::Cap {
905                    api_url: deserializer
906                        .take_raw_setting("cap_api_url")
907                        .unwrap_or_default(),
908                    site_key: deserializer
909                        .take_raw_setting("cap_site_key")
910                        .unwrap_or_default(),
911                    secret_key: deserializer
912                        .take_raw_setting("cap_secret_key")
913                        .unwrap_or_default(),
914                },
915                _ => CaptchaProvider::None,
916            },
917            app: deserializer
918                .nest("app", &app::AppSettingsAppDeserializer)
919                .await?,
920            metadata: deserializer
921                .nest("metadata", &metadata::AppSettingsMetadataDeserializer)
922                .await?,
923            webauthn: deserializer
924                .nest("webauthn", &webauthn::AppSettingsWebauthnDeserializer)
925                .await?,
926            server: deserializer
927                .nest("server", &server::AppSettingsServerDeserializer)
928                .await?,
929            user: deserializer
930                .nest("user", &user::AppSettingsUserDeserializer)
931                .await?,
932            activity: deserializer
933                .nest("activity", &activity::AppSettingsActivityDeserializer)
934                .await?,
935            ratelimits: deserializer
936                .nest("ratelimits", &ratelimits::AppSettingsRatelimitsDeserializer)
937                .await?,
938            disabled_extensions: deserializer
939                .take_raw_setting("disabled_extensions")
940                .map(|s| parse_disabled_extensions(&s))
941                .unwrap_or_default(),
942            extension_permissions: deserializer
943                .take_raw_setting("extension_permissions")
944                .and_then(|s| serde_json::from_str(&s).ok())
945                .unwrap_or_default(),
946            extensions,
947        }))
948    }
949}
950
951fn parse_disabled_extensions(raw: &str) -> Vec<compact_str::CompactString> {
952    raw.split(',')
953        .map(str::trim)
954        .filter(|entry| !entry.is_empty())
955        .map(Into::into)
956        .collect()
957}
958
959pub async fn fetch_disabled_extensions(
960    database: &crate::database::Database,
961) -> Vec<compact_str::CompactString> {
962    let value: Option<String> =
963        sqlx::query_scalar("SELECT value FROM settings WHERE key = '::disabled_extensions'")
964            .fetch_optional(database.read())
965            .await
966            .ok()
967            .flatten();
968
969    value
970        .as_deref()
971        .map(parse_disabled_extensions)
972        .unwrap_or_default()
973}
974
975pub struct SettingsReadGuard<'a> {
976    settings: RwLockReadGuard<'a, SettingsBuffer>,
977}
978
979impl Deref for SettingsReadGuard<'_> {
980    type Target = AppSettings;
981
982    fn deref(&self) -> &Self::Target {
983        &self.settings.settings
984    }
985}
986
987fn render_index_html(settings: &AppSettings) -> Result<String, anyhow::Error> {
988    let mut environment = minijinja::Environment::new();
989    environment.set_auto_escape_callback(|_| minijinja::AutoEscape::Html);
990    environment.add_global(
991        "settings",
992        minijinja::Value::from(minijinja::value::Serde(settings)),
993    );
994    environment.add_global(
995        "metadata",
996        minijinja::Value::from(minijinja::value::Serde(
997            settings.metadata.resolve(&settings.app),
998        )),
999    );
1000
1001    let index_html = crate::frontend_index_html().ok_or_else(|| {
1002        anyhow::anyhow!("no frontend registered, call shared::register_frontend first")
1003    })?;
1004
1005    Ok(environment.render_str(index_html, minijinja::context! {})?)
1006}
1007
1008#[derive(Clone)]
1009pub struct ArcedIndexHtml(Arc<String>);
1010
1011// SAFETY: Just wrapping Arc
1012unsafe impl arc_swap::RefCnt for ArcedIndexHtml {
1013    type Base = String;
1014
1015    fn into_ptr(me: Self) -> *mut Self::Base {
1016        arc_swap::RefCnt::into_ptr(me.0)
1017    }
1018
1019    fn as_ptr(me: &Self) -> *mut Self::Base {
1020        arc_swap::RefCnt::as_ptr(&me.0)
1021    }
1022
1023    unsafe fn from_ptr(ptr: *const Self::Base) -> Self {
1024        // SAFETY: Just wrapping Arc
1025        Self(unsafe { arc_swap::RefCnt::from_ptr(ptr) })
1026    }
1027}
1028
1029impl AsRef<str> for ArcedIndexHtml {
1030    fn as_ref(&self) -> &str {
1031        &self.0
1032    }
1033}
1034
1035impl AsRef<[u8]> for ArcedIndexHtml {
1036    fn as_ref(&self) -> &[u8] {
1037        self.0.as_bytes()
1038    }
1039}
1040
1041pub struct SettingsWriteGuard<'a> {
1042    parent: &'a Settings,
1043    buffer_index: usize,
1044    settings: Option<RwLockWriteGuard<'a, SettingsBuffer>>,
1045    _writer_token: SemaphorePermit<'a>,
1046}
1047
1048impl<'a> SettingsWriteGuard<'a> {
1049    pub async fn save(mut self) -> Result<(), crate::database::DatabaseError> {
1050        let buffer_index = self.buffer_index;
1051
1052        let mut settings_guard = self.settings.take().ok_or_else(|| {
1053            crate::database::DatabaseError::Any(anyhow::anyhow!(
1054                "settings have already been saved or dropped"
1055            ))
1056        })?;
1057
1058        let (keys, values) = SettingsSerializeExt::serialize(
1059            &settings_guard.settings,
1060            SettingsSerializer::new(self.parent.database.clone(), ""),
1061        )
1062        .await?
1063        .into_parts();
1064
1065        sqlx::query!(
1066            "INSERT INTO settings (key, value)
1067            SELECT * FROM UNNEST($1::text[], $2::text[])
1068            ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value",
1069            &keys as &[compact_str::CompactString],
1070            &values as &[compact_str::CompactString]
1071        )
1072        .execute(self.parent.database.write())
1073        .await?;
1074
1075        let rendered_index_html = render_index_html(&settings_guard.settings)?;
1076        self.parent
1077            .rendered_index_html
1078            .store(ArcedIndexHtml(Arc::new(rendered_index_html)));
1079
1080        settings_guard.expires = std::time::Instant::now() + std::time::Duration::from_secs(60);
1081
1082        drop(settings_guard);
1083
1084        self.parent
1085            .cached_index
1086            .store(buffer_index, Ordering::Release);
1087
1088        crate::models::node::Node::invalidate_permissions_everywhere(
1089            &self.parent.database,
1090            crate::models::node::PermissionsInvalidation {
1091                servers: Some(wings_api::ServerSelector::All),
1092                ..Default::default()
1093            },
1094        );
1095
1096        Ok(())
1097    }
1098
1099    /// The settings as they may be logged, with every secret censored.
1100    ///
1101    /// [`AppSettings`] deliberately has no [`Censor`] impl: this runs on the live buffer that
1102    /// [`Self::save`] writes to the database, so censoring it in place would persist the
1103    /// placeholder as the real secret. The secret bearing settings are censored on clones instead.
1104    pub fn censored(&self) -> serde_json::Value {
1105        let settings = &self
1106            .settings
1107            .as_ref()
1108            .expect("settings have been dropped")
1109            .settings;
1110
1111        let mut json = serde_json::to_value(settings).unwrap();
1112
1113        json["storage_driver"] =
1114            serde_json::to_value(settings.storage_driver.clone().censored()).unwrap();
1115        json["mail_mode"] = serde_json::to_value(settings.mail_mode.clone().censored()).unwrap();
1116        json["captcha_provider"] =
1117            serde_json::to_value(settings.captcha_provider.clone().censored()).unwrap();
1118
1119        json
1120    }
1121}
1122
1123impl Deref for SettingsWriteGuard<'_> {
1124    type Target = AppSettings;
1125
1126    fn deref(&self) -> &Self::Target {
1127        &self
1128            .settings
1129            .as_ref()
1130            .expect("settings have been dropped")
1131            .settings
1132    }
1133}
1134
1135impl DerefMut for SettingsWriteGuard<'_> {
1136    fn deref_mut(&mut self) -> &mut Self::Target {
1137        &mut self
1138            .settings
1139            .as_mut()
1140            .expect("settings have been dropped")
1141            .settings
1142    }
1143}
1144
1145struct SettingsBuffer {
1146    settings: AppSettings,
1147    expires: std::time::Instant,
1148}
1149
1150pub struct Settings {
1151    // Write protocol: every writer (refresh in `get`, `get_mut`, `invalidate_cache`)
1152    // holds `write_serializing`, locks only the buffer that is INACTIVE at the index
1153    // it loaded, and publishes it by flipping `cached_index`. Readers only ever lock
1154    // the buffer `cached_index` points at. Locking the active buffer in place is what
1155    // deadlocks against long-lived `SettingsReadGuard`s, so never do it.
1156    cached: [RwLock<SettingsBuffer>; 2],
1157    cached_index: AtomicUsize,
1158    write_serializing: Semaphore,
1159
1160    rendered_index_html: arc_swap::ArcSwapAny<ArcedIndexHtml>,
1161
1162    database: Arc<crate::database::Database>,
1163}
1164
1165impl Settings {
1166    pub(crate) async fn fetch_settings_map(
1167        database: &Arc<crate::database::Database>,
1168    ) -> Result<HashMap<compact_str::CompactString, compact_str::CompactString>, anyhow::Error>
1169    {
1170        let rows = sqlx::query!("SELECT * FROM settings")
1171            .fetch_all(database.read())
1172            .await?;
1173
1174        let mut map = HashMap::new();
1175        for row in rows {
1176            map.insert(row.key.into(), row.value.into());
1177        }
1178
1179        Ok(map)
1180    }
1181
1182    async fn fetch_settings(
1183        database: &Arc<crate::database::Database>,
1184    ) -> Result<AppSettings, anyhow::Error> {
1185        let mut map = Self::fetch_settings_map(database).await?;
1186
1187        let boxed = SettingsDeserializeExt::deserialize_boxed(
1188            &AppSettingsDeserializer,
1189            SettingsDeserializer::new(database.clone(), "", &mut map),
1190        )
1191        .await?;
1192
1193        Ok(*(boxed as Box<dyn std::any::Any>)
1194            .downcast::<AppSettings>()
1195            .expect("settings has invalid type"))
1196    }
1197
1198    pub async fn new(database: Arc<crate::database::Database>) -> Result<Self, anyhow::Error> {
1199        let (s1, s2) = tokio::try_join!(
1200            Self::fetch_settings(&database),
1201            Self::fetch_settings(&database)
1202        )?;
1203
1204        let rendered_index_html = render_index_html(&s1)?;
1205        let rendered_index_html =
1206            arc_swap::ArcSwapAny::new(ArcedIndexHtml(Arc::new(rendered_index_html)));
1207
1208        Ok(Self {
1209            cached: [
1210                RwLock::new(SettingsBuffer {
1211                    settings: s1,
1212                    expires: std::time::Instant::now() + std::time::Duration::from_secs(60),
1213                }),
1214                RwLock::new(SettingsBuffer {
1215                    settings: s2,
1216                    expires: std::time::Instant::now() + std::time::Duration::from_secs(60),
1217                }),
1218            ],
1219            cached_index: AtomicUsize::new(0),
1220            write_serializing: Semaphore::new(1),
1221            rendered_index_html,
1222            database,
1223        })
1224    }
1225
1226    #[inline]
1227    pub fn get_rendered_index_html(&self) -> ArcedIndexHtml {
1228        self.rendered_index_html.load_full()
1229    }
1230
1231    pub async fn get(&self) -> Result<SettingsReadGuard<'_>, anyhow::Error> {
1232        let now = std::time::Instant::now();
1233
1234        let index = self.cached_index.load(Ordering::Acquire);
1235        {
1236            let guard = self.cached[index % 2].read().await;
1237            if now < guard.expires {
1238                return Ok(SettingsReadGuard { settings: guard });
1239            }
1240        }
1241
1242        let _write_token = self.write_serializing.acquire().await?;
1243
1244        let index = self.cached_index.load(Ordering::Acquire);
1245        {
1246            let guard = self.cached[index % 2].read().await;
1247            if now < guard.expires {
1248                return Ok(SettingsReadGuard { settings: guard });
1249            }
1250        }
1251
1252        let start = std::time::Instant::now();
1253        tracing::info!("settings cache expired, reloading from database");
1254
1255        let settings = Self::fetch_settings(&self.database).await?;
1256
1257        let inactive_index = (index + 1) % 2;
1258        {
1259            let mut guard = self.cached[inactive_index].write().await;
1260            guard.settings = settings;
1261            guard.expires = now + std::time::Duration::from_secs(60);
1262        }
1263
1264        self.cached_index.store(inactive_index, Ordering::Release);
1265
1266        tracing::info!(
1267            "reloaded settings from database in {} ms",
1268            start.elapsed().as_millis()
1269        );
1270
1271        Ok(SettingsReadGuard {
1272            settings: self.cached[inactive_index].read().await,
1273        })
1274    }
1275
1276    pub async fn get_as<F: FnOnce(&AppSettings) -> T, T>(&self, f: F) -> Result<T, anyhow::Error> {
1277        let settings = self.get().await?;
1278        Ok(f(&settings))
1279    }
1280
1281    pub async fn get_webauthn(&self) -> Result<webauthn_rs::Webauthn, anyhow::Error> {
1282        let settings = self.get().await?;
1283
1284        let rp_origin: reqwest::Url = settings.webauthn.rp_origin.parse()?;
1285        let mut builder = webauthn_rs::WebauthnBuilder::new(&settings.webauthn.rp_id, &rp_origin)?;
1286
1287        for url in settings.app.urls() {
1288            let Ok(url) = reqwest::Url::parse(url) else {
1289                continue;
1290            };
1291
1292            if url.origin() == rp_origin.origin() {
1293                continue;
1294            }
1295
1296            // browsers reject credentials whose rp_id is not a registrable suffix of the page origin
1297            if url.domain().is_some_and(|domain| {
1298                domain == settings.webauthn.rp_id
1299                    || domain.ends_with(&format!(".{}", settings.webauthn.rp_id))
1300            }) {
1301                builder = builder.append_allowed_origin(&url);
1302            }
1303        }
1304
1305        Ok(builder
1306            .rp_name(&settings.app.name)
1307            .timeout(std::time::Duration::from_secs(
1308                settings.webauthn.authentication_timeout_seconds,
1309            ))
1310            .build()?)
1311    }
1312
1313    pub async fn get_mut(&self) -> Result<SettingsWriteGuard<'_>, anyhow::Error> {
1314        let writer_token = self.write_serializing.acquire().await?;
1315
1316        let active_index = self.cached_index.load(Ordering::Acquire);
1317        let buffer_index = (active_index + 1) % 2;
1318        let inactive_buffer = &self.cached[buffer_index];
1319
1320        let mut guard = inactive_buffer.write().await;
1321
1322        guard.settings = Self::fetch_settings(&self.database).await?;
1323
1324        Ok(SettingsWriteGuard {
1325            parent: self,
1326            buffer_index,
1327            settings: Some(guard),
1328            _writer_token: writer_token,
1329        })
1330    }
1331
1332    pub async fn set_oobe_step(
1333        &self,
1334        oobe_step: Option<compact_str::CompactString>,
1335    ) -> Result<(), crate::database::DatabaseError> {
1336        sqlx::query(
1337            "INSERT INTO settings (key, value) VALUES ('oobe_step', $1)
1338            ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value",
1339        )
1340        .bind(oobe_step.map(|s| s.to_string()).unwrap_or_default())
1341        .execute(self.database.write())
1342        .await?;
1343
1344        self.invalidate_cache().await;
1345
1346        Ok(())
1347    }
1348
1349    pub async fn set_disabled_extensions(
1350        &self,
1351        disabled_extensions: &[compact_str::CompactString],
1352    ) -> Result<(), crate::database::DatabaseError> {
1353        sqlx::query(
1354            "INSERT INTO settings (key, value) VALUES ('::disabled_extensions', $1)
1355            ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value",
1356        )
1357        .bind(disabled_extensions.join(","))
1358        .execute(self.database.write())
1359        .await?;
1360
1361        self.invalidate_cache().await;
1362
1363        Ok(())
1364    }
1365
1366    pub async fn set_extension_permissions(
1367        &self,
1368        extension_permissions: &BTreeMap<compact_str::CompactString, ExtensionPermissions>,
1369    ) -> Result<(), crate::database::DatabaseError> {
1370        sqlx::query(
1371            "INSERT INTO settings (key, value) VALUES ('::extension_permissions', $1)
1372            ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value",
1373        )
1374        .bind(serde_json::to_string(extension_permissions).map_err(anyhow::Error::new)?)
1375        .execute(self.database.write())
1376        .await?;
1377
1378        self.invalidate_cache().await;
1379
1380        Ok(())
1381    }
1382
1383    pub async fn cleanup_ratelimit_exempt_api_keys(&self) -> Result<usize, anyhow::Error> {
1384        let exempt_api_keys = self
1385            .get_as(|s| s.ratelimits.exempt_api_keys.clone())
1386            .await?;
1387        if exempt_api_keys.is_empty() {
1388            return Ok(0);
1389        }
1390
1391        let existing: Vec<uuid::Uuid> =
1392            crate::models::user_api_key::UserApiKey::by_uuids(&self.database, &exempt_api_keys)
1393                .await?
1394                .into_iter()
1395                .map(|api_key| api_key.uuid)
1396                .collect();
1397        if existing.len() == exempt_api_keys.len() {
1398            return Ok(0);
1399        }
1400
1401        let mut settings = self.get_mut().await?;
1402        let before = settings.ratelimits.exempt_api_keys.len();
1403        settings
1404            .ratelimits
1405            .exempt_api_keys
1406            .retain(|uuid| existing.contains(uuid));
1407        let removed = before - settings.ratelimits.exempt_api_keys.len();
1408
1409        if removed > 0 {
1410            settings.save().await?;
1411        }
1412
1413        Ok(removed)
1414    }
1415
1416    pub async fn invalidate_cache(&self) {
1417        let Ok(_lock) = self.write_serializing.acquire().await else {
1418            return;
1419        };
1420        let index = self.cached_index.load(Ordering::Acquire);
1421        let inactive_index = (index + 1) % 2;
1422        self.cached[inactive_index].write().await.expires = std::time::Instant::now();
1423        self.cached_index.store(inactive_index, Ordering::Release);
1424    }
1425}
1426
1427#[cfg(test)]
1428mod tests {
1429    use super::*;
1430
1431    #[test]
1432    fn storage_driver_censors_its_s3_credentials() {
1433        let value = serde_json::to_value(
1434            StorageDriver::S3 {
1435                public_url: "https://cdn.example.com".into(),
1436                access_key: "AKIAEXAMPLE".into(),
1437                secret_key: "s3cr3t".into(),
1438                bucket: "assets".into(),
1439                region: "eu-central-1".into(),
1440                endpoint: "https://s3.example.com".into(),
1441                path_style: false,
1442            }
1443            .censored(),
1444        )
1445        .unwrap();
1446
1447        assert_eq!(value["access_key"], CENSORED_PLACEHOLDER);
1448        assert_eq!(value["secret_key"], CENSORED_PLACEHOLDER);
1449
1450        assert_eq!(value["public_url"], "https://cdn.example.com");
1451        assert_eq!(value["bucket"], "assets");
1452        assert_eq!(value["region"], "eu-central-1");
1453    }
1454
1455    #[test]
1456    fn mail_mode_censors_its_smtp_credentials() {
1457        let value = serde_json::to_value(
1458            MailMode::Smtp {
1459                host: "smtp.example.com".into(),
1460                port: 587,
1461                username: Some("panel@example.com".into()),
1462                password: Some("hunter2".into()),
1463                tls_mode: TlsMode::StartTls,
1464                skip_cert_validation: false,
1465                helo_domain: None,
1466                from_address: "panel@example.com".into(),
1467                from_name: Some("Panel".into()),
1468            }
1469            .censored(),
1470        )
1471        .unwrap();
1472
1473        assert_eq!(value["username"], CENSORED_PLACEHOLDER);
1474        assert_eq!(value["password"], CENSORED_PLACEHOLDER);
1475
1476        assert_eq!(value["host"], "smtp.example.com");
1477        assert_eq!(value["from_address"], "panel@example.com");
1478    }
1479
1480    #[test]
1481    fn captcha_provider_censors_its_secret_but_not_its_site_key() {
1482        for provider in [
1483            CaptchaProvider::Cap {
1484                api_url: "https://cap.example.com".into(),
1485                site_key: "public".into(),
1486                secret_key: "s3cr3t".into(),
1487            },
1488            CaptchaProvider::Turnstile {
1489                site_key: "public".into(),
1490                secret_key: "s3cr3t".into(),
1491            },
1492            CaptchaProvider::Recaptcha {
1493                v3: true,
1494                threshold: 0.5,
1495                site_key: "public".into(),
1496                secret_key: "s3cr3t".into(),
1497            },
1498            CaptchaProvider::Hcaptcha {
1499                site_key: "public".into(),
1500                secret_key: "s3cr3t".into(),
1501            },
1502        ] {
1503            let value = serde_json::to_value(provider.censored()).unwrap();
1504
1505            assert_eq!(value["secret_key"], CENSORED_PLACEHOLDER);
1506            assert_eq!(value["site_key"], "public");
1507        }
1508
1509        let value = serde_json::to_value(
1510            CaptchaProvider::FriendlyCaptcha {
1511                site_key: "public".into(),
1512                api_key: "s3cr3t".into(),
1513            }
1514            .censored(),
1515        )
1516        .unwrap();
1517
1518        assert_eq!(value["api_key"], CENSORED_PLACEHOLDER);
1519        assert_eq!(value["site_key"], "public");
1520    }
1521
1522    #[test]
1523    fn cap_provider_validates_configuration_and_keeps_secrets_private() {
1524        for api_url in ["https://cap.example.com", "http://localhost:3000/cap/"] {
1525            let provider = CaptchaProvider::Cap {
1526                api_url: api_url.into(),
1527                site_key: "site-key_123".into(),
1528                secret_key: "secret".into(),
1529            };
1530            assert!(provider.validate().is_ok());
1531            let public = serde_json::to_value(provider.to_public_provider()).unwrap();
1532            assert_eq!(public["api_url"], api_url);
1533            assert_eq!(public["site_key"], "site-key_123");
1534            assert!(public.get("secret_key").is_none());
1535
1536            let serialized = serde_json::to_value(&provider).unwrap();
1537            let restored: CaptchaProvider = serde_json::from_value(serialized.clone()).unwrap();
1538            assert_eq!(serde_json::to_value(restored).unwrap(), serialized);
1539        }
1540
1541        for api_url in [
1542            "not a url",
1543            "file:///tmp/cap",
1544            "https://user:password@cap.example.com",
1545            "https://cap.example.com?query",
1546            "https://cap.example.com#fragment",
1547            "https://cap.example.com ",
1548            " https://cap.example.com",
1549        ] {
1550            let provider = CaptchaProvider::Cap {
1551                api_url: api_url.into(),
1552                site_key: "public".into(),
1553                secret_key: "secret".into(),
1554            };
1555            assert!(provider.validate().is_err(), "{api_url}");
1556        }
1557
1558        for site_key in ["", "../other", "site/key", "site?query", "site#fragment"] {
1559            let provider = CaptchaProvider::Cap {
1560                api_url: "https://cap.example.com".into(),
1561                site_key: site_key.into(),
1562                secret_key: "secret".into(),
1563            };
1564            assert!(provider.validate().is_err(), "{site_key}");
1565        }
1566    }
1567
1568    #[test]
1569    fn recaptcha_threshold_defaults_and_validates_bounds() {
1570        let provider: CaptchaProvider = serde_json::from_value(serde_json::json!({
1571            "type": "recaptcha",
1572            "v3": true,
1573            "site_key": "public",
1574            "secret_key": "secret",
1575        }))
1576        .unwrap();
1577        assert_eq!(serde_json::to_value(provider).unwrap()["threshold"], 0.5);
1578
1579        for threshold in [
1580            0.0,
1581            0.5,
1582            0.9,
1583            1.0,
1584            -0.1,
1585            1.1,
1586            f64::NAN,
1587            f64::INFINITY,
1588            f64::NEG_INFINITY,
1589        ] {
1590            let provider = CaptchaProvider::Recaptcha {
1591                v3: true,
1592                threshold,
1593                site_key: "public".into(),
1594                secret_key: "secret".into(),
1595            };
1596            assert_eq!(
1597                provider.validate().is_ok(),
1598                (0.0..=1.0).contains(&threshold)
1599            );
1600            let public = serde_json::to_value(provider.to_public_provider()).unwrap();
1601            assert!(public.get("secret_key").is_none());
1602            assert!(public.get("threshold").is_none());
1603        }
1604    }
1605
1606    #[test]
1607    fn settings_without_secrets_are_left_untouched() {
1608        let value = serde_json::to_value(
1609            StorageDriver::Filesystem {
1610                path: "/var/lib/panel/storage".into(),
1611            }
1612            .censored(),
1613        )
1614        .unwrap();
1615
1616        assert_eq!(value["path"], "/var/lib/panel/storage");
1617
1618        let value = serde_json::to_value(MailMode::None.censored()).unwrap();
1619
1620        assert_eq!(value["type"], "none");
1621
1622        let value = serde_json::to_value(CaptchaProvider::None.censored()).unwrap();
1623
1624        assert_eq!(value["type"], "none");
1625    }
1626}