1use 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 pub media_dir: Option<String>,
116
117 pub templates: TemplateConf,
119
120 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 pub fn with_env(mut self) -> Result<Self, ConfError> {
178 apply_env_patches(&mut self, None)?;
179 Ok(self)
180 }
181
182 pub fn from_env() -> Result<Self, ConfError> {
184 Self::default().with_env()
185 }
186
187 pub fn from_env_with_files() -> Result<Self, ConfError> {
189 Self::load_env_files();
190 Self::default().with_env()
191 }
192
193 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 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 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 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 _ => {} }
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}