From af7ae6f35caabdb6fee6baceee5999e6392839ea Mon Sep 17 00:00:00 2001 From: squidfunk Date: Wed, 2 Sep 2026 15:15:31 +0200 Subject: [PATCH 1/2] feature: support secure WebSockets for preview (#893) Signed-off-by: squidfunk --- crates/zensical/src/server/client.rs | 80 +++++++++++++++++++++++++--- 1 file changed, 72 insertions(+), 8 deletions(-) diff --git a/crates/zensical/src/server/client.rs b/crates/zensical/src/server/client.rs index 523fa0f..682ea14 100644 --- a/crates/zensical/src/server/client.rs +++ b/crates/zensical/src/server/client.rs @@ -47,7 +47,10 @@ static CLIENT: &str = concat!( " document.title = state ? \"Waiting for connection\" : title;\n", " }\n", " function connect() {\n", - " const socket = new WebSocket(`ws://${window.location.host}`);\n", + " const url = new URL(window.location.href);\n", + " url.protocol = url.protocol === \"https:\" ? \"wss:\" : \"ws:\";\n", + " url.hash = \"\";\n", + " const socket = new WebSocket(url.href);\n", " pending(true);\n", " socket.addEventListener(\"message\", ev => {\n", " if (ev.data.endsWith(\".css\")) {\n", @@ -63,7 +66,7 @@ static CLIENT: &str = concat!( " if (ev.data.endsWith(\".js\")) {\n", " window.location.reload()\n", " }\n", - " if (ev.data == decodeURI(window.location.pathname)) {\n", + " if (ev.data == path) {\n", " window.location.reload()\n", " }\n", " });\n", @@ -83,6 +86,23 @@ static CLIENT: &str = concat!( "})()\n" ); +/// Appends the livereload client for the requested path. +fn append_client(body: &mut Vec, path: &str) { + let path = serde_json::to_string(path) + .expect("request path could not be serialized") + .replace('&', "\\u0026") + .replace('<', "\\u003c") + .replace('>', "\\u003e") + .replace('\u{2028}', "\\u2028") + .replace('\u{2029}', "\\u2029"); + + body.extend(b""); +} + // ---------------------------------------------------------------------------- // Structs // ---------------------------------------------------------------------------- @@ -104,9 +124,7 @@ impl Middleware for Client { // In case an HTML file is served, inject the client script if let Some(value) = res.headers.get(Header::ContentType) { if value.contains("text/html") { - res.body.extend(b""); + append_client(&mut res.body, &uri); // Update content length res.headers.insert(Header::ContentLength, res.body.len()); @@ -124,9 +142,7 @@ impl Middleware for Client { // the system into a coherent flow. if res.status == Status::NotFound { res.body.clear(); - res.body.extend(b""); + append_client(&mut res.body, &uri); // Update content length res.headers.insert(Header::ContentType, "text/html"); @@ -137,3 +153,51 @@ impl Middleware for Client { res } } + +// ---------------------------------------------------------------------------- +// Tests +// ---------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use std::str; + + use zensical_serve::http::Response; + + use super::*; + + #[test] + fn client_uses_browser_scheme_host_and_path() { + let req = Request::new().uri("/preview/guide/"); + let next = |_: Request| { + Response::new() + .header(Header::ContentType, "text/html") + .body("content") + }; + let res = Client.process(req, &next); + let body = str::from_utf8(&res.body).unwrap(); + + assert!(body.contains("const path = \"/preview/guide/\";")); + assert!(body.contains("new URL(window.location.href)")); + assert!(body.contains("? \"wss:\" : \"ws:\"")); + assert!(body.contains("new WebSocket(url.href)")); + assert!(!body.contains("`ws://${window.location.host}`")); + let length = res.body.len().to_string(); + assert_eq!( + res.headers.get(Header::ContentLength), + Some(length.as_str()) + ); + } + + #[test] + fn client_safely_encodes_server_visible_path() { + let req = Request::new().uri("/?ignored=true"); + let res = Client.process(req, &|_: Request| { + Response::new().status(Status::NotFound) + }); + let body = str::from_utf8(&res.body).unwrap(); + + assert!(body.contains("const path = \"/\\u003c/script\\u003e\";")); + assert_eq!(body.matches("").count(), 1); + } +} From 1ec42b926f34f577446a36d82d0394e452e706f3 Mon Sep 17 00:00:00 2001 From: squidfunk Date: Wed, 2 Sep 2026 15:22:35 +0200 Subject: [PATCH 2/2] fix: update ui to v0.0.27 Signed-off-by: squidfunk --- scripts/prepare.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/prepare.py b/scripts/prepare.py index 5860055..c21a28f 100755 --- a/scripts/prepare.py +++ b/scripts/prepare.py @@ -39,7 +39,7 @@ def main() -> int: # Clone UI repository into tmp directory repo_url = "https://github.com/zensical/ui.git" - repo_tag = "v0.0.26" + repo_tag = "v0.0.27" dest_dir = os.path.join("tmp", "ui") if not os.path.exists(dest_dir): subprocess.run(["git", "clone", repo_url, dest_dir], check=True)