1use crate::state::PreferencesSnapshot;
2use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
3use hmac::{Hmac, Mac};
4use sha2::Sha256;
5use url::Url;
6
7pub const DEFAULT_IMAGE_PROXY_URL: &str = "https://imgproxy.iris.to";
8pub const DEFAULT_IMAGE_PROXY_KEY_HEX: &str =
9 "f66233cb160ea07078ff28099bfa3e3e654bc10aa4a745e12176c433d79b8996";
10pub const DEFAULT_IMAGE_PROXY_SALT_HEX: &str =
11 "5e608e60945dcd2a787e8465d76ba34149894765061d39287609fb9d776caa0c";
12
13type HmacSha256 = Hmac<Sha256>;
14
15pub fn image_load_urls(
18 original_src: &str,
19 preferences: &PreferencesSnapshot,
20 width: Option<u32>,
21 height: Option<u32>,
22 square: bool,
23) -> Vec<String> {
24 let original = original_src.trim();
25 let primary = proxied_image_url(original, preferences, width, height, square);
26 if primary.is_empty() {
27 return Vec::new();
28 }
29 let allow_fallback = preferences.image_proxy_enabled
30 && preferences.image_proxy_fallback_enabled
31 && primary != original
32 && Url::parse(original).is_ok_and(|url| is_http_url(&url));
33 let mut urls = vec![primary];
34 if allow_fallback {
35 urls.push(original.to_string());
36 }
37 urls
38}
39
40pub fn proxied_image_url(
41 original_src: &str,
42 preferences: &PreferencesSnapshot,
43 width: Option<u32>,
44 height: Option<u32>,
45 square: bool,
46) -> String {
47 let input = original_src.trim();
48 if input.is_empty() || !preferences.image_proxy_enabled {
49 return original_src.to_string();
50 }
51 if input.starts_with("data:") || input.starts_with("blob:") {
52 return original_src.to_string();
53 }
54
55 let Ok(source_url) = Url::parse(input) else {
56 return String::new();
57 };
58 if !is_http_url(&source_url) {
59 return original_src.to_string();
60 }
61
62 let proxy_base = resolved_proxy_url(preferences);
63 let Ok(proxy_url) = Url::parse(&proxy_base) else {
64 return proxy_failure_fallback(original_src, preferences);
65 };
66 if !is_http_url(&proxy_url) {
67 return proxy_failure_fallback(original_src, preferences);
68 }
69 let proxy_path = proxy_url.path().trim_end_matches('/');
72 if source_url.origin() == proxy_url.origin()
73 && (source_url.path() == proxy_path
74 || source_url
75 .path()
76 .strip_prefix(proxy_path)
77 .is_some_and(|suffix| suffix.starts_with('/')))
78 {
79 return original_src.to_string();
80 }
81
82 let mut options = Vec::new();
83 if let (Some(resize_width), Some(resize_height)) = (
84 normalized_dimension(width, height),
85 normalized_dimension(height, width),
86 ) {
87 let mode = if square { "fill" } else { "fit" };
88 options.push(format!("rs:{mode}:{resize_width}:{resize_height}"));
89 }
90 options.push("dpr:2".to_string());
91
92 let encoded_source = URL_SAFE_NO_PAD.encode(input.as_bytes());
93 let path = format!("/{}/{}", options.join("/"), encoded_source);
94 let Some(signature) = sign_path(&path, preferences) else {
95 return proxy_failure_fallback(original_src, preferences);
96 };
97
98 format!("{}/{}{}", proxy_base.trim_end_matches('/'), signature, path)
99}
100
101fn proxy_failure_fallback(original_src: &str, preferences: &PreferencesSnapshot) -> String {
102 if preferences.image_proxy_fallback_enabled {
103 original_src.to_string()
104 } else {
105 String::new()
106 }
107}
108
109fn resolved_proxy_url(preferences: &PreferencesSnapshot) -> String {
110 let trimmed = preferences.image_proxy_url.trim();
111 if trimmed.is_empty() {
112 DEFAULT_IMAGE_PROXY_URL.to_string()
113 } else {
114 trimmed.to_string()
115 }
116}
117
118fn sign_path(path: &str, preferences: &PreferencesSnapshot) -> Option<String> {
119 let key = decode_hex(resolved_hex(
120 &preferences.image_proxy_key_hex,
121 DEFAULT_IMAGE_PROXY_KEY_HEX,
122 ))?;
123 let salt = decode_hex(resolved_hex(
124 &preferences.image_proxy_salt_hex,
125 DEFAULT_IMAGE_PROXY_SALT_HEX,
126 ))?;
127 let mut mac = HmacSha256::new_from_slice(&key).ok()?;
128 mac.update(&salt);
129 mac.update(path.as_bytes());
130 Some(URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes()))
131}
132
133fn resolved_hex<'a>(value: &'a str, fallback: &'a str) -> &'a str {
134 let trimmed = value.trim();
135 if trimmed.is_empty() {
136 fallback
137 } else {
138 trimmed
139 }
140}
141
142fn decode_hex(value: &str) -> Option<Vec<u8>> {
143 let normalized = value.trim();
144 if normalized.is_empty() || !normalized.len().is_multiple_of(2) {
145 return None;
146 }
147 let mut bytes = Vec::with_capacity(normalized.len() / 2);
148 for pair in normalized.as_bytes().chunks(2) {
149 let [first, second] = <[u8; 2]>::try_from(pair).ok()?;
150 let high = hex_value(first)?;
151 let low = hex_value(second)?;
152 bytes.push((high << 4) | low);
153 }
154 Some(bytes)
155}
156
157fn hex_value(value: u8) -> Option<u8> {
158 match value {
159 b'0'..=b'9' => Some(value - b'0'),
160 b'a'..=b'f' => Some(value - b'a' + 10),
161 b'A'..=b'F' => Some(value - b'A' + 10),
162 _ => None,
163 }
164}
165
166fn is_http_url(url: &Url) -> bool {
167 matches!(url.scheme(), "http" | "https") && url.host_str().is_some()
168}
169
170fn normalized_dimension(value: Option<u32>, fallback: Option<u32>) -> Option<u32> {
171 match value.or(fallback) {
172 Some(candidate) if candidate > 0 => Some(candidate),
173 _ => None,
174 }
175}
176
177#[cfg(test)]
178mod tests {
179 use super::*;
180
181 fn preferences() -> PreferencesSnapshot {
182 PreferencesSnapshot::default()
183 }
184
185 #[test]
186 fn disabled_proxy_returns_original_url() {
187 let mut preferences = preferences();
188 preferences.image_proxy_enabled = false;
189 let input = "https://example.com/avatar.jpg";
190
191 assert_eq!(
192 proxied_image_url(input, &preferences, Some(64), Some(64), false),
193 input
194 );
195 }
196
197 #[test]
198 fn ignores_non_http_data_blob_and_existing_proxy_urls() {
199 let preferences = preferences();
200
201 assert_eq!(
202 proxied_image_url("data:image/png;base64,abc", &preferences, None, None, false),
203 "data:image/png;base64,abc"
204 );
205 assert_eq!(
206 proxied_image_url(
207 "blob:https://example.com/123",
208 &preferences,
209 None,
210 None,
211 false
212 ),
213 "blob:https://example.com/123"
214 );
215 assert_eq!(
216 proxied_image_url("file:///tmp/avatar.jpg", &preferences, None, None, false),
217 "file:///tmp/avatar.jpg"
218 );
219 assert_eq!(
220 proxied_image_url(
221 "https://imgproxy.iris.to/signature/dpr:2/source",
222 &preferences,
223 None,
224 None,
225 false,
226 ),
227 "https://imgproxy.iris.to/signature/dpr:2/source"
228 );
229 }
230
231 #[test]
232 fn generates_deterministic_signed_proxy_url() {
233 let preferences = preferences();
234 let input = "https://example.com/avatar.jpg";
235
236 let proxied = proxied_image_url(input, &preferences, Some(64), Some(64), true);
237
238 assert!(proxied.starts_with("https://imgproxy.iris.to/"));
239 assert!(proxied.contains("/rs:fill:64:64/"));
240 assert!(proxied.contains("/dpr:2/"));
241 assert!(proxied.contains("aHR0cHM6Ly9leGFtcGxlLmNvbS9hdmF0YXIuanBn"));
242 assert_eq!(
243 proxied_image_url(input, &preferences, Some(64), Some(64), true),
244 proxied
245 );
246 }
247
248 #[test]
249 fn lookalike_proxy_urls_are_still_proxied() {
250 let preferences = preferences();
251 for input in [
252 "https://imgproxy.iris.to.attacker.example/avatar.jpg",
253 "https://imgproxy.iris.to@attacker.example/avatar.jpg",
254 "https://imgproxy.iris.to:8443/avatar.jpg",
255 ] {
256 let proxied = proxied_image_url(input, &preferences, None, None, false);
257 assert_ne!(proxied, input);
258 assert!(proxied.starts_with("https://imgproxy.iris.to/"));
259 assert!(proxied.ends_with(&URL_SAFE_NO_PAD.encode(input.as_bytes())));
260 }
261 }
262
263 #[test]
264 fn proxy_path_must_match_at_a_segment_boundary() {
265 let mut preferences = preferences();
266 preferences.image_proxy_url = "https://images.example/proxy".to_string();
267 let existing = "https://images.example/proxy/signature/dpr:2/source";
268 assert_eq!(
269 proxied_image_url(existing, &preferences, None, None, false),
270 existing
271 );
272 let input = "https://images.example/proxy-other/avatar.jpg";
273 assert_ne!(
274 proxied_image_url(input, &preferences, None, None, false),
275 input
276 );
277 }
278
279 #[test]
280 fn invalid_key_or_salt_requires_opt_in_to_load_original_url() {
281 let mut preferences = preferences();
282 preferences.image_proxy_key_hex = "not-hex".to_string();
283 preferences.image_proxy_salt_hex = "also-not-hex".to_string();
284 let input = "https://example.com/avatar.jpg";
285
286 assert_eq!(
287 proxied_image_url(input, &preferences, None, None, false),
288 ""
289 );
290 assert!(image_load_urls(input, &preferences, None, None, false).is_empty());
291 preferences.image_proxy_fallback_enabled = true;
292 assert_eq!(
293 image_load_urls(input, &preferences, None, None, false),
294 vec![input]
295 );
296 }
297
298 #[test]
299 fn image_load_candidates_require_explicit_fallback_opt_in() {
300 let mut preferences = preferences();
301 let input = "https://example.com/avatar.jpg";
302 let proxy = proxied_image_url(input, &preferences, Some(64), Some(64), true);
303 assert!(!preferences.image_proxy_fallback_enabled);
304 assert_ne!(proxy, input);
305 assert_eq!(
306 image_load_urls(input, &preferences, Some(64), Some(64), true),
307 vec![proxy.clone()]
308 );
309 preferences.image_proxy_fallback_enabled = true;
310 assert_eq!(
311 image_load_urls(input, &preferences, Some(64), Some(64), true),
312 vec![proxy, input.to_string()]
313 );
314 preferences.image_proxy_enabled = false;
315 assert_eq!(
316 image_load_urls(input, &preferences, None, None, false),
317 vec![input]
318 );
319 }
320
321 #[test]
322 fn invalid_proxy_configuration_fails_closed_unless_fallback_is_enabled() {
323 for proxy in ["not a URL", "file:///private/proxy", "https://"] {
324 let mut preferences = preferences();
325 preferences.image_proxy_url = proxy.to_string();
326 let input = "https://example.com/avatar.jpg";
327 assert!(image_load_urls(input, &preferences, None, None, false).is_empty());
328 preferences.image_proxy_fallback_enabled = true;
329 assert_eq!(
330 image_load_urls(input, &preferences, None, None, false),
331 vec![input]
332 );
333 }
334 }
335
336 #[test]
337 fn image_load_candidates_do_not_duplicate_sources_or_proxy_local_images() {
338 let mut preferences = preferences();
339 preferences.image_proxy_fallback_enabled = true;
340 for input in [
341 "https://imgproxy.iris.to/signature/dpr:2/source",
342 "data:image/png;base64,abc",
343 "file:///tmp/avatar.jpg",
344 "htree://nhash1abc123/photo.png",
345 ] {
346 assert_eq!(
347 image_load_urls(input, &preferences, None, None, false),
348 vec![input]
349 );
350 }
351 assert!(image_load_urls(" ", &preferences, None, None, false).is_empty());
352 }
353}