diff --git a/Cargo.toml b/Cargo.toml index 8449762..a272cf6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,10 @@ url = "1" hyper-old-types = "0.11.0" futures-util = "0.3.25" +[dependencies.magic] +version = "0.13.0" +optional = true + [dependencies.uuid] version = "1.2.2" features = ["v4"] @@ -80,8 +84,9 @@ html2text = "0.4.4" version = "0.13" [features] -all = ["toml", "json", "env"] -default = ["reqwest/default-tls"] +all = ["toml", "json", "env", "magic"] +# default = ["reqwest/default-tls"] +default = ["reqwest/default-tls", "magic"] env = ["envy"] json = [] rustls-tls = ["reqwest/rustls-tls"] diff --git a/examples/upload_photo.rs b/examples/upload_photo.rs index b7806ef..62c9904 100644 --- a/examples/upload_photo.rs +++ b/examples/upload_photo.rs @@ -30,8 +30,14 @@ async fn main() -> Result<()> { femme::with_level(femme::LevelFilter::Trace); let mastodon = register::get_mastodon_data().await?; let input = register::read_line("Enter the path to the photo you'd like to post: ")?; + let description = register::read_line("describe the media? ")?; + let description = if description.trim().is_empty() { + None + } else { + Some(description) + }; - let media = mastodon.media(input).await?; + let media = mastodon.media(input, description).await?; let status = StatusBuilder::new() .status("Mastodon-async photo upload example/demo (automated post)") .media_ids([media.id]) diff --git a/src/errors.rs b/src/errors.rs index f04e545..eb547ba 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -3,6 +3,8 @@ use std::{error, fmt, io::Error as IoError, num::TryFromIntError}; #[cfg(feature = "env")] use envy::Error as EnvyError; use hyper_old_types::Error as HeaderParseError; +#[cfg(feature = "magic")] +use magic::MagicError; use reqwest::{header::ToStrError as HeaderStrError, Error as HttpError, StatusCode}; use serde::Deserialize; use serde_json::Error as SerdeError; @@ -68,6 +70,9 @@ pub enum Error { /// At the time of writing, this can only be triggered when a file is /// larger than the system's usize allows. IntConversion(TryFromIntError), + #[cfg(feature = "magic")] + /// An error received from the magic crate + Magic(MagicError), /// Other errors Other(String), } @@ -80,30 +85,33 @@ impl fmt::Display for Error { impl error::Error for Error { fn source(&self) -> Option<&(dyn error::Error + 'static)> { + use Error::*; match *self { - Error::Serde(ref e) => Some(e), - Error::UrlEncoded(ref e) => Some(e), - Error::Http(ref e) => Some(e), - Error::Io(ref e) => Some(e), - Error::Url(ref e) => Some(e), + Serde(ref e) => Some(e), + UrlEncoded(ref e) => Some(e), + Http(ref e) => Some(e), + Io(ref e) => Some(e), + Url(ref e) => Some(e), #[cfg(feature = "toml")] - Error::TomlSer(ref e) => Some(e), + TomlSer(ref e) => Some(e), #[cfg(feature = "toml")] - Error::TomlDe(ref e) => Some(e), - Error::HeaderStrError(ref e) => Some(e), - Error::HeaderParseError(ref e) => Some(e), + TomlDe(ref e) => Some(e), + HeaderStrError(ref e) => Some(e), + HeaderParseError(ref e) => Some(e), #[cfg(feature = "env")] - Error::Envy(ref e) => Some(e), - Error::SerdeQs(ref e) => Some(e), - Error::IntConversion(ref e) => Some(e), - Error::Api { + Envy(ref e) => Some(e), + SerdeQs(ref e) => Some(e), + IntConversion(ref e) => Some(e), + #[cfg(feature = "magic")] + Magic(ref e) => Some(e), + Api { .. } - | Error::ClientIdRequired - | Error::ClientSecretRequired - | Error::AccessTokenRequired - | Error::MissingField(_) - | Error::Other(..) => None, + | ClientIdRequired + | ClientSecretRequired + | AccessTokenRequired + | MissingField(_) + | Other(..) => None, } } } @@ -145,14 +153,19 @@ from! { SerdeError => Serde, UrlEncodedError => UrlEncoded, UrlError => Url, - #[cfg(feature = "toml")] TomlSerError => TomlSer, - #[cfg(feature = "toml")] TomlDeError => TomlDe, + #[cfg(feature = "toml")] + TomlSerError => TomlSer, + #[cfg(feature = "toml")] + TomlDeError => TomlDe, HeaderStrError => HeaderStrError, HeaderParseError => HeaderParseError, - #[cfg(feature = "env")] EnvyError => Envy, + #[cfg(feature = "env")] + EnvyError => Envy, SerdeQsError => SerdeQs, String => Other, TryFromIntError => IntConversion, + #[cfg(feature = "magic")] + MagicError => Magic, } #[macro_export] diff --git a/src/macros.rs b/src/macros.rs index 00d1cfc..4b8eee3 100644 --- a/src/macros.rs +++ b/src/macros.rs @@ -163,33 +163,15 @@ macro_rules! route_v2 { "`, with a description/alt-text.", "\n# Errors\nIf `access_token` is not set."), pub async fn $name(&self $(, $param: $typ)*, description: Option) -> Result<$ret> { - use reqwest::multipart::{Form, Part}; - use std::io::Read; - use log::{debug, error, as_debug}; + use reqwest::multipart::Form; + use log::{debug, as_debug}; use uuid::Uuid; let call_id = Uuid::new_v4(); let form_data = Form::new() $( - .part(stringify!($param), { - let path = $param.as_ref(); - match std::fs::File::open(path) { - Ok(mut file) => { - let mut data = if let Ok(metadata) = file.metadata() { - Vec::with_capacity(metadata.len().try_into()?) - } else { - vec![] - }; - file.read_to_end(&mut data)?; - Part::bytes(data) - } - Err(err) => { - error!(path = as_debug!(path), error = as_debug!(err); "error reading file contents for multipart form"); - return Err(err.into()); - } - } - }) + .part(stringify!($param), self.get_form_part($param)?) )*; let form_data = if let Some(description) = description { @@ -224,33 +206,16 @@ macro_rules! route_v2 { $url, "`\n# Errors\nIf `access_token` is not set."), pub async fn $name(&self, $($param: $typ,)*) -> Result<$ret> { - use reqwest::multipart::{Form, Part}; - use std::io::Read; - use log::{debug, error, as_debug}; + use reqwest::multipart::Form; + use log::{debug, as_debug}; use uuid::Uuid; + let call_id = Uuid::new_v4(); let form_data = Form::new() $( - .part(stringify!($param), { - let path = $param.as_ref(); - match std::fs::File::open(path) { - Ok(mut file) => { - let mut data = if let Ok(metadata) = file.metadata() { - Vec::with_capacity(metadata.len().try_into()?) - } else { - vec![] - }; - file.read_to_end(&mut data)?; - Part::bytes(data) - } - Err(err) => { - error!(path = as_debug!(path), error = as_debug!(err); "error reading file contents for multipart form"); - return Err(err.into()); - } - } - }) + .part(stringify!($param), self.get_form_part($param)?) )*; let url = &self.route(concat!("/api/v2/", $url)); @@ -285,33 +250,16 @@ macro_rules! route { $url, "`\n# Errors\nIf `access_token` is not set."), pub async fn $name(&self, $($param: $typ,)*) -> Result<$ret> { - use reqwest::multipart::{Form, Part}; - use std::io::Read; - use log::{debug, error, as_debug}; + use reqwest::multipart::Form; + use log::{debug, as_debug}; use uuid::Uuid; + let call_id = Uuid::new_v4(); let form_data = Form::new() $( - .part(stringify!($param), { - let path = $param.as_ref(); - match std::fs::File::open(path) { - Ok(mut file) => { - let mut data = if let Ok(metadata) = file.metadata() { - Vec::with_capacity(metadata.len().try_into()?) - } else { - vec![] - }; - file.read_to_end(&mut data)?; - Part::bytes(data) - } - Err(err) => { - error!(path = as_debug!(path), error = as_debug!(err); "error reading file contents for multipart form"); - return Err(err.into()); - } - } - }) + .part(stringify!($param), self.get_form_part($param)?) )*; let url = &self.route(concat!("/api/v1/", $url)); @@ -343,33 +291,16 @@ macro_rules! route { "`, with a description/alt-text.", "\n# Errors\nIf `access_token` is not set."), pub async fn $name(&self $(, $param: $typ)*, description: Option) -> Result<$ret> { - use reqwest::multipart::{Form, Part}; - use std::io::Read; - use log::{debug, error, as_debug}; + use reqwest::multipart::Form; + use log::{debug, as_debug}; use uuid::Uuid; + let call_id = Uuid::new_v4(); let form_data = Form::new() $( - .part(stringify!($param), { - let path = $param.as_ref(); - match std::fs::File::open(path) { - Ok(mut file) => { - let mut data = if let Ok(metadata) = file.metadata() { - Vec::with_capacity(metadata.len().try_into()?) - } else { - vec![] - }; - file.read_to_end(&mut data)?; - Part::bytes(data) - } - Err(err) => { - error!(path = as_debug!(path), error = as_debug!(err); "error reading file contents for multipart form"); - return Err(err.into()); - } - } - }) + .part(stringify!($param), self.get_form_part($param)?) )*; let form_data = if let Some(description) = description { diff --git a/src/mastodon.rs b/src/mastodon.rs index dfe1aaa..dc585e2 100644 --- a/src/mastodon.rs +++ b/src/mastodon.rs @@ -23,16 +23,21 @@ use crate::{ }; use futures::TryStream; use log::{as_debug, as_serde, debug, error, trace}; -use reqwest::{Client, RequestBuilder}; +#[cfg(feature = "magic")] +use magic::CookieFlags; +use reqwest::{multipart::Part, Client, RequestBuilder}; use url::Url; use uuid::Uuid; /// The Mastodon client is a smart pointer to this struct -#[derive(Clone, Debug)] +#[derive(Debug)] pub struct MastodonClient { pub(crate) client: Client, /// Raw data about your mastodon instance. pub data: Data, + /// A handle to access libmagic for mime-types. + #[cfg(feature = "magic")] + magic: magic::Cookie, } /// Your mastodon application client, handles all requests to and from Mastodon. @@ -49,6 +54,18 @@ pub struct MastodonUnauthenticated { impl From for Mastodon { /// Creates a mastodon instance from the data struct. + #[cfg(feature = "magic")] + fn from(data: Data) -> Mastodon { + MastodonClient { + client: Client::new(), + data, + magic: Self::default_magic().expect("failed to open magic cookie or load database"), + } + .into() + } + + /// Creates a mastodon instance from the data struct. + #[cfg(not(feature = "magic"))] fn from(data: Data) -> Mastodon { MastodonClient { client: Client::new(), @@ -156,7 +173,41 @@ impl Mastodon { stream_direct@"direct", } + /// Return a magic cookie, loaded with the default mime + #[cfg(feature = "magic")] + fn default_magic() -> Result { + let magic = magic::Cookie::open(Default::default())?; + magic.load::<&str>(&[])?; + magic.set_flags(CookieFlags::MIME)?; + Ok(magic) + } + /// Create a new Mastodon Client + #[cfg(feature = "magic")] + pub fn new(client: Client, data: Data) -> Self { + Self::new_with_magic( + client, + data, + Self::default_magic().expect("failed to open magic cookie or load database"), + ) + } + + /// Create a new Mastodon Client, passing in a pre-constructed magic + /// cookie. + /// + /// This is mainly here so you have a wait to construct the client which + /// won't panic. + #[cfg(feature = "magic")] + pub fn new_with_magic(client: Client, data: Data, magic: magic::Cookie) -> Self { + Mastodon(Arc::new(MastodonClient { + client, + data, + magic, + })) + } + + /// Create a new Mastodon Client + #[cfg(not(feature = "magic"))] pub fn new(client: Client, data: Data) -> Self { Mastodon(Arc::new(MastodonClient { client, @@ -342,6 +393,43 @@ impl Mastodon { fn authenticated(&self, request: RequestBuilder) -> RequestBuilder { request.bearer_auth(&self.data.token) } + + /// Return a part for a multipart form submission from a file, including + /// the name of the file, and, if the "magic" feature is enabled, the mime- + /// type. + fn get_form_part(&self, path: impl AsRef) -> Result { + use std::io::Read; + + let path = path.as_ref(); + let mime = if cfg!(feature = "magic") { + self.magic.file(path).ok() + // if it doesn't work, it's no big deal. The server will look at + // the filepath if this isn't here and things should still work. + } else { + None + }; + match std::fs::File::open(path) { + Ok(mut file) => { + let mut data = if let Ok(metadata) = file.metadata() { + Vec::with_capacity(metadata.len().try_into()?) + } else { + vec![] + }; + file.read_to_end(&mut data)?; + let part = + Part::bytes(data).file_name(Cow::Owned(path.to_string_lossy().to_string())); + Ok(if let Some(mime) = mime { + part.mime_str(&mime)? + } else { + part + }) + }, + Err(err) => { + error!(path = as_debug!(path), error = as_debug!(err); "error reading file contents for multipart form"); + return Err(err.into()); + }, + } + } } impl MastodonUnauthenticated {