1use hmac::{Hmac, KeyInit, Mac};
3use sha2::Sha256;
4use url::Url;
5
6use super::auth::{
7 canonical_query_without_signature, extend_transform_query, signed_source_query, url_authority,
8};
9use super::config::DEFAULT_BIND_ADDR;
10use crate::TransformOptions;
11
12pub(super) type HmacSha256 = Hmac<Sha256>;
13
14#[derive(Debug, Clone, PartialEq, Eq)]
16#[non_exhaustive]
17pub enum SignedUrlSource {
18 Path {
20 path: String,
22 version: Option<String>,
24 },
25 Url {
27 url: String,
29 version: Option<String>,
31 },
32}
33
34#[derive(Debug, Default)]
82#[non_exhaustive]
83pub struct SignedWatermarkParams {
84 pub url: String,
86 pub position: Option<String>,
88 pub opacity: Option<u8>,
90 pub margin: Option<u32>,
92}
93
94impl SignedWatermarkParams {
95 #[must_use]
101 pub fn new(url: impl Into<String>) -> Self {
102 Self {
103 url: url.into(),
104 ..Self::default()
105 }
106 }
107}
108
109#[allow(clippy::too_many_arguments)]
110pub fn sign_public_url(
111 base_url: &str,
112 source: SignedUrlSource,
113 options: &TransformOptions,
114 key_id: &str,
115 secret: &str,
116 expires: u64,
117 watermark: Option<&SignedWatermarkParams>,
118 preset: Option<&str>,
119) -> Result<String, String> {
120 sign_public_url_with_method(
121 "GET", base_url, source, options, key_id, secret, expires, watermark, preset,
122 )
123}
124
125pub(crate) fn signing_input_error(
136 key_id: &str,
137 secret: &str,
138 source: &SignedUrlSource,
139) -> Option<&'static str> {
140 if key_id.is_empty() {
141 return Some("key id must not be empty");
142 }
143 if secret.is_empty() {
144 return Some("secret must not be empty");
145 }
146 match source {
147 SignedUrlSource::Path { path, .. } if path.is_empty() => Some("path must not be empty"),
148 SignedUrlSource::Url { url, .. } if url.is_empty() => Some("url must not be empty"),
149 _ => None,
150 }
151}
152
153#[allow(clippy::too_many_arguments)]
156pub fn sign_public_url_with_method(
157 method: &str,
158 base_url: &str,
159 source: SignedUrlSource,
160 options: &TransformOptions,
161 key_id: &str,
162 secret: &str,
163 expires: u64,
164 watermark: Option<&SignedWatermarkParams>,
165 preset: Option<&str>,
166) -> Result<String, String> {
167 let mut base_url =
168 Url::parse(base_url).map_err(|error| format!("base URL is invalid: {error}"))?;
169 match base_url.scheme() {
170 "http" | "https" => {}
171 _ => return Err("base URL must use the http or https scheme".to_string()),
172 }
173 if let Some(reason) = signing_input_error(key_id, secret, &source) {
174 return Err(reason.to_string());
175 }
176
177 let route_path = match source {
178 SignedUrlSource::Path { .. } => "/images/by-path",
179 SignedUrlSource::Url { .. } => "/images/by-url",
180 };
181 if !base_url.path().ends_with('/') {
186 let with_slash = format!("{}/", base_url.path());
187 base_url.set_path(&with_slash);
188 }
189 let mut endpoint = base_url
190 .join(route_path.trim_start_matches('/'))
191 .map_err(|error| format!("failed to resolve the public endpoint URL: {error}"))?;
192 let authority = url_authority(&endpoint)?;
193 let mut query = signed_source_query(source);
194 if let Some(name) = preset {
195 query.insert("preset".to_string(), name.to_string());
196 }
197 extend_transform_query(&mut query, options);
198 if let Some(wm) = watermark {
199 query.insert("watermarkUrl".to_string(), wm.url.clone());
200 if let Some(ref pos) = wm.position {
201 query.insert("watermarkPosition".to_string(), pos.clone());
202 }
203 if let Some(opacity) = wm.opacity {
204 query.insert("watermarkOpacity".to_string(), opacity.to_string());
205 }
206 if let Some(margin) = wm.margin {
207 query.insert("watermarkMargin".to_string(), margin.to_string());
208 }
209 }
210 query.insert("keyId".to_string(), key_id.to_string());
211 query.insert("expires".to_string(), expires.to_string());
212
213 let canonical = format!(
217 "{}\n{}\n{}\n{}",
218 method.to_ascii_uppercase(),
219 authority,
220 route_path,
221 canonical_query_without_signature(&query)
222 );
223 let mut mac = HmacSha256::new_from_slice(secret.as_bytes())
224 .map_err(|error| format!("failed to initialize signed URL HMAC: {error}"))?;
225 mac.update(canonical.as_bytes());
226 query.insert(
227 "signature".to_string(),
228 hex::encode(mac.finalize().into_bytes()),
229 );
230
231 let mut serializer = url::form_urlencoded::Serializer::new(String::new());
232 for (name, value) in query {
233 serializer.append_pair(&name, &value);
234 }
235 endpoint.set_query(Some(&serializer.finish()));
236 Ok(endpoint.into())
237}
238
239pub fn bind_addr() -> String {
246 std::env::var("TRUSS_BIND_ADDR").unwrap_or_else(|_| DEFAULT_BIND_ADDR.to_string())
247}
248
249#[cfg(test)]
250mod tests {
251 use super::*;
252 use crate::{OptimizeMode, TargetQuality, TransformOptions};
253
254 #[test]
255 fn sign_public_url_rejects_invalid_base_url() {
256 let result = sign_public_url(
257 "not-a-url",
258 SignedUrlSource::Path {
259 path: "/img.png".to_string(),
260 version: None,
261 },
262 &TransformOptions::default(),
263 "key",
264 "secret",
265 0,
266 None,
267 None,
268 );
269 assert!(result.is_err());
270 assert!(result.unwrap_err().contains("base URL is invalid"));
271 }
272
273 #[test]
274 fn sign_public_url_rejects_non_http_scheme() {
275 let result = sign_public_url(
276 "ftp://example.com",
277 SignedUrlSource::Path {
278 path: "/img.png".to_string(),
279 version: None,
280 },
281 &TransformOptions::default(),
282 "key",
283 "secret",
284 0,
285 None,
286 None,
287 );
288 assert!(result.is_err());
289 assert!(result.unwrap_err().contains("http or https"));
290 }
291
292 #[test]
293 fn sign_public_url_path_source_generates_by_path_url() {
294 let url = sign_public_url(
295 "https://cdn.example.com",
296 SignedUrlSource::Path {
297 path: "/photo.jpg".to_string(),
298 version: None,
299 },
300 &TransformOptions::default(),
301 "mykey",
302 "mysecret",
303 9999,
304 None,
305 None,
306 )
307 .unwrap();
308 assert!(url.starts_with("https://cdn.example.com/images/by-path?"));
309 assert!(url.contains("keyId=mykey"));
310 assert!(url.contains("signature="));
311 assert!(url.contains("expires=9999"));
312 }
313
314 #[test]
315 fn sign_public_url_url_source_generates_by_url() {
316 let url = sign_public_url(
317 "https://cdn.example.com",
318 SignedUrlSource::Url {
319 url: "https://remote.example.com/img.png".to_string(),
320 version: None,
321 },
322 &TransformOptions::default(),
323 "key",
324 "secret",
325 0,
326 None,
327 None,
328 )
329 .unwrap();
330 assert!(url.starts_with("https://cdn.example.com/images/by-url?"));
331 }
332
333 #[test]
334 fn sign_public_url_includes_preset() {
335 let url = sign_public_url(
336 "https://cdn.example.com",
337 SignedUrlSource::Path {
338 path: "/img.png".to_string(),
339 version: None,
340 },
341 &TransformOptions::default(),
342 "key",
343 "secret",
344 0,
345 None,
346 Some("thumbnail"),
347 )
348 .unwrap();
349 assert!(url.contains("preset=thumbnail"));
350 }
351
352 #[test]
353 fn sign_public_url_includes_watermark_params() {
354 let wm = SignedWatermarkParams {
355 url: "https://example.com/logo.png".to_string(),
356 position: Some("southeast".to_string()),
357 opacity: Some(80),
358 margin: Some(10),
359 };
360 let url = sign_public_url(
361 "https://cdn.example.com",
362 SignedUrlSource::Path {
363 path: "/img.png".to_string(),
364 version: None,
365 },
366 &TransformOptions::default(),
367 "key",
368 "secret",
369 0,
370 Some(&wm),
371 None,
372 )
373 .unwrap();
374 assert!(url.contains("watermarkUrl="));
375 assert!(url.contains("watermarkPosition=southeast"));
376 assert!(url.contains("watermarkOpacity=80"));
377 assert!(url.contains("watermarkMargin=10"));
378 }
379
380 #[test]
381 fn sign_public_url_includes_optimize_params() {
382 let url = sign_public_url(
383 "https://cdn.example.com",
384 SignedUrlSource::Path {
385 path: "/img.png".to_string(),
386 version: None,
387 },
388 &TransformOptions {
389 format: Some(crate::MediaType::Jpeg),
390 optimize: OptimizeMode::Lossy,
391 target_quality: Some("ssim:0.98".parse::<TargetQuality>().unwrap()),
392 ..TransformOptions::default()
393 },
394 "key",
395 "secret",
396 0,
397 None,
398 None,
399 )
400 .unwrap();
401
402 assert!(url.contains("optimize=lossy"));
403 assert!(url.contains("targetQuality=ssim%3A0.98"));
404 }
405
406 #[test]
413 fn sign_public_url_refuses_inputs_no_server_can_accept() {
414 let sign = |key_id: &str, secret: &str, source: SignedUrlSource| {
415 sign_public_url(
416 "https://images.example.com",
417 source,
418 &TransformOptions::default(),
419 key_id,
420 secret,
421 1_900_000_000,
422 None,
423 None,
424 )
425 };
426 let path = |path: &str| SignedUrlSource::Path {
427 path: path.to_string(),
428 version: None,
429 };
430
431 assert_eq!(
432 sign("", "secret-value", path("/image.png")),
433 Err("key id must not be empty".to_string())
434 );
435 assert_eq!(
436 sign("public-demo", "", path("/image.png")),
437 Err("secret must not be empty".to_string())
438 );
439 assert_eq!(
440 sign("public-demo", "secret-value", path("")),
441 Err("path must not be empty".to_string())
442 );
443 assert_eq!(
444 sign(
445 "public-demo",
446 "secret-value",
447 SignedUrlSource::Url {
448 url: String::new(),
449 version: None,
450 },
451 ),
452 Err("url must not be empty".to_string())
453 );
454 assert!(sign("public-demo", "secret-value", path("/image.png")).is_ok());
455 }
456
457 #[test]
464 fn sign_public_url_keeps_a_path_in_the_base_url() {
465 let sign = |base_url: &str| {
466 sign_public_url(
467 base_url,
468 SignedUrlSource::Path {
469 path: "image.png".to_string(),
470 version: None,
471 },
472 &TransformOptions::default(),
473 "public-demo",
474 "secret-value",
475 1_900_000_000,
476 None,
477 None,
478 )
479 .expect("sign")
480 };
481
482 let plain = sign("https://images.example.com");
483 let signature = |url: &str| {
484 url.split("signature=")
485 .nth(1)
486 .expect("a signature")
487 .split('&')
488 .next()
489 .expect("the signature value")
490 .to_string()
491 };
492
493 for base in [
494 "https://images.example.com/img",
495 "https://images.example.com/img/",
496 ] {
497 let prefixed = sign(base);
498 assert!(
499 prefixed.starts_with("https://images.example.com/img/images/by-path?"),
500 "the prefix has to reach the emitted URL, got: {prefixed}"
501 );
502 assert_eq!(
503 signature(&prefixed),
504 signature(&plain),
505 "the canonical string carries the endpoint path, not the base URL's"
506 );
507 }
508
509 assert!(
510 sign("https://images.example.com/")
511 .starts_with("https://images.example.com/images/by-path?")
512 );
513 }
514
515 #[test]
516 fn sign_public_url_matches_fixed_compatibility_vector() {
517 let url = sign_public_url(
518 "https://images.example.com",
519 SignedUrlSource::Path {
520 path: "image.png".to_string(),
521 version: None,
522 },
523 &TransformOptions {
524 width: Some(800),
525 format: Some(crate::MediaType::Webp),
526 ..TransformOptions::default()
527 },
528 "public-demo",
529 "secret-value",
530 1_900_000_000,
531 None,
532 None,
533 )
534 .unwrap();
535
536 assert_eq!(
537 url,
538 "https://images.example.com/images/by-path?expires=1900000000&format=webp&keyId=public-demo&path=image.png&signature=8c3234125e0e20efeaae1e2afaa88a81d387c82cef0080780fddd31c5689199e&width=800"
539 );
540 }
541}