From 58b25d92d404c0e6d1c327622c33ab322dd56881 Mon Sep 17 00:00:00 2001 From: Joseph Micheli Date: Sun, 10 Dec 2023 13:11:51 -0600 Subject: [PATCH] :art: Refactor core config (#214) * Partial progress * Add comments to new config code. * Additional progress * Revert "Additional progress" This reverts commit b73699cf4c24aec0d18e262d387006ed4aa70e38. * Finish refactoring project to use passed-state instead of global state * Test new configs and fix StumpCore docs. * Fix all them doctests * Remove unnecessary utils module in server. * Handle allowed_origins properly. * Cloning strings is silly. * Update cargo lock * Complete removal of env-based configs. * Fix mispelling * Reverse change to session save fn - it broke things. * Modify scripts to support building on windows * Finishing touches * Address comments on first revision of pull request. * Can disable the clap env feature in the cli now. --------- Co-authored-by: Aaron Leopold <36278431+aaronleopold@users.noreply.github.com> --- Cargo.lock | 166 +++-- Cargo.toml | 1 + apps/server/src/config/cors.rs | 47 +- apps/server/src/config/mod.rs | 1 - apps/server/src/config/session/mod.rs | 5 +- apps/server/src/config/session/store.rs | 11 +- apps/server/src/config/session/utils.rs | 41 +- apps/server/src/config/utils.rs | 9 - apps/server/src/http_server.rs | 9 +- apps/server/src/main.rs | 18 +- apps/server/src/middleware/logging.rs | 8 +- apps/server/src/routers/api/v1/auth.rs | 4 +- apps/server/src/routers/api/v1/library.rs | 37 +- apps/server/src/routers/api/v1/log.rs | 20 +- apps/server/src/routers/api/v1/media.rs | 25 +- apps/server/src/routers/api/v1/series.rs | 17 +- apps/server/src/routers/api/v1/user.rs | 15 +- apps/server/src/routers/mod.rs | 10 +- apps/server/src/routers/opds.rs | 5 +- apps/server/src/routers/spa.rs | 7 +- apps/server/src/utils/auth.rs | 7 - core/Cargo.toml | 4 +- core/integration-tests/Cargo.toml | 2 +- core/integration-tests/data/mock-stump.toml | 8 + core/src/config/env.rs | 244 ------ core/src/config/logging.rs | 25 +- core/src/config/mod.rs | 104 ++- core/src/config/stump_config.rs | 699 ++++++++++++++---- core/src/context.rs | 30 +- core/src/db/client.rs | 47 +- core/src/event/event_manager.rs | 5 +- core/src/filesystem/common.rs | 14 +- core/src/filesystem/content_type.rs | 22 +- core/src/filesystem/image/thumbnail.rs | 36 +- core/src/filesystem/image/thumbnail_job.rs | 12 +- core/src/filesystem/media/builder.rs | 18 +- core/src/filesystem/media/epub.rs | 13 +- core/src/filesystem/media/pdf.rs | 29 +- core/src/filesystem/media/process.rs | 54 +- core/src/filesystem/media/rar.rs | 22 +- core/src/filesystem/media/zip.rs | 25 +- core/src/filesystem/scanner/series_scanner.rs | 19 +- core/src/lib.rs | 53 +- crates/cli/Cargo.toml | 2 +- crates/cli/bin/main.rs | 11 +- crates/cli/src/commands/account.rs | 43 +- crates/cli/src/commands/mod.rs | 5 +- crates/cli/src/config.rs | 22 +- pnpm-lock.yaml | 26 +- 49 files changed, 1192 insertions(+), 865 deletions(-) delete mode 100644 apps/server/src/config/utils.rs create mode 100644 core/integration-tests/data/mock-stump.toml delete mode 100644 core/src/config/env.rs diff --git a/Cargo.lock b/Cargo.lock index 413670df6..1dd6078f8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1619,6 +1619,16 @@ dependencies = [ "serde", ] +[[package]] +name = "errno" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a258e46cdc063eb8519c00b9fc845fc47bcfca4130e2f08e88665ceda8474245" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + [[package]] name = "exr" version = "1.5.2" @@ -1670,12 +1680,9 @@ checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" [[package]] name = "fastrand" -version = "1.8.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7a407cfaa3385c4ae6b23e84623d48c2798d06e3e6a1878f7f59f17b3f86499" -dependencies = [ - "instant", -] +checksum = "25cbce373ec4653f1a01a31e8a5e5ec0c622dc27ff9c4e6606eefef5cbbed4a5" [[package]] name = "fax" @@ -1715,7 +1722,7 @@ checksum = "4e884668cd0c7480504233e951174ddc3b382f7c2666e3b7310b5c4e7b0c37f9" dependencies = [ "cfg-if", "libc", - "redox_syscall", + "redox_syscall 0.2.16", "windows-sys 0.42.0", ] @@ -2912,9 +2919,9 @@ checksum = "03087c2bad5e1034e8cace5926dec053fb3790248370865f5117a7d0213354c8" [[package]] name = "libc" -version = "0.2.147" +version = "0.2.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4668fb0ea861c1df094127ac5f1da3409a82116a4ba74fca2e58ef927159bb3" +checksum = "89d92a4743f9a61002fae18374ed11e7973f530cb3a3255fb354818118b2203c" [[package]] name = "libdbus-sys" @@ -3008,6 +3015,12 @@ dependencies = [ "cc", ] +[[package]] +name = "linux-raw-sys" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4cd1a83af159aa67994778be9070f0ae1bd732942279cabb14f86f986a21456" + [[package]] name = "litrs" version = "0.2.3" @@ -3790,34 +3803,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "optional_struct" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e60da57c6a9d057c07f1a90ca7abed9d104fca0d0db1a7d7e3304e4567d977fd" -dependencies = [ - "optional_struct_internal", - "optional_struct_macro_impl", -] - -[[package]] -name = "optional_struct_internal" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64e389cec0df3c934737dadc7b927a8e05b8c8ef792cd1af06a524bd129e9f4d" - -[[package]] -name = "optional_struct_macro_impl" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "286db11c92049709d5fbbe89eecaa2febc0efe6c18d94d9ebf942e592ac80f9f" -dependencies = [ - "optional_struct_internal", - "proc-macro2", - "quote", - "syn 1.0.107", -] - [[package]] name = "ordered-float" version = "2.10.0" @@ -3915,7 +3900,7 @@ dependencies = [ "cfg-if", "instant", "libc", - "redox_syscall", + "redox_syscall 0.2.16", "smallvec", "winapi", ] @@ -3928,7 +3913,7 @@ checksum = "ba1ef8814b5c993410bb3adfad7a5ed269563e4a2f90c41f5d85be7fb47133bf" dependencies = [ "cfg-if", "libc", - "redox_syscall", + "redox_syscall 0.2.16", "smallvec", "windows-sys 0.42.0", ] @@ -3969,7 +3954,7 @@ dependencies = [ "deflate", "fax", "globalcache", - "indexmap 1.9.2", + "indexmap 2.0.2", "istring", "itertools 0.10.5", "jpeg-decoder", @@ -4807,6 +4792,15 @@ dependencies = [ "bitflags 1.3.2", ] +[[package]] +name = "redox_syscall" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" +dependencies = [ + "bitflags 1.3.2", +] + [[package]] name = "redox_users" version = "0.4.3" @@ -4814,7 +4808,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b033d837a7cf162d7993aded9304e30a83213c648b6e389db233191f891e5c2b" dependencies = [ "getrandom 0.2.8", - "redox_syscall", + "redox_syscall 0.2.16", "thiserror", ] @@ -5056,6 +5050,19 @@ dependencies = [ "semver 1.0.16", ] +[[package]] +name = "rustix" +version = "0.38.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfeae074e687625746172d639330f1de242a178bf3189b51e35a7a21573513ac" +dependencies = [ + "bitflags 2.4.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.52.0", +] + [[package]] name = "rustversion" version = "1.0.11" @@ -5852,7 +5859,6 @@ dependencies = [ "image", "infer", "itertools 0.11.0", - "optional_struct", "pdf", "pdfium-render", "prisma-client-rust", @@ -5863,6 +5869,7 @@ dependencies = [ "serde-xml-rs", "serde_json", "specta", + "tempfile", "thiserror", "tokio", "toml 0.8.2", @@ -6261,16 +6268,15 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.3.0" +version = "3.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cdb1ef4eaeeaddc8fbd371e5017057064af0911902ef36b39801f67cc6d79e4" +checksum = "7ef1adac450ad7f4b3c28589471ade84f25f731a7a0fe30d71dfa9f60fd808e5" dependencies = [ "cfg-if", "fastrand", - "libc", - "redox_syscall", - "remove_dir_all", - "winapi", + "redox_syscall 0.4.1", + "rustix", + "windows-sys 0.48.0", ] [[package]] @@ -7379,6 +7385,15 @@ dependencies = [ "windows-targets 0.48.5", ] +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.0", +] + [[package]] name = "windows-targets" version = "0.42.1" @@ -7409,6 +7424,21 @@ dependencies = [ "windows_x86_64_msvc 0.48.5", ] +[[package]] +name = "windows-targets" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a18201040b24831fbb9e4eb208f8892e1f50a37feb53cc7ff887feb8f50e7cd" +dependencies = [ + "windows_aarch64_gnullvm 0.52.0", + "windows_aarch64_msvc 0.52.0", + "windows_i686_gnu 0.52.0", + "windows_i686_msvc 0.52.0", + "windows_x86_64_gnu 0.52.0", + "windows_x86_64_gnullvm 0.52.0", + "windows_x86_64_msvc 0.52.0", +] + [[package]] name = "windows-tokens" version = "0.39.0" @@ -7427,6 +7457,12 @@ version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7764e35d4db8a7921e09562a0304bf2f93e0a51bfccee0bd0bb0b666b015ea" + [[package]] name = "windows_aarch64_msvc" version = "0.37.0" @@ -7451,6 +7487,12 @@ version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbaa0368d4f1d2aaefc55b6fcfee13f41544ddf36801e793edbbfd7d7df075ef" + [[package]] name = "windows_i686_gnu" version = "0.37.0" @@ -7475,6 +7517,12 @@ version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" +[[package]] +name = "windows_i686_gnu" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28637cb1fa3560a16915793afb20081aba2c92ee8af57b4d5f28e4b3e7df313" + [[package]] name = "windows_i686_msvc" version = "0.37.0" @@ -7499,6 +7547,12 @@ version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" +[[package]] +name = "windows_i686_msvc" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffe5e8e31046ce6230cc7215707b816e339ff4d4d67c65dffa206fd0f7aa7b9a" + [[package]] name = "windows_x86_64_gnu" version = "0.37.0" @@ -7523,6 +7577,12 @@ version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d6fa32db2bc4a2f5abeacf2b69f7992cd09dca97498da74a151a3132c26befd" + [[package]] name = "windows_x86_64_gnullvm" version = "0.42.1" @@ -7535,6 +7595,12 @@ version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a657e1e9d3f514745a572a6846d3c7aa7dbe1658c056ed9c3344c4109a6949e" + [[package]] name = "windows_x86_64_msvc" version = "0.37.0" @@ -7559,6 +7625,12 @@ version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dff9641d1cd4be8d1a070daf9e3773c5f67e78b4d9d42263020c057706765c04" + [[package]] name = "winnow" version = "0.5.15" diff --git a/Cargo.toml b/Cargo.toml index c03382f26..4b2dbbb76 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,6 +38,7 @@ urlencoding = "2.1.3" ### DEV UTILS ### specta = "1.0.5" +tempfile = "3.8.1" ### AUTH ### bcrypt = "0.15.0" diff --git a/apps/server/src/config/cors.rs b/apps/server/src/config/cors.rs index 5936d2c34..7ce940413 100644 --- a/apps/server/src/config/cors.rs +++ b/apps/server/src/config/cors.rs @@ -1,14 +1,11 @@ -use std::env; - use axum::http::{ header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE}, HeaderValue, Method, }; use local_ip_address::local_ip; +use stump_core::config::StumpConfig; use tower_http::cors::{AllowOrigin, CorsLayer}; -use crate::config::utils::is_debug; - const DEFAULT_ALLOWED_ORIGINS: &[&str] = &["tauri://localhost", "https://tauri.localhost"]; const DEBUG_ALLOWED_ORIGINS: &[&str] = &[ @@ -28,35 +25,16 @@ fn merge_origins(origins: &[&str], local_origins: Vec) -> Vec>() } -pub fn get_cors_layer(port: u16) -> CorsLayer { - let is_debug = is_debug(); +pub fn get_cors_layer(config: StumpConfig) -> CorsLayer { + let is_debug = config.is_debug(); - let allowed_origins = match env::var("STUMP_ALLOWED_ORIGINS") { - Ok(val) => { - if val.is_empty() { - None - } else { - Some( - val.split(',') - .map(|val| val.trim().to_string().parse::()) - // Note: doing this the more verbose way so I can log errors... - .filter_map(|res| { - if let Ok(val) = res { - Some(val) - } else { - tracing::error!( - "Failed to parse allowed origin: {:?}", - res - ); - None - } - }) - .collect::>(), - ) - } - }, - Err(_) => None, - }; + let mut allowed_origins = Vec::new(); + for origin in config.allowed_origins { + match origin.parse::() { + Ok(val) => allowed_origins.push(val), + Err(_) => tracing::error!("Failed to parse allowed origin: {:?}", origin), + } + } let local_ip = local_ip() .map_err(|e| { @@ -69,6 +47,7 @@ pub fn get_cors_layer(port: u16) -> CorsLayer { // Format the local IP with both http and https, and the port. If is_debug is true, // then also add port 3000. let local_orgins = if !local_ip.is_empty() { + let port = config.port; let mut base = vec![ format!("http://{local_ip}:{port}"), format!("https://{local_ip}:{port}"), @@ -88,10 +67,10 @@ pub fn get_cors_layer(port: u16) -> CorsLayer { let mut cors_layer = CorsLayer::new(); - if let Some(origins_list) = allowed_origins { + if !allowed_origins.is_empty() { // TODO: consider adding some config to allow for this list to be appended to defaults, rather than // completely overriding them. - cors_layer = cors_layer.allow_origin(AllowOrigin::list(origins_list)); + cors_layer = cors_layer.allow_origin(AllowOrigin::list(allowed_origins)); } else if is_debug { let debug_origins = merge_origins(DEBUG_ALLOWED_ORIGINS, local_orgins); cors_layer = cors_layer.allow_origin(debug_origins); diff --git a/apps/server/src/config/mod.rs b/apps/server/src/config/mod.rs index 77e04bf3e..f334f75bf 100644 --- a/apps/server/src/config/mod.rs +++ b/apps/server/src/config/mod.rs @@ -1,4 +1,3 @@ pub mod cors; pub mod session; pub mod state; -pub mod utils; diff --git a/apps/server/src/config/session/mod.rs b/apps/server/src/config/session/mod.rs index db8f4eb88..f4a78f182 100644 --- a/apps/server/src/config/session/mod.rs +++ b/apps/server/src/config/session/mod.rs @@ -4,7 +4,4 @@ mod utils; pub use cleanup::SessionCleanupJob; pub use store::{PrismaSessionStore, SessionError}; -pub use utils::{ - get_session_expiry_cleanup_interval, get_session_layer, get_session_ttl, - handle_session_service_error, SESSION_USER_KEY, -}; +pub use utils::{get_session_layer, handle_session_service_error, SESSION_USER_KEY}; diff --git a/apps/server/src/config/session/store.rs b/apps/server/src/config/session/store.rs index bd5738dd4..721fc23fb 100644 --- a/apps/server/src/config/session/store.rs +++ b/apps/server/src/config/session/store.rs @@ -2,6 +2,7 @@ use std::sync::Arc; use prisma_client_rust::chrono::{DateTime, Duration, FixedOffset, Utc}; use stump_core::{ + config::StumpConfig, db::entity::User, prisma::{session, user, PrismaClient}, Ctx, @@ -10,7 +11,7 @@ use time::OffsetDateTime; use tokio::time::MissedTickBehavior; use tower_sessions::{session::SessionId, Session, SessionRecord, SessionStore}; -use super::{get_session_ttl, SessionCleanupJob, SESSION_USER_KEY}; +use super::{SessionCleanupJob, SESSION_USER_KEY}; #[derive(Debug, thiserror::Error)] pub enum SessionError { @@ -27,11 +28,12 @@ pub enum SessionError { #[derive(Clone)] pub struct PrismaSessionStore { client: Arc, + config: Arc, } impl PrismaSessionStore { - pub fn new(client: Arc) -> Self { - Self { client } + pub fn new(client: Arc, config: Arc) -> Self { + Self { client, config } } pub async fn continuously_delete_expired( @@ -59,7 +61,8 @@ impl SessionStore for PrismaSessionStore { async fn save(&self, session_record: &SessionRecord) -> Result<(), Self::Error> { let expires_at: DateTime = - (Utc::now() + Duration::seconds(get_session_ttl())).into(); + (Utc::now() + Duration::seconds(self.config.session_ttl)).into(); + let session_user = session_record .data() .get(SESSION_USER_KEY) diff --git a/apps/server/src/config/session/utils.rs b/apps/server/src/config/session/utils.rs index a43dbe892..893fa8f12 100644 --- a/apps/server/src/config/session/utils.rs +++ b/apps/server/src/config/session/utils.rs @@ -4,7 +4,7 @@ use axum::{ BoxError, }; use hyper::StatusCode; -use std::{env, sync::Arc}; +use std::sync::Arc; use stump_core::Ctx; use time::Duration; @@ -16,39 +16,11 @@ pub const SESSION_USER_KEY: &str = "user"; pub const SESSION_NAME: &str = "stump_session"; pub const SESSION_PATH: &str = "/"; -pub const DEFAULT_SESSION_TTL: i64 = 3600 * 24 * 3; // 3 days -pub const DEFAULT_SESSION_EXPIRY_CLEANUP_INTERVAL: u64 = 60 * 60 * 24; // 24 hours - -pub fn get_session_ttl() -> i64 { - env::var("SESSION_TTL") - .map(|s| { - s.parse::().unwrap_or_else(|error| { - tracing::error!(?error, "Failed to parse provided SESSION_TTL"); - DEFAULT_SESSION_TTL - }) - }) - .unwrap_or(DEFAULT_SESSION_TTL) -} - -pub fn get_session_expiry_cleanup_interval() -> u64 { - env::var("SESSION_EXPIRY_CLEANUP_INTERVAL") - .map(|s| { - s.parse::().unwrap_or_else(|error| { - tracing::error!( - ?error, - "Failed to parse provided SESSION_EXPIRY_CLEANUP_INTERVAL" - ); - DEFAULT_SESSION_EXPIRY_CLEANUP_INTERVAL - }) - }) - .unwrap_or(DEFAULT_SESSION_EXPIRY_CLEANUP_INTERVAL) -} - pub fn get_session_layer(ctx: Arc) -> SessionManagerLayer { let client = ctx.db.clone(); - let store = PrismaSessionStore::new(client); + let store = PrismaSessionStore::new(client, ctx.config.clone()); - let cleanup_interval = get_session_expiry_cleanup_interval(); + let cleanup_interval = ctx.config.expired_session_cleanup_interval; if cleanup_interval > 0 { tracing::trace!( cleanup_interval = cleanup_interval, @@ -56,17 +28,16 @@ pub fn get_session_layer(ctx: Arc) -> SessionManagerLayer String { - env::var("STUMP_CLIENT_DIR").unwrap_or_else(|_| "./dist".to_string()) -} - -pub(crate) fn is_debug() -> bool { - env::var("STUMP_PROFILE").unwrap_or_else(|_| "release".into()) == "debug" -} diff --git a/apps/server/src/http_server.rs b/apps/server/src/http_server.rs index 67b99b2a0..947f29108 100644 --- a/apps/server/src/http_server.rs +++ b/apps/server/src/http_server.rs @@ -16,9 +16,10 @@ use crate::{ routers, utils::shutdown_signal_with_cleanup, }; +use stump_core::config::StumpConfig; -pub(crate) async fn run_http_server(port: u16) -> ServerResult<()> { - let core = StumpCore::new().await; +pub(crate) async fn run_http_server(config: StumpConfig) -> ServerResult<()> { + let core = StumpCore::new(config.clone()).await; if let Err(err) = core.run_migrations().await { tracing::error!("Failed to run migrations: {:?}", err); return Err(ServerError::ServerStartError(err.to_string())); @@ -42,7 +43,7 @@ pub(crate) async fn run_http_server(port: u16) -> ServerResult<()> { let server_ctx = core.get_context(); let app_state = server_ctx.arced(); - let cors_layer = cors::get_cors_layer(port); + let cors_layer = cors::get_cors_layer(config.clone()); tracing::info!("{}", core.get_shadow_text()); @@ -57,7 +58,7 @@ pub(crate) async fn run_http_server(port: u16) -> ServerResult<()> { .layer(cors_layer) .layer(TraceLayer::new_for_http()); - let addr = SocketAddr::from(([0, 0, 0, 0], port)); + let addr = SocketAddr::from(([0, 0, 0, 0], config.port)); tracing::info!("⚡️ Stump HTTP server starting on http://{}", addr); // TODO: might need to refactor to use https://docs.rs/async-shutdown/latest/async_shutdown/ diff --git a/apps/server/src/main.rs b/apps/server/src/main.rs index 936a33c6c..9ecf5c635 100644 --- a/apps/server/src/main.rs +++ b/apps/server/src/main.rs @@ -1,6 +1,8 @@ use cli::{handle_command, Cli, Parser}; use errors::EntryError; -use stump_core::{config::logging::init_tracing, StumpCore}; +use stump_core::{ + config::bootstrap_config_dir, config::logging::init_tracing, StumpCore, +}; mod config; mod errors; @@ -24,23 +26,25 @@ async fn main() -> Result<(), EntryError> { #[cfg(debug_assertions)] debug_setup(); - let config = StumpCore::init_environment() + // Get STUMP_CONFIG_DIR to bootstrap startup + let config_dir = bootstrap_config_dir(); + + let config = StumpCore::init_config(config_dir) .map_err(|e| EntryError::InvalidConfig(e.to_string()))?; let cli = Cli::parse(); if let Some(command) = cli.command { - Ok(handle_command(command, cli.config).await?) + Ok(handle_command(command, &cli.config.merge_stump_config(config)).await?) } else { - let port = config.port.unwrap_or(10801); // Note: init_tracing after loading the environment so the correct verbosity // level is used for logging. - init_tracing(); + init_tracing(&config); - if config.verbosity.unwrap_or(1) >= 3 { + if config.verbosity >= 3 { tracing::trace!(?config, "App config"); } - Ok(http_server::run_http_server(port).await?) + Ok(http_server::run_http_server(config).await?) } } diff --git a/apps/server/src/middleware/logging.rs b/apps/server/src/middleware/logging.rs index cef4b430f..54f0f8fad 100644 --- a/apps/server/src/middleware/logging.rs +++ b/apps/server/src/middleware/logging.rs @@ -4,7 +4,6 @@ use axum::{ middleware::Next, response::IntoResponse, }; -use stump_core::config::logging::get_log_verbosity; use crate::errors::ApiResult; @@ -13,9 +12,10 @@ pub(crate) async fn logging_middleware( req: Request, next: Next, ) -> ApiResult { - if get_log_verbosity() < 3 { - return Ok(next.run(req).await); - } + // TODO: Refactor to not rely on global state when re-enabling logging middleware + // if get_log_verbosity() < 3 { + // return Ok(next.run(req).await); + // } let (parts, body) = req.into_parts(); diff --git a/apps/server/src/routers/api/v1/auth.rs b/apps/server/src/routers/api/v1/auth.rs index 8cbdbaf84..d13dd16ee 100644 --- a/apps/server/src/routers/api/v1/auth.rs +++ b/apps/server/src/routers/api/v1/auth.rs @@ -22,7 +22,7 @@ use crate::{ config::{session::SESSION_USER_KEY, state::AppState}, errors::{ApiError, ApiResult}, http_server::StumpRequestInfo, - utils::{self, verify_password}, + utils::verify_password, }; pub(crate) fn mount() -> Router { @@ -275,7 +275,7 @@ pub async fn register( is_server_owner = true; } - let hashed_password = bcrypt::hash(&input.password, utils::get_hash_cost())?; + let hashed_password = bcrypt::hash(&input.password, ctx.config.password_hash_cost)?; let created_user = db .user() diff --git a/apps/server/src/routers/api/v1/library.rs b/apps/server/src/routers/api/v1/library.rs index e8c2e9597..ef2c350db 100644 --- a/apps/server/src/routers/api/v1/library.rs +++ b/apps/server/src/routers/api/v1/library.rs @@ -15,7 +15,7 @@ use tracing::{debug, error, trace}; use utoipa::ToSchema; use stump_core::{ - config::get_config_dir, + config::StumpConfig, db::{ entity::{ library_series_ids_media_ids_include, library_thumbnails_deletion_include, @@ -434,20 +434,24 @@ pub(crate) fn get_library_thumbnail( first_series: &series::Data, first_book: &media::Data, image_format: Option, + config: &StumpConfig, ) -> ApiResult<(ContentType, Vec)> { - let thumbnails = get_config_dir().join("thumbnails"); let library_id = library.id.clone(); if let Some(format) = image_format.clone() { let extension = format.extension(); - let path = thumbnails.join(format!("{}.{}", library_id, extension)); + let path = config + .get_thumbnails_dir() + .join(format!("{}.{}", library_id, extension)); if path.exists() { tracing::trace!(?path, library_id, "Found generated library thumbnail"); return Ok((ContentType::from(format), read_entire_file(path)?)); } - } else if let Some(path) = get_unknown_thumnail(&library_id) { + } else if let Some(path) = + get_unknown_thumnail(&library_id, config.get_thumbnails_dir()) + { tracing::debug!(path = ?path, library_id, "Found library thumbnail that does not align with config"); let FileParts { extension, .. } = path.file_parts(); return Ok(( @@ -456,7 +460,7 @@ pub(crate) fn get_library_thumbnail( )); } - get_series_thumbnail(first_series, first_book, image_format) + get_series_thumbnail(first_series, first_book, image_format, config) } // TODO: ImageResponse for utoipa @@ -524,8 +528,14 @@ async fn get_library_thumbnail_handler( "Library has no media to get thumbnail from".to_string(), ))?; - get_library_thumbnail(library, &first_series, first_book, image_format) - .map(ImageResponse::from) + get_library_thumbnail( + library, + &first_series, + first_book, + image_format, + &ctx.config, + ) + .map(ImageResponse::from) } #[derive(Deserialize, ToSchema, specta::Type)] @@ -613,7 +623,7 @@ async fn patch_library_thumbnail( .with_page(target_page); let format = thumbnail_options.format.clone(); - let path_buf = generate_thumbnail(&id, &media.path, thumbnail_options)?; + let path_buf = generate_thumbnail(&id, &media.path, thumbnail_options, &ctx.config)?; Ok(ImageResponse::from(( ContentType::from(format), read_entire_file(path_buf)?, @@ -641,6 +651,7 @@ async fn delete_library_thumbnails( State(ctx): State, ) -> ApiResult> { let db = ctx.get_db(); + let thumbnails_dir = ctx.config.get_thumbnails_dir(); let result = db .library() @@ -665,9 +676,9 @@ async fn delete_library_thumbnails( .collect::>(); if let Some(ext) = extension { - remove_thumbnails_of_type(&media_ids, ext)?; + remove_thumbnails_of_type(&media_ids, ext, thumbnails_dir)?; } else { - remove_thumbnails(&media_ids)?; + remove_thumbnails(&media_ids, thumbnails_dir)?; } Ok(Json(())) @@ -810,6 +821,7 @@ async fn clean_library( get_user_and_enforce_permission(&session, UserPermission::ManageLibrary)?; let db = ctx.get_db(); + let thumbnails_dir = ctx.config.get_thumbnails_dir(); let result: ApiResult<(CleanLibraryResponse, Vec)> = db ._transaction() @@ -906,7 +918,7 @@ async fn clean_library( let (response, media_to_delete_ids) = result?; if !media_to_delete_ids.is_empty() { - image::remove_thumbnails(&media_to_delete_ids).map_or_else( + image::remove_thumbnails(&media_to_delete_ids, thumbnails_dir).map_or_else( |error| { tracing::error!(?error, "Failed to remove thumbnails for library media"); }, @@ -1218,6 +1230,7 @@ async fn delete_library( ) -> ApiResult> { get_session_server_owner_user(&session)?; let db = ctx.get_db(); + let thumbnails_dir = ctx.config.get_thumbnails_dir(); trace!(?id, "Attempting to delete library"); @@ -1242,7 +1255,7 @@ async fn delete_library( media_ids.len() ); - if let Err(err) = image::remove_thumbnails(&media_ids) { + if let Err(err) = image::remove_thumbnails(&media_ids, thumbnails_dir) { error!("Failed to remove thumbnails for library media: {:?}", err); } else { debug!("Removed thumbnails for library media (if present)"); diff --git a/apps/server/src/routers/api/v1/log.rs b/apps/server/src/routers/api/v1/log.rs index 41014b1b1..200aa1605 100644 --- a/apps/server/src/routers/api/v1/log.rs +++ b/apps/server/src/routers/api/v1/log.rs @@ -1,4 +1,5 @@ use axum::{ + extract::State, middleware::from_extractor_with_state, response::{sse::Event, Sse}, routing::get, @@ -12,7 +13,7 @@ use std::{ fs::File, io::{Read, Seek, SeekFrom}, }; -use stump_core::{config::logging::get_log_file, db::entity::LogMetadata}; +use stump_core::db::entity::LogMetadata; use tokio::sync::broadcast; use tower_sessions::Session; @@ -58,9 +59,11 @@ async fn get_logs() -> ApiResult<()> { // file appender for the log config writes to the file, notify is not picking it up. I'm not // sure if this is a result of the recommended_watcher defaults, or perhaps something about how // the file appender works. I'm going to leave this here for now, as its a cool to have. -async fn tail_log_file() -> Sse>> { +async fn tail_log_file( + State(ctx): State, +) -> Sse>> { let stream = async_stream::stream! { - let log_file_path = get_log_file(); + let log_file_path = ctx.config.get_log_file(); let mut file = File::open(log_file_path.as_path()).expect("Failed to open log file"); let file_length = file .seek(SeekFrom::End(0)) @@ -126,9 +129,12 @@ async fn tail_log_file() -> Sse>> { )] /// Get information about the Stump log file, located at STUMP_CONFIG_DIR/Stump.log, or /// ~/.stump/Stump.log by default. Information such as the file size, last modified date, etc. -async fn get_logfile_info(session: Session) -> ApiResult> { +async fn get_logfile_info( + session: Session, + State(ctx): State, +) -> ApiResult> { get_session_server_owner_user(&session)?; - let log_file_path = get_log_file(); + let log_file_path = ctx.config.get_log_file(); let file = File::open(log_file_path.as_path())?; let metadata = file.metadata()?; @@ -159,9 +165,9 @@ async fn get_logfile_info(session: Session) -> ApiResult> { // a resource. This is not semantically correct, but I want it to be clear that // this route *WILL* delete all of the file contents. // #[delete("/logs")] -async fn clear_logs(session: Session) -> ApiResult<()> { +async fn clear_logs(session: Session, State(ctx): State) -> ApiResult<()> { get_session_server_owner_user(&session)?; - let log_file_path = get_log_file(); + let log_file_path = ctx.config.get_log_file(); File::create(log_file_path.as_path())?; diff --git a/apps/server/src/routers/api/v1/media.rs b/apps/server/src/routers/api/v1/media.rs index 342a65b62..8aaf091de 100644 --- a/apps/server/src/routers/api/v1/media.rs +++ b/apps/server/src/routers/api/v1/media.rs @@ -13,7 +13,7 @@ use prisma_client_rust::{ use serde::{Deserialize, Serialize}; use serde_qs::axum::QsQuery; use stump_core::{ - config::get_config_dir, + config::StumpConfig, db::{ entity::{LibraryOptions, Media, ReadProgress, User}, query::pagination::{PageQuery, Pageable, Pagination, PaginationQuery}, @@ -855,7 +855,7 @@ async fn get_media_page( page, id ))) } else { - Ok(get_page(&media.path, page)?.into()) + Ok(get_page(&media.path, page, &ctx.config)?.into()) } } @@ -863,6 +863,7 @@ pub(crate) async fn get_media_thumbnail_by_id( id: String, db: &PrismaClient, session: &Session, + config: &StumpConfig, ) -> ApiResult<(ContentType, Vec)> { let user = get_session_user(session)?; let age_restrictions = user @@ -907,8 +908,9 @@ pub(crate) async fn get_media_thumbnail_by_id( (Some(book), Some(options)) => get_media_thumbnail( &book, options.thumbnail_config.map(|config| config.format), + config, ), - (Some(book), None) => get_media_thumbnail(&book, None), + (Some(book), None) => get_media_thumbnail(&book, None, config), _ => Err(ApiError::NotFound(String::from("Media not found"))), } } @@ -916,16 +918,21 @@ pub(crate) async fn get_media_thumbnail_by_id( pub(crate) fn get_media_thumbnail( media: &media::Data, target_format: Option, + config: &StumpConfig, ) -> ApiResult<(ContentType, Vec)> { - let thumbnail_dir = get_config_dir().join("thumbnails"); if let Some(format) = target_format { let extension = format.extension(); - let thumbnail_path = thumbnail_dir.join(format!("{}.{}", media.id, extension)); + let thumbnail_path = config + .get_thumbnails_dir() + .join(format!("{}.{}", media.id, extension)); + if thumbnail_path.exists() { tracing::trace!(path = ?thumbnail_path, media_id = ?media.id, "Found generated media thumbnail"); return Ok((ContentType::from(format), read_entire_file(thumbnail_path)?)); } - } else if let Some(path) = get_unknown_thumnail(&media.id) { + } else if let Some(path) = + get_unknown_thumnail(&media.id, config.get_thumbnails_dir()) + { // If there exists a file that starts with the media id in the thumbnails dir, // then return it. This might happen if a user manually regenerates thumbnails // via the API without updating the thumbnail config... @@ -937,7 +944,7 @@ pub(crate) fn get_media_thumbnail( )); } - Ok(get_page(media.path.as_str(), 1)?) + Ok(get_page(media.path.as_str(), 1, config)?) } // TODO: ImageResponse as body type @@ -964,7 +971,7 @@ async fn get_media_thumbnail_handler( ) -> ApiResult { tracing::trace!(?id, "get_media_thumbnail"); let db = ctx.get_db(); - get_media_thumbnail_by_id(id, db, &session) + get_media_thumbnail_by_id(id, db, &session, &ctx.config) .await .map(ImageResponse::from) } @@ -1047,7 +1054,7 @@ async fn patch_media_thumbnail( .with_page(target_page); let format = thumbnail_options.format.clone(); - let path_buf = generate_thumbnail(&id, &media.path, thumbnail_options)?; + let path_buf = generate_thumbnail(&id, &media.path, thumbnail_options, &ctx.config)?; Ok(ImageResponse::from(( ContentType::from(format), read_entire_file(path_buf)?, diff --git a/apps/server/src/routers/api/v1/series.rs b/apps/server/src/routers/api/v1/series.rs index 24ae46b7d..3412f6ca7 100644 --- a/apps/server/src/routers/api/v1/series.rs +++ b/apps/server/src/routers/api/v1/series.rs @@ -9,7 +9,7 @@ use prisma_client_rust::{or, Direction}; use serde::{Deserialize, Serialize}; use serde_qs::axum::QsQuery; use stump_core::{ - config::get_config_dir, + config::StumpConfig, db::{ entity::{LibraryOptions, Media, Series}, query::{ @@ -400,20 +400,20 @@ pub(crate) fn get_series_thumbnail( series: &series::Data, first_book: &media::Data, image_format: Option, + config: &StumpConfig, ) -> ApiResult<(ContentType, Vec)> { - let thumbnails = get_config_dir().join("thumbnails"); + let thumbnails_dir = config.get_thumbnails_dir(); let series_id = series.id.clone(); if let Some(format) = image_format.clone() { let extension = format.extension(); - - let path = thumbnails.join(format!("{}.{}", series_id, extension)); + let path = thumbnails_dir.join(format!("{}.{}", series_id, extension)); if path.exists() { tracing::trace!(?path, series_id, "Found generated series thumbnail"); return Ok((ContentType::from(format), read_entire_file(path)?)); } - } else if let Some(path) = get_unknown_thumnail(&series_id) { + } else if let Some(path) = get_unknown_thumnail(&series_id, thumbnails_dir) { tracing::debug!(path = ?path, series_id, "Found series thumbnail that does not align with config"); let FileParts { extension, .. } = path.file_parts(); return Ok(( @@ -422,7 +422,7 @@ pub(crate) fn get_series_thumbnail( )); } - get_media_thumbnail(first_book, image_format) + get_media_thumbnail(first_book, image_format, config) } // TODO: ImageResponse type for body @@ -494,7 +494,8 @@ async fn get_series_thumbnail_handler( .thumbnail_config .map(|config| config.format); - get_series_thumbnail(&series, first_book, image_format).map(ImageResponse::from) + get_series_thumbnail(&series, first_book, image_format, &ctx.config) + .map(ImageResponse::from) } #[derive(Deserialize, ToSchema, specta::Type)] @@ -582,7 +583,7 @@ async fn patch_series_thumbnail( .with_page(target_page); let format = thumbnail_options.format.clone(); - let path_buf = generate_thumbnail(&id, &media.path, thumbnail_options)?; + let path_buf = generate_thumbnail(&id, &media.path, thumbnail_options, &ctx.config)?; Ok(ImageResponse::from(( ContentType::from(format), read_entire_file(path_buf)?, diff --git a/apps/server/src/routers/api/v1/user.rs b/apps/server/src/routers/api/v1/user.rs index 87708a963..6eda88db1 100644 --- a/apps/server/src/routers/api/v1/user.rs +++ b/apps/server/src/routers/api/v1/user.rs @@ -9,6 +9,7 @@ use prisma_client_rust::{chrono::Utc, Direction}; use serde::Deserialize; use specta::Type; use stump_core::{ + config::StumpConfig, db::{ entity::{AgeRestriction, LoginActivity, User, UserPermission, UserPreferences}, query::pagination::{Pageable, Pagination, PaginationQuery}, @@ -26,9 +27,7 @@ use crate::{ config::{session::SESSION_USER_KEY, state::AppState}, errors::{ApiError, ApiResult}, middleware::auth::Auth, - utils::{ - get_hash_cost, get_session_server_owner_user, get_session_user, UserQueryRelation, - }, + utils::{get_session_server_owner_user, get_session_user, UserQueryRelation}, }; pub(crate) fn mount(app_state: AppState) -> Router { @@ -243,13 +242,14 @@ async fn update_user( client: &PrismaClient, for_user_id: String, input: UpdateUser, + config: &StumpConfig, ) -> ApiResult { let mut update_params = vec![ user::username::set(input.username), user::avatar_url::set(input.avatar_url), ]; if let Some(password) = input.password { - let hashed_password = bcrypt::hash(password, get_hash_cost())?; + let hashed_password = bcrypt::hash(password, config.password_hash_cost)?; update_params.push(user::hashed_password::set(hashed_password)); } @@ -386,7 +386,7 @@ async fn create_user( get_session_server_owner_user(&session)?; let db = ctx.get_db(); - let hashed_password = bcrypt::hash(input.password, get_hash_cost())?; + let hashed_password = bcrypt::hash(input.password, ctx.config.password_hash_cost)?; // TODO: https://github.com/Brendonovich/prisma-client-rust/issues/44 let created_user = db @@ -484,7 +484,8 @@ async fn update_current_user( let db = ctx.get_db(); let user = get_session_user(&session)?; - let updated_user = update_user(&user, db, user.id.clone(), input).await?; + let updated_user = + update_user(&user, db, user.id.clone(), input, &ctx.config).await?; debug!(?updated_user, "Updated user"); session @@ -716,7 +717,7 @@ async fn update_user_handler( return Err(ApiError::forbidden_discreet()); } - let updated_user = update_user(&user, db, id.clone(), input).await?; + let updated_user = update_user(&user, db, id.clone(), input, &ctx.config).await?; debug!(?updated_user, "Updated user"); if user.id == id { diff --git a/apps/server/src/routers/mod.rs b/apps/server/src/routers/mod.rs index 2ea0e120e..5e7f0436e 100644 --- a/apps/server/src/routers/mod.rs +++ b/apps/server/src/routers/mod.rs @@ -1,8 +1,6 @@ -use std::env; - use axum::Router; -use crate::config::{state::AppState, utils::is_debug}; +use crate::config::state::AppState; mod api; mod opds; @@ -14,14 +12,12 @@ mod ws; pub(crate) fn mount(app_state: AppState) -> Router { let mut app_router = Router::new(); - let enable_swagger = - env::var("ENABLE_SWAGGER_UI").unwrap_or_else(|_| String::from("true")); - if enable_swagger != "false" || is_debug() { + if !app_state.config.disable_swagger || app_state.config.is_debug() { app_router = app_router.merge(utoipa::mount(app_state.clone())); } app_router - .merge(spa::mount()) + .merge(spa::mount(app_state.clone())) .merge(ws::mount()) .merge(sse::mount()) .merge(api::mount(app_state.clone())) diff --git a/apps/server/src/routers/opds.rs b/apps/server/src/routers/opds.rs index 85c701441..953eb6d6b 100644 --- a/apps/server/src/routers/opds.rs +++ b/apps/server/src/routers/opds.rs @@ -561,7 +561,7 @@ async fn get_book_thumbnail( .await? .ok_or(ApiError::NotFound(String::from("Book not found")))?; - let (content_type, image_buffer) = get_page(book.path.as_str(), 1)?; + let (content_type, image_buffer) = get_page(book.path.as_str(), 1, &ctx.config)?; handle_opds_image_response(content_type, image_buffer) } @@ -627,7 +627,8 @@ async fn get_book_page( .await; let (book, _) = result?; - let (content_type, image_buffer) = get_page(book.path.as_str(), correct_page)?; + let (content_type, image_buffer) = + get_page(book.path.as_str(), correct_page, &ctx.config)?; handle_opds_image_response(content_type, image_buffer) } diff --git a/apps/server/src/routers/spa.rs b/apps/server/src/routers/spa.rs index 9241f2119..95a7cc21e 100644 --- a/apps/server/src/routers/spa.rs +++ b/apps/server/src/routers/spa.rs @@ -2,13 +2,12 @@ use std::path::Path; use axum_extra::routing::SpaRouter; -use crate::config::{state::AppState, utils::get_client_dir}; +use crate::config::state::AppState; // FIXME: I am not picking up the favicon.ico file in docker, but can't seem // to replicate it locally... -pub(crate) fn mount() -> SpaRouter { - let dist = get_client_dir(); - let dist_path = Path::new(&dist); +pub(crate) fn mount(app_state: AppState) -> SpaRouter { + let dist_path = Path::new(&app_state.config.client_dir); SpaRouter::new("/assets", dist_path.join("assets")).index_file("../index.html") } diff --git a/apps/server/src/utils/auth.rs b/apps/server/src/utils/auth.rs index 58187f7f4..a3cddb4c8 100644 --- a/apps/server/src/utils/auth.rs +++ b/apps/server/src/utils/auth.rs @@ -12,13 +12,6 @@ pub struct DecodedCredentials { pub password: String, } -pub fn get_hash_cost() -> u32 { - std::env::var("HASH_COST") - .unwrap_or_else(|_e| "12".to_string()) - .parse() - .unwrap_or(12) -} - pub fn verify_password(hash: &str, password: &str) -> Result { Ok(bcrypt::verify(password, hash)?) } diff --git a/core/Cargo.toml b/core/Cargo.toml index 2359e4448..1b2f0a268 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -20,7 +20,6 @@ xml-rs = "0.8.19" # used for creating XML docs serde-xml-rs = "0.6.0" # used for serializing/deserializing xml serde_json = { workspace = true } itertools = "0.11.0" -optional_struct = "0.3.1" utoipa = { version = "3.5.0" } uuid = "1.4.1" regex = "1.9.6" @@ -50,3 +49,6 @@ toml = "0.8.2" tracing = { workspace = true } tracing-subscriber = { version = "0.3.15", features = ["env-filter"] } tracing-appender = "0.2.2" + +[dev-dependencies] +tempfile = { workspace = true } diff --git a/core/integration-tests/Cargo.toml b/core/integration-tests/Cargo.toml index 7c078c5cf..b173d52ef 100644 --- a/core/integration-tests/Cargo.toml +++ b/core/integration-tests/Cargo.toml @@ -15,4 +15,4 @@ serde = { workspace = true } prisma-client-rust = { workspace = true } tokio = { workspace = true } stump_core = { path = ".." } -tempfile = "3.3.0" \ No newline at end of file +tempfile = { workspace = true } \ No newline at end of file diff --git a/core/integration-tests/data/mock-stump.toml b/core/integration-tests/data/mock-stump.toml new file mode 100644 index 000000000..10391ec7d --- /dev/null +++ b/core/integration-tests/data/mock-stump.toml @@ -0,0 +1,8 @@ +profile = "release" +port = 1337 +verbosity = 3 +db_path = "not_a_real_path" +client_dir = "not_a_real_dir" +config_dir = "also_not_a_real_dir" +allowed_origins = ["origin1", "origin2"] +pdfium_path = "not_a_path_to_pdfium" diff --git a/core/src/config/env.rs b/core/src/config/env.rs deleted file mode 100644 index b9662871c..000000000 --- a/core/src/config/env.rs +++ /dev/null @@ -1,244 +0,0 @@ -use std::{env, path::Path}; - -use serde::{Deserialize, Serialize}; -use tracing::debug; - -use crate::{ - config::get_config_dir, - error::{CoreError, CoreResult}, -}; - -/// [`StumpEnvironment`] is the the representation of the Stump configuration file. -/// Each field is an [`Option`] because the configuration file is not guaranteed -/// to have all fields, and the paths are -/// -/// Example: -/// ```rust -/// use std::fs; -/// use stump_core::config::env::StumpEnvironment; -/// -/// fn read_toml() { -/// let toml_str = r#" -/// profile = "debug" -/// port = 8080 -/// verbosity = 3 -/// client_dir = "client" -/// "#; -/// let stump_toml = toml::from_str::(toml_str); -/// -/// assert!(stump_toml.is_ok()); -/// let stump_toml = stump_toml.unwrap(); -/// println!("{:?}", stump_toml); -/// assert_eq!(stump_toml.profile, Some("debug".to_string())); -/// assert_eq!(stump_toml.port, Some(8080)); -/// assert_eq!(stump_toml.verbosity, Some(3)); -/// assert_eq!(stump_toml.client_dir, Some(String::from("client"))); -/// } -/// ``` -#[derive(Serialize, Deserialize, Debug, Clone)] -pub struct StumpEnvironment { - pub profile: Option, - pub port: Option, - pub verbosity: Option, - pub client_dir: Option, - pub config_dir: Option, - pub allowed_origins: Option>, - pub pdfium_path: Option, -} - -impl Default for StumpEnvironment { - fn default() -> Self { - Self { - profile: Some(String::from("debug")), - port: Some(10801), - // TODO: change default back to 0 - verbosity: Some(1), - client_dir: Some(String::from("client")), - config_dir: None, - allowed_origins: None, - pdfium_path: None, - } - } -} - -// TODO: error handling -// FIXME: I don't believe this will work very well, but it requires some testing. -impl StumpEnvironment { - /// Will try to create a new [StumpEnvironment] object from set environment variables. If none are set, - /// will return the default [StumpEnvironment] object. - /// - /// ## Example - /// ```rust - /// use std::env; - /// use stump_core::config::env::StumpEnvironment; - /// - /// env::set_var("STUMP_PORT", "8080"); - /// env::set_var("STUMP_VERBOSITY", "3"); - /// - /// let env = StumpEnvironment::from_env(None); - /// assert!(env.is_ok()); - /// let env = env.unwrap(); - /// assert_eq!(env.port, Some(8080)); - /// assert_eq!(env.verbosity, Some(3)); - /// ``` - pub fn from_env(existing: Option) -> CoreResult { - let mut env = match existing { - Some(env) => env, - None => Self::default(), - }; - - if let Ok(port) = env::var("STUMP_PORT") { - env.port = Some(port.parse().unwrap()); - } - - if let Ok(profile) = env::var("STUMP_PROFILE") { - if profile == "release" || profile == "debug" { - env.profile = Some(profile); - } else { - debug!("Invalid PROFILE value: {}", profile); - - env.profile = Some(String::from("debug")); - } - } - - if let Ok(verbosity) = env::var("STUMP_VERBOSITY") { - env.verbosity = Some(verbosity.parse().unwrap()); - } - - if let Ok(client_dir) = env::var("STUMP_CLIENT_DIR") { - env.client_dir = Some(client_dir); - } - - if let Ok(config_dir) = env::var("STUMP_CONFIG_DIR") { - if !config_dir.is_empty() { - if Path::new(&config_dir).exists() { - env.config_dir = Some(config_dir); - } else { - debug!( - "Invalid STUMP_CONFIG_DIR value, cannot find on file system: {}", - config_dir - ); - } - } else { - debug!("Invalid STUMP_CONFIG_DIR value: EMPTY"); - } - } - - env.config_dir = Some(get_config_dir().to_string_lossy().to_string()); - - if let Ok(pdfium_path) = env::var("PDFIUM_PATH") { - env.pdfium_path = Some(pdfium_path); - } - - env.write()?; - - Ok(env) - } - - /// Will set the environment variables to the values in the [StumpEnvironment] object. - /// If the values are not set, will use the default values. - /// - /// ## Example - /// ```rust - /// use std::env; - /// use stump_core::config::env::StumpEnvironment; - /// - /// let mut env = StumpEnvironment::default(); - /// env.port = Some(8080); - /// - /// assert_eq!(env::var("STUMP_PORT").is_err(), true); - /// env.set_env().unwrap(); - /// assert_eq!(env::var("STUMP_PORT").unwrap(), "8080"); - /// ``` - pub fn set_env(&self) -> CoreResult<()> { - if let Some(profile) = &self.profile { - if profile != "debug" { - env::set_var("STUMP_PROFILE", "release"); - } else { - env::set_var("STUMP_PROFILE", "debug"); - } - } - - let port = &self.port.unwrap_or(10801); - - env::set_var("STUMP_PORT", port.to_string()); - env::set_var("STUMP_VERBOSITY", self.verbosity.unwrap_or(1).to_string()); - - if let Some(config_dir) = &self.config_dir { - if !config_dir.is_empty() { - env::set_var("STUMP_CONFIG_DIR", config_dir); - } - } - - if let Some(client_dir) = &self.client_dir { - if !client_dir.is_empty() { - env::set_var("STUMP_CLIENT_DIR", client_dir); - } - } - - if let Some(allowed_origins) = &self.allowed_origins { - if !allowed_origins.is_empty() { - env::set_var("STUMP_ALLOWED_ORIGINS", allowed_origins.join(",")); - } - } - - if let Some(pdfium_path) = &self.pdfium_path { - if !pdfium_path.is_empty() { - env::set_var("PDFIUM_PATH", pdfium_path); - } - } - - Ok(()) - } - - /// Will load the [StumpEnvironment] object from the Stump.toml file. If the file does not exist, - /// it will create it with the default values. Internally, it will call `StumpEnvironment::from_env` - /// to override the toml values with newly set environment variables. This is done so that a user - /// can set an environment variable if they prefer to not manually edit the toml. - /// - /// ## Example - /// ```rust - /// use stump_core::config::env::StumpEnvironment; - /// use std::env; - /// - /// env::set_var("STUMP_PORT", "8080"); - /// let env = StumpEnvironment::load().unwrap(); - /// assert_eq!(env.port, Some(8080)); - /// ``` - pub fn load() -> CoreResult { - let config_dir = get_config_dir(); - let stump_toml = config_dir.join("Stump.toml"); - - let environment = if stump_toml.exists() { - let toml_str = std::fs::read_to_string(stump_toml)?; - toml::from_str::(&toml_str) - .map_err(|e| CoreError::InitializationError(e.to_string()))? - } else { - debug!("Stump.toml does not exist, creating it"); - std::fs::File::create(stump_toml)?; - debug!("Stump.toml created"); - - Self::default() - }; - - // I reassign the env here to make sure it picks up changes when a user manually sets a value - let environment = Self::from_env(Some(environment))?; - // I then reassign the env here to make sure the vars it previously set are correct - environment.set_env()?; - - Ok(environment) - } - - pub fn write(&self) -> CoreResult<()> { - let config_dir = get_config_dir(); - let stump_toml = config_dir.join("Stump.toml"); - - std::fs::write( - stump_toml.as_path(), - toml::to_string(&self) - .map_err(|e| CoreError::InitializationError(e.to_string()))?, - )?; - - Ok(()) - } -} diff --git a/core/src/config/logging.rs b/core/src/config/logging.rs index 9a5291264..169cb03d2 100644 --- a/core/src/config/logging.rs +++ b/core/src/config/logging.rs @@ -1,36 +1,21 @@ -use std::path::PathBuf; - use tracing_subscriber::{ filter::LevelFilter, prelude::__tracing_subscriber_SubscriberExt, util::SubscriberInitExt, EnvFilter, }; -use super::get_config_dir; +use super::StumpConfig; pub const STUMP_SHADOW_TEXT: &str = include_str!("stump_shadow_text.txt"); -pub fn get_log_file() -> PathBuf { - get_config_dir().join("Stump.log") -} - -pub fn get_log_verbosity() -> u64 { - match std::env::var("STUMP_VERBOSITY") { - Ok(s) => s.parse::().unwrap_or(1), - Err(_) => 1, - } -} - // TODO: allow for overriding of format /// Initializes the logging system, which uses the [tracing] crate. Logs are written to /// both the console and a file in the config directory. The file is called `Stump.log` /// by default. -pub fn init_tracing() { - let config_dir = get_config_dir(); - +pub fn init_tracing(config: &StumpConfig) { + let config_dir = config.get_config_dir(); let file_appender = tracing_appender::rolling::never(config_dir, "Stump.log"); - let verbosity = get_log_verbosity(); - let max_level = match verbosity { + let max_level = match config.verbosity { 0 => LevelFilter::OFF, 1 => LevelFilter::INFO, 2 => LevelFilter::DEBUG, @@ -79,5 +64,5 @@ pub fn init_tracing() { ) .init(); - tracing::debug!(verbosity, "Tracing initialized"); + tracing::debug!(config.verbosity, "Tracing initialized"); } diff --git a/core/src/config/mod.rs b/core/src/config/mod.rs index 34cb7ab0b..45b6a263e 100644 --- a/core/src/config/mod.rs +++ b/core/src/config/mod.rs @@ -1,72 +1,60 @@ -use std::path::{Path, PathBuf}; - -pub mod env; pub mod logging; mod stump_config; -pub use stump_config::*; +use std::env; -/// Gets the home directory of the system running Stump -fn home() -> PathBuf { - dirs::home_dir().expect("Could not determine your home directory") -} +use stump_config::env_keys::{CONFIG_DIR_KEY, IN_DOCKER_KEY}; +pub use stump_config::{defaults, env_keys, StumpConfig}; -fn check_configuration_dir(path: &Path) { - if !path.exists() { - std::fs::create_dir_all(path).unwrap_or_else(|e| { - panic!( - "Failed to create Stump configuration directory at {:?}: {:?}", - path, - e.to_string() - ) - }); - } +/// Gets the default config directory located at `~/.stump` where `~` is the +/// user's home directory. +pub fn get_default_config_dir() -> String { + let home = dirs::home_dir().expect("Could not determine user home directory"); + let config_dir = home.join(".stump"); - if !path.is_dir() { - panic!( - "Invalid Stump configuration, the item located at {:?} must be a directory.", - path - ); - } + config_dir.to_string_lossy().into_owned() } -/// Gets the Stump config directory. If the directory does not exist, it will be created. If -/// the path is not a directory (only possible if overridden using STUMP_CONFIG_DIR) it will -/// panic. -pub fn get_config_dir() -> PathBuf { - let config_dir = std::env::var("STUMP_CONFIG_DIR") - .map(|val| { - if val.is_empty() { - home().join(".stump") +/// Returns the value of the `STUMP_CONFIG_DIR` environment variable if it is set, +/// logs an error and returns and `~/.stump` otherwise. +pub fn bootstrap_config_dir() -> String { + match env::var(CONFIG_DIR_KEY) { + // Environment variable set + Ok(config_dir) => { + if config_dir.is_empty() { + let default_dir = get_default_config_dir(); + tracing::error!( + "{} set to an empty value - falling back to {}", + CONFIG_DIR_KEY, + default_dir + ); + + default_dir } else { - PathBuf::from(val) + config_dir } - }) - .unwrap_or_else(|_| home().join(".stump")); - - check_configuration_dir(&config_dir); - - config_dir -} - -pub fn get_cache_dir() -> PathBuf { - let cache_dir = get_config_dir().join("cache"); - - check_configuration_dir(&cache_dir); - - cache_dir -} - -pub fn get_thumbnails_dir() -> PathBuf { - let thumbnails_dir = get_config_dir().join("thumbnails"); - - check_configuration_dir(&thumbnails_dir); - - thumbnails_dir + }, + // Environment variable not set + Err(e) => { + let default_dir = get_default_config_dir(); + tracing::error!( + "Error {} retrieving {} - falling back to {}", + e, + CONFIG_DIR_KEY, + default_dir + ); + + default_dir + }, + } } +/// Checks if Stump is running in docker by checking each of: +/// 1. The `STUMP_IN_DOCKER` environment variable. +/// 2. The existence of `/run/.containerenv` and `/.dockerenv`. +/// 3. The presence of "docker" or "containerd" processes. pub fn stump_in_docker() -> bool { - let env_set = std::env::var("STUMP_IN_DOCKER").is_ok(); + let env_set = std::env::var(IN_DOCKER_KEY).is_ok(); if env_set { return true; } @@ -88,7 +76,3 @@ pub fn stump_in_docker() -> bool { }) .unwrap_or(false) } - -pub fn get_pdfium_path() -> Option { - std::env::var("PDFIUM_PATH").ok().map(PathBuf::from) -} diff --git a/core/src/config/stump_config.rs b/core/src/config/stump_config.rs index a3ec57f67..849e1c6f6 100644 --- a/core/src/config/stump_config.rs +++ b/core/src/config/stump_config.rs @@ -1,191 +1,291 @@ -use std::{env, path::Path}; +use std::{env, path::PathBuf}; -use optional_struct::{optional_struct, Applyable}; +use itertools::Itertools; use serde::{Deserialize, Serialize}; use tracing::debug; -use crate::{ - config::get_config_dir, - error::{CoreError, CoreResult}, -}; +use crate::error::{CoreError, CoreResult}; -// TODO: before I actually use this, test to see if it works well. +pub mod env_keys { + pub const CONFIG_DIR_KEY: &str = "STUMP_CONFIG_DIR"; + pub const IN_DOCKER_KEY: &str = "STUMP_IN_DOCKER"; + pub const PROFILE_KEY: &str = "STUMP_PROFILE"; + pub const PORT_KEY: &str = "STUMP_PORT"; + pub const VERBOSITY_KEY: &str = "STUMP_VERBOSITY"; + pub const DB_PATH_KEY: &str = "STUMP_DB_PATH"; + pub const CLIENT_KEY: &str = "STUMP_CLIENT_DIR"; + pub const ORIGINS_KEY: &str = "STUMP_ALLOWED_ORIGINS"; + pub const PDFIUM_KEY: &str = "PDFIUM_PATH"; + pub const DISABLE_SWAGGER_KEY: &str = "DISABLE_SWAGGER_UI"; + pub const HASH_COST_KEY: &str = "HASH_COST"; + pub const SESSION_TTL_KEY: &str = "SESSION_TTL"; + pub const SESSION_EXPIRY_INTERVAL_KEY: &str = "SESSION_EXPIRY_CLEANUP_INTERVAL"; + pub const ENABLE_WAL_KEY: &str = "ENABLE_WAL"; +} +use env_keys::*; + +pub mod defaults { + pub const DEFAULT_PASSWORD_HASH_COST: u32 = 12; + pub const DEFAULT_SESSION_TTL: i64 = 3600 * 24 * 3; // 3 days + pub const DEFAULT_SESSION_EXPIRY_CLEANUP_INTERVAL: u64 = 60 * 60 * 24; // 24 hours + pub const DEFAULT_ENABLE_WAL: bool = true; +} +use defaults::*; -#[optional_struct(PartialStumpConfig)] -#[derive(Serialize, Deserialize, Debug, Clone)] +/// Represents the configuration of a Stump application. This file is generated at startup +/// using a TOML file, environment variables, or both and is input when creating a `StumpCore` +/// instance. +/// +/// Example: +/// ``` +/// use stump_core::{config::{self, StumpConfig}, StumpCore}; +/// +/// #[tokio::main] +/// async fn main() { +/// /// Get config dir from environment variables. +/// let config_dir = config::bootstrap_config_dir(); +/// +/// // Create a StumpConfig using the config file and environment variables. +/// let config = StumpConfig::new(config_dir) +/// // Load Stump.toml file (if any) +/// .with_config_file().unwrap() +/// // Overlay environment variables +/// .with_environment().unwrap(); +/// +/// // Ensure that config directory exists and write Stump.toml. +/// config.write_config_dir().unwrap(); +/// // Create an instance of the stump core. +/// let core = StumpCore::new(config).await; +/// } +/// ``` +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] pub struct StumpConfig { + /// The "release" | "debug" profile with which the application is running. pub profile: String, + /// The port from which to serve the application (default: 10801). pub port: u16, + /// The verbosity with which to log errors (default: 0). pub verbosity: u64, + /// An optional custom path for the database. + pub db_path: Option, + /// The client directory. pub client_dir: String, + /// The configuration root for the Stump application, cotains thumbnails, cache, and logs. pub config_dir: String, - pub allowed_origins: Option>, + /// A list of origins for CORS. + pub allowed_origins: Vec, + /// Path to the PDFium binary for PDF support. + pub pdfium_path: Option, + /// Indicates if the Swagger UI should be disabled. + pub disable_swagger: bool, + /// Password hash cost + pub password_hash_cost: u32, + /// The time in seconds that a login session will be valid for. + pub session_ttl: i64, + /// The interval at which automatic deleted session cleanup is performed. + pub expired_session_cleanup_interval: u64, + /// Indicates whether the prisma client will support write-ahead logging. + pub enable_wal: bool, } -impl Default for StumpConfig { - fn default() -> Self { +impl StumpConfig { + /// Create a new `StumpConfig` instance with a given `config_dir` as + /// the configuration root and default values for other variables. + pub fn new(config_dir: String) -> Self { Self { profile: String::from("debug"), port: 10801, - // TODO: change default back to 0 - verbosity: 1, - client_dir: String::from("client"), - config_dir: get_config_dir().to_string_lossy().to_string(), - allowed_origins: None, + verbosity: 0, + db_path: None, + client_dir: String::from("./dist"), + config_dir, + allowed_origins: vec![], + pdfium_path: None, + disable_swagger: false, + password_hash_cost: DEFAULT_PASSWORD_HASH_COST, + session_ttl: DEFAULT_SESSION_TTL, + expired_session_cleanup_interval: DEFAULT_SESSION_EXPIRY_CLEANUP_INTERVAL, + enable_wal: DEFAULT_ENABLE_WAL, } } -} -impl StumpConfig { - /// Will load the [StumpConfig] object from the Stump.toml file. If the file does not exist, - /// it will create it with the default values. Internally, it will call `StumpConfig::from_env` - /// to override the toml values with newly set environment variables. This is done so that a user - /// can set an environment variable if they prefer to not manually edit the toml. - /// - /// ## Example - /// ```rust - /// use stump_core::config::StumpConfig; - /// use std::env; - /// - /// env::set_var("STUMP_PORT", "8080"); - /// let env = StumpConfig::load().unwrap(); - /// assert_eq!(env.port, 8080); - /// ``` - pub fn load() -> CoreResult { - let config_dir = get_config_dir(); - let stump_toml = config_dir.join("Stump.toml"); - - let environment = if stump_toml.exists() { - StumpConfig::from_toml(&stump_toml)? - } else { - debug!("Stump.toml does not exist, creating it"); - std::fs::File::create(stump_toml)?; - debug!("Stump.toml created"); - StumpConfig::default() - }; + /// Create a debug version of `StumpConfig` with `config_dir` + /// automatically set using `get_default_config_dir()` and `client_dir` set + /// to `env!("CARGO_MANIFEST_DIR").to_string() + "/../web/dist"`. + pub fn debug() -> Self { + Self { + profile: String::from("debug"), + port: 10801, + verbosity: 0, + db_path: None, + client_dir: env!("CARGO_MANIFEST_DIR").to_string() + "/../web/dist", + config_dir: super::get_default_config_dir(), + allowed_origins: vec![], + pdfium_path: None, + disable_swagger: false, + password_hash_cost: DEFAULT_PASSWORD_HASH_COST, + session_ttl: DEFAULT_SESSION_TTL, + expired_session_cleanup_interval: DEFAULT_SESSION_EXPIRY_CLEANUP_INTERVAL, + enable_wal: DEFAULT_ENABLE_WAL, + } + } - // I reassign the env here to make sure it picks up changes when a user manually sets a value - let environment = StumpConfig::from_env(Some(environment))?; - // I then reassign the env here to make sure the vars it previously set are correct - environment.set_env()?; + /// Looks for Stump.toml at `self.config_dir`, loading its contents and replacing + /// stored configuration variables with those contents. If Stump.toml doesn't exist, + /// the stored variables remain unchanged and the function returns `Ok`. + pub fn with_config_file(mut self) -> CoreResult { + let stump_toml = self.get_config_dir().join("Stump.toml"); - Ok(environment) - } + // The config file may not exist (e.g. on first startup), + // this isn't an error, so we just return early. + if !stump_toml.exists() { + return Ok(self); + } - /// Will load the [StumpConfig] object from the Stump.toml file. - pub fn from_toml>(path: P) -> CoreResult { - let path = path.as_ref(); - let toml_str = std::fs::read_to_string(path)?; - let partial_config = toml::from_str::(&toml_str) + let toml_content_str = std::fs::read_to_string(stump_toml)?; + let toml_configs = toml::from_str::(&toml_content_str) .map_err(|e| CoreError::InitializationError(e.to_string()))?; - Ok(StumpConfig::from(partial_config)) + toml_configs.apply_to_config(&mut self); + Ok(self) } - /// Will try to create a new [StumpConfig] object from set environment variables. If none are set, - /// will return the default [StumpConfig] object. - /// - /// ## Example - /// ```rust - /// use std::env; - /// use stump_core::config::stump_config::StumpConfig; - /// - /// env::set_var("STUMP_PORT", "8080"); - /// env::set_var("STUMP_VERBOSITY", "3"); - /// - /// let config = StumpConfig::from_env(None); - /// assert!(env.is_ok()); - /// let config = config.unwrap(); - /// assert_eq!(config.port, 8080); - /// assert_eq!(config.verbosity, 3); - /// ``` - pub fn from_env(existing: Option) -> CoreResult { - let mut config = existing.unwrap_or_default(); - - if let Ok(port) = env::var("STUMP_PORT") { - config.port = port - .parse::() - .map_err(|e| CoreError::InitializationError(e.to_string()))?; - } + /// Loads configuration variables from the environment, replacing stored + /// values with the environment values. + pub fn with_environment(mut self) -> CoreResult { + let mut env_configs = PartialStumpConfig::empty(); - if let Ok(profile) = env::var("STUMP_PROFILE") { + if let Ok(profile) = env::var(PROFILE_KEY) { if profile == "release" || profile == "debug" { - config.profile = profile; + env_configs.profile = Some(profile); } else { debug!("Invalid PROFILE value: {}", profile); - config.profile = String::from("debug"); } } - if let Ok(verbosity) = env::var("STUMP_VERBOSITY") { - config.verbosity = verbosity + if let Ok(port) = env::var(PORT_KEY) { + let port_u16 = port + .parse::() + .map_err(|e| CoreError::InitializationError(e.to_string()))?; + env_configs.port = Some(port_u16); + } + + if let Ok(verbosity) = env::var(VERBOSITY_KEY) { + let verbosity_u64 = verbosity .parse::() .map_err(|e| CoreError::InitializationError(e.to_string()))?; + env_configs.verbosity = Some(verbosity_u64); } - if let Ok(client_dir) = env::var("STUMP_CLIENT_DIR") { - config.client_dir = client_dir; + if let Ok(db_path) = env::var(DB_PATH_KEY) { + env_configs.db_path = Some(db_path); } - if let Ok(config_dir) = env::var("STUMP_CONFIG_DIR") { - if Path::new(&config_dir).exists() { - config.config_dir = config_dir; - } else { - return Err(CoreError::ConfigDirDoesNotExist(config_dir)); - } + if let Ok(client_dir) = env::var(CLIENT_KEY) { + env_configs.client_dir = Some(client_dir); } - config.config_dir = get_config_dir().to_string_lossy().to_string(); - config.write()?; + if let Ok(config_dir) = env::var(CONFIG_DIR_KEY) { + env_configs.config_dir = Some(config_dir); + } - Ok(config) - } + if let Ok(allowed_origins) = env::var(ORIGINS_KEY) { + if !allowed_origins.is_empty() { + env_configs.allowed_origins = Some( + allowed_origins + .split(',') + .map(|val| val.trim().to_string()) + .collect_vec(), + ) + } + }; - /// Will set the environment variables to the values in the [StumpConfig] object. - /// If the values are not set, will use the default values. - /// - /// ## Example - /// ```rust - /// use std::env; - /// use stump_core::config::StumpConfig; - /// - /// let mut config = StumpConfig::default(); - /// config.port = 8080; - /// - /// assert_eq!(env::var("STUMP_PORT").is_err(), true); - /// config.set_env().unwrap(); - /// assert_eq!(env::var("STUMP_PORT").unwrap(), "8080"); - /// ``` - pub fn set_env(&self) -> CoreResult<()> { - if self.profile != "debug" { - env::set_var("STUMP_PROFILE", "release"); - } else { - env::set_var("STUMP_PROFILE", "debug"); + if let Ok(pdfium_path) = env::var(PDFIUM_KEY) { + env_configs.pdfium_path = Some(pdfium_path); } - env::set_var("STUMP_PORT", self.port.to_string()); - env::set_var("STUMP_VERBOSITY", self.verbosity.to_string()); + if let Ok(hash_cost) = env::var(HASH_COST_KEY) { + if let Ok(val) = hash_cost.parse() { + env_configs.password_hash_cost = Some(val); + } + } - if Path::new(self.config_dir.as_str()).exists() { - env::set_var("STUMP_CONFIG_DIR", self.config_dir.as_str()); + if let Ok(disable_swagger) = env::var(DISABLE_SWAGGER_KEY) { + if let Ok(val) = disable_swagger.parse() { + env_configs.disable_swagger = Some(val); + } } - if Path::new(self.client_dir.as_str()).exists() { - env::set_var("STUMP_CLIENT_DIR", self.client_dir.as_str()); + if let Ok(session_ttl) = env::var(SESSION_TTL_KEY) { + match session_ttl.parse() { + Ok(val) => env_configs.session_ttl = Some(val), + Err(e) => tracing::error!(?e, "Failed to parse provided SESSION_TTL"), + } } - if let Some(allowed_origins) = &self.allowed_origins { - if !allowed_origins.is_empty() { - env::set_var("STUMP_ALLOWED_ORIGINS", allowed_origins.join(",")); + if let Ok(session_expiry_interval) = env::var(SESSION_EXPIRY_INTERVAL_KEY) { + match session_expiry_interval.parse() { + Ok(val) => env_configs.expired_session_cleanup_interval = Some(val), + Err(e) => tracing::error!( + ?e, + "Failed to parse provided SESSION_EXPIRY_CLEANUP_INTERVAL" + ), } } - Ok(()) + if let Ok(enable_wal) = env::var(ENABLE_WAL_KEY) { + match enable_wal.parse() { + Ok(val) => env_configs.enable_wal = Some(val), + Err(e) => tracing::error!(?e, "Failed to parse ENABLE_WAL"), + } + } + + env_configs.apply_to_config(&mut self); + Ok(self) } - /// Will write the [StumpConfig] object to the Stump.toml file. - pub fn write(&self) -> CoreResult<()> { - let config_dir = get_config_dir(); + /// Ensures that the configuration directory exists and saves the `StumpConfig`'s current values + /// to Stump.toml in the configuration directory. + /// + /// This function first checks if `config_dir` exists and creates it if it does not, then does the + /// same for the thumbnails and cache directories. Finally, a Stump.toml file containing the current + /// configuration values is written. Returns `Ok` on success and `Err` if paths are misconfigured or + /// file IO errors are encountered. + pub fn write_config_dir(&self) -> CoreResult<()> { + // Check that config directory is configured correctly + let config_dir = self.get_config_dir(); + if config_dir.is_file() { + return Err(CoreError::InitializationError(format!( + "Error writing config directory: {:?} is a file", + config_dir + ))); + } + + // And create directory if it is missing. + if !config_dir.exists() { + match std::fs::create_dir_all(config_dir.clone()) { + Ok(_) => (), + Err(e) => { + return Err(CoreError::InitializationError(format!( + "Failed to create Stump configuration directory at {:?}: {:?}", + config_dir, + e.to_string() + ))); + }, + } + } + + // Create cache and thumbnail directories if they are missing + let cache_dir = self.get_cache_dir(); + let thumbs_dir = self.get_thumbnails_dir(); + if !cache_dir.exists() { + std::fs::create_dir(cache_dir).unwrap(); + } + if !thumbs_dir.exists() { + std::fs::create_dir(thumbs_dir).unwrap(); + } + + // Save configuration to Stump.toml let stump_toml = config_dir.join("Stump.toml"); std::fs::write( @@ -196,12 +296,337 @@ impl StumpConfig { Ok(()) } + + /// Returns True if the configuration profile is "debug" and False otherwise. + pub fn is_debug(&self) -> bool { + self.profile.as_str() == "debug" + } + + /// Returns a `PathBuf` to the Stump configuration directory. + pub fn get_config_dir(&self) -> PathBuf { + PathBuf::from(&self.config_dir) + } + + /// Returns a `PathBuf` to the Stump cache directory. + pub fn get_cache_dir(&self) -> PathBuf { + PathBuf::from(&self.config_dir).join("cache") + } + + /// Returns a `PathBuf` to the Stump thumbnails directory. + pub fn get_thumbnails_dir(&self) -> PathBuf { + PathBuf::from(&self.config_dir).join("thumbnails") + } + + /// Returns a `PathBuf` to the Stump log file. + pub fn get_log_file(&self) -> PathBuf { + self.get_config_dir().join("Stump.log") + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +pub struct PartialStumpConfig { + pub profile: Option, + pub port: Option, + pub verbosity: Option, + pub db_path: Option, + pub client_dir: Option, + pub config_dir: Option, + pub allowed_origins: Option>, + pub pdfium_path: Option, + pub disable_swagger: Option, + pub password_hash_cost: Option, + pub session_ttl: Option, + pub expired_session_cleanup_interval: Option, + pub enable_wal: Option, } -impl From for StumpConfig { - fn from(partial: PartialStumpConfig) -> StumpConfig { - let mut default = StumpConfig::default(); - partial.apply_to(&mut default); - default +impl PartialStumpConfig { + pub fn empty() -> Self { + Self { + profile: None, + port: None, + verbosity: None, + db_path: None, + client_dir: None, + config_dir: None, + allowed_origins: None, + pdfium_path: None, + disable_swagger: None, + password_hash_cost: None, + session_ttl: None, + expired_session_cleanup_interval: None, + enable_wal: None, + } + } + + pub fn apply_to_config(self, config: &mut StumpConfig) { + if let Some(port) = self.port { + config.port = port; + } + if let Some(verbosity) = self.verbosity { + config.verbosity = verbosity; + } + if let Some(db_path) = self.db_path { + config.db_path = Some(db_path); + } + if let Some(client_dir) = self.client_dir { + config.client_dir = client_dir; + } + if let Some(config_dir) = self.config_dir { + config.config_dir = config_dir; + } + + // Profile - validate profile selection + if let Some(profile) = self.profile { + if profile == "release" || profile == "debug" { + config.profile = profile; + } else { + debug!("Invalid PROFILE value: {}", profile); + } + } + + // Allowed Origins - merge lists + if let Some(origins) = self.allowed_origins { + let orig_origins = config.allowed_origins.clone(); + config + .allowed_origins + .extend(origins.into_iter().filter(|x| !orig_origins.contains(x))); + } + + // Pdfium Path - Merge if not None + if let Some(pdfium_path) = self.pdfium_path { + config.pdfium_path = Some(pdfium_path); + } + // Disable Swagger - Merge if not None + if let Some(disable_swagger) = self.disable_swagger { + config.disable_swagger = disable_swagger; + } + // Password Hash Cost - Merge if not None + if let Some(hash_cost) = self.password_hash_cost { + config.password_hash_cost = hash_cost; + } + // Session TTL - Merge if not None + if let Some(session_ttl) = self.session_ttl { + config.session_ttl = session_ttl; + } + // Session Expiry Cleanup Interval - Merge if not None + if let Some(cleanup_interval) = self.expired_session_cleanup_interval { + config.expired_session_cleanup_interval = cleanup_interval; + } + // Enable WAL - Merge if not None + if let Some(enable_wal) = self.enable_wal { + config.enable_wal = enable_wal; + } + } +} + +#[cfg(test)] +mod tests { + use std::fs; + + use tempfile; + + use super::*; + + #[test] + fn test_apply_partial_to_debug() { + let mut config = StumpConfig::debug(); + config.allowed_origins = vec!["origin1".to_string(), "origin2".to_string()]; + + let partial_config = PartialStumpConfig { + profile: Some("release".to_string()), + port: Some(1337), + verbosity: Some(3), + db_path: Some("not_a_real_path".to_string()), + client_dir: Some("not_a_real_dir".to_string()), + config_dir: Some("also_not_a_real_dir".to_string()), + allowed_origins: Some(vec![ + "origin1".to_string(), + "origin3".to_string(), + "origin2".to_string(), + ]), + pdfium_path: Some("not_a_path_to_pdfium".to_string()), + disable_swagger: Some(true), + password_hash_cost: Some(24), + session_ttl: Some(3600 * 24), + expired_session_cleanup_interval: Some(60 * 60 * 8), + enable_wal: Some(true), + }; + + // Apply the partial configuration + partial_config.apply_to_config(&mut config); + + // Check that values are as expected + assert_eq!( + config, + StumpConfig { + profile: "release".to_string(), + port: 1337, + verbosity: 3, + db_path: Some("not_a_real_path".to_string()), + client_dir: "not_a_real_dir".to_string(), + config_dir: "also_not_a_real_dir".to_string(), + allowed_origins: vec![ + "origin1".to_string(), + "origin2".to_string(), + "origin3".to_string() + ], + pdfium_path: Some("not_a_path_to_pdfium".to_string()), + disable_swagger: true, + password_hash_cost: 24, + session_ttl: 3600 * 24, + expired_session_cleanup_interval: 60 * 60 * 8, + enable_wal: true, + } + ); + } + + #[test] + fn test_getting_config_from_environment() { + // Set environment variables + env::set_var(PROFILE_KEY, "release"); + env::set_var(PORT_KEY, "1337"); + env::set_var(VERBOSITY_KEY, "3"); + env::set_var(DB_PATH_KEY, "not_a_real_path"); + env::set_var(CLIENT_KEY, "not_a_real_dir"); + env::set_var(CONFIG_DIR_KEY, "also_not_a_real_dir"); + env::set_var(DISABLE_SWAGGER_KEY, "true"); + env::set_var(HASH_COST_KEY, "24"); + env::set_var(SESSION_TTL_KEY, (3600 * 24).to_string()); + env::set_var(SESSION_EXPIRY_INTERVAL_KEY, (60 * 60 * 8).to_string()); + env::set_var(ENABLE_WAL_KEY, "true".to_string()); + + // Create a new StumpConfig and load values from the environment. + let config = StumpConfig::new("not_a_dir".to_string()) + .with_environment() + .unwrap(); + + // Confirm values are as expected + assert_eq!( + config, + StumpConfig { + profile: "release".to_string(), + port: 1337, + verbosity: 3, + db_path: Some("not_a_real_path".to_string()), + client_dir: "not_a_real_dir".to_string(), + config_dir: "also_not_a_real_dir".to_string(), + allowed_origins: vec![], + pdfium_path: None, + disable_swagger: true, + password_hash_cost: 24, + session_ttl: 3600 * 24, + expired_session_cleanup_interval: 60 * 60 * 8, + enable_wal: true, + } + ); + } + + #[test] + fn test_getting_config_from_toml() { + // Create temporary directory and place a copy of our mock Stump.toml in it + let tempdir = tempfile::tempdir().expect("Failed to create temporary directory"); + let temp_config_file_path = tempdir.path().join("Stump.toml"); + fs::write(temp_config_file_path, get_mock_config_file()) + .expect("Failed to write temporary Stump.toml"); + + // Now we can create a StumpConfig rooted at the temporary directory and load the values + let config_dir = tempdir.path().to_string_lossy().to_string(); + let config = StumpConfig::new(config_dir).with_config_file().unwrap(); + + // Check that values are as expected + assert_eq!( + config, + StumpConfig { + profile: "release".to_string(), + port: 1337, + verbosity: 3, + db_path: Some("not_a_real_path".to_string()), + client_dir: "not_a_real_dir".to_string(), + config_dir: "also_not_a_real_dir".to_string(), + allowed_origins: vec!["origin1".to_string(), "origin2".to_string()], + pdfium_path: Some("not_a_path_to_pdfium".to_string()), + disable_swagger: false, + password_hash_cost: DEFAULT_PASSWORD_HASH_COST, + session_ttl: DEFAULT_SESSION_TTL, + expired_session_cleanup_interval: DEFAULT_SESSION_EXPIRY_CLEANUP_INTERVAL, + enable_wal: DEFAULT_ENABLE_WAL, + } + ); + + // Ensure that the temporary directory is deleted + tempdir + .close() + .expect("Failed to delete temporary directory"); + } + + #[test] + fn test_writing_to_config_dir() { + let tempdir = tempfile::tempdir().expect("Failed to create temporary directory"); + + // Now we can create a StumpConfig rooted at the temporary directory + let config_dir = tempdir.path().to_string_lossy().to_string(); + let mut config = StumpConfig::new(config_dir.clone()); + + // Apply a partial config to set the values + let partial_config = PartialStumpConfig { + profile: Some("release".to_string()), + port: Some(1337), + verbosity: Some(3), + db_path: Some("not_a_real_path".to_string()), + client_dir: Some("not_a_real_dir".to_string()), + config_dir: None, + allowed_origins: Some(vec!["origin1".to_string(), "origin2".to_string()]), + pdfium_path: Some("not_a_path_to_pdfium".to_string()), + disable_swagger: Some(false), + password_hash_cost: None, + session_ttl: None, + expired_session_cleanup_interval: None, + enable_wal: None, + }; + partial_config.apply_to_config(&mut config); + + // Write to the config directory + config.write_config_dir().unwrap(); + + // Load the toml that should have been created + let new_toml_path = tempdir.path().join("Stump.toml"); + let new_toml_content = std::fs::read_to_string(new_toml_path).unwrap(); + let new_toml_vals = + toml::from_str::(&new_toml_content).unwrap(); + + // And check its values against what we expect + assert_eq!( + new_toml_vals, + PartialStumpConfig { + profile: Some("release".to_string()), + port: Some(1337), + verbosity: Some(3), + db_path: Some("not_a_real_path".to_string()), + client_dir: Some("not_a_real_dir".to_string()), + config_dir: Some(config_dir), + allowed_origins: Some(vec!["origin1".to_string(), "origin2".to_string()]), + pdfium_path: Some("not_a_path_to_pdfium".to_string()), + disable_swagger: Some(false), + password_hash_cost: Some(DEFAULT_PASSWORD_HASH_COST), + session_ttl: Some(DEFAULT_SESSION_TTL), + expired_session_cleanup_interval: Some( + DEFAULT_SESSION_EXPIRY_CLEANUP_INTERVAL + ), + enable_wal: Some(DEFAULT_ENABLE_WAL) + } + ); + + // Ensure that the temporary directory is deleted + tempdir + .close() + .expect("Failed to delete temporary directory"); + } + + fn get_mock_config_file() -> String { + let mock_config_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("integration-tests/data/mock-stump.toml"); + + fs::read_to_string(mock_config_path).expect("Failed to fetch mock config file") } } diff --git a/core/src/context.rs b/core/src/context.rs index 22e8ccc4c..2c541457d 100644 --- a/core/src/context.rs +++ b/core/src/context.rs @@ -6,6 +6,7 @@ use tokio::sync::{ }; use crate::{ + config::StumpConfig, db::{self, entity::Log}, event::{CoreEvent, InternalCoreTask}, job::JobExecutorTrait, @@ -20,6 +21,7 @@ type ClientChannel = (Sender, Receiver); /// to all the different parts of the application, and is used to access the database /// and manage the event channels. pub struct Ctx { + pub config: Arc, pub db: Arc, pub internal_sender: Arc, pub response_channel: Arc, @@ -38,18 +40,20 @@ impl Ctx { /// /// ## Example /// ```rust - /// use stump_core::config::Ctx; + /// use stump_core::{Ctx, config::StumpConfig}; /// use tokio::sync::mpsc::unbounded_channel; /// /// #[tokio::main] /// async fn main() { /// let (sender, _) = unbounded_channel(); - /// let ctx = Ctx::new(sender).await; + /// let config = StumpConfig::debug(); + /// let ctx = Ctx::new(config, sender).await; /// } /// ``` - pub async fn new(internal_sender: InternalSender) -> Ctx { + pub async fn new(config: StumpConfig, internal_sender: InternalSender) -> Ctx { Ctx { - db: Arc::new(db::create_client().await), + config: Arc::new(config.clone()), + db: Arc::new(db::create_client(&config).await), internal_sender: Arc::new(internal_sender), response_channel: Arc::new(channel::(1024)), } @@ -61,6 +65,7 @@ impl Ctx { /// **This should not be used in production.** pub async fn mock() -> Ctx { Ctx { + config: Arc::new(StumpConfig::debug()), db: Arc::new(db::create_test_client().await), internal_sender: Arc::new(unbounded_channel::().0), response_channel: Arc::new(channel::(1024)), @@ -72,15 +77,16 @@ impl Ctx { /// /// ## Example /// ```rust - /// use stump_core::config::Ctx; + /// use stump_core::{Ctx, config::StumpConfig}; /// use tokio::sync::mpsc::unbounded_channel; /// use std::sync::Arc; /// /// #[tokio::main] /// async fn main() { /// let (sender, _) = unbounded_channel(); + /// let config = StumpConfig::debug(); /// - /// let ctx = Ctx::new(sender).await; + /// let ctx = Ctx::new(config, sender).await; /// let arced_ctx = ctx.arced(); /// let ctx_clone = arced_ctx.clone(); /// @@ -99,6 +105,7 @@ impl Ctx { /// Returns a copy of the ctx pub fn get_ctx(&self) -> Ctx { Ctx { + config: self.config.clone(), db: self.db.clone(), internal_sender: self.internal_sender.clone(), response_channel: self.response_channel.clone(), @@ -115,16 +122,17 @@ impl Ctx { /// /// ## Example /// ```rust - /// use stump_core::{config::Ctx, event::CoreEvent}; + /// use stump_core::{Ctx, config::StumpConfig, event::CoreEvent}; /// use tokio::sync::mpsc::unbounded_channel; /// /// #[tokio::main] /// async fn main() { /// let (sender, _) = unbounded_channel(); - /// let ctx = Ctx::new(sender).await; + /// let config = StumpConfig::debug(); + /// let ctx = Ctx::new(config, sender).await; /// /// let event = CoreEvent::JobFailed { - /// runner_id: "Gandalf quote".to_string(), + /// job_id: "Gandalf quote".to_string(), /// message: "When in doubt, follow your nose".to_string(), /// }; /// @@ -134,8 +142,8 @@ impl Ctx { /// let received_event = receiver.recv().await; /// assert_eq!(received_event.is_ok(), true); /// match received_event.unwrap() { - /// CoreEvent::JobFailed { runner_id, message } => { - /// assert_eq!(runner_id, "Gandalf quote"); + /// CoreEvent::JobFailed { job_id, message } => { + /// assert_eq!(job_id, "Gandalf quote"); /// assert_eq!(message, "When in doubt, follow your nose"); /// } /// _ => unreachable!("Wrong event type received"), diff --git a/core/src/db/client.rs b/core/src/db/client.rs index fe8203f51..6d8c9ee7d 100644 --- a/core/src/db/client.rs +++ b/core/src/db/client.rs @@ -2,7 +2,7 @@ use prisma_client_rust::raw; use std::path::Path; use tracing::trace; -use crate::{config::get_config_dir, prisma}; +use crate::{config::StumpConfig, prisma}; #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub(crate) struct JournalModeQueryResult { @@ -10,19 +10,16 @@ pub(crate) struct JournalModeQueryResult { } /// Creates the PrismaClient. Will call `create_data_dir` as well -pub async fn create_client() -> prisma::PrismaClient { - let config_dir = get_config_dir() +pub async fn create_client(config: &StumpConfig) -> prisma::PrismaClient { + let config_dir = config + .get_config_dir() .to_str() .expect("Error parsing config directory") .to_string(); - let profile = std::env::var("STUMP_PROFILE").unwrap_or_else(|_| "debug".to_string()); - let db_override = std::env::var("STUMP_DB_PATH").ok(); - - let client = if let Some(path) = db_override { - trace!("Creating Prisma client with url: file:{}", &path); - create_client_with_url(&format!("file:{}", &path)).await - } else if profile == "release" { + let client = if let Some(path) = config.db_path.clone() { + create_client_with_url(&format!("file:{}/stump.db", &path)).await + } else if config.profile == "release" { trace!( "Creating Prisma client with url: file:{}/stump.db", &config_dir @@ -42,35 +39,15 @@ pub async fn create_client() -> prisma::PrismaClient { .await }; - let enable_wal_var = std::env::var("ENABLE_WAL") - .ok() - .and_then(|v| v.parse::().ok()); - - if let Some(enable_wal) = enable_wal_var { - let journal_value = if enable_wal { "WAL" } else { "DELETE" }; - - if enable_wal { - tracing::warn!( - "WAL is highly unstable at the moment. DO NOT SET MORE THAN ONCE! Be sure to remove the environment variable after usage" - ); - } - - let result = client - ._query_raw::(raw!(&format!( - "PRAGMA journal_mode={journal_value};" - ))) + if config.enable_wal { + let _affected_rows = client + ._execute_raw(raw!("PRAGMA journal_mode=WAL;")) .exec() .await .unwrap_or_else(|error| { - tracing::error!(?error, "Failed to set journal mode"); - vec![] + tracing::error!(?error, "Failed to enable WAL mode"); + 0 }); - - if let Some(journal_mode) = result.first() { - tracing::debug!(?journal_mode, "Journal mode set"); - } else { - tracing::error!("No journal mode set!"); - } } client diff --git a/core/src/event/event_manager.rs b/core/src/event/event_manager.rs index 0f14e4b40..68fbbd60f 100644 --- a/core/src/event/event_manager.rs +++ b/core/src/event/event_manager.rs @@ -19,13 +19,14 @@ impl EventManager { /// /// ## Example /// ```rust - /// use stump_core::{event::event_manager::EventManager, config::Ctx}; + /// use stump_core::{event::event_manager::EventManager, Ctx, config::StumpConfig}; /// use tokio::sync::mpsc::unbounded_channel; /// /// #[tokio::main] /// async fn main() { /// let (sender, reciever) = unbounded_channel(); - /// let ctx = Ctx::new(sender).await; + /// let config = StumpConfig::debug(); + /// let ctx = Ctx::new(config, sender).await; /// let event_manager = EventManager::new(ctx, reciever); /// } /// ``` diff --git a/core/src/filesystem/common.rs b/core/src/filesystem/common.rs index fa953f254..d7cb4103e 100644 --- a/core/src/filesystem/common.rs +++ b/core/src/filesystem/common.rs @@ -7,8 +7,6 @@ use std::{ use tracing::error; use walkdir::WalkDir; -use crate::config::get_thumbnails_dir; - use super::{media::is_accepted_cover_name, ContentType, FileError}; pub fn read_entire_file>(path: P) -> Result, FileError> { @@ -22,18 +20,16 @@ pub fn read_entire_file>(path: P) -> Result, FileError> { /// A function that returns the path of a thumbnail image, if it exists. /// This should be used when the thumbnail extension is not known. -pub fn get_unknown_thumnail(id: &str) -> Option { - let mut path = get_thumbnails_dir(); - +pub fn get_unknown_thumnail(id: &str, mut thumbnails_dir: PathBuf) -> Option { let accepted_extensions = ["jpg", "png", "jpeg", "webp"]; for extension in accepted_extensions.iter() { - path.push(format!("{}.{}", id, extension)); + thumbnails_dir.push(format!("{}.{}", id, extension)); - if path.exists() { - return Some(path); + if thumbnails_dir.exists() { + return Some(thumbnails_dir); } - path.pop(); + thumbnails_dir.pop(); } None diff --git a/core/src/filesystem/content_type.rs b/core/src/filesystem/content_type.rs index a24c5d370..ff8b066e9 100644 --- a/core/src/filesystem/content_type.rs +++ b/core/src/filesystem/content_type.rs @@ -59,10 +59,10 @@ impl ContentType { /// /// ### Example /// ```rust - /// use stump_core::prelude::server::http::ContentType; + /// use stump_core::filesystem::ContentType; /// /// let content_type = ContentType::from_extension("png"); - /// assert_eq!(content_type, Some(ContentType::PNG)); + /// assert_eq!(content_type, ContentType::PNG); /// ``` pub fn from_extension(extension: &str) -> ContentType { match extension.to_lowercase().as_str() { @@ -89,7 +89,7 @@ impl ContentType { /// /// ### Example /// ```rust - /// use stump_core::prelude::server::http::ContentType; + /// use stump_core::filesystem::ContentType; /// /// let content_type = ContentType::from_file("test.png"); /// assert_eq!(content_type, ContentType::PNG); @@ -104,7 +104,7 @@ impl ContentType { /// /// ### Example /// ```rust - /// use stump_core::prelude::server::http::ContentType; + /// use stump_core::filesystem::ContentType; /// /// let buf = [0xFF, 0xD8, 0xFF, 0xAA]; /// let content_type = ContentType::from_bytes(&buf); @@ -121,7 +121,7 @@ impl ContentType { /// /// ### Example /// ```rust - /// use stump_core::prelude::server::http::ContentType; + /// use stump_core::filesystem::ContentType; /// /// // This is NOT a valid PNG buff /// let buf = [0xFF, 0xD8, 0xBB, 0xBB]; @@ -148,7 +148,7 @@ impl ContentType { /// /// ### Example /// ```rust - /// use stump_core::types::server::http::preludentType; + /// use stump_core::filesystem::ContentType; /// use std::path::Path; /// /// let path = Path::new("test.png"); @@ -177,7 +177,7 @@ impl ContentType { /// /// ## Example /// ```rust - /// use stump_core::prelude::server::http::ContentType; + /// use stump_core::filesystem::ContentType; /// /// let content_type = ContentType::PNG; /// assert!(content_type.is_image()); @@ -195,7 +195,7 @@ impl ContentType { /// ## Example /// /// ```rust - /// use stump_core::prelude::server::http::ContentType; + /// use stump_core::filesystem::ContentType; /// /// let content_type = ContentType::PNG; /// assert!(content_type.is_opds_legacy_image()); @@ -211,7 +211,7 @@ impl ContentType { /// ## Example /// /// ```rust - /// use stump_core::prelude::server::http::ContentType; + /// use stump_core::filesystem::ContentType; /// /// let content_type = ContentType::ZIP; /// assert!(content_type.is_zip()); @@ -225,7 +225,7 @@ impl ContentType { /// ## Example /// /// ```rust - /// use stump_core::prelude::server::http::ContentType; + /// use stump_core::filesystem::ContentType; /// /// let content_type = ContentType::RAR; /// assert!(content_type.is_rar()); @@ -239,7 +239,7 @@ impl ContentType { /// ## Example /// /// ```rust - /// use stump_core::prelude::server::http::ContentType; + /// use stump_core::filesystem::ContentType; /// /// let content_type = ContentType::EPUB_ZIP; /// assert!(content_type.is_epub()); diff --git a/core/src/filesystem/image/thumbnail.rs b/core/src/filesystem/image/thumbnail.rs index 1acd15093..d2a6cff35 100644 --- a/core/src/filesystem/image/thumbnail.rs +++ b/core/src/filesystem/image/thumbnail.rs @@ -4,7 +4,7 @@ use rayon::prelude::{IntoParallelIterator, ParallelIterator}; use tracing::{debug, error, trace}; use crate::{ - config::get_thumbnails_dir, + config::StumpConfig, db::entity::Media, filesystem::{media, FileError}, prisma::media as prisma_media, @@ -19,11 +19,12 @@ pub fn generate_thumbnail( id: &str, media_path: &str, options: ImageProcessorOptions, + config: &StumpConfig, ) -> Result { - let (_, buf) = media::get_page(media_path, options.page.unwrap_or(1))?; + let (_, buf) = media::get_page(media_path, options.page.unwrap_or(1), config)?; let ext = options.format.extension(); - let thumbnail_path = get_thumbnails_dir().join(format!("{}.{}", &id, ext)); + let thumbnail_path = config.get_thumbnails_dir().join(format!("{}.{}", &id, ext)); if !thumbnail_path.exists() { // TODO: this will be more complicated once more specialized processors are added... let image_buffer = if options.format == ImageFormat::Webp { @@ -45,6 +46,7 @@ pub fn generate_thumbnail( pub fn generate_thumbnails( media: &[Media], options: ImageProcessorOptions, + config: &StumpConfig, ) -> Result, FileError> { trace!("Enter generate_thumbnails"); @@ -56,7 +58,14 @@ pub fn generate_thumbnails( trace!(chunk = idx + 1, "Processing chunk for thumbnail generation"); let results = chunk .into_par_iter() - .map(|m| generate_thumbnail(m.id.as_str(), m.path.as_str(), options.clone())) + .map(|m| { + generate_thumbnail( + m.id.as_str(), + m.path.as_str(), + options.clone(), + config, + ) + }) .filter_map(|res| { if res.is_err() { error!(error = ?res.err(), "Error generating thumbnail!"); @@ -80,6 +89,7 @@ pub const THUMBNAIL_CHUNK_SIZE: usize = 5; pub fn generate_thumbnails_for_media( media: Vec, options: ImageProcessorOptions, + config: &StumpConfig, mut on_progress: impl FnMut(String) + Send + Sync + 'static, ) -> Result, FileError> { trace!(media_count = media.len(), "Enter generate_thumbnails"); @@ -98,7 +108,14 @@ pub fn generate_thumbnails_for_media( ); let results = chunk .into_par_iter() - .map(|m| generate_thumbnail(m.id.as_str(), m.path.as_str(), options.clone())) + .map(|m| { + generate_thumbnail( + m.id.as_str(), + m.path.as_str(), + options.clone(), + config, + ) + }) .filter_map(|res| { if res.is_err() { error!(error = ?res.err(), "Error generating thumbnail!"); @@ -117,8 +134,10 @@ pub fn generate_thumbnails_for_media( Ok(generated_paths) } -pub fn remove_thumbnails(id_list: &[String]) -> Result<(), FileError> { - let thumbnails_dir = get_thumbnails_dir(); +pub fn remove_thumbnails( + id_list: &[String], + thumbnails_dir: PathBuf, +) -> Result<(), FileError> { let found_thumbnails = thumbnails_dir .read_dir() .ok() @@ -153,9 +172,8 @@ pub fn remove_thumbnails(id_list: &[String]) -> Result<(), FileError> { pub fn remove_thumbnails_of_type( ids: &[String], extension: &str, + thumbnails_dir: PathBuf, ) -> Result<(), FileError> { - let thumbnails_dir = get_thumbnails_dir(); - for (idx, chunk) in ids.chunks(THUMBNAIL_CHUNK_SIZE).enumerate() { trace!(chunk = idx + 1, "Processing chunk for thumbnail removal"); let results = chunk diff --git a/core/src/filesystem/image/thumbnail_job.rs b/core/src/filesystem/image/thumbnail_job.rs index 2c3be2b34..8b0178a31 100644 --- a/core/src/filesystem/image/thumbnail_job.rs +++ b/core/src/filesystem/image/thumbnail_job.rs @@ -11,7 +11,6 @@ use specta::Type; use tracing::{info, trace}; use crate::{ - config::get_thumbnails_dir, event::CoreEvent, filesystem::{ image::thumbnail::{ @@ -108,7 +107,7 @@ impl JobTrait for ThumbnailJob { library_id, force_regenerate, } => { - let thumbnail_dir = get_thumbnails_dir(); + let thumbnail_dir = core_ctx.config.get_thumbnails_dir(); let library_media = core_ctx .db .media() @@ -136,6 +135,7 @@ impl JobTrait for ThumbnailJob { .filter(|m| readdir_hash_set.contains(&m.id)) .map(|m| m.id.to_owned()) .collect::>(), + thumbnail_dir, )?; // Generate thumbnails for all media in the library let tasks = library_media.len() as u64; @@ -164,6 +164,7 @@ impl JobTrait for ThumbnailJob { let generated_thumbnail_paths = generate_thumbnails_for_media( library_media, self.options.to_owned(), + &core_ctx.config, on_progress, )?; @@ -198,6 +199,7 @@ impl JobTrait for ThumbnailJob { let generated_thumbnail_paths = generate_thumbnails_for_media( media_without_thumbnails, self.options.to_owned(), + &core_ctx.config, on_progress, )?; Ok(generated_thumbnail_paths) @@ -207,7 +209,7 @@ impl JobTrait for ThumbnailJob { series_id, force_regenerate, } => { - let thumbnail_dir = get_thumbnails_dir(); + let thumbnail_dir = core_ctx.config.get_thumbnails_dir(); let series_media = core_ctx .db @@ -234,6 +236,7 @@ impl JobTrait for ThumbnailJob { .filter(|m| readdir_hash_set.contains(&m.id)) .map(|m| m.id.to_owned()) .collect::>(), + thumbnail_dir, )?; let tasks = series_media.len() as u64; @@ -262,6 +265,7 @@ impl JobTrait for ThumbnailJob { let generated_thumbnail_paths = generate_thumbnails_for_media( series_media, self.options.to_owned(), + &core_ctx.config, on_progress, )?; Ok(generated_thumbnail_paths) @@ -295,6 +299,7 @@ impl JobTrait for ThumbnailJob { let generated_thumbnail_paths = generate_thumbnails_for_media( media_without_thumbnails, self.options.to_owned(), + &core_ctx.config, on_progress, )?; Ok(generated_thumbnail_paths) @@ -331,6 +336,7 @@ impl JobTrait for ThumbnailJob { let generated_thumbnail_paths = generate_thumbnails_for_media( media, self.options.to_owned(), + &core_ctx.config, on_progress, )?; Ok(generated_thumbnail_paths) diff --git a/core/src/filesystem/media/builder.rs b/core/src/filesystem/media/builder.rs index 84c40986d..799fc04c2 100644 --- a/core/src/filesystem/media/builder.rs +++ b/core/src/filesystem/media/builder.rs @@ -1,8 +1,12 @@ -use std::path::{Path, PathBuf}; +use std::{ + path::{Path, PathBuf}, + sync::Arc, +}; use prisma_client_rust::chrono::{DateTime, FixedOffset, Utc}; use crate::{ + config::StumpConfig, db::entity::{LibraryOptions, Media, Series}, filesystem::{process, FileParts, PathUtils, SeriesJson}, CoreError, CoreResult, @@ -12,14 +16,21 @@ pub struct MediaBuilder { path: PathBuf, series_id: String, library_options: LibraryOptions, + config: Arc, } impl MediaBuilder { - pub fn new(path: &Path, series_id: &str, library_options: LibraryOptions) -> Self { + pub fn new( + path: &Path, + series_id: &str, + library_options: LibraryOptions, + config: &Arc, + ) -> Self { Self { path: path.to_path_buf(), series_id: series_id.to_string(), library_options, + config: config.clone(), } } @@ -32,7 +43,8 @@ impl MediaBuilder { } pub fn build(self) -> CoreResult { - let mut processed_entry = process(&self.path, self.library_options.into())?; + let mut processed_entry = + process(&self.path, self.library_options.into(), &self.config)?; tracing::trace!(?processed_entry, "Processed entry"); diff --git a/core/src/filesystem/media/epub.rs b/core/src/filesystem/media/epub.rs index 40107cb8b..e8f6005e4 100644 --- a/core/src/filesystem/media/epub.rs +++ b/core/src/filesystem/media/epub.rs @@ -4,6 +4,7 @@ const ACCEPTED_EPUB_COVER_MIMES: [&str; 2] = ["image/jpeg", "image/png"]; const DEFAULT_EPUB_COVER_ID: &str = "cover"; use crate::{ + config::StumpConfig, db::entity::metadata::MediaMetadata, filesystem::{content_type::ContentType, error::FileError, hash}, }; @@ -60,7 +61,11 @@ impl FileProcessor for EpubProcessor { } } - fn process(path: &str, _: FileProcessorOptions) -> Result { + fn process( + path: &str, + _: FileProcessorOptions, + _: &StumpConfig, + ) -> Result { debug!(?path, "processing epub"); let path_buf = PathBuf::from(path); @@ -78,7 +83,11 @@ impl FileProcessor for EpubProcessor { }) } - fn get_page(path: &str, page: i32) -> Result<(ContentType, Vec), FileError> { + fn get_page( + path: &str, + page: i32, + _: &StumpConfig, + ) -> Result<(ContentType, Vec), FileError> { if page == 1 { // Assume this is the cover page EpubProcessor::get_cover(path) diff --git a/core/src/filesystem/media/pdf.rs b/core/src/filesystem/media/pdf.rs index c2afb9e96..a9daadb3e 100644 --- a/core/src/filesystem/media/pdf.rs +++ b/core/src/filesystem/media/pdf.rs @@ -9,7 +9,7 @@ use pdf::file::FileOptions; use pdfium_render::{prelude::Pdfium, render_config::PdfRenderConfig}; use crate::{ - config, + config::StumpConfig, db::entity::metadata::MediaMetadata, filesystem::{ archive::create_zip_archive, error::FileError, hash, image::ImageFormat, @@ -58,7 +58,11 @@ impl FileProcessor for PdfProcessor { } } - fn process(path: &str, _: FileProcessorOptions) -> Result { + fn process( + path: &str, + _: FileProcessorOptions, + _: &StumpConfig, + ) -> Result { let file = FileOptions::cached().open(path)?; let pages = file.pages().count() as i32; @@ -73,8 +77,12 @@ impl FileProcessor for PdfProcessor { } // TODO: The decision to use PNG should be a configuration option - fn get_page(path: &str, page: i32) -> Result<(ContentType, Vec), FileError> { - let pdfium = PdfProcessor::renderer()?; + fn get_page( + path: &str, + page: i32, + config: &StumpConfig, + ) -> Result<(ContentType, Vec), FileError> { + let pdfium = PdfProcessor::renderer(&config.pdfium_path)?; let document = pdfium.load_pdf_from_file(path, None)?; let document_page = @@ -125,11 +133,9 @@ impl FileProcessor for PdfProcessor { impl PdfProcessor { /// Initializes a PDFium renderer. If a path to the PDFium library is not provided - pub fn renderer() -> Result { - let pdfium_path = config::get_pdfium_path(); - + pub fn renderer(pdfium_path: &Option) -> Result { if let Some(path) = pdfium_path { - let bindings = Pdfium::bind_to_library(&path) + let bindings = Pdfium::bind_to_library(path) .or_else(|e| { tracing::error!(provided_path = ?path, ?e, "Failed to bind to PDFium library at provided path"); Pdfium::bind_to_system_library() @@ -151,8 +157,9 @@ impl FileConverter for PdfProcessor { path: &str, delete_source: bool, format: Option, + config: &StumpConfig, ) -> Result { - let pdfium = PdfProcessor::renderer()?; + let pdfium = PdfProcessor::renderer(&config.pdfium_path)?; let document = pdfium.load_pdf_from_file(path, None)?; let iter = document.pages().iter(); @@ -202,8 +209,8 @@ impl FileConverter for PdfProcessor { extension, } = path_buf.as_path().file_parts(); - let cache_dir = config::get_cache_dir(); - let unpacked_path = cache_dir.join(&file_stem); + let cache_dir = config.get_cache_dir(); + let unpacked_path = cache_dir.join(file_stem); // create folder for the zip std::fs::create_dir_all(&unpacked_path)?; diff --git a/core/src/filesystem/media/process.rs b/core/src/filesystem/media/process.rs index c5db26bc5..f97be11e8 100644 --- a/core/src/filesystem/media/process.rs +++ b/core/src/filesystem/media/process.rs @@ -9,6 +9,7 @@ use serde::{Deserialize, Serialize}; use tracing::debug; use crate::{ + config::StumpConfig, db::entity::{ metadata::{MediaMetadata, SeriesMetadata}, LibraryOptions, @@ -50,18 +51,27 @@ impl From<&LibraryOptions> for FileProcessorOptions { pub trait FileProcessor { /// Get the sample size for a file. This is used for generating a hash of the file. fn get_sample_size(path: &str) -> Result; + /// Generate a hash of the file. In most cases, the hash is generated from select pages /// of the file, rather than the entire file. This is to prevent the hash from changing /// when the metadata of the file changes. fn hash(path: &str) -> Option; + /// Process a file. Should gather the basic metadata and information required for /// processing the file. fn process( path: &str, options: FileProcessorOptions, + config: &StumpConfig, ) -> Result; + /// Get the bytes of a page of the file. - fn get_page(path: &str, page: i32) -> Result<(ContentType, Vec), FileError>; + fn get_page( + path: &str, + page: i32, + config: &StumpConfig, + ) -> Result<(ContentType, Vec), FileError>; + /// Get the content types of a list of pages of the file. This should determine content /// types by actually testing the bytes for each page. fn get_page_content_types( @@ -76,6 +86,7 @@ pub trait FileConverter { path: &str, delete_source: bool, image_format: Option, + config: &StumpConfig, ) -> Result; } @@ -112,6 +123,7 @@ pub struct ProcessedFile { pub fn process( path: &Path, options: FileProcessorOptions, + config: &StumpConfig, ) -> Result { debug!(?path, ?options, "Processing entry"); let mime = ContentType::from_path(path).mime_type(); @@ -119,26 +131,34 @@ pub fn process( let path_str = path.to_str().unwrap_or_default(); match mime.as_str() { - "application/zip" => ZipProcessor::process(path_str, options), - "application/vnd.comicbook+zip" => ZipProcessor::process(path_str, options), - "application/vnd.rar" => RarProcessor::process(path_str, options), - "application/vnd.comicbook-rar" => RarProcessor::process(path_str, options), - "application/epub+zip" => EpubProcessor::process(path_str, options), - "application/pdf" => PdfProcessor::process(path_str, options), + "application/zip" | "application/vnd.comicbook+zip" => { + ZipProcessor::process(path_str, options, config) + }, + "application/vnd.rar" | "application/vnd.comicbook-rar" => { + RarProcessor::process(path_str, options, config) + }, + "application/epub+zip" => EpubProcessor::process(path_str, options, config), + "application/pdf" => PdfProcessor::process(path_str, options, config), _ => Err(FileError::UnsupportedFileType(path.display().to_string())), } } -pub fn get_page(path: &str, page: i32) -> Result<(ContentType, Vec), FileError> { +pub fn get_page( + path: &str, + page: i32, + config: &StumpConfig, +) -> Result<(ContentType, Vec), FileError> { let mime = ContentType::from_file(path).mime_type(); match mime.as_str() { - "application/zip" => ZipProcessor::get_page(path, page), - "application/vnd.comicbook+zip" => ZipProcessor::get_page(path, page), - "application/vnd.rar" => RarProcessor::get_page(path, page), - "application/vnd.comicbook-rar" => RarProcessor::get_page(path, page), - "application/epub+zip" => EpubProcessor::get_page(path, page), - "application/pdf" => PdfProcessor::get_page(path, page), + "application/zip" | "application/vnd.comicbook+zip" => { + ZipProcessor::get_page(path, page, config) + }, + "application/vnd.rar" | "application/vnd.comicbook-rar" => { + RarProcessor::get_page(path, page, config) + }, + "application/epub+zip" => EpubProcessor::get_page(path, page, config), + "application/pdf" => PdfProcessor::get_page(path, page, config), _ => Err(FileError::UnsupportedFileType(path.to_string())), } } @@ -150,12 +170,10 @@ pub fn get_content_types_for_pages( let mime = ContentType::from_file(path).mime_type(); match mime.as_str() { - "application/zip" => ZipProcessor::get_page_content_types(path, pages), - "application/vnd.comicbook+zip" => { + "application/zip" | "application/vnd.comicbook+zip" => { ZipProcessor::get_page_content_types(path, pages) }, - "application/vnd.rar" => RarProcessor::get_page_content_types(path, pages), - "application/vnd.comicbook-rar" => { + "application/vnd.rar" | "application/vnd.comicbook-rar" => { RarProcessor::get_page_content_types(path, pages) }, "application/epub+zip" => EpubProcessor::get_page_content_types(path, pages), diff --git a/core/src/filesystem/media/rar.rs b/core/src/filesystem/media/rar.rs index b2adad739..e4e0c1a9b 100644 --- a/core/src/filesystem/media/rar.rs +++ b/core/src/filesystem/media/rar.rs @@ -8,7 +8,7 @@ use tracing::{debug, error, trace, warn}; use unrar::Archive; use crate::{ - config, + config::StumpConfig, filesystem::{ archive::create_zip_archive, content_type::ContentType, @@ -67,16 +67,21 @@ impl FileProcessor for RarProcessor { fn process( path: &str, options: FileProcessorOptions, + config: &StumpConfig, ) -> Result { if options.convert_rar_to_zip { - let zip_path_buf = - RarProcessor::to_zip(path, options.delete_conversion_source, None)?; + let zip_path_buf = RarProcessor::to_zip( + path, + options.delete_conversion_source, + None, + config, + )?; let zip_path = zip_path_buf.to_str().ok_or_else(|| { FileError::UnknownError( "Converted RAR file failed to be discovered".to_string(), ) })?; - return ZipProcessor::process(zip_path, options); + return ZipProcessor::process(zip_path, options, config); } debug!(path, "Processing RAR"); @@ -116,7 +121,11 @@ impl FileProcessor for RarProcessor { }) } - fn get_page(file: &str, page: i32) -> Result<(ContentType, Vec), FileError> { + fn get_page( + file: &str, + page: i32, + _: &StumpConfig, + ) -> Result<(ContentType, Vec), FileError> { let archive = Archive::new(file).open_for_listing()?; let mut valid_entries = archive @@ -224,6 +233,7 @@ impl FileConverter for RarProcessor { path: &str, delete_source: bool, _: Option, + config: &StumpConfig, ) -> Result { debug!(path, "Converting RAR to ZIP"); @@ -236,7 +246,7 @@ impl FileConverter for RarProcessor { file_name, } = path_buf.as_path().file_parts(); - let cache_dir = config::get_cache_dir(); + let cache_dir = config.get_cache_dir(); let unpacked_path = cache_dir.join(file_stem); trace!(?unpacked_path, "Extracting RAR to cache"); diff --git a/core/src/filesystem/media/zip.rs b/core/src/filesystem/media/zip.rs index 0130aa9fb..bda9c24e2 100644 --- a/core/src/filesystem/media/zip.rs +++ b/core/src/filesystem/media/zip.rs @@ -7,11 +7,14 @@ use std::{ use tracing::{debug, error, trace}; use zip::read::ZipFile; -use crate::filesystem::{ - content_type::ContentType, - error::FileError, - hash, - media::common::{metadata_from_buf, sort_file_names}, +use crate::{ + config::StumpConfig, + filesystem::{ + content_type::ContentType, + error::FileError, + hash, + media::common::{metadata_from_buf, sort_file_names}, + }, }; use super::{FileProcessor, FileProcessorOptions, ProcessedFile}; @@ -57,7 +60,11 @@ impl FileProcessor for ZipProcessor { } } - fn process(path: &str, _: FileProcessorOptions) -> Result { + fn process( + path: &str, + _: FileProcessorOptions, + _: &StumpConfig, + ) -> Result { debug!(path, "Processing zip"); let hash = ZipProcessor::hash(path); @@ -91,7 +98,11 @@ impl FileProcessor for ZipProcessor { }) } - fn get_page(path: &str, page: i32) -> Result<(ContentType, Vec), FileError> { + fn get_page( + path: &str, + page: i32, + _: &StumpConfig, + ) -> Result<(ContentType, Vec), FileError> { let zip_file = File::open(path)?; let mut archive = zip::ZipArchive::new(&zip_file)?; diff --git a/core/src/filesystem/scanner/series_scanner.rs b/core/src/filesystem/scanner/series_scanner.rs index d98b3549d..046ba4650 100644 --- a/core/src/filesystem/scanner/series_scanner.rs +++ b/core/src/filesystem/scanner/series_scanner.rs @@ -204,9 +204,13 @@ impl SeriesScanner { "File has been modified since last scan" ); - let build_result = - MediaBuilder::new(path, &series.id, library_options.clone()) - .rebuild(media); + let build_result = MediaBuilder::new( + path, + &series.id, + library_options.clone(), + &ctx.config, + ) + .rebuild(media); if let Ok(generated) = build_result { tracing::warn!( @@ -237,8 +241,13 @@ impl SeriesScanner { *visited_media.entry(path_str).or_insert(true) = true; } else { tracing::trace!(series_id = ?series.id, new_media_path = ?path, "New media found in series"); - let build_result = - MediaBuilder::new(path, &series.id, library_options.clone()).build(); + let build_result = MediaBuilder::new( + path, + &series.id, + library_options.clone(), + &ctx.config, + ) + .build(); if let Ok(generated) = build_result { match create_media(&ctx.db, generated).await { Ok(created_media) => { diff --git a/core/src/lib.rs b/core/src/lib.rs index efd47606c..88a76bbe8 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -21,8 +21,8 @@ pub mod error; #[allow(warnings, unused)] pub mod prisma; -use config::env::StumpEnvironment; use config::logging::STUMP_SHADOW_TEXT; +use config::StumpConfig; use event::{event_manager::EventManager, InternalCoreTask}; use job::JobScheduler; use tokio::sync::mpsc::unbounded_channel; @@ -30,23 +30,26 @@ use tokio::sync::mpsc::unbounded_channel; pub use context::Ctx; pub use error::{CoreError, CoreResult}; -/// The [`StumpCore`] struct is the main entry point for any server-side Stump -/// applications. It is responsible for managing incoming tasks ([`InternalCoreTask`]), -/// outgoing events ([`CoreEvent`](event::CoreEvent)), and providing access to the database -/// via the core's [`Ctx`]. +/// The [StumpCore] struct is the main entry point for any server-side Stump +/// applications. It is responsible for managing incoming tasks ([InternalCoreTask]), +/// outgoing events ([CoreEvent](event::CoreEvent)), and providing access to the database +/// via the core's [Ctx]. /// -/// [`StumpCore`] also provides a few initilization functions, such as `init_environment`. This -/// is provided to standardize various configurations for consumers of the library. +/// [StumpCore] expects the consuming application to determine its configuration prior to startup. +/// [config::bootstrap_config_dir] enables consumers to fetch the configuration directory automatically, +/// and [StumpCore::init_config](#method.init_config) will load any Stump.toml in the config directory +/// or environment variables to return a [StumpConfig] struct. /// /// ## Example: /// ```rust -/// use stump_core::StumpCore; +/// use stump_core::{config, StumpCore}; /// /// #[tokio::main] /// async fn main() { -/// assert!(StumpCore::init_environment().is_ok()); +/// let config_dir = config::bootstrap_config_dir(); +/// let config = StumpCore::init_config(config_dir).unwrap(); /// -/// let core = StumpCore::new().await; +/// let core = StumpCore::new(config).await; /// } /// ``` pub struct StumpCore { @@ -56,10 +59,10 @@ pub struct StumpCore { impl StumpCore { /// Creates a new instance of [`StumpCore`] and returns it wrapped in an [`Arc`]. - pub async fn new() -> StumpCore { + pub async fn new(config: StumpConfig) -> StumpCore { let internal_channel = unbounded_channel::(); - let core_ctx = Ctx::new(internal_channel.0).await; + let core_ctx = Ctx::new(config, internal_channel.0).await; let event_manager = EventManager::new(core_ctx.get_ctx(), internal_channel.1); StumpCore { @@ -68,10 +71,28 @@ impl StumpCore { } } - /// Loads environment variables from the `Stump.toml` configuration file, if - /// it exists, using the [`StumpEnv`] struct. - pub fn init_environment() -> CoreResult { - StumpEnvironment::load() + /// A three-step configuration initialization function. + /// + /// 1. Loads configuration variables from Stump.toml, located at the input + /// config_dir, if such a file exists. + /// + /// 2. Overrides variables with those set in the environment. + /// + /// 3. Creates the configuration directory (if it does not exist) and writes + /// to Stump.toml. + /// + /// Returns the configuration variables in a `StumpConfig` struct. + pub fn init_config(config_dir: String) -> CoreResult { + let config = StumpConfig::new(config_dir) + // Load config file (if any) + .with_config_file()? + // Overlay environment variables + .with_environment()?; + + // Write ensure that config directory exists and write Stump.toml + config.write_config_dir()?; + + Ok(config) } /// Returns [`StumpCore`] wrapped in an [`Arc`]. Will take ownership of self. Created diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 7b1a7b735..589bfd6a7 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -8,7 +8,7 @@ name = "cli-bin" path = "bin/main.rs" [dependencies] -clap = { version = "4.4.6", features = ["derive", "env"] } +clap = { version = "4.4.6", features = ["derive"] } stump_core = { path = "../../core" } tokio = { workspace = true } diff --git a/crates/cli/bin/main.rs b/crates/cli/bin/main.rs index ec03ff905..263562387 100644 --- a/crates/cli/bin/main.rs +++ b/crates/cli/bin/main.rs @@ -1,5 +1,5 @@ use cli::{handle_command, Cli, Parser}; -use stump_core::StumpCore; +use stump_core::{config, StumpCore}; /// This is just an example of how to use this crate. It is going to be used in the /// server app. This is not meant to be a real CLI binary. @@ -7,13 +7,12 @@ use stump_core::StumpCore; async fn main() { let app = Cli::parse(); - let environment_load_result = StumpCore::init_environment(); - if let Err(err) = environment_load_result { - println!("Failed to load environment variables: {:?}", err); - } + let config_dir = config::bootstrap_config_dir(); + let stump_config = StumpCore::init_config(config_dir) + .expect("Failed to initialize stump configuration"); if let Some(command) = app.command { - handle_command(command, app.config) + handle_command(command, &app.config.merge_stump_config(stump_config)) .await .expect("Failed to handle command"); } else { diff --git a/crates/cli/src/commands/account.rs b/crates/cli/src/commands/account.rs index 8fcbc19b7..128177eec 100644 --- a/crates/cli/src/commands/account.rs +++ b/crates/cli/src/commands/account.rs @@ -3,11 +3,12 @@ use std::{thread, time::Duration}; use clap::Subcommand; use dialoguer::{theme::ColorfulTheme, Confirm, Input, Password}; use stump_core::{ + config::StumpConfig, db::create_client, prisma::{session, user}, }; -use crate::{commands::chain_optional_iter, error::CliResult, CliConfig, CliError}; +use crate::{commands::chain_optional_iter, error::CliResult, CliError}; use super::default_progress_spinner; @@ -44,20 +45,28 @@ pub enum Account { pub async fn handle_account_command( command: Account, - config: CliConfig, + config: &StumpConfig, ) -> CliResult<()> { match command { - Account::Lock { username } => set_account_lock_status(username, true).await, - Account::Unlock { username } => set_account_lock_status(username, false).await, - Account::List { locked } => print_accounts(locked).await, + Account::Lock { username } => { + set_account_lock_status(username, true, config).await + }, + Account::Unlock { username } => { + set_account_lock_status(username, false, config).await + }, + Account::List { locked } => print_accounts(locked, config).await, Account::ResetPassword { username } => { - reset_account_password(username, config.password_hash_cost).await + reset_account_password(username, config.password_hash_cost, config).await }, - Account::ResetOwner => change_server_owner().await, + Account::ResetOwner => change_server_owner(config).await, } } -async fn set_account_lock_status(username: String, lock: bool) -> CliResult<()> { +async fn set_account_lock_status( + username: String, + lock: bool, + config: &StumpConfig, +) -> CliResult<()> { let progress = default_progress_spinner(); progress.set_message(if lock { "Locking account..." @@ -65,7 +74,7 @@ async fn set_account_lock_status(username: String, lock: bool) -> CliResult<()> "Unlocking account..." }); - let client = create_client().await; + let client = create_client(config).await; let affected_rows = client .user() @@ -104,8 +113,12 @@ async fn set_account_lock_status(username: String, lock: bool) -> CliResult<()> } } -async fn reset_account_password(username: String, hash_cost: u32) -> CliResult<()> { - let client = create_client().await; +async fn reset_account_password( + username: String, + hash_cost: u32, + config: &StumpConfig, +) -> CliResult<()> { + let client = create_client(config).await; let theme = &ColorfulTheme::default(); let builder = Password::with_theme(theme) @@ -144,11 +157,11 @@ async fn reset_account_password(username: String, hash_cost: u32) -> CliResult<( // TODO: print pretty table // TODO: handle empty state -async fn print_accounts(locked: Option) -> CliResult<()> { +async fn print_accounts(locked: Option, config: &StumpConfig) -> CliResult<()> { let progress = default_progress_spinner(); progress.set_message("Fetching accounts..."); - let client = create_client().await; + let client = create_client(config).await; let users = client .user() @@ -172,8 +185,8 @@ async fn print_accounts(locked: Option) -> CliResult<()> { Ok(()) } -async fn change_server_owner() -> CliResult<()> { - let client = create_client().await; +async fn change_server_owner(config: &StumpConfig) -> CliResult<()> { + let client = create_client(config).await; let all_accounts = client .user() diff --git a/crates/cli/src/commands/mod.rs b/crates/cli/src/commands/mod.rs index 827910c4d..4df01f1ec 100644 --- a/crates/cli/src/commands/mod.rs +++ b/crates/cli/src/commands/mod.rs @@ -4,8 +4,9 @@ use std::time::Duration; use clap::Subcommand; use indicatif::{ProgressBar, ProgressStyle}; +use stump_core::config::StumpConfig; -use crate::{error::CliResult, CliConfig}; +use crate::error::CliResult; use self::account::Account; @@ -15,7 +16,7 @@ pub enum Commands { Account(Account), } -pub async fn handle_command(command: Commands, config: CliConfig) -> CliResult<()> { +pub async fn handle_command(command: Commands, config: &StumpConfig) -> CliResult<()> { match command { Commands::Account(account) => { account::handle_account_command(account, config).await diff --git a/crates/cli/src/config.rs b/crates/cli/src/config.rs index 89288dcc9..1377bcaae 100644 --- a/crates/cli/src/config.rs +++ b/crates/cli/src/config.rs @@ -1,13 +1,29 @@ use std::path::PathBuf; use clap::Parser; +use stump_core::config::StumpConfig; #[derive(Default, Parser)] pub struct CliConfig { /// The path to the configuration directory - #[clap(long, env = "STUMP_CONFIG_DIR")] + #[clap(long)] pub config_dir: Option, /// The desired cost for password hashing. Defaults to 12. - #[clap(long, env = "HASH_COST", default_value = "12")] - pub password_hash_cost: u32, + #[clap(long)] + pub password_hash_cost: Option, +} + +impl CliConfig { + /// Consume both a [CliConfig] and [StumpConfig] to produce a [StumpConfig] with + /// any values set in the [CliConfig] overriding the [StumpConfig]'s values. + pub fn merge_stump_config(self, mut config: StumpConfig) -> StumpConfig { + if let Some(config_dir) = self.config_dir { + config.config_dir = config_dir.to_string_lossy().to_string(); + } + if let Some(hash_cost) = self.password_hash_cost { + config.password_hash_cost = hash_cost; + } + + config + } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f5c38ff14..578b66113 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6882,7 +6882,7 @@ packages: dev: true /array-flatten@1.1.1: - resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==} + resolution: {integrity: sha1-ml9pkFGx5wczKPKgCJaLZOopVdI=} dev: true /array-includes@3.1.7: @@ -7415,7 +7415,7 @@ packages: dev: false /bytes@3.0.0: - resolution: {integrity: sha512-pMhOfFDPiv9t5jjIXkHosWmkSyQbvsgEVNkz0ERHbuLh2T/7j4Mqqpz523Fe8MVY89KC6Sh/QfS2sM+SjgFDcw==} + resolution: {integrity: sha1-0ygVQE1olpn4Wk6k+odV3ROpYEg=} engines: {node: '>= 0.8'} dev: true @@ -7864,7 +7864,7 @@ packages: dev: false /concat-map@0.0.1: - resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + resolution: {integrity: sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=} /concat-stream@1.6.2: resolution: {integrity: sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==} @@ -7901,7 +7901,7 @@ packages: dev: true /content-disposition@0.5.2: - resolution: {integrity: sha512-kRGRZw3bLlFISDBgwTSA1TMBFN6J6GWDeubmDE3AF+3+yXL8hTWv8r5rkLbqYXY4RjPk/EzHnClI3zQf1cFmHA==} + resolution: {integrity: sha1-DPaLud318r55YcOoUXjLhdunjLQ=} engines: {node: '>= 0.6'} dev: true @@ -7926,11 +7926,11 @@ packages: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} /cookie-signature@1.0.6: - resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==} + resolution: {integrity: sha1-4wOogrNCzD7oylE6eZmXNNqzriw=} dev: true /cookie@0.3.1: - resolution: {integrity: sha512-+IJOX0OqlHCszo2mBUq+SrEbCj6w7Kpffqx60zYbPTFaO4+yYgRjHwcZNpWvaTylDHaV7PPmBHzSecZiMhtPgw==} + resolution: {integrity: sha1-5+Ch+e9DtMi6klxcWpboBtFoc7s=} engines: {node: '>= 0.6'} dev: true @@ -8818,7 +8818,7 @@ packages: dev: true /ee-first@1.1.1: - resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + resolution: {integrity: sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=} dev: true /ejs@3.1.9: @@ -9925,7 +9925,7 @@ packages: dev: false /fresh@0.5.2: - resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} + resolution: {integrity: sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac=} engines: {node: '>= 0.6'} dev: true @@ -12147,7 +12147,7 @@ packages: dev: false /media-typer@0.3.0: - resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} + resolution: {integrity: sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g=} engines: {node: '>= 0.6'} dev: true @@ -12209,7 +12209,7 @@ packages: dev: true /merge-descriptors@1.0.1: - resolution: {integrity: sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==} + resolution: {integrity: sha1-sAqqVW3YtEVoFQ7J0blT8/kMu2E=} dev: true /merge-stream@2.0.0: @@ -13705,7 +13705,7 @@ packages: dev: true /path-to-regexp@0.1.7: - resolution: {integrity: sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==} + resolution: {integrity: sha1-32BBeABfUi8V60SQ5yR6G/qmf4w=} dev: true /path-type@3.0.0: @@ -15106,7 +15106,7 @@ packages: dev: true /retry@0.12.0: - resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} + resolution: {integrity: sha1-G0KmJmoh8HQh0bC1S33BZ7AcATs=} engines: {node: '>= 4'} dev: true @@ -16836,7 +16836,7 @@ packages: dev: true /utils-merge@1.0.1: - resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} + resolution: {integrity: sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=} engines: {node: '>= 0.4.0'} dev: true