From 3446290231069942970119599acfb68cfaa64531 Mon Sep 17 00:00:00 2001 From: Aleh Zasypkin Date: Sat, 23 Mar 2024 15:35:15 +0100 Subject: [PATCH] feat(platform)!: add support for the application TOML configuration file --- .dockerignore | 1 + .gitignore | 1 + Cargo.lock | 103 ++++- Cargo.toml | 8 +- README.md | 64 +++- src/config.rs | 243 +++++++++++- src/config/components_config.rs | 40 +- src/config/js_runtime_config.rs | 50 ++- src/config/raw_config.rs | 316 ++++++++++++++++ src/config/scheduler_jobs_config.rs | 51 ++- src/config/security_config.rs | 118 ++++++ src/config/smtp_catch_all_config.rs | 44 ++- src/config/smtp_config.rs | 76 +++- src/config/subscriptions_config.rs | 43 ++- src/config/utils_config.rs | 42 ++ src/js_runtime.rs | 10 +- src/main.rs | 358 ++---------------- .../scheduler_jobs/notifications_send_job.rs | 8 +- .../web_page_trackers_fetch_job.rs | 35 +- .../web_page_trackers_schedule_job.rs | 12 +- src/server.rs | 93 ++++- src/server/app_state.rs | 3 +- src/server/handlers/ui_state_get.rs | 2 +- src/server/handlers/webhooks_responders.rs | 2 +- src/users.rs | 2 - src/users/builtin_user.rs | 80 ++-- src/users/builtin_users_initializer.rs | 28 -- .../subscription_features.rs | 4 +- .../webhooks_responders.rs | 12 +- 29 files changed, 1368 insertions(+), 481 deletions(-) create mode 100644 src/config/raw_config.rs create mode 100644 src/config/security_config.rs create mode 100644 src/config/utils_config.rs delete mode 100644 src/users/builtin_users_initializer.rs diff --git a/.dockerignore b/.dockerignore index 27bc8c0..1817435 100644 --- a/.dockerignore +++ b/.dockerignore @@ -6,3 +6,4 @@ LICENSE .env .gitignore *.json +secutils.toml diff --git a/.gitignore b/.gitignore index dbe11ed..de6aadb 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ target/ .env *.private.env.json +secutils.toml diff --git a/Cargo.lock b/Cargo.lock index 97e73aa..52aeb05 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -735,6 +735,15 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c59bdb34bc650a32731b31bd8f0829cc15d24a708ee31559e0bb34f2bc320cba" +[[package]] +name = "atomic" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d818003e740b63afc82337e3160717f4f63078720a810b7b903e70a5d1d2994" +dependencies = [ + "bytemuck", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -936,6 +945,12 @@ version = "3.15.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ff69b9dd49fd426c69a0db9fc04dd934cdb6645ff000864d98f7e2af8830eaa" +[[package]] +name = "bytemuck" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d6d68c57235a3a081186990eca2867354726650f42f7516ca50c28d6281fd15" + [[package]] name = "byteorder" version = "1.5.0" @@ -1740,6 +1755,19 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "25cbce373ec4653f1a01a31e8a5e5ec0c622dc27ff9c4e6606eefef5cbbed4a5" +[[package]] +name = "figment" +version = "0.10.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7270677e7067213e04f323b55084586195f18308cd7546cfac9f873344ccceb6" +dependencies = [ + "atomic 0.6.0", + "serde", + "toml 0.8.12", + "uncased", + "version_check", +] + [[package]] name = "finl_unicode" version = "1.2.0" @@ -2470,6 +2498,7 @@ dependencies = [ "regex", "serde", "similar", + "toml 0.5.11", "yaml-rust", ] @@ -4025,6 +4054,7 @@ dependencies = [ "deno_core", "directories", "dotenvy", + "figment", "futures", "handlebars", "hex", @@ -4057,6 +4087,7 @@ dependencies = [ "tlsh2", "tokio", "tokio-cron-scheduler", + "toml 0.8.12", "trust-dns-resolver", "url", "urlencoding", @@ -4167,6 +4198,15 @@ dependencies = [ "syn 2.0.53", ] +[[package]] +name = "serde_spanned" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb3622f419d1296904700073ea6cc23ad690adbd66f13ea683df73298736f0c1" +dependencies = [ + "serde", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -5169,6 +5209,49 @@ dependencies = [ "tracing", ] +[[package]] +name = "toml" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234" +dependencies = [ + "serde", +] + +[[package]] +name = "toml" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9dd1545e8208b4a5af1aa9bbd0b4cf7e9ea08fabc5d0a5c67fcaafa17433aa3" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3550f4e9685620ac18a50ed434eb3aec30db8ba93b0287467bca5826ea25baf1" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e40bb779c5187258fd7aad0eb68cb8706a0a81fa712fbea808ab43c4b8374c4" +dependencies = [ + "indexmap 2.2.6", + "serde", + "serde_spanned", + "toml_datetime", + "winnow", +] + [[package]] name = "tower" version = "0.4.13" @@ -5323,6 +5406,15 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed646292ffc8188ef8ea4d1e0e0150fb15a5c2e12ad9b8fc191ae7a8a7f3c4b9" +[[package]] +name = "uncased" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1b88fcfe09e89d3866a5c11019378088af2d24c3fbd4f0543f96b479ec90697" +dependencies = [ + "version_check", +] + [[package]] name = "unicode-bidi" version = "0.3.15" @@ -5426,7 +5518,7 @@ version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a183cf7feeba97b4dd1c0d46788634f6221d87fa961b305bed08c851829efcc0" dependencies = [ - "atomic", + "atomic 0.5.3", "getrandom", "serde", ] @@ -5888,6 +5980,15 @@ version = "0.52.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32b752e52a2da0ddfbdbcc6fceadfeede4c939ed16d13e648833a61dfb611ed8" +[[package]] +name = "winnow" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dffa400e67ed5a4dd237983829e66475f0a4a26938c4b04c21baede6262215b8" +dependencies = [ + "memchr", +] + [[package]] name = "winreg" version = "0.50.0" diff --git a/Cargo.toml b/Cargo.toml index 85d2546..8a23bd3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,8 @@ [package] name = "secutils" version = "1.0.0-beta.1" -authors = ["Secutils "] +authors = ["Aleh Zasypkin "] +description = "An open-source, versatile, yet simple security toolbox for engineers and researchers." edition = "2021" [[bin]] @@ -26,6 +27,7 @@ deno_core = "0.272.0" directories = "5.0.1" dotenvy = "0.15.7" structured-logger = "1.0.3" +figment = "0.10.15" futures = "0.3.30" handlebars = "5.1.0" hex = "0.4.3" @@ -66,6 +68,7 @@ zip = "0.6.6" ctor = "0.2.7" httpmock = "0.7.0" insta = "1.36.1" +toml = "0.8.12" [patch.crates-io] tokio-cron-scheduler = { path = "./vendor/tokio-cron-scheduler" } @@ -76,12 +79,15 @@ default = [ "actix-web/cookies", "actix-web/secure-cookies", "bytes/serde", + "clap/cargo", "clap/env", "content-security-policy/serde", + "figment/toml", "handlebars/rust-embed", "insta/filters", "insta/json", "insta/redactions", + "insta/toml", "lettre/builder", "lettre/smtp-transport", "lettre/tokio1-rustls-tls", diff --git a/README.md b/README.md index eb6f1f8..6c82636 100644 --- a/README.md +++ b/README.md @@ -35,27 +35,61 @@ Secutils.dev adheres to [open security principles](https://en.wikipedia.org/wiki ## Getting started -Before running the Secutils.dev server locally, you need to provide several required parameters. The easiest way is to -specify them through a local `.env` file: +You can start the Secutils.dev server with `cargo run`. By default, the server will be accessible +via http://localhost:7070. Use `curl` to verify that the server is up and running: -```dotenv -# An authenticated session key. For example, can be generated with `openssl rand -hex 32` -SECUTILS_SESSION_KEY=a1a95f90e375d24ee4abb567c96ec3b053ceb083a4df726c76f8570230311c58 +```shell +curl -XGET http://localhost:7070/api/status +--- +{"version":"1.0.0-alpha.1","level":"available"} +``` -# Defines a pipe-separated (`|`) list of predefined users in the following format: `email:password:role`. -SECUTILS_BUILTIN_USERS=user@domain.xyz:3efab73129f3d36e:admin +The server can be configured with a TOML configuration file. See the example below for a basic configuration: -# Path to a local SQLite database file. Refer to https://github.com/launchbadge/sqlx for more details. -DATABASE_URL=sqlite:///home/user/.local/share/secutils/data.db +```toml +port = 7070 + +# A session key used to encrypt session cookie. Should be at least 64 characters long. +# For example, can be generated with `openssl rand -hex 32` +[security] +session-key = "a1a95f90e375d24ee4abb567c96ec3b053ceb083a4df726c76f8570230311c58" + +# The configuration of the Deno runtime used to run responder scripts. +[js-runtime] +max-heap-size = 10_485_760 # 10 MB +max-user-script-execution-time = 30_000 # 30 seconds + +# SMTP server configuration used to send emails (signup emails, notifications etc.). +[smtp] +address = "xxx" +username = "xxx" +password = "xxx" + +# Defines a list of predefined Secutils.dev users. +[[security.builtin-users]] +email = "user@domain.xyz" +handle = "local" +password = "3efab73129f3d36e" +tier = "ultimate" + +[utils] +webhook-url-type = "path" ``` -Once the .env file is created, you can start the Secutils.dev server with `cargo run`. By default, the server will be -accessible via http://localhost:7070. Use `curl` to verify that the server is up and running: +If you saved your configuration to a file named `secutils.toml`, you can start the server with the following command: -```shellThis command -curl -XGET http://localhost:7070/api/status ---- -{"version":"1.0.0-alpha.1","level":"available"} +```shell +cargo run -- -c secutils.toml +``` + +You can also use `.env` file to specify the location of the configuration file and the main database: + +```dotenv +# Path to the configuration file. +SECUTILS_CONFIG=${PWD}/secutils.toml + +# Path to a local SQLite database file. Refer to https://github.com/launchbadge/sqlx for more details. +DATABASE_URL=sqlite:///home/user/.local/share/secutils/data.db ``` ### Usage diff --git a/src/config.rs b/src/config.rs index 155ac90..e2f9fd8 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,17 +1,25 @@ mod components_config; mod js_runtime_config; +mod raw_config; mod scheduler_jobs_config; +mod security_config; mod smtp_catch_all_config; mod smtp_config; mod subscriptions_config; +mod utils_config; -use crate::server::WebhookUrlType; use url::Url; pub use self::{ - components_config::ComponentsConfig, js_runtime_config::JsRuntimeConfig, - scheduler_jobs_config::SchedulerJobsConfig, smtp_catch_all_config::SmtpCatchAllConfig, - smtp_config::SmtpConfig, subscriptions_config::SubscriptionsConfig, + components_config::ComponentsConfig, + js_runtime_config::JsRuntimeConfig, + raw_config::RawConfig, + scheduler_jobs_config::SchedulerJobsConfig, + security_config::{BuiltinUserConfig, SecurityConfig, SESSION_KEY_LENGTH_BYTES}, + smtp_catch_all_config::SmtpCatchAllConfig, + smtp_config::SmtpConfig, + subscriptions_config::SubscriptionsConfig, + utils_config::UtilsConfig, }; /// Secutils.dev user agent name used for all HTTP requests. @@ -21,20 +29,16 @@ pub static SECUTILS_USER_AGENT: &str = /// Main server config. #[derive(Clone, Debug)] pub struct Config { - /// Version of the Secutils binary. - pub version: String, - /// HTTP port to bind API server to. - pub http_port: u16, /// External/public URL through which service is being accessed. pub public_url: Url, - /// Describes the preferred way to construct webhook URLs. - pub webhook_url_type: WebhookUrlType, + /// Configuration for the utility functions. + pub utils: UtilsConfig, /// Configuration for the SMTP functionality. pub smtp: Option, /// Configuration for the components that are deployed separately. pub components: ComponentsConfig, /// Configuration for the scheduler jobs. - pub jobs: SchedulerJobsConfig, + pub scheduler: SchedulerJobsConfig, /// Configuration for the JS runtime. pub js_runtime: JsRuntimeConfig, /// Configuration related to the Secutils.dev subscriptions. @@ -46,3 +50,220 @@ impl AsRef for Config { self } } + +impl From for Config { + fn from(raw_config: RawConfig) -> Self { + Self { + public_url: raw_config.public_url, + smtp: raw_config.smtp, + components: raw_config.components, + subscriptions: raw_config.subscriptions, + utils: raw_config.utils, + js_runtime: raw_config.js_runtime, + scheduler: raw_config.scheduler, + } + } +} + +#[cfg(test)] +mod tests { + use crate::config::{Config, RawConfig, SmtpCatchAllConfig, SmtpConfig}; + use insta::assert_debug_snapshot; + use regex::Regex; + use url::Url; + + #[test] + fn conversion_from_raw_config() { + let mut raw_config = RawConfig::default(); + raw_config.subscriptions.feature_overview_url = + Some(Url::parse("http://localhost:7272").unwrap()); + raw_config.smtp = Some(SmtpConfig { + username: "test@secutils.dev".to_string(), + password: "password".to_string(), + address: "smtp.secutils.dev".to_string(), + catch_all: Some(SmtpCatchAllConfig { + recipient: "test@secutils.dev".to_string(), + text_matcher: Regex::new(r"test").unwrap(), + }), + }); + + assert_debug_snapshot!(Config::from(raw_config), @r###" + Config { + public_url: Url { + scheme: "http", + cannot_be_a_base: false, + username: "", + password: None, + host: Some( + Domain( + "localhost", + ), + ), + port: Some( + 7070, + ), + path: "/", + query: None, + fragment: None, + }, + utils: UtilsConfig { + webhook_url_type: Subdomain, + }, + smtp: Some( + SmtpConfig { + username: "test@secutils.dev", + password: "password", + address: "smtp.secutils.dev", + catch_all: Some( + SmtpCatchAllConfig { + recipient: "test@secutils.dev", + text_matcher: Regex( + "test", + ), + }, + ), + }, + ), + components: ComponentsConfig { + web_scraper_url: Url { + scheme: "http", + cannot_be_a_base: false, + username: "", + password: None, + host: Some( + Domain( + "localhost", + ), + ), + port: Some( + 7272, + ), + path: "/", + query: None, + fragment: None, + }, + search_index_version: 3, + }, + scheduler: SchedulerJobsConfig { + web_page_trackers_schedule: Schedule { + source: "0 * * * * * *", + fields: ScheduleFields { + years: Years { + ordinals: None, + }, + days_of_week: DaysOfWeek { + ordinals: None, + }, + months: Months { + ordinals: None, + }, + days_of_month: DaysOfMonth { + ordinals: None, + }, + hours: Hours { + ordinals: None, + }, + minutes: Minutes { + ordinals: None, + }, + seconds: Seconds { + ordinals: Some( + { + 0, + }, + ), + }, + }, + }, + web_page_trackers_fetch: Schedule { + source: "0 * * * * * *", + fields: ScheduleFields { + years: Years { + ordinals: None, + }, + days_of_week: DaysOfWeek { + ordinals: None, + }, + months: Months { + ordinals: None, + }, + days_of_month: DaysOfMonth { + ordinals: None, + }, + hours: Hours { + ordinals: None, + }, + minutes: Minutes { + ordinals: None, + }, + seconds: Seconds { + ordinals: Some( + { + 0, + }, + ), + }, + }, + }, + notifications_send: Schedule { + source: "0/30 * * * * * *", + fields: ScheduleFields { + years: Years { + ordinals: None, + }, + days_of_week: DaysOfWeek { + ordinals: None, + }, + months: Months { + ordinals: None, + }, + days_of_month: DaysOfMonth { + ordinals: None, + }, + hours: Hours { + ordinals: None, + }, + minutes: Minutes { + ordinals: None, + }, + seconds: Seconds { + ordinals: Some( + { + 0, + 30, + }, + ), + }, + }, + }, + }, + js_runtime: JsRuntimeConfig { + max_heap_size: 10485760, + max_user_script_execution_time: 30s, + }, + subscriptions: SubscriptionsConfig { + manage_url: None, + feature_overview_url: Some( + Url { + scheme: "http", + cannot_be_a_base: false, + username: "", + password: None, + host: Some( + Domain( + "localhost", + ), + ), + port: Some( + 7272, + ), + path: "/", + query: None, + fragment: None, + }, + ), + }, + } + "###); + } +} diff --git a/src/config/components_config.rs b/src/config/components_config.rs index 5299c0c..4de5c67 100644 --- a/src/config/components_config.rs +++ b/src/config/components_config.rs @@ -1,7 +1,9 @@ +use serde_derive::{Deserialize, Serialize}; use url::Url; /// Configuration for the main Secutils.dev components. -#[derive(Clone, Debug)] +#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)] +#[serde(rename_all = "kebab-case")] pub struct ComponentsConfig { /// The URL to access the Web Scraper component. pub web_scraper_url: Url, @@ -9,3 +11,39 @@ pub struct ComponentsConfig { /// upgrades when there are breaking changes in the data or schema format). pub search_index_version: u16, } + +impl Default for ComponentsConfig { + fn default() -> Self { + Self { + web_scraper_url: Url::parse("http://localhost:7272") + .expect("Cannot parse Web Scraper URL parameter."), + search_index_version: 3, + } + } +} + +#[cfg(test)] +mod tests { + use crate::config::ComponentsConfig; + use insta::assert_toml_snapshot; + + #[test] + fn serialization_and_default() { + assert_toml_snapshot!(ComponentsConfig::default(), @r###" + web-scraper-url = 'http://localhost:7272/' + search-index-version = 3 + "###); + } + + #[test] + fn deserialization() { + let config: ComponentsConfig = toml::from_str( + r#" + web-scraper-url = 'http://localhost:7272/' + search-index-version = 3 + "#, + ) + .unwrap(); + assert_eq!(config, ComponentsConfig::default()); + } +} diff --git a/src/config/js_runtime_config.rs b/src/config/js_runtime_config.rs index e6389ab..628e0ab 100644 --- a/src/config/js_runtime_config.rs +++ b/src/config/js_runtime_config.rs @@ -1,10 +1,52 @@ +use serde_derive::{Deserialize, Serialize}; +use serde_with::{serde_as, DurationMilliSeconds}; use std::time::Duration; /// Configuration for the JS runtime (Deno). -#[derive(Clone, Debug)] +#[serde_as] +#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)] +#[serde(rename_all = "kebab-case")] pub struct JsRuntimeConfig { - /// The hard limit for the JS runtime heap size in bytes. - pub max_heap_size_bytes: usize, - /// The maximum duration for a single JS script execution. + /// The hard limit for the JS runtime heap size in bytes. Defaults to 10485760 bytes or 10 MB. + pub max_heap_size: usize, + /// The maximum duration for a single JS script execution. Defaults to 30 seconds. + #[serde_as(as = "DurationMilliSeconds")] pub max_user_script_execution_time: Duration, } + +impl Default for JsRuntimeConfig { + fn default() -> Self { + Self { + // Default value for max size of the heap in bytes is 10 MB. + max_heap_size: 10485760, + // Default value for max user script execution time is 30 seconds. + max_user_script_execution_time: Duration::from_secs(30), + } + } +} + +#[cfg(test)] +mod tests { + use crate::config::JsRuntimeConfig; + use insta::assert_toml_snapshot; + + #[test] + fn serialization_and_default() { + assert_toml_snapshot!(JsRuntimeConfig::default(), @r###" + max-heap-size = 10485760 + max-user-script-execution-time = 30000 + "###); + } + + #[test] + fn deserialization() { + let config: JsRuntimeConfig = toml::from_str( + r#" +max-heap-size = 10485760 +max-user-script-execution-time = 30000 +"#, + ) + .unwrap(); + assert_eq!(config, JsRuntimeConfig::default()); + } +} diff --git a/src/config/raw_config.rs b/src/config/raw_config.rs new file mode 100644 index 0000000..fe03ae5 --- /dev/null +++ b/src/config/raw_config.rs @@ -0,0 +1,316 @@ +use crate::config::{ + utils_config::UtilsConfig, ComponentsConfig, JsRuntimeConfig, SchedulerJobsConfig, + SecurityConfig, SmtpConfig, SubscriptionsConfig, +}; +use figment::{providers, providers::Format, value, Figment, Metadata, Profile, Provider}; +use serde_derive::{Deserialize, Serialize}; +use url::Url; + +/// Raw configuration structure that is used to read the configuration from the file. +#[derive(Deserialize, Serialize, Debug, Clone)] +#[serde(rename_all = "kebab-case")] +pub struct RawConfig { + /// Defines a TCP port to listen on. + pub port: u16, + /// External/public URL through which service is being accessed. + pub public_url: Url, + /// Security configuration (session, built-in users etc.). + pub security: SecurityConfig, + /// Configuration for the components that are deployed separately. + pub components: ComponentsConfig, + /// Configuration for the JS runtime. + pub js_runtime: JsRuntimeConfig, + /// Configuration for the scheduler jobs. + pub scheduler: SchedulerJobsConfig, + /// Configuration related to the Secutils.dev subscriptions. + pub subscriptions: SubscriptionsConfig, + /// Configuration for the utilities. + pub utils: UtilsConfig, + /// Configuration for the SMTP functionality. + pub smtp: Option, +} + +impl RawConfig { + /// Reads the configuration from the file (TOML) and merges it with the default values. + pub fn read_from_file(path: &str) -> anyhow::Result { + Ok(Figment::from(RawConfig::default()) + .merge(providers::Toml::file(path)) + .extract()?) + } +} + +impl Default for RawConfig { + fn default() -> Self { + let port = 7070; + Self { + port, + public_url: Url::parse(&format!("http://localhost:{port}")) + .expect("Cannot parse public URL parameter."), + security: SecurityConfig::default(), + components: ComponentsConfig::default(), + js_runtime: JsRuntimeConfig::default(), + scheduler: SchedulerJobsConfig::default(), + subscriptions: SubscriptionsConfig::default(), + utils: UtilsConfig::default(), + smtp: None, + } + } +} + +impl Provider for RawConfig { + fn metadata(&self) -> Metadata { + Metadata::named("Secutils.dev main configuration") + } + + fn data(&self) -> Result, figment::Error> { + providers::Serialized::defaults(Self::default()).data() + } +} + +#[cfg(test)] +mod tests { + use crate::config::{RawConfig, SESSION_KEY_LENGTH_BYTES}; + use insta::{assert_debug_snapshot, assert_toml_snapshot}; + use url::Url; + + #[test] + fn serialization_and_default() { + let mut default_config = RawConfig::default(); + default_config.security.session_key = "a".repeat(SESSION_KEY_LENGTH_BYTES); + default_config.subscriptions.feature_overview_url = + Some(Url::parse("http://localhost:7272").unwrap()); + + assert_toml_snapshot!(default_config, @r###" + port = 7070 + public-url = 'http://localhost:7070/' + + [security] + session-key = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' + use-insecure-session-cookie = false + + [components] + web-scraper-url = 'http://localhost:7272/' + search-index-version = 3 + + [js-runtime] + max-heap-size = 10485760 + max-user-script-execution-time = 30000 + + [scheduler] + web-page-trackers-schedule = '0 * * * * * *' + web-page-trackers-fetch = '0 * * * * * *' + notifications-send = '0/30 * * * * * *' + + [subscriptions] + feature-overview-url = 'http://localhost:7272/' + + [utils] + webhook-url-type = 'subdomain' + "###); + } + + #[test] + fn deserialization() { + let config: RawConfig = toml::from_str( + r#" + port = 7070 + public-url = 'http://localhost:7070/' + + [security] + session-key = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' + use-insecure-session-cookie = false + + [components] + web-scraper-url = 'http://localhost:7272/' + search-index-version = 3 + + [js-runtime] + max-heap-size = 10485760 + max-user-script-execution-time = 30000 + + [scheduler] + web-page-trackers-schedule = '0 * * * * * *' + web-page-trackers-fetch = '0 * * * * * *' + notifications-send = '0/30 * * * * * *' + + [subscriptions] + feature-overview-url = 'http://localhost:7272/' + + [utils] + webhook-url-type = 'subdomain' + "#, + ) + .unwrap(); + + assert_debug_snapshot!(config, @r###" + RawConfig { + port: 7070, + public_url: Url { + scheme: "http", + cannot_be_a_base: false, + username: "", + password: None, + host: Some( + Domain( + "localhost", + ), + ), + port: Some( + 7070, + ), + path: "/", + query: None, + fragment: None, + }, + security: SecurityConfig { + session_key: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + use_insecure_session_cookie: false, + builtin_users: None, + }, + components: ComponentsConfig { + web_scraper_url: Url { + scheme: "http", + cannot_be_a_base: false, + username: "", + password: None, + host: Some( + Domain( + "localhost", + ), + ), + port: Some( + 7272, + ), + path: "/", + query: None, + fragment: None, + }, + search_index_version: 3, + }, + js_runtime: JsRuntimeConfig { + max_heap_size: 10485760, + max_user_script_execution_time: 30s, + }, + scheduler: SchedulerJobsConfig { + web_page_trackers_schedule: Schedule { + source: "0 * * * * * *", + fields: ScheduleFields { + years: Years { + ordinals: None, + }, + days_of_week: DaysOfWeek { + ordinals: None, + }, + months: Months { + ordinals: None, + }, + days_of_month: DaysOfMonth { + ordinals: None, + }, + hours: Hours { + ordinals: None, + }, + minutes: Minutes { + ordinals: None, + }, + seconds: Seconds { + ordinals: Some( + { + 0, + }, + ), + }, + }, + }, + web_page_trackers_fetch: Schedule { + source: "0 * * * * * *", + fields: ScheduleFields { + years: Years { + ordinals: None, + }, + days_of_week: DaysOfWeek { + ordinals: None, + }, + months: Months { + ordinals: None, + }, + days_of_month: DaysOfMonth { + ordinals: None, + }, + hours: Hours { + ordinals: None, + }, + minutes: Minutes { + ordinals: None, + }, + seconds: Seconds { + ordinals: Some( + { + 0, + }, + ), + }, + }, + }, + notifications_send: Schedule { + source: "0/30 * * * * * *", + fields: ScheduleFields { + years: Years { + ordinals: None, + }, + days_of_week: DaysOfWeek { + ordinals: None, + }, + months: Months { + ordinals: None, + }, + days_of_month: DaysOfMonth { + ordinals: None, + }, + hours: Hours { + ordinals: None, + }, + minutes: Minutes { + ordinals: None, + }, + seconds: Seconds { + ordinals: Some( + { + 0, + 30, + }, + ), + }, + }, + }, + }, + subscriptions: SubscriptionsConfig { + manage_url: None, + feature_overview_url: Some( + Url { + scheme: "http", + cannot_be_a_base: false, + username: "", + password: None, + host: Some( + Domain( + "localhost", + ), + ), + port: Some( + 7272, + ), + path: "/", + query: None, + fragment: None, + }, + ), + }, + utils: UtilsConfig { + webhook_url_type: Subdomain, + }, + smtp: None, + } + "###); + } +} diff --git a/src/config/scheduler_jobs_config.rs b/src/config/scheduler_jobs_config.rs index 691b9e6..df651bd 100644 --- a/src/config/scheduler_jobs_config.rs +++ b/src/config/scheduler_jobs_config.rs @@ -1,12 +1,61 @@ use cron::Schedule; +use serde_derive::{Deserialize, Serialize}; +use serde_with::{serde_as, DisplayFromStr}; +use std::str::FromStr; /// Configuration for the Secutils.dev scheduler jobs. -#[derive(Clone, Debug)] +#[serde_as] +#[derive(Deserialize, Serialize, Clone, Debug, PartialEq)] +#[serde(rename_all = "kebab-case")] pub struct SchedulerJobsConfig { /// The schedule to use for the `WebPageTrackersSchedule` job. + #[serde_as(as = "DisplayFromStr")] pub web_page_trackers_schedule: Schedule, /// The schedule to use for the `WebPageTrackersFetch` job. + #[serde_as(as = "DisplayFromStr")] pub web_page_trackers_fetch: Schedule, /// The schedule to use for the `NotificationsSend` job. + #[serde_as(as = "DisplayFromStr")] pub notifications_send: Schedule, } + +impl Default for SchedulerJobsConfig { + fn default() -> Self { + Self { + web_page_trackers_schedule: Schedule::from_str("0 * * * * * *") + .expect("Cannot parse web page trackers schedule job schedule."), + web_page_trackers_fetch: Schedule::from_str("0 * * * * * *") + .expect("Cannot parse web page trackers fetch job schedule."), + notifications_send: Schedule::from_str("0/30 * * * * * *") + .expect("Cannot parse notifications send job schedule."), + } + } +} + +#[cfg(test)] +mod tests { + use crate::config::SchedulerJobsConfig; + use insta::assert_toml_snapshot; + + #[test] + fn serialization_and_default() { + assert_toml_snapshot!(SchedulerJobsConfig::default(), @r###" + web-page-trackers-schedule = '0 * * * * * *' + web-page-trackers-fetch = '0 * * * * * *' + notifications-send = '0/30 * * * * * *' + "###); + } + + #[test] + fn deserialization() { + let config: SchedulerJobsConfig = toml::from_str( + r#" + web-page-trackers-schedule = '0 * * * * * *' + web-page-trackers-fetch = '0 * * * * * *' + notifications-send = '0/30 * * * * * *' + "#, + ) + .unwrap(); + assert_eq!(config, SchedulerJobsConfig::default()); + } +} diff --git a/src/config/security_config.rs b/src/config/security_config.rs new file mode 100644 index 0000000..3ba6170 --- /dev/null +++ b/src/config/security_config.rs @@ -0,0 +1,118 @@ +use crate::users::SubscriptionTier; +use hex::ToHex; +use rand_core::{OsRng, RngCore}; +use serde_derive::{Deserialize, Serialize}; + +pub const SESSION_KEY_LENGTH_BYTES: usize = 64; + +/// Describes the builtin user configuration. +#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)] +pub struct BuiltinUserConfig { + /// Builtin user email. + pub email: String, + /// Builtin user handle (used to construct unique user sub-domain). + pub handle: String, + /// Builtin user credentials. + pub password: String, + /// Builtin user subscription tier. + pub tier: SubscriptionTier, +} + +/// Configuration for the SMTP functionality. +#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)] +#[serde(rename_all = "kebab-case")] +pub struct SecurityConfig { + /// Session encryption key. + pub session_key: String, + /// Indicates that server shouldn't set `Secure` flag on the session cookie (do not use in production). + pub use_insecure_session_cookie: bool, + /// List of the builtin users, if specified. + pub builtin_users: Option>, +} + +impl Default for SecurityConfig { + fn default() -> Self { + let mut session_key = [0; SESSION_KEY_LENGTH_BYTES / 2]; + OsRng.fill_bytes(&mut session_key); + + Self { + session_key: session_key.encode_hex(), + use_insecure_session_cookie: false, + builtin_users: None, + } + } +} + +#[cfg(test)] +mod tests { + use crate::{ + config::{BuiltinUserConfig, SecurityConfig, SESSION_KEY_LENGTH_BYTES}, + users::SubscriptionTier, + }; + use insta::assert_toml_snapshot; + + #[test] + fn serialization_and_default() { + let mut default_config = SecurityConfig::default(); + assert_eq!(default_config.session_key.len(), SESSION_KEY_LENGTH_BYTES); + assert!(default_config.builtin_users.is_none()); + assert!(!default_config.use_insecure_session_cookie); + + default_config.session_key = "a".repeat(SESSION_KEY_LENGTH_BYTES); + + assert_toml_snapshot!(default_config, @r###" + session-key = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' + use-insecure-session-cookie = false + "###); + + default_config.builtin_users = Some(vec![BuiltinUserConfig { + email: "test@secutils.dev".to_string(), + handle: "test-handle".to_string(), + password: "test-password".to_string(), + tier: SubscriptionTier::Basic, + }]); + + assert_toml_snapshot!(default_config, @r###" + session-key = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' + use-insecure-session-cookie = false + + [[builtin-users]] + email = 'test@secutils.dev' + handle = 'test-handle' + password = 'test-password' + tier = 'basic' + "###); + } + + #[test] + fn deserialization() { + let config: SecurityConfig = toml::from_str( + r#" + session-key = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' + use-insecure-session-cookie = true + + [[builtin-users]] + email = 'test@secutils.dev' + handle = 'test-handle' + password = 'test-password' + tier = 'basic' + "#, + ) + .unwrap(); + + assert_eq!( + config, + SecurityConfig { + session_key: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + .to_string(), + use_insecure_session_cookie: true, + builtin_users: Some(vec![BuiltinUserConfig { + email: "test@secutils.dev".to_string(), + handle: "test-handle".to_string(), + password: "test-password".to_string(), + tier: SubscriptionTier::Basic, + }]) + } + ); + } +} diff --git a/src/config/smtp_catch_all_config.rs b/src/config/smtp_catch_all_config.rs index 41013bc..cfe39c1 100644 --- a/src/config/smtp_catch_all_config.rs +++ b/src/config/smtp_catch_all_config.rs @@ -1,11 +1,53 @@ use regex::Regex; +use serde_derive::{Deserialize, Serialize}; +use serde_with::{serde_as, DisplayFromStr}; /// Configuration for the SMTP catch-all functionality. -#[derive(Clone, Debug)] +#[serde_as] +#[derive(Deserialize, Serialize, Debug, Clone)] +#[serde(rename_all = "kebab-case")] pub struct SmtpCatchAllConfig { /// Address of the catch-all email recipient. pub recipient: String, /// Email will be sent to the catch-all recipient instead of original one only if the email text /// matches regular expression specified in `text_matcher`. + #[serde_as(as = "DisplayFromStr")] pub text_matcher: Regex, } + +#[cfg(test)] +mod tests { + use crate::config::SmtpCatchAllConfig; + use insta::{assert_debug_snapshot, assert_toml_snapshot}; + use regex::Regex; + + #[test] + fn serialization() { + assert_toml_snapshot!(SmtpCatchAllConfig { + recipient: "test@secutils.dev".to_string(), + text_matcher: Regex::new(r"test").unwrap(), + }, @r###" + recipient = 'test@secutils.dev' + text-matcher = 'test' + "###); + } + + #[test] + fn deserialization() { + let config: SmtpCatchAllConfig = toml::from_str( + r#" + recipient = 'test@secutils.dev' + text-matcher = 'test' + "#, + ) + .unwrap(); + assert_debug_snapshot!(config, @r###" + SmtpCatchAllConfig { + recipient: "test@secutils.dev", + text_matcher: Regex( + "test", + ), + } + "###); + } +} diff --git a/src/config/smtp_config.rs b/src/config/smtp_config.rs index d7e393c..0402a0a 100644 --- a/src/config/smtp_config.rs +++ b/src/config/smtp_config.rs @@ -1,7 +1,9 @@ use crate::config::SmtpCatchAllConfig; +use serde_derive::{Deserialize, Serialize}; /// Configuration for the SMTP functionality. -#[derive(Clone, Debug)] +#[derive(Deserialize, Serialize, Debug, Clone)] +#[serde(rename_all = "kebab-case")] pub struct SmtpConfig { /// Username to use to authenticate to the SMTP server. pub username: String, @@ -12,3 +14,75 @@ pub struct SmtpConfig { /// Optional configuration for catch-all email recipient (used for troubleshooting only). pub catch_all: Option, } + +#[cfg(test)] +mod tests { + use crate::config::{SmtpCatchAllConfig, SmtpConfig}; + use insta::{assert_debug_snapshot, assert_toml_snapshot}; + use regex::Regex; + + #[test] + fn serialization() { + let config = SmtpConfig { + username: "test@secutils.dev".to_string(), + password: "password".to_string(), + address: "smtp.secutils.dev".to_string(), + catch_all: None, + }; + assert_toml_snapshot!(config, @r###" + username = 'test@secutils.dev' + password = 'password' + address = 'smtp.secutils.dev' + "###); + + let config = SmtpConfig { + username: "test@secutils.dev".to_string(), + password: "password".to_string(), + address: "smtp.secutils.dev".to_string(), + catch_all: Some(SmtpCatchAllConfig { + recipient: "test@secutils.dev".to_string(), + text_matcher: Regex::new(r"test").unwrap(), + }), + }; + assert_toml_snapshot!(config, @r###" + username = 'test@secutils.dev' + password = 'password' + address = 'smtp.secutils.dev' + + [catch-all] + recipient = 'test@secutils.dev' + text-matcher = 'test' + "###); + } + + #[test] + fn deserialization() { + let config: SmtpConfig = toml::from_str( + r#" + username = 'test@secutils.dev' + password = 'password' + address = 'smtp.secutils.dev' + + [catch-all] + recipient = 'test@secutils.dev' + text-matcher = 'test' + "#, + ) + .unwrap(); + assert_debug_snapshot!(config, @r###" + SmtpConfig { + username: "test@secutils.dev", + password: "password", + address: "smtp.secutils.dev", + catch_all: Some( + SmtpCatchAllConfig { + recipient: "test@secutils.dev", + text_matcher: Regex( + "test", + ), + }, + ), + } + "###); + } +} diff --git a/src/config/subscriptions_config.rs b/src/config/subscriptions_config.rs index 2aca601..8267f03 100644 --- a/src/config/subscriptions_config.rs +++ b/src/config/subscriptions_config.rs @@ -1,10 +1,51 @@ +use serde_derive::{Deserialize, Serialize}; use url::Url; /// Configuration related to the Secutils.dev subscriptions. -#[derive(Clone, Debug)] +#[derive(Deserialize, Serialize, Debug, Clone, Default, PartialEq)] +#[serde(rename_all = "kebab-case")] pub struct SubscriptionsConfig { /// The URL to access the subscription management page. pub manage_url: Option, /// The URL to access the feature overview page. pub feature_overview_url: Option, } + +#[cfg(test)] +mod tests { + use crate::config::SubscriptionsConfig; + use insta::assert_toml_snapshot; + use url::Url; + + #[test] + fn serialization_and_default() { + assert_toml_snapshot!(SubscriptionsConfig::default(), @""); + + let config = SubscriptionsConfig { + manage_url: Some(Url::parse("http://localhost:7272").unwrap()), + feature_overview_url: Some(Url::parse("http://localhost:7272").unwrap()), + }; + assert_toml_snapshot!(config, @r###" + manage-url = 'http://localhost:7272/' + feature-overview-url = 'http://localhost:7272/' + "###); + } + + #[test] + fn deserialization() { + let config: SubscriptionsConfig = toml::from_str( + r#" + manage-url = 'http://localhost:7272/' + feature-overview-url = 'http://localhost:7272/' + "#, + ) + .unwrap(); + assert_eq!( + config, + SubscriptionsConfig { + manage_url: Some(Url::parse("http://localhost:7272").unwrap()), + feature_overview_url: Some(Url::parse("http://localhost:7272").unwrap()), + } + ); + } +} diff --git a/src/config/utils_config.rs b/src/config/utils_config.rs new file mode 100644 index 0000000..e392dda --- /dev/null +++ b/src/config/utils_config.rs @@ -0,0 +1,42 @@ +use crate::server::WebhookUrlType; +use serde_derive::{Deserialize, Serialize}; +use serde_with::serde_as; + +/// Configuration for the JS runtime (Deno). +#[serde_as] +#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)] +#[serde(rename_all = "kebab-case")] +pub struct UtilsConfig { + /// Describes the preferred way to construct webhook URLs. + pub webhook_url_type: WebhookUrlType, +} + +impl Default for UtilsConfig { + fn default() -> Self { + Self { + webhook_url_type: WebhookUrlType::Subdomain, + } + } +} + +#[cfg(test)] +mod tests { + use crate::{config::UtilsConfig, server::WebhookUrlType}; + use insta::assert_toml_snapshot; + + #[test] + fn serialization_and_default() { + assert_toml_snapshot!(UtilsConfig::default(), @"webhook-url-type = 'subdomain'"); + } + + #[test] + fn deserialization() { + let config: UtilsConfig = toml::from_str(r#"webhook-url-type = 'path'"#).unwrap(); + assert_eq!( + config, + UtilsConfig { + webhook_url_type: WebhookUrlType::Path + } + ); + } +} diff --git a/src/js_runtime.rs b/src/js_runtime.rs index adce30e..d25e39a 100644 --- a/src/js_runtime.rs +++ b/src/js_runtime.rs @@ -30,7 +30,7 @@ impl JsRuntime { Self { inner_runtime: deno_core::JsRuntime::new(RuntimeOptions { create_params: Some( - v8::Isolate::create_params().heap_limits(0, config.max_heap_size_bytes), + v8::Isolate::create_params().heap_limits(0, config.max_heap_size), ), ..Default::default() }), @@ -164,14 +164,14 @@ impl JsRuntime { #[cfg(test)] pub mod tests { use super::JsRuntime; - use crate::JsRuntimeConfig; + use crate::config::JsRuntimeConfig; use deno_core::error::JsError; use serde::{Deserialize, Serialize}; #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn can_execute_scripts() -> anyhow::Result<()> { let config = JsRuntimeConfig { - max_heap_size_bytes: 10 * 1024 * 1024, + max_heap_size: 10 * 1024 * 1024, max_user_script_execution_time: std::time::Duration::from_secs(5), }; @@ -228,7 +228,7 @@ pub mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn can_limit_execution_time() -> anyhow::Result<()> { let config = JsRuntimeConfig { - max_heap_size_bytes: 10 * 1024 * 1024, + max_heap_size: 10 * 1024 * 1024, max_user_script_execution_time: std::time::Duration::from_secs(5), }; @@ -281,7 +281,7 @@ pub mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn can_limit_execution_memory() -> anyhow::Result<()> { let config = JsRuntimeConfig { - max_heap_size_bytes: 10 * 1024 * 1024, + max_heap_size: 10 * 1024 * 1024, max_user_script_execution_time: std::time::Duration::from_secs(5), }; diff --git a/src/main.rs b/src/main.rs index 4ed2386..1fda049 100644 --- a/src/main.rs +++ b/src/main.rs @@ -17,329 +17,50 @@ mod templates; mod users; mod utils; -use crate::{ - config::{ - ComponentsConfig, Config, JsRuntimeConfig, SchedulerJobsConfig, SmtpCatchAllConfig, - SmtpConfig, SubscriptionsConfig, - }, - server::WebhookUrlType, -}; -use anyhow::{anyhow, Context}; -use bytes::Buf; -use clap::{value_parser, Arg, ArgMatches, Command}; -use cron::Schedule; -use lettre::message::Mailbox; -use std::{str::FromStr, time::Duration}; -use url::Url; - -fn process_command(version: &str, matches: ArgMatches) -> Result<(), anyhow::Error> { - let smtp_catch_all_config = match ( - matches.get_one::("SMTP_CATCH_ALL_RECIPIENT"), - matches.get_one::("SMTP_CATCH_ALL_TEXT_MATCHER"), - ) { - (Some(recipient), Some(text_matcher)) => { - let text_matcher = regex::Regex::new(text_matcher.as_str()) - .with_context(|| "Cannot parse SMTP catch-all text matcher.")?; - Mailbox::from_str(recipient.as_str()) - .with_context(|| "Cannot parse SMTP catch-all recipient.")?; - Some(SmtpCatchAllConfig { - recipient: recipient.to_string(), - text_matcher, - }) - } - (None, None) => None, - (recipient, text_matcher) => { - log::warn!( - "SMTP catch-all config is not invalid: recipient ({:?}) and text_matcher ({:?}).", - recipient, - text_matcher - ); - None - } - }; - let smtp_config = match ( - matches.get_one::("SMTP_USERNAME"), - matches.get_one::("SMTP_PASSWORD"), - matches.get_one::("SMTP_ADDRESS"), - ) { - (Some(username), Some(password), Some(address)) => Some(SmtpConfig { - username: username.to_string(), - password: password.to_string(), - address: address.to_string(), - catch_all: smtp_catch_all_config, - }), - (username, password, address) => { - log::warn!("SMTP config is not provided or invalid: username ({:?}), password ({:?}), address ({:?}).", username, password, address); - None - } - }; - - let config = Config { - version: version.to_owned(), - smtp: smtp_config, - http_port: *matches - .get_one("HTTP_PORT") - .ok_or_else(|| anyhow!(" argument is not provided."))?, - public_url: matches - .get_one::("PUBLIC_URL") - .ok_or_else(|| anyhow!(" argument is not provided.")) - .and_then(|public_url| { - Url::parse(public_url) - .with_context(|| "Cannot parse public URL parameter.".to_string()) - })?, - webhook_url_type: matches - .get_one::("WEBHOOK_URL_TYPE") - .ok_or_else(|| anyhow!(" argument is not provided.")) - .and_then(|webhook_url_type| { - WebhookUrlType::from_str(webhook_url_type) - .with_context(|| "Cannot parse webhook URL type parameter.".to_string()) - })?, - components: ComponentsConfig { - web_scraper_url: matches - .get_one::("COMPONENT_WEB_SCRAPER_URL") - .ok_or_else(|| anyhow!(" argument is not provided.")) - .and_then(|url| { - Url::parse(url) - .with_context(|| "Cannot parse Web Scraper URL parameter.".to_string()) - })?, - search_index_version: 3, - }, - jobs: SchedulerJobsConfig { - web_page_trackers_schedule: matches - .get_one::("JOBS_WEB_PAGE_TRACKERS_SCHEDULE") - .ok_or_else(|| { - anyhow!(" argument is not provided.") - }) - .and_then(|schedule| { - Schedule::try_from(schedule.as_str()) - .with_context(|| "Cannot parse web page trackers schedule job schedule.") - })?, - web_page_trackers_fetch: matches - .get_one::("JOBS_WEB_PAGE_TRACKERS_FETCH") - .ok_or_else(|| anyhow!(" argument is not provided.")) - .and_then(|schedule| { - Schedule::try_from(schedule.as_str()) - .with_context(|| "Cannot parse web page trackers fetch job schedule.") - })?, - notifications_send: matches - .get_one::("JOBS_NOTIFICATIONS_SEND") - .ok_or_else(|| anyhow!(" argument is not provided.")) - .and_then(|schedule| { - Schedule::try_from(schedule.as_str()) - .with_context(|| "Cannot parse notifications send job schedule.") - })?, - }, - js_runtime: JsRuntimeConfig { - max_heap_size_bytes: *matches - .get_one("JS_RUNTIME_MAX_HEAP_SIZE") - .ok_or_else(|| anyhow!(" argument is not provided."))?, - max_user_script_execution_time: matches - .get_one::("JS_RUNTIME_MAX_USER_SCRIPT_EXECUTION_TIME") - .map(|value| Duration::from_secs(*value)) - .ok_or_else(|| { - anyhow!(" argument is not provided.") - })?, - }, - subscriptions: SubscriptionsConfig { - manage_url: matches - .get_one::("SUBSCRIPTIONS_MANAGE_URL") - .map(|value| Url::parse(value.as_str())) - .transpose() - .with_context(|| "Cannot parse subscription management URL.")?, - feature_overview_url: matches - .get_one::("SUBSCRIPTIONS_FEATURE_OVERVIEW_URL") - .map(|value| Url::parse(value.as_str())) - .transpose() - .with_context(|| "Cannot parse subscription feature overview URL.")?, - }, - }; - - let session_key = matches - .get_one::("SESSION_KEY") - .ok_or_else(|| anyhow!(" argument is not provided.")) - .and_then(|value| { - let mut session_key = [0; 64]; - if value.as_bytes().len() != session_key.len() { - Err(anyhow!(format!( - " argument should be {} bytes long.", - session_key.len() - ))) - } else { - value.as_bytes().copy_to_slice(&mut session_key); - Ok(session_key) - } - })?; - - let secure_cookies = !matches.get_flag("SESSION_USE_INSECURE_COOKIES"); - - let builtin_users = matches - .get_one::("BUILTIN_USERS") - .map(|value| value.to_string()); - - server::run(config, session_key, secure_cookies, builtin_users) -} +use crate::config::{Config, RawConfig}; +use anyhow::anyhow; +use clap::{crate_authors, crate_description, crate_version, value_parser, Arg, Command}; fn main() -> Result<(), anyhow::Error> { dotenvy::dotenv().ok(); structured_logger::Builder::new().init(); - let version = env!("CARGO_PKG_VERSION"); - let matches = Command::new("Secutils.dev API server") - .version(version) - .author("Secutils ("CONFIG") + .ok_or_else(|| anyhow!(" argument is not provided."))?, + )?; + + // CLI argument takes precedence. + if let Some(port) = matches.get_one::("PORT") { + raw_config.port = *port; + } + + log::info!("Secutils.dev raw configuration: {raw_config:?}."); + + server::run(raw_config) } #[cfg(test)] @@ -366,10 +87,9 @@ mod tests { use url::Url; use crate::{ - config::JsRuntimeConfig, + config::{JsRuntimeConfig, UtilsConfig}, search::SearchIndex, security::create_webauthn, - server::WebhookUrlType, templates::create_templates, users::{SubscriptionTier, UserSubscription}, }; @@ -539,29 +259,21 @@ mod tests { pub fn mock_config() -> anyhow::Result { Ok(Config { - version: "1.0.0".to_string(), - http_port: 1234, public_url: Url::parse("http://localhost:1234")?, - webhook_url_type: WebhookUrlType::Subdomain, + utils: UtilsConfig::default(), smtp: Some(SmtpConfig { username: "dev@secutils.dev".to_string(), password: "password".to_string(), address: "localhost".to_string(), catch_all: None, }), - components: ComponentsConfig { - web_scraper_url: Url::parse("http://localhost:7272")?, - search_index_version: 3, - }, - jobs: SchedulerJobsConfig { + components: ComponentsConfig::default(), + scheduler: SchedulerJobsConfig { web_page_trackers_schedule: Schedule::try_from("0 * 0 * * * *")?, web_page_trackers_fetch: Schedule::try_from("0 * 1 * * * *")?, notifications_send: Schedule::try_from("0 * 2 * * * *")?, }, - js_runtime: JsRuntimeConfig { - max_heap_size_bytes: 10485760, - max_user_script_execution_time: Duration::from_secs(30), - }, + js_runtime: JsRuntimeConfig::default(), subscriptions: SubscriptionsConfig { manage_url: Some(Url::parse("http://localhost:1234/subscription")?), feature_overview_url: Some(Url::parse("http://localhost:1234/features")?), diff --git a/src/scheduler/scheduler_jobs/notifications_send_job.rs b/src/scheduler/scheduler_jobs/notifications_send_job.rs index a9efdb8..61adaef 100644 --- a/src/scheduler/scheduler_jobs/notifications_send_job.rs +++ b/src/scheduler/scheduler_jobs/notifications_send_job.rs @@ -39,7 +39,7 @@ impl NotificationsSendJob { ET::Error: EmailTransportError, { let mut job = Job::new_async( - api.config.jobs.notifications_send.clone(), + api.config.scheduler.notifications_send.clone(), move |_, scheduler| { let api = api.clone(); Box::pin(async move { @@ -139,7 +139,7 @@ mod tests { #[tokio::test] async fn can_create_job_with_correct_parameters() -> anyhow::Result<()> { let mut config = mock_config()?; - config.jobs.notifications_send = Schedule::try_from("1/5 * * * * *")?; + config.scheduler.notifications_send = Schedule::try_from("1/5 * * * * *")?; let api = mock_api_with_config(config).await?; @@ -170,7 +170,7 @@ mod tests { #[tokio::test] async fn can_resume_job() -> anyhow::Result<()> { let mut config = mock_config()?; - config.jobs.notifications_send = Schedule::try_from("0 0 * * * *")?; + config.scheduler.notifications_send = Schedule::try_from("0 0 * * * *")?; let api = mock_api_with_config(config).await?; @@ -206,7 +206,7 @@ mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn can_send_pending_notifications() -> anyhow::Result<()> { let mut config = mock_config()?; - config.jobs.notifications_send = Schedule::try_from(mock_schedule_in_sec(2).as_str())?; + config.scheduler.notifications_send = Schedule::try_from(mock_schedule_in_sec(2).as_str())?; let user = mock_user()?; let api = Arc::new(mock_api_with_config(config).await?); diff --git a/src/scheduler/scheduler_jobs/web_page_trackers_fetch_job.rs b/src/scheduler/scheduler_jobs/web_page_trackers_fetch_job.rs index f35afd9..764d7e7 100644 --- a/src/scheduler/scheduler_jobs/web_page_trackers_fetch_job.rs +++ b/src/scheduler/scheduler_jobs/web_page_trackers_fetch_job.rs @@ -43,7 +43,7 @@ impl WebPageTrackersFetchJob { ET::Error: EmailTransportError, { let mut job = Job::new_async( - api.config.jobs.web_page_trackers_fetch.clone(), + api.config.scheduler.web_page_trackers_fetch.clone(), move |_, scheduler| { let api = api.clone(); Box::pin(async move { @@ -441,7 +441,7 @@ mod tests { #[tokio::test] async fn can_create_job_with_correct_parameters() -> anyhow::Result<()> { let mut config = mock_config()?; - config.jobs.web_page_trackers_fetch = Schedule::try_from("1/5 * * * * *")?; + config.scheduler.web_page_trackers_fetch = Schedule::try_from("1/5 * * * * *")?; let api = mock_api_with_config(config).await?; @@ -472,7 +472,7 @@ mod tests { #[tokio::test] async fn can_resume_job() -> anyhow::Result<()> { let mut config = mock_config()?; - config.jobs.web_page_trackers_fetch = Schedule::try_from("0 0 * * * *")?; + config.scheduler.web_page_trackers_fetch = Schedule::try_from("0 0 * * * *")?; let api = mock_api_with_config(config).await?; @@ -508,7 +508,8 @@ mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn remove_pending_trackers_jobs_if_zero_revisions() -> anyhow::Result<()> { let mut config = mock_config()?; - config.jobs.web_page_trackers_fetch = Schedule::try_from(mock_schedule_in_sec(2).as_str())?; + config.scheduler.web_page_trackers_fetch = + Schedule::try_from(mock_schedule_in_sec(2).as_str())?; let user = mock_user()?; let api = Arc::new(mock_api_with_config(config).await?); @@ -621,7 +622,8 @@ mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn can_fetch_resources() -> anyhow::Result<()> { let mut config = mock_config()?; - config.jobs.web_page_trackers_fetch = Schedule::try_from(mock_schedule_in_sec(3).as_str())?; + config.scheduler.web_page_trackers_fetch = + Schedule::try_from(mock_schedule_in_sec(3).as_str())?; let server = MockServer::start(); config.components.web_scraper_url = Url::parse(&server.base_url())?; @@ -802,7 +804,8 @@ mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn can_fetch_content() -> anyhow::Result<()> { let mut config = mock_config()?; - config.jobs.web_page_trackers_fetch = Schedule::try_from(mock_schedule_in_sec(3).as_str())?; + config.scheduler.web_page_trackers_fetch = + Schedule::try_from(mock_schedule_in_sec(3).as_str())?; let server = MockServer::start(); config.components.web_scraper_url = Url::parse(&server.base_url())?; @@ -961,7 +964,8 @@ mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn schedules_notification_when_resources_change() -> anyhow::Result<()> { let mut config = mock_config()?; - config.jobs.web_page_trackers_fetch = Schedule::try_from(mock_schedule_in_sec(3).as_str())?; + config.scheduler.web_page_trackers_fetch = + Schedule::try_from(mock_schedule_in_sec(3).as_str())?; let server = MockServer::start(); config.components.web_scraper_url = Url::parse(&server.base_url())?; @@ -1147,7 +1151,8 @@ mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn schedules_notification_when_resources_change_check_fails() -> anyhow::Result<()> { let mut config = mock_config()?; - config.jobs.web_page_trackers_fetch = Schedule::try_from(mock_schedule_in_sec(3).as_str())?; + config.scheduler.web_page_trackers_fetch = + Schedule::try_from(mock_schedule_in_sec(3).as_str())?; let server = MockServer::start(); config.components.web_scraper_url = Url::parse(&server.base_url())?; @@ -1316,7 +1321,7 @@ mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn retries_when_resources_change_check_fails() -> anyhow::Result<()> { let mut config = mock_config()?; - config.jobs.web_page_trackers_fetch = + config.scheduler.web_page_trackers_fetch = Schedule::try_from(mock_schedule_in_secs(&[3, 6]).as_str())?; let server = MockServer::start(); @@ -1524,7 +1529,7 @@ mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn retries_when_resources_change_check_fails_until_succeeds() -> anyhow::Result<()> { let mut config = mock_config()?; - config.jobs.web_page_trackers_fetch = + config.scheduler.web_page_trackers_fetch = Schedule::try_from(mock_schedule_in_secs(&[3, 6]).as_str())?; let server = MockServer::start(); @@ -1769,7 +1774,8 @@ mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn schedules_notification_when_content_change() -> anyhow::Result<()> { let mut config = mock_config()?; - config.jobs.web_page_trackers_fetch = Schedule::try_from(mock_schedule_in_sec(3).as_str())?; + config.scheduler.web_page_trackers_fetch = + Schedule::try_from(mock_schedule_in_sec(3).as_str())?; let server = MockServer::start(); config.components.web_scraper_url = Url::parse(&server.base_url())?; @@ -1939,7 +1945,8 @@ mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn schedules_notification_when_content_change_check_fails() -> anyhow::Result<()> { let mut config = mock_config()?; - config.jobs.web_page_trackers_fetch = Schedule::try_from(mock_schedule_in_sec(3).as_str())?; + config.scheduler.web_page_trackers_fetch = + Schedule::try_from(mock_schedule_in_sec(3).as_str())?; let server = MockServer::start(); config.components.web_scraper_url = Url::parse(&server.base_url())?; @@ -2106,7 +2113,7 @@ mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn retries_when_content_change_check_fails() -> anyhow::Result<()> { let mut config = mock_config()?; - config.jobs.web_page_trackers_fetch = + config.scheduler.web_page_trackers_fetch = Schedule::try_from(mock_schedule_in_secs(&[3, 6]).as_str())?; let server = MockServer::start(); @@ -2312,7 +2319,7 @@ mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn retries_when_content_change_check_fails_until_succeeds() -> anyhow::Result<()> { let mut config = mock_config()?; - config.jobs.web_page_trackers_fetch = + config.scheduler.web_page_trackers_fetch = Schedule::try_from(mock_schedule_in_secs(&[3, 6]).as_str())?; let server = MockServer::start(); diff --git a/src/scheduler/scheduler_jobs/web_page_trackers_schedule_job.rs b/src/scheduler/scheduler_jobs/web_page_trackers_schedule_job.rs index f9e20b4..f7ab322 100644 --- a/src/scheduler/scheduler_jobs/web_page_trackers_schedule_job.rs +++ b/src/scheduler/scheduler_jobs/web_page_trackers_schedule_job.rs @@ -34,7 +34,7 @@ impl WebPageTrackersScheduleJob { api: Arc>, ) -> anyhow::Result { let mut job = Job::new_async( - api.config.jobs.web_page_trackers_schedule.clone(), + api.config.scheduler.web_page_trackers_schedule.clone(), move |_, scheduler| { let api = api.clone(); Box::pin(async move { @@ -167,7 +167,7 @@ mod tests { #[tokio::test] async fn can_create_job_with_correct_parameters() -> anyhow::Result<()> { let mut config = mock_config()?; - config.jobs.web_page_trackers_schedule = Schedule::try_from("1/5 * * * * *")?; + config.scheduler.web_page_trackers_schedule = Schedule::try_from("1/5 * * * * *")?; let api = mock_api_with_config(config).await?; @@ -198,7 +198,7 @@ mod tests { #[tokio::test] async fn can_resume_job() -> anyhow::Result<()> { let mut config = mock_config()?; - config.jobs.web_page_trackers_schedule = Schedule::try_from("0 0 * * * *")?; + config.scheduler.web_page_trackers_schedule = Schedule::try_from("0 0 * * * *")?; let api = mock_api_with_config(config).await?; @@ -249,7 +249,7 @@ mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn can_schedule_trackers_jobs() -> anyhow::Result<()> { let mut config = mock_config()?; - config.jobs.web_page_trackers_schedule = Schedule::try_from("1/1 * * * * *")?; + config.scheduler.web_page_trackers_schedule = Schedule::try_from("1/1 * * * * *")?; let user = mock_user()?; let api = Arc::new(mock_api_with_config(config).await?); @@ -436,7 +436,7 @@ mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn does_not_schedule_trackers_without_schedule() -> anyhow::Result<()> { let mut config = mock_config()?; - config.jobs.web_page_trackers_schedule = Schedule::try_from("1/1 * * * * *")?; + config.scheduler.web_page_trackers_schedule = Schedule::try_from("1/1 * * * * *")?; let user = mock_user()?; let api = Arc::new(mock_api_with_config(config).await?); @@ -533,7 +533,7 @@ mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn does_not_schedule_trackers_if_revisions_is_zero() -> anyhow::Result<()> { let mut config = mock_config()?; - config.jobs.web_page_trackers_schedule = Schedule::try_from("1/1 * * * * *")?; + config.scheduler.web_page_trackers_schedule = Schedule::try_from("1/1 * * * * *")?; let user = mock_user()?; let api = Arc::new(mock_api_with_config(config).await?); diff --git a/src/server.rs b/src/server.rs index 1a2db91..1e00956 100644 --- a/src/server.rs +++ b/src/server.rs @@ -6,7 +6,6 @@ mod ui_state; use crate::{ api::Api, - config::Config, database::Database, directories::Directories, js_runtime::JsRuntime, @@ -15,38 +14,45 @@ use crate::{ search::{populate_search_index, SearchIndex}, security::create_webauthn, templates::create_templates, - users::builtin_users_initializer, }; use actix_cors::Cors; use actix_identity::IdentityMiddleware; use actix_session::{storage::CookieSessionStore, SessionMiddleware}; use actix_web::{cookie::Key, middleware, web, App, HttpServer, Result}; -use anyhow::Context; -use lettre::{transport::smtp::authentication::Credentials, AsyncSmtpTransport, Tokio1Executor}; -use std::sync::Arc; +use anyhow::{bail, Context}; +use bytes::Buf; +use lettre::{ + message::Mailbox, transport::smtp::authentication::Credentials, AsyncSmtpTransport, + Tokio1Executor, +}; +use std::{str::FromStr, sync::Arc}; #[cfg(test)] pub use self::app_state::tests; +use crate::{ + config::{Config, RawConfig, SESSION_KEY_LENGTH_BYTES}, + users::BuiltinUser, +}; pub use app_state::AppState; pub use ui_state::{Status, StatusLevel, SubscriptionState, UiState, WebhookUrlType}; #[tokio::main] -pub async fn run( - config: Config, - session_key: [u8; 64], - secure_cookies: bool, - builtin_users: Option, -) -> Result<(), anyhow::Error> { +pub async fn run(raw_config: RawConfig) -> Result<(), anyhow::Error> { let datastore_dir = Directories::ensure_data_dir_exists()?; log::info!("Data is available at {}", datastore_dir.as_path().display()); let search_index = SearchIndex::open_path(datastore_dir.join(format!( "search_index_v{}", - config.components.search_index_version + raw_config.components.search_index_version )))?; let database = Database::open_path(datastore_dir).await?; - let email_transport = if let Some(ref smtp_config) = config.as_ref().smtp { + let email_transport = if let Some(ref smtp_config) = raw_config.smtp { + if let Some(ref catch_all_config) = smtp_config.catch_all { + Mailbox::from_str(catch_all_config.recipient.as_str()) + .with_context(|| "Cannot parse SMTP catch-all recipient.")?; + } + AsyncSmtpTransport::::relay(&smtp_config.address)? .credentials(Credentials::new( smtp_config.username.clone(), @@ -57,6 +63,40 @@ pub async fn run( AsyncSmtpTransport::::unencrypted_localhost() }; + let http_secure_cookies = !raw_config.security.use_insecure_session_cookie; + let http_port = raw_config.port; + + if raw_config.security.session_key.len() < SESSION_KEY_LENGTH_BYTES { + bail!("Session key should be at least {SESSION_KEY_LENGTH_BYTES} characters long."); + } + + let mut http_session_key = [0; SESSION_KEY_LENGTH_BYTES]; + raw_config + .security + .session_key + .as_bytes() + .copy_to_slice(&mut http_session_key); + + // Extract and parse builtin users from the configuration. + let builtin_users = raw_config + .security + .builtin_users + .as_ref() + .and_then(|users| { + if users.is_empty() { + None + } else { + Some( + users + .iter() + .map(BuiltinUser::try_from) + .collect::>>(), + ) + } + }) + .transpose()?; + + let config = Config::from(raw_config); let api = Arc::new(Api::new( config.clone(), database, @@ -66,10 +106,18 @@ pub async fn run( create_templates()?, )); - if let Some(ref builtin_users) = builtin_users { - builtin_users_initializer(&api, builtin_users) - .await - .with_context(|| "Cannot initialize builtin users")?; + if let Some(builtin_users) = builtin_users { + let builtin_users_count = builtin_users.len(); + + let users = api.users(); + for builtin_user in builtin_users { + users + .upsert_builtin(builtin_user) + .await + .with_context(|| "Cannot initialize builtin user")?; + } + + log::info!("Initialized {builtin_users_count} builtin users."); } populate_search_index(&api).await?; @@ -78,7 +126,6 @@ pub async fn run( JsRuntime::init_platform(); - let http_server_url = format!("0.0.0.0:{}", config.http_port); let state = web::Data::new(AppState::new(config, api.clone())); let http_server = HttpServer::new(move || { App::new() @@ -89,9 +136,12 @@ pub async fn run( // invokes middleware in the OPPOSITE order of registration when it receives an incoming // request. .wrap( - SessionMiddleware::builder(CookieSessionStore::default(), Key::from(&session_key)) - .cookie_secure(secure_cookies) - .build(), + SessionMiddleware::builder( + CookieSessionStore::default(), + Key::from(&http_session_key), + ) + .cookie_secure(http_secure_cookies) + .build(), ) .app_data(state.clone()) .service( @@ -200,6 +250,7 @@ pub async fn run( ) }); + let http_server_url = format!("0.0.0.0:{}", http_port); let http_server = http_server .bind(&http_server_url) .with_context(|| format!("Failed to bind to {}.", &http_server_url))?; diff --git a/src/server/app_state.rs b/src/server/app_state.rs index 990b7ea..933c8f0 100644 --- a/src/server/app_state.rs +++ b/src/server/app_state.rs @@ -21,11 +21,10 @@ pub struct AppState< impl AppState { pub fn new(config: Config, api: Arc>) -> Self { - let version = config.version.to_string(); Self { config, status: RwLock::new(Status { - version, + version: env!("CARGO_PKG_VERSION").to_string(), level: StatusLevel::Available, }), api, diff --git a/src/server/handlers/ui_state_get.rs b/src/server/handlers/ui_state_get.rs index 33d8eb1..b08827a 100644 --- a/src/server/handlers/ui_state_get.rs +++ b/src/server/handlers/ui_state_get.rs @@ -50,6 +50,6 @@ pub async fn ui_state_get( user_share: user_share.map(ClientUserShare::from), settings, utils, - webhook_url_type: state.config.webhook_url_type, + webhook_url_type: state.config.utils.webhook_url_type, })) } diff --git a/src/server/handlers/webhooks_responders.rs b/src/server/handlers/webhooks_responders.rs index a4edfa9..23fc2bb 100644 --- a/src/server/handlers/webhooks_responders.rs +++ b/src/server/handlers/webhooks_responders.rs @@ -206,7 +206,7 @@ pub async fn webhooks_responders( // Configure JavaScript runtime based on user's subscription level/overrides. let features = user.subscription.get_features(&state.config); let js_runtime_config = JsRuntimeConfig { - max_heap_size_bytes: features.webhooks_responders.max_script_memory, + max_heap_size: features.webhooks_responders.max_script_memory, max_user_script_execution_time: features.webhooks_responders.max_script_time, }; diff --git a/src/users.rs b/src/users.rs index d2e50ef..d8f8452 100644 --- a/src/users.rs +++ b/src/users.rs @@ -1,6 +1,5 @@ pub mod api_ext; mod builtin_user; -mod builtin_users_initializer; mod database_ext; mod internal_user_data_namespace; mod public_user_data_namespace; @@ -16,7 +15,6 @@ mod user_subscription; pub use self::{ api_ext::errors::UserSignupError, builtin_user::BuiltinUser, - builtin_users_initializer::builtin_users_initializer, internal_user_data_namespace::InternalUserDataNamespace, public_user_data_namespace::PublicUserDataNamespace, user::User, diff --git a/src/users/builtin_user.rs b/src/users/builtin_user.rs index 98bc2d8..09a1356 100644 --- a/src/users/builtin_user.rs +++ b/src/users/builtin_user.rs @@ -1,48 +1,51 @@ -use crate::{security::StoredCredentials, users::SubscriptionTier}; +use crate::{config::BuiltinUserConfig, security::StoredCredentials, users::SubscriptionTier}; use anyhow::bail; #[derive(Debug, Clone)] pub struct BuiltinUser { + /// Builtin user email. pub email: String, + /// Builtin user handle (used to construct unique user sub-domain). pub handle: String, + /// Builtin user credentials. pub credentials: StoredCredentials, + /// Builtin user subscription tier. pub tier: SubscriptionTier, } -impl TryFrom<&str> for BuiltinUser { +impl TryFrom<&BuiltinUserConfig> for BuiltinUser { type Error = anyhow::Error; - fn try_from(value: &str) -> Result { - let user_properties = value.split(':').collect::>(); - if user_properties.len() < 4 || user_properties.len() > 5 { - bail!("Builtin user is malformed."); - } - - let user_email = user_properties[0].trim(); - let user_password = user_properties[1].trim(); - let user_handle = user_properties[2].trim(); - if user_password.is_empty() || user_email.is_empty() || user_handle.is_empty() { - bail!( - "Builtin user cannot have empty password, username, handle, or subscription tier." - ); + fn try_from(value: &BuiltinUserConfig) -> Result { + if value.password.is_empty() || value.email.is_empty() || value.handle.is_empty() { + bail!("Builtin user cannot have empty password, username, or handle."); } Ok(BuiltinUser { - email: user_email.to_string(), - handle: user_handle.to_string(), - credentials: StoredCredentials::try_from_password(user_password)?, - tier: user_properties[3].parse::()?.try_into()?, + email: value.email.to_owned(), + handle: value.handle.to_owned(), + credentials: StoredCredentials::try_from_password(&value.password)?, + tier: value.tier, }) } } #[cfg(test)] mod tests { - use crate::users::{builtin_user::BuiltinUser, SubscriptionTier}; + use crate::users::{ + builtin_user::{BuiltinUser, BuiltinUserConfig}, + SubscriptionTier, + }; #[test] fn can_parse_builtin_user() -> anyhow::Result<()> { - let parsed_user = BuiltinUser::try_from("su@secutils.dev:password:su_handle:100")?; + let user_config = BuiltinUserConfig { + email: "su@secutils.dev".to_string(), + handle: "su_handle".to_string(), + password: "password".to_string(), + tier: SubscriptionTier::Ultimate, + }; + let parsed_user = BuiltinUser::try_from(&user_config)?; assert_eq!(parsed_user.email, "su@secutils.dev"); assert_eq!(parsed_user.handle, "su_handle"); assert_eq!(parsed_user.tier, SubscriptionTier::Ultimate); @@ -52,7 +55,13 @@ mod tests { .unwrap() .starts_with("$argon2id$v=19$m=19456,t=2,p=1$")); - let parsed_user = BuiltinUser::try_from("su@secutils.dev:password:su_handle:10")?; + let user_config = BuiltinUserConfig { + email: "su@secutils.dev".to_string(), + handle: "su_handle".to_string(), + password: "password".to_string(), + tier: SubscriptionTier::Basic, + }; + let parsed_user = BuiltinUser::try_from(&user_config)?; assert_eq!(parsed_user.email, "su@secutils.dev"); assert_eq!(parsed_user.handle, "su_handle"); assert_eq!(parsed_user.tier, SubscriptionTier::Basic); @@ -67,10 +76,29 @@ mod tests { #[test] fn fails_if_malformed() -> anyhow::Result<()> { - assert!(BuiltinUser::try_from("su@secutils.dev:").is_err()); - assert!(BuiltinUser::try_from("su@secutils.dev").is_err()); - assert!(BuiltinUser::try_from("su@secutils.dev:handle").is_err()); - assert!(BuiltinUser::try_from("su@secutils.dev:handle:").is_err()); + let user_config = BuiltinUserConfig { + email: "su@secutils.dev".to_string(), + handle: "su_handle".to_string(), + password: "".to_string(), + tier: SubscriptionTier::Basic, + }; + assert!(BuiltinUser::try_from(&user_config).is_err()); + + let user_config = BuiltinUserConfig { + email: "".to_string(), + handle: "su_handle".to_string(), + password: "password".to_string(), + tier: SubscriptionTier::Basic, + }; + assert!(BuiltinUser::try_from(&user_config).is_err()); + + let user_config = BuiltinUserConfig { + email: "su@secutils.dev".to_string(), + handle: "".to_string(), + password: "password".to_string(), + tier: SubscriptionTier::Basic, + }; + assert!(BuiltinUser::try_from(&user_config).is_err()); Ok(()) } diff --git a/src/users/builtin_users_initializer.rs b/src/users/builtin_users_initializer.rs deleted file mode 100644 index b85fb4c..0000000 --- a/src/users/builtin_users_initializer.rs +++ /dev/null @@ -1,28 +0,0 @@ -use crate::{ - api::Api, - network::{DnsResolver, EmailTransport}, - users::BuiltinUser, -}; - -pub async fn builtin_users_initializer, DR: DnsResolver, ET: EmailTransport>( - api: &Api, - builtin_users: BU, -) -> anyhow::Result<()> { - log::info!("Initializing builtin users"); - let users = api.users(); - - let mut initialized_builtin_users = 0; - for builtin_user_str in builtin_users.as_ref().split('|') { - users - .upsert_builtin(BuiltinUser::try_from(builtin_user_str)?) - .await?; - initialized_builtin_users += 1; - } - - log::info!( - "Successfully initialized {} builtin users.", - initialized_builtin_users - ); - - Ok(()) -} diff --git a/src/users/user_subscription/subscription_features.rs b/src/users/user_subscription/subscription_features.rs index 963a9d0..f67c971 100644 --- a/src/users/user_subscription/subscription_features.rs +++ b/src/users/user_subscription/subscription_features.rs @@ -73,7 +73,7 @@ mod test { assert!(!features.admin); assert_eq!( features.webhooks_responders.max_script_memory, - config.js_runtime.max_heap_size_bytes + config.js_runtime.max_heap_size ); assert_eq!( features.webhooks_responders.max_script_time, @@ -90,7 +90,7 @@ mod test { assert!(features.admin); assert_eq!( features.webhooks_responders.max_script_memory, - config.js_runtime.max_heap_size_bytes + config.js_runtime.max_heap_size ); assert_eq!( features.webhooks_responders.max_script_time, diff --git a/src/users/user_subscription/subscription_features/webhooks_responders.rs b/src/users/user_subscription/subscription_features/webhooks_responders.rs index 2b77771..3812e9d 100644 --- a/src/users/user_subscription/subscription_features/webhooks_responders.rs +++ b/src/users/user_subscription/subscription_features/webhooks_responders.rs @@ -21,7 +21,7 @@ impl WebhooksRespondersFeature { SubscriptionTier::Standard | SubscriptionTier::Professional | SubscriptionTier::Ultimate => ( - config.js_runtime.max_heap_size_bytes, + config.js_runtime.max_heap_size, config.js_runtime.max_user_script_execution_time, ), }; @@ -79,10 +79,7 @@ mod test { for subscription in subscriptions { let features = WebhooksRespondersFeature::new(&config, subscription); - assert_eq!( - features.max_script_memory, - config.js_runtime.max_heap_size_bytes - ); + assert_eq!(features.max_script_memory, config.js_runtime.max_heap_size); assert_eq!( features.max_script_time, config.js_runtime.max_user_script_execution_time @@ -121,10 +118,7 @@ mod test { for subscription in subscriptions { let features = WebhooksRespondersFeature::new(&config, subscription); - assert_eq!( - features.max_script_memory, - config.js_runtime.max_heap_size_bytes - ); + assert_eq!(features.max_script_memory, config.js_runtime.max_heap_size); assert_eq!( features.max_script_time, config.js_runtime.max_user_script_execution_time