#![warn(clippy::nursery, clippy::pedantic)] #![deny(unsafe_code)] // OmegaUpload CLI Client // Copyright (C) 2021 Edward Shen // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. If not, see . use std::io::{Cursor, Read, Write}; use std::path::PathBuf; use anyhow::{anyhow, bail, Context, Result}; use atty::Stream; use bytes::Bytes; use clap::Parser; use indicatif::{ProgressBar, ProgressStyle}; use omegaupload_common::crypto::{open_in_place, seal_in_place}; use omegaupload_common::fragment::Builder; use omegaupload_common::secrecy::{ExposeSecret, SecretString, SecretVec}; use omegaupload_common::{ base64, Expiration, ParsedUrl, Url, API_ENDPOINT, EXPIRATION_HEADER_NAME, }; use reqwest::blocking::{Body, Client}; use reqwest::header::EXPIRES; use reqwest::StatusCode; use rpassword::prompt_password; #[derive(Parser)] struct Opts { #[clap(subcommand)] action: Action, } #[derive(Parser)] enum Action { /// Upload a paste to an omegaupload server. Upload { /// The OmegaUpload instance to upload data to. url: Url, /// Encrypt the uploaded paste with the provided password, preventing /// public access. #[clap(short, long)] password: bool, /// How long for the paste to last, or until someone has read it. #[clap(short, long, possible_values = Expiration::variants())] duration: Option, /// The path to the file to upload. If none is provided, then reads /// stdin instead. path: Option, /// Hint that the uploaded file should be syntax highlighted with a /// specific language. #[clap(short, long)] language: Option, /// Don't provide a file name hint. #[clap(short = 'F', long)] no_file_name_hint: bool, }, /// Download a paste from an omegaupload server. Download { /// The paste to download. url: ParsedUrl, }, } fn main() -> Result<()> { let opts = Opts::parse(); match opts.action { Action::Upload { url, password, duration, path, language, no_file_name_hint, } => handle_upload(url, password, duration, path, language, no_file_name_hint), Action::Download { url } => handle_download(url), }?; Ok(()) } fn handle_upload( mut url: Url, password: bool, duration: Option, path: Option, language: Option, no_file_name_hint: bool, ) -> Result<()> { url.set_fragment(None); if password && path.is_none() { bail!("Reading data from stdin is incompatible with a password. Provide a path to a file to upload."); } let (data, key) = { let mut container = if let Some(ref path) = path { std::fs::read(path)? } else { let mut container = vec![]; std::io::stdin().lock().read_to_end(&mut container)?; container }; if container.is_empty() { bail!("Nothing to upload."); } let password = if password { let maybe_password = prompt_password("Please set the password for this paste: ")?; Some(SecretVec::new(maybe_password.into_bytes())) } else { None }; let enc_key = seal_in_place(&mut container, password)?; let key = SecretString::new(base64::encode(&enc_key.expose_secret().as_ref())); (container, key) }; let mut req = Client::new().post(url.as_ref()); if let Some(duration) = duration { req = req.header(&*EXPIRATION_HEADER_NAME, duration); } let data_size = data.len() as u64; let progress_style = ProgressStyle::with_template( "[{elapsed_precise}] {bar:40} {bytes}/{total_bytes} {eta_precise}", ) .unwrap(); let progress_bar = ProgressBar::new(data_size).with_style(progress_style); let res = req .body(Body::sized( WrappedBody::new( move |amt| { progress_bar.inc(amt as u64); }, data, ), data_size, )) .build() .expect("Failed to build body"); let res = reqwest::blocking::ClientBuilder::new() .timeout(None) .build()? .execute(res) .context("Request to server failed")?; if res.status() != StatusCode::OK { bail!("Upload failed. Got HTTP error {}", res.status()); } url.path_segments_mut() .map_err(|_| anyhow!("Failed to get base URL"))? .extend(std::iter::once(res.text()?)); let mut fragment = Builder::new(key); if password { fragment = fragment.needs_password(); } if !no_file_name_hint { let file_name = path.and_then(|path| { path.file_name() .map(|str| str.to_string_lossy().to_string()) }); if let Some(file_name) = file_name { fragment = fragment.file_name(file_name); } } if let Some(language) = language { fragment = fragment.language(language); } url.set_fragment(Some(fragment.build().expose_secret())); println!("{url}"); Ok(()) } struct WrappedBody { callback: Callback, inner: Cursor, } impl WrappedBody { fn new(callback: Callback, data: Vec) -> Self { Self { callback, inner: Cursor::new(Bytes::from(data)), } } } impl Read for WrappedBody { fn read(&mut self, buf: &mut [u8]) -> std::io::Result { let res = self.inner.read(buf); if let Ok(size) = res { (self.callback)(size); } res } } fn handle_download(mut url: ParsedUrl) -> Result<()> { url.sanitized_url .set_path(&format!("{API_ENDPOINT}{}", url.sanitized_url.path())); let res = Client::new() .get(url.sanitized_url) .send() .context("Failed to get data")?; if res.status() != StatusCode::OK { bail!("Got bad response from server: {}", res.status()); } let expiration_text = res .headers() .get(EXPIRES) .and_then(|v| Expiration::try_from(v).ok()) .as_ref() .map_or_else( || "This paste will not expire.".to_string(), ToString::to_string, ); let mut data = res.bytes()?.as_ref().to_vec(); let password = if url.needs_password { // Only print prompt on interactive, else it messes with output let maybe_password = prompt_password("Please enter the password to access this paste: ")?; Some(SecretVec::new(maybe_password.into_bytes())) } else { None }; open_in_place(&mut data, &url.decryption_key, password)?; if atty::is(Stream::Stdout) { if let Ok(data) = String::from_utf8(data) { std::io::stdout().write_all(data.as_bytes())?; } else { bail!("Binary output detected. Please pipe to a file."); } } else { std::io::stdout().write_all(&data)?; } eprintln!("{expiration_text}"); Ok(()) }