Add bare config file

This commit is contained in:
Wim Velzeboer
2026-09-07 09:59:59 +01:00
parent a240e95adb
commit fb5e462564
11 changed files with 4348 additions and 47 deletions
Generated
+3734 -3
View File
File diff suppressed because it is too large Load Diff
+18 -1
View File
@@ -1,9 +1,26 @@
[package]
name = "imojefs"
description = "Johnson & Johnson IMoje File Sync"
description = "Johnson & Johnson IMoje CLI"
authors = ["Wim Velzeboer <wim@velzeboer.nl>"]
version = "0.0.1"
edition = "2024"
[dependencies]
anyhow = "1.0.104"
aes-gcm = "0.10"
aws-config = { version = "1.1.7", features = ["behavior-version-latest"] }
aws-credential-types = "1.2.14"
aws-sdk-s3 = "1.132.0"
base64 = "0.22"
clap = { version = "4.6.1", features = ["derive", "env"] }
dirs = "6.0"
env_logger = "0.11.10"
lazy_static = "1.5.0"
log = "0.4"
rand = "0.8"
rustsf = { path = "../rustsf" }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0.149"
signal-hook = "0.4.4"
tokio = { version = "1", features = ["full"] }
toml = "0.9"
+8
View File
@@ -8,3 +8,11 @@
|___\____|__ /\____/\__| |\___ >___ / /_______ /
\/ \______| \/ \/ \/
```
## Design
This CLI tool extracts binary file contents from Salesforce to S3.
It leave a link with the S3 object-key in Salesforce,
so that a pre-signed url can be generated whenever needed.
The tool is deigned to run at certain intervals,
e.g. twice daily, and should be scheduled in the standard crontab in Linux
+34
View File
@@ -0,0 +1,34 @@
use aws_config::Region;
use aws_credential_types::provider::SharedCredentialsProvider;
pub(crate) async fn connect(
client_id: &str,
client_secret: &str,
custom_endpoint: Option<&str>,
region: &str) -> aws_sdk_s3::Client {
let creds = SharedCredentialsProvider::new(aws_credential_types::Credentials::new(
client_id, client_secret, None, None, "Static",
));
let config = match custom_endpoint {
Some(endpoint) => {
aws_config::from_env()
.region(Region::new(region.to_string()))
.endpoint_url(endpoint)
.credentials_provider(creds)
.load()
.await
}
None => {
aws_config::from_env()
.region(Region::new(region.to_string()))
.credentials_provider(creds)
.load()
.await
}
};
aws_sdk_s3::Client::new(&config)
}
+142 -39
View File
@@ -1,49 +1,122 @@
use crate::logger;
use anyhow::{Context, Result};
use clap::{Parser, ValueEnum};
use log::error;
#[derive(Parser, Debug)]
#[command(version, about, long_about = None)]
struct Cli {
/// Number of seconds to wait
#[arg(short = 'w', long, default_value_t = 20, env = "IMOJEFS_WAIT")]
pub wait: u64,
/// The logging level
#[arg(short = 'l', long, value_enum, default_value_t = LogLevel::Info, env = "IMOJEFS_LOG_LEVEL")]
pub log_level: LogLevel,
/// The configuration to use
#[arg(short = 'c', long, default_value_t = String::from("imoje"))]
config: String,
#[command(subcommand)]
command: Option<Commands>,
}
#[derive(clap::Subcommand, Debug)]
enum Commands {
/// Controls testing features
FileSync,
/// Change Settings
Set {
#[command(subcommand)]
command: Option<SettingsCommands>,
},
}
#[derive(clap::Subcommand, Debug)]
enum SettingsCommands {
/// Set the Salesforce Authentication Url
SfdxAuthUrl {
/// Salesforce Authentication Url
#[arg(
short = 'x',
long,
env = "IMOJEEH_SFDX_AUTH_URL",
hide_env_values = true
)]
url: String,
},
}
#[derive(Parser, Debug)]
#[command(version, about, long_about = None)]
pub struct Args {
/// AWS Bucket name
#[arg(short = 'b', long, env = "IMOJEFS_S3_BUCKET")]
pub s3_bucket: String,
/// AWS Bucket name
#[arg(short = 'b', long, env = "IMOJEFS_S3_BUCKET")]
pub s3_bucket: String,
/// AWS Access Key Id
#[arg(short = 'k', long, env = "IMOJEFS_S3_CLIENT_ID")]
pub s3_client_id: String,
/// AWS Access Key Id
#[arg(short = 'k', long, env = "IMOJEFS_S3_CLIENT_ID")]
pub s3_key: String,
/// AWS Secret Access Key
#[arg(
short = 's',
long,
env = "IMOJEFS_S3_CLIENT_SECRET",
hide_env_values = true
)]
pub s3_client_secret: String,
/// AWS Secret Access Key
#[arg(short = 's', long, env = "IMOJEFS_S3_SECRET", hide_env_values = true)]
pub s3_secret: String,
/// AWS Endpoint URL, [default: AWS url]
#[arg(short = 'e', long, env = "IMOJEFS_S3_ENDPOINT")]
pub s3_endpoint: Option<String>,
/// AWS Endpoint URL, [default: AWS url]
#[arg(short = 'e', long, env = "IMOJEFS_S3_ENDPOINT")]
pub s3_endpoint: Option<String>,
/// AWS Region
#[arg(
short = 'r',
long,
default_value = "us-east-1",
env = "IMOJEFS_S3_REGION"
)]
pub s3_region: String,
/// AWS Region
#[arg(short = 'r', long, default_value = "us-east-1", env = "IMOJEFS_S3_REGION")]
pub s3_region: String,
/// Salesforce Authentication Url
#[arg(
short = 'x',
long,
env = "IMOJEFS_SFDX_AUTH_URL",
hide_env_values = true
)]
pub sfdx_auth_url: String,
/// Salesforce Authentication Url
#[arg(short = 'x', long, env = "IMOJEFS_SFDX_AUTH_URL", hide_env_values = true)]
pub sfdx_auth_url: String,
/// Retry failures after X runs
#[arg(long, default_value_t = 180, env = "IMOJEFS_RETRY")]
pub retry: u64,
/// Number of seconds to wait
#[arg(short = 'w', long, default_value_t = 20, env = "IMOJEFS_WAIT")]
pub wait: u64,
/// A comma separated list of fallback email address,
/// used when Salesforce cannot be reached to deliver log messages
#[arg(short = 'a', long, env = "IMOJEFS_ADMIN_EMAIL")]
pub admin_email: Option<String>,
}
/// The logging level
#[arg(short = 'l', long, value_enum, default_value_t = LogLevel::Info, env = "IMOJEFS_LOG_LEVEL")]
pub log_level: LogLevel,
/// Retry failures after X runs
#[arg(long, default_value_t = 180, env = "IMOJEFS_RETRY")]
pub retry: u64,
/// A comma separated list of fallback email address,
/// used when Salesforce cannot be reached to deliver log messages
#[arg(short = 'a', long, env = "IMOJEFS_ADMIN_EMAIL")]
pub admin_email: Option<String>,
impl Args {
pub(crate) fn s3_bucket(&self) -> &str {
&self.s3_bucket
}
pub(crate) fn s3_client_id(&self) -> &str {
&self.s3_client_id
}
pub(crate) fn s3_client_secret(&self) -> &str {
&self.s3_client_secret
}
pub(crate) fn s3_endpoint(&self) -> Option<&str> {
self.s3_endpoint.as_deref()
}
pub(crate) fn s3_region(&self) -> &str {
&self.s3_region
}
}
/// Represents the different levels of logging that can be used in an application.
@@ -58,9 +131,39 @@ pub struct Args {
/// as well as `Clone` and `Debug` for efficient copying and debugging purposes.
#[derive(ValueEnum, Clone, Debug)]
pub enum LogLevel {
Debug,
Info,
Warning,
Error,
Trace,
}
Debug,
Info,
Warning,
Error,
Trace,
}
fn load_configuration_file(config_file: &str) -> Result<()> {
let app_config =
crate::config::load(config_file).context("Failed to load application configuration")?;
crate::APP_CONFIG
.set(app_config)
.map_err(|_| anyhow::anyhow!("Application configuration has already been initialized"))?;
Ok(())
}
pub(crate) fn run() {
let cli = Cli::parse();
logger::init(&cli.log_level);
match load_configuration_file(&cli.config) {
Ok(()) => {}
Err(e) => {
error!("Error loading configuration file: {:#}", e);
std::process::exit(1);
}
}
match cli.command {
Some(subcommand) => match subcommand {
Commands::FileSync { .. } => {}
Commands::Set { .. } => {}
},
None => {}
}
}
+127
View File
@@ -0,0 +1,127 @@
use std::fs;
use std::path::PathBuf;
use aes_gcm::aead::{Aead, KeyInit, OsRng};
use aes_gcm::{Aes256Gcm, Nonce};
use anyhow::{anyhow, Context, Result};
use base64::engine::general_purpose::STANDARD as BASE64;
use base64::Engine;
use rand::RngCore;
use serde::{Deserialize, Serialize};
const CONFIG_KEY_ENV: &str = "IMOJE_CONFIG_KEY";
const CONFIG_DIR: &str = "config/imoje";
const NONCE_LEN: usize = 12;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppConfig {
pub dummy_setting: String,
}
impl Default for AppConfig {
fn default() -> Self {
Self {
dummy_setting: "dummy-value".to_string(),
}
}
}
pub fn load(config_file: &str) -> Result<AppConfig> {
let config_path = config_path(config_file)?;
if !config_path.exists() {
let default_config = AppConfig::default();
save_encrypted(&config_path, &default_config)
.context("Unable to create encrypted default configuration file")?;
return Ok(default_config);
}
let encrypted_config = fs::read_to_string(&config_path)
.with_context(|| format!("Unable to read configuration file {}", config_path.display()))?;
decrypt_config(&encrypted_config)
.with_context(|| format!("Unable to decrypt configuration file {}", config_path.display()))
}
fn config_path(config_file: &str) -> Result<PathBuf> {
let home_dir = dirs::home_dir().ok_or_else(|| anyhow!("Unable to determine home directory"))?;
Ok(home_dir.join(CONFIG_DIR).join(format!("{}.conf", config_file)))
}
fn save_encrypted(config_path: &PathBuf, config: &AppConfig) -> Result<()> {
if let Some(parent) = config_path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("Unable to create configuration directory {}", parent.display()))?;
}
let serialized_config = toml::to_string_pretty(config)
.context("Unable to serialize default configuration")?;
let encrypted_config = encrypt_config(&serialized_config)
.context("Unable to encrypt default configuration")?;
fs::write(config_path, encrypted_config)
.with_context(|| format!("Unable to write configuration file {}", config_path.display()))?;
Ok(())
}
fn encrypt_config(config: &str) -> Result<String> {
let key = config_key()?;
let cipher = Aes256Gcm::new_from_slice(&key)
.map_err(|_| anyhow!("Invalid encryption key length"))?;
let mut nonce_bytes = [0_u8; NONCE_LEN];
OsRng.fill_bytes(&mut nonce_bytes);
let ciphertext = cipher
.encrypt(Nonce::from_slice(&nonce_bytes), config.as_bytes())
.map_err(|error| anyhow!("Unable to encrypt configuration: {error}"))?;
let mut encrypted_payload = Vec::with_capacity(NONCE_LEN + ciphertext.len());
encrypted_payload.extend_from_slice(&nonce_bytes);
encrypted_payload.extend_from_slice(&ciphertext);
Ok(BASE64.encode(encrypted_payload))
}
fn decrypt_config(encrypted_config: &str) -> Result<AppConfig> {
let encrypted_payload = BASE64
.decode(encrypted_config.trim())
.context("Configuration file is not valid base64")?;
if encrypted_payload.len() <= NONCE_LEN {
return Err(anyhow!("Configuration file is too short"));
}
let key = config_key()?;
let cipher = Aes256Gcm::new_from_slice(&key)
.map_err(|_| anyhow!("Invalid encryption key length"))?;
let nonce = Nonce::from_slice(&encrypted_payload[..NONCE_LEN]);
let ciphertext = &encrypted_payload[NONCE_LEN..];
let plaintext = cipher
.decrypt(nonce, ciphertext)
.map_err(|error| anyhow!("Unable to decrypt configuration: {error}"))?;
let plaintext = String::from_utf8(plaintext)
.context("Configuration file does not contain valid UTF-8")?;
toml::from_str(&plaintext).context("Unable to parse decrypted configuration")
}
fn config_key() -> Result<[u8; 32]> {
let encoded_key = std::env::var(CONFIG_KEY_ENV)
.unwrap_or("ihDlaCqJqUGJ1am4dT9k/BHRG2UT+iK9IfYcPe0Db/I=".to_string());
// .with_context(|| format!("Missing required environment variable {CONFIG_KEY_ENV}"))?;
let key = BASE64
.decode(encoded_key.trim())
.with_context(|| format!("{CONFIG_KEY_ENV} must be a base64-encoded 32-byte key"))?;
key.try_into()
.map_err(|_| anyhow!("{CONFIG_KEY_ENV} must decode to exactly 32 bytes"))
}
+37
View File
@@ -0,0 +1,37 @@
use crate::cli::LogLevel;
use log::{LevelFilter, info};
pub(crate) fn init(log_level: &LogLevel) {
let filter_level = match log_level {
LogLevel::Info => LevelFilter::Info,
LogLevel::Debug => LevelFilter::Debug,
LogLevel::Warning => LevelFilter::Warn,
LogLevel::Error => LevelFilter::Error,
LogLevel::Trace => LevelFilter::Trace,
};
env_logger::builder()
.filter_level(filter_level)
.format_target(false)
.init();
welcome_message()
}
fn welcome_message() {
let authors = match option_env!("CARGO_PKG_AUTHORS") {
Some(authors_str) => {
", by ".to_string() + &authors_str.split(':').collect::<Vec<&str>>().join(", ")
}
None => "".to_string(),
};
let description = env!("CARGO_PKG_DESCRIPTION");
let version = env!("CARGO_PKG_VERSION");
info!("Starting {}, v{}{}", description, version, authors);
info!(r#".___ _____ __ _________ .____ .___"#);
info!(r#"| | / \ ____ |__| ____ \_ ___ \| | | |"#);
info!(r#"| |/ \ / \ / _ \ | |/ __ \/ \ \/| | | |"#);
info!(r#"| / Y ( <_> ) | \ ___/\ \___| |___| |"#);
info!(r#"|___\____|__ /\____/\__| |\___ >\______ /_______ \___|"#);
info!(r#") \/ \______| \/ \/ \/"#);
}
+47 -4
View File
@@ -1,14 +1,57 @@
//! # IMoje File Sync
//!
//! Syncs the contents of a Salesforce File to S3
use anyhow::Result;
use clap::Parser;
mod cli;
use std::sync::OnceLock;
fn main() {
mod aws_s3;
mod cli;
mod config;
mod logger;
mod salesforce;
mod terminator;
pub static APP_CONFIG: OnceLock<config::AppConfig> = OnceLock::new();
#[::tokio::main]
async fn main() {
cli::run();
/*
let args = cli::Args::parse();
println!("Hello, world!");
let salesforce_client = salesforce::connect(&args.sfdx_auth_url).await?;
let s3_client = aws_s3::connect(
args.s3_client_id(),
args.s3_client_secret(),
args.s3_endpoint(),
args.s3_region(),
);
salesforce_client.query(r#"SELECT Id, Title, FileType, FileExtension, Description,
ContentLocation, Origin, VersionData, ContentDocumentId, FirstPublishLocationId, PathOnClient
FROM ContentVersion
WHERE IsMajorVersion = TRUE
AND ContentLocation = 'S'
LIMIT 1\
"#)
if terminator::CONFIG.is_fired() {
info!("Received termination signal, exiting...");
}
info!("Done.");*/
}
+15
View File
@@ -0,0 +1,15 @@
use anyhow::{Context, Result};
use rustsf::{RestApi, AuthUrl};
pub(crate) mod models;
pub async fn connect(sfdx_auth_url: &str) -> Result<RestApi> {
let credentials = AuthUrl::new(sfdx_auth_url.to_string())
.context("Incorrect SFDX_AUTH_URL")?;
let client = rustsf::Client::new(credentials).await
.context("Unable to connect to Salesforce")?;
Ok(RestApi::new(client))
}
+154
View File
@@ -0,0 +1,154 @@
use rustsf::DefSObject;
#[DefSObject(sobject_type = "ContentVersion", fields="owner,audit,system")]
pub(crate) struct ContentVersion {
/// The ID of the ContentDocument associated with this version.
#[serde(rename = "ContentDocumentId")]
content_document_id: Option<String>,
/// The location where the content is stored. Valid values include Salesforce, external, or linked content locations.
#[serde(rename = "ContentLocation")]
content_location: Option<String>,
/// The date and time when the file content was last modified.
#[serde(rename = "ContentModifiedDate")]
content_modified_date: Option<String>,
/// The ID of the user who last modified the file content.
#[serde(rename = "ContentModifiedById")]
content_modified_by_id: Option<String>,
/// The URL of the content when the file is stored outside Salesforce.
#[serde(rename = "ContentUrl")]
content_url: Option<String>,
/// The version number of the file.
#[serde(rename = "VersionNumber")]
version_number: Option<String>,
/// The title of the file.
#[serde(rename = "Title")]
title: Option<String>,
/// The description of the file.
#[serde(rename = "Description")]
description: Option<String>,
/// The reason this version of the file was created or changed.
#[serde(rename = "ReasonForChange")]
reason_for_change: Option<String>,
/// Controls whether users can share the file with other users, groups, or records.
#[serde(rename = "SharingOption")]
sharing_option: Option<String>,
/// Controls file privacy on records.
#[serde(rename = "SharingPrivacy")]
sharing_privacy: Option<String>,
/// The complete path of the file on the client machine when it was uploaded.
#[serde(rename = "PathOnClient")]
path_on_client: Option<String>,
/// Indicates whether this version is the latest version of the file.
#[serde(rename = "IsLatest")]
is_latest: bool,
/// Indicates whether this version is a major version.
#[serde(rename = "IsMajorVersion")]
is_major_version: bool,
/// Indicates whether the file can be used as an asset file.
#[serde(rename = "IsAssetEnabled")]
is_asset_enabled: bool,
/// Indicates whether the file is managed by an external client.
#[serde(rename = "IsClientManaged")]
is_client_managed: bool,
/// Indicates whether the file is available externally.
#[serde(rename = "IsExternallyAvailable")]
is_externally_available: bool,
/// The ID of the external data source for externally stored content.
#[serde(rename = "ExternalDataSourceId")]
external_data_source_id: Option<String>,
/// External document information used to identify externally stored content.
#[serde(rename = "ExternalDocumentInfo1")]
external_document_info1: Option<String>,
/// Additional external document information used to identify externally stored content.
#[serde(rename = "ExternalDocumentInfo2")]
external_document_info2: Option<String>,
/// The boost value used to promote featured content.
#[serde(rename = "FeaturedContentBoost")]
featured_content_boost: Option<i64>,
/// The date when the file was marked as featured content.
#[serde(rename = "FeaturedContentDate")]
featured_content_date: Option<String>,
/// The file extension of the uploaded file.
#[serde(rename = "FileExtension")]
file_extension: Option<String>,
/// The type of the file, such as PDF, PNG, WORD, or LINK.
#[serde(rename = "FileType")]
file_type: Option<String>,
/// The ID of the first location where the file was published.
#[serde(rename = "FirstPublishLocationId")]
first_publish_location_id: Option<String>,
/// The source from which the content originated.
#[serde(rename = "Origin")]
origin: Option<String>,
/// The ID of the Experience Cloud site or network associated with the file.
#[serde(rename = "NetworkId")]
network_id: Option<String>,
/// The number of positive ratings submitted for this file version.
#[serde(rename = "PositiveRatingCount")]
positive_rating_count: Option<i64>,
/// The publishing status of the file version.
#[serde(rename = "PublishStatus")]
publish_status: Option<String>,
/// A comma-separated list of tags associated with the file.
#[serde(rename = "TagCsv")]
tag_csv: Option<String>,
/// A text preview of the file content.
#[serde(rename = "TextPreview")]
text_preview: Option<String>,
/// The body of the file, represented as base64-encoded content when retrieved through the API.
#[serde(rename = "VersionData")]
version_data: Option<String>,
/// The size of the file in bytes.
#[serde(rename = "ContentSize")]
content_size: Option<i64>,
/// MD5 checksum for the file.
#[serde(rename = "Checksum")]
checksum: Option<String>,
/// The ID of the ContentBody record that stores the binary file data.
#[serde(rename = "ContentBodyId")]
content_body_id: Option<String>,
/// The ID of the user who created the file version.
#[serde(rename = "CreatedById")]
created_by_id: String,
/// The date and time when the file version was created.
#[serde(rename = "CreatedDate")]
created_date: String,
}
+32
View File
@@ -0,0 +1,32 @@
use lazy_static::lazy_static;
use signal_hook::consts::TERM_SIGNALS;
use signal_hook::flag;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
lazy_static! {
pub static ref CONFIG: Terminator = Terminator::new();
}
pub struct Terminator {
term: Arc<AtomicBool>,
}
impl Terminator {
pub(crate) fn is_fired(&self) -> bool {
!self.term.load(Ordering::Relaxed)
}
}
impl Terminator {
pub fn new() -> Self {
let term_now = Arc::new(AtomicBool::new(false));
// Register signals to set the flag to true
for sig in TERM_SIGNALS {
flag::register(*sig, Arc::clone(&term_now)).unwrap();
}
Self { term: term_now }
}
}