Merge branch '30-allow-multi-file-uploads' into 'master'

Support multiple files for uploading through archive

See merge request timvisee/ffsend!29
This commit is contained in:
Tim Visée 2019-06-20 20:36:50 +00:00
commit 02559bc6ce
6 changed files with 274 additions and 85 deletions

7
Cargo.lock generated
View file

@ -646,6 +646,7 @@ dependencies = [
"lazy_static 1.3.0 (registry+https://github.com/rust-lang/crates.io-index)",
"open 1.2.2 (registry+https://github.com/rust-lang/crates.io-index)",
"openssl-probe 0.1.2 (registry+https://github.com/rust-lang/crates.io-index)",
"pathdiff 0.1.0 (registry+https://github.com/rust-lang/crates.io-index)",
"pbr 1.0.1 (registry+https://github.com/rust-lang/crates.io-index)",
"prettytable-rs 0.8.0 (registry+https://github.com/rust-lang/crates.io-index)",
"qr2term 0.1.2 (registry+https://github.com/rust-lang/crates.io-index)",
@ -1251,6 +1252,11 @@ dependencies = [
"winapi 0.3.7 (registry+https://github.com/rust-lang/crates.io-index)",
]
[[package]]
name = "pathdiff"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
[[package]]
name = "pbr"
version = "1.0.1"
@ -2433,6 +2439,7 @@ dependencies = [
"checksum owning_ref 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)" = "49a4b8ea2179e6a2e27411d3bca09ca6dd630821cf6894c6c7c8467a8ee7ef13"
"checksum parking_lot 0.7.1 (registry+https://github.com/rust-lang/crates.io-index)" = "ab41b4aed082705d1056416ae4468b6ea99d52599ecf3169b00088d43113e337"
"checksum parking_lot_core 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)" = "94c8c7923936b28d546dfd14d4472eaf34c99b14e1c973a32b3e6d4eb04298c9"
"checksum pathdiff 0.1.0 (registry+https://github.com/rust-lang/crates.io-index)" = "a3bf70094d203e07844da868b634207e71bfab254fe713171fae9a6e751ccf31"
"checksum pbr 1.0.1 (registry+https://github.com/rust-lang/crates.io-index)" = "deb73390ab68d81992bd994d145f697451bb0b54fd39738e72eef32458ad6907"
"checksum percent-encoding 1.0.1 (registry+https://github.com/rust-lang/crates.io-index)" = "31010dd2e1ac33d5b46a5b413495239882813e0369f8ed8a5e266f173602f831"
"checksum phf 0.7.24 (registry+https://github.com/rust-lang/crates.io-index)" = "b3da44b85f8e8dfaec21adae67f95d93244b2ecf6ad2a692320598dcc8e6dd18"

View file

@ -108,6 +108,7 @@ fs2 = "0.4"
lazy_static = "1.0"
open = "1"
openssl-probe = "0.1"
pathdiff = "0.1"
pbr = "1"
prettytable-rs = "0.8"
qr2term = { version = "0.1", optional = true }

View file

@ -1,6 +1,11 @@
use std::env::current_dir;
#[cfg(feature = "archive")]
use std::io::Error as IoError;
use std::path::Path;
#[cfg(feature = "archive")]
use std::path::PathBuf;
#[cfg(feature = "archive")]
use std::process::exit;
use std::sync::{Arc, Mutex};
use clap::ArgMatches;
@ -10,6 +15,7 @@ use ffsend_api::action::upload::{Error as UploadError, Upload as ApiUpload};
use ffsend_api::action::version::Error as VersionError;
use ffsend_api::config::{upload_size_max, UPLOAD_SIZE_MAX_RECOMMENDED};
use ffsend_api::pipe::ProgressReporter;
use pathdiff::diff_paths;
use prettytable::{format::FormatBuilder, Cell, Row, Table};
#[cfg(feature = "qrcode")]
use qr2term::print_qr;
@ -53,9 +59,183 @@ impl<'a> Upload<'a> {
// Get API parameters
#[allow(unused_mut)]
let mut path = Path::new(matcher_upload.file()).to_path_buf();
let mut paths: Vec<_> = matcher_upload
.files()
.into_iter()
.map(|p| Path::new(p).to_path_buf())
.collect();
let mut path = Path::new(paths.first().unwrap()).to_path_buf();
let host = matcher_upload.host();
// The file name to use
#[allow(unused_mut)]
let mut file_name = matcher_upload.name().map(|s| s.to_owned());
// All paths must exist
// TODO: ensure the file exists and is accessible
for path in &paths {
if !path.exists() {
quit_error_msg(
format!("the path '{}' does not exist", path.to_str().unwrap_or("?")),
ErrorHintsBuilder::default().build().unwrap(),
);
}
}
// A temporary archive file, only used when archiving
// The temporary file is stored here, to ensure it's lifetime exceeds the upload process
#[allow(unused_mut)]
#[cfg(feature = "archive")]
let mut tmp_archive: Option<NamedTempFile> = None;
#[cfg(feature = "archive")]
{
// Determine whether to archive, we must archive for multiple files/directory
let mut archive = matcher_upload.archive();
if !archive {
if paths.len() > 1 {
if prompt_yes(
"You've selected multiple files, only a single file may be uploaded.\n\
Archive the files into a single file?",
Some(true),
&matcher_main,
) {
archive = true;
} else {
exit(1);
}
} else if path.is_dir() {
if prompt_yes(
"You've selected a directory, only a single file may be uploaded.\n\
Archive the directory into a single file?",
Some(true),
&matcher_main,
) {
archive = true;
} else {
exit(1);
}
}
}
// Archive the selected file or directory
if archive {
eprintln!("Archiving...");
let archive_extention = ".tar";
// Create a new temporary file to write the archive to
tmp_archive = Some(
TempBuilder::new()
.prefix(&format!(".{}-archive-", crate_name!()))
.suffix(archive_extention)
.tempfile()
.map_err(ArchiveError::TempFile)?,
);
if let Some(tmp_archive) = &tmp_archive {
// Get the path, and the actual file
let archive_path = tmp_archive.path().to_path_buf();
let archive_file = tmp_archive
.as_file()
.try_clone()
.map_err(ArchiveError::CloneHandle)?;
// Select the file name to use if not set
if file_name.is_none() {
// Require user to specify name if multiple files are given
if paths.len() > 1 {
quit_error_msg(
"you must specify a file name for the archive",
ErrorHintsBuilder::default()
.name(true)
.verbose(false)
.build()
.unwrap(),
);
}
// Derive name from given file
file_name = Some(
path.canonicalize()
.map_err(|err| ArchiveError::FileName(Some(err)))?
.file_name()
.ok_or(ArchiveError::FileName(None))?
.to_str()
.map(|s| s.to_owned())
.ok_or(ArchiveError::FileName(None))?,
);
}
// Get the current working directory, including working directory as highest possible root, canonicalize it
let working_dir =
current_dir().expect("failed to get current working directory");
let shared_dir = {
let mut paths = paths.clone();
paths.push(working_dir.clone());
match shared_dir(paths) {
Some(p) => p,
None => quit_error_msg(
"when archiving, all files must be within a same directory",
ErrorHintsBuilder::default().verbose(false).build().unwrap(),
),
}
};
// Build an archiver, append each file
let mut archiver = Archiver::new(archive_file);
for path in &paths {
// Canonicalize the path
let mut path = Path::new(path).to_path_buf();
if let Ok(p) = path.canonicalize() {
path = p;
}
// Find relative name to share dir, used to derive name from
let name = diff_paths(&path, &shared_dir)
.expect("failed to determine relative path of file to archive");
let name = name.to_str().expect("failed to get file path");
// Add file to archiver
archiver
.append_path(name, &path)
.map_err(ArchiveError::AddFile)?;
}
// Finish the archival process, writes the archive file
archiver.finish().map_err(ArchiveError::Write)?;
// Append archive extention to name, set to upload archived file
if let Some(ref mut file_name) = file_name {
file_name.push_str(archive_extention);
}
path = archive_path;
paths.clear();
}
}
}
// Quit with error when uploading multiple files or directory, if we cannot archive
#[cfg(not(feature = "archive"))]
{
if paths.len() > 1 {
quit_error_msg(
"uploading multiple files is not supported, ffsend must be compiled with 'archive' feature for this",
ErrorHintsBuilder::default()
.verbose(false)
.build()
.unwrap(),
);
}
if path.is_dir() {
quit_error_msg(
"uploading a directory is not supported, ffsend must be compiled with 'archive' feature for this",
ErrorHintsBuilder::default()
.verbose(false)
.build()
.unwrap(),
);
}
}
// Create a reqwest client capable for uploading files
let client_config = create_config(&matcher_main);
let client = client_config.clone().client(false);
@ -65,8 +245,6 @@ impl<'a> Upload<'a> {
select_api_version(&client, host.clone(), &mut desired_version)?;
let api_version = desired_version.version().unwrap();
// TODO: ensure the file exists and is accessible
// We do not authenticate for now
let auth = false;
@ -155,83 +333,6 @@ impl<'a> Upload<'a> {
}
};
// The file name to use
#[allow(unused_mut)]
let mut file_name = matcher_upload.name().map(|s| s.to_owned());
// A temporary archive file, only used when archiving
// The temporary file is stored here, to ensure it's lifetime exceeds the upload process
#[allow(unused_mut)]
#[cfg(feature = "archive")]
let mut tmp_archive: Option<NamedTempFile> = None;
#[cfg(feature = "archive")]
{
// Determine whether to archive, ask if a directory was selected
let mut archive = matcher_upload.archive();
if !archive && path.is_dir() {
if prompt_yes(
"You've selected a directory, only a single file may be uploaded.\n\
Archive the directory into a single file?",
Some(true),
&matcher_main,
) {
archive = true;
}
}
// Archive the selected file or directory
if archive {
eprintln!("Archiving...");
let archive_extention = ".tar";
// Create a new temporary file to write the archive to
tmp_archive = Some(
TempBuilder::new()
.prefix(&format!(".{}-archive-", crate_name!()))
.suffix(archive_extention)
.tempfile()
.map_err(ArchiveError::TempFile)?,
);
if let Some(tmp_archive) = &tmp_archive {
// Get the path, and the actual file
let archive_path = tmp_archive.path().to_path_buf();
let archive_file = tmp_archive
.as_file()
.try_clone()
.map_err(ArchiveError::CloneHandle)?;
// Select the file name to use if not set
if file_name.is_none() {
file_name = Some(
path.canonicalize()
.map_err(|err| ArchiveError::FileName(Some(err)))?
.file_name()
.ok_or(ArchiveError::FileName(None))?
.to_str()
.map(|s| s.to_owned())
.ok_or(ArchiveError::FileName(None))?,
);
}
// Build an archiver and append the file
let mut archiver = Archiver::new(archive_file);
archiver
.append_path(file_name.as_ref().unwrap(), &path)
.map_err(ArchiveError::AddFile)?;
// Finish the archival process, writes the archive file
archiver.finish().map_err(ArchiveError::Write)?;
// Append archive extention to name, set to upload archived file
if let Some(ref mut file_name) = file_name {
file_name.push_str(archive_extention);
}
path = archive_path;
}
}
}
// Build the progress reporter
let progress_reporter: Arc<Mutex<ProgressReporter>> = progress_bar;
@ -376,6 +477,74 @@ impl<'a> Upload<'a> {
}
}
/// Find the deepest directory all given paths share.
///
/// This function canonicalizes the paths, make sure the paths exist.
///
/// Returns `None` if paths are using a different root.
///
/// # Examples
///
/// If the following paths are given:
///
/// - `/home/user/git/ffsend/src`
/// - `/home/user/git/ffsend/src/main.rs`
/// - `/home/user/git/ffsend/Cargo.toml`
///
/// The following is returned:
///
/// `/home/user/git/ffsend`
#[cfg(feature = "archive")]
fn shared_dir(paths: Vec<PathBuf>) -> Option<PathBuf> {
// Any path must be given
if paths.is_empty() {
return None;
}
// Build vector
let c: Vec<Vec<PathBuf>> = paths
.into_iter()
.map(|p| p.canonicalize().expect("failed to canonicalize path"))
.map(|mut p| {
// Start with parent if current path is file
if p.is_file() {
p = match p.parent() {
Some(p) => p.to_path_buf(),
None => return vec![],
};
}
// Build list of path buffers for each path component
let mut items = vec![p];
#[allow(mutable_borrow_reservation_conflict)]
while let Some(item) = items.last().unwrap().parent() {
items.push(item.to_path_buf());
}
// Reverse as we built it in the wrong order
items.reverse();
items
})
.collect();
// Find the index at which the paths are last shared at by walking through indices
let i = (0..)
.take_while(|i| {
// Get path for first item, stop if none
let base = &c[0].get(*i);
if base.is_none() {
return false;
};
// All other paths must equal at this index
c.iter().skip(1).all(|p| &p.get(*i) == base)
})
.last();
// Find the shared path
i.map(|i| c[0][i].to_path_buf())
}
#[derive(Debug, Fail)]
pub enum Error {
/// Selecting the API version to use failed.

View file

@ -18,10 +18,11 @@ pub struct UploadMatcher<'a> {
impl<'a: 'b, 'b> UploadMatcher<'a> {
/// Get the selected file to upload.
// TODO: maybe return a file or path instance here
pub fn file(&'a self) -> &'a str {
pub fn files(&'a self) -> Vec<&'a str> {
self.matches
.value_of("FILE")
.values_of("FILE")
.expect("no file specified to upload")
.collect()
}
/// The the name to use for the uploaded file.

View file

@ -16,9 +16,9 @@ impl CmdUpload {
.visible_alias("up")
.arg(
Arg::with_name("FILE")
.help("The file to upload")
.help("The file(s) to upload")
.required(true)
.multiple(false),
.multiple(true),
)
.arg(ArgPassword::build().help("Protect the file with a password"))
.arg(ArgGenPassphrase::build())

View file

@ -132,6 +132,9 @@ pub struct ErrorHints {
/// A list of info messages to print along with the error.
info: Vec<String>,
/// Show about the name option.
name: bool,
/// Show about the password option.
password: bool,
@ -157,7 +160,8 @@ impl ErrorHints {
pub fn any(&self) -> bool {
// Determine the result
#[allow(unused_mut)]
let mut result = self.password || self.owner || self.force || self.verbose || self.help;
let mut result =
self.name || self.password || self.owner || self.force || self.verbose || self.help;
// Factor in the history hint when enabled
#[cfg(feature = "history")]
@ -189,6 +193,12 @@ impl ErrorHints {
highlight("--api <VERSION>")
);
}
if self.name {
eprintln!(
"Use '{}' to specify a file name",
highlight("--name <NAME>")
);
}
if self.password {
eprintln!(
"Use '{}' to specify a password",
@ -230,6 +240,7 @@ impl Default for ErrorHints {
ErrorHints {
api: false,
info: Vec::new(),
name: false,
password: false,
owner: false,
#[cfg(feature = "history")]