diff --git a/apps/lightcraft-web/src/automation.rs b/apps/lightcraft-web/src/automation.rs new file mode 100644 index 000000000..ff11e0ee9 --- /dev/null +++ b/apps/lightcraft-web/src/automation.rs @@ -0,0 +1,48 @@ +//! The page's automation entry point, `lightcraft.command(id, params)` (see `web.rs`): what the +//! params text means. Plain Rust, so it is tested natively. + +use serde_json::Value; + +/// The command parameters in `text`: a JSON object, or `{}` when the text is empty or blank (or +/// `null`). Anything else is an error, never `{}`: a command run without its params acts on the +/// selection or with defaults, so a typo such as `{ids: [3]}` must not delete or reset the +/// selected photos. The control channel and MCP refuse such params the same way. +pub fn params(text: &str) -> Result { + let text = text.trim(); + if text.is_empty() { + return Ok(Value::Object(Default::default())); + } + match serde_json::from_str::(text) { + Ok(Value::Null) => Ok(Value::Object(Default::default())), + Ok(v @ Value::Object(_)) => Ok(v), + Ok(_) => Err("params must be a JSON object, e.g. {\"ids\": [3]}".into()), + Err(e) => Err(format!("params are not valid JSON ({e}); nothing was run")), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn blank_or_null_params_are_an_empty_object() { + for text in ["", " ", "\n", "null", " null "] { + assert_eq!(params(text), Ok(json!({})), "{text:?}"); + } + } + + #[test] + fn an_object_is_passed_through() { + assert_eq!(params(r#"{"ids": [3], "rating": 5}"#), Ok(json!({"ids": [3], "rating": 5}))); + } + + /// Before, any of these ran the command with `{}`: `photo.delete` then removed the selection. + #[test] + fn invalid_json_or_a_non_object_is_refused() { + for text in ["{ids: [3]}", "{\"ids\": [3]", "ids=3", "[3]", "3", "\"{}\"", "true"] { + assert!(params(text).is_err(), "{text:?} must not run the command"); + } + assert!(params("{ids: [3]}").unwrap_err().contains("nothing was run")); + } +} diff --git a/apps/lightcraft-web/src/lib.rs b/apps/lightcraft-web/src/lib.rs index 58966e6e8..f984cb34c 100644 --- a/apps/lightcraft-web/src/lib.rs +++ b/apps/lightcraft-web/src/lib.rs @@ -16,6 +16,7 @@ #![forbid(unsafe_code)] #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +pub mod automation; pub mod backup; pub mod bench; pub mod files; diff --git a/apps/lightcraft-web/src/web.rs b/apps/lightcraft-web/src/web.rs index 2ca6d7105..f0da66256 100644 --- a/apps/lightcraft-web/src/web.rs +++ b/apps/lightcraft-web/src/web.rs @@ -643,8 +643,11 @@ impl WebApp { fn run_inbox(&mut self) { let inbox = INBOX.with(|q| std::mem::take(&mut *q.borrow_mut())); for (id, params, (resolve, reject)) in inbox { - let params: serde_json::Value = serde_json::from_str(¶ms).unwrap_or(json!({})); - let r = if id == "web.stats" { Ok(self.web_stats()) } else { self.app.run(&id, params) }; + let r = match crate::automation::params(¶ms) { + Err(e) => Err(format!("{id}: {e}")), + Ok(_) if id == "web.stats" => Ok(self.web_stats()), + Ok(params) => self.app.run(&id, params), + }; let _ = match r { Ok(v) => resolve.call1(&JsValue::NULL, &v.to_string().into()), Err(e) => reject.call1(&JsValue::NULL, &e.into()), diff --git a/docs/web.md b/docs/web.md index 98d0dd91c..94413a3db 100644 --- a/docs/web.md +++ b/docs/web.md @@ -158,7 +158,10 @@ are the same in every version, so serve them with `Cache-Control: no-cache` (not `write` service hands each file to the browser as a download instead of writing it to disk. - **Automation.** `await lightcraft.command("library.info", "{}")` runs any engine or UI command by id on the next frame and resolves to the JSON result (`web.stats` reports storage, workers - and the render queue). This is how the headless-Chrome checks drive the page. + and the render queue). The params are a JSON object; empty text and JSON `null` mean `{}` + (no params: a command that takes photo ids then acts on the selection). Any other text that + isn't an object rejects the promise and runs nothing, as the control channel and MCP do, so a + typo can't run a command on the selection. This is how the headless-Chrome checks drive the page. ## Not yet