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}