~whbboyd/russet

7c0f1e1846570e3ebb22431d1e005d3d502fc0d3 — Will Boyd 5 months ago 9ad437d
Combined configuration class for cli and config file
5 files changed, 165 insertions(+), 108 deletions(-)

M Cargo.lock
M Cargo.toml
D src/cli.rs
M src/conf.rs
M src/main.rs
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?;
		}