Skip to main content

vector_core/
proxy.rs

1//! Pictures, files and previews fetched by the user's Magnitude server
2//! instead of by this device.
3//!
4//! A picture loaded here, a file downloaded here, or a page previewed here
5//! is a request from this device's address to whoever hosts the thing, and
6//! most of it arrives without a click: sending someone a link or an emoji
7//! is enough to learn where they are. Magnitude fetches on the client's
8//! behalf (`magnitude-unfurl` for a page's metadata, `magnitude-proxy` for
9//! everything else), advertised in its information document. This module
10//! finds the first configured server that offers it and routes through it,
11//! under one privacy setting, on by default.
12//!
13//! Every outbound request in every crate resolves its destination through
14//! [`crate::net::egress`], which asks here. The only requests that never come
15//! through are the ones that cannot: Nostr relay sockets, and the signed
16//! writes (upload, mirror, delete, discovery) to a Blossom server the user
17//! chose, which already carry the user's key.
18//!
19//! When the setting is on and no configured server offers previews, there is
20//! no preview: falling back to fetching the page here would be the leak with
21//! a new name. Pictures do fall back to a direct load when no server offers
22//! the proxy, because an avatar that never appears is not a privacy feature
23//! anyone chose; the setting's own help text says so.
24
25use std::sync::Mutex;
26use std::time::Duration;
27use web_time::Instant;
28
29pub const SETTING_KEY: &str = "privacy_proxy_media";
30pub const UNFURL_EXT: &str = "magnitude-unfurl";
31pub const PROXY_EXT: &str = "magnitude-proxy";
32
33/// The setting, read fresh each time: it is one SQLite row and a change in
34/// Settings must take effect on the next picture, not the next launch.
35pub fn enabled() -> bool {
36    match crate::db::get_sql_setting(SETTING_KEY.to_string()) {
37        Ok(Some(v)) => v == "true" || v == "1",
38        _ => true,
39    }
40}
41
42/// Which server offers what, remembered briefly so a chat full of pictures
43/// does not scan the server list per picture.
44struct Pick {
45    extension: &'static str,
46    server: Option<String>,
47    at: Instant,
48}
49
50static PICKS: Mutex<Vec<Pick>> = Mutex::new(Vec::new());
51const PICK_TTL: Duration = Duration::from_secs(10 * 60);
52/// How long "no server offers it" is believed before asking again.
53const NEGATIVE_PICK_TTL: Duration = Duration::from_secs(60);
54
55/// The first configured Blossom server whose information document lists
56/// `extension`, as an origin without a trailing slash.
57pub async fn server_offering(extension: &'static str) -> Option<String> {
58    if let Ok(picks) = PICKS.lock() {
59        if let Some(p) = picks.iter().find(|p| p.extension == extension) {
60            if p.at.elapsed() < PICK_TTL {
61                return p.server.clone();
62            }
63        }
64    }
65    // The list is installed at login, and the first pictures are asked for
66    // in the same seconds; a pick made against an empty list is not "no
67    // server offers it", it is "not yet". Wait a little for it.
68    let mut servers = crate::state::get_blossom_servers();
69    for _ in 0..25 {
70        if !servers.is_empty() {
71            break;
72        }
73        crate::rt::time::sleep(Duration::from_millis(200)).await;
74        servers = crate::state::get_blossom_servers();
75    }
76    let mut found = None;
77    // Whether every server was actually asked. A document that could not
78    // be fetched says nothing about what the server offers.
79    let mut every_answer_known = !servers.is_empty();
80    for server in servers {
81        // A document no older than an hour, refetched otherwise. Never the
82        // persisted copy at any age: this account's was written before the
83        // server offered previews or the proxy, and trusting it meant every
84        // picture loaded directly while the toggle said otherwise.
85        let info = match crate::signer::active_signer() {
86            Ok(signer) => match crate::blossom_info::refresh(&signer, &server, Duration::from_secs(60 * 60)).await {
87                Ok(i) => i,
88                Err(_) => {
89                    every_answer_known = false;
90                    None
91                }
92            },
93            // No signer to ask with: the last copy is all there is.
94            Err(_) => {
95                every_answer_known = false;
96                crate::blossom_info::cached(&server)
97            }
98        };
99        if info.map(|i| i.extensions.iter().any(|e| e == extension)).unwrap_or(false) {
100            found = Some(server.trim_end_matches('/').to_string());
101            break;
102        }
103    }
104    match &found {
105        Some(s) => crate::log_debug!("[Proxy] {} offered by {}", extension, s),
106        None if every_answer_known => crate::log_warn!("[Proxy] no configured Blossom server offers {}; falling back", extension),
107        None => crate::log_warn!("[Proxy] could not learn which server offers {}; asking again next time", extension),
108    }
109    // A found server is remembered; "none", settled or not, only briefly, so a
110    // server that starts offering it (or answers again) is seen soon. An
111    // unsettled answer loads pictures directly either way; remembering it only
112    // spares every picture meanwhile a round of document fetches (a browser
113    // can't read a server whose error pages refuse cross-origin reads).
114    if let Ok(mut picks) = PICKS.lock() {
115        picks.retain(|p| p.extension != extension);
116        match &found {
117            Some(_) => picks.push(Pick { extension, server: found.clone(), at: Instant::now() }),
118            // Dated back so it ages out after the short window, not the long.
119            None => {
120                if let Some(at) = Instant::now().checked_sub(PICK_TTL - NEGATIVE_PICK_TTL) {
121                    picks.push(Pick { extension, server: None, at });
122                }
123            }
124        }
125    }
126    found
127}
128
129/// Forget the remembered picks: the server list or a setting changed.
130pub fn forget_picks() {
131    if let Ok(mut picks) = PICKS.lock() {
132        picks.clear();
133    }
134}
135
136/// Where to actually load `url` from. The proxy's address when the setting is
137/// on, a server offers it, and the URL is somebody else's; `None` means load
138/// it directly. A blob on one of the user's own servers is already theirs to
139/// fetch, and a proxy URL is not proxied again.
140pub async fn proxied(url: &str) -> Option<String> {
141    if !enabled() || !wants_proxy(url) {
142        return None;
143    }
144    let server = server_offering(PROXY_EXT).await?;
145    Some(proxy_url(&server, url))
146}
147
148pub fn proxy_url(server: &str, url: &str) -> String {
149    let encoded: String = url::form_urlencoded::byte_serialize(url.as_bytes()).collect();
150    format!("{server}/proxy?url={encoded}")
151}
152
153/// Domains that are ours: a Magnitude answers these, so loading from them
154/// directly reveals nothing to anyone else, and routing them through a
155/// Magnitude would be Magnitude proxying Magnitude.
156const OWN_DOMAINS: &[&str] = &["vectorapp.io", "jskitty.com"];
157
158fn is_own_host(host: &str) -> bool {
159    let h = host.to_ascii_lowercase();
160    OWN_DOMAINS.iter().any(|d| h == *d || h.ends_with(&format!(".{d}")))
161}
162
163/// Everything over http(s) that is not ours goes through the proxy: a
164/// picture, an avatar, an emoji, and an attachment on somebody's Blossom
165/// server, which otherwise learns the recipient's address on every
166/// download. Only our own domains load directly, and a URL that is already
167/// a proxy or preview request is never wrapped again.
168pub fn wants_proxy(url: &str) -> bool {
169    let Ok(parsed) = url::Url::parse(url) else { return false };
170    if !matches!(parsed.scheme(), "http" | "https") {
171        return false;
172    }
173    if parsed.path() == "/proxy" || parsed.path() == "/unfurl" {
174        return false;
175    }
176    !parsed.host_str().map(is_own_host).unwrap_or(true)
177}
178
179/// A signed `Authorization` for a proxied request, so the proxy charges the
180/// account rather than the address and a recognised account gets its own
181/// allowance. `None` when nobody is signed in; the proxy then treats the
182/// request as a stranger's, which still works.
183pub async fn proxy_authorization(server: &str) -> Option<reqwest::header::HeaderValue> {
184    let signer = crate::signer::active_signer().ok()?;
185    let server_url = url::Url::parse(server).ok()?;
186    crate::blossom_info::build_get_auth_header(&signer, &server_url, "Proxied fetch")
187        .await
188        .ok()
189}
190
191/// The proxy server a proxied URL points at, so its authorization can be
192/// scoped to it.
193pub fn proxy_server_of(url: &str) -> Option<String> {
194    let parsed = url::Url::parse(url).ok()?;
195    if parsed.path() != "/proxy" {
196        return None;
197    }
198    Some(parsed.origin().ascii_serialization())
199}
200
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205
206    #[test]
207    fn everything_but_our_own_domains_wants_the_proxy() {
208        assert!(wants_proxy("https://cdn.example.net/a.jpg"));
209        assert!(wants_proxy("https://blossom.primal.net/abc.bin"), "somebody's Blossom server is somebody's");
210        assert!(wants_proxy("https://image.nostr.build/x.png"));
211        assert!(!wants_proxy("https://magnitude.jskitty.com/abc.png"), "ours");
212        assert!(!wants_proxy("https://us.magnitude.jskitty.com/abc.png"), "ours, an edge");
213        assert!(!wants_proxy("https://vectorapp.io/assets/x.png"), "ours");
214        assert!(wants_proxy("https://notjskitty.com/x.png"), "a suffix is not a subdomain");
215        assert!(!wants_proxy("data:image/png;base64,AAAA"));
216        assert!(!wants_proxy("asset://localhost/x.png"));
217        assert!(!wants_proxy("https://magnitude.example/proxy?url=x"));
218        assert_eq!(proxy_server_of("https://us.magnitude.jskitty.com/proxy?url=x").as_deref(), Some("https://us.magnitude.jskitty.com"));
219        assert!(proxy_server_of("https://cdn.example.net/a.jpg").is_none());
220    }
221}