M Cargo.lock => Cargo.lock +48 -1
@@ 1242,6 1242,28 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c8640c5d730cb13ebd907d8d04b52f55ac9a2eec55b440c8892f40d56c76c1d"
[[package]]
+name = "merge"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "10bbef93abb1da61525bbc45eeaff6473a41907d19f8f9aa5168d214e10693e9"
+dependencies = [
+ "merge_derive",
+ "num-traits",
+]
+
+[[package]]
+name = "merge_derive"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "209d075476da2e63b4b29e72a2ef627b840589588e71400a25e3565c4f849d07"
+dependencies = [
+ "proc-macro-error",
+ "proc-macro2",
+ "quote",
+ "syn 1.0.109",
+]
+
+[[package]]
name = "mime"
version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ 1619,6 1641,30 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de"
[[package]]
+name = "proc-macro-error"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c"
+dependencies = [
+ "proc-macro-error-attr",
+ "proc-macro2",
+ "quote",
+ "syn 1.0.109",
+ "version_check",
+]
+
+[[package]]
+name = "proc-macro-error-attr"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "version_check",
+]
+
+[[package]]
name = "proc-macro2"
version = "1.0.81"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ 1833,7 1879,7 @@ dependencies = [
[[package]]
name = "russet"
-version = "0.9.1"
+version = "0.9.2"
dependencies = [
"argon2",
"atom_syndication",
@@ 1845,6 1891,7 @@ dependencies = [
"chrono-tz",
"clap",
"getrandom",
+ "merge",
"reqwest",
"rpassword",
"rss",
M Cargo.toml => Cargo.toml +4 -4
@@ 1,6 1,6 @@
[package]
name = "russet"
-version = "0.9.1"
+version = "0.9.2"
edition = "2021"
license = "AGPL-3.0"
@@ 27,7 27,9 @@ tokio = { version = "1.36", features = ["full"] }
# Database interface
sqlx = { version = "0.7", features = ["sqlite", "migrate", "runtime-tokio-native-tls"] }
-# Used for CLI
+# Configuration (general/config file/CLI)
+merge = "0.1"
+toml = "0.8"
clap = { version = "4.5", features = ["derive"] }
rpassword = "7.3"
@@ 47,8 49,6 @@ sailfish = "0.8"
tracing = { version = "0.1", features = ["log"] }
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
-# Config file
-toml = "0.8"
# Serde is used for all sorts of stuff
serde = "1.0"
D src/cli.rs => src/cli.rs +0 -72
@@ 1,72 0,0 @@
-use clap::{ Parser, Subcommand};
-use std::num::ParseIntError;
-use std::time::Duration;
-
-#[derive(Debug, Parser)]
-#[command(version = crate::VERSION, about, long_about = None)]
-pub struct Cli {
- /// Command
- #[command(subcommand)]
- pub command: Option<Command>,
-
- /// Config file
- #[arg(short, long, value_name = "FILE")]
- pub config_file: Option<String>,
-
- /// Database file
- #[arg(short, long, value_name = "FILE")]
- pub db_file: Option<String>,
-
- /// Listen address
- #[arg(short, long, value_name = "ADDRESS")]
- pub listen_address: Option<String>,
-
- /// Duration between feed checks, in seconds
- #[arg(
- short,
- long,
- value_name = "SECONDS",
- value_parser = |arg: &str| Ok::<Duration, ParseIntError>(
- Duration::from_secs(arg.parse()?)
- )
- )]
- pub feed_check_interval: Option<Duration>,
-}
-
-#[derive(Debug, Subcommand)]
-pub enum Command {
- /// Run the Russet server
- Run,
-
- /// Add a user
- AddUser {
- user_name: String,
- password: Option<String>,
- },
-
- /// Reset a user's password
- SetUserPassword {
- user_name: String,
- password: Option<String>,
- },
-
- /// Delete a user
- DeleteUser {
- user_name: String,
- },
-
- /// Delete all sessions for a user
- DeleteSessions {
- user_name: String,
- },
-
- /// Add a feed by URL
- AddFeed {
- url: String,
- },
-
- /// Remove a feed by URL
- RemoveFeed {
- url: String,
- },
-}
M src/conf.rs => src/conf.rs +84 -11
@@ 1,21 1,56 @@
+use clap::{ Parser, Subcommand};
+use merge::Merge;
use serde::Deserialize;
+use std::num::ParseIntError;
use std::time::Duration;
-#[derive(Deserialize)]
+#[derive(Deserialize, Merge, Parser)]
+#[command(version = crate::VERSION, about, long_about = None)]
#[serde(default)]
pub struct Config {
- pub db_file: String,
- pub listen: String,
- pub pepper: String,
- pub feed_check_interval: Duration,
+ /// Command
+ #[command(subcommand)]
+ pub command: Option<Command>,
+
+ /// Config file
+ #[arg(short, long, value_name = "FILE")]
+ #[serde(skip)] // We're not going to recursively load config files
+ pub config_file: Option<String>,
+
+ /// Database file
+ #[arg(short, long, value_name = "FILE")]
+ pub db_file: Option<String>,
+
+ /// Listen address
+ #[arg(short, long, value_name = "ADDRESS")]
+ pub listen_address: Option<String>,
+
+ /// Pepper for password hashing
+ ///
+ /// (Not exposed on the CLI; let's not enoucrage putting secrets in
+ /// commandlines or shell histories.)
+ pub pepper: Option<String>,
+
+ /// Duration between feed checks, in seconds
+ #[arg(
+ short,
+ long,
+ value_name = "SECONDS",
+ value_parser = |arg: &str| Ok::<Duration, ParseIntError>(
+ Duration::from_secs(arg.parse()?)
+ )
+ )]
+ pub feed_check_interval: Option<Duration>,
}
impl Default for Config {
fn default() -> Self {
Config {
- db_file: "/tmp/russet-db.sqlite".to_string(),
- listen: "127.0.0.1:9892".to_string(),
- pepper: "IzvoEPMQIi82NSXTz7cZ".to_string(),
- feed_check_interval: Duration::from_secs(3_600),
+ command: Some(Command::Run),
+ config_file: None,
+ db_file: Some("/tmp/russet-db.sqlite".to_string()),
+ listen_address: Some("127.0.0.1:9892".to_string()),
+ pepper: Some("IzvoEPMQIi82NSXTz7cZ".to_string()),
+ feed_check_interval: Some(Duration::from_secs(3_600)),
}
}
}
@@ 23,10 58,48 @@ impl std::fmt::Debug for Config {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Config")
.field("db_file", &self.db_file)
- .field("listen", &self.listen)
+ .field("config_file", &self.config_file)
+ .field("listen_address", &self.listen_address)
.field("pepper", &"<redacted>")
- .field("feed_check_interval", &self.feed_check_interval.as_secs())
+ .field("feed_check_interval", &self.feed_check_interval.map(|duration| duration.as_secs()))
.finish()
}
}
+#[derive(Debug, Deserialize, Subcommand)]
+pub enum Command {
+ /// Run the Russet server
+ Run,
+
+ /// Add a user
+ AddUser {
+ user_name: String,
+ password: Option<String>,
+ },
+
+ /// Reset a user's password
+ SetUserPassword {
+ user_name: String,
+ password: Option<String>,
+ },
+
+ /// Delete a user
+ DeleteUser {
+ user_name: String,
+ },
+
+ /// Delete all sessions for a user
+ DeleteSessions {
+ user_name: String,
+ },
+
+ /// Add a feed by URL
+ AddFeed {
+ url: String,
+ },
+
+ /// Remove a feed by URL
+ RemoveFeed {
+ url: String,
+ },
+}
M src/main.rs => src/main.rs +29 -20
@@ 4,7 4,6 @@ extern crate rss;
extern crate sqlx;
extern crate tokio;
-mod cli;
mod conf;
mod domain;
mod feed;
@@ 14,14 13,14 @@ mod server;
mod model;
use clap::Parser;
-use crate::cli::{ Cli, Command };
-use crate::conf::Config;
+use crate::conf::{ Command, Config };
use crate::domain::RussetDomainService;
use crate::feed::atom::AtomFeedReader;
use crate::feed::rss::RssFeedReader;
use crate::feed::RussetFeedReader;
use crate::persistence::sql::SqlDatabase;
use crate::server::start;
+use merge::Merge;
use rpassword::prompt_password;
use std::error::Error;
use std::fs::read_to_string;
@@ 46,19 45,29 @@ pub type Result<T> = std::result::Result<T, Err>;
async fn main() -> Result<()> {
init_tracing();
- let cli = Cli::parse();
+ // Hierarchy of configs
+ let config = {
+ // Commandline flags override all
+ let mut config = Config::parse();
- let config = match cli.config_file {
- Some(file_name) => {
- let s = read_to_string(file_name)?;
- toml::from_str(&s)?
- },
- None => Config::default(),
+ // If present, load config
+ if let Some(&ref config_file) = config.config_file.as_ref() {
+ let s = read_to_string(config_file)?;
+ let file_config = toml::from_str(&s)?;
+ config.merge(file_config);
+ }
+
+ // Finally, load defaults
+ config.merge(Config::default());
+
+ config
};
- let db_file = cli.db_file.unwrap_or(config.db_file);
- let listen_address = cli.listen_address.unwrap_or(config.listen);
- let feed_check_interval = cli.feed_check_interval.unwrap_or(config.feed_check_interval);
+ let command = config.command.expect("No command");
+ let db_file = config.db_file.expect("No db_file");
+ let listen_address = config.listen_address.expect("No listen_address");
+ let pepper = config.pepper.expect("No pepper");
+ let feed_check_interval = config.feed_check_interval.expect("No feed_check_interval");
let db = SqlDatabase::new(Path::new(&db_file)).await?;
let readers: Vec<Box<dyn RussetFeedReader>> = vec![
@@ 68,13 77,13 @@ async fn main() -> Result<()> {
let domain_service = Arc::new(RussetDomainService::new(
db,
readers,
- config.pepper.as_bytes().to_vec(),
+ pepper.as_bytes().to_vec(),
feed_check_interval,
)?);
- match cli.command {
- None | Some(Command::Run) => start(domain_service, listen_address).await?,
- Some(Command::AddUser { user_name, password }) => {
+ match command {
+ Command::Run => start(domain_service, listen_address).await?,
+ Command::AddUser { user_name, password } => {
info!("Adding user {user_name}…");
let plaintext_password = match password {
Some(password) => password,
@@ 82,7 91,7 @@ async fn main() -> Result<()> {
};
domain_service.add_user(&user_name, &plaintext_password).await?;
},
- Some(Command::SetUserPassword { user_name, password }) => {
+ Command::SetUserPassword { user_name, password } => {
info!("Setting password for user {user_name}…");
let plaintext_password = match password {
Some(password) => password,
@@ 92,11 101,11 @@ async fn main() -> Result<()> {
.set_user_password(&user_name, &plaintext_password)
.await?;
},
- Some(Command::DeleteUser { user_name }) => {
+ Command::DeleteUser { user_name } => {
info!("Deleting user {user_name}…");
domain_service.delete_user(&user_name).await?;
}
- Some(Command::DeleteSessions { user_name }) => {
+ Command::DeleteSessions { user_name } => {
info!("Delete sessions for {user_name}…");
domain_service.delete_user_sessions(&user_name).await?;
}