Skip to main content

iris_chat_core/
image_proxy.rs

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 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 [first, second] = <[u8; 2]>::try_from(pair).ok()?;
108        let high = hex_value(first)?;
109        let low = hex_value(second)?;
110        bytes.push((high << 4) | low);
111    }
112    Some(bytes)
113}
114
115fn hex_value(value: u8) -> Option<u8> {
116    match value {
117        b'0'..=b'9' => Some(value - b'0'),
118        b'a'..=b'f' => Some(value - b'a' + 10),
119        b'A'..=b'F' => Some(value - b'A' + 10),
120        _ => None,
121    }
122}
123
124fn is_http_url(url: &Url) -> bool {
125    matches!(url.scheme(), "http" | "https") && url.host_str().is_some()
126}
127
128fn normalized_dimension(value: Option<u32>, fallback: Option<u32>) -> Option<u32> {
129    match value.or(fallback) {
130        Some(candidate) if candidate > 0 => Some(candidate),
131        _ => None,
132    }
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138
139    fn preferences() -> PreferencesSnapshot {
140        PreferencesSnapshot::default()
141    }
142
143    #[test]
144    fn disabled_proxy_returns_original_url() {
145        let mut preferences = preferences();
146        preferences.image_proxy_enabled = false;
147        let input = "https://example.com/avatar.jpg";
148
149        assert_eq!(
150            proxied_image_url(input, &preferences, Some(64), Some(64), false),
151            input
152        );
153    }
154
155    #[test]
156    fn ignores_non_http_data_blob_and_existing_proxy_urls() {
157        let preferences = preferences();
158
159        assert_eq!(
160            proxied_image_url("data:image/png;base64,abc", &preferences, None, None, false),
161            "data:image/png;base64,abc"
162        );
163        assert_eq!(
164            proxied_image_url(
165                "blob:https://example.com/123",
166                &preferences,
167                None,
168                None,
169                false
170            ),
171            "blob:https://example.com/123"
172        );
173        assert_eq!(
174            proxied_image_url("file:///tmp/avatar.jpg", &preferences, None, None, false),
175            "file:///tmp/avatar.jpg"
176        );
177        assert_eq!(
178            proxied_image_url(
179                "https://imgproxy.iris.to/signature/dpr:2/source",
180                &preferences,
181                None,
182                None,
183                false,
184            ),
185            "https://imgproxy.iris.to/signature/dpr:2/source"
186        );
187    }
188
189    #[test]
190    fn generates_deterministic_signed_proxy_url() {
191        let preferences = preferences();
192        let input = "https://example.com/avatar.jpg";
193
194        let proxied = proxied_image_url(input, &preferences, Some(64), Some(64), true);
195
196        assert!(proxied.starts_with("https://imgproxy.iris.to/"));
197        assert!(proxied.contains("/rs:fill:64:64/"));
198        assert!(proxied.contains("/dpr:2/"));
199        assert!(proxied.contains("aHR0cHM6Ly9leGFtcGxlLmNvbS9hdmF0YXIuanBn"));
200        assert_eq!(
201            proxied_image_url(input, &preferences, Some(64), Some(64), true),
202            proxied
203        );
204    }
205
206    #[test]
207    fn invalid_key_or_salt_returns_original_url() {
208        let mut preferences = preferences();
209        preferences.image_proxy_key_hex = "not-hex".to_string();
210        preferences.image_proxy_salt_hex = "also-not-hex".to_string();
211        let input = "https://example.com/avatar.jpg";
212
213        assert_eq!(
214            proxied_image_url(input, &preferences, None, None, false),
215            input
216        );
217    }
218}