Implement archive download extract feature
This commit is contained in:
parent
f2771f7953
commit
738ae46054
15 changed files with 214 additions and 34 deletions
|
@ -19,5 +19,6 @@ toolchain.
|
|||
- `FFSEND_INCOGNITO`: incognito mode (present/boolean)
|
||||
- `FFSEND_OPEN`: open an uploaded file (present/boolean)
|
||||
- `FFSEND_ARCHIVE`: enable file archival (present/boolean)
|
||||
- `FFSEND_EXTRACT`: enable file extraction (present/boolean)
|
||||
- `FFSEND_COPY`: copy share URL to clipboard (present/boolean)
|
||||
- `FFSEND_VERBOSE`: copy share URL to clipboard (present/boolean)
|
||||
|
|
|
@ -3,8 +3,6 @@ The first release used for gathering feedback on the application by selected
|
|||
people.
|
||||
|
||||
Features:
|
||||
- Make use of stdout and stderr consistent
|
||||
- Allow file/directory archiving on upload
|
||||
- Allow unarchiving on download
|
||||
- Use clipboard through `xclip` on Linux if available for persistence
|
||||
- Write complete README
|
||||
|
@ -16,6 +14,7 @@ Features:
|
|||
- Arch AUR package
|
||||
- Windows, macOS and Redox support
|
||||
- Implement verbose logging with `-v`
|
||||
- Make use of stdout and stderr consistent
|
||||
- Allow empty owner token for info command
|
||||
- Check and validate all errors, are some too verbose?
|
||||
|
||||
|
@ -23,6 +22,7 @@ Features:
|
|||
The first public release.
|
||||
|
||||
Features:
|
||||
- Do not write archives to the disk (temporarily), stream their contents
|
||||
- Implement error handling everywhere properly
|
||||
- Extract utility module
|
||||
- Embed/wrap request errors with failure
|
||||
|
@ -41,6 +41,8 @@ Features:
|
|||
- Host configuration file for host tags, to easily upload to other hosts
|
||||
|
||||
# Other ideas
|
||||
- Check if extracting an archive overwrites files
|
||||
- Flag to disable logging to stderr
|
||||
- Rework encrypted reader/writer
|
||||
- API actions contain duplicate code, create centralized functions
|
||||
- Only allow file extension renaming on upload with `-f` flag
|
||||
|
|
|
@ -13,6 +13,9 @@ use serde_json;
|
|||
|
||||
use crypto::b64;
|
||||
|
||||
/// The MIME type string for a tar file.
|
||||
const MIME_TAR: &str = "application/x-tar";
|
||||
|
||||
/// File metadata, which is send to the server.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Metadata {
|
||||
|
@ -66,6 +69,14 @@ impl Metadata {
|
|||
// Create a sized array
|
||||
*array_ref!(decoded, 0, 12)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether this MIME type is recognized as supported archive type.
|
||||
* `true` is returned if it's an archive, `false` if not.
|
||||
*/
|
||||
pub fn is_archive(&self) -> bool {
|
||||
self.mime.to_lowercase() == MIME_TAR.to_lowercase()
|
||||
}
|
||||
}
|
||||
|
||||
/// A X-File-Metadata header for reqwest, that is used to pass encrypted
|
||||
|
|
|
@ -14,7 +14,7 @@ use cmd::matcher::{
|
|||
Matcher,
|
||||
};
|
||||
use error::ActionError;
|
||||
use util::format_duration;
|
||||
use util::{format_bool, format_duration};
|
||||
|
||||
/// A file debug action.
|
||||
pub struct Debug<'a> {
|
||||
|
@ -58,6 +58,12 @@ impl<'a> Debug<'a> {
|
|||
Cell::new(&format_duration(Duration::seconds(SEND_DEFAULT_EXPIRE_TIME))),
|
||||
]));
|
||||
|
||||
// Show whether verbose is used
|
||||
table.add_row(Row::new(vec![
|
||||
Cell::new("Verbose:"),
|
||||
Cell::new(format_bool(matcher_main.verbose())),
|
||||
]));
|
||||
|
||||
// Print the debug table
|
||||
table.printstd();
|
||||
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
use std::env::current_dir;
|
||||
use std::fs::create_dir_all;
|
||||
use std::io::Error as IoError;
|
||||
use std::path::{self, PathBuf};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
|
@ -20,7 +21,13 @@ use ffsend_api::action::metadata::{
|
|||
use ffsend_api::file::remote_file::{FileParseError, RemoteFile};
|
||||
use ffsend_api::reader::ProgressReporter;
|
||||
use ffsend_api::reqwest::Client;
|
||||
use tempfile::{
|
||||
Builder as TempBuilder,
|
||||
NamedTempFile,
|
||||
};
|
||||
|
||||
#[cfg(feature = "archive")]
|
||||
use archive::archive::Archive;
|
||||
use cmd::matcher::{
|
||||
Matcher,
|
||||
download::DownloadMatcher,
|
||||
|
@ -92,12 +99,60 @@ impl<'a> Download<'a> {
|
|||
false,
|
||||
).invoke(&client)?;
|
||||
|
||||
// Prepare the output path to use
|
||||
let target = Self::prepare_path(
|
||||
// A temporary archive file, only used when archiving
|
||||
// The temporary file is stored here, to ensure it's lifetime exceeds the upload process
|
||||
#[cfg(feature = "archive")]
|
||||
let mut tmp_archive: Option<NamedTempFile> = None;
|
||||
|
||||
// Check whether to extract
|
||||
#[cfg(feature = "archive")]
|
||||
let mut extract = matcher_download.extract();
|
||||
|
||||
#[cfg(feature = "archive")] {
|
||||
// Ask to extract if downloading an archive
|
||||
if !extract && metadata.metadata().is_archive() {
|
||||
if prompt_yes(
|
||||
"You're downloading an archive, extract it into the selected directory?",
|
||||
Some(true),
|
||||
&matcher_main,
|
||||
) {
|
||||
extract = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare the download target and output path to use
|
||||
#[cfg(feature = "archive")]
|
||||
let output_dir = !extract;
|
||||
#[cfg(not(feature = "archive"))]
|
||||
let output_dir = false;
|
||||
let mut target = Self::prepare_path(
|
||||
&target,
|
||||
metadata.metadata().name(),
|
||||
&matcher_main,
|
||||
output_dir,
|
||||
);
|
||||
let output_path = target.clone();
|
||||
|
||||
#[cfg(feature = "archive")] {
|
||||
// Allocate an archive file, and update the download and target paths
|
||||
if extract {
|
||||
// TODO: select the extention dynamically
|
||||
let archive_extention = ".tar";
|
||||
|
||||
// Allocate a temporary file to download the archive to
|
||||
tmp_archive = Some(
|
||||
TempBuilder::new()
|
||||
.prefix(&format!(".{}-archive-", crate_name!()))
|
||||
.suffix(archive_extention)
|
||||
.tempfile()
|
||||
.map_err(ExtractError::TempFile)?
|
||||
);
|
||||
if let Some(tmp_archive) = &tmp_archive {
|
||||
target = tmp_archive.path().to_path_buf();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure there is enough disk space available when not being forced
|
||||
if !matcher_main.force() {
|
||||
|
@ -117,6 +172,18 @@ impl<'a> Download<'a> {
|
|||
Some(metadata),
|
||||
).invoke(&client, &progress_reader)?;
|
||||
|
||||
// Extract the downloaded file if working with an archive
|
||||
#[cfg(feature = "archive")] {
|
||||
if extract {
|
||||
eprintln!("Extracting...");
|
||||
|
||||
// Extract the downloaded file
|
||||
Archive::new(tmp_archive.unwrap().into_file())
|
||||
.extract(output_path)
|
||||
.map_err(ExtractError::Extract)?;
|
||||
}
|
||||
}
|
||||
|
||||
// Add the file to the history
|
||||
#[cfg(feature = "history")]
|
||||
history_tool::add(&matcher_main, file, true);
|
||||
|
@ -130,6 +197,8 @@ impl<'a> Download<'a> {
|
|||
/// This methods prepares a full file path to use for the file to
|
||||
/// download, based on the current directory, the original file name,
|
||||
/// and the user input.
|
||||
/// If `file` is set to false, no file name is included and the path
|
||||
/// will point to a directory.
|
||||
///
|
||||
/// If no file name was given, the original file name is used.
|
||||
///
|
||||
|
@ -143,12 +212,18 @@ impl<'a> Download<'a> {
|
|||
target: &PathBuf,
|
||||
name_hint: &str,
|
||||
main_matcher: &MainMatcher,
|
||||
file: bool,
|
||||
) -> PathBuf {
|
||||
// Select the path to use
|
||||
let target = Self::select_path(&target, name_hint);
|
||||
let mut target = Self::select_path(&target, name_hint);
|
||||
|
||||
// Use the parent directory, if we don't want a file
|
||||
if !file {
|
||||
target = target.parent().unwrap().to_path_buf();
|
||||
}
|
||||
|
||||
// Ask to overwrite
|
||||
if target.exists() && !main_matcher.force() {
|
||||
if file && target.exists() && !main_matcher.force() {
|
||||
eprintln!(
|
||||
"The path '{}' already exists",
|
||||
target.to_str().unwrap_or("?"),
|
||||
|
@ -159,14 +234,27 @@ impl<'a> Download<'a> {
|
|||
}
|
||||
}
|
||||
|
||||
// Validate the parent directory exists
|
||||
match target.parent() {
|
||||
Some(parent) => if !parent.is_dir() {
|
||||
{
|
||||
// Get the deepest directory, as we have to ensure it exists
|
||||
let dir = if file {
|
||||
match target.parent() {
|
||||
Some(parent) => parent,
|
||||
None => quit_error_msg(
|
||||
"invalid output file path",
|
||||
ErrorHints::default(),
|
||||
),
|
||||
}
|
||||
} else {
|
||||
&target
|
||||
};
|
||||
|
||||
// Ensure the directory exists
|
||||
if !dir.is_dir() {
|
||||
// Prompt to create them if not forced
|
||||
if !main_matcher.force() {
|
||||
eprintln!(
|
||||
"The directory '{}' doesn't exists",
|
||||
parent.to_str().unwrap_or("?"),
|
||||
dir.to_str().unwrap_or("?"),
|
||||
);
|
||||
if !prompt_yes("Create it?", Some(true), main_matcher) {
|
||||
println!("Download cancelled");
|
||||
|
@ -175,16 +263,12 @@ impl<'a> Download<'a> {
|
|||
}
|
||||
|
||||
// Create the parent directories
|
||||
if let Err(err) = create_dir_all(parent) {
|
||||
if let Err(err) = create_dir_all(dir) {
|
||||
quit_error(err.context(
|
||||
"failed to create parent directories for output file",
|
||||
), ErrorHints::default());
|
||||
}
|
||||
},
|
||||
None => quit_error_msg(
|
||||
"invalid output file path",
|
||||
ErrorHints::default(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
target
|
||||
|
@ -280,6 +364,10 @@ pub enum Error {
|
|||
#[fail(display = "")]
|
||||
Download(#[cause] DownloadError),
|
||||
|
||||
/// An error occurred while extracting the file.
|
||||
#[fail(display = "failed the extraction procedure")]
|
||||
Extract(#[cause] ExtractError),
|
||||
|
||||
/// The given Send file has expired, or did never exist in the first place.
|
||||
#[fail(display = "the file has expired or did never exist")]
|
||||
Expired,
|
||||
|
@ -308,3 +396,20 @@ impl From<DownloadError> for Error {
|
|||
Error::Download(err)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ExtractError> for Error {
|
||||
fn from(err: ExtractError) -> Error {
|
||||
Error::Extract(err)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Fail)]
|
||||
pub enum ExtractError {
|
||||
/// An error occurred while creating the temporary archive file.
|
||||
#[fail(display = "failed to create temporary archive file")]
|
||||
TempFile(#[cause] IoError),
|
||||
|
||||
/// Failed to extract the file contents to the target directory.
|
||||
#[fail(display = "failed to extract archive contents to target directory")]
|
||||
Extract(#[cause] IoError),
|
||||
}
|
||||
|
|
|
@ -1,5 +1,3 @@
|
|||
extern crate tempfile;
|
||||
|
||||
use std::fs::File;
|
||||
use std::io::Error as IoError;
|
||||
use std::path::Path;
|
||||
|
@ -21,7 +19,7 @@ use prettytable::{
|
|||
row::Row,
|
||||
Table,
|
||||
};
|
||||
use self::tempfile::{
|
||||
use tempfile::{
|
||||
Builder as TempBuilder,
|
||||
NamedTempFile,
|
||||
};
|
||||
|
@ -137,8 +135,7 @@ impl<'a> Upload<'a> {
|
|||
// The temporary file is stored here, to ensure it's lifetime exceeds the upload process
|
||||
let mut tmp_archive: Option<NamedTempFile> = None;
|
||||
|
||||
#[cfg(feature = "archive")]
|
||||
{
|
||||
#[cfg(feature = "archive")] {
|
||||
// Determine whether to archive, ask if a directory was selected
|
||||
let mut archive = matcher_upload.archive();
|
||||
if !archive && path.is_dir() {
|
||||
|
@ -244,15 +241,13 @@ impl<'a> Upload<'a> {
|
|||
}
|
||||
|
||||
// Copy the URL in the user's clipboard
|
||||
#[cfg(feature = "clipboard")]
|
||||
{
|
||||
#[cfg(feature = "clipboard")] {
|
||||
if matcher_upload.copy() && set_clipboard(url.as_str().to_owned()).is_err() {
|
||||
print_error_msg("failed to copy the URL to the clipboard");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "archive")]
|
||||
{
|
||||
#[cfg(feature = "archive")] {
|
||||
// Close the temporary zip file, to ensure it's removed
|
||||
if let Some(tmp_archive) = tmp_archive.take() {
|
||||
if let Err(err) = tmp_archive.close() {
|
||||
|
|
28
cli/src/archive/archive.rs
Normal file
28
cli/src/archive/archive.rs
Normal file
|
@ -0,0 +1,28 @@
|
|||
use std::io::{
|
||||
Error as IoError,
|
||||
Read,
|
||||
};
|
||||
use std::path::Path;
|
||||
|
||||
use super::tar::Archive as TarArchive;
|
||||
|
||||
pub type Result<T> = ::std::result::Result<T, IoError>;
|
||||
|
||||
pub struct Archive<R: Read> {
|
||||
/// The tar archive
|
||||
inner: TarArchive<R>,
|
||||
}
|
||||
|
||||
impl<R: Read> Archive<R> {
|
||||
/// Construct a new archive extractor.
|
||||
pub fn new(reader: R) -> Archive<R> {
|
||||
Archive {
|
||||
inner: TarArchive::new(reader),
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract the archive to the given destination.
|
||||
pub fn extract<P: AsRef<Path>>(&mut self, destination: P) -> Result<()> {
|
||||
self.inner.unpack(destination)
|
||||
}
|
||||
}
|
|
@ -1,5 +1,3 @@
|
|||
extern crate tar;
|
||||
|
||||
use std::fs::File;
|
||||
use std::io::{
|
||||
Error as IoError,
|
||||
|
@ -7,7 +5,7 @@ use std::io::{
|
|||
};
|
||||
use std::path::Path;
|
||||
|
||||
use self::tar::Builder as TarBuilder;
|
||||
use super::tar::Builder as TarBuilder;
|
||||
|
||||
pub type Result<T> = ::std::result::Result<T, IoError>;
|
||||
|
||||
|
|
|
@ -1 +1,4 @@
|
|||
extern crate tar;
|
||||
|
||||
pub mod archive;
|
||||
pub mod archiver;
|
||||
|
|
|
@ -5,6 +5,7 @@ use ffsend_api::url::Url;
|
|||
|
||||
use cmd::arg::{ArgPassword, ArgUrl, CmdArgOption};
|
||||
use super::Matcher;
|
||||
use util::env_var_present;
|
||||
|
||||
/// The download command matcher.
|
||||
pub struct DownloadMatcher<'a> {
|
||||
|
@ -35,6 +36,12 @@ impl<'a: 'b, 'b> DownloadMatcher<'a> {
|
|||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| PathBuf::from("./"))
|
||||
}
|
||||
|
||||
/// Check whether to extract an archived file.
|
||||
#[cfg(feature = "archive")]
|
||||
pub fn extract(&self) -> bool {
|
||||
self.matches.is_present("extract") || env_var_present("FFSEND_EXTRACT")
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Matcher<'a> for DownloadMatcher<'a> {
|
||||
|
|
|
@ -72,7 +72,6 @@ impl<'a: 'b, 'b> UploadMatcher<'a> {
|
|||
}
|
||||
|
||||
/// Check whether to archive the file to upload.
|
||||
/// TODO: infer to use this flag if a directory is selected
|
||||
#[cfg(feature = "archive")]
|
||||
pub fn archive(&self) -> bool {
|
||||
self.matches.is_present("archive") || env_var_present("FFSEND_ARCHIVE")
|
||||
|
|
|
@ -7,7 +7,8 @@ pub struct CmdDownload;
|
|||
|
||||
impl CmdDownload {
|
||||
pub fn build<'a, 'b>() -> App<'a, 'b> {
|
||||
SubCommand::with_name("download")
|
||||
// Build the subcommand
|
||||
let mut cmd = SubCommand::with_name("download")
|
||||
.about("Download files")
|
||||
.visible_alias("d")
|
||||
.visible_alias("down")
|
||||
|
@ -20,6 +21,19 @@ impl CmdDownload {
|
|||
.alias("out")
|
||||
.alias("file")
|
||||
.value_name("PATH")
|
||||
.help("The output file or directory"))
|
||||
.help("The output file or directory"));
|
||||
|
||||
// Optional archive support
|
||||
#[cfg(feature = "archive")] {
|
||||
cmd = cmd.arg(Arg::with_name("extract")
|
||||
.long("extract")
|
||||
.short("e")
|
||||
.alias("archive")
|
||||
.alias("arch")
|
||||
.alias("a")
|
||||
.help("Extract an archived file"))
|
||||
}
|
||||
|
||||
cmd
|
||||
}
|
||||
}
|
||||
|
|
|
@ -43,7 +43,7 @@ impl CmdUpload {
|
|||
.long("archive")
|
||||
.short("a")
|
||||
.alias("arch")
|
||||
.help("Package the file as an archive"))
|
||||
.help("Archive the upload in a single file"))
|
||||
}
|
||||
|
||||
// Optional clipboard support
|
||||
|
@ -52,7 +52,8 @@ impl CmdUpload {
|
|||
.long("copy")
|
||||
.short("c")
|
||||
.help("Copy the share link to your clipboard"));
|
||||
}
|
||||
}
|
||||
|
||||
cmd
|
||||
}
|
||||
}
|
||||
|
|
|
@ -16,6 +16,7 @@ extern crate serde;
|
|||
#[cfg(feature = "history")]
|
||||
#[macro_use]
|
||||
extern crate serde_derive;
|
||||
extern crate tempfile;
|
||||
|
||||
mod action;
|
||||
#[cfg(feature = "archive")]
|
||||
|
|
|
@ -561,6 +561,15 @@ pub fn format_duration(duration: impl Borrow<Duration>) -> String {
|
|||
components.join("")
|
||||
}
|
||||
|
||||
/// Format the given boolean, as `yes` or `no`.
|
||||
pub fn format_bool(b: bool) -> &'static str {
|
||||
if b {
|
||||
"yes"
|
||||
} else {
|
||||
"no"
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the name of the executable that was invoked.
|
||||
pub fn exe_name() -> String {
|
||||
current_exe()
|
||||
|
|
Loading…
Add table
Reference in a new issue