Skip to main content

brep_app/
offsite.rs

1//! Features that call another site, on a CAD app hosted by a PLM.
2//!
3//! A PLM serves the wasm app under a Content Security Policy whose
4//! `connect-src` is its own origin plus the origins its administrator allows
5//! (`brep-plm serve --cad-connect-src`, BREP_plm's `cad` module). A request to
6//! any other origin is refused by the browser. `fetch` rejects with an opaque
7//! network error, the same one it gives when offline or on a CORS refusal, so
8//! the failure cannot say why. The app therefore asks BEFORE it fetches:
9//!
10//! * [`prefetch`] (at boot, wasm only) reads `/cad/config` when the page is
11//!   served from under `/cad/app/`. Its `connect_src` is the administrator's
12//!   list.
13//! * [`refusal`] answers, for a URL a feature is about to fetch, the sentence
14//!   to show instead: "STEP parts search is unavailable on this server:
15//!   `https://api.step.parts` is not allowed". [`explain`] maps a failed fetch
16//!   to the same sentence, for the one case where the policy arrived after the
17//!   request left.
18//!
19//! The native app has no such policy, and neither does the public site or any
20//! page not served by a PLM: there both functions let everything through.
21//! The matching follows CSP's source-expression rules for what
22//! `--cad-connect-src` accepts: a scheme, a host that may start with `*.`, and
23//! an optional port (`*` for any).
24
25/// What a hosted page may reach: its own origin and the listed ones.
26#[derive(Clone, Debug, PartialEq, Eq)]
27pub struct Policy {
28    /// The page's own origin, e.g. `https://plm.example.com`.
29    pub page_origin: String,
30    /// The administrator's `--cad-connect-src` origins.
31    pub allowed: Vec<String>,
32}
33
34/// A URL's scheme, lower-case host and effective port.
35#[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
73/// `host[:port]`, minding an IPv6 literal's colons.
74fn 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
86/// Whether the CSP source `source` (`scheme://host[:port]`, host maybe
87/// `*.suffix`, port maybe `*`) matches `url`. As in CSP, an `http` source also
88/// matches `https`, and `ws`/`wss` follow `http`/`https`.
89fn 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
113/// The sentence `feature` shows instead of fetching `url` under `policy`, or
114/// `None` when the fetch may go. A URL with no scheme is relative, so it is
115/// the page's own origin and always allowed.
116pub 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
128/// The `connect_src` list out of a `/cad/config` body. `None` when the body
129/// has no such list (a PLM from before it existed): then nothing is known,
130/// and nothing is refused.
131pub 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
137/// Start reading this page's policy (wasm, on a page under `/cad/app/`).
138/// Idempotent; the native app has nothing to read.
139pub fn prefetch() {
140    #[cfg(target_arch = "wasm32")]
141    web::prefetch();
142}
143
144/// The sentence to show instead of fetching `url` for `feature`, or `None`
145/// when the fetch may go — including whenever the policy is not known (yet).
146pub 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/// `crate::http::fetch_text` for a request that may leave before the policy
161/// is known — the `?loadModel=` link, fetched at boot. On a hosted page it
162/// waits for the policy, then answers the refusal sentence or fetches; on an
163/// unhosted page it fetches at once. Wasm only, like its one caller.
164#[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        crate::http::fetch_text_into(&ctx, url, tx);
179    });
180    rx
181}
182
183/// A failed fetch's message: the refusal sentence when the policy (which may
184/// have arrived after the request left) forbids `url`, else `error` as is.
185pub 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        /// `None`: not served by a PLM, or the PLM names no list.
198        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    /// Settle the policy and run whatever waited for it.
209    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    /// Run `f` with the policy once it is known: now, or when `/cad/config`
218    /// answers.
219    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 prefetch() {
232        let start = STATE.with(|s| matches!(*s.borrow(), State::Unasked));
233        if !start {
234            return;
235        }
236        let location = web_sys::window().map(|w| w.location());
237        let path = location.as_ref().and_then(|l| l.pathname().ok()).unwrap_or_default();
238        let origin = location.as_ref().and_then(|l| l.origin().ok()).unwrap_or_default();
239        if !path.starts_with("/cad/app/") {
240            settle(None);
241            return;
242        }
243        STATE.with(|s| *s.borrow_mut() = State::Asking);
244        ehttp::fetch(ehttp::Request::get("/cad/config"), move |result| {
245            let policy = result
246                .ok()
247                .filter(|r| r.ok)
248                .and_then(|r| r.text().and_then(parse_config))
249                .map(|allowed| Policy { page_origin: origin.clone(), allowed });
250            settle(policy);
251        });
252    }
253
254    pub fn policy() -> Option<Policy> {
255        prefetch();
256        STATE.with(|s| match &*s.borrow() {
257            State::Known(policy) => policy.clone(),
258            _ => None,
259        })
260    }
261}
262