use hmac::{Hmac, Mac};
use sha2::Sha256;
use url::Url;
use super::auth::{
canonical_query_without_signature, extend_transform_query, signed_source_query, url_authority,
};
use super::config::DEFAULT_BIND_ADDR;
use crate::TransformOptions;
pub(super) type HmacSha256 = Hmac<Sha256>;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SignedUrlSource {
Path {
path: String,
version: Option<String>,
},
Url {
url: String,
version: Option<String>,
},
}
#[derive(Debug, Default)]
pub struct SignedWatermarkParams {
pub url: String,
pub position: Option<String>,
pub opacity: Option<u8>,
pub margin: Option<u32>,
}
#[allow(clippy::too_many_arguments)]
pub fn sign_public_url(
base_url: &str,
source: SignedUrlSource,
options: &TransformOptions,
key_id: &str,
secret: &str,
expires: u64,
watermark: Option<&SignedWatermarkParams>,
preset: Option<&str>,
) -> Result<String, String> {
sign_public_url_with_method(
"GET", base_url, source, options, key_id, secret, expires, watermark, preset,
)
}
#[allow(clippy::too_many_arguments)]
pub fn sign_public_url_with_method(
method: &str,
base_url: &str,
source: SignedUrlSource,
options: &TransformOptions,
key_id: &str,
secret: &str,
expires: u64,
watermark: Option<&SignedWatermarkParams>,
preset: Option<&str>,
) -> Result<String, String> {
let base_url = Url::parse(base_url).map_err(|error| format!("base URL is invalid: {error}"))?;
match base_url.scheme() {
"http" | "https" => {}
_ => return Err("base URL must use the http or https scheme".to_string()),
}
let route_path = match source {
SignedUrlSource::Path { .. } => "/images/by-path",
SignedUrlSource::Url { .. } => "/images/by-url",
};
let mut endpoint = base_url
.join(route_path)
.map_err(|error| format!("failed to resolve the public endpoint URL: {error}"))?;
let authority = url_authority(&endpoint)?;
let mut query = signed_source_query(source);
if let Some(name) = preset {
query.insert("preset".to_string(), name.to_string());
}
extend_transform_query(&mut query, options);
if let Some(wm) = watermark {
query.insert("watermarkUrl".to_string(), wm.url.clone());
if let Some(ref pos) = wm.position {
query.insert("watermarkPosition".to_string(), pos.clone());
}
if let Some(opacity) = wm.opacity {
query.insert("watermarkOpacity".to_string(), opacity.to_string());
}
if let Some(margin) = wm.margin {
query.insert("watermarkMargin".to_string(), margin.to_string());
}
}
query.insert("keyId".to_string(), key_id.to_string());
query.insert("expires".to_string(), expires.to_string());
let canonical = format!(
"{}\n{}\n{}\n{}",
method.to_ascii_uppercase(),
authority,
endpoint.path(),
canonical_query_without_signature(&query)
);
let mut mac = HmacSha256::new_from_slice(secret.as_bytes())
.map_err(|error| format!("failed to initialize signed URL HMAC: {error}"))?;
mac.update(canonical.as_bytes());
query.insert(
"signature".to_string(),
hex::encode(mac.finalize().into_bytes()),
);
let mut serializer = url::form_urlencoded::Serializer::new(String::new());
for (name, value) in query {
serializer.append_pair(&name, &value);
}
endpoint.set_query(Some(&serializer.finish()));
Ok(endpoint.into())
}
pub fn bind_addr() -> String {
std::env::var("TRUSS_BIND_ADDR").unwrap_or_else(|_| DEFAULT_BIND_ADDR.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{OptimizeMode, TargetQuality, TransformOptions};
#[test]
fn sign_public_url_rejects_invalid_base_url() {
let result = sign_public_url(
"not-a-url",
SignedUrlSource::Path {
path: "/img.png".to_string(),
version: None,
},
&TransformOptions::default(),
"key",
"secret",
0,
None,
None,
);
assert!(result.is_err());
assert!(result.unwrap_err().contains("base URL is invalid"));
}
#[test]
fn sign_public_url_rejects_non_http_scheme() {
let result = sign_public_url(
"ftp://example.com",
SignedUrlSource::Path {
path: "/img.png".to_string(),
version: None,
},
&TransformOptions::default(),
"key",
"secret",
0,
None,
None,
);
assert!(result.is_err());
assert!(result.unwrap_err().contains("http or https"));
}
#[test]
fn sign_public_url_path_source_generates_by_path_url() {
let url = sign_public_url(
"https://cdn.example.com",
SignedUrlSource::Path {
path: "/photo.jpg".to_string(),
version: None,
},
&TransformOptions::default(),
"mykey",
"mysecret",
9999,
None,
None,
)
.unwrap();
assert!(url.starts_with("https://cdn.example.com/images/by-path?"));
assert!(url.contains("keyId=mykey"));
assert!(url.contains("signature="));
assert!(url.contains("expires=9999"));
}
#[test]
fn sign_public_url_url_source_generates_by_url() {
let url = sign_public_url(
"https://cdn.example.com",
SignedUrlSource::Url {
url: "https://remote.example.com/img.png".to_string(),
version: None,
},
&TransformOptions::default(),
"key",
"secret",
0,
None,
None,
)
.unwrap();
assert!(url.starts_with("https://cdn.example.com/images/by-url?"));
}
#[test]
fn sign_public_url_includes_preset() {
let url = sign_public_url(
"https://cdn.example.com",
SignedUrlSource::Path {
path: "/img.png".to_string(),
version: None,
},
&TransformOptions::default(),
"key",
"secret",
0,
None,
Some("thumbnail"),
)
.unwrap();
assert!(url.contains("preset=thumbnail"));
}
#[test]
fn sign_public_url_includes_watermark_params() {
let wm = SignedWatermarkParams {
url: "https://example.com/logo.png".to_string(),
position: Some("southeast".to_string()),
opacity: Some(80),
margin: Some(10),
};
let url = sign_public_url(
"https://cdn.example.com",
SignedUrlSource::Path {
path: "/img.png".to_string(),
version: None,
},
&TransformOptions::default(),
"key",
"secret",
0,
Some(&wm),
None,
)
.unwrap();
assert!(url.contains("watermarkUrl="));
assert!(url.contains("watermarkPosition=southeast"));
assert!(url.contains("watermarkOpacity=80"));
assert!(url.contains("watermarkMargin=10"));
}
#[test]
fn sign_public_url_includes_optimize_params() {
let url = sign_public_url(
"https://cdn.example.com",
SignedUrlSource::Path {
path: "/img.png".to_string(),
version: None,
},
&TransformOptions {
format: Some(crate::MediaType::Jpeg),
optimize: OptimizeMode::Lossy,
target_quality: Some("ssim:0.98".parse::<TargetQuality>().unwrap()),
..TransformOptions::default()
},
"key",
"secret",
0,
None,
None,
)
.unwrap();
assert!(url.contains("optimize=lossy"));
assert!(url.contains("targetQuality=ssim%3A0.98"));
}
#[test]
fn sign_public_url_matches_fixed_compatibility_vector() {
let url = sign_public_url(
"https://images.example.com",
SignedUrlSource::Path {
path: "image.png".to_string(),
version: None,
},
&TransformOptions {
width: Some(800),
format: Some(crate::MediaType::Webp),
..TransformOptions::default()
},
"public-demo",
"secret-value",
1_900_000_000,
None,
None,
)
.unwrap();
assert_eq!(
url,
"https://images.example.com/images/by-path?expires=1900000000&format=webp&keyId=public-demo&path=image.png&signature=8c3234125e0e20efeaae1e2afaa88a81d387c82cef0080780fddd31c5689199e&width=800"
);
}
}