use std::sync::Mutex;
use std::time::Duration;
use web_time::Instant;
pub const SETTING_KEY: &str = "privacy_proxy_media";
pub const UNFURL_EXT: &str = "magnitude-unfurl";
pub const PROXY_EXT: &str = "magnitude-proxy";
pub fn enabled() -> bool {
match crate::db::get_sql_setting(SETTING_KEY.to_string()) {
Ok(Some(v)) => v == "true" || v == "1",
_ => true,
}
}
struct Pick {
extension: &'static str,
server: Option<String>,
at: Instant,
}
static PICKS: Mutex<Vec<Pick>> = Mutex::new(Vec::new());
const PICK_TTL: Duration = Duration::from_secs(10 * 60);
const NEGATIVE_PICK_TTL: Duration = Duration::from_secs(60);
pub async fn server_offering(extension: &'static str) -> Option<String> {
if let Ok(picks) = PICKS.lock() {
if let Some(p) = picks.iter().find(|p| p.extension == extension) {
if p.at.elapsed() < PICK_TTL {
return p.server.clone();
}
}
}
let mut servers = crate::state::get_blossom_servers();
for _ in 0..25 {
if !servers.is_empty() {
break;
}
crate::rt::time::sleep(Duration::from_millis(200)).await;
servers = crate::state::get_blossom_servers();
}
let mut found = None;
let mut every_answer_known = !servers.is_empty();
for server in servers {
let info = match crate::signer::active_signer() {
Ok(signer) => match crate::blossom_info::refresh(&signer, &server, Duration::from_secs(60 * 60)).await {
Ok(i) => i,
Err(_) => {
every_answer_known = false;
None
}
},
Err(_) => {
every_answer_known = false;
crate::blossom_info::cached(&server)
}
};
if info.map(|i| i.extensions.iter().any(|e| e == extension)).unwrap_or(false) {
found = Some(server.trim_end_matches('/').to_string());
break;
}
}
match &found {
Some(s) => crate::log_debug!("[Proxy] {} offered by {}", extension, s),
None if every_answer_known => crate::log_warn!("[Proxy] no configured Blossom server offers {}; falling back", extension),
None => crate::log_warn!("[Proxy] could not learn which server offers {}; asking again next time", extension),
}
if let Ok(mut picks) = PICKS.lock() {
picks.retain(|p| p.extension != extension);
match &found {
Some(_) => picks.push(Pick { extension, server: found.clone(), at: Instant::now() }),
None => {
if let Some(at) = Instant::now().checked_sub(PICK_TTL - NEGATIVE_PICK_TTL) {
picks.push(Pick { extension, server: None, at });
}
}
}
}
found
}
pub fn forget_picks() {
if let Ok(mut picks) = PICKS.lock() {
picks.clear();
}
}
pub async fn proxied(url: &str) -> Option<String> {
if !enabled() || !wants_proxy(url) {
return None;
}
let server = server_offering(PROXY_EXT).await?;
Some(proxy_url(&server, url))
}
pub fn proxy_url(server: &str, url: &str) -> String {
let encoded: String = url::form_urlencoded::byte_serialize(url.as_bytes()).collect();
format!("{server}/proxy?url={encoded}")
}
const OWN_DOMAINS: &[&str] = &["vectorapp.io", "jskitty.com"];
fn is_own_host(host: &str) -> bool {
let h = host.to_ascii_lowercase();
OWN_DOMAINS.iter().any(|d| h == *d || h.ends_with(&format!(".{d}")))
}
pub fn wants_proxy(url: &str) -> bool {
let Ok(parsed) = url::Url::parse(url) else { return false };
if !matches!(parsed.scheme(), "http" | "https") {
return false;
}
if parsed.path() == "/proxy" || parsed.path() == "/unfurl" {
return false;
}
!parsed.host_str().map(is_own_host).unwrap_or(true)
}
pub async fn proxy_authorization(server: &str) -> Option<reqwest::header::HeaderValue> {
let signer = crate::signer::active_signer().ok()?;
let server_url = url::Url::parse(server).ok()?;
crate::blossom_info::build_get_auth_header(&signer, &server_url, "Proxied fetch")
.await
.ok()
}
pub fn proxy_server_of(url: &str) -> Option<String> {
let parsed = url::Url::parse(url).ok()?;
if parsed.path() != "/proxy" {
return None;
}
Some(parsed.origin().ascii_serialization())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn everything_but_our_own_domains_wants_the_proxy() {
assert!(wants_proxy("https://cdn.example.net/a.jpg"));
assert!(wants_proxy("https://blossom.primal.net/abc.bin"), "somebody's Blossom server is somebody's");
assert!(wants_proxy("https://image.nostr.build/x.png"));
assert!(!wants_proxy("https://magnitude.jskitty.com/abc.png"), "ours");
assert!(!wants_proxy("https://us.magnitude.jskitty.com/abc.png"), "ours, an edge");
assert!(!wants_proxy("https://vectorapp.io/assets/x.png"), "ours");
assert!(wants_proxy("https://notjskitty.com/x.png"), "a suffix is not a subdomain");
assert!(!wants_proxy("data:image/png;base64,AAAA"));
assert!(!wants_proxy("asset://localhost/x.png"));
assert!(!wants_proxy("https://magnitude.example/proxy?url=x"));
assert_eq!(proxy_server_of("https://us.magnitude.jskitty.com/proxy?url=x").as_deref(), Some("https://us.magnitude.jskitty.com"));
assert!(proxy_server_of("https://cdn.example.net/a.jpg").is_none());
}
}