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 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
1011unsafe 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 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 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 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 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}