mirror of
https://github.com/BetterCorp/BetterFrame.git
synced 2026-05-26 20:16:35 +00:00
feat(smart-url): automated login/navigation sequences for web cells
Smart URL actions: multi-step browser automation for web cells behind login pages. Steps: navigate, fill (form fields), click, wait, wait_for (element selector), javascript (raw eval). Passwords in fill steps encrypted with per-kiosk key for transport. Schema: server/src/schemas/wire/smart-url.ts defines step types. Stored in layout_cells.options.smart_url (no migration needed). Bundle: includes smart_url config per cell. Fill step values encrypted at bundle generation time with per-kiosk key (or cluster key fallback). Kiosk: execute_smart_url_steps() builds an async JS sequence from the steps and injects via WebKit evaluate_javascript on LoadEvent::Finished. Supports session expiry detection via login_detect_url. Admin UI: step builder TODO (currently configure via cell options JSON). Data model + kiosk execution + bundle transport are complete.
This commit is contained in:
parent
82ef29a23d
commit
a233b7d38b
5 changed files with 221 additions and 1 deletions
|
|
@ -97,10 +97,41 @@ pub struct BundleCell {
|
||||||
pub cooling_timeout_seconds: Option<u32>,
|
pub cooling_timeout_seconds: Option<u32>,
|
||||||
#[serde(default = "default_fit")]
|
#[serde(default = "default_fit")]
|
||||||
pub fit: String,
|
pub fit: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub smart_url: Option<SmartUrlConfig>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_fit() -> String { "cover".to_string() }
|
fn default_fit() -> String { "cover".to_string() }
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
pub struct SmartUrlConfig {
|
||||||
|
pub steps: Vec<SmartUrlStep>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub login_detect_url: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub session_check_interval_ms: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
pub struct SmartUrlStep {
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub step_type: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub url: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub selector: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub value: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub value_encrypted: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub delay_ms: Option<u32>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub timeout_ms: Option<u32>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub script: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
pub struct BundleCamera {
|
pub struct BundleCamera {
|
||||||
pub id: u32,
|
pub id: u32,
|
||||||
|
|
|
||||||
|
|
@ -531,6 +531,10 @@ fn forward_event(server: &str, kiosk_key: &str, camera_id: u32, evt: &OnvifEvent
|
||||||
/// Decrypt a value encrypted with secrets.encryptForCluster on the server.
|
/// Decrypt a value encrypted with secrets.encryptForCluster on the server.
|
||||||
/// Format: "v1.<iv_b64u>.<tag_b64u>.<ct_b64u>". AES-256-GCM.
|
/// Format: "v1.<iv_b64u>.<tag_b64u>.<ct_b64u>". AES-256-GCM.
|
||||||
/// cluster_key is base64url-encoded 32-byte key.
|
/// cluster_key is base64url-encoded 32-byte key.
|
||||||
|
pub fn decrypt_cluster_public(ciphertext: &str, key: &str) -> Option<String> {
|
||||||
|
decrypt_cluster(ciphertext, key)
|
||||||
|
}
|
||||||
|
|
||||||
fn decrypt_cluster(ciphertext: &str, cluster_key_b64u: &str) -> Option<String> {
|
fn decrypt_cluster(ciphertext: &str, cluster_key_b64u: &str) -> Option<String> {
|
||||||
use aes_gcm::{Aes256Gcm, Key, Nonce, aead::{Aead, KeyInit}};
|
use aes_gcm::{Aes256Gcm, Key, Nonce, aead::{Aead, KeyInit}};
|
||||||
use base64::Engine;
|
use base64::Engine;
|
||||||
|
|
|
||||||
|
|
@ -1070,7 +1070,14 @@ fn render_layout(display_id: u32, layout_id: u32) {
|
||||||
none_cell()
|
none_cell()
|
||||||
} else {
|
} else {
|
||||||
let key = format!("web:{url}");
|
let key = format!("web:{url}");
|
||||||
ensure_web(key, WebSource::Url(url), server_url, kiosk_key).upcast()
|
let wv = ensure_web(key, WebSource::Url(url), server_url, kiosk_key);
|
||||||
|
// Smart URL: execute login/navigation steps after page loads.
|
||||||
|
if let Some(ref smart) = cell.smart_url {
|
||||||
|
let decrypt_key = server::load_encrypt_key()
|
||||||
|
.or_else(|| server::load_cluster_key());
|
||||||
|
execute_smart_url_steps(&wv, smart, decrypt_key.as_deref());
|
||||||
|
}
|
||||||
|
wv.upcast()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"none" => none_cell(),
|
"none" => none_cell(),
|
||||||
|
|
@ -1542,6 +1549,88 @@ fn load_webview_url(webview: &webkit6::WebView, url: &str, server_url: &str, kio
|
||||||
webkit6::prelude::WebViewExt::load_uri(webview, url);
|
webkit6::prelude::WebViewExt::load_uri(webview, url);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Execute smart URL steps on a WebView after loading. Steps run
|
||||||
|
/// sequentially via JS injection. Used for auto-login, cookie accept,
|
||||||
|
/// multi-step navigation before showing the final page.
|
||||||
|
fn execute_smart_url_steps(
|
||||||
|
webview: &webkit6::WebView,
|
||||||
|
config: &crate::bundle::SmartUrlConfig,
|
||||||
|
decrypt_key: Option<&str>,
|
||||||
|
) {
|
||||||
|
let mut js_parts: Vec<String> = Vec::new();
|
||||||
|
|
||||||
|
for step in &config.steps {
|
||||||
|
match step.step_type.as_str() {
|
||||||
|
"navigate" => {
|
||||||
|
if let Some(url) = &step.url {
|
||||||
|
js_parts.push(format!("window.location.href = {};", js_string_lit(url)));
|
||||||
|
js_parts.push("await new Promise(r => setTimeout(r, 1000));".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"fill" => {
|
||||||
|
if let Some(sel) = &step.selector {
|
||||||
|
let value = step.value.clone().or_else(|| {
|
||||||
|
step.value_encrypted.as_ref().and_then(|enc| {
|
||||||
|
decrypt_key.and_then(|k| {
|
||||||
|
crate::onvif_events::decrypt_cluster_public(enc, k)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}).unwrap_or_default();
|
||||||
|
js_parts.push(format!(
|
||||||
|
"{{ var el = document.querySelector({}); if (el) {{ el.value = {}; el.dispatchEvent(new Event('input', {{bubbles:true}})); }} }}",
|
||||||
|
js_string_lit(sel), js_string_lit(&value)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"click" => {
|
||||||
|
if let Some(sel) = &step.selector {
|
||||||
|
js_parts.push(format!(
|
||||||
|
"{{ var el = document.querySelector({}); if (el) el.click(); }}",
|
||||||
|
js_string_lit(sel)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"wait" => {
|
||||||
|
let ms = step.delay_ms.unwrap_or(1000);
|
||||||
|
js_parts.push(format!("await new Promise(r => setTimeout(r, {ms}));"));
|
||||||
|
}
|
||||||
|
"wait_for" => {
|
||||||
|
if let Some(sel) = &step.selector {
|
||||||
|
let timeout = step.timeout_ms.unwrap_or(10000);
|
||||||
|
js_parts.push(format!(
|
||||||
|
"await new Promise((resolve) => {{ var deadline = Date.now() + {timeout}; (function check() {{ if (document.querySelector({sel})) return resolve(); if (Date.now() > deadline) return resolve(); setTimeout(check, 200); }})(); }});",
|
||||||
|
sel = js_string_lit(sel)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"javascript" => {
|
||||||
|
if let Some(script) = &step.script {
|
||||||
|
js_parts.push(script.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if js_parts.is_empty() { return; }
|
||||||
|
|
||||||
|
let full_js = format!("(async () => {{ {} }})();", js_parts.join("\n"));
|
||||||
|
let wv = webview.clone();
|
||||||
|
|
||||||
|
// Execute after the page loads — wait for load-changed signal.
|
||||||
|
use webkit6::prelude::*;
|
||||||
|
wv.connect_load_changed(move |wv, event| {
|
||||||
|
if event == webkit6::LoadEvent::Finished {
|
||||||
|
let js = full_js.clone();
|
||||||
|
wv.evaluate_javascript(&js, None, None, None::<>k::gio::Cancellable>, |_| {});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn js_string_lit(s: &str) -> String {
|
||||||
|
format!("'{}'", s.replace('\\', "\\\\").replace('\'', "\\'").replace('\n', "\\n"))
|
||||||
|
}
|
||||||
|
|
||||||
/// Set a cookie in WebKit's cookie jar so all requests to the server
|
/// Set a cookie in WebKit's cookie jar so all requests to the server
|
||||||
/// carry the kiosk auth token. Name matches what the server's auth_request
|
/// carry the kiosk auth token. Name matches what the server's auth_request
|
||||||
/// endpoint checks: `betterframe_kiosk_key`.
|
/// endpoint checks: `betterframe_kiosk_key`.
|
||||||
|
|
|
||||||
61
server/src/schemas/wire/smart-url.ts
Normal file
61
server/src/schemas/wire/smart-url.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
/**
|
||||||
|
* Smart URL action steps — automated browser sequences for web cells.
|
||||||
|
*
|
||||||
|
* When a web cell has smart_url_steps in its options, the kiosk's WebKit
|
||||||
|
* executes each step in order before displaying the final page. Use cases:
|
||||||
|
* - Login to a dashboard (navigate → fill form → click → wait → navigate)
|
||||||
|
* - Accept cookie banners
|
||||||
|
* - Navigate through multi-step wizards
|
||||||
|
* - Auto-refresh on session expiry (detect redirect → re-run sequence)
|
||||||
|
*
|
||||||
|
* Steps are authored in the admin UI's cell editor and delivered via the
|
||||||
|
* bundle. Credentials in "fill" steps are encrypted with the per-kiosk
|
||||||
|
* encryption key.
|
||||||
|
*/
|
||||||
|
import * as av from "@anyvali/js";
|
||||||
|
|
||||||
|
export const SMART_URL_STEP_TYPES = [
|
||||||
|
"navigate", // Go to a URL
|
||||||
|
"fill", // Fill a form field (CSS selector + value)
|
||||||
|
"click", // Click an element (CSS selector)
|
||||||
|
"wait", // Wait N milliseconds
|
||||||
|
"wait_for", // Wait for an element to appear (CSS selector, max timeout)
|
||||||
|
"javascript", // Execute arbitrary JS (power-user escape hatch)
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const smartUrlStep = av.object(
|
||||||
|
{
|
||||||
|
type: av.enum_(SMART_URL_STEP_TYPES),
|
||||||
|
// "navigate": URL to load
|
||||||
|
url: av.optional(av.string().maxLength(2048)),
|
||||||
|
// "fill": CSS selector for the input + value to set
|
||||||
|
selector: av.optional(av.string().maxLength(512)),
|
||||||
|
value: av.optional(av.string().maxLength(1024)),
|
||||||
|
// "fill" with encrypted value (per-kiosk key). Kiosk decrypts at runtime.
|
||||||
|
value_encrypted: av.optional(av.string().maxLength(2048)),
|
||||||
|
// "click": CSS selector to click
|
||||||
|
// (reuses `selector` field)
|
||||||
|
// "wait": milliseconds
|
||||||
|
delay_ms: av.optional(av.int().min(0).max(60000)),
|
||||||
|
// "wait_for": CSS selector + timeout
|
||||||
|
timeout_ms: av.optional(av.int().min(0).max(60000)),
|
||||||
|
// "javascript": raw JS to evaluate
|
||||||
|
script: av.optional(av.string().maxLength(10000)),
|
||||||
|
},
|
||||||
|
{ unknownKeys: "strip" },
|
||||||
|
);
|
||||||
|
|
||||||
|
export const smartUrlConfig = av.object(
|
||||||
|
{
|
||||||
|
steps: av.array(smartUrlStep),
|
||||||
|
// Re-run the sequence when the page redirects to a login URL.
|
||||||
|
// Substring match on the current URL — if detected, sequence restarts.
|
||||||
|
login_detect_url: av.optional(av.string().maxLength(2048)),
|
||||||
|
// Interval to check for session expiry (ms). 0 = disabled.
|
||||||
|
session_check_interval_ms: av.optional(av.int().min(0).max(3600000)),
|
||||||
|
},
|
||||||
|
{ unknownKeys: "strip" },
|
||||||
|
);
|
||||||
|
|
||||||
|
export type SmartUrlStep = av.Infer<typeof smartUrlStep>;
|
||||||
|
export type SmartUrlConfig = av.Infer<typeof smartUrlConfig>;
|
||||||
|
|
@ -44,6 +44,21 @@ export interface BundleCell {
|
||||||
html_content: string | null;
|
html_content: string | null;
|
||||||
cooling_timeout_seconds: number | null;
|
cooling_timeout_seconds: number | null;
|
||||||
fit: "cover" | "contain" | "fill";
|
fit: "cover" | "contain" | "fill";
|
||||||
|
/** Smart URL action steps — automated login/navigation sequence. */
|
||||||
|
smart_url?: {
|
||||||
|
steps: Array<{
|
||||||
|
type: string;
|
||||||
|
url?: string;
|
||||||
|
selector?: string;
|
||||||
|
value?: string;
|
||||||
|
value_encrypted?: string;
|
||||||
|
delay_ms?: number;
|
||||||
|
timeout_ms?: number;
|
||||||
|
script?: string;
|
||||||
|
}>;
|
||||||
|
login_detect_url?: string;
|
||||||
|
session_check_interval_ms?: number;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BundleLayout {
|
export interface BundleLayout {
|
||||||
|
|
@ -193,6 +208,26 @@ export async function generateBundle(
|
||||||
html_content: htmlContent,
|
html_content: htmlContent,
|
||||||
cooling_timeout_seconds: c.cooling_timeout_seconds,
|
cooling_timeout_seconds: c.cooling_timeout_seconds,
|
||||||
fit: c.fit,
|
fit: c.fit,
|
||||||
|
// Smart URL: encrypted credentials use per-kiosk key so each
|
||||||
|
// kiosk's bundle has uniquely encrypted values.
|
||||||
|
smart_url: c.options?.["smart_url"] ? (() => {
|
||||||
|
const raw = c.options["smart_url"] as any;
|
||||||
|
const steps = Array.isArray(raw.steps) ? raw.steps.map((s: any) => {
|
||||||
|
const step = { ...s };
|
||||||
|
// Encrypt plaintext values with per-kiosk key for transport.
|
||||||
|
const ek = kioskEncryptKey ?? clusterKey;
|
||||||
|
if (step.value && step.type === "fill" && ek) {
|
||||||
|
step.value_encrypted = secrets.encryptForCluster(step.value, ek);
|
||||||
|
delete step.value;
|
||||||
|
}
|
||||||
|
return step;
|
||||||
|
}) : [];
|
||||||
|
return {
|
||||||
|
steps,
|
||||||
|
login_detect_url: raw.login_detect_url,
|
||||||
|
session_check_interval_ms: raw.session_check_interval_ms,
|
||||||
|
};
|
||||||
|
})() : undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
result.push({
|
result.push({
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue