use base64::Engine as _;
use bytes::Bytes;
use reqwest::{Method, Response, StatusCode};
use url::Url;
use crate::{Bucket, FileOptions, StorageClient, StorageError};
pub const TUS_CHUNK_SIZE: usize = 6 * 1024 * 1024;
pub struct ResumableUpload<'a> {
client: &'a StorageClient,
url: Url,
offset: u64,
length: u64,
dirty: bool,
upsert: bool,
}
impl core::fmt::Debug for ResumableUpload<'_> {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.debug_struct("ResumableUpload")
.field("offset", &self.offset)
.field("length", &self.length)
.field("dirty", &self.dirty)
.finish_non_exhaustive()
}
}
fn validate_url(client: &StorageClient, url: &Url) -> Result<(), StorageError> {
let base = client.url(["upload", "resumable"]);
if url.origin() != base.origin()
|| !url.username().is_empty()
|| url.password().is_some()
|| url.fragment().is_some()
{
return Err(StorageError::Protocol("unsafe resumable upload URL"));
}
Ok(())
}
fn number(response: &Response, name: &'static str) -> Result<u64, StorageError> {
response
.headers()
.get(name)
.and_then(|value| value.to_str().ok())
.and_then(|value| value.parse().ok())
.ok_or(StorageError::Protocol(
"missing or invalid TUS offset/length",
))
}
fn version(response: &Response) -> Result<(), StorageError> {
if response
.headers()
.get("tus-resumable")
.is_none_or(|value| value != "1.0.0")
{
return Err(StorageError::Protocol("missing or unsupported TUS version"));
}
Ok(())
}
impl<'a> Bucket<'a> {
pub async fn create_resumable_upload(
&self,
path: &str,
length: u64,
options: &FileOptions,
) -> Result<ResumableUpload<'a>, StorageError> {
crate::path::bucket(self.id())?;
crate::path::object(path)?;
let encode = |value: &str| base64::engine::general_purpose::STANDARD.encode(value);
let metadata = format!(
"bucketName {},objectName {},contentType {},cacheControl {}",
encode(self.id()),
encode(path),
encode(
options
.content_type
.as_deref()
.unwrap_or("application/octet-stream")
),
encode(&options.cache_control.to_string())
);
let endpoint = self.client().url(["upload", "resumable"]);
validate_url(self.client(), &endpoint)?;
let response = self
.client()
.send(
self.client()
.request(Method::POST, endpoint.clone())
.header("tus-resumable", "1.0.0")
.header("upload-length", length)
.header("upload-metadata", metadata)
.header("x-upsert", if options.upsert { "true" } else { "false" }),
)
.await?;
if response.status() != StatusCode::CREATED {
return Err(StorageError::Protocol("TUS creation must return 201"));
}
version(&response)?;
let location = response
.headers()
.get("location")
.and_then(|value| value.to_str().ok())
.ok_or(StorageError::Protocol("TUS creation has no Location"))?;
let url = endpoint.join(location)?;
validate_url(self.client(), &url)?;
let offset = response
.headers()
.get("upload-offset")
.map_or(Ok(0), |_| number(&response, "upload-offset"))?;
if offset > length {
return Err(StorageError::Protocol("TUS offset exceeds length"));
}
Ok(ResumableUpload {
client: self.client(),
url,
offset,
length,
dirty: false,
upsert: options.upsert,
})
}
}
impl StorageClient {
pub async fn resume_upload(
&self,
url: &Url,
upsert: bool,
) -> Result<ResumableUpload<'_>, StorageError> {
validate_url(self, url)?;
let mut upload = ResumableUpload {
client: self,
url: url.clone(),
offset: 0,
length: 0,
dirty: true,
upsert,
};
upload.refresh().await?;
Ok(upload)
}
}
impl ResumableUpload<'_> {
#[must_use]
pub const fn url(&self) -> &Url {
&self.url
}
#[must_use]
pub const fn offset(&self) -> u64 {
self.offset
}
#[must_use]
pub const fn length(&self) -> u64 {
self.length
}
#[must_use]
pub const fn needs_refresh(&self) -> bool {
self.dirty
}
pub async fn refresh(&mut self) -> Result<u64, StorageError> {
self.dirty = true;
let response = self
.client
.send(
self.client
.request(Method::HEAD, self.url.clone())
.header("tus-resumable", "1.0.0")
.header("x-upsert", if self.upsert { "true" } else { "false" }),
)
.await?;
version(&response)?;
let offset = number(&response, "upload-offset")?;
let length = number(&response, "upload-length")?;
if offset > length {
return Err(StorageError::Protocol("TUS offset exceeds length"));
}
self.offset = offset;
self.length = length;
self.dirty = false;
Ok(offset)
}
pub async fn write_chunk(&mut self, offset: u64, chunk: Bytes) -> Result<u64, StorageError> {
if self.dirty {
self.refresh().await?;
}
if offset != self.offset {
return Err(StorageError::Protocol("stale TUS chunk offset"));
}
let remaining = self
.length
.checked_sub(self.offset)
.ok_or(StorageError::Protocol("TUS offset exceeds length"))?;
let chunk_size = u64::try_from(TUS_CHUNK_SIZE)
.map_err(|_error| StorageError::Protocol("TUS chunk size exceeds u64"))?;
let expected = remaining.min(chunk_size);
let chunk_length = u64::try_from(chunk.len())
.map_err(|_error| StorageError::Protocol("TUS chunk length exceeds u64"))?;
let expected_offset = offset
.checked_add(expected)
.ok_or(StorageError::Protocol("TUS chunk offset overflow"))?;
if chunk_length != expected {
return Err(StorageError::Protocol(
"TUS chunk must be 6 MiB except the final tail",
));
}
if remaining == 0 {
return Ok(self.offset);
}
self.dirty = true;
let request = self
.client
.request(Method::PATCH, self.url.clone())
.header("tus-resumable", "1.0.0")
.header("x-upsert", if self.upsert { "true" } else { "false" })
.header("upload-offset", offset)
.header("content-type", "application/offset+octet-stream")
.body(chunk);
let (http, request) = request.build_split();
let response =
rp_supabase_http::execute(&http, request?, rp_supabase_http::RetryPolicy::none())
.await?;
if response.status() == StatusCode::CONFLICT {
self.refresh().await?;
return Err(StorageError::Protocol(
"TUS offset conflict; offset refreshed",
));
}
if !response.status().is_success() {
let status = response.status();
let body = crate::ApiErrorBody::decode(response.text().await?);
return Err(StorageError::Api { status, body });
}
if response.status() != StatusCode::NO_CONTENT {
return Err(StorageError::Protocol("TUS PATCH must return 204"));
}
version(&response)?;
let confirmed = number(&response, "upload-offset")?;
if confirmed != expected_offset {
self.refresh().await?;
return Err(StorageError::Protocol(
"TUS PATCH offset mismatch; offset refreshed",
));
}
self.offset = confirmed;
self.dirty = false;
Ok(confirmed)
}
}