Skip to main content

geop/
open.rs

1//! `geop open [DIR]`: the web editor in a browser, its kernel this process
2//! and its workspace the folder `DIR`.
3//!
4//! The page is the web app as `vite build --mode open` builds it
5//! (`web/src/backend.local.ts`), embedded in the executable with the
6//! feature `frontend`. It talks to this process over HTTP on 127.0.0.1:
7//!
8//! - `GET /ws?token=…`: a WebSocket to the editor's [`Session`]. Each text
9//!   message is one command of the JSON protocol `geop serve` speaks on its
10//!   stdin, and is answered, in order, by one message: the update.
11//! - `GET /api/files`: the folder's program and data files, by their paths
12//!   relative to it, with the one to edit first, if one was named.
13//! - `PUT /api/file?path=…` writes a file, `DELETE` removes one.
14//! - `POST /api/raise` brings the window showing the page to the front:
15//!   only in `geop app`, where it is how a second `geop app` of the same
16//!   folder hands over to the first (see `app.rs`).
17//! - Anything else is a file of the page itself.
18//!
19//! What can reach 127.0.0.1 is more than the user: every page the browser
20//! has open can send requests to it, and a page of a name that resolves to
21//! 127.0.0.1 (DNS rebinding) can even read the answers. So every request
22//! must name this server as its `Host`, a request from a page must come
23//! from this server's own origin, and the WebSocket and the file API take
24//! a token that is made afresh each run and given only in the URL printed
25//! (see [`Access`]). The file API reads and writes only files under `DIR`,
26//! of the kinds the explorer shows, never through a link out of it (see
27//! [`Folder`]).
28//!
29//! The editor is one, for every WebSocket: a thread owns it and answers
30//! the commands the connections send it ([`SessionHandle`]). That is the
31//! session another front end would attach to — an MCP client driving what
32//! the page shows (`geop mcp --attach`, not yet written) would be another
33//! sender of commands to it.
34
35use crate::session::Session;
36use geop_cad_base::disk::{Folder, MOST_BYTES, extensions, workspace_of};
37use geop_core_math::geop_error::{GeopError, GeopResult};
38use std::{
39    io::Read,
40    path::{Path, PathBuf},
41    sync::mpsc,
42};
43
44#[derive(clap::Args)]
45pub struct OpenArgs {
46    /// The folder to edit the files of — or a program in it, to edit
47    /// first.
48    #[arg(default_value = ".")]
49    pub path: PathBuf,
50    /// The port to serve on, on 127.0.0.1; a free one, if not given.
51    #[arg(long)]
52    pub port: Option<u16>,
53    /// Print the editor's URL only, rather than opening it in the default
54    /// browser too.
55    #[arg(long)]
56    pub no_browser: bool,
57}
58
59/// The page, as `vite build --mode open` writes it (`web/dist-open`).
60#[cfg(feature = "frontend")]
61#[derive(rust_embed::RustEmbed)]
62#[folder = "../../web/dist-open"]
63struct Page;
64
65/// The file of the page at `path`, if the page was built in.
66fn page_file(path: &str) -> Option<std::borrow::Cow<'static, [u8]>> {
67    #[cfg(feature = "frontend")]
68    return Page::get(path).map(|file| file.data);
69    #[cfg(not(feature = "frontend"))]
70    {
71        let _ = path;
72        None
73    }
74}
75
76/// What the page's file `path` is, for its `Content-Type`.
77pub fn media_type(path: &str) -> &'static str {
78    let extension = path.rsplit_once('.').map(|(_, e)| e.to_ascii_lowercase());
79    match extension.as_deref() {
80        Some("html") => "text/html; charset=utf-8",
81        Some("js" | "mjs") => "text/javascript; charset=utf-8",
82        Some("css") => "text/css; charset=utf-8",
83        Some("json" | "map") => "application/json",
84        Some("svg") => "image/svg+xml",
85        Some("png") => "image/png",
86        Some("jpg" | "jpeg") => "image/jpeg",
87        Some("gif") => "image/gif",
88        Some("webp") => "image/webp",
89        Some("ico") => "image/x-icon",
90        Some("woff") => "font/woff",
91        Some("woff2") => "font/woff2",
92        Some("wasm") => "application/wasm",
93        Some("txt") => "text/plain; charset=utf-8",
94        _ => "application/octet-stream",
95    }
96}
97
98// ── who may ask ────────────────────────────────────────────────────────
99
100/// Who this server answers: requests naming it as their host, from its own
101/// pages, and — for the editor and the files — knowing the token.
102#[derive(Clone)]
103pub struct Access {
104    pub port: u16,
105    pub token: String,
106}
107
108/// How a request is let in.
109#[derive(Clone, Copy, Debug, PartialEq, Eq)]
110pub enum Route {
111    /// A file of the page: no secret in it, so no token.
112    Page,
113    /// The file API: the token in an `Authorization: Bearer` header.
114    Api,
115    /// The WebSocket: the token in the query (a browser cannot give a
116    /// WebSocket headers), and an `Origin`, which a browser always sends.
117    Socket,
118}
119
120/// What a request says of where it comes from.
121#[derive(Default)]
122pub struct Claims<'a> {
123    pub host: Option<&'a str>,
124    pub origin: Option<&'a str>,
125    /// The token it gives, from the header or the query its route takes.
126    pub token: Option<&'a str>,
127}
128
129impl Access {
130    /// A token no other page can guess: 256 random bits.
131    pub fn new(port: u16) -> GeopResult<Self> {
132        let mut bytes = [0u8; 32];
133        getrandom::fill(&mut bytes).map_err(|e| GeopError::new(format!("making a token: {e}")))?;
134        let token = bytes.iter().map(|b| format!("{b:02x}")).collect();
135        Ok(Self { port, token })
136    }
137
138    /// Where the editor is, the token in the fragment: never sent to the
139    /// server with the page's own files, nor to another site as a referrer.
140    pub fn url(&self) -> String {
141        format!("http://127.0.0.1:{}/#token={}", self.port, self.token)
142    }
143
144    /// The names a request may give this server by: `127.0.0.1` and
145    /// `localhost`, at its port. Any other is a page of another name that
146    /// resolved here.
147    fn is_this(&self, host: &str) -> bool {
148        let port = self.port.to_string();
149        host.rsplit_once(':')
150            .is_some_and(|(name, p)| p == port && (name == "127.0.0.1" || name == "localhost"))
151    }
152
153    /// Why `claims` may not take `route`, if they may not.
154    pub fn refusal(&self, route: Route, claims: &Claims) -> Option<&'static str> {
155        if !claims.host.is_some_and(|h| self.is_this(h)) {
156            return Some("this server answers to 127.0.0.1 and localhost only");
157        }
158        let origin_ok =
159            |origin: &str| (origin.strip_prefix("http://")).is_some_and(|host| self.is_this(host));
160        match (route, claims.origin) {
161            (Route::Socket, None) => return Some("a WebSocket must say its origin"),
162            (_, Some(origin)) if !origin_ok(origin) => {
163                return Some("requests from other sites are refused");
164            }
165            _ => {}
166        }
167        let token_ok = claims
168            .token
169            .is_some_and(|t| same(t.as_bytes(), self.token.as_bytes()));
170        (route != Route::Page && !token_ok).then_some("the token is missing or wrong")
171    }
172}
173
174/// Whether `a` and `b` are equal, in a time that does not tell how much of
175/// them is.
176fn same(a: &[u8], b: &[u8]) -> bool {
177    a.len() == b.len() && a.iter().zip(b).fold(0, |d, (x, y)| d | (x ^ y)) == 0
178}
179
180/// The value of `key` in the query `query` (`a=1&b=2`), percent-decoded.
181pub fn query_value(query: &str, key: &str) -> Option<String> {
182    query.split('&').find_map(|pair| {
183        let (k, v) = pair.split_once('=').unwrap_or((pair, ""));
184        (k == key).then(|| percent_decoded(v)).flatten()
185    })
186}
187
188/// `text` with every `%XX` and `+` decoded, if it is UTF-8 then.
189pub fn percent_decoded(text: &str) -> Option<String> {
190    let mut bytes = Vec::with_capacity(text.len());
191    let mut rest = text.bytes();
192    while let Some(b) = rest.next() {
193        match b {
194            b'%' => {
195                let hex = [rest.next()?, rest.next()?];
196                bytes.push(u8::from_str_radix(std::str::from_utf8(&hex).ok()?, 16).ok()?);
197            }
198            b'+' => bytes.push(b' '),
199            b => bytes.push(b),
200        }
201    }
202    String::from_utf8(bytes).ok()
203}
204
205// ── the editor ─────────────────────────────────────────────────────────
206
207/// A command to the session, and where its answer goes.
208type Call = (String, mpsc::Sender<String>);
209
210/// The editor's session, answering the commands sent to it in the order
211/// they come, from whichever sender. Cloned for every connection.
212#[derive(Clone)]
213pub struct SessionHandle(mpsc::Sender<Call>);
214
215impl SessionHandle {
216    /// A thread owning a session that keeps what files are made into under
217    /// `root`. It ends once every handle is dropped.
218    pub fn start(root: PathBuf) -> Self {
219        let (sender, calls) = mpsc::channel::<Call>();
220        std::thread::spawn(move || {
221            let mut session = Session::new(Some(root));
222            for (command, answer) in calls {
223                let answered = session.answer(&command);
224                // A kernel that panicked is replaced; the front end gives
225                // the new one the program again (`backend.local.ts`).
226                if answered.crashed {
227                    session.restart();
228                }
229                let _ = answer.send(answered.reply);
230            }
231        });
232        Self(sender)
233    }
234
235    /// The session's answer to `command`.
236    pub fn call(&self, command: String) -> String {
237        let (sender, answer) = mpsc::channel();
238        let gone = || serde_json::json!({ "fatal": "the editor stopped" }).to_string();
239        if self.0.send((command, sender)).is_err() {
240            return gone();
241        }
242        answer.recv().unwrap_or_else(|_| gone())
243    }
244}
245
246// ── serving ────────────────────────────────────────────────────────────
247
248/// What a `POST /api/raise` does: bring the window showing the page to
249/// the front (`geop app`, asked by another `geop app` of the same folder).
250pub type Raise = Box<dyn Fn() + Send + Sync>;
251
252/// What every request is answered with.
253struct Server {
254    access: Access,
255    folder: Folder,
256    /// The program to edit first, if one was named.
257    active: Option<String>,
258    session: SessionHandle,
259    raise: Option<Raise>,
260}
261
262type Response = tiny_http::Response<std::io::Cursor<Vec<u8>>>;
263
264fn header(name: &str, value: &str) -> tiny_http::Header {
265    tiny_http::Header::from_bytes(name.as_bytes(), value.as_bytes())
266        .expect("a header of ASCII text")
267}
268
269fn answer(status: u16, media_type: &str, body: Vec<u8>) -> Response {
270    tiny_http::Response::from_data(body)
271        .with_status_code(status)
272        .with_header(header("Content-Type", media_type))
273        .with_header(header("X-Content-Type-Options", "nosniff"))
274        .with_header(header("Referrer-Policy", "no-referrer"))
275        .with_header(header("X-Frame-Options", "DENY"))
276        .with_header(header("Content-Security-Policy", "frame-ancestors 'none'"))
277        .with_header(header("Cache-Control", "no-cache"))
278}
279
280fn text(status: u16, message: &str) -> Response {
281    answer(
282        status,
283        "text/plain; charset=utf-8",
284        message.as_bytes().to_vec(),
285    )
286}
287
288fn json(value: serde_json::Value) -> Response {
289    answer(200, "application/json", value.to_string().into_bytes())
290}
291
292impl Server {
293    fn handle(&self, mut request: tiny_http::Request) {
294        let get = |name: &'static str| {
295            (request.headers().iter())
296                .find(|h| h.field.equiv(name))
297                .map(|h| h.value.as_str().to_string())
298        };
299        let (host, origin) = (get("Host"), get("Origin"));
300        let authorization = get("Authorization");
301        let upgrade = get("Upgrade");
302        let key = get("Sec-WebSocket-Key");
303        let url = request.url().to_string();
304        let (path, query) = url.split_once('?').unwrap_or((&url, ""));
305        let route = match path {
306            "/ws" => Route::Socket,
307            p if p.starts_with("/api/") => Route::Api,
308            _ => Route::Page,
309        };
310        let query_token = query_value(query, "token");
311        let token = match route {
312            Route::Socket => query_token.as_deref(),
313            _ => authorization
314                .as_deref()
315                .and_then(|a| a.strip_prefix("Bearer ")),
316        };
317        let claims = Claims {
318            host: host.as_deref(),
319            origin: origin.as_deref(),
320            token,
321        };
322        if let Some(why) = self.access.refusal(route, &claims) {
323            let _ = request.respond(text(403, why));
324            return;
325        }
326        use tiny_http::Method::*;
327        let response = match (route, request.method(), path) {
328            (Route::Socket, Get, _) => {
329                let (Some("websocket"), Some(key)) = (upgrade.as_deref(), key) else {
330                    let _ = request.respond(text(400, "a WebSocket upgrade is expected"));
331                    return;
332                };
333                let accept = tungstenite::handshake::derive_accept_key(key.as_bytes());
334                let switching = tiny_http::Response::empty(101)
335                    .with_header(header("Upgrade", "websocket"))
336                    .with_header(header("Connection", "Upgrade"))
337                    .with_header(header("Sec-WebSocket-Accept", &accept));
338                let stream = request.upgrade("websocket", switching);
339                socket(stream, &self.session);
340                return;
341            }
342            (Route::Api, Get, "/api/files") => match self.folder.files() {
343                Ok(files) => json(serde_json::json!({
344                    "root": self.folder.root().display().to_string(),
345                    "files": files,
346                    "active": self.active,
347                })),
348                Err(e) => text(500, e.root_message()),
349            },
350            (Route::Api, method @ (Put | Delete), "/api/file") => {
351                let Some(file) = query_value(query, "path") else {
352                    let _ = request.respond(text(400, "which file? `?path=` is missing"));
353                    return;
354                };
355                let done = if *method == Put {
356                    let mut body = Vec::new();
357                    let read = (request.as_reader().take(MOST_BYTES + 1))
358                        .read_to_end(&mut body)
359                        .map_err(|e| GeopError::new(format!("reading {file}: {e}")));
360                    read.and_then(|_| self.folder.write(&file, &String::from_utf8_lossy(&body)))
361                } else {
362                    self.folder.remove(&file)
363                };
364                match done {
365                    Ok(()) => text(200, "done"),
366                    Err(e) => text(400, e.root_message()),
367                }
368            }
369            (Route::Api, Post, "/api/raise") if self.raise.is_some() => {
370                (self.raise.as_ref().expect("checked"))();
371                text(200, "raised")
372            }
373            (Route::Api, _, _) => text(404, "no such call"),
374            (Route::Page, Get | Head, _) => {
375                let file = path.trim_start_matches('/');
376                let file = if file.is_empty() { "index.html" } else { file };
377                match page_file(file) {
378                    Some(data) => answer(200, media_type(file), data.into_owned()),
379                    None => text(404, "not found"),
380                }
381            }
382            _ => text(405, "not allowed"),
383        };
384        let _ = request.respond(response);
385    }
386}
387
388/// Answer the commands of one WebSocket until it closes.
389fn socket(stream: Box<dyn tiny_http::ReadWrite + Send>, session: &SessionHandle) {
390    use tungstenite::{Message, protocol::Role, protocol::WebSocketConfig};
391    // A command sends the folder's files, a STEP file among them perhaps:
392    // as large as the folder may be.
393    let size = MOST_BYTES as usize + (1 << 20);
394    let config = WebSocketConfig::default()
395        .max_message_size(Some(size))
396        .max_frame_size(Some(size));
397    let mut socket = tungstenite::WebSocket::from_raw_socket(stream, Role::Server, Some(config));
398    loop {
399        match socket.read() {
400            Ok(Message::Text(command)) => {
401                let reply = session.call(command.to_string());
402                if socket.send(Message::text(reply)).is_err() {
403                    return;
404                }
405            }
406            Ok(Message::Close(_)) | Err(_) => return,
407            Ok(_) => {}
408        }
409    }
410}
411
412/// Open the default browser at `url`.
413pub fn open_browser(url: &str) -> std::io::Result<()> {
414    let mut command = if cfg!(target_os = "macos") {
415        std::process::Command::new("open")
416    } else if cfg!(windows) {
417        let mut start = std::process::Command::new("cmd");
418        start.args(["/C", "start", ""]);
419        start
420    } else {
421        std::process::Command::new("xdg-open")
422    };
423    command
424        .arg(url)
425        .stdout(std::process::Stdio::null())
426        .stderr(std::process::Stdio::null())
427        .spawn()
428        .map(drop)
429}
430
431/// Why this `geop` cannot serve the editor's page, if it cannot: it was
432/// built without it.
433pub fn page_missing() -> Option<GeopError> {
434    page_file("index.html").is_none().then(|| {
435        GeopError::new(
436            "this geop was built without the editor's page. Build it with the page in: \
437             `npm --prefix web run build:open`, then \
438             `cargo build --release -p geop-cad-cli --features frontend`",
439        )
440    })
441}
442
443/// The folder `path` names — or the one a program `path` is in — with
444/// the program to edit first, if one was named.
445pub fn folder_of(path: &Path) -> GeopResult<(Folder, Option<String>)> {
446    let (dir, active) = workspace_of(path)?;
447    let folder = Folder::new(&dir, extensions(&geop_cad_base::registry()?))?;
448    Ok((folder, active))
449}
450
451/// The editor of a folder, served on 127.0.0.1: bound, and answering
452/// once [`Serving::run`] is.
453pub struct Serving {
454    http: tiny_http::Server,
455    server: std::sync::Arc<Server>,
456}
457
458impl Serving {
459    /// Serve `folder` on `port`, a free one if `None`, `active` the
460    /// program to edit first; `raise` answers `POST /api/raise`, which
461    /// is no call without it.
462    pub fn start(
463        folder: Folder,
464        active: Option<String>,
465        port: Option<u16>,
466        raise: Option<Raise>,
467    ) -> GeopResult<Self> {
468        let http = tiny_http::Server::http(("127.0.0.1", port.unwrap_or(0)))
469            .map_err(|e| GeopError::new(format!("serving on 127.0.0.1: {e}")))?;
470        let port = (http.server_addr().to_ip())
471            .map(|a| a.port())
472            .ok_or_else(|| GeopError::new("serving on no port"))?;
473        let server = std::sync::Arc::new(Server {
474            access: Access::new(port)?,
475            session: SessionHandle::start(folder.root().to_path_buf()),
476            folder,
477            active,
478            raise,
479        });
480        Ok(Self { http, server })
481    }
482
483    /// Who may ask: the port, and the token the page is given.
484    pub fn access(&self) -> &Access {
485        &self.server.access
486    }
487
488    /// The folder edited, as its canonical path.
489    pub fn root(&self) -> &Path {
490        self.server.folder.root()
491    }
492
493    /// Answer every request, each on a thread of its own, for as long as
494    /// the process runs.
495    pub fn run(self) {
496        for request in self.http.incoming_requests() {
497            let server = self.server.clone();
498            std::thread::spawn(move || server.handle(request));
499        }
500    }
501}
502
503pub fn open(args: &OpenArgs) -> GeopResult<()> {
504    if let Some(missing) = page_missing() {
505        return Err(missing);
506    }
507    let (folder, active) = folder_of(&args.path)?;
508    let serving = Serving::start(folder, active, args.port, None)?;
509    let url = serving.access().url();
510    eprintln!("geop is editing {}", serving.root().display());
511    eprintln!("open the editor at {url}");
512    eprintln!("(Ctrl+C stops it)");
513    if !args.no_browser && open_browser(&url).is_err() {
514        eprintln!("could not open a browser: open the URL above yourself");
515    }
516    serving.run();
517    Ok(())
518}
519
520#[cfg(test)]
521mod tests {
522    use super::*;
523
524    fn scratch(test: &str) -> PathBuf {
525        let dir = std::env::temp_dir().join(format!("geop-open-{test}-{}", std::process::id()));
526        let _ = std::fs::remove_dir_all(&dir);
527        std::fs::create_dir_all(&dir).unwrap();
528        dir
529    }
530
531    fn access() -> Access {
532        Access {
533            port: 4321,
534            token: "t0k3n".into(),
535        }
536    }
537
538    #[test]
539    fn a_request_must_name_this_server_as_its_host() {
540        let a = access();
541        let page = |host| {
542            a.refusal(
543                Route::Page,
544                &Claims {
545                    host,
546                    ..Default::default()
547                },
548            )
549        };
550        assert_eq!(page(Some("127.0.0.1:4321")), None);
551        assert_eq!(page(Some("localhost:4321")), None);
552        assert!(page(Some("127.0.0.1:4322")).is_some());
553        assert!(page(Some("evil.example:4321")).is_some());
554        assert!(page(Some("127.0.0.1.evil.example:4321")).is_some());
555        assert!(page(Some("127.0.0.1")).is_some());
556        assert!(page(None).is_some());
557    }
558
559    #[test]
560    fn the_api_and_the_socket_take_the_token_and_this_origin_only() {
561        let a = access();
562        let host = Some("127.0.0.1:4321");
563        let ask = |route, origin, token| {
564            a.refusal(
565                route,
566                &Claims {
567                    host,
568                    origin,
569                    token,
570                },
571            )
572        };
573        let here = Some("http://127.0.0.1:4321");
574        assert_eq!(ask(Route::Api, None, Some("t0k3n")), None);
575        assert_eq!(ask(Route::Api, here, Some("t0k3n")), None);
576        assert_eq!(ask(Route::Socket, here, Some("t0k3n")), None);
577        assert_eq!(
578            ask(Route::Socket, Some("http://localhost:4321"), Some("t0k3n")),
579            None
580        );
581        assert!(ask(Route::Api, None, None).is_some());
582        assert!(ask(Route::Api, None, Some("t0k3")).is_some());
583        assert!(ask(Route::Api, None, Some("t0k3n!")).is_some());
584        assert!(ask(Route::Socket, None, Some("t0k3n")).is_some());
585        assert!(ask(Route::Socket, here, None).is_some());
586        assert!(ask(Route::Socket, Some("https://evil.example"), Some("t0k3n")).is_some());
587        assert!(ask(Route::Api, Some("http://evil.example:4321"), Some("t0k3n")).is_some());
588        assert!(ask(Route::Page, Some("null"), None).is_some());
589        assert_eq!(ask(Route::Page, here, None), None);
590    }
591
592    #[test]
593    fn every_token_is_new_and_in_the_fragment() {
594        let (a, b) = (Access::new(80).unwrap(), Access::new(80).unwrap());
595        assert_ne!(a.token, b.token);
596        assert_eq!(a.token.len(), 64);
597        assert_eq!(a.url(), format!("http://127.0.0.1:80/#token={}", a.token));
598    }
599
600    #[test]
601    fn queries_are_percent_decoded() {
602        assert_eq!(
603            query_value("token=abc&path=parts%2Fa%20b.geop", "path").as_deref(),
604            Some("parts/a b.geop")
605        );
606        assert_eq!(
607            query_value("path=a+b.geop", "path").as_deref(),
608            Some("a b.geop")
609        );
610        assert_eq!(query_value("token=abc", "path"), None);
611        assert_eq!(query_value("path=%2", "path"), None);
612        assert_eq!(query_value("path=%ff", "path"), None);
613        assert_eq!(percent_decoded("%2e%2E%2fx").as_deref(), Some("../x"));
614    }
615
616    #[test]
617    fn the_page_is_served_as_what_it_is() {
618        assert_eq!(media_type("index.html"), "text/html; charset=utf-8");
619        assert_eq!(
620            media_type("assets/index-abc.js"),
621            "text/javascript; charset=utf-8"
622        );
623        assert_eq!(media_type("favicon.SVG"), "image/svg+xml");
624        assert_eq!(media_type("LICENSE"), "application/octet-stream");
625    }
626
627    /// One editor answers every connection, in order, and a command it
628    /// cannot read is answered too.
629    #[test]
630    fn the_session_answers_each_command() {
631        let dir = scratch("session");
632        let session = SessionHandle::start(dir);
633        let other = session.clone();
634        let shown: serde_json::Value =
635            serde_json::from_str(&other.call(r#"{"command": "show"}"#.into())).unwrap();
636        assert!(shown["error"].is_null(), "{shown}");
637        let fatal: serde_json::Value = serde_json::from_str(&session.call("nope".into())).unwrap();
638        assert!(fatal["fatal"].is_string(), "{fatal}");
639    }
640}