use std::path::Path;
use reqwest::header::HeaderMap;
use reqwest::{Client as HttpClient, StatusCode};
use tokio::fs::File;
use tokio::io::{AsyncReadExt, AsyncSeekExt};
mod error;
pub use error::TusError;
pub use reqwest::Url;
const TUS_RESUMABLE: &str = "1.0.0";
const CONTENT_TYPE: &str = "application/offset+octet-stream";
#[derive(Debug, Clone)]
pub struct ServerInfo {
pub version: Box<[Box<str>]>,
pub extensions: Box<[Box<str>]>,
pub max_size: Option<u64>,
}
#[derive(Debug, Clone)]
pub struct UploadInfo {
pub offset: u64,
pub length: Option<u64>,
pub expires: Option<Box<str>>,
}
#[derive(Debug, Clone)]
pub struct TusClient {
http: HttpClient,
endpoint: Url,
chunk_size: Option<u64>,
headers: HeaderMap,
}
impl ServerInfo {
pub fn has_extension(&self, name: &str) -> bool {
self.extensions.iter().any(|e| e.as_ref() == name)
}
}
impl TusClient {
pub fn new(endpoint: &str) -> Result<Self, TusError> {
let endpoint = Url::parse(endpoint)?;
let http = HttpClient::new();
Ok(Self {
http,
endpoint,
chunk_size: None,
headers: HeaderMap::new(),
})
}
pub fn with_http_client(mut self, client: HttpClient) -> Self {
self.http = client;
self
}
pub fn with_chunk_size(mut self, size: u64) -> Self {
self.chunk_size = Some(size);
self
}
pub fn with_headers(mut self, headers: HeaderMap) -> Self {
self.headers = headers;
self
}
pub async fn server_info(&self) -> Result<ServerInfo, TusError> {
let resp = self
.http
.request(reqwest::Method::OPTIONS, self.endpoint.clone())
.headers(self.headers.clone())
.send()
.await?;
let status = resp.status();
if status != StatusCode::OK && status != StatusCode::NO_CONTENT {
return Err(TusError::UnexpectedStatus(status));
}
let version = parse_comma_list(resp.headers(), "tus-version");
let extensions = parse_comma_list(resp.headers(), "tus-extension");
let max_size = optional_header::<u64>(resp.headers(), "tus-max-size");
Ok(ServerInfo {
version,
extensions,
max_size,
})
}
pub async fn create(
&self,
length: u64,
metadata: Option<&[(&str, &str)]>,
) -> Result<Url, TusError> {
let mut req = self
.http
.post(self.endpoint.clone())
.headers(self.headers.clone())
.header("tus-resumable", TUS_RESUMABLE)
.header("upload-length", length.to_string())
.header("content-length", "0");
if let Some(meta) = metadata {
req = req.header("upload-metadata", encode_metadata(meta));
}
let resp = req.send().await?;
if resp.status() != StatusCode::CREATED {
return Err(TusError::UnexpectedStatus(resp.status()));
}
let location = required_header_str(resp.headers(), "location")?;
resolve_url(&self.endpoint, location)
}
pub async fn get_offset(&self, upload_url: &Url) -> Result<UploadInfo, TusError> {
let resp = self
.http
.head(upload_url.clone())
.headers(self.headers.clone())
.header("tus-resumable", TUS_RESUMABLE)
.send()
.await?;
let status = resp.status();
if status != StatusCode::OK && status != StatusCode::NO_CONTENT {
return Err(TusError::UnexpectedStatus(status));
}
let offset = required_header::<u64>(resp.headers(), "upload-offset")?;
let length = optional_header::<u64>(resp.headers(), "upload-length");
let expires = optional_header_boxed(resp.headers(), "upload-expires");
Ok(UploadInfo {
offset,
length,
expires,
})
}
pub async fn upload(&self, upload_url: &Url, path: &Path) -> Result<(), TusError> {
let mut file = File::open(path).await?;
let file_len = file.metadata().await?.len();
let info = self.get_offset(upload_url).await?;
if info.offset >= file_len {
tracing::debug!(offset = info.offset, len = file_len, "already complete");
return Ok(());
}
let mut offset = info.offset;
if offset > 0 {
file.seek(std::io::SeekFrom::Start(offset)).await?;
}
while offset < file_len {
let remaining = file_len - offset;
let chunk_len = match self.chunk_size {
Some(cs) => remaining.min(cs),
None => remaining,
};
let chunk = read_chunk(&mut file, chunk_len).await?;
let resp = self
.http
.patch(upload_url.clone())
.headers(self.headers.clone())
.header("tus-resumable", TUS_RESUMABLE)
.header("upload-offset", offset.to_string())
.header("content-length", chunk_len.to_string())
.header("content-type", CONTENT_TYPE)
.body(chunk)
.send()
.await?;
if resp.status() != StatusCode::NO_CONTENT {
return Err(TusError::UnexpectedStatus(resp.status()));
}
offset = required_header::<u64>(resp.headers(), "upload-offset")?;
tracing::debug!(offset, total = file_len, "chunk uploaded");
}
Ok(())
}
pub async fn delete(&self, upload_url: &Url) -> Result<(), TusError> {
let resp = self
.http
.delete(upload_url.clone())
.headers(self.headers.clone())
.header("tus-resumable", TUS_RESUMABLE)
.header("content-length", "0")
.send()
.await?;
if resp.status() != StatusCode::NO_CONTENT {
return Err(TusError::UnexpectedStatus(resp.status()));
}
Ok(())
}
}
fn required_header<T: std::str::FromStr>(
headers: &HeaderMap,
name: &'static str,
) -> Result<T, TusError> {
headers
.get(name)
.ok_or(TusError::BadHeader(name))?
.to_str()
.map_err(|_| TusError::BadHeader(name))?
.parse::<T>()
.map_err(|_| TusError::BadHeader(name))
}
fn required_header_str<'a>(
headers: &'a HeaderMap,
name: &'static str,
) -> Result<&'a str, TusError> {
headers
.get(name)
.ok_or(TusError::BadHeader(name))?
.to_str()
.map_err(|_| TusError::BadHeader(name))
}
fn optional_header<T: std::str::FromStr>(headers: &HeaderMap, name: &str) -> Option<T> {
headers
.get(name)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse().ok())
}
fn optional_header_boxed(headers: &HeaderMap, name: &str) -> Option<Box<str>> {
headers
.get(name)
.and_then(|v| v.to_str().ok())
.map(|v| v.into())
}
fn parse_comma_list(headers: &HeaderMap, name: &str) -> Box<[Box<str>]> {
headers
.get(name)
.and_then(|v| v.to_str().ok())
.map(|v| {
v.split(',')
.map(|s| s.trim().into())
.collect::<Vec<Box<str>>>()
.into_boxed_slice()
})
.unwrap_or_default()
}
fn encode_metadata(metadata: &[(&str, &str)]) -> String {
use base64::Engine;
use base64::engine::general_purpose::STANDARD;
metadata
.iter()
.map(|(k, v)| {
if v.is_empty() {
(*k).to_owned()
} else {
format!("{} {}", k, STANDARD.encode(v))
}
})
.collect::<Vec<_>>()
.join(",")
}
fn resolve_url(base: &Url, location: &str) -> Result<Url, TusError> {
Ok(base.join(location)?)
}
async fn read_chunk(file: &mut File, len: u64) -> Result<Vec<u8>, TusError> {
let len = usize::try_from(len).map_err(|_| {
TusError::Io(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"chunk size exceeds platform pointer width",
))
})?;
let mut buf = vec![0u8; len];
file.read_exact(&mut buf).await?;
Ok(buf)
}