use base64::Engine as _;
use reqwest::Method;
use serde::{Deserialize, Serialize};
use url::Url;
use crate::{Bucket, ObjectDownload, StorageError};
#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum ResizeMode {
Cover,
Contain,
Fill,
}
#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum ImageFormat {
Origin,
}
#[derive(Debug, Clone, Default, Serialize, PartialEq, Eq)]
pub struct TransformOptions {
#[serde(skip_serializing_if = "Option::is_none")]
pub width: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub height: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub resize: Option<ResizeMode>,
#[serde(skip_serializing_if = "Option::is_none")]
pub quality: Option<u8>,
#[serde(skip_serializing_if = "Option::is_none")]
pub format: Option<ImageFormat>,
}
impl TransformOptions {
fn validate(&self) -> Result<(), StorageError> {
if [self.width, self.height]
.into_iter()
.flatten()
.any(|dimension| !(1..=2500).contains(&dimension))
|| self
.quality
.is_some_and(|quality| !(20..=100).contains(&quality))
{
return Err(StorageError::Protocol(
"image dimensions or quality out of range",
));
}
Ok(())
}
fn apply(&self, url: &mut Url) -> Result<(), StorageError> {
self.validate()?;
let value = serde_json::to_value(self)?;
if let Some(options) = value.as_object() {
for (key, value) in options {
let value = value
.as_str()
.map_or_else(|| value.to_string(), str::to_owned);
url.query_pairs_mut().append_pair(key, &value);
}
}
Ok(())
}
}
fn validate_signed_path(token: &str, bucket: &str, path: &str) -> Result<(), StorageError> {
#[derive(Deserialize)]
struct Claims {
url: String,
}
let invalid = || StorageError::Protocol("signed image response has invalid token claims");
let mut parts = token.split('.');
let header = parts.next().ok_or_else(invalid)?;
let payload = parts.next().ok_or_else(invalid)?;
let signature = parts.next().ok_or_else(invalid)?;
if header.is_empty() || signature.is_empty() || parts.next().is_some() {
return Err(invalid());
}
let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(payload)
.map_err(|_error| invalid())?;
let claims: Claims = serde_json::from_slice(&payload).map_err(|_error| invalid())?;
if claims
.url
.strip_prefix(bucket)
.and_then(|tail| tail.strip_prefix('/'))
!= Some(path)
{
return Err(StorageError::Protocol(
"remote signed-path mismatch: signed image token does not match requested bucket/key",
));
}
Ok(())
}
impl Bucket<'_> {
pub fn public_transformed_url(
&self,
path: &str,
options: &TransformOptions,
) -> Result<Url, StorageError> {
let mut url = self.object_url(&["render", "image", "public"], path)?;
options.apply(&mut url)?;
Ok(url)
}
pub async fn download_public_transformed(
&self,
path: &str,
options: &TransformOptions,
) -> Result<ObjectDownload, StorageError> {
let url = self.public_transformed_url(path, options)?;
Ok(ObjectDownload::new(
self.client()
.send(self.client().request(Method::GET, url))
.await?,
))
}
pub async fn download_transformed(
&self,
path: &str,
options: &TransformOptions,
) -> Result<ObjectDownload, StorageError> {
let mut url = self.object_url(&["render", "image", "authenticated"], path)?;
options.apply(&mut url)?;
Ok(ObjectDownload::new(
self.client()
.send(self.client().request(Method::GET, url))
.await?,
))
}
pub async fn create_signed_transformed_url(
&self,
path: &str,
expires_in: u64,
options: &TransformOptions,
) -> Result<Url, StorageError> {
#[derive(Deserialize)]
struct Signed {
#[serde(rename = "signedURL")]
url: String,
}
options.validate()?;
let endpoint = self.object_url(&["object", "sign"], path)?;
let signed: Signed = self
.client()
.json(
self.client()
.request(Method::POST, endpoint)
.json(&serde_json::json!({"expiresIn": expires_in, "transform": options})),
)
.await?;
let (_, token) = signed
.url
.rsplit_once("?token=")
.ok_or(StorageError::Protocol("signed image response has no token"))?;
let token = url::form_urlencoded::parse(format!("token={token}").as_bytes())
.find(|(key, _)| key == "token")
.map(|(_, value)| value.into_owned())
.ok_or(StorageError::Protocol("signed image response has no token"))?;
validate_signed_path(&token, self.id(), path)?;
let mut url = self.object_url(&["render", "image", "sign"], path)?;
url.query_pairs_mut().append_pair("token", &token);
Ok(url)
}
}