1use std::time::Duration;
8
9use image::imageops::FilterType;
10use reqwest::header::{CONTENT_LENGTH, CONTENT_TYPE};
11use thiserror::Error;
12
13pub const AVATAR_HASH_ALGORITHM: &str = "dhash64_v1";
15
16pub const DEFAULT_AVATAR_HASH_MAX_BYTES: usize = 1_000_000;
18
19pub const DEFAULT_AVATAR_HASH_TIMEOUT: Duration = Duration::from_secs(5);
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub struct AvatarHashOptions {
25 pub max_bytes: usize,
27 pub timeout: Duration,
29}
30
31impl Default for AvatarHashOptions {
32 fn default() -> Self {
33 Self {
34 max_bytes: DEFAULT_AVATAR_HASH_MAX_BYTES,
35 timeout: DEFAULT_AVATAR_HASH_TIMEOUT,
36 }
37 }
38}
39
40#[derive(Debug, Error)]
42pub enum AvatarHashError {
43 #[error("invalid avatar URL: {0}")]
45 InvalidUrl(#[from] url::ParseError),
46 #[error("avatar URL must use http or https")]
48 UnsupportedScheme,
49 #[error("avatar response was not successful: {0}")]
51 Status(reqwest::StatusCode),
52 #[error("avatar response content type {0:?} is not allowed")]
54 UnsupportedContentType(Option<String>),
55 #[error("avatar response exceeded {max_bytes} bytes")]
57 TooLarge {
58 max_bytes: usize,
60 },
61 #[error("avatar fetch failed: {0}")]
63 Request(#[from] reqwest::Error),
64 #[error("avatar image decode failed: {0}")]
66 Decode(#[from] image::ImageError),
67}
68
69pub async fn fetch_avatar_hash(
76 client: &reqwest::Client,
77 url: &str,
78 options: AvatarHashOptions,
79) -> Result<String, AvatarHashError> {
80 let parsed = url::Url::parse(url)?;
81 if !matches!(parsed.scheme(), "http" | "https") {
82 return Err(AvatarHashError::UnsupportedScheme);
83 }
84
85 let mut response = client.get(parsed).timeout(options.timeout).send().await?;
86 let status = response.status();
87 if !status.is_success() {
88 return Err(AvatarHashError::Status(status));
89 }
90
91 let content_type = normalized_content_type(response.headers().get(CONTENT_TYPE));
92 if !content_type
93 .as_deref()
94 .is_some_and(allowed_avatar_content_type)
95 {
96 return Err(AvatarHashError::UnsupportedContentType(content_type));
97 }
98
99 if advertised_length(response.headers().get(CONTENT_LENGTH))
100 .is_some_and(|length| length > options.max_bytes)
101 {
102 return Err(AvatarHashError::TooLarge {
103 max_bytes: options.max_bytes,
104 });
105 }
106
107 let mut bytes = Vec::new();
108 while let Some(chunk) = response.chunk().await? {
109 let next_len = bytes.len().saturating_add(chunk.len());
110 if next_len > options.max_bytes {
111 return Err(AvatarHashError::TooLarge {
112 max_bytes: options.max_bytes,
113 });
114 }
115 bytes.extend_from_slice(&chunk);
116 }
117
118 avatar_hash_from_bytes(&bytes)
119}
120
121pub fn avatar_hash_from_bytes(bytes: &[u8]) -> Result<String, AvatarHashError> {
124 let image = image::load_from_memory(bytes)?;
125 let gray = image.resize_exact(9, 8, FilterType::Triangle).to_luma8();
126
127 let mut bits = 0_u64;
128 for y in 0..8 {
129 for x in 0..8 {
130 let left = gray.get_pixel(x, y)[0];
131 let right = gray.get_pixel(x + 1, y)[0];
132 let index = y * 8 + x;
133 if left > right {
134 bits |= 1_u64 << index;
135 }
136 }
137 }
138
139 Ok(format!("{AVATAR_HASH_ALGORITHM}:{bits:016x}"))
140}
141
142fn normalized_content_type(value: Option<&reqwest::header::HeaderValue>) -> Option<String> {
143 value
144 .and_then(|value| value.to_str().ok())
145 .map(|value| value.split(';').next().unwrap_or(value).trim())
146 .filter(|value| !value.is_empty())
147 .map(str::to_ascii_lowercase)
148}
149
150fn allowed_avatar_content_type(content_type: &str) -> bool {
151 matches!(
152 content_type,
153 "image/gif" | "image/jpeg" | "image/png" | "image/webp"
154 )
155}
156
157fn advertised_length(value: Option<&reqwest::header::HeaderValue>) -> Option<usize> {
158 value
159 .and_then(|value| value.to_str().ok())
160 .and_then(|value| value.parse().ok())
161}
162
163#[cfg(test)]
164mod tests {
165 use std::io::Cursor;
166
167 use image::{DynamicImage, ImageFormat, Rgb, RgbImage};
168 use wiremock::matchers::{method, path};
169 use wiremock::{Mock, MockServer, ResponseTemplate};
170
171 use super::*;
172
173 fn png_bytes(pattern: impl Fn(u32, u32) -> [u8; 3]) -> Vec<u8> {
174 let image = RgbImage::from_fn(16, 16, |x, y| Rgb(pattern(x, y)));
175 let mut cursor = Cursor::new(Vec::new());
176 DynamicImage::ImageRgb8(image)
177 .write_to(&mut cursor, ImageFormat::Png)
178 .unwrap();
179 cursor.into_inner()
180 }
181
182 #[test]
183 fn same_image_produces_same_hash() {
184 let image = png_bytes(|x, y| {
185 if (x + y) % 2 == 0 {
186 [255, 255, 255]
187 } else {
188 [0, 0, 0]
189 }
190 });
191
192 let left = avatar_hash_from_bytes(&image).unwrap();
193 let right = avatar_hash_from_bytes(&image).unwrap();
194
195 assert_eq!(left, right);
196 assert!(left.starts_with("dhash64_v1:"));
197 }
198
199 #[test]
200 fn visibly_different_images_produce_different_hashes() {
201 let vertical = png_bytes(|x, _| if x < 8 { [255, 255, 255] } else { [0, 0, 0] });
202 let horizontal = png_bytes(|_, y| if y < 8 { [255, 255, 255] } else { [0, 0, 0] });
203
204 let vertical_hash = avatar_hash_from_bytes(&vertical).unwrap();
205 let horizontal_hash = avatar_hash_from_bytes(&horizontal).unwrap();
206
207 assert_ne!(vertical_hash, horizontal_hash);
208 }
209
210 #[tokio::test]
211 async fn fetch_rejects_non_image_responses() {
212 let server = MockServer::start().await;
213 Mock::given(method("GET"))
214 .and(path("/avatar.txt"))
215 .respond_with(
216 ResponseTemplate::new(200)
217 .insert_header("content-type", "text/plain")
218 .set_body_string("not an image"),
219 )
220 .mount(&server)
221 .await;
222
223 let client = reqwest::Client::new();
224 let err = fetch_avatar_hash(
225 &client,
226 &format!("{}/avatar.txt", server.uri()),
227 AvatarHashOptions::default(),
228 )
229 .await
230 .unwrap_err();
231
232 assert!(matches!(err, AvatarHashError::UnsupportedContentType(_)));
233 }
234
235 #[tokio::test]
236 async fn fetch_rejects_oversized_responses_before_decoding() {
237 let server = MockServer::start().await;
238 Mock::given(method("GET"))
239 .and(path("/avatar.png"))
240 .respond_with(
241 ResponseTemplate::new(200)
242 .insert_header("content-type", "image/png")
243 .insert_header("content-length", "100")
244 .set_body_bytes(vec![0_u8; 100]),
245 )
246 .mount(&server)
247 .await;
248
249 let client = reqwest::Client::new();
250 let err = fetch_avatar_hash(
251 &client,
252 &format!("{}/avatar.png", server.uri()),
253 AvatarHashOptions {
254 max_bytes: 16,
255 timeout: Duration::from_secs(1),
256 },
257 )
258 .await
259 .unwrap_err();
260
261 assert!(matches!(err, AvatarHashError::TooLarge { max_bytes: 16 }));
262 }
263}