diff --git a/src/appstate.rs b/src/appstate.rs index f3bcbcf..b7e4709 100644 --- a/src/appstate.rs +++ b/src/appstate.rs @@ -1,138 +1,84 @@ use std::{ - collections::VecDeque, - fs, - io::{self, Write}, - path::{Path, PathBuf}, + cell::{Ref, RefCell}, + fmt, io, time::Duration, }; use console::style; use indicatif::{ProgressBar, ProgressStyle}; -use log::{debug, trace}; -use serde::{Deserialize, Serialize}; +use log::debug; use super::{ + cachefile::CacheFile, cli::Cli, - sharry::{Alias, ChunkState, FileChecked, FileUploading, Share, SharryFile, UploadError}, + file::{self, FileTrait}, + sharry::{self, Client, ClientError}, }; -#[derive(Serialize, Deserialize, Debug)] pub struct AppState { - #[serde(skip)] - file_name: PathBuf, - #[serde(skip)] - progress: Option, + current_bar: RefCell>, + buffer: Vec, - alias: Alias, - share: Share, - files: VecDeque, + http: ureq::Agent, + inner: CacheFile, } -#[derive(Serialize, Deserialize, Debug)] -enum FileState { - C(FileChecked), - U(FileUploading), +impl fmt::Debug for AppState { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("AppState") + .field("inner", &self.inner) + .finish_non_exhaustive() + } } -impl FileState { - fn file_name(&self) -> &str { - match self { - FileState::C(checked) => checked.get_name(), - FileState::U(uploading) => uploading.get_name(), - } - } - - fn start_upload( - self, - http: &ureq::Agent, - alias: &Alias, - share: &Share, - ) -> io::Result { - match self { - FileState::C(checked) => checked.start_upload(http, alias, share), - FileState::U(uploading) => Ok(uploading), - } - } +fn new_http(timeout: Option) -> ureq::Agent { + ureq::Agent::config_builder() + .timeout_global(timeout) + .build() + .into() } impl AppState { - fn cache_dir() -> PathBuf { - let dir_name = dirs_next::cache_dir() - .expect("could not determine cache directory") - .join("shrupl"); - - trace!("cachedir: {:?}", dir_name.display()); - dir_name - } - - fn cache_file(args: &Cli) -> PathBuf { - let file_name = Self::cache_dir().join(format!("{}.json", args.get_hash())); - - trace!("cachefile: {:?}", file_name.display()); - file_name - } - - fn load(file_name: impl AsRef) -> io::Result { - let content = fs::read_to_string(file_name)?; - serde_json::from_str(&content).map_err(io::Error::other) + fn new(chunk_size: usize, http: ureq::Agent, inner: CacheFile) -> Self { + Self { + current_bar: None.into(), + buffer: vec![0; chunk_size * 1024 * 1024], + http, + inner, + } } pub fn try_resume(args: &Cli) -> Option { - let file_name = Self::cache_file(args); + let inner = CacheFile::try_resume(args) + .inspect_err(|e| debug!("could not resume from hash {:?}: {e}", args.get_hash())) + .ok()?; - Self::load(&file_name) - .inspect_err(|e| debug!("could not resume from {:?}: {e}", file_name.display())) - .map(|state| { - debug!("successfully loaded AppState"); - - Self { - file_name, - progress: None, - alias: state.alias, - share: state.share, - files: state.files, - } - }) - .ok() + Some(Self::new( + args.chunk_size, + new_http(args.get_timeout()), + inner, + )) } - pub fn from_args(args: &Cli, http: &ureq::Agent) -> Result { - let file_name = Self::cache_file(args); - let alias = args.get_alias(); + pub fn from_args(args: &Cli) -> sharry::Result { + let http = new_http(args.get_timeout()); - let share = Share::create(http, &alias, args.get_share_request())?; + let share_id = http.share_create( + &args.get_uri().endpoint("alias/upload/new"), + &args.alias, + args.get_share_request(), + )?; - let files: VecDeque<_> = args.files.clone().into_iter().map(FileState::C).collect(); - - Ok(Self { - file_name, - progress: None, - alias, - share, - files, - }) + Ok(Self::new( + args.chunk_size, + http, + CacheFile::from_args(args, share_id), + )) } - pub fn file_names(&self) -> Vec<&str> { - self.files.iter().map(FileState::file_name).collect() - } - - pub fn upload_chunk( - &mut self, - http: &ureq::Agent, - chunk_size: usize, - ) -> Result, UploadError> { - let uploading = if let Some(state) = self.files.pop_front() { - state.start_upload(http, &self.alias, &self.share).unwrap() // HACK unwrap - } else { - return Ok(None); - }; - - debug!("{uploading} chunk {chunk_size}"); - - // Initialize or fetch the existing ProgressBar in one call: - let bar = &*self.progress.get_or_insert_with(|| { - // Create a new bar with style + fn get_or_create_progressbar(&self, uploading: &file::Uploading) -> Ref<'_, ProgressBar> { + let mut slot = self.current_bar.borrow_mut(); + if slot.is_none() { let bar = ProgressBar::new(uploading.get_size()) .with_style( ProgressStyle::with_template(&format!( @@ -143,51 +89,80 @@ impl AppState { ), style("/").magenta(), )) - .unwrap(), + .unwrap(), // safe as long as the style template is valid ) - .with_message(uploading.get_name().to_owned()) - .with_position(uploading.get_offset()); + .with_position(uploading.get_offset()) + .with_message(uploading.get_name().to_owned()); bar.enable_steady_tick(Duration::from_millis(100)); - bar - }); + *slot = Some(bar); + } + drop(slot); - match uploading.upload_chunk(http, &self.alias, chunk_size) { - ChunkState::Ok(upl) => { - bar.set_position(upl.get_offset()); - self.files.push_front(FileState::U(upl)); + // unwrap is safe: We just made sure it's `Some`. + Ref::map(self.current_bar.borrow(), |opt| opt.as_ref().unwrap()) + } + + fn finish_bar(&self) { + let mut slot = self.current_bar.borrow_mut(); + if let Some(bar) = &*slot { + bar.finish(); + *slot = None; + } + } + + pub fn upload_chunk(&mut self) -> sharry::Result> { + let Some(mut uploading) = self.inner.pop_file(&self.http) else { + self.inner.share_notify(&self.http).unwrap(); // HACK unwrap + + return Ok(None); + }; + + self.get_or_create_progressbar(&uploading); + + debug!("{uploading} chunk {}", self.buffer.len()); + + let chunk = uploading + .read(&mut self.buffer) + .map_err(ClientError::from)?; + + self.http.file_patch( + chunk.get_patch_uri(), + self.inner.alias_id(), + chunk.get_offset(), + chunk.get_data(), + )?; + + match uploading.check_eof() { + Ok(uploading) => { + let bar = self.get_or_create_progressbar(&uploading); + bar.set_position(uploading.get_offset()); + // BUG in `indicatif` crate? + // `set_position` does not force immediate redraw, so we also call `inc_length` here + bar.inc_length(0); + drop(bar); + + self.inner.push_file(uploading); Ok(Some(())) } - ChunkState::Err(upl, e) => { - self.files.push_front(FileState::U(upl)); - Err(e) - } - ChunkState::Finished(path) => { + Err(path) => { debug!("Finished {:?}!", path.display()); - bar.finish(); - self.progress = None; - self.share.notify(http, &self.alias).unwrap(); // HACK unwrap + self.finish_bar(); - Ok(self.files.front().map(drop)) + Ok(self.inner.has_file().then_some(())) } } } + pub fn file_names(&self) -> Vec<&str> { + self.inner.file_names() + } + pub fn save(&self) -> io::Result<()> { - fs::create_dir_all(Self::cache_dir())?; - - let json = serde_json::to_string_pretty(self).map_err(io::Error::other)?; - let mut file = fs::File::create(&self.file_name)?; - file.write_all(json.as_bytes())?; - - trace!("updated {:?}", self.file_name.display()); - Ok(()) + self.inner.save() } pub fn clear(self) -> io::Result<()> { - fs::remove_file(&self.file_name)?; - - trace!("removed {:?}", self.file_name.display()); - Ok(()) + self.inner.clear() } } diff --git a/src/cachefile.rs b/src/cachefile.rs new file mode 100644 index 0000000..fe9b3a9 --- /dev/null +++ b/src/cachefile.rs @@ -0,0 +1,149 @@ +use std::{ + collections::VecDeque, + fs, + io::{self, Write}, + path::PathBuf, +}; + +use log::trace; +use serde::{Deserialize, Serialize}; + +use super::{ + cli::Cli, + file::{self, FileTrait}, + sharry::{self, Client, Uri}, +}; + +#[derive(Serialize, Deserialize, Debug)] +enum FileState { + C(file::Checked), + U(file::Uploading), +} + +impl FileState { + fn file_name(&self) -> &str { + match self { + FileState::C(c) => c.get_name(), + FileState::U(u) => u.get_name(), + } + } + + fn start_upload( + self, + http: &impl Client, + endpoint: &str, + alias_id: &str, + ) -> sharry::Result { + match self { + FileState::C(checked) => checked.start_upload(http, endpoint, alias_id), + FileState::U(uploading) => Ok(uploading), + } + } +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct CacheFile { + #[serde(skip)] + file_name: PathBuf, + + uri: Uri, + alias_id: String, + share_id: String, + files: VecDeque, +} + +impl CacheFile { + fn cache_dir() -> PathBuf { + let dir_name = dirs_next::cache_dir() + .expect("could not determine cache directory") + .join("shrupl"); + + trace!("cachedir: {:?}", dir_name.display()); + dir_name + } + + fn cache_file(args: &Cli) -> PathBuf { + let file_name = Self::cache_dir().join(format!("{}.json", args.get_hash())); + + trace!("cachefile: {:?}", file_name.display()); + file_name + } + + pub fn try_resume(args: &Cli) -> io::Result { + let file_name = Self::cache_file(args); + + let state: Self = { + let file = fs::File::open(&file_name)?; + let reader = io::BufReader::new(file); + serde_json::from_reader(reader).map_err(io::Error::other)? + }; + + Ok(Self { file_name, ..state }) + } + + pub fn from_args(args: &Cli, share_id: String) -> Self { + Self { + file_name: Self::cache_file(args), + uri: args.get_uri(), + alias_id: args.alias.clone(), + share_id, + files: args.files.clone().into_iter().map(FileState::C).collect(), + } + } + + pub fn alias_id(&self) -> &str { + &self.alias_id + } + + pub fn file_names(&self) -> Vec<&str> { + self.files.iter().map(FileState::file_name).collect() + } + + pub fn has_file(&self) -> bool { + !self.files.is_empty() + } + + pub fn pop_file(&mut self, http: &impl Client) -> Option { + if let Some(state) = self.files.pop_front() { + let endpoint = self + .uri + .endpoint(format!("alias/upload/{}/files/tus", self.share_id)); + Some(state.start_upload(http, &endpoint, &self.alias_id).unwrap()) // HACK unwrap + } else { + None + } + } + + pub fn push_file(&mut self, file: file::Uploading) { + self.files.push_front(FileState::U(file)); + } + + pub fn share_notify(&self, http: &impl Client) -> sharry::Result<()> { + let endpoint = self + .uri + .endpoint(format!("alias/mail/notify/{}", self.share_id)); + + http.share_notify(&endpoint, &self.alias_id) + } + + pub fn save(&self) -> io::Result<()> { + let cache_dir = self.file_name.parent().ok_or_else(|| { + io::Error::other(format!("orphan file {:?}", self.file_name.display())) + })?; + fs::create_dir_all(cache_dir)?; + + let json = serde_json::to_string_pretty(self).map_err(io::Error::other)?; + let mut file = fs::File::create(&self.file_name)?; + file.write_all(json.as_bytes())?; + + trace!("updated {:?}", self.file_name.display()); + Ok(()) + } + + pub fn clear(self) -> io::Result<()> { + fs::remove_file(&self.file_name)?; + + trace!("removed {:?}", self.file_name.display()); + Ok(()) + } +} diff --git a/src/cli.rs b/src/cli.rs index 6e96e3b..d161e11 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,13 +1,17 @@ use std::{ + fmt, hash::{DefaultHasher, Hash, Hasher}, time::Duration, }; use clap::{Parser, builder::PossibleValuesParser}; -use super::sharry::{Alias, FileChecked, NewShareRequest, Uri}; +use super::{ + file::Checked, + sharry::{NewShareRequest, Uri}, +}; -#[derive(Parser, Debug, Hash)] +#[derive(Parser, Hash)] #[command(version, about, long_about = None)] pub struct Cli { /// Timeout in seconds for HTTP actions (set 0 or invalid to disable) @@ -46,19 +50,33 @@ pub struct Cli { url: String, /// ID of a public alias to use - alias: String, + pub alias: String, /// Files to upload to the new share #[arg(value_name = "FILE", required = true, value_parser = parse_sharry_file)] - pub files: Vec, + pub files: Vec, +} + +impl fmt::Debug for Cli { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Cli") + .field("uri", &self.get_uri()) + .field("alias", &self.alias) + .field("timeout", &self.get_timeout()) + .field("chunk_size", &self.chunk_size) + .field("share_request", &self.get_share_request()) + .field("files", &self.files) + .field("hash", &self.get_hash()) + .finish_non_exhaustive() + } } fn parse_seconds(data: &str) -> Result { data.parse().or(Ok(0)).map(Duration::from_secs) } -fn parse_sharry_file(data: &str) -> Result { - FileChecked::new(data).map_err(|e| e.to_string()) +fn parse_sharry_file(data: &str) -> Result { + Checked::new(data).map_err(|e| e.to_string()) } impl Cli { @@ -66,8 +84,8 @@ impl Cli { (!self.timeout.is_zero()).then_some(self.timeout) } - pub fn get_alias(&self) -> Alias { - Alias::new(Uri::with_protocol(&self.protocol, &self.url), &self.alias) + pub fn get_uri(&self) -> Uri { + Uri::with_protocol(&self.protocol, &self.url) } pub fn get_share_request(&self) -> NewShareRequest { @@ -83,7 +101,7 @@ impl Cli { }; let mut hasher = DefaultHasher::new(); - (self.get_alias(), file_refs).hash(&mut hasher); + (self.get_uri(), &self.alias, file_refs).hash(&mut hasher); format!("{:x}", hasher.finish()) } diff --git a/src/file/checked.rs b/src/file/checked.rs new file mode 100644 index 0000000..16d16ca --- /dev/null +++ b/src/file/checked.rs @@ -0,0 +1,57 @@ +use std::{ + fs, io, + path::{Path, PathBuf}, +}; + +use serde::{Deserialize, Serialize}; + +use crate::sharry; + +use super::{FileTrait, Uploading}; + +#[derive(Debug, Clone, Hash, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +pub struct Checked { + path: PathBuf, + size: u64, +} + +impl Checked { + pub fn new(value: impl AsRef) -> io::Result { + let meta = fs::metadata(&value)?; + if meta.is_file() { + Ok(Self { + path: fs::canonicalize(&value)?, + size: meta.len(), + }) + } else { + Err(io::Error::new( + io::ErrorKind::InvalidInput, + "Not a regular file", + )) + } + } + + pub fn start_upload( + self, + client: &impl sharry::Client, + endpoint: &str, + alias_id: &str, + ) -> sharry::Result { + let patch_uri = client.file_create(endpoint, alias_id, self.get_name(), self.size)?; + + Ok(Uploading::new(self.path, self.size, patch_uri)) + } +} + +impl<'t> FileTrait<'t> for Checked { + /// get a reference to the file's name + /// + /// Uses `SharryFile::extract_file_name`, which may **panic**! + fn get_name(&'t self) -> &'t str { + ::extract_file_name(&self.path) + } + + fn get_size(&self) -> u64 { + self.size + } +} diff --git a/src/file/chunk.rs b/src/file/chunk.rs new file mode 100644 index 0000000..838e66b --- /dev/null +++ b/src/file/chunk.rs @@ -0,0 +1,36 @@ +pub struct Chunk<'t> { + data: &'t [u8], + patch_uri: &'t str, + offset: u64, +} + +impl<'t> Chunk<'t> { + pub fn new(data: &'t [u8], patch_uri: &'t str, offset: u64) -> Self { + Self { + data, + patch_uri, + offset, + } + } + + pub fn get_data(&self) -> &[u8] { + self.data + } + + pub fn get_length(&self) -> u64 { + let len = self.data.len(); + + // BOOKMARK this might **panic** on platforms where `usize` has more than 64 bit. + // Also, you've allocated more than 2 EiB ... in ONE chunk. + // Whoa! Maybe just chill? + u64::try_from(len).unwrap_or_else(|e| panic!("usize={len} did not fit into u64: {e}")) + } + + pub fn get_patch_uri(&self) -> &str { + self.patch_uri + } + + pub fn get_offset(&self) -> u64 { + self.offset + } +} diff --git a/src/sharry/file/mod.rs b/src/file/mod.rs similarity index 62% rename from src/sharry/file/mod.rs rename to src/file/mod.rs index c3e4502..4d508e6 100644 --- a/src/sharry/file/mod.rs +++ b/src/file/mod.rs @@ -1,17 +1,14 @@ mod checked; +mod chunk; mod uploading; -use std::{ - ffi::OsStr, - path::{Path, PathBuf}, -}; +use std::{ffi::OsStr, path::Path}; -pub use checked::FileChecked; -pub use uploading::{ChunkState, FileUploading, UploadError}; +pub use checked::Checked; +pub use chunk::Chunk; +pub use uploading::Uploading; -use super::{Alias, Share, alias::SharryAlias}; - -pub trait SharryFile<'t> { +pub trait FileTrait<'t> { /// extract the filename part of a `Path` reference /// /// # Panics @@ -23,8 +20,6 @@ pub trait SharryFile<'t> { .expect("bad file name") } - fn into_path(self) -> PathBuf; - fn get_name(&'t self) -> &'t str; fn get_size(&self) -> u64; diff --git a/src/file/uploading.rs b/src/file/uploading.rs new file mode 100644 index 0000000..962c2c6 --- /dev/null +++ b/src/file/uploading.rs @@ -0,0 +1,84 @@ +use std::{ + fmt, fs, + io::{self, Read, Seek, SeekFrom}, + path::PathBuf, +}; + +use serde::{Deserialize, Serialize}; + +use super::{Chunk, FileTrait}; + +#[derive(Serialize, Deserialize, Debug)] +pub struct Uploading { + path: PathBuf, + size: u64, + patch_uri: String, + offset: u64, +} + +impl fmt::Display for Uploading { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "Uploading {:?} ({}/{})", + self.path.display(), + self.offset, + self.size + ) + } +} + +impl Uploading { + pub(super) fn new(path: PathBuf, size: u64, patch_uri: String) -> Self { + Self { + path, + size, + patch_uri, + offset: 0, + } + } + + pub fn get_offset(&self) -> u64 { + self.offset + } + + pub fn read<'t>(&'t mut self, buf: &'t mut [u8]) -> io::Result> { + let mut f = fs::File::open(&self.path)?; + + f.seek(SeekFrom::Start(self.offset))?; + let read_len = f.read(buf)?; + + if read_len == 0 { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + format!("could not read from file {:?}", self.path.display()), + )); + } + + let chunk = Chunk::new(&buf[..read_len], &self.patch_uri, self.offset); + self.offset += chunk.get_length(); + + Ok(chunk) + } + + pub fn check_eof(self) -> Result { + if self.offset < self.size { + Ok(self) + } else { + Err(self.path) + } + } +} + +impl<'t> FileTrait<'t> for Uploading { + /// get a reference to the file's name + /// + /// Uses `SharryFile::extract_file_name`, which may **panic**! + fn get_name(&'t self) -> &'t str { + ::extract_file_name(&self.path) + } + + fn get_size(&self) -> u64 { + self.size + } +} diff --git a/src/main.rs b/src/main.rs index 7e17e70..2ca9d32 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,9 +1,11 @@ mod appstate; +mod cachefile; mod cli; +mod file; mod sharry; use std::{ - process::exit, + process, sync::{ Arc, atomic::{AtomicBool, Ordering}, @@ -14,37 +16,65 @@ use clap::Parser; use console::style; use dialoguer::{Confirm, theme::ColorfulTheme}; use log::{error, info}; -use ureq::Agent; use appstate::AppState; use cli::Cli; +use sharry::ClientError; + +fn print_error(e: &ClientError) { + if let Some(cause) = match e { + // known errors + ClientError::ResponseStatus { + actual: 403, + expected: _, + } => Some("Alias ID"), + ClientError::StdIo(_) => Some("URL"), + // unknown error + _ => None, + } { + // handle known error + info!("known error: {e:?}"); + println!( + "{} probably wrong: {}", + style("Error!").red().bold(), + style(cause).cyan(), + ); + println!("{}", style(e.to_string()).yellow().italic()); + } else { + // handle unknown error + error!("unknown error: {e} ({e:?})"); + println!("{}", style("Unknown Error!").red().bold()); + } +} fn main() { + env_logger::init(); + println!( "{} to {}!", style("Welcome").magenta().bold(), style("ShrUpl").yellow().bold(), ); - let stop = Arc::new(AtomicBool::new(false)); + let check_ctrlc = { + let stop = Arc::new(AtomicBool::new(false)); + let stop_ctrlc = stop.clone(); - let stop_ctrlc = stop.clone(); - ctrlc::set_handler(move || { - stop_ctrlc.store(true, Ordering::SeqCst); - info!("stopping as soon as possible ..."); - }) - .expect("Error setting Ctrl-C handler"); + ctrlc::set_handler(move || { + stop_ctrlc.store(true, Ordering::SeqCst); + info!("stopping as soon as possible ..."); + }) + .expect("Error setting Ctrl-C handler"); - env_logger::init(); + move || { + if stop.load(Ordering::SeqCst) { + process::exit(255); + } + } + }; let args = Cli::parse(); info!("args: {args:?}"); - info!("timeout: {:?}", args.get_timeout()); - - let agent: Agent = Agent::config_builder() - .timeout_global(args.get_timeout()) - .build() - .into(); let mut state = AppState::try_resume(&args) .and_then(|state| { @@ -55,46 +85,30 @@ fn main() { .map_or(None, |b| b.then_some(state)) }) .unwrap_or_else(|| { - stop.load(Ordering::SeqCst).then(|| exit(0)); + check_ctrlc(); - match AppState::from_args(&args, &agent) { + match AppState::from_args(&args) { Ok(state) => { state.save().unwrap(); // HACK unwrap state } Err(e) => { - if let Some(cause) = match e { - ureq::Error::StatusCode(403) => Some("Alias ID"), - ureq::Error::Io(_) => Some("URL"), - _ => None, - } { - info!("handling error: {e:?}"); - println!( - "{} probably wrong: {} – {:?}", - style("Error!").red().bold(), - style(cause).cyan().italic(), - style(e.to_string()).yellow() - ); - } else { - error!("unknown error: {e} – {e:?}"); - println!("{}", style("Unknown Error!").red().bold()); - } - - exit(1); + print_error(&e); + process::exit(1); } } }); + info!("continuing with state: {state:?}"); + println!( "{} uploading: {}", style("ShrUpl").yellow().bold(), style(state.file_names().join(", ")).magenta(), ); - info!("continuing with state: {state:?}"); - loop { - match state.upload_chunk(&agent, args.chunk_size * 1024 * 1024) { + match state.upload_chunk() { Err(e) => error!("error: {e:?}"), Ok(None) => { info!("all uploads done"); @@ -105,6 +119,6 @@ fn main() { } state.save().unwrap(); // HACK unwrap - stop.load(Ordering::SeqCst).then(|| exit(0)); + check_ctrlc(); } } diff --git a/src/sharry/alias.rs b/src/sharry/alias.rs deleted file mode 100644 index ba51bce..0000000 --- a/src/sharry/alias.rs +++ /dev/null @@ -1,36 +0,0 @@ -use std::fmt::{Debug, Display}; - -use log::debug; -use serde::{Deserialize, Serialize}; -use ureq::RequestBuilder; - -use super::api::Uri; - -#[derive(Serialize, Deserialize, Debug, Hash)] -pub struct Alias { - pub(super) uri: Uri, - pub(super) id: String, -} - -pub(super) trait SharryAlias { - fn sharry_header(self, alias: &Alias) -> Self; -} - -impl SharryAlias for RequestBuilder { - fn sharry_header(self, alias: &Alias) -> Self { - self.header("Sharry-Alias", &alias.id) - } -} - -impl Alias { - pub fn new(uri: Uri, id: impl Into) -> Self { - Self { uri, id: id.into() } - } - - pub(super) fn get_endpoint(&self, endpoint: impl Display + Debug) -> String { - let uri = format!("{}/{}", self.uri, endpoint); - debug!("endpoint uri: {uri:?}"); - - uri - } -} diff --git a/src/sharry/api.rs b/src/sharry/api.rs index 079dcda..5e0ed17 100644 --- a/src/sharry/api.rs +++ b/src/sharry/api.rs @@ -10,19 +10,19 @@ pub struct Uri { } impl Uri { - pub(super) fn get_endpoint(&self, endpoint: impl fmt::Display + fmt::Debug) -> String { - let uri = format!("{}/{}", self, endpoint); - debug!("endpoint uri: {uri:?}"); - - uri - } - pub fn with_protocol(protocol: impl Into, base_url: impl Into) -> Self { Self { protocol: protocol.into(), base_url: base_url.into(), } } + + pub fn endpoint(&self, endpoint: impl fmt::Display) -> String { + let uri = format!("{self}/{endpoint}"); + debug!("endpoint: {uri:?}"); + + uri + } } impl fmt::Display for Uri { @@ -31,7 +31,7 @@ impl fmt::Display for Uri { } } -#[derive(Serialize)] +#[derive(Serialize, Debug)] #[allow(non_snake_case)] pub struct NewShareRequest { name: String, diff --git a/src/sharry/client.rs b/src/sharry/client.rs index 85951ee..68c6e49 100644 --- a/src/sharry/client.rs +++ b/src/sharry/client.rs @@ -1,79 +1,106 @@ -use std::{error::Error, io}; +use std::fmt; -use log::debug; +use log::{debug, trace}; +use thiserror::Error; -use super::{ - api::{NewShareRequest, NewShareResponse, NotifyShareResponse, Uri}, - file::{FileChecked, FileUploading, SharryFile}, -}; +use super::api::{NewShareRequest, NewShareResponse, NotifyShareResponse}; + +pub type Result = std::result::Result; pub trait Client { - fn sharry_share_create( - &self, - uri: &Uri, - alias_id: &str, - data: NewShareRequest, - ) -> Result; + fn share_create(&self, endpoint: &str, alias_id: &str, data: NewShareRequest) + -> Result; - fn sharry_share_notify( - &self, - uri: &Uri, - alias_id: &str, - share_id: &str, - ) -> Result<(), ClientError>; + fn share_notify(&self, endpoint: &str, alias_id: &str) -> Result<()>; - fn sharry_file_create( + fn file_create( &self, - uri: &Uri, + endpoint: &str, alias_id: &str, - share_id: &str, - file: FileChecked, - ) -> Result; + file_name: &str, + file_size: u64, + ) -> Result; - // fn sharry_file_patch(&self); + fn file_patch(&self, endpoint: &str, alias_id: &str, offset: u64, chunk: &[u8]) -> Result<()>; } -#[derive(Debug, thiserror::Error)] +#[derive(Debug, Error)] pub enum ClientError { - #[error("file I/O error: {0}")] - FileIO(#[from] io::Error), + #[error(transparent)] + StdIo(#[from] std::io::Error), #[error("network request failed: {0}")] Request(String), - #[error("response parsing failed: {0}")] - ResponseParsing(String), - #[error("unexpected response status: {actual} (expected {expected})")] ResponseStatus { actual: u16, expected: u16 }, + #[error("response parsing failed: {0}")] + ResponseParsing(String), + #[error("unexpected response content: {0}")] ResponseContent(String), - // - // #[error("could not parse offset header")] - // ResponseOffset, +} + +impl ClientError { + pub fn req_err(msg: impl fmt::Display) -> Self { + Self::Request(msg.to_string()) + } + + pub fn res_parse_err(msg: impl fmt::Display) -> Self { + Self::ResponseParsing(msg.to_string()) + } + + pub fn res_status_check(actual: T, expected: T) -> Result<()> + where + T: PartialEq + Into + Copy, + { + if actual == expected { + Ok(()) + } else { + Err(Self::ResponseStatus { + actual: actual.into(), + expected: expected.into(), + }) + } + } +} + +impl From for ClientError { + fn from(value: ureq::Error) -> Self { + match value { + ureq::Error::StatusCode(status) => Self::ResponseStatus { + actual: status, + expected: 200, + }, + ureq::Error::Io(e) => e.into(), + error => Self::req_err(error), + } + } } impl Client for ureq::Agent { - fn sharry_share_create( + fn share_create( &self, - uri: &Uri, + endpoint: &str, alias_id: &str, data: NewShareRequest, - ) -> Result { - let res = { - let endpoint = uri.get_endpoint("alias/upload/new"); + ) -> Result { + let mut res = self + .post(endpoint) + .header("Sharry-Alias", alias_id) + .send_json(data) + .map_err(ClientError::from)?; - self.post(endpoint) - .header("Sharry-Alias", alias_id) - .send_json(data) - .map_err(|e| ClientError::Request(e.to_string()))? - .body_mut() - .read_json::() - .map_err(|e| ClientError::ResponseParsing(e.to_string()))? - }; + trace!("{endpoint:?} response: {res:?}"); + ClientError::res_status_check(res.status(), ureq::http::StatusCode::OK)?; - debug!("response: {res:?}"); + let res = res + .body_mut() + .read_json::() + .map_err(ClientError::res_parse_err)?; + + debug!("{res:?}"); if res.success && (res.message == "Share created.") { Ok(res.id) @@ -82,64 +109,84 @@ impl Client for ureq::Agent { } } - fn sharry_share_notify( - &self, - uri: &Uri, - alias_id: &str, - share_id: &str, - ) -> Result<(), ClientError> { - let res = { - let endpoint = uri.get_endpoint(format!("alias/mail/notify/{}", share_id)); + fn share_notify(&self, endpoint: &str, alias_id: &str) -> Result<()> { + let mut res = self + .post(endpoint) + .header("Sharry-Alias", alias_id) + .send_empty() + .map_err(ClientError::from)?; - self.post(endpoint) - .header("Sharry-Alias", alias_id) - .send_empty() - .map_err(|e| ClientError::Request(e.to_string()))? - .body_mut() - .read_json::() - .map_err(|e| ClientError::ResponseParsing(e.to_string()))? - }; + trace!("{endpoint:?} response: {res:?}"); + ClientError::res_status_check(res.status(), ureq::http::StatusCode::OK)?; - debug!("response: {res:?}"); + let res = res + .body_mut() + .read_json::() + .map_err(ClientError::res_parse_err)?; + + debug!("{res:?}"); Ok(()) } - fn sharry_file_create( + fn file_create( &self, - uri: &Uri, + endpoint: &str, alias_id: &str, - share_id: &str, - file: FileChecked, - ) -> Result { - let size = file.get_size(); + file_name: &str, + file_size: u64, + ) -> Result { + let res = self + .post(endpoint) + .header("Sharry-Alias", alias_id) + .header("Sharry-File-Name", file_name) + .header("Upload-Length", file_size) + .send_empty() + .map_err(ClientError::from)?; - let res = { - let endpoint = uri.get_endpoint(format!("alias/upload/{}/files/tus", share_id)); - - self.post(endpoint) - .header("Sharry-Alias", alias_id) - .header("Sharry-File-Name", file.get_name()) - .header("Upload-Length", size) - .send_empty() - .map_err(|e| ClientError::Request(e.to_string()))? - }; - - if res.status() != ureq::http::StatusCode::CREATED { - return Err(ClientError::ResponseStatus { - actual: res.status().as_u16(), - expected: ureq::http::StatusCode::CREATED.as_u16(), - }); - } + trace!("{endpoint:?} response: {res:?}"); + ClientError::res_status_check(res.status(), ureq::http::StatusCode::CREATED)?; let location = (res.headers().get("Location")) - .ok_or_else(|| ClientError::ResponseParsing("Location header not found".to_owned()))? + .ok_or_else(|| ClientError::res_parse_err("Location header not found"))? .to_str() - .map_err(|_| ClientError::ResponseParsing("Location header invalid".to_owned()))? + .map_err(ClientError::res_parse_err)? .to_string(); - debug!("patch uri: {location}"); + debug!("{location:?}"); - Ok(FileUploading::new(file.into_path(), size, location)) + Ok(location) + } + + fn file_patch(&self, endpoint: &str, alias_id: &str, offset: u64, chunk: &[u8]) -> Result<()> { + let res = self + .patch(endpoint) + .header("Sharry-Alias", alias_id) + .header("Upload-Offset", offset) + .send(chunk) + .map_err(ClientError::from)?; + + trace!("{endpoint:?} response: {res:?}"); + ClientError::res_status_check(res.status(), ureq::http::StatusCode::NO_CONTENT)?; + + let res_offset = (res.headers().get("Upload-Offset")) + .ok_or_else(|| ClientError::res_parse_err("Upload-Offset header not found"))? + .to_str() + .map_err(ClientError::res_parse_err)? + .parse::() + .map_err(ClientError::res_parse_err)?; + + // get chunk length as `u64` (we have checked while reading the chunk!) + let chunk_len = u64::try_from(chunk.len()).expect("something's VERY wrong"); + + if offset + chunk_len == res_offset { + Ok(()) + } else { + Err(ClientError::ResponseContent(format!( + "Unexpected Upload-Offset: {} (expected {})", + res_offset, + offset + chunk_len + ))) + } } } diff --git a/src/sharry/file/checked.rs b/src/sharry/file/checked.rs deleted file mode 100644 index e31adc9..0000000 --- a/src/sharry/file/checked.rs +++ /dev/null @@ -1,83 +0,0 @@ -use std::{ - ffi::OsStr, - fs, io, - path::{Path, PathBuf}, -}; - -use log::debug; -use serde::{Deserialize, Serialize}; -use ureq::http::{HeaderValue, StatusCode}; - -use super::{Alias, FileUploading, Share, SharryAlias, SharryFile}; - -#[derive(Debug, Clone, Hash, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] -pub struct FileChecked { - path: PathBuf, -} - -impl FileChecked { - pub fn new(value: impl AsRef) -> io::Result { - let meta = fs::metadata(&value)?; - if meta.is_file() { - Ok(Self { - path: fs::canonicalize(&value)?, - }) - } else { - Err(io::Error::new( - io::ErrorKind::InvalidInput, - "Not a regular file", - )) - } - } - - pub fn start_upload( - self, - http: &ureq::Agent, - alias: &Alias, - share: &Share, - ) -> io::Result { - let size = self.get_size(); - - let res = { - let endpoint = alias.get_endpoint(format!("alias/upload/{}/files/tus", share.id)); - - (http.post(endpoint)) - .sharry_header(alias) - .header("Sharry-File-Name", self.get_name()) - .header("Upload-Length", size) - .send_empty() - .map_err(ureq::Error::into_io)? - }; - - if res.status() != StatusCode::CREATED { - return Err(io::Error::other("unexpected response status")); - } - - let location = (res.headers().get("Location")) - .ok_or_else(|| io::Error::other("Location header not found"))? - .to_str() - .map_err(|_| io::Error::other("Location header invalid"))? - .to_string(); - - debug!("patch uri: {location}"); - - Ok(FileUploading::new(self.path, size, location)) - } -} - -impl<'t> SharryFile<'t> for FileChecked { - fn into_path(self) -> PathBuf { - self.path - } - - /// get a reference to the file's name - /// - /// Uses `SharryFile::extract_file_name`, which may **panic**! - fn get_name(&'t self) -> &'t str { - ::extract_file_name(&self.path) - } - - fn get_size(&self) -> u64 { - fs::metadata(&self.path).unwrap().len() - } -} diff --git a/src/sharry/file/uploading.rs b/src/sharry/file/uploading.rs deleted file mode 100644 index 15fa3d9..0000000 --- a/src/sharry/file/uploading.rs +++ /dev/null @@ -1,149 +0,0 @@ -use std::{ - ffi::OsStr, - fmt, fs, - io::{self, Read, Seek, SeekFrom}, - path::PathBuf, -}; - -use log::debug; -use serde::{Deserialize, Serialize}; -use ureq::http::{HeaderValue, StatusCode}; - -use super::{Alias, SharryAlias, SharryFile}; - -#[derive(Serialize, Deserialize, Debug)] -pub struct FileUploading { - path: PathBuf, - size: u64, - uri: String, - offset: u64, -} - -#[derive(Debug, thiserror::Error)] -pub enum UploadError { - #[error("file I/O error: {0}")] - FileIO(#[from] io::Error), - - #[error("network request failed")] - Request, - - #[error("unexpected response status")] - ResponseStatus, - - #[error("could not parse offset header")] - ResponseOffset, - // #[error("chunk length conversion failed: {0}")] - // InvalidChunkLength(String), - - // #[error("offset mismatch")] - // ResponseOffsetMismatch, -} - -pub enum ChunkState { - Ok(FileUploading), - Err(FileUploading, UploadError), - Finished(PathBuf), -} - -impl fmt::Display for FileUploading { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!( - f, - "Uploading {:?} ({}/{})", - self.path.display(), - self.offset, - self.size - ) - } -} - -impl FileUploading { - pub fn new(path: PathBuf, size: u64, uri: String) -> Self { - Self { - path, - size, - uri, - offset: 0, - } - } - - pub fn get_offset(&self) -> u64 { - self.offset - } - - fn read_chunk(&self, chunk_size: usize) -> io::Result> { - let mut f = fs::File::open(&self.path)?; - f.seek(SeekFrom::Start(self.offset))?; - - let mut bytes = vec![0; chunk_size]; - let read_len = f.read(&mut bytes)?; - bytes.truncate(read_len); - - Ok(bytes) - } - - pub fn upload_chunk( - mut self, - http: &ureq::Agent, - alias: &Alias, - chunk_size: usize, - ) -> ChunkState { - let chunk = match self.read_chunk(chunk_size) { - Err(e) => return ChunkState::Err(self, UploadError::FileIO(e)), - Ok(value) => value, - }; - - let Ok(res) = (http.patch(&self.uri)) - .sharry_header(alias) - .header("Upload-Offset", self.offset) - .send(&chunk) - else { - return ChunkState::Err(self, UploadError::Request); - }; - - if res.status() != StatusCode::NO_CONTENT { - return ChunkState::Err(self, UploadError::ResponseStatus); - } - - let Some(Ok(Ok(res_offset))) = (res.headers().get("Upload-Offset")) - .map(HeaderValue::to_str) - .map(|v| v.map(str::parse::)) - else { - return ChunkState::Err(self, UploadError::ResponseOffset); - }; - - // convert chunk.len() into an `u64` - // - // BOOKMARK this might panic on platforms where `usize` > 64 bit. - // Also whoa, you've sent more than 2 EiB ... in ONE chunk. - // Maybe just chill? - let chunk_len = u64::try_from(chunk.len()) - .unwrap_or_else(|e| panic!("usize={} did not fit into u64: {}", chunk.len(), e)); - - if self.offset + chunk_len != res_offset { - return ChunkState::Err(self, UploadError::ResponseOffset); - } - - self.offset = res_offset; - - if self.offset == self.size { - return ChunkState::Finished(self.path); - } - - ChunkState::Ok(self) - } -} - -impl<'t> SharryFile<'t> for FileUploading { - fn into_path(self) -> PathBuf { - self.path - } - - fn get_name(&'t self) -> &'t str { - ::extract_file_name(&self.path) - } - - fn get_size(&self) -> u64 { - self.size - } -} diff --git a/src/sharry/mod.rs b/src/sharry/mod.rs index 05ce16e..e230f9d 100644 --- a/src/sharry/mod.rs +++ b/src/sharry/mod.rs @@ -1,13 +1,5 @@ -#![allow(unused_imports)] - -mod alias; mod api; mod client; -mod file; -mod share; -pub use alias::Alias; pub use api::{NewShareRequest, Uri}; -// pub use client::SharryClient; -pub use file::{ChunkState, FileChecked, FileUploading, SharryFile, UploadError}; -pub use share::Share; +pub use client::{Client, ClientError, Result}; diff --git a/src/sharry/share.rs b/src/sharry/share.rs deleted file mode 100644 index 0733bd4..0000000 --- a/src/sharry/share.rs +++ /dev/null @@ -1,48 +0,0 @@ -use log::debug; -use serde::{Deserialize, Serialize}; - -use super::{ - alias::{Alias, SharryAlias}, - api::{NewShareRequest, NewShareResponse, NotifyShareResponse}, -}; - -#[derive(Serialize, Deserialize, Debug)] -pub struct Share { - pub(super) id: String, -} - -impl Share { - pub fn create( - http: &ureq::Agent, - alias: &Alias, - data: NewShareRequest, - ) -> Result { - let res = (http.post(alias.get_endpoint("alias/upload/new"))) - .sharry_header(alias) - .send_json(data)? - .body_mut() - .read_json::()?; - - debug!("response: {res:?}"); - - if !(res.success && (res.message == "Share created.")) { - return Err(ureq::Error::Other("unexpected json response".into())); - } - - Ok(Self { id: res.id }) - } - - pub fn notify(&self, http: &ureq::Agent, alias: &Alias) -> Result<(), ureq::Error> { - let endpoint = alias.get_endpoint(format!("alias/mail/notify/{}", self.id)); - - let res = (http.post(endpoint)) - .sharry_header(alias) - .send_empty()? - .body_mut() - .read_json::()?; - - debug!("response: {res:?}"); - - Ok(()) - } -}