1#[derive(Clone, Debug, PartialEq, Eq)]
27pub struct Policy {
28 pub page_origin: String,
30 pub allowed: Vec<String>,
32}
33
34#[derive(Clone, Debug, PartialEq, Eq)]
36struct Origin {
37 scheme: String,
38 host: String,
39 port: u16,
40}
41
42impl Origin {
43 fn parse(url: &str) -> Option<Origin> {
44 let (scheme, rest) = url.split_once("://")?;
45 let scheme = scheme.to_ascii_lowercase();
46 let authority = rest.split(['/', '?', '#']).next().unwrap_or("");
47 let authority = authority.rsplit('@').next().unwrap_or(authority);
48 let (host, port) = split_port(authority);
49 let port = match port {
50 Some(port) => port.parse().ok()?,
51 None => default_port(&scheme)?,
52 };
53 (!host.is_empty()).then(|| Origin { scheme, host: host.to_ascii_lowercase(), port })
54 }
55
56 fn display(&self) -> String {
57 let host = if self.host.contains(':') { format!("[{}]", self.host) } else { self.host.clone() };
58 match default_port(&self.scheme) {
59 Some(port) if port == self.port => format!("{}://{host}", self.scheme),
60 _ => format!("{}://{host}:{}", self.scheme, self.port),
61 }
62 }
63}
64
65fn default_port(scheme: &str) -> Option<u16> {
66 match scheme {
67 "https" | "wss" => Some(443),
68 "http" | "ws" => Some(80),
69 _ => None,
70 }
71}
72
73fn split_port(authority: &str) -> (&str, Option<&str>) {
75 if let Some(rest) = authority.strip_prefix('[') {
76 if let Some((host, after)) = rest.split_once(']') {
77 return (host, after.strip_prefix(':'));
78 }
79 }
80 match authority.rsplit_once(':') {
81 Some((host, port)) if !host.contains(':') => (host, Some(port)),
82 _ => (authority, None),
83 }
84}
85
86fn source_matches(source: &str, url: &Origin) -> bool {
90 let Some((scheme, rest)) = source.split_once("://") else { return false };
91 let scheme = scheme.to_ascii_lowercase();
92 let scheme_ok = scheme == url.scheme
93 || (scheme == "http" && matches!(url.scheme.as_str(), "https" | "ws" | "wss"))
94 || (scheme == "https" && url.scheme == "wss");
95 if !scheme_ok {
96 return false;
97 }
98 let authority = rest.split('/').next().unwrap_or("");
99 let (host, port) = split_port(authority);
100 let host = host.to_ascii_lowercase();
101 let host_ok = match host.strip_prefix("*.") {
102 Some(suffix) => url.host.ends_with(&format!(".{suffix}")),
103 None => host == url.host,
104 };
105 let port_ok = match port {
106 Some("*") => true,
107 Some(port) => port.parse::<u16>().ok() == Some(url.port),
108 None => Some(url.port) == default_port(&url.scheme) || Some(url.port) == default_port(&scheme),
109 };
110 host_ok && port_ok
111}
112
113pub fn refusal_for(policy: &Policy, feature: &str, url: &str) -> Option<String> {
117 if !url.contains("://") {
118 return None;
119 }
120 let Some(origin) = Origin::parse(url) else {
121 return Some(format!("{feature} is unavailable on this server: {url} is not a web address"));
122 };
123 let own = Origin::parse(&policy.page_origin).is_some_and(|page| page == origin);
124 let listed = policy.allowed.iter().any(|source| source_matches(source, &origin));
125 (!own && !listed).then(|| format!("{feature} is unavailable on this server: {} is not allowed", origin.display()))
126}
127
128pub fn parse_config(body: &str) -> Option<Vec<String>> {
132 let value: serde_json::Value = serde_json::from_str(body).ok()?;
133 let list = value.get("connect_src")?.as_array()?;
134 Some(list.iter().filter_map(|v| v.as_str().map(str::to_string)).collect())
135}
136
137pub fn prefetch() {
140 #[cfg(target_arch = "wasm32")]
141 web::prefetch();
142}
143
144pub fn refusal(feature: &str, url: &str) -> Option<String> {
147 #[cfg(target_arch = "wasm32")]
148 {
149 return web::policy().and_then(|policy| refusal_for(&policy, feature, url));
150 }
151 #[cfg(all(not(target_arch = "wasm32"), not(test)))]
152 {
153 let _ = (feature, url);
154 None
155 }
156}
157
158
159
160#[cfg(target_arch = "wasm32")]
165pub(crate) fn fetch_text(
166 ctx: &eframe::egui::Context,
167 feature: &'static str,
168 url: String,
169) -> std::sync::mpsc::Receiver<Result<String, String>> {
170 let (tx, rx) = std::sync::mpsc::channel();
171 let ctx = ctx.clone();
172 web::when_known(move |policy| {
173 if let Some(sentence) = policy.and_then(|p| refusal_for(&p, feature, &url)) {
174 let _ = tx.send(Err(sentence));
175 ctx.request_repaint();
176 return;
177 }
178 web::fetch_text_into(ctx, url, tx);
179 });
180 rx
181}
182
183pub fn explain(feature: &str, url: &str, error: String) -> String {
186 refusal(feature, url).unwrap_or(error)
187}
188
189#[cfg(target_arch = "wasm32")]
190mod web {
191 use super::{parse_config, Policy};
192 use std::cell::RefCell;
193
194 enum State {
195 Unasked,
196 Asking,
197 Known(Option<Policy>),
199 }
200
201 type Waiter = Box<dyn FnOnce(Option<Policy>)>;
202
203 thread_local! {
204 static STATE: RefCell<State> = const { RefCell::new(State::Unasked) };
205 static WAITERS: RefCell<Vec<Waiter>> = const { RefCell::new(Vec::new()) };
206 }
207
208 fn settle(policy: Option<Policy>) {
210 STATE.with(|s| *s.borrow_mut() = State::Known(policy.clone()));
211 let waiting = WAITERS.with(|w| std::mem::take(&mut *w.borrow_mut()));
212 for waiter in waiting {
213 waiter(policy.clone());
214 }
215 }
216
217 pub fn when_known(f: impl FnOnce(Option<Policy>) + 'static) {
220 prefetch();
221 let known = STATE.with(|s| match &*s.borrow() {
222 State::Known(policy) => Some(policy.clone()),
223 _ => None,
224 });
225 match known {
226 Some(policy) => f(policy),
227 None => WAITERS.with(|w| w.borrow_mut().push(Box::new(f))),
228 }
229 }
230
231 pub fn fetch_text_into(
233 ctx: eframe::egui::Context,
234 url: String,
235 tx: std::sync::mpsc::Sender<Result<String, String>>,
236 ) {
237 ehttp::fetch(ehttp::Request::get(url), move |result| {
238 let out = match result {
239 Ok(r) if r.ok => Ok(r.text().map(str::to_owned).unwrap_or_else(|| String::from_utf8_lossy(&r.bytes).into_owned())),
240 Ok(r) => Err(format!("HTTP {} {}", r.status, r.status_text)),
241 Err(error) => Err(error),
242 };
243 let _ = tx.send(out);
244 ctx.request_repaint();
245 });
246 }
247
248 pub fn prefetch() {
249 let start = STATE.with(|s| matches!(*s.borrow(), State::Unasked));
250 if !start {
251 return;
252 }
253 let location = web_sys::window().map(|w| w.location());
254 let path = location.as_ref().and_then(|l| l.pathname().ok()).unwrap_or_default();
255 let origin = location.as_ref().and_then(|l| l.origin().ok()).unwrap_or_default();
256 if !path.starts_with("/cad/app/") {
257 settle(None);
258 return;
259 }
260 STATE.with(|s| *s.borrow_mut() = State::Asking);
261 ehttp::fetch(ehttp::Request::get("/cad/config"), move |result| {
262 let policy = result
263 .ok()
264 .filter(|r| r.ok)
265 .and_then(|r| r.text().and_then(parse_config))
266 .map(|allowed| Policy { page_origin: origin.clone(), allowed });
267 settle(policy);
268 });
269 }
270
271 pub fn policy() -> Option<Policy> {
272 prefetch();
273 STATE.with(|s| match &*s.borrow() {
274 State::Known(policy) => policy.clone(),
275 _ => None,
276 })
277 }
278}
279