1
use crate::db::get_connection;
2
use derive_more::From;
3
use rust_i18n::t;
4
use sqlx::Error::RowNotFound;
5
use std::env::var;
6
use std::fmt;
7
use std::sync::LazyLock;
8
use 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)]
34
pub 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)]
69
pub enum ConfigOption {
70
    String(#[from] String),
71
    Blob(#[from] Vec<u8>),
72
}
73

            
74
impl fmt::Display for ConfigOption {
75
78
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
76
78
        match self {
77
78
            ConfigOption::String(s) => write!(f, "{s}"),
78
            ConfigOption::Blob(b) => write!(f, "ConfigOption<Blob>: \"{}\" bytes", b.len()),
79
        }
80
78
    }
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
/// ```
95
impl From<&str> for ConfigOption {
96
156
    fn from(value: &str) -> Self {
97
156
        ConfigOption::String(value.to_string())
98
156
    }
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
/// ```
110
impl 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.
155
196
async fn configdata_on(
156
196
    conn: &mut sqlx::PgConnection,
157
196
    field: &str,
158
196
) -> Result<Option<ConfigOption>, ConfigError> {
159
196
    let value = sqlx::query_file_scalar!("../server/sql/select/config/data.sql", field)
160
196
        .fetch_one(&mut *conn)
161
196
        .await
162
196
        .map_err(|err| {
163
195
            if let RowNotFound = err {
164
195
                ConfigError::NoConfig(String::from(field))
165
            } else {
166
                log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
167
                ConfigError::DB
168
            }
169
195
        })?;
170

            
171
1
    Ok(Some(ConfigOption::Blob(value)))
172
196
}
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.
179
354
pub async fn config_on(
180
354
    conn: &mut sqlx::PgConnection,
181
354
    field: &str,
182
354
) -> Result<Option<ConfigOption>, ConfigError> {
183
354
    let value = sqlx::query_file_scalar!("../server/sql/select/config/string.sql", field)
184
354
        .fetch_one(&mut *conn)
185
354
        .await;
186
196
    match value {
187
158
        Ok(l) => Ok(Some(ConfigOption::String(l))),
188
196
        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
354
}
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.
203
198
pub async fn set_config_on(
204
198
    conn: &mut sqlx::PgConnection,
205
198
    field: &str,
206
198
    contents: ConfigOption,
207
198
) -> Result<(), ConfigError> {
208
198
    let id = uuid::Uuid::new_v4();
209
198
    match contents {
210
197
        ConfigOption::String(s) => {
211
197
            sqlx::query_file!("../server/sql/set/config/string.sql", &id, field, s)
212
        }
213
1
        ConfigOption::Blob(b) => {
214
1
            sqlx::query_file!("../server/sql/set/config/blob.sql", &id, field, b)
215
        }
216
    }
217
198
    .execute(&mut *conn)
218
198
    .await
219
198
    .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
198
    Ok(())
229
198
}
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.
237
158
pub async fn system_config(field: &str) -> Result<Option<ConfigOption>, ConfigError> {
238
158
    let mut conn = get_connection().await.map_err(|err| {
239
        log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
240
        ConfigError::DB
241
    })?;
242
158
    config_on(&mut conn, field).await
243
158
}
244

            
245
/// Reads a SYSTEM config blob field from the global admin database.
246
///
247
/// # Errors
248
/// `ConfigError::DB` on a database error.
249
pub 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.
261
2
pub async fn set_system_config(field: &str, contents: ConfigOption) -> Result<(), ConfigError> {
262
2
    let mut conn = get_connection().await.map_err(|err| {
263
        log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
264
        ConfigError::DB
265
    })?;
266
2
    set_config_on(&mut conn, field, contents).await
267
2
}
268

            
269
static LOCALE_NAME: LazyLock<String> = LazyLock::new(|| var("LANG").unwrap_or(String::from("en")));
270

            
271
pub 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)]
290
mod 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
2
    async fn setup() {
301
2
        CONTEXT
302
2
            .get_or_init(|| async {
303
                #[cfg(feature = "testlog")]
304
1
                let _ = env_logger::builder()
305
1
                    .is_test(true)
306
1
                    .filter_level(log::LevelFilter::Trace)
307
1
                    .try_init();
308
2
            })
309
2
            .await;
310
2
    }
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
}