iris_chat_core/
image_proxy.rs1use 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 proxied_image_url(
16 original_src: &str,
17 preferences: &PreferencesSnapshot,
18 width: Option<u32>,
19 height: Option<u32>,
20 square: bool,
21) -> String {
22 let input = original_src.trim();
23 if input.is_empty() || !preferences.image_proxy_enabled {
24 return original_src.to_string();
25 }
26 if input.starts_with("data:") || input.starts_with("blob:") {
27 return original_src.to_string();
28 }
29
30 let Ok(source_url) = Url::parse(input) else {
31 return original_src.to_string();
32 };
33 if !is_http_url(&source_url) {
34 return original_src.to_string();
35 }
36
37 let proxy_base = resolved_proxy_url(preferences);
38 let Ok(proxy_url) = Url::parse(&proxy_base) else {
39 return original_src.to_string();
40 };
41 if !is_http_url(&proxy_url) {
42 return original_src.to_string();
43 }
44 if input.starts_with(&proxy_base) {
45 return original_src.to_string();
46 }
47
48 let mut options = Vec::new();
49 if let (Some(resize_width), Some(resize_height)) = (
50 normalized_dimension(width, height),
51 normalized_dimension(height, width),
52 ) {
53 let mode = if square { "fill" } else { "fit" };
54 options.push(format!("rs:{mode}:{resize_width}:{resize_height}"));
55 }
56 options.push("dpr:2".to_string());
57
58 let encoded_source = URL_SAFE_NO_PAD.encode(input.as_bytes());
59 let path = format!("/{}/{}", options.join("/"), encoded_source);
60 let Some(signature) = sign_path(&path, preferences) else {
61 return original_src.to_string();
62 };
63
64 format!("{}/{}{}", proxy_base.trim_end_matches('/'), signature, path)
65}
66
67fn resolved_proxy_url(preferences: &PreferencesSnapshot) -> String {
68 let trimmed = preferences.image_proxy_url.trim();
69 if trimmed.is_empty() {
70 DEFAULT_IMAGE_PROXY_URL.to_string()
71 } else {
72 trimmed.to_string()
73 }
74}
75
76fn sign_path(path: &str, preferences: &PreferencesSnapshot) -> Option<String> {
77 let key = decode_hex(resolved_hex(
78 &preferences.image_proxy_key_hex,
79 DEFAULT_IMAGE_PROXY_KEY_HEX,
80 ))?;
81 let salt = decode_hex(resolved_hex(
82 &preferences.image_proxy_salt_hex,
83 DEFAULT_IMAGE_PROXY_SALT_HEX,
84 ))?;
85 let mut mac = HmacSha256::new_from_slice(&key).ok()?;
86 mac.update(&salt);
87 mac.update(path.as_bytes());
88 Some(URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes()))
89}
90
91fn resolved_hex<'a>(value: &'a str, fallback: &'a str) -> &'a str {
92 let trimmed = value.trim();
93 if trimmed.is_empty() {
94 fallback
95 } else {
96 trimmed
97 }
98}
99
100fn decode_hex(value: &str) -> Option<Vec<u8>> {
101 let normalized = value.trim();
102 if normalized.is_empty() || !normalized.len().is_multiple_of(2) {
103 return None;
104 }
105 let mut bytes = Vec::with_capacity(normalized.len() / 2);
106 for pair in normalized.as_bytes().chunks_exact(2) {
107 let high = hex_value(pair[0])?;
108 let low = hex_value(pair[1])?;
109 bytes.push((high << 4) | low);
110 }
111 Some(bytes)
112}
113
114fn hex_value(value: u8) -> Option<u8> {
115 match value {
116 b'0'..=b'9' => Some(value - b'0'),
117 b'a'..=b'f' => Some(value - b'a' + 10),
118 b'A'..=b'F' => Some(value - b'A' + 10),
119 _ => None,
120 }
121}
122
123fn is_http_url(url: &Url) -> bool {
124 matches!(url.scheme(), "http" | "https") && url.host_str().is_some()
125}
126
127fn normalized_dimension(value: Option<u32>, fallback: Option<u32>) -> Option<u32> {
128 match value.or(fallback) {
129 Some(candidate) if candidate > 0 => Some(candidate),
130 _ => None,
131 }
132}
133
134#[cfg(test)]
135mod tests {
136 use super::*;
137
138 fn preferences() -> PreferencesSnapshot {
139 PreferencesSnapshot::default()
140 }
141
142 #[test]
143 fn disabled_proxy_returns_original_url() {
144 let mut preferences = preferences();
145 preferences.image_proxy_enabled = false;
146 let input = "https://example.com/avatar.jpg";
147
148 assert_eq!(
149 proxied_image_url(input, &preferences, Some(64), Some(64), false),
150 input
151 );
152 }
153
154 #[test]
155 fn ignores_non_http_data_blob_and_existing_proxy_urls() {
156 let preferences = preferences();
157
158 assert_eq!(
159 proxied_image_url("data:image/png;base64,abc", &preferences, None, None, false),
160 "data:image/png;base64,abc"
161 );
162 assert_eq!(
163 proxied_image_url(
164 "blob:https://example.com/123",
165 &preferences,
166 None,
167 None,
168 false
169 ),
170 "blob:https://example.com/123"
171 );
172 assert_eq!(
173 proxied_image_url("file:///tmp/avatar.jpg", &preferences, None, None, false),
174 "file:///tmp/avatar.jpg"
175 );
176 assert_eq!(
177 proxied_image_url(
178 "https://imgproxy.iris.to/signature/dpr:2/source",
179 &preferences,
180 None,
181 None,
182 false,
183 ),
184 "https://imgproxy.iris.to/signature/dpr:2/source"
185 );
186 }
187
188 #[test]
189 fn generates_deterministic_signed_proxy_url() {
190 let preferences = preferences();
191 let input = "https://example.com/avatar.jpg";
192
193 let proxied = proxied_image_url(input, &preferences, Some(64), Some(64), true);
194
195 assert!(proxied.starts_with("https://imgproxy.iris.to/"));
196 assert!(proxied.contains("/rs:fill:64:64/"));
197 assert!(proxied.contains("/dpr:2/"));
198 assert!(proxied.contains("aHR0cHM6Ly9leGFtcGxlLmNvbS9hdmF0YXIuanBn"));
199 assert_eq!(
200 proxied_image_url(input, &preferences, Some(64), Some(64), true),
201 proxied
202 );
203 }
204
205 #[test]
206 fn invalid_key_or_salt_returns_original_url() {
207 let mut preferences = preferences();
208 preferences.image_proxy_key_hex = "not-hex".to_string();
209 preferences.image_proxy_salt_hex = "also-not-hex".to_string();
210 let input = "https://example.com/avatar.jpg";
211
212 assert_eq!(
213 proxied_image_url(input, &preferences, None, None, false),
214 input
215 );
216 }
217}