pub mod api;
use std::path::PathBuf;
use api::*;
use anyhow::Context;
use reqwest::Client;
const API: &str = "https://waifuvault.moe/rest";
#[derive(Debug, Clone, Default)]
pub struct ApiCaller {
client: Client,
}
impl ApiCaller {
pub fn new() -> Self {
Self::default()
}
pub async fn upload_file(&self, request: WaifuUploadRequest) -> anyhow::Result<WaifuResponse> {
let request = {
let mut intermediate = self.client.put(API).query(&[
("hide_filename", request.one_time_download),
("one_time_download", request.one_time_download),
]);
if let Some(expiry) = request.expires {
intermediate = intermediate.query(&[("expires", expiry)]);
}
if let Some(file) = request.file {
let path = PathBuf::from(&file);
let f = std::fs::read(&path)
.with_context(|| format!("reading file {}", path.display()))?;
let filename = path.file_name().expect("this should be a valid filename");
let filename = filename
.to_str()
.expect("this should be a valid convertion from os string");
let file_part = reqwest::multipart::Part::bytes(f).file_name(filename.to_owned());
let mut form = reqwest::multipart::Form::new().part("file", file_part);
if let Some(password) = request.password {
form = form.text("password", password);
}
println!("Form data: {form:?}");
intermediate = intermediate.multipart(form);
} else if let Some(url) = request.url {
intermediate = match request.password {
Some(password) => intermediate.form(&[("url", url), ("password", password)]),
None => intermediate.form(&[("url", url)]),
};
} else if let (Some(raw), Some(filename)) = (request.bytes, request.filename) {
let file_part = reqwest::multipart::Part::bytes(raw).file_name(filename);
let mut form = reqwest::multipart::Form::new().part("file", file_part);
if let Some(password) = request.password {
form = form.text("password", password);
}
println!("Form data: {form:?}");
intermediate = intermediate.multipart(form);
} else {
anyhow::bail!("need either a file, url, or stream");
}
intermediate
};
let response = request
.send()
.await
.context("sending upload request")?
.json()
.await
.context("converting upload response")?;
let response = parse_response(response).context("parsing waifu api response")?;
Ok(response)
}
pub async fn file_info(&self, request: WaifuGetRequest) -> anyhow::Result<WaifuResponse> {
let url = format!("{API}/{}", request.token);
let request = self
.client
.get(url)
.query(&[("formatted", request.formatted)]);
let response: WaifuApiResponse = request
.send()
.await
.context("sending file info request")?
.json()
.await
.context("converting response")?;
let response = parse_response(response).context("parsing waifu api response")?;
Ok(response)
}
pub async fn update_file(
&self,
request: WaifuModificationRequest,
) -> anyhow::Result<WaifuResponse> {
let url = format!("{API}/{}", request.token);
let response: WaifuApiResponse = self
.client
.patch(url)
.header("Content-Type", "application/json")
.json(&request)
.send()
.await
.context("sending modification request")?
.json()
.await
.context("converting response")?;
let response = parse_response(response).context("parsing waifu api response")?;
Ok(response)
}
pub async fn delete_file(&self, token: impl AsRef<str>) -> anyhow::Result<bool> {
let url = format!("{API}/{}", token.as_ref());
let response: WaifuApiResponse = self
.client
.delete(url)
.send()
.await
.context("sending delete request")?
.json()
.await
.context("converting response")?;
match response {
WaifuApiResponse::Delete(del) => Ok(del),
WaifuApiResponse::WaifuError(err) => Err(err.into()),
_ => anyhow::bail!("received unexpected response from DELETE call"),
}
}
pub async fn download_file(
&self,
url: impl AsRef<str>,
password: Option<String>,
) -> anyhow::Result<Vec<u8>> {
let request = {
let mut r = self.client.get(url.as_ref());
if let Some(password) = &password {
r = r.header("x-password", password);
}
r
};
let response = request.send().await.context("sending download request")?;
let status = response.status();
match status {
reqwest::StatusCode::OK => {}
reqwest::StatusCode::FORBIDDEN => {
if password.is_some() {
anyhow::bail!("supplied password is incorrect");
} else {
anyhow::bail!("this file requires a password to download");
}
}
_ => {
let api_response: WaifuApiResponse =
response.json().await.context("converting error")?;
match api_response {
WaifuApiResponse::WaifuError(err) => return Err(err.into()),
_ => anyhow::bail!("something went wrong"),
}
}
}
let content = response
.bytes()
.await
.context("getting content bytes")?
.to_vec();
Ok(content)
}
}
pub(crate) fn parse_response(response: WaifuApiResponse) -> anyhow::Result<WaifuResponse> {
match response {
WaifuApiResponse::WaifuResponse(resp) => Ok(resp),
WaifuApiResponse::WaifuError(err) => Err(anyhow::anyhow!(err)),
WaifuApiResponse::Delete(_) => unreachable!("unused"),
}
}
#[cfg(test)]
mod tests {
use super::*;
use anyhow::Result;
use rand::RngCore;
use sha1::{Digest, Sha1};
use std::path::PathBuf;
use tokio::{fs, io::AsyncWriteExt};
struct TempFileCreator {
file: PathBuf,
}
impl TempFileCreator {
pub async fn new(filename: &str) -> Result<Self> {
let tmp = std::env::temp_dir();
let mut data = Vec::with_capacity(16_384);
rand::thread_rng().fill_bytes(&mut data);
let test_file = tmp.join(filename);
let mut f = tokio::fs::File::create(&test_file).await?;
f.write_all(&mut data).await?;
return Ok(Self { file: test_file });
}
}
impl Drop for TempFileCreator {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.file);
}
}
async fn cleanup(caller: &ApiCaller, token: &str) -> Result<()> {
caller.delete_file(token).await?;
Ok(())
}
#[tokio::test]
async fn upload_file() -> Result<()> {
let tmp = TempFileCreator::new("upload_basic.bin").await?;
assert!(tmp.file.exists());
let caller = ApiCaller::new();
let upload_request = api::WaifuUploadRequest::new().file(&tmp.file);
let response = caller
.upload_file(upload_request)
.await
.context("upload file - basic");
assert!(response.is_ok());
let response = response?;
let options = response
.options
.expect("expected options when there are none");
assert!(!options.hide_filename);
assert!(!options.protected);
assert!(!options.one_time_download);
cleanup(&caller, &response.token).await?;
be_nice().await;
Ok(())
}
#[tokio::test]
async fn upload_file_with_options() -> Result<()> {
let tmp = TempFileCreator::new("upload_with_options.bin").await?;
let caller = ApiCaller::new();
let upload_request = api::WaifuUploadRequest::new()
.file(&tmp.file)
.expires("1h")
.password("apple")
.one_time_download(true)
.hide_filename(true);
let response = caller
.upload_file(upload_request)
.await
.context("upload file with options")?;
let options = response
.options
.expect("expected options when there are none");
assert!(options.hide_filename);
assert!(options.protected);
assert!(options.one_time_download);
cleanup(&caller, &response.token).await?;
be_nice().await;
Ok(())
}
#[tokio::test]
async fn upload_file_from_url() -> Result<()> {
let url = "https://waifuvault.moe/assets/custom/images/08.png";
let caller = ApiCaller::new();
let request = WaifuUploadRequest::new().url(url).expires("1h");
let response = caller
.upload_file(request)
.await
.context("upload from url")?;
let options = response
.options
.expect("expected options when there are none");
assert!(!options.hide_filename);
assert!(!options.protected);
assert!(!options.one_time_download);
cleanup(&caller, &response.token).await?;
be_nice().await;
Ok(())
}
#[tokio::test]
async fn upload_file_bytes() -> Result<()> {
let tmp = TempFileCreator::new("upload_from_raw_bytes.bin").await?;
let caller = ApiCaller::new();
let content = fs::read(&tmp.file).await?;
let request = WaifuUploadRequest::new()
.bytes(content, "test_raw_bytes.bin")
.expires("1h");
let response = caller.upload_file(request).await?;
cleanup(&caller, &response.token).await?;
be_nice().await;
Ok(())
}
#[tokio::test]
async fn get_file_info() -> Result<()> {
let tmp = TempFileCreator::new("get_file_info_basic.bin").await?;
let caller = ApiCaller::new();
let upload = WaifuUploadRequest::new().file(&tmp.file);
let response = caller.upload_file(upload).await?;
let token = response.token;
let options = response
.options
.expect("options expected but there are none");
let get_req = WaifuGetRequest::new(&token);
let response = caller.file_info(get_req).await?;
assert_eq!(&token, &response.token);
assert_eq!(
&options,
&response
.options
.expect("expected options but there are none")
);
cleanup(&caller, &response.token).await?;
be_nice().await;
Ok(())
}
#[tokio::test]
async fn invalid_token() -> Result<()> {
let caller = ApiCaller::new();
let request = WaifuGetRequest::new("hithere");
let response = caller.file_info(request).await;
assert!(response.is_err());
be_nice().await;
Ok(())
}
#[tokio::test]
async fn patch_entry() -> Result<()> {
let tmp = TempFileCreator::new("some_entry_to_be_patched.bin").await?;
let caller = ApiCaller::new();
let init = WaifuUploadRequest::new().file(&tmp.file).expires("1h");
let response = caller.upload_file(init).await?;
let token = response.token;
let original_exp = response.retention_period;
let original_opts = response.options.unwrap();
be_nice().await;
let mod_request = WaifuModificationRequest::new(&token).password("banana");
let response = caller.update_file(mod_request).await?;
let options = response.options.unwrap();
assert!(options.protected);
assert_ne!(options.protected, original_opts.protected);
be_nice().await;
let mod_request = WaifuModificationRequest::new(&token).custom_expiry("5m");
let response = caller.update_file(mod_request).await?;
assert_ne!(response.retention_period, original_exp);
be_nice().await;
let mod_request = WaifuModificationRequest::new(&token).hide_filename(true);
let response = caller.update_file(mod_request).await?;
let options = response.options.unwrap();
assert!(options.hide_filename);
assert_ne!(options.hide_filename, original_opts.hide_filename);
be_nice().await;
let mod_request = WaifuModificationRequest::new(&token)
.password("apple")
.previous_password("banana");
let response = caller.update_file(mod_request).await?;
let options = response.options.unwrap();
assert!(options.protected);
be_nice().await;
cleanup(&caller, &token).await?;
be_nice().await;
Ok(())
}
#[tokio::test]
async fn delete_file() -> Result<()> {
let tmp = TempFileCreator::new("something_to_delete.bin").await?;
let caller = ApiCaller::new();
let request = WaifuUploadRequest::new().file(&tmp.file);
let response = caller.upload_file(request).await?;
let token = response.token;
let success = caller.delete_file(token).await?;
assert!(success);
be_nice().await;
Ok(())
}
#[tokio::test]
async fn download_file() -> Result<()> {
let url = "https://waifuvault.moe/assets/custom/images/08.png";
let original = reqwest::get(url).await?.bytes().await?.to_vec();
let og_hash = hash_item(&original);
let caller = ApiCaller::new();
let request = WaifuUploadRequest::new().url(url).expires("1h");
let response = caller.upload_file(request).await?;
let url = response.url;
let response = caller.download_file(url, None).await?;
let result = hash_item(&response);
assert_eq!(og_hash, result);
be_nice().await;
Ok(())
}
#[tokio::test]
async fn download_file_with_password() -> Result<()> {
let url = "https://waifuvault.moe/assets/custom/images/08.png";
let original = reqwest::get(url).await?.bytes().await?.to_vec();
let og_hash = hash_item(&original);
let caller = ApiCaller::new();
let request = WaifuUploadRequest::new()
.url(url)
.expires("1h")
.password("banana");
let response = caller
.upload_file(request)
.await
.context("uploading protected file to download")?;
let url = response.url;
let response = caller
.download_file(url, Some("banana".to_string()))
.await?;
let result = hash_item(&response);
assert_eq!(og_hash, result);
be_nice().await;
Ok(())
}
fn hash_item(content: &Vec<u8>) -> String {
let mut hasher = Sha1::new();
hasher.update(content);
let raw = hasher.finalize();
hex::encode(raw)
}
async fn be_nice() {
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
}
}