diff --git a/data/screenly_inject.js b/data/screenly_inject.js new file mode 100644 index 0000000..378a371 --- /dev/null +++ b/data/screenly_inject.js @@ -0,0 +1,77 @@ +// JavaScript injected into the remote entrypoint on every load. +// +// Edge App settings and secrets are available as `screenly_settings`. +// Use this to authenticate the remote page without baking credentials +// into the script. +// +// This script runs AFTER the page has fully loaded — DOMContentLoaded / +// window.load have already fired, so manipulate the DOM directly. Don't +// wrap your code in `document.addEventListener('DOMContentLoaded', ...)` +// (the listener will be registered too late and never fire). +// +// ---- Helpers -------------------------------------------------------------- + +// Set an input's value and notify listeners. +function setValue(selector, value) { + const el = document.querySelector(selector); + if (!el) return false; + el.value = value; + el.dispatchEvent(new Event('change', { bubbles: true })); + return true; +} + +// Set an input's value through the native setter so frameworks like React +// pick it up. Use this when `setValue` doesn't take effect. +function setReactValue(selector, value) { + const el = document.querySelector(selector); + if (!el) return false; + const setter = Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + 'value' + ).set; + setter.call(el, value); + el.dispatchEvent(new Event('input', { bubbles: true })); + return true; +} + +// Set a cookie scoped to `domain` and reload the page. No-ops if already set. +function setCookie(key, value, domain) { + if (document.cookie.split('; ').some(c => c.startsWith(key + '='))) return; + document.cookie = `${key}=${value}; path=/; domain=${domain}`; + location.reload(); +} + +// Run `fn` only when the current path matches `path`. +function onPath(path, fn) { + if (location.pathname === path) fn(); +} + +// ---- Examples (uncomment one) -------------------------------------------- + +// Form-fill login on /login: +// +// onPath('/login', () => { +// if (setValue('input[name="username"]', screenly_settings.username) && +// setValue('input[name="password"]', screenly_settings.password)) { +// document.querySelector('button[type="submit"]').click(); +// } else { +// setTimeout(arguments.callee, 1000); // retry until the form is rendered +// } +// }); + +// SSO via cookie: +// +// setCookie('session_id', screenly_settings.session_id, '.example.com'); + +// Override fetch to attach a Bearer token to every request the page makes: +// +// const token = screenly_settings.api_token; +// const originalFetch = window.fetch; +// window.fetch = (input, init = {}) => { +// init.headers = { ...(init.headers || {}), Authorization: `Bearer ${token}` }; +// return originalFetch(input, init); +// }; + +// ---- Default: log what's available --------------------------------------- + +console.log('screenly_settings:', screenly_settings); diff --git a/docs/CommandLineHelp.md b/docs/CommandLineHelp.md index 825ec84..797429b 100644 --- a/docs/CommandLineHelp.md +++ b/docs/CommandLineHelp.md @@ -474,6 +474,7 @@ Creates an Edge App in the store * `-n`, `--name ` — Edge App name * `-p`, `--path ` — Path to the directory with the manifest. Defaults to the current working directory * `-i`, `--in-place` — Use an existing Edge App directory with the manifest and index.html +* `-e`, `--entrypoint ` — Remote entrypoint URL. When set, the created app uses entrypoint.type = remote-global with this URL and a starter `screenly_inject.js` is dropped next to `screenly.yml`. The inject file is shipped with each deploy and the player runs it on every load diff --git a/docs/EdgeApps.md b/docs/EdgeApps.md index 237f805..f64d9aa 100644 --- a/docs/EdgeApps.md +++ b/docs/EdgeApps.md @@ -83,6 +83,55 @@ When you run the screenly edge-app create command, two files will be created in - `screenly.yml` - `index.html` +#### Remote entrypoint (`--entrypoint`) + +If your Edge App should load a remote URL instead of a bundled `index.html`, +pass the URL at create time: + +```shell +$ screenly edge-app create --name hello-remote --entrypoint https://example.com/app +``` + +This sets `entrypoint.type: remote-global` and `entrypoint.uri` in +`screenly.yml`, skips writing the `index.html` stub, and drops a starter +`screenly_inject.js` next to the manifest. + +The main use case for remote entrypoints is **JS injection** — a snippet +of JavaScript that the player executes against the loaded remote page on +every load. The most common reason to reach for this is **authenticating +to the remote page**: overriding `fetch`/`XHR` to attach `Authorization` +headers, setting cookies or `localStorage` tokens before the page boots, +auto-filling and submitting login forms, etc. + +To use it, edit `screenly_inject.js` and run `screenly edge-app deploy`. +The file is bundled as part of the Edge App's revision; the player picks +it up automatically on remote-entrypoint Edge Apps. If you don't want JS +injection, delete the file before deploying. + +To pass credentials securely into the injected script, define them as +Edge App **settings** or **secrets** (see [Settings](#settings) below) +and read them as `screenly_settings.` inside `screenly_inject.js`. +The player provides `screenly_settings` to the script at runtime, so +secret values don't appear in the page source or in the injection +script you commit to source control. + +Example `screenly_inject.js` that adds a Bearer token to every outbound +request from the remote page: + +```js +const token = screenly_settings.api_token; +const originalFetch = window.fetch; +window.fetch = (input, init = {}) => { + init.headers = { ...(init.headers || {}), Authorization: `Bearer ${token}` }; + return originalFetch(input, init); +}; +``` + +Only `remote-global` (one URL shared across all instances) can be configured +via `--entrypoint`. For `remote-local` (per-instance URLs), set +`entrypoint.type: remote-local` in `screenly.yml` and `entrypoint_uri` per +instance in `instance.yml`. + `screenly.yml` contains the metadata. In this file, you can define settings, secrets, and various other metadata. In our "Hello World" example, we have a single setting called `greeting`, which is used in the Edge App. `index.html` is our entry point. It is what the client (i.e., the player) will load. This particular file is very simple and just includes some styling and various metadata examples. diff --git a/src/cli.rs b/src/cli.rs index 1839fec..d3ba88e 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -355,6 +355,13 @@ pub enum EdgeAppCommands { /// Use an existing Edge App directory with the manifest and index.html. #[arg(short, long, action = clap::ArgAction::SetTrue)] in_place: Option, + /// Remote entrypoint URL. When set, the created app uses + /// entrypoint.type = remote-global with this URL and a starter + /// `screenly_inject.js` is dropped next to `screenly.yml`. The + /// inject file is shipped with each deploy and the player runs it + /// on every load. + #[arg(short, long)] + entrypoint: Option, }, /// Lists your Edge Apps. @@ -908,13 +915,8 @@ pub fn handle_cli_edge_app_command(command: &EdgeAppCommands) { name, path, in_place, + entrypoint, } => { - let create_func = if in_place.unwrap_or(false) { - commands::edge_app::EdgeAppCommand::create_in_place - } else { - commands::edge_app::EdgeAppCommand::create - }; - let manifest_path = match transform_edge_app_path_to_manifest(path) { Ok(path) => path, Err(e) => { @@ -923,7 +925,17 @@ pub fn handle_cli_edge_app_command(command: &EdgeAppCommands) { } }; - match create_func(&edge_app_command, name, manifest_path.as_path()) { + let result = if in_place.unwrap_or(false) { + if entrypoint.is_some() { + eprintln!("--entrypoint cannot be used with --in-place."); + std::process::exit(1); + } + edge_app_command.create_in_place(name, manifest_path.as_path()) + } else { + edge_app_command.create(name, manifest_path.as_path(), entrypoint.clone()) + }; + + match result { Ok(()) => { println!("Edge App successfully created."); } diff --git a/src/commands/edge_app/app.rs b/src/commands/edge_app/app.rs index baf96d0..d5ed98d 100644 --- a/src/commands/edge_app/app.rs +++ b/src/commands/edge_app/app.rs @@ -29,9 +29,16 @@ use crate::commands::edge_app::utils::{ use crate::commands::edge_app::EdgeAppCommand; use crate::commands::{CommandError, EdgeApps}; +pub const INJECT_JS_FILE_NAME: &str = "screenly_inject.js"; + // Edge apps commands impl EdgeAppCommand { - pub fn create(&self, name: &str, path: &Path) -> Result<(), CommandError> { + pub fn create( + &self, + name: &str, + path: &Path, + entrypoint: Option, + ) -> Result<(), CommandError> { let parent_dir_path = path.parent().ok_or(CommandError::FileSystemError( "Cannot obtain Edge App root directory.".to_owned(), ))?; @@ -44,15 +51,33 @@ impl EdgeAppCommand { ))); } + let entrypoint_value = match entrypoint { + Some(url) => { + match reqwest::Url::parse(&url) { + Ok(parsed) if parsed.scheme() == "http" || parsed.scheme() == "https" => {} + _ => { + return Err(CommandError::InitializationError(format!( + "Invalid --entrypoint URL: {url}. Must be a valid http or https URL." + ))); + } + } + Some(Entrypoint { + entrypoint_type: EntrypointType::RemoteGlobal, + uri: Some(url), + }) + } + None => Some(Entrypoint { + entrypoint_type: EntrypointType::File, + uri: None, + }), + }; + let app_id = self.api.create_app(name.to_string())?; let manifest = EdgeAppManifest { syntax: MANIFEST_VERSION.to_owned(), id: Some(app_id), - entrypoint: Some(Entrypoint { - entrypoint_type: EntrypointType::File, - uri: None, - }), + entrypoint: entrypoint_value, settings: vec![ Setting { name: "secret_word".to_string(), @@ -80,14 +105,70 @@ impl EdgeAppCommand { EdgeAppManifest::save_to_file(&manifest, path)?; - let index_html_template = - include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/data/index.html")); - let index_html_file = File::create(&index_html_path)?; - write!(&index_html_file, "{index_html_template}")?; + let is_remote = matches!( + manifest.entrypoint, + Some(Entrypoint { + entrypoint_type: EntrypointType::RemoteGlobal, + .. + }) + ); + + if !is_remote { + let index_html_template = + include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/data/index.html")); + let index_html_file = File::create(&index_html_path)?; + write!(&index_html_file, "{index_html_template}")?; + } + + if is_remote { + let inject_js_path = parent_dir_path.join(INJECT_JS_FILE_NAME); + if !inject_js_path.exists() { + let template = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/data/screenly_inject.js" + )); + fs::write(&inject_js_path, template)?; + } + } Ok(()) } +} + +/// RAII guard that materializes an empty `index.html` in the edge app directory +/// for the duration of a deploy of a remote-entrypoint app. The backend rejects +/// publishing a version with no asset signatures, so we ship a placeholder. +struct VirtualIndexHtml { + path: PathBuf, + created: bool, +} + +impl VirtualIndexHtml { + /// Minimal HTML so the asset has non-zero size (the backend rejects empty + /// asset bodies with a check-constraint violation). The contents are never + /// rendered — the player loads the remote entrypoint URL instead. + const PLACEHOLDER: &'static str = + "\n"; + + fn ensure(parent_dir: &Path) -> Result { + let path = parent_dir.join("index.html"); + let created = !path.exists(); + if created { + fs::write(&path, Self::PLACEHOLDER)?; + } + Ok(Self { path, created }) + } +} + +impl Drop for VirtualIndexHtml { + fn drop(&mut self) { + if self.created { + let _ = fs::remove_file(&self.path); + } + } +} +impl EdgeAppCommand { pub fn create_in_place(&self, name: &str, path: &Path) -> Result<(), CommandError> { let parent_dir_path = path.parent().ok_or(CommandError::FileSystemError( "Cannot obtain Edge App root directory.".to_owned(), @@ -141,13 +222,31 @@ impl EdgeAppCommand { let edge_app_dir = manifest_path.parent().ok_or(CommandError::MissingField)?; + // Remote-entrypoint apps don't need a real index.html, but the backend + // requires every published version to have at least one signed asset. + // Materialize an empty placeholder for the duration of this deploy. + let is_remote_entrypoint = matches!( + manifest.entrypoint, + Some(Entrypoint { + entrypoint_type: EntrypointType::RemoteGlobal, + .. + }) | Some(Entrypoint { + entrypoint_type: EntrypointType::RemoteLocal, + .. + }) + ); + let _virtual_index_html = if is_remote_entrypoint { + Some(VirtualIndexHtml::ensure(edge_app_dir)?) + } else { + None + }; + let local_files = collect_paths_for_upload(edge_app_dir)?; ensure_edge_app_has_all_necessary_files(&local_files)?; - let revision = match self.api.get_latest_revision(&actual_app_id)? { - Some(revision) => revision.revision, - None => 0, - }; + let prior_revision = self.api.get_latest_revision(&actual_app_id)?; + let is_first_deploy = prior_revision.is_none(); + let revision = prior_revision.map(|r| r.revision).unwrap_or(0); let remote_files = self .api @@ -166,7 +265,7 @@ impl EdgeAppCommand { changed_settings, )?; - self.update_entrypoint_value(path)?; + self.update_entrypoint_value(path.clone())?; let file_tree = generate_file_tree(&local_files, edge_app_dir); @@ -178,28 +277,31 @@ impl EdgeAppCommand { }; debug!("File tree changed: {file_tree_changed}"); - if !self.requires_upload(&changed_files) && !file_tree_changed && !version_metadata_changed - { - return Err(CommandError::NoChangesToUpload( - "No changes detected".to_owned(), - )); - } - - // now that we know we have changes, we can create a new version - let revision = - self.create_version(&manifest, generate_file_tree(&local_files, edge_app_dir))?; - - self.upload_changed_files(edge_app_dir, &actual_app_id, revision, &changed_files)?; - debug!("Files uploaded"); - - self.ensure_assets_processing_finished(&actual_app_id, revision)?; - // now we freeze it by publishing it - self.api.publish_version(&actual_app_id, revision)?; - debug!("Edge App published."); - - self.promote_version(&actual_app_id, revision, "stable")?; + let needs_new_version = is_first_deploy + || self.requires_upload(&changed_files) + || file_tree_changed + || version_metadata_changed; + + let final_revision = if needs_new_version { + let revision = + self.create_version(&manifest, generate_file_tree(&local_files, edge_app_dir))?; + + self.upload_changed_files(edge_app_dir, &actual_app_id, revision, &changed_files)?; + debug!("Files uploaded"); + + self.ensure_assets_processing_finished(&actual_app_id, revision)?; + // now we freeze it by publishing it + self.api.publish_version(&actual_app_id, revision)?; + debug!("Edge App published."); + + self.promote_version(&actual_app_id, revision, "stable")?; + revision + } else { + debug!("No version-creating changes; skipping version creation."); + revision + }; - Ok(revision) + Ok(final_revision) } fn promote_version( @@ -639,12 +741,14 @@ mod tests { let result = command.create( "Best app ever", tmp_dir.path().join("screenly.yml").as_path(), + None, ); post_mock.assert(); assert!(tmp_dir.path().join("screenly.yml").exists()); assert!(tmp_dir.path().join("index.html").exists()); + assert!(!tmp_dir.path().join(".ignore").exists()); let data = fs::read_to_string(tmp_dir.path().join("screenly.yml")).unwrap(); let manifest: EdgeAppManifest = serde_yaml::from_str(&data).unwrap(); @@ -699,6 +803,7 @@ mod tests { let result = command.create( "Best app ever", tmp_dir.path().join("screenly.yml").as_path(), + None, ); assert!(result.is_err()); @@ -714,6 +819,7 @@ mod tests { let result = command.create( "Best app ever", tmp_dir.path().join("screenly.yml").as_path(), + None, ); assert!(result.is_err()); @@ -826,6 +932,68 @@ mod tests { ); } + #[test] + fn test_edge_app_create_with_remote_entrypoint_should_set_remote_global_and_write_inject_template( + ) { + let (tmp_dir, command, mock_server, _manifest, _instance_manifest) = + prepare_edge_apps_test(false, false); + + let post_mock = mock_server.mock(|when, then| { + when.method(POST) + .path("/v4/edge-apps") + .header("Authorization", "Token token") + .json_body(json!({ "name": "Remote app" })); + then.status(201) + .json_body(json!([{"id": "test-id", "name": "Remote app"}])); + }); + + let result = command.create( + "Remote app", + tmp_dir.path().join("screenly.yml").as_path(), + Some("https://example.com/app".to_string()), + ); + + post_mock.assert(); + assert!(result.is_ok()); + + let data = fs::read_to_string(tmp_dir.path().join("screenly.yml")).unwrap(); + let manifest: EdgeAppManifest = serde_yaml::from_str(&data).unwrap(); + assert_eq!( + manifest.entrypoint, + Some(Entrypoint { + entrypoint_type: EntrypointType::RemoteGlobal, + uri: Some("https://example.com/app".to_string()), + }) + ); + + assert!(!tmp_dir.path().join("index.html").exists()); + assert!(!tmp_dir.path().join(".ignore").exists()); + + let inject_path = tmp_dir.path().join(INJECT_JS_FILE_NAME); + assert!(inject_path.exists()); + let inject_content = fs::read_to_string(&inject_path).unwrap(); + assert!(inject_content.contains("screenly_settings")); + } + + #[test] + fn test_edge_app_create_with_invalid_entrypoint_url_should_fail() { + let (tmp_dir, command, _mock_server, _manifest, _instance_manifest) = + prepare_edge_apps_test(false, false); + + let result = command.create( + "Bad app", + tmp_dir.path().join("screenly.yml").as_path(), + Some("not-a-url".to_string()), + ); + + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("Invalid --entrypoint URL")); + assert!(!tmp_dir.path().join("screenly.yml").exists()); + } + #[test] fn test_list_edge_apps_should_send_correct_request() { let (_tmp_dir, command, mock_server, _manifest, _instance_manifest) = diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 33086c9..db4f605 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -137,16 +137,18 @@ pub fn get( endpoint: &str, ) -> Result { let url = format!("{}/{}", &authentication.config.url, endpoint); + debug!("GET {url}"); let mut headers = HeaderMap::new(); headers.insert("Prefer", "return=representation".parse()?); let response = authentication .build_client()? - .get(url) + .get(&url) .headers(headers) .send()?; let status = response.status(); + debug!("GET {url} -> {status}"); if status != StatusCode::OK { println!("Response: {:?}", &response.text());