//! The `pilcrow` command-line interface. //! //! This module supports running `pilcrow` as a freestanding program, via the //! [`Args`] struct. use std::{future, str}; use axum::{ http::header, middleware, response::{IntoResponse, Response}, }; use clap::{CommandFactory, Parser, Subcommand}; use sqlx::sqlite::SqlitePool; use tokio::net; use web_push::{IsahcWebPushClient, WebPushClient}; pub use crate::exit::Exit; use crate::{ app::App, clock, db, error::failed::{Failed, ResultExt as _}, routes, umask::Umask, }; /// Command-line entry point for running the `pilcrow` server. /// /// This is intended to be used as a Clap [Parser], to capture command-line /// arguments for the `pilcrow` server: /// /// ```no_run /// # #[tokio::main] /// # async fn main() -> Result<(), Box> { /// use clap::Parser; /// use pilcrow::cli::Args; /// /// let args = Args::parse(); /// args.run().await?; /// # Ok(()) /// # } /// ``` #[derive(Parser)] #[command( version, about = "Run the `pilcrow` server.", long_about = r#"Run the `pilcrow` server. The database at `--database-url` will be created, or upgraded, automatically."# )] pub struct Args { /// The network address `pilcrow` should listen on #[arg(short, long, env, default_value = "localhost")] address: String, /// The network port `pilcrow` should listen on #[arg(short, long, env, default_value_t = 64209)] port: u16, /// The umask pilcrow should run under (octal, `inherit`, or `masked`) #[arg(short = 'U', long, default_value_t = Umask::Masked)] umask: Umask, /// Sqlite URL or path for the `pilcrow` database #[arg(short, long, env, default_value = "sqlite://pilcrow.db")] database_url: String, /// Sqlite URL or path for a backup of the `pilcrow` database during /// upgrades #[arg(short = 'D', long, env, default_value = "sqlite://pilcrow.db.backup")] backup_database_url: String, #[command(subcommand)] command: Option, } #[derive(Subcommand)] enum Command { /// Immediately rotate the server's VAPID (Web Push) application key. RotateVapidKey, } impl Args { /// Runs the `pilcrow` server, using the parsed configuation in `self`. /// /// This will perform the following tasks: /// /// * Set the process' umask (as specified by `--umask`). /// * Migrate the `pilcrow` database (at `--database-url`). /// * Start an HTTP server (on the interface and port controlled by /// `--address` and `--port`). /// * Print a status message. /// * Wait for that server to shut down. /// /// # Errors /// /// Will return `Err` if the server is unable to start, or terminates /// prematurely. The returned error contains a user-facing explanation /// of the failure. pub async fn run(self) -> Result<(), impl std::error::Error> { self.umask.set(); let pool = self.pool().await.fail("Failed to create database pool")?; let webpush = IsahcWebPushClient::new().fail("Failed to create web push publisher")?; let app = App::from(pool, webpush); match self.command { None => self.serve(app).await?, Some(Command::RotateVapidKey) => app .vapid() .rotate_key() .await .fail("Failed to rotate VAPID key")?, } Result::<_, Failed>::Ok(()) } async fn serve

(self, app: App

) -> Result<(), Failed> where P: WebPushClient + Clone + Send + Sync + 'static, { let app = routes::routes(&app) .route_layer(middleware::from_fn(clock::middleware)) .route_layer(middleware::map_response(Self::server_info())) .with_state(app); let listen_addr = self.listen_addr(); let listener = net::TcpListener::bind(&listen_addr).await.fail_with(|| { format!( "Failed to bind to {host}:{port}", host = listen_addr.0, port = listen_addr.1 ) })?; let started_msg = started_msg(&listen_addr); let serve = axum::serve(listener, app); println!("{started_msg}"); serve.await.fail("Failed to serve application")?; Ok(()) } fn listen_addr(&self) -> (String, u16) { (self.address.clone(), self.port) } async fn pool(&self) -> Result { db::prepare(&self.database_url, &self.backup_database_url).await } fn server_info() -> impl Clone + Fn(Response) -> future::Ready { let command = Self::command(); let name = command.get_name(); let version = command.get_version().unwrap_or("unknown version"); let version = format!("{name}/{version}"); move |resp| { let response = ([(header::SERVER, &version)], resp).into_response(); future::ready(response) } } } fn started_msg(listen_addr: &(String, u16)) -> String { let (host, port) = listen_addr; format!("Listening on http://{host}:{port}/") }