1use 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 #[arg(default_value = ".")]
49 pub path: PathBuf,
50 #[arg(long)]
52 pub port: Option<u16>,
53 #[arg(long)]
56 pub no_browser: bool,
57}
58
59#[cfg(feature = "frontend")]
61#[derive(rust_embed::RustEmbed)]
62#[folder = "../../web/dist-open"]
63struct Page;
64
65fn 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
76pub 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#[derive(Clone)]
103pub struct Access {
104 pub port: u16,
105 pub token: String,
106}
107
108#[derive(Clone, Copy, Debug, PartialEq, Eq)]
110pub enum Route {
111 Page,
113 Api,
115 Socket,
118}
119
120#[derive(Default)]
122pub struct Claims<'a> {
123 pub host: Option<&'a str>,
124 pub origin: Option<&'a str>,
125 pub token: Option<&'a str>,
127}
128
129impl Access {
130 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 pub fn url(&self) -> String {
141 format!("http://127.0.0.1:{}/#token={}", self.port, self.token)
142 }
143
144 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 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
174fn 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
180pub 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
188pub 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
205type Call = (String, mpsc::Sender<String>);
209
210#[derive(Clone)]
213pub struct SessionHandle(mpsc::Sender<Call>);
214
215impl SessionHandle {
216 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 if answered.crashed {
227 session.restart();
228 }
229 let _ = answer.send(answered.reply);
230 }
231 });
232 Self(sender)
233 }
234
235 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
246pub type Raise = Box<dyn Fn() + Send + Sync>;
251
252struct Server {
254 access: Access,
255 folder: Folder,
256 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
388fn socket(stream: Box<dyn tiny_http::ReadWrite + Send>, session: &SessionHandle) {
390 use tungstenite::{Message, protocol::Role, protocol::WebSocketConfig};
391 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
412pub 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
431pub 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
443pub 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
451pub struct Serving {
454 http: tiny_http::Server,
455 server: std::sync::Arc<Server>,
456}
457
458impl Serving {
459 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 pub fn access(&self) -> &Access {
485 &self.server.access
486 }
487
488 pub fn root(&self) -> &Path {
490 self.server.folder.root()
491 }
492
493 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 #[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}