use std::sync::Arc;
use clap::{Parser, Subcommand};
use allowthem_core::applications::BrandingConfig;
use allowthem_core::{AccentInk, AllowThemBuilder, Email, EmbeddedAuthClient, LogEmailSender};
use allowthem_server::AllRoutesBuilder;
use sendword::config::AppConfig;
#[derive(Parser)]
#[command(name = "sendword", about = "HTTP webhook to command runner sidecar")]
struct Cli {
#[command(subcommand)]
command: Option<Command>,
}
#[derive(Subcommand)]
enum Command {
Serve,
Export,
Import {
path: std::path::PathBuf,
},
User {
#[command(subcommand)]
action: UserAction,
},
Backup {
#[command(subcommand)]
action: BackupAction,
},
Restore {
#[arg(long)]
from: String,
#[arg(long, default_value = "restored")]
output: std::path::PathBuf,
},
}
#[derive(Subcommand)]
enum UserAction {
Create {
#[arg(long)]
email: String,
},
}
#[derive(Subcommand)]
enum BackupAction {
Create,
List,
}
#[tokio::main]
async fn main() -> eyre::Result<()> {
let cli = Cli::parse();
match cli.command {
None | Some(Command::Serve) => serve().await,
Some(Command::Export) => config_export().await,
Some(Command::Import { path }) => config_import(&path).await,
Some(Command::User { action }) => match action {
UserAction::Create { email } => user_create(&email).await,
},
Some(Command::Backup { action }) => match action {
BackupAction::Create => backup_create().await,
BackupAction::List => backup_list().await,
},
Some(Command::Restore { from, output }) => backup_restore(&from, &output).await,
}
}
async fn csrf_key_or_create() -> eyre::Result<[u8; 32]> {
let path = std::path::Path::new("data/csrf_key");
if path.exists() {
let bytes = tokio::fs::read(path).await?;
let key: [u8; 32] = bytes
.try_into()
.map_err(|v: Vec<u8>| eyre::eyre!("csrf_key file is {} bytes, expected 32", v.len()))?;
return Ok(key);
}
let key: [u8; 32] = rand::random();
tokio::fs::write(path, key).await?;
Ok(key)
}
async fn serve() -> eyre::Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "sendword=debug,tower_http=debug".parse().unwrap()),
)
.init();
let config = AppConfig::load()?;
tracing::info!(
bind = %config.server.bind,
port = config.server.port,
hooks = config.hooks.len(),
"config loaded"
);
tokio::fs::create_dir_all(&config.scripts.dir).await?;
tracing::info!(dir = %config.scripts.dir, "scripts directory ready");
let db = sendword::db::Db::new(&config.database).await?;
db.migrate().await?;
tracing::info!("database ready");
sendword::barriers::recover_barriers(db.pool()).await;
tracing::info!("barrier state recovered");
let csrf_key = csrf_key_or_create().await?;
tracing::info!("CSRF key ready");
let email_sender: Box<dyn allowthem_core::EmailSender> = match &config.auth.smtp {
Some(smtp_config) => {
let sender = sendword::email::SmtpEmailSender::new(smtp_config)?;
tracing::info!("SMTP email sender configured");
Box::new(sender)
}
None => {
tracing::info!("no SMTP config — using log email sender");
Box::new(LogEmailSender)
}
};
let session_ttl = chrono::Duration::from_std(config.auth.session_lifetime)
.unwrap_or(chrono::Duration::hours(24));
let mut ath_builder = AllowThemBuilder::with_pool(db.pool().clone())
.session_ttl(session_ttl)
.cookie_secure(config.auth.secure_cookie)
.csrf_key(csrf_key)
.email_sender(email_sender);
if let Some(base_url) = &config.auth.base_url {
ath_builder = ath_builder.base_url(base_url.clone());
}
let ath = ath_builder.build().await?;
let auth_client = Arc::new(EmbeddedAuthClient::new(ath.clone(), "/login"));
tracing::info!("allowthem auth ready");
let branding = BrandingConfig::new("sendword").with_accent("#cba6f7", AccentInk::Black);
let mut auth_routes_builder = AllRoutesBuilder::new()
.login()
.logout()
.settings()
.default_branding(branding);
if let Some(base_url) = &config.auth.base_url {
auth_routes_builder = auth_routes_builder
.password_reset()
.base_url(base_url.clone());
}
let auth_router = auth_routes_builder.build(&ath)?;
tracing::info!("allowthem auth routes ready");
let templates =
sendword::templates::Templates::new(sendword::templates::Templates::default_dir());
tracing::info!("templates loaded");
let state =
sendword::server::AppState::new(config, "sendword.toml", db, templates, ath, auth_client);
let _rate_limit_sweep = sendword::tasks::spawn_rate_limit_sweep(state.db.pool().clone());
tracing::info!("rate limit sweep task started");
let _approval_sweep = sendword::tasks::spawn_approval_sweep(
state.db.pool().clone(),
std::sync::Arc::clone(&state),
);
tracing::info!("approval sweep task started");
if state
.config
.load()
.backup
.as_ref()
.and_then(|b| b.schedule.as_ref())
.is_some()
{
let _backup_scheduler =
sendword::backup::scheduler::spawn_backup_scheduler(std::sync::Arc::clone(&state));
tracing::info!("backup scheduler started");
}
sendword::server::run(state, auth_router).await?;
Ok(())
}
async fn config_export() -> eyre::Result<()> {
let config = AppConfig::load()?;
let json = serde_json::to_string_pretty(&config)?;
println!("{json}");
Ok(())
}
async fn config_import(path: &std::path::Path) -> eyre::Result<()> {
let contents = std::fs::read_to_string(path)?;
let config: AppConfig = serde_json::from_str(&contents)?;
if let Err(e) = config.validate() {
eprintln!("error: {e}");
std::process::exit(1);
}
let toml_str = toml_edit::ser::to_string_pretty(&config)?;
std::fs::write("sendword.toml", toml_str.as_bytes())?;
eprintln!("config imported and written to sendword.toml");
Ok(())
}
async fn user_create(email_str: &str) -> eyre::Result<()> {
let email = match Email::new(email_str.to_owned()) {
Ok(e) => e,
Err(_) => {
eprintln!("error: invalid email address");
std::process::exit(1);
}
};
let password = rpassword::prompt_password("Password: ")?;
if password.is_empty() {
eprintln!("error: password must not be empty");
std::process::exit(1);
}
let confirm = rpassword::prompt_password("Confirm password: ")?;
if password != confirm {
eprintln!("error: passwords do not match");
std::process::exit(1);
}
let config = AppConfig::load()?;
let db = sendword::db::Db::new(&config.database).await?;
db.migrate().await?;
let ath = AllowThemBuilder::with_pool(db.pool().clone())
.build()
.await?;
match ath.db().create_user(email, &password, None, None).await {
Ok(user) => {
eprintln!("user '{}' created (id: {})", user.email.as_str(), user.id);
}
Err(allowthem_core::AuthError::Conflict(msg)) => {
eprintln!("error: {msg}");
std::process::exit(1);
}
Err(e) => {
return Err(e.into());
}
}
Ok(())
}
async fn backup_create() -> eyre::Result<()> {
let config = AppConfig::load()?;
let backup_config = config
.backup
.as_ref()
.ok_or_else(|| eyre::eyre!("backup is not configured in sendword.toml"))?;
let db = sendword::db::Db::new(&config.database).await?;
db.migrate().await?;
let config_path = std::path::Path::new("sendword.toml");
match sendword::backup::create_backup(db.pool(), backup_config, config_path).await {
Ok(key) => {
eprintln!("backup created: {key}");
}
Err(e) => {
eprintln!("error: {e}");
std::process::exit(1);
}
}
Ok(())
}
async fn backup_list() -> eyre::Result<()> {
let config = AppConfig::load()?;
let backup_config = config
.backup
.as_ref()
.ok_or_else(|| eyre::eyre!("backup is not configured in sendword.toml"))?;
match sendword::backup::list_backups(backup_config).await {
Ok(entries) => {
if entries.is_empty() {
eprintln!("no backups found");
} else {
for entry in &entries {
println!("{}\t{}\t{}", entry.last_modified, entry.size, entry.key);
}
}
}
Err(e) => {
eprintln!("error: {e}");
std::process::exit(1);
}
}
Ok(())
}
async fn backup_restore(key: &str, output: &std::path::Path) -> eyre::Result<()> {
let config = AppConfig::load()?;
let backup_config = config
.backup
.as_ref()
.ok_or_else(|| eyre::eyre!("backup is not configured in sendword.toml"))?;
match sendword::backup::restore_backup(backup_config, key, output).await {
Ok(()) => {
eprintln!("backup extracted to: {}", output.display());
eprintln!(
"apply manually: copy sendword.toml and sendword.db from the output directory"
);
}
Err(e) => {
eprintln!("error: {e}");
std::process::exit(1);
}
}
Ok(())
}