1use crate::SyncError;
12use std::time::Duration;
13
14pub const WAIT: Duration = Duration::from_secs(15);
16const HOPS: usize = 3;
18
19#[derive(Debug, Clone, PartialEq, Eq)]
21pub enum Outcome {
22 Done,
24 Refused(u16),
26 Elsewhere(String),
28 TimedOut,
30}
31
32pub fn one_click(url: &str) -> Result<Outcome, SyncError> {
35 let host = sioul_core::unsubscribe::host_of(url).filter(|_| url.get(..8).is_some_and(|s| s.eq_ignore_ascii_case("https://"))).ok_or_else(|| SyncError::Message(url.to_string()))?;
36 let agent: ureq::Agent = ureq::Agent::config_builder().https_only(true).max_redirects(0).http_status_as_error(false).timeout_global(Some(WAIT)).user_agent("Sioul").build().into();
38 post(&agent, url, &host)
39}
40
41fn post(agent: &ureq::Agent, url: &str, host: &str) -> Result<Outcome, SyncError> {
44 let mut url = url.to_string();
45 let mut posting = true;
46 let mut last = 0;
47 for _ in 0..=HOPS {
48 let answer = if posting {
49 agent.post(&url).header("Content-Type", "application/x-www-form-urlencoded").send(sioul_core::unsubscribe::ONE_CLICK)
50 } else {
51 agent.get(&url).call()
52 };
53 let response = match answer {
54 Ok(response) => response,
55 Err(ureq::Error::Timeout(_)) => return Ok(Outcome::TimedOut),
56 Err(ureq::Error::Tls(e)) => return Err(SyncError::Tls(e.to_string())),
57 Err(e) => return Err(SyncError::Network(e.to_string())),
58 };
59 last = response.status().as_u16();
60 if (200..300).contains(&last) {
61 return Ok(Outcome::Done);
62 }
63 if !matches!(last, 301 | 302 | 303 | 307 | 308) {
64 return Ok(Outcome::Refused(last));
65 }
66 let location = response.headers().get("location").and_then(|v| v.to_str().ok()).unwrap_or("").trim().to_string();
67 let next = resolve(&url, &location);
68 let same = next.as_deref().and_then(authority_host).is_some_and(|h| h == host) && next.as_deref().is_some_and(|n| scheme(n) == scheme(&url));
69 let Some(next) = next.filter(|_| same) else {
70 return Ok(Outcome::Elsewhere(resolve(&url, &location).as_deref().and_then(authority_host).unwrap_or(location)));
71 };
72 posting = posting && matches!(last, 307 | 308);
73 url = next;
74 }
75 Ok(Outcome::Refused(last))
76}
77
78fn scheme(url: &str) -> String {
80 url.split_once("://").map(|(s, _)| s.to_ascii_lowercase()).unwrap_or_default()
81}
82
83fn authority_host(url: &str) -> Option<String> {
85 let rest = url.split_once("://")?.1;
86 let authority = rest.split(['/', '?', '#']).next()?;
87 let authority = authority.rsplit_once('@').map_or(authority, |(_, host)| host);
88 let host = authority.rsplit_once(':').filter(|(_, port)| port.chars().all(|c| c.is_ascii_digit())).map_or(authority, |(host, _)| host);
89 (!host.is_empty()).then(|| host.trim_end_matches('.').to_ascii_lowercase())
90}
91
92fn resolve(base: &str, location: &str) -> Option<String> {
95 if location.is_empty() {
96 return None;
97 }
98 if location.contains("://") {
99 return Some(location.to_string());
100 }
101 let (scheme, rest) = base.split_once("://")?;
102 if let Some(other) = location.strip_prefix("//") {
103 return Some(format!("{scheme}://{other}"));
104 }
105 let authority = rest.split(['/', '?', '#']).next()?;
106 if location.starts_with('/') {
107 return Some(format!("{scheme}://{authority}{location}"));
108 }
109 let path = rest[authority.len()..].split(['?', '#']).next().unwrap_or("");
110 let folder = path.rsplit_once('/').map_or("", |(folder, _)| folder);
111 Some(format!("{scheme}://{authority}{folder}/{location}"))
112}
113
114#[cfg(test)]
115mod tests {
116 use super::*;
117 use std::io::{BufRead, BufReader, Read, Write};
118 use std::net::TcpListener;
119
120 #[derive(Debug, Default, Clone)]
122 struct Seen {
123 line: String,
124 headers: Vec<(String, String)>,
125 body: String,
126 }
127
128 fn stand_in(answers: Vec<String>) -> (String, std::sync::mpsc::Receiver<Seen>) {
130 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
131 let address = format!("http://{}", listener.local_addr().unwrap());
132 let (tell, heard) = std::sync::mpsc::channel();
133 std::thread::spawn(move || {
134 for answer in answers {
135 let Ok((stream, _)) = listener.accept() else { return };
136 let mut reader = BufReader::new(stream.try_clone().unwrap());
137 let mut seen = Seen::default();
138 reader.read_line(&mut seen.line).unwrap();
139 let mut length = 0;
140 loop {
141 let mut line = String::new();
142 reader.read_line(&mut line).unwrap();
143 let line = line.trim_end().to_string();
144 if line.is_empty() {
145 break;
146 }
147 if let Some((name, value)) = line.split_once(':') {
148 let (name, value) = (name.trim().to_ascii_lowercase(), value.trim().to_string());
149 if name == "content-length" {
150 length = value.parse().unwrap_or(0);
151 }
152 seen.headers.push((name, value));
153 }
154 }
155 let mut body = vec![0; length];
156 reader.read_exact(&mut body).unwrap();
157 seen.body = String::from_utf8_lossy(&body).into_owned();
158 let _ = tell.send(seen);
159 let mut stream = stream;
160 let _ = stream.write_all(answer.as_bytes());
161 }
162 });
163 (address, heard)
164 }
165
166 fn agent() -> ureq::Agent {
167 ureq::Agent::config_builder().max_redirects(0).http_status_as_error(false).timeout_global(Some(Duration::from_secs(5))).user_agent("Sioul").build().into()
168 }
169
170 const OK: &str = "HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok";
171
172 #[test]
173 fn posts_the_one_click_form_and_nothing_else() {
174 let (base, heard) = stand_in(vec![OK.to_string()]);
175 let outcome = post(&agent(), &format!("{base}/u/a1b2?l=112"), "127.0.0.1").unwrap();
176 assert_eq!(outcome, Outcome::Done);
177 let seen = heard.recv().unwrap();
178 assert_eq!(seen.line.trim_end(), "POST /u/a1b2?l=112 HTTP/1.1");
179 assert_eq!(seen.body, "List-Unsubscribe=One-Click");
180 let header = |name: &str| seen.headers.iter().find(|(n, _)| n == name).map(|(_, v)| v.as_str());
181 assert_eq!(header("content-type"), Some("application/x-www-form-urlencoded"));
182 assert_eq!((header("cookie"), header("authorization"), header("referer")), (None, None, None));
184 }
185
186 #[test]
187 fn a_redirect_to_another_site_is_not_followed() {
188 let elsewhere = "HTTP/1.1 302 Found\r\nLocation: https://tracker.example/landing\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".to_string();
189 let (base, heard) = stand_in(vec![elsewhere]);
190 assert_eq!(post(&agent(), &format!("{base}/u"), "127.0.0.1").unwrap(), Outcome::Elsewhere("tracker.example".into()));
191 assert_eq!(heard.recv().unwrap().body, "List-Unsubscribe=One-Click");
192 let again = "HTTP/1.1 307 Temporary Redirect\r\nLocation: /u/confirm\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".to_string();
194 let (base, heard) = stand_in(vec![again, OK.to_string()]);
195 assert_eq!(post(&agent(), &format!("{base}/u"), "127.0.0.1").unwrap(), Outcome::Done);
196 let (first, second) = (heard.recv().unwrap(), heard.recv().unwrap());
197 assert!(first.line.starts_with("POST /u ") && second.line.starts_with("POST /u/confirm "), "{first:?} {second:?}");
198 let gone = "HTTP/1.1 410 Gone\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".to_string();
200 let (base, _heard) = stand_in(vec![gone]);
201 assert_eq!(post(&agent(), &format!("{base}/u"), "127.0.0.1").unwrap(), Outcome::Refused(410));
202 }
203
204 #[test]
205 fn only_https_to_a_name_on_the_internet() {
206 for url in ["http://letters.example/u", "https://127.0.0.1/u", "https://router.lan/u", "https://letters.example@evil.example/u"] {
207 assert!(matches!(one_click(url), Err(SyncError::Message(_))), "{url}");
208 }
209 assert_eq!(resolve("https://a.example/u/x?y=1", "done"), Some("https://a.example/u/done".into()));
210 assert_eq!(resolve("https://a.example/u", "//b.example/z"), Some("https://b.example/z".into()));
211 assert_eq!(authority_host("https://user@A.Example:8443/x"), Some("a.example".into()));
212 }
213}