Skip to main content

vyuh/
conf.rs

1//! Configuration system.
2//!
3//! Secrets and deployment-specific values go in env vars.
4//! Project structure and logic go in source code.
5
6use std::{ffi::OsString, path::PathBuf};
7
8use crate::{
9    auth::{AuthConf, JwtKeySource},
10    channels::ChannelConf,
11    console::ConsoleConf,
12    db::DbConf,
13    emitters::EmitterConf,
14    errors::ErrorConf,
15    file_storage::UploadConf,
16    logging,
17    middlewares::HttpConf,
18    tasks::TaskConf,
19    templates::TemplateConf,
20};
21use serde::{Deserialize, Serialize};
22use thiserror::Error;
23
24#[derive(Debug, Error)]
25pub enum ConfError {
26    #[error("missing required field '{field}': {reason}")]
27    RequiredField { field: String, reason: String },
28
29    #[error("invalid value for '{field}': {reason}{}", expected.as_ref().map(|e| format!(" (expected: {})", e)).unwrap_or_default())]
30    InvalidValue {
31        field: String,
32        reason: String,
33        expected: Option<String>,
34    },
35
36    #[error("invalid path for '{field}' at '{path}': {reason}")]
37    InvalidPath {
38        field: String,
39        path: String,
40        reason: String,
41    },
42
43    #[error("validation failed with {} error(s):\n{}", .0.len(), ConfError::display_many(.0))]
44    Many(Vec<ConfError>),
45
46    #[error("missing required field: {0}")]
47    MissingField(String),
48
49    #[error("{0}")]
50    Other(String),
51}
52
53impl ConfError {
54    fn display_many(errors: &[ConfError]) -> String {
55        errors
56            .iter()
57            .map(|e| format!("- {}", e))
58            .collect::<Vec<_>>()
59            .join("\n")
60    }
61}
62
63pub fn workspace_root(crate_dir: OsString) -> PathBuf {
64    let mut dir = PathBuf::from(crate_dir);
65
66    loop {
67        let cargo = dir.join("Cargo.toml");
68
69        if cargo.exists() {
70            if let Ok(content) = std::fs::read_to_string(&cargo) {
71                if content.contains("[workspace]") {
72                    return dir;
73                }
74            }
75        }
76
77        if !dir.pop() {
78            break;
79        }
80    }
81
82    PathBuf::from(env!("CARGO_MANIFEST_DIR"))
83}
84
85pub fn project_dir() -> PathBuf {
86    if let Some(crate_dir) = std::env::var_os("CARGO_MANIFEST_DIR") {
87        workspace_root(crate_dir)
88    } else {
89        std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."))
90    }
91}
92
93fn default_secret_key() -> String {
94    format!(
95        "dev-secret-{}-replace-before-production",
96        env!("CARGO_PKG_NAME")
97    )
98}
99
100#[derive(Serialize, Deserialize, Debug, Clone)]
101#[serde(rename_all = "snake_case")]
102pub struct SiteConf {
103    pub host: String,
104
105    pub port: u16,
106
107    pub project_dir: String,
108
109    pub database: DbConf,
110
111    #[serde(default = "default_secret_key")]
112    pub secret_key: String,
113
114    /// absolute or relative to project_dir
115    pub media_dir: Option<String>,
116
117    /// Template environment behavior. Template files are registered through bundles.
118    pub templates: TemplateConf,
119
120    /// absolute or relative to project_dir
121    pub touch_reload: Option<String>,
122
123    pub log_init: bool,
124
125    pub tz: Option<String>,
126
127    pub auth: AuthConf,
128
129    pub tasks: TaskConf,
130
131    pub uploads: UploadConf,
132
133    pub channels: ChannelConf,
134
135    pub console: ConsoleConf,
136
137    #[serde(default)]
138    pub emitters: EmitterConf,
139
140    pub logging: logging::LoggingConf,
141
142    pub http: HttpConf,
143
144    #[serde(skip)]
145    pub errors: ErrorConf,
146}
147
148impl Default for SiteConf {
149    fn default() -> Self {
150        let secret_key = default_secret_key();
151        Self {
152            host: "localhost".to_string(),
153            port: 8080,
154            project_dir: project_dir().as_os_str().to_string_lossy().to_string(),
155            database: Default::default(),
156            secret_key,
157            media_dir: None,
158            templates: TemplateConf::default(),
159            touch_reload: None,
160            log_init: true,
161            tz: None,
162            auth: AuthConf::default(),
163            tasks: TaskConf::default(),
164            uploads: UploadConf::default(),
165            channels: ChannelConf::default(),
166            console: ConsoleConf::default(),
167            emitters: EmitterConf::default(),
168            logging: logging::LoggingConf::default(),
169            http: HttpConf::default(),
170            errors: ErrorConf::default(),
171        }
172    }
173}
174
175impl SiteConf {
176    /// Apply env vars as patches. Errors if invalid format.
177    pub fn with_env(mut self) -> Result<Self, ConfError> {
178        apply_env_patches(&mut self, None)?;
179        Ok(self)
180    }
181
182    /// Parse from loaded env vars, no validation.
183    pub fn from_env() -> Result<Self, ConfError> {
184        Self::default().with_env()
185    }
186
187    /// Load .env files and parse env vars.
188    pub fn from_env_with_files() -> Result<Self, ConfError> {
189        Self::load_env_files();
190        Self::default().with_env()
191    }
192
193    /// Load .env files by build config.
194    pub fn load_env_files() {
195        dotenvy::dotenv().ok();
196
197        #[cfg(test)]
198        dotenvy::from_filename_override(".env.test").ok();
199
200        #[cfg(all(debug_assertions, not(test)))]
201        dotenvy::from_filename_override(".env.dev").ok();
202
203        #[cfg(not(any(debug_assertions, test)))]
204        dotenvy::from_filename_override(".env.prod").ok();
205    }
206
207    /// Load .env from path.
208    pub fn load_env_file(path: &str) {
209        if let Err(e) = dotenvy::from_filename_override(path) {
210            tracing::warn!("Failed to load env file {}: {}", path, e);
211        }
212    }
213
214    // Chainable setter methods
215
216    pub fn host(mut self, host: impl Into<String>) -> Self {
217        self.host = host.into();
218        self
219    }
220
221    pub fn port(mut self, port: u16) -> Self {
222        self.port = port;
223        self
224    }
225
226    pub fn project_dir(mut self, dir: impl Into<String>) -> Self {
227        self.project_dir = dir.into();
228        self
229    }
230
231    pub fn database(mut self, database: DbConf) -> Self {
232        self.database = database;
233        self
234    }
235
236    pub fn secret_key(mut self, key: impl Into<String>) -> Self {
237        self.secret_key = key.into();
238        self
239    }
240
241    pub fn media_dir(mut self, dir: impl Into<String>) -> Self {
242        self.media_dir = Some(dir.into());
243        self
244    }
245
246    pub fn templates(mut self, templates: TemplateConf) -> Self {
247        self.templates = templates;
248        self
249    }
250
251    pub fn uploads(mut self, uploads: UploadConf) -> Self {
252        self.uploads = uploads;
253        self
254    }
255
256    pub fn channels(mut self, channels: ChannelConf) -> Self {
257        self.channels = channels;
258        self
259    }
260
261    pub fn console(mut self, console: ConsoleConf) -> Self {
262        self.console = console;
263        self
264    }
265
266    pub fn emitters(mut self, emitters: EmitterConf) -> Self {
267        self.emitters = emitters;
268        self
269    }
270
271    pub fn http(mut self, http: HttpConf) -> Self {
272        self.http = http;
273        self
274    }
275
276    pub fn touch_reload(mut self, path: impl Into<String>) -> Self {
277        self.touch_reload = Some(path.into());
278        self
279    }
280
281    pub fn log_init(mut self, enable: bool) -> Self {
282        self.log_init = enable;
283        self
284    }
285
286    pub fn timezone(mut self, tz: impl Into<String>) -> Self {
287        self.tz = Some(tz.into());
288        self
289    }
290
291    pub fn auth(mut self, auth: AuthConf) -> Self {
292        self.auth = auth;
293        self
294    }
295
296    pub fn tasks(mut self, tasks: TaskConf) -> Self {
297        self.tasks = tasks;
298        self
299    }
300
301    pub fn logging(mut self, logging: logging::LoggingConf) -> Self {
302        self.logging = logging;
303        self
304    }
305
306    pub fn errors(mut self, errors: ErrorConf) -> Self {
307        self.errors = errors;
308        self
309    }
310
311    /// Validate config. Returns Ok(()) if valid, or Err(ConfError::Many) with all errors.
312    pub fn validate(&self) -> Result<(), ConfError> {
313        let mut errors = Vec::new();
314
315        self.validate_required(&mut errors);
316        self.validate_database(&mut errors);
317        self.validate_paths(&mut errors);
318        self.console.validate(&mut errors);
319
320        if errors.is_empty() {
321            Ok(())
322        } else {
323            Err(ConfError::Many(errors))
324        }
325    }
326
327    fn validate_required(&self, errors: &mut Vec<ConfError>) {
328        if self.secret_key.is_empty() {
329            errors.push(ConfError::RequiredField {
330                field: "secret_key".into(),
331                reason: "cannot be empty".into(),
332            });
333        } else if matches!(self.auth.jwt.signing_key, JwtKeySource::SiteSecret)
334            && self.secret_key.len() < self.auth.min_secret_len
335        {
336            errors.push(ConfError::InvalidValue {
337                field: "secret_key".into(),
338                reason: format!(
339                    "must be at least {} characters for auth signing",
340                    self.auth.min_secret_len
341                ),
342                expected: Some(format!("{} or more characters", self.auth.min_secret_len)),
343            });
344        }
345        #[cfg(not(debug_assertions))]
346        {
347            let default_key = default_secret_key();
348            if self.secret_key == default_key {
349                errors.push(ConfError::InvalidValue {
350                    field: "secret_key".into(),
351                    reason: "must not be the default value in release builds".into(),
352                    expected: Some("a custom secret key".into()),
353                });
354            }
355        }
356        if self.port == 0 {
357            errors.push(ConfError::InvalidValue {
358                field: "port".into(),
359                reason: "must be non-zero".into(),
360                expected: Some("1-65535".into()),
361            });
362        }
363        if self.host.is_empty() {
364            errors.push(ConfError::RequiredField {
365                field: "host".into(),
366                reason: "cannot be empty".into(),
367            });
368        }
369    }
370
371    fn validate_database(&self, errors: &mut Vec<ConfError>) {
372        #[cfg(not(debug_assertions))]
373        {
374            if self.database.lazy {
375                errors.push(ConfError::InvalidValue {
376                    field: "database.lazy".into(),
377                    reason: "must be false in release builds".into(),
378                    expected: Some("false".into()),
379                });
380            }
381        }
382        if self.database.url.is_empty() {
383            errors.push(ConfError::RequiredField {
384                field: "database.url".into(),
385                reason: "cannot be empty".into(),
386            });
387        }
388        if self.database.max_connections == 0 {
389            errors.push(ConfError::InvalidValue {
390                field: "database.max_connections".into(),
391                reason: "must be non-zero".into(),
392                expected: Some("positive integer".into()),
393            });
394        }
395        if self.database.min_connections > self.database.max_connections {
396            errors.push(ConfError::InvalidValue {
397                field: "database.min_connections".into(),
398                reason: format!(
399                    "cannot exceed max_connections ({} > {})",
400                    self.database.min_connections, self.database.max_connections
401                ),
402                expected: Some(format!("<= {}", self.database.max_connections)),
403            });
404        }
405    }
406
407    fn validate_paths(&self, errors: &mut Vec<ConfError>) {
408        let base = PathBuf::from(&self.project_dir);
409
410        validate_dir_readable(&base, "project_dir", errors);
411
412        if let Some(ref dir) = self.media_dir {
413            validate_dir_writable(&base, dir, "media_dir", errors);
414        }
415        validate_upload_dir(&base, &self.uploads.dir, "uploads.dir", errors);
416        if let Some(ref dir) = self.uploads.temp_dir {
417            validate_upload_dir(&base, dir, "uploads.temp_dir", errors);
418        }
419        if let Some(ref file) = self.touch_reload {
420            validate_file_writable(&base, file, "touch_reload", errors);
421        }
422    }
423}
424
425fn apply_env_patches(conf: &mut SiteConf, prefix: Option<&str>) -> Result<(), ConfError> {
426    let strip_prefix = |key: &str, pref: Option<&str>| -> String {
427        pref.and_then(|p| key.strip_prefix(p))
428            .unwrap_or(key)
429            .to_lowercase()
430    };
431
432    for (key, value) in std::env::vars() {
433        if let Some(pref) = prefix {
434            if !key.starts_with(pref) {
435                continue;
436            }
437        }
438
439        let field_name = strip_prefix(&key, prefix);
440
441        match field_name.as_str() {
442            "database_url" => match DbConf::from_url(&value) {
443                Ok(db) => conf.database = db,
444                Err(e) => {
445                    return Err(ConfError::Other(format!("Database config error: {}", e)));
446                }
447            },
448            "secret_key" => conf.secret_key = value,
449            "host" => conf.host = value,
450            "port" => match value.parse::<u16>() {
451                Ok(p) => conf.port = p,
452                Err(_) => {
453                    return Err(ConfError::Other(format!(
454                        "PORT must be a valid u16, got: {}",
455                        value
456                    )));
457                }
458            },
459            "tz" => conf.tz = Some(value),
460            "log_init" => match value.parse::<bool>() {
461                Ok(b) => conf.log_init = b,
462                Err(_) => {
463                    return Err(ConfError::Other(format!(
464                        "LOG_INIT must be 'true' or 'false', got: {}",
465                        value
466                    )));
467                }
468            },
469            _ => {} // Ignore unknown fields
470        }
471    }
472    Ok(())
473}
474
475fn validate_dir_readable(path: &PathBuf, field: &str, errors: &mut Vec<ConfError>) {
476    if !path.exists() {
477        errors.push(ConfError::InvalidPath {
478            field: field.into(),
479            path: path.display().to_string(),
480            reason: "directory does not exist".into(),
481        });
482        return;
483    }
484    if !path.is_dir() {
485        errors.push(ConfError::InvalidPath {
486            field: field.into(),
487            path: path.display().to_string(),
488            reason: "not a directory".into(),
489        });
490        return;
491    }
492    if let Err(e) = std::fs::read_dir(path) {
493        errors.push(ConfError::InvalidPath {
494            field: field.into(),
495            path: path.display().to_string(),
496            reason: format!("cannot read directory: {}", e),
497        });
498    }
499}
500
501fn validate_dir_writable(base: &PathBuf, dir: &str, field: &str, errors: &mut Vec<ConfError>) {
502    if dir.is_empty() {
503        return;
504    }
505    let path = base.join(dir);
506    validate_dir_readable(&path, field, errors);
507
508    if path.exists() && path.is_dir() {
509        let test_file = path.join(format!(".vyuh_dir_write_{}", std::process::id()));
510        if std::fs::write(&test_file, b"").is_err() {
511            errors.push(ConfError::InvalidPath {
512                field: field.into(),
513                path: path.display().to_string(),
514                reason: "directory is not writable".into(),
515            });
516        } else {
517            let _ = std::fs::remove_file(test_file);
518        }
519    }
520}
521
522fn validate_upload_dir(base: &PathBuf, dir: &str, field: &str, errors: &mut Vec<ConfError>) {
523    if dir.is_empty() {
524        errors.push(ConfError::RequiredField {
525            field: field.into(),
526            reason: "cannot be empty".into(),
527        });
528        return;
529    }
530    let path = base.join(dir);
531    if path.exists() {
532        validate_dir_writable(base, dir, field, errors);
533        return;
534    }
535    let Some(parent) = path.parent() else {
536        return;
537    };
538    if !parent.exists() {
539        errors.push(ConfError::InvalidPath {
540            field: field.into(),
541            path: parent.display().to_string(),
542            reason: "parent directory does not exist".into(),
543        });
544    }
545}
546
547fn validate_file_writable(base: &PathBuf, file: &str, field: &str, errors: &mut Vec<ConfError>) {
548    if file.is_empty() {
549        return;
550    }
551    let path = base.join(file);
552
553    if let Some(parent) = path.parent() {
554        if !parent.exists() {
555            errors.push(ConfError::InvalidPath {
556                field: field.into(),
557                path: parent.display().to_string(),
558                reason: "parent directory does not exist".into(),
559            });
560            return;
561        }
562        if !parent.is_dir() {
563            errors.push(ConfError::InvalidPath {
564                field: field.into(),
565                path: parent.display().to_string(),
566                reason: "parent is not a directory".into(),
567            });
568            return;
569        }
570
571        let test_file = parent.join(format!(".vyuh_touch_write_{}", std::process::id()));
572        if std::fs::write(&test_file, b"").is_err() {
573            errors.push(ConfError::InvalidPath {
574                field: field.into(),
575                path: parent.display().to_string(),
576                reason: "parent directory is not writable".into(),
577            });
578        } else {
579            let _ = std::fs::remove_file(test_file);
580        }
581    }
582}