Fix bug in media upload, add optional detectoin of mime-/file-type

This commit is contained in:
D. Scott Boggs 2022-12-26 11:19:54 -05:00
parent 88bfa67f5a
commit 6e5c93d997
5 changed files with 153 additions and 110 deletions

View File

@ -26,6 +26,10 @@ url = "1"
hyper-old-types = "0.11.0" hyper-old-types = "0.11.0"
futures-util = "0.3.25" futures-util = "0.3.25"
[dependencies.magic]
version = "0.13.0"
optional = true
[dependencies.uuid] [dependencies.uuid]
version = "1.2.2" version = "1.2.2"
features = ["v4"] features = ["v4"]
@ -80,8 +84,9 @@ html2text = "0.4.4"
version = "0.13" version = "0.13"
[features] [features]
all = ["toml", "json", "env"] all = ["toml", "json", "env", "magic"]
default = ["reqwest/default-tls"] # default = ["reqwest/default-tls"]
default = ["reqwest/default-tls", "magic"]
env = ["envy"] env = ["envy"]
json = [] json = []
rustls-tls = ["reqwest/rustls-tls"] rustls-tls = ["reqwest/rustls-tls"]

View File

@ -30,8 +30,14 @@ async fn main() -> Result<()> {
femme::with_level(femme::LevelFilter::Trace); femme::with_level(femme::LevelFilter::Trace);
let mastodon = register::get_mastodon_data().await?; let mastodon = register::get_mastodon_data().await?;
let input = register::read_line("Enter the path to the photo you'd like to post: ")?; 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() let status = StatusBuilder::new()
.status("Mastodon-async photo upload example/demo (automated post)") .status("Mastodon-async photo upload example/demo (automated post)")
.media_ids([media.id]) .media_ids([media.id])

View File

@ -3,6 +3,8 @@ use std::{error, fmt, io::Error as IoError, num::TryFromIntError};
#[cfg(feature = "env")] #[cfg(feature = "env")]
use envy::Error as EnvyError; use envy::Error as EnvyError;
use hyper_old_types::Error as HeaderParseError; use hyper_old_types::Error as HeaderParseError;
#[cfg(feature = "magic")]
use magic::MagicError;
use reqwest::{header::ToStrError as HeaderStrError, Error as HttpError, StatusCode}; use reqwest::{header::ToStrError as HeaderStrError, Error as HttpError, StatusCode};
use serde::Deserialize; use serde::Deserialize;
use serde_json::Error as SerdeError; 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 /// At the time of writing, this can only be triggered when a file is
/// larger than the system's usize allows. /// larger than the system's usize allows.
IntConversion(TryFromIntError), IntConversion(TryFromIntError),
#[cfg(feature = "magic")]
/// An error received from the magic crate
Magic(MagicError),
/// Other errors /// Other errors
Other(String), Other(String),
} }
@ -80,30 +85,33 @@ impl fmt::Display for Error {
impl error::Error for Error { impl error::Error for Error {
fn source(&self) -> Option<&(dyn error::Error + 'static)> { fn source(&self) -> Option<&(dyn error::Error + 'static)> {
use Error::*;
match *self { match *self {
Error::Serde(ref e) => Some(e), Serde(ref e) => Some(e),
Error::UrlEncoded(ref e) => Some(e), UrlEncoded(ref e) => Some(e),
Error::Http(ref e) => Some(e), Http(ref e) => Some(e),
Error::Io(ref e) => Some(e), Io(ref e) => Some(e),
Error::Url(ref e) => Some(e), Url(ref e) => Some(e),
#[cfg(feature = "toml")] #[cfg(feature = "toml")]
Error::TomlSer(ref e) => Some(e), TomlSer(ref e) => Some(e),
#[cfg(feature = "toml")] #[cfg(feature = "toml")]
Error::TomlDe(ref e) => Some(e), TomlDe(ref e) => Some(e),
Error::HeaderStrError(ref e) => Some(e), HeaderStrError(ref e) => Some(e),
Error::HeaderParseError(ref e) => Some(e), HeaderParseError(ref e) => Some(e),
#[cfg(feature = "env")] #[cfg(feature = "env")]
Error::Envy(ref e) => Some(e), Envy(ref e) => Some(e),
Error::SerdeQs(ref e) => Some(e), SerdeQs(ref e) => Some(e),
Error::IntConversion(ref e) => Some(e), IntConversion(ref e) => Some(e),
Error::Api { #[cfg(feature = "magic")]
Magic(ref e) => Some(e),
Api {
.. ..
} }
| Error::ClientIdRequired | ClientIdRequired
| Error::ClientSecretRequired | ClientSecretRequired
| Error::AccessTokenRequired | AccessTokenRequired
| Error::MissingField(_) | MissingField(_)
| Error::Other(..) => None, | Other(..) => None,
} }
} }
} }
@ -145,14 +153,19 @@ from! {
SerdeError => Serde, SerdeError => Serde,
UrlEncodedError => UrlEncoded, UrlEncodedError => UrlEncoded,
UrlError => Url, UrlError => Url,
#[cfg(feature = "toml")] TomlSerError => TomlSer, #[cfg(feature = "toml")]
#[cfg(feature = "toml")] TomlDeError => TomlDe, TomlSerError => TomlSer,
#[cfg(feature = "toml")]
TomlDeError => TomlDe,
HeaderStrError => HeaderStrError, HeaderStrError => HeaderStrError,
HeaderParseError => HeaderParseError, HeaderParseError => HeaderParseError,
#[cfg(feature = "env")] EnvyError => Envy, #[cfg(feature = "env")]
EnvyError => Envy,
SerdeQsError => SerdeQs, SerdeQsError => SerdeQs,
String => Other, String => Other,
TryFromIntError => IntConversion, TryFromIntError => IntConversion,
#[cfg(feature = "magic")]
MagicError => Magic,
} }
#[macro_export] #[macro_export]

