camo/
camo.rs

1pub use crate::utils::crypto::{generate_digest, verify_digest};
2pub use crate::utils::encoding::{encode_url_base64, encode_url_hex};
3
4/// URL encoding format
5#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
6pub enum Encoding {
7    /// Hexadecimal encoding (default, compatible with original Camo)
8    #[default]
9    Hex,
10    /// URL-safe Base64 encoding
11    Base64,
12}
13
14/// A signed Camo URL ready for use
15#[derive(Debug, Clone)]
16pub struct SignedUrl {
17    /// The original URL that was signed
18    pub original_url: String,
19    /// The HMAC-SHA1 digest
20    pub digest: String,
21    /// The encoded URL
22    pub encoded_url: String,
23    /// The encoding format used
24    pub encoding: Encoding,
25}
26
27impl SignedUrl {
28    /// Generate the full proxy URL with a base URL
29    ///
30    /// # Example
31    ///
32    /// ```rust
33    /// use camo::CamoUrl;
34    ///
35    /// let camo = CamoUrl::new("secret");
36    /// let url = camo.sign("http://example.com/image.png")
37    ///     .to_url("https://camo.example.com");
38    /// ```
39    pub fn to_url(&self, base: &str) -> String {
40        let base = base.trim_end_matches('/');
41        format!("{}/{}/{}", base, self.digest, self.encoded_url)
42    }
43
44    /// Get just the path portion (without base URL)
45    ///
46    /// # Example
47    ///
48    /// ```rust
49    /// use camo::CamoUrl;
50    ///
51    /// let camo = CamoUrl::new("secret");
52    /// let path = camo.sign("http://example.com/image.png").to_path();
53    /// // Returns: /abc123.../68747470...
54    /// ```
55    pub fn to_path(&self) -> String {
56        format!("/{}/{}", self.digest, self.encoded_url)
57    }
58
59    /// Switch to Base64 encoding
60    pub fn base64(mut self) -> Self {
61        if self.encoding != Encoding::Base64 {
62            self.encoded_url = encode_url_base64(&self.original_url);
63            self.encoding = Encoding::Base64;
64        }
65        self
66    }
67
68    /// Switch to Hex encoding
69    pub fn hex(mut self) -> Self {
70        if self.encoding != Encoding::Hex {
71            self.encoded_url = encode_url_hex(&self.original_url);
72            self.encoding = Encoding::Hex;
73        }
74        self
75    }
76}
77
78/// Camo URL generator
79///
80/// Use this struct to generate signed URLs for a Camo proxy.
81///
82/// # Example
83///
84/// ```rust
85/// use camo::CamoUrl;
86///
87/// let camo = CamoUrl::new("your-secret-key");
88/// let signed = camo.sign("http://example.com/image.png");
89/// let url = signed.to_url("https://camo.example.com");
90/// ```
91#[derive(Debug, Clone)]
92pub struct CamoUrl {
93    key: String,
94    default_encoding: Encoding,
95}
96
97impl CamoUrl {
98    /// Create a new CamoUrl generator with the given HMAC key
99    ///
100    /// # Arguments
101    ///
102    /// * `key` - The HMAC secret key for signing URLs
103    ///
104    /// # Example
105    ///
106    /// ```rust
107    /// use camo::CamoUrl;
108    ///
109    /// let camo = CamoUrl::new("your-secret-key");
110    /// ```
111    pub fn new(key: impl Into<String>) -> Self {
112        Self {
113            key: key.into(),
114            default_encoding: Encoding::Hex,
115        }
116    }
117
118    /// Set the default encoding format for generated URLs
119    ///
120    /// # Example
121    ///
122    /// ```rust
123    /// use camo::{CamoUrl, Encoding};
124    ///
125    /// let camo = CamoUrl::new("secret")
126    ///     .with_encoding(Encoding::Base64);
127    /// ```
128    pub fn with_encoding(mut self, encoding: Encoding) -> Self {
129        self.default_encoding = encoding;
130        self
131    }
132
133    /// Sign a URL and return a SignedUrl
134    ///
135    /// # Arguments
136    ///
137    /// * `url` - The URL to sign (typically an HTTP image URL)
138    ///
139    /// # Example
140    ///
141    /// ```rust
142    /// use camo::CamoUrl;
143    ///
144    /// let camo = CamoUrl::new("secret");
145    /// let signed = camo.sign("http://example.com/image.png");
146    ///
147    /// // Get the full URL
148    /// let url = signed.to_url("https://camo.example.com");
149    ///
150    /// // Or just the path
151    /// let path = camo.sign("http://example.com/image.png").to_path();
152    /// ```
153    pub fn sign(&self, url: impl AsRef<str>) -> SignedUrl {
154        let url = url.as_ref();
155        let digest = generate_digest(&self.key, url);
156        let encoded_url = match self.default_encoding {
157            Encoding::Hex => encode_url_hex(url),
158            Encoding::Base64 => encode_url_base64(url),
159        };
160
161        SignedUrl {
162            original_url: url.to_string(),
163            digest,
164            encoded_url,
165            encoding: self.default_encoding,
166        }
167    }
168
169    /// Convenience method to sign and generate a full URL in one call
170    ///
171    /// # Example
172    ///
173    /// ```rust
174    /// use camo::CamoUrl;
175    ///
176    /// let camo = CamoUrl::new("secret");
177    /// let url = camo.sign_url("http://example.com/image.png", "https://camo.example.com");
178    /// ```
179    pub fn sign_url(&self, url: impl AsRef<str>, base: &str) -> String {
180        self.sign(url).to_url(base)
181    }
182
183    /// Verify a digest matches the expected value for a URL
184    ///
185    /// # Example
186    ///
187    /// ```rust
188    /// use camo::CamoUrl;
189    ///
190    /// let camo = CamoUrl::new("secret");
191    /// let signed = camo.sign("http://example.com/image.png");
192    ///
193    /// assert!(camo.verify("http://example.com/image.png", &signed.digest));
194    /// assert!(!camo.verify("http://example.com/image.png", "invalid"));
195    /// ```
196    pub fn verify(&self, url: impl AsRef<str>, digest: &str) -> bool {
197        verify_digest(&self.key, url.as_ref(), digest)
198    }
199}
200
201/// Generate a signed Camo URL (convenience function)
202///
203/// This is a shorthand for creating a CamoUrl and calling sign_url.
204///
205/// # Arguments
206///
207/// * `key` - The HMAC secret key
208/// * `url` - The URL to sign
209/// * `base` - The Camo proxy base URL
210///
211/// # Example
212///
213/// ```rust
214/// let url = camo::sign_url("secret", "http://example.com/image.png", "https://camo.example.com");
215/// ```
216pub fn sign_url(key: &str, url: &str, base: &str) -> String {
217    CamoUrl::new(key).sign_url(url, base)
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223
224    #[test]
225    fn test_sign_url() {
226        let camo = CamoUrl::new("test-secret");
227        let signed = camo.sign("http://example.com/image.png");
228
229        assert!(!signed.digest.is_empty());
230        assert!(!signed.encoded_url.is_empty());
231        assert_eq!(signed.encoding, Encoding::Hex);
232    }
233
234    #[test]
235    fn test_sign_url_base64() {
236        let camo = CamoUrl::new("test-secret").with_encoding(Encoding::Base64);
237        let signed = camo.sign("http://example.com/image.png");
238
239        assert_eq!(signed.encoding, Encoding::Base64);
240    }
241
242    #[test]
243    fn test_to_url() {
244        let camo = CamoUrl::new("test-secret");
245        let url = camo.sign_url("http://example.com/image.png", "https://camo.example.com");
246
247        assert!(url.starts_with("https://camo.example.com/"));
248        assert!(url.contains('/'));
249    }
250
251    #[test]
252    fn test_verify() {
253        let camo = CamoUrl::new("test-secret");
254        let signed = camo.sign("http://example.com/image.png");
255
256        assert!(camo.verify("http://example.com/image.png", &signed.digest));
257        assert!(!camo.verify("http://example.com/image.png", "invalid-digest"));
258    }
259
260    #[test]
261    fn test_encoding_switch() {
262        let camo = CamoUrl::new("test-secret");
263        let signed = camo.sign("http://example.com/image.png");
264        let hex_encoded = signed.encoded_url.clone();
265
266        let signed = signed.base64();
267        assert_ne!(signed.encoded_url, hex_encoded);
268        assert_eq!(signed.encoding, Encoding::Base64);
269
270        let signed = signed.hex();
271        assert_eq!(signed.encoded_url, hex_encoded);
272        assert_eq!(signed.encoding, Encoding::Hex);
273    }
274
275    #[test]
276    fn test_convenience_function() {
277        let url = sign_url(
278            "secret",
279            "http://example.com/image.png",
280            "https://camo.example.com",
281        );
282        assert!(url.starts_with("https://camo.example.com/"));
283    }
284}