Skip to main content

server/
config.rs

1use crate::db::get_connection;
2use derive_more::From;
3use rust_i18n::t;
4use sqlx::Error::RowNotFound;
5use std::env::var;
6use std::fmt;
7use std::sync::LazyLock;
8use thiserror::Error;
9
10/// Represents an error that can occur while accessing the configuration.
11///
12/// # Variants
13///
14/// - `NoConfig(String)`: Returned when a specific configuration field is not found.
15/// - `DB`: Indicates a database access error.
16/// - `Sqlx`: An error propagated from the `SQLx` crate.
17///
18/// # Example
19///
20/// ```rust
21/// use thiserror::Error;
22///
23/// #[derive(Debug, Error)]
24/// pub enum ConfigError {
25///     #[error("No such config field: {0}")]
26///     NoConfig(String),
27///     #[error("Can't access db")]
28///     DB,
29///     #[error("Sqlx")]
30///     Sqlx(#[from] sqlx::Error),
31/// }
32/// ```
33#[derive(Debug, Error)]
34pub enum ConfigError {
35    #[error("No such config field: {0}")]
36    NoConfig(String),
37    #[error("Can't access db")]
38    DB,
39    // The underlying error is carried through: swallowing it turned a unique
40    // violation into the word "Sqlx" in the log, which names no cause at all.
41    #[error("{0}")]
42    Sqlx(#[from] sqlx::Error),
43}
44
45/// Represents a configuration value stored in the system.
46///
47/// # Variants
48///
49/// - `String`: Stores the configuration as a string.
50/// - `Blob`: Stores the configuration as a binary blob.
51///
52/// # Example
53///
54/// ```rust
55/// use server::config::ConfigOption;
56/// let config_str = ConfigOption::String("example".to_string());
57/// let config_blob = ConfigOption::Blob(vec![1, 2, 3, 4]);
58/// ```
59///
60/// You can also convert `String` and `Vec<u8>` directly into `ConfigOption`
61/// using the `From` trait:
62///
63/// ```rust
64/// use server::config::ConfigOption;
65/// let config: ConfigOption = "example".to_string().into();
66/// let blob: ConfigOption = vec![1, 2, 3, 4].into();
67/// ```
68#[derive(Debug, From, PartialEq, Clone)]
69pub enum ConfigOption {
70    String(#[from] String),
71    Blob(#[from] Vec<u8>),
72}
73
74impl fmt::Display for ConfigOption {
75    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
76        match self {
77            ConfigOption::String(s) => write!(f, "{s}"),
78            ConfigOption::Blob(b) => write!(f, "ConfigOption<Blob>: \"{}\" bytes", b.len()),
79        }
80    }
81}
82
83/// Allows for the conversion from a `&str` to `ConfigOption`.
84///
85/// This is useful for passing string literals or string slices directly as `ConfigOption`.
86/// The conversion will wrap the `&str` in the `ConfigOption::String` variant.
87///
88/// # Example
89///
90/// ```rust
91/// use server::config::ConfigOption;
92/// let config_option: ConfigOption = "example text".into();
93/// assert_eq!(config_option, ConfigOption::String("example text".to_string()));
94/// ```
95impl From<&str> for ConfigOption {
96    fn from(value: &str) -> Self {
97        ConfigOption::String(value.to_string())
98    }
99}
100
101/// Convert `ConfigOption` into a `&[u8]` reference when the variant is a `Blob`.
102///
103/// # Example
104///
105/// ```rust
106/// use server::config::ConfigOption;
107/// let config = ConfigOption::Blob(vec![1, 2, 3]);
108/// let byte_ref: &[u8] = config.as_ref(); // Works
109/// ```
110impl AsRef<[u8]> for ConfigOption {
111    fn as_ref(&self) -> &[u8] {
112        match self {
113            ConfigOption::String(s) => s.as_bytes(),
114            ConfigOption::Blob(b) => b.as_slice(),
115        }
116    }
117}
118
119// The config read/write logic is identical for the system (admin DB) and
120// per-user surfaces — only the connection differs. The `*_on` helpers take an
121// explicit connection so both surfaces share one implementation; `User::config`
122// / `User::set_config` (see `user.rs`) pass a per-user connection, while the
123// `system_*` wrappers below pass the global admin connection.
124
125/// Fetches the `contents` of a field from the configdata.
126///
127/// This function attempts to retrieve the configuration stored in the
128/// `configdata` table.  If the field is not found, it returns a
129/// `ConfigError::NoConfig`.
130///
131/// # Arguments
132///
133/// - `field`: The name of the field to retrieve.
134///
135/// # Returns
136///
137/// - `Ok(Some(ConfigOption))` if the field exists.
138/// - `Err(ConfigError::NoConfig)` if the field is absent from both the
139///   `config` and `configdata` tables (callers that treat "absent" as
140///   "not set" — e.g. the `GetConfig` command — normalise this to `Ok(None)`).
141/// - `Err(ConfigError)` on any other retrieval error.
142///
143/// # Example
144///
145/// ```rust,ignore
146/// let config = system_configdata("example_field").await?;
147/// if let Some(option) = config {
148///     println!("Config value: {}", option);
149/// }
150/// ```
151///
152/// # Errors
153///
154/// Returns a `ConfigError::DB` if there is an issue with the database connection.
155async fn configdata_on(
156    conn: &mut sqlx::PgConnection,
157    field: &str,
158) -> Result<Option<ConfigOption>, ConfigError> {
159    let value = sqlx::query_file_scalar!("../server/sql/select/config/data.sql", field)
160        .fetch_one(&mut *conn)
161        .await
162        .map_err(|err| {
163            if let RowNotFound = err {
164                ConfigError::NoConfig(String::from(field))
165            } else {
166                log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
167                ConfigError::DB
168            }
169        })?;
170
171    Ok(Some(ConfigOption::Blob(value)))
172}
173
174/// Reads a config field on the given connection: the `config` (string) table
175/// first, falling back to `configdata` (blob).
176///
177/// # Errors
178/// `ConfigError::DB` on a database error.
179pub async fn config_on(
180    conn: &mut sqlx::PgConnection,
181    field: &str,
182) -> Result<Option<ConfigOption>, ConfigError> {
183    let value = sqlx::query_file_scalar!("../server/sql/select/config/string.sql", field)
184        .fetch_one(&mut *conn)
185        .await;
186    match value {
187        Ok(l) => Ok(Some(ConfigOption::String(l))),
188        Err(RowNotFound) => configdata_on(conn, field).await,
189        Err(err) => {
190            log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
191            Err(ConfigError::DB)
192        }
193    }
194}
195
196/// Upserts a config field on the given connection (string → `config`, blob →
197/// `configdata`). The upsert keys on `lower(field)` (see the SQL), so it is
198/// idempotent and case-insensitive.
199///
200/// # Errors
201/// `ConfigError::DB` on a database error, `ConfigError::NoConfig` if the row is
202/// missing for an update.
203pub async fn set_config_on(
204    conn: &mut sqlx::PgConnection,
205    field: &str,
206    contents: ConfigOption,
207) -> Result<(), ConfigError> {
208    let id = uuid::Uuid::new_v4();
209    match contents {
210        ConfigOption::String(s) => {
211            sqlx::query_file!("../server/sql/set/config/string.sql", &id, field, s)
212        }
213        ConfigOption::Blob(b) => {
214            sqlx::query_file!("../server/sql/set/config/blob.sql", &id, field, b)
215        }
216    }
217    .execute(&mut *conn)
218    .await
219    .map_err(|err| {
220        if let RowNotFound = err {
221            ConfigError::NoConfig(String::from(field))
222        } else {
223            log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
224            ConfigError::DB
225        }
226    })?;
227
228    Ok(())
229}
230
231/// Reads a SYSTEM (server-wide) config field from the global admin database.
232/// For server-wide keys (infra URLs, locale) and the bootstrap reads that run
233/// before any user is resolved. Per-user config goes through `User::config`.
234///
235/// # Errors
236/// `ConfigError::DB` on a database error.
237pub async fn system_config(field: &str) -> Result<Option<ConfigOption>, ConfigError> {
238    let mut conn = get_connection().await.map_err(|err| {
239        log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
240        ConfigError::DB
241    })?;
242    config_on(&mut conn, field).await
243}
244
245/// Reads a SYSTEM config blob field from the global admin database.
246///
247/// # Errors
248/// `ConfigError::DB` on a database error.
249pub async fn system_configdata(field: &str) -> Result<Option<ConfigOption>, ConfigError> {
250    let mut conn = get_connection().await.map_err(|err| {
251        log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
252        ConfigError::DB
253    })?;
254    configdata_on(&mut conn, field).await
255}
256
257/// Writes a SYSTEM config field to the global admin database.
258///
259/// # Errors
260/// `ConfigError::DB` on a database error.
261pub async fn set_system_config(field: &str, contents: ConfigOption) -> Result<(), ConfigError> {
262    let mut conn = get_connection().await.map_err(|err| {
263        log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
264        ConfigError::DB
265    })?;
266    set_config_on(&mut conn, field, contents).await
267}
268
269static LOCALE_NAME: LazyLock<String> = LazyLock::new(|| var("LANG").unwrap_or(String::from("en")));
270
271pub async fn load_config() -> Result<(), ConfigError> {
272    // Matched rather than deref'd: only the String variant names a locale, and
273    // a Blob in that row should fall back rather than panic the boot.
274    if let Ok(Some(ConfigOption::String(locale))) = system_config("locale").await {
275        rust_i18n::set_locale(&locale);
276        log::info!(
277            "{}",
278            t!(
279                "Loaded the %{loc} locale from config, setting as main",
280                loc = &locale
281            )
282        );
283    } else {
284        rust_i18n::set_locale(&LOCALE_NAME);
285    }
286    Ok(())
287}
288
289#[cfg(test)]
290mod config_tests {
291    use super::*;
292    use crate::db::DB_POOL;
293    use sqlx::PgPool;
294    use supp_macro::local_db_sqlx_test;
295    use tokio::sync::OnceCell;
296
297    /// Context for keeping environment intact
298    static CONTEXT: OnceCell<()> = OnceCell::const_new();
299
300    async fn setup() {
301        CONTEXT
302            .get_or_init(|| async {
303                #[cfg(feature = "testlog")]
304                let _ = env_logger::builder()
305                    .is_test(true)
306                    .filter_level(log::LevelFilter::Trace)
307                    .try_init();
308            })
309            .await;
310    }
311
312    #[local_db_sqlx_test]
313    async fn test_config_string(pool: PgPool) {
314        let opt = ConfigOption::String("testval".to_string());
315        set_system_config("testfield", opt.clone()).await.unwrap();
316        let val = system_config("testfield").await.unwrap().unwrap();
317        assert_eq!(val, opt);
318    }
319
320    #[local_db_sqlx_test]
321    async fn test_config_configdata(pool: PgPool) {
322        let opt = ConfigOption::Blob(vec![0, 1, 2, 3]);
323        set_system_config("testblob", opt.clone()).await.unwrap();
324        let val = system_config("testblob").await.unwrap().unwrap();
325        assert_eq!(val, opt);
326    }
327}