View File

@ -163,33 +163,15 @@ macro_rules! route_v2 {
"`, with a description/alt-text.", "`, with a description/alt-text.",
"\n# Errors\nIf `access_token` is not set."), "\n# Errors\nIf `access_token` is not set."),
pub async fn $name(&self $(, $param: $typ)*, description: Option<String>) -> Result<$ret> { pub async fn $name(&self $(, $param: $typ)*, description: Option<String>) -> Result<$ret> {
use reqwest::multipart::{Form, Part}; use reqwest::multipart::Form;
use std::io::Read; use log::{debug, as_debug};
use log::{debug, error, as_debug};
use uuid::Uuid; use uuid::Uuid;
let call_id = Uuid::new_v4(); let call_id = Uuid::new_v4();
let form_data = Form::new() let form_data = Form::new()
$( $(
.part(stringify!($param), { .part(stringify!($param), self.get_form_part($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());
}
}
})
)*; )*;
let form_data = if let Some(description) = description { let form_data = if let Some(description) = description {
@ -224,33 +206,16 @@ macro_rules! route_v2 {
$url, $url,
"`\n# Errors\nIf `access_token` is not set."), "`\n# Errors\nIf `access_token` is not set."),
pub async fn $name(&self, $($param: $typ,)*) -> Result<$ret> { pub async fn $name(&self, $($param: $typ,)*) -> Result<$ret> {
use reqwest::multipart::{Form, Part}; use reqwest::multipart::Form;
use std::io::Read; use log::{debug, as_debug};
use log::{debug, error, as_debug};
use uuid::Uuid; use uuid::Uuid;
let call_id = Uuid::new_v4(); let call_id = Uuid::new_v4();
let form_data = Form::new() let form_data = Form::new()
$( $(
.part(stringify!($param), { .part(stringify!($param), self.get_form_part($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());
}
}
})
)*; )*;
let url = &self.route(concat!("/api/v2/", $url)); let url = &self.route(concat!("/api/v2/", $url));
@ -285,33 +250,16 @@ macro_rules! route {
$url, $url,
"`\n# Errors\nIf `access_token` is not set."), "`\n# Errors\nIf `access_token` is not set."),
pub async fn $name(&self, $($param: $typ,)*) -> Result<$ret> { pub async fn $name(&self, $($param: $typ,)*) -> Result<$ret> {
use reqwest::multipart::{Form, Part}; use reqwest::multipart::Form;
use std::io::Read; use log::{debug, as_debug};
use log::{debug, error, as_debug};
use uuid::Uuid; use uuid::Uuid;
let call_id = Uuid::new_v4(); let call_id = Uuid::new_v4();
let form_data = Form::new() let form_data = Form::new()
$( $(
.part(stringify!($param), { .part(stringify!($param), self.get_form_part($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());
}
}
})
)*; )*;
let url = &self.route(concat!("/api/v1/", $url)); let url = &self.route(concat!("/api/v1/", $url));
@ -343,33 +291,16 @@ macro_rules! route {
"`, with a description/alt-text.", "`, with a description/alt-text.",
"\n# Errors\nIf `access_token` is not set."), "\n# Errors\nIf `access_token` is not set."),
pub async fn $name(&self $(, $param: $typ)*, description: Option<String>) -> Result<$ret> { pub async fn $name(&self $(, $param: $typ)*, description: Option<String>) -> Result<$ret> {
use reqwest::multipart::{Form, Part}; use reqwest::multipart::Form;
use std::io::Read; use log::{debug, as_debug};
use log::{debug, error, as_debug};
use uuid::Uuid; use uuid::Uuid;
let call_id = Uuid::new_v4(); let call_id = Uuid::new_v4();
let form_data = Form::new() let form_data = Form::new()
$( $(
.part(stringify!($param), { .part(stringify!($param), self.get_form_part($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());
}
}
})
)*; )*;
let form_data = if let Some(description) = description { let form_data = if let Some(description) = description {

View File

@ -23,16 +23,21 @@ use crate::{
}; };
use futures::TryStream; use futures::TryStream;
use log::{as_debug, as_serde, debug, error, trace}; 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 url::Url;
use uuid::Uuid; use uuid::Uuid;
/// The Mastodon client is a smart pointer to this struct /// The Mastodon client is a smart pointer to this struct
#[derive(Clone, Debug)] #[derive(Debug)]
pub struct MastodonClient { pub struct MastodonClient {
pub(crate) client: Client, pub(crate) client: Client,
/// Raw data about your mastodon instance. /// Raw data about your mastodon instance.
pub data: Data, 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. /// Your mastodon application client, handles all requests to and from Mastodon.
@ -49,6 +54,18 @@ pub struct MastodonUnauthenticated {
impl From<Data> for Mastodon { impl From<Data> for Mastodon {
/// Creates a mastodon instance from the data struct. /// 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 { fn from(data: Data) -> Mastodon {
MastodonClient { MastodonClient {
client: Client::new(), client: Client::new(),
@ -156,7 +173,41 @@ impl Mastodon {
stream_direct@"direct", stream_direct@"direct",
} }
/// Return a magic cookie, loaded with the default mime
#[cfg(feature = "magic")]
fn default_magic() -> Result<magic::Cookie> {
let magic = magic::Cookie::open(Default::default())?;
magic.load::<&str>(&[])?;
magic.set_flags(CookieFlags::MIME)?;
Ok(magic)
}
/// Create a new Mastodon Client /// 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 { pub fn new(client: Client, data: Data) -> Self {
Mastodon(Arc::new(MastodonClient { Mastodon(Arc::new(MastodonClient {
client, client,
@ -342,6 +393,43 @@ impl Mastodon {
fn authenticated(&self, request: RequestBuilder) -> RequestBuilder { fn authenticated(&self, request: RequestBuilder) -> RequestBuilder {
request.bearer_auth(&self.data.token) 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<Path>) -> Result<Part> {
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 { impl MastodonUnauthenticated {