diff --git a/Cargo.lock b/Cargo.lock index 4d9e11cc8..72c622440 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2310,6 +2310,7 @@ dependencies = [ "tempfile", "toml 0.8.23", "ulid", + "url", ] [[package]] diff --git a/apps/fabro-web/app/install-app.test.tsx b/apps/fabro-web/app/install-app.test.tsx index bff6a80c2..6d1d7d3c4 100644 --- a/apps/fabro-web/app/install-app.test.tsx +++ b/apps/fabro-web/app/install-app.test.tsx @@ -510,4 +510,75 @@ describe("InstallApp", () => { console.error = originalConsoleError; } }); + + test("shows the GitHub App callback URL on the review step", async () => { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + const originalConsoleError = console.error; + console.error = ((...args: unknown[]) => { + if ( + typeof args[0] === "string" && + args[0].startsWith("react-test-renderer is deprecated") + ) { + return; + } + originalConsoleError(...args); + }) as typeof console.error; + try { + const fetchMock = mock((input: RequestInfo | URL) => { + expect(String(input)).toBe("/install/session"); + return Promise.resolve( + new Response( + JSON.stringify({ + completed_steps: ["server", "object_store", "llm", "github"], + llm: { + providers: [{ provider: "anthropic" }], + }, + server: { canonical_url: "https://fabro.example.com" }, + object_store: { provider: "local" }, + github: { + strategy: "app", + owner: { kind: "personal" }, + app_name: "octocat-fabro", + slug: "octocat-fabro", + allowed_username: "octocat", + }, + prefill: INSTALL_PREFILL, + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + }, + ), + ); + }); + globalThis.fetch = fetchMock as typeof fetch; + + const testWindow = createTestWindow("https://fabro.example.com/install/review"); + testWindow.sessionStorage.setItem("fabro-install-token", "test-install-token"); + (globalThis as { window?: unknown }).window = testWindow; + + let renderer: TestRenderer.ReactTestRenderer | null = null; + await act(async () => { + renderer = TestRenderer.create( + + + } /> + + , + ); + }); + + await waitFor(() => { + const text = renderTreeText(renderer!.toJSON()); + expect(text).toContain("GitHub callback URL"); + expect(text).toContain("https://fabro.example.com/auth/callback/github"); + }); + + await act(async () => { + renderer?.unmount(); + }); + } finally { + console.error = originalConsoleError; + } + }); }); diff --git a/apps/fabro-web/app/install-app.tsx b/apps/fabro-web/app/install-app.tsx index c04b5988e..4053de577 100644 --- a/apps/fabro-web/app/install-app.tsx +++ b/apps/fabro-web/app/install-app.tsx @@ -1166,7 +1166,7 @@ function ReviewScreen({ /> {renderObjectStoreSummaryRows(session?.object_store)} - {renderGithubSummaryRows(session?.github)} + {renderGithubSummaryRows(session?.github, serverUrl)} {error ? : null}
@@ -1727,6 +1727,7 @@ function describeProvider(id: string): string { function renderGithubSummaryRows( github: InstallSessionResponse["github"], + serverUrl: string, ): ReactNode { if (!github) { return ; @@ -1741,6 +1742,11 @@ function renderGithubSummaryRows( value={github.allowed_username ? `@${github.allowed_username}` : "Not set"} mono={Boolean(github.allowed_username)} /> + ); } @@ -1756,6 +1762,10 @@ function renderGithubSummaryRows( ); } +function githubCallbackUrl(serverUrl: string): string { + return `${serverUrl.replace(/\/+$/, "")}/auth/callback/github`; +} + function renderObjectStoreSummaryRows( objectStore: InstallSessionResponse["object_store"], ): ReactNode { diff --git a/lib/crates/fabro-cli/src/commands/doctor.rs b/lib/crates/fabro-cli/src/commands/doctor.rs index 2ba2b813d..ee0c48e91 100644 --- a/lib/crates/fabro-cli/src/commands/doctor.rs +++ b/lib/crates/fabro-cli/src/commands/doctor.rs @@ -1,8 +1,9 @@ -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use anyhow::Result; use fabro_api::types as api_types; use fabro_config::user::active_settings_path; +use fabro_types::settings::replace_wildcard_host; pub(crate) use fabro_util::check_report::{ CheckDetail, CheckReport, CheckResult, CheckSection, CheckStatus, }; @@ -19,15 +20,30 @@ pub(crate) fn check_config(settings_path: Option) -> CheckResult { match settings_path { Some(path) => { let display = contract_tilde(&path); - CheckResult { - name: "Configuration".to_string(), - status: CheckStatus::Pass, - summary: display.display().to_string(), - details: vec![CheckDetail::new(format!( - "Loaded from {}", - display.display() - ))], - remediation: None, + let wildcard_urls = wildcard_public_url_details(&path); + let mut details = vec![CheckDetail::new(format!( + "Loaded from {}", + display.display() + ))]; + if wildcard_urls.is_empty() { + CheckResult { + name: "Configuration".to_string(), + status: CheckStatus::Pass, + summary: display.display().to_string(), + details, + remediation: None, + } + } else { + details.extend(wildcard_urls); + CheckResult { + name: "Configuration".to_string(), + status: CheckStatus::Warning, + summary: "wildcard public URL configured".to_string(), + details, + remediation: Some( + "Replace wildcard public URLs with loopback or proxy URLs, then update the GitHub App callback URL.".to_string(), + ), + } } } None => CheckResult { @@ -42,6 +58,94 @@ pub(crate) fn check_config(settings_path: Option) -> CheckResult { } } +struct WildcardPublicUrl { + field: &'static str, + value: String, + suggestion: String, +} + +#[expect( + clippy::disallowed_methods, + reason = "Doctor synchronously reads one small local settings file while assembling a CLI report." +)] +fn wildcard_public_url_details(path: &Path) -> Vec { + let Ok(contents) = std::fs::read_to_string(path) else { + return Vec::new(); + }; + let Ok(doc) = contents.parse::() else { + return Vec::new(); + }; + + let mut bad_urls = Vec::new(); + for (field, value) in [ + ( + "server.web.url", + toml_string_at(&doc, &["server", "web", "url"]), + ), + ( + "server.api.url", + toml_string_at(&doc, &["server", "api", "url"]), + ), + ( + "cli.target.url", + toml_string_at(&doc, &["cli", "target", "url"]), + ), + ] { + let Some(value) = value else { + continue; + }; + let Some(suggestion) = replace_wildcard_host(value, "127.0.0.1") else { + continue; + }; + bad_urls.push(WildcardPublicUrl { + field, + value: value.to_string(), + suggestion, + }); + } + + if bad_urls.is_empty() { + return Vec::new(); + } + + let mut details = bad_urls + .iter() + .map(|entry| CheckDetail { + text: format!( + "{} uses wildcard host {}; set it to {}", + entry.field, entry.value, entry.suggestion + ), + warn: true, + }) + .collect::>(); + + let callback_base = bad_urls + .iter() + .find(|entry| entry.field == "server.web.url") + .unwrap_or(&bad_urls[0]) + .suggestion + .as_str(); + let github_settings_url = toml_string_at(&doc, &["server", "integrations", "github", "slug"]) + .map_or_else( + || "the GitHub App settings page".to_string(), + |slug| format!("https://github.com/settings/apps/{slug}"), + ); + details.push(CheckDetail { + text: format!( + "Update the GitHub App Callback URL at {github_settings_url} -> General -> Callback URL to {callback_base}/auth/callback/github" + ), + warn: true, + }); + + details +} + +fn toml_string_at<'a>(doc: &'a toml::Value, path: &[&str]) -> Option<&'a str> { + path.iter() + .try_fold(doc, |value, key| value.get(*key)) + .and_then(toml::Value::as_str) +} + fn check_version_parity(server_version: &str) -> CheckResult { let cli_version = FABRO_VERSION; if server_version == cli_version { @@ -333,6 +437,51 @@ mod tests { assert!(result.remediation.is_some()); } + #[test] + #[expect( + clippy::disallowed_methods, + reason = "unit test stages a temporary settings.toml fixture with sync std::fs" + )] + fn check_config_warns_about_wildcard_public_urls() { + let dir = tempfile::tempdir().unwrap(); + let settings_path = dir.path().join("settings.toml"); + std::fs::write( + &settings_path, + r#" +_version = 1 + +[server.web] +url = "http://0.0.0.0:32276" + +[server.api] +url = "http://0.0.0.0:32276" + +[server.integrations.github] +slug = "octocat-fabro" + +[cli.target] +type = "http" +url = "http://0.0.0.0:32276" +"#, + ) + .unwrap(); + + let result = check_config(Some(settings_path)); + assert_eq!(result.status, CheckStatus::Warning); + let details = result + .details + .iter() + .map(|detail| detail.text.as_str()) + .collect::>() + .join("\n"); + + assert!(details.contains("server.web.url")); + assert!(details.contains("server.api.url")); + assert!(details.contains("cli.target.url")); + assert!(details.contains("http://127.0.0.1:32276/auth/callback/github")); + assert!(details.contains("https://github.com/settings/apps/octocat-fabro")); + } + #[test] fn check_version_parity_warns_on_mismatch() { let result = check_version_parity("0.0.0-test"); diff --git a/lib/crates/fabro-cli/src/commands/install.rs b/lib/crates/fabro-cli/src/commands/install.rs index 8e2d990a6..a5bba5c24 100644 --- a/lib/crates/fabro-cli/src/commands/install.rs +++ b/lib/crates/fabro-cli/src/commands/install.rs @@ -35,6 +35,7 @@ use fabro_server::serve; use fabro_store::ArtifactStore; use fabro_types::ServerSettings; use fabro_types::settings::server::ServerAuthMethod; +use fabro_types::settings::validate_public_url_with_label; use fabro_util::printer::Printer; use fabro_util::terminal::Styles; use fabro_util::version::FABRO_VERSION; @@ -122,6 +123,10 @@ fn merge_server_settings(doc: &mut toml::Value, web_url: &str) -> Result<()> { ) } +fn validate_web_url_arg(value: &str) -> Result { + validate_public_url_with_label(value, "--web-url").map_err(anyhow::Error::msg) +} + #[cfg(test)] fn format_config_toml() -> String { let mut doc = toml::Value::Table(toml::Table::default()); @@ -1453,6 +1458,7 @@ async fn run_install_github_inner( printer: Printer, ) -> Result<()> { let s = Styles::detect_stderr(); + let web_url = validate_web_url_arg(&args.web_url)?; let fabro_dir = fabro_util::Home::from_env().root().to_path_buf(); let config_path = fabro_dir.join(SETTINGS_CONFIG_FILENAME); if !config_path.exists() { @@ -1498,7 +1504,7 @@ async fn run_install_github_inner( ]); let registration = setup_github_app( &s, - &args.web_url, + &web_url, &owner, username.as_deref(), if args.non_interactive { @@ -1601,7 +1607,7 @@ async fn run_install_inner(args: &InstallArgs, ctx: &CommandContext) -> Result<( let _cli = &ctx.user_settings().cli; let printer = ctx.printer(); let json = ctx.json_output(); - let web_url = &args.web_url; + let web_url = validate_web_url_arg(&args.web_url)?; let s = Styles::detect_stderr(); let emoji = console::Emoji("⚒️ ", ""); let local_config = @@ -1686,7 +1692,7 @@ async fn run_install_inner(args: &InstallArgs, ctx: &CommandContext) -> Result<( )?; let registration = setup_github_app( &s, - web_url, + &web_url, &owner, username.as_deref(), if args.non_interactive { @@ -1740,7 +1746,7 @@ async fn run_install_inner(args: &InstallArgs, ctx: &CommandContext) -> Result<( ); } ServerConfigSelection::Write => { - merge_server_settings(&mut doc, web_url)?; + merge_server_settings(&mut doc, &web_url)?; } } @@ -1821,7 +1827,7 @@ async fn run_install_inner(args: &InstallArgs, ctx: &CommandContext) -> Result<( ) .await?; if let Some(token) = dev_token_for_auth_store { - let target = ServerTarget::http_url(&args.web_url)?; + let target = ServerTarget::http_url(&web_url)?; if let Err(err) = AuthStore::default().put( &target, AuthEntry::DevToken(DevTokenEntry { diff --git a/lib/crates/fabro-cli/src/commands/server/mod.rs b/lib/crates/fabro-cli/src/commands/server/mod.rs index 687010146..d844bcd0a 100644 --- a/lib/crates/fabro-cli/src/commands/server/mod.rs +++ b/lib/crates/fabro-cli/src/commands/server/mod.rs @@ -3,6 +3,7 @@ pub(crate) mod start; pub(crate) mod status; pub(crate) mod stop; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; use std::sync::Arc; use std::time::Duration; @@ -303,10 +304,24 @@ fn install_url_hint(bind: &Bind, token: &str) -> Option { return Some(format!("https://{domain}/install?token={token}")); } - match bind { - Bind::Tcp(addr) => Some(format!("http://{addr}/install?token={token}")), - Bind::Unix(_) => None, - } + bind_to_browser_url(bind).map(|url| format!("{url}/install?token={token}")) +} + +pub(super) fn bind_to_browser_url(bind: &Bind) -> Option { + let Bind::Tcp(addr) = bind else { + return None; + }; + + let browser_addr = match addr.ip() { + IpAddr::V4(ip) if ip.is_unspecified() => { + SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), addr.port()) + } + IpAddr::V6(ip) if ip.is_unspecified() => { + SocketAddr::new(IpAddr::V6(Ipv6Addr::LOCALHOST), addr.port()) + } + _ => *addr, + }; + Some(format!("http://{browser_addr}")) } fn default_install_bind_request() -> BindRequest { @@ -345,7 +360,9 @@ fn generate_install_token() -> Result { #[cfg(test)] mod tests { - use super::install_mode_next_step_message; + use fabro_config::bind::Bind; + + use super::{bind_to_browser_url, install_mode_next_step_message}; #[test] fn install_mode_next_step_message_recommends_manual_restart_locally() { @@ -362,4 +379,24 @@ mod tests { " After install, the server should restart automatically." ); } + + #[test] + fn bind_to_browser_url_uses_loopback_for_ipv4_wildcard_bind() { + let bind = Bind::Tcp("0.0.0.0:32276".parse().unwrap()); + + assert_eq!( + bind_to_browser_url(&bind).as_deref(), + Some("http://127.0.0.1:32276") + ); + } + + #[test] + fn bind_to_browser_url_uses_loopback_for_ipv6_wildcard_bind() { + let bind = Bind::Tcp("[::]:32276".parse().unwrap()); + + assert_eq!( + bind_to_browser_url(&bind).as_deref(), + Some("http://[::1]:32276") + ); + } } diff --git a/lib/crates/fabro-cli/src/commands/server/start.rs b/lib/crates/fabro-cli/src/commands/server/start.rs index 47d5cdff9..85cf31c2e 100644 --- a/lib/crates/fabro-cli/src/commands/server/start.rs +++ b/lib/crates/fabro-cli/src/commands/server/start.rs @@ -363,8 +363,7 @@ async fn execute_daemon( pid, daemon.bind ); - if let Bind::Tcp(addr) = &daemon.bind { - let url = format!("http://{addr}"); + if let Some(url) = super::bind_to_browser_url(&daemon.bind) { let styled = match styles { Some(s) => format!("{}", s.cyan.apply_to(&url)), None => url, diff --git a/lib/crates/fabro-cli/tests/it/cmd/install.rs b/lib/crates/fabro-cli/tests/it/cmd/install.rs index e61be1632..a6f473e86 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/install.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/install.rs @@ -143,6 +143,29 @@ fn non_interactive_without_inputs_prints_scripted_usage_and_fails() { assert!(stderr.contains("--github-strategy")); } +#[test] +fn install_rejects_wildcard_web_url_before_collecting_inputs() { + let context = test_context!(); + let output = context + .command() + .args([ + "install", + "--web-url", + "http://0.0.0.0:32276", + "--non-interactive", + ]) + .output() + .expect("command should run"); + + assert!(!output.status.success()); + let stderr = String::from_utf8(output.stderr).unwrap(); + assert!(stderr.contains("--web-url must not use a wildcard host")); + assert!( + !stderr.contains("Non-interactive install requires additional flags"), + "wildcard web URL should be rejected before scripted input validation: {stderr}" + ); +} + #[test] fn hidden_non_interactive_args_require_non_interactive() { let context = test_context!(); diff --git a/lib/crates/fabro-server/src/canonical_origin.rs b/lib/crates/fabro-server/src/canonical_origin.rs index 41e32d854..2a9ab1170 100644 --- a/lib/crates/fabro-server/src/canonical_origin.rs +++ b/lib/crates/fabro-server/src/canonical_origin.rs @@ -3,8 +3,7 @@ reason = "Canonical origin validation handles the public server origin; it is not credential-bearing log output." )] -use fabro_types::settings::ServerNamespace; -use url::Url; +use fabro_types::settings::{ServerNamespace, validate_public_url}; use crate::server::EnvLookup; @@ -19,12 +18,7 @@ pub(crate) fn resolve_canonical_origin( .map_err(|_| canonical_origin_error(&resolved.web.url.as_source()))? .value; - let parsed = Url::parse(&value).map_err(|_| canonical_origin_error(&value))?; - if !matches!(parsed.scheme(), "http" | "https") || parsed.host_str().is_none() { - return Err(canonical_origin_error(&value)); - } - - Ok(value) + validate_public_url(&value).map_err(|_| canonical_origin_error(&value)) } fn canonical_origin_error(value: &str) -> String { diff --git a/lib/crates/fabro-server/src/install.rs b/lib/crates/fabro-server/src/install.rs index bab069159..988e0c4da 100644 --- a/lib/crates/fabro-server/src/install.rs +++ b/lib/crates/fabro-server/src/install.rs @@ -27,6 +27,7 @@ use fabro_store::ArtifactStore; use fabro_types::ServerSettings; use fabro_types::settings::interp::InterpString; use fabro_types::settings::server::ObjectStoreSettings; +use fabro_types::settings::{is_wildcard_host, validate_public_url_with_label}; use fabro_util::version::FABRO_VERSION; use fabro_util::{Home, dev_token, session_secret}; use fabro_vault::SecretType as VaultSecretType; @@ -768,10 +769,11 @@ async fn put_install_server( .into_response(); } - if let Err(err) = validate_canonical_url(canonical_url) { - return install_error_response(StatusCode::UNPROCESSABLE_ENTITY, err); - } - input.canonical_url = canonical_url.to_string(); + let canonical_url = match validate_public_url_with_label(canonical_url, "canonical_url") { + Ok(value) => value, + Err(err) => return install_error_response(StatusCode::UNPROCESSABLE_ENTITY, err), + }; + input.canonical_url = canonical_url; lock_unpoisoned(&state.pending_install, "install session").server = Some(input); info!(step = "server", "install step completed"); @@ -1620,7 +1622,34 @@ fn detect_canonical_url(headers: &HeaderMap) -> String { .filter(|value| !value.is_empty()) .unwrap_or("127.0.0.1:32276"); - format!("{scheme}://{host}") + format!("{scheme}://{}", sanitize_client_facing_host(host)) +} + +fn sanitize_client_facing_host(host: &str) -> String { + let host = host.trim(); + if let Some(end) = host + .strip_prefix('[') + .and_then(|rest| rest.find(']').map(|end| end + 1)) + { + let address = &host[1..end]; + let suffix = &host[end + 1..]; + if is_wildcard_host(address) { + return format!("localhost{suffix}"); + } + return host.to_string(); + } + + if let Some((address, port)) = host.rsplit_once(':') { + if !address.contains(':') && is_wildcard_host(address) { + return format!("localhost:{port}"); + } + } + + if is_wildcard_host(host) { + return "localhost".to_string(); + } + + host.to_string() } fn completed_steps(pending_install: &PendingInstall) -> Vec<&'static str> { @@ -1692,35 +1721,6 @@ fn install_error_response(status: StatusCode, message: impl Into) -> Res ApiError::new(status, message).into_response() } -#[expect( - clippy::disallowed_types, - reason = "Install canonical_url validation parses a public origin and rejects query/fragment credentials before storage." -)] -fn validate_canonical_url(value: &str) -> Result<(), String> { - let trimmed = value.trim(); - let parsed = fabro_http::Url::parse(trimmed).map_err(|err| err.to_string())?; - match parsed.scheme() { - "http" | "https" => {} - other => return Err(format!("canonical_url must use http or https, got {other}")), - } - if parsed.host_str().is_none() { - return Err("canonical_url must include a host".to_string()); - } - if trimmed.ends_with('/') { - return Err("canonical_url must not end with a trailing slash".to_string()); - } - if parsed.path() != "/" { - return Err("canonical_url must not include a path".to_string()); - } - if parsed.query().is_some() { - return Err("canonical_url must not include a query string".to_string()); - } - if parsed.fragment().is_some() { - return Err("canonical_url must not include a fragment".to_string()); - } - Ok(()) -} - fn generate_ephemeral_secret() -> String { URL_SAFE_NO_PAD.encode(rand::random::<[u8; 32]>()) } diff --git a/lib/crates/fabro-server/src/server.rs b/lib/crates/fabro-server/src/server.rs index 42b72a700..08df66210 100644 --- a/lib/crates/fabro-server/src/server.rs +++ b/lib/crates/fabro-server/src/server.rs @@ -8244,7 +8244,12 @@ url = "{url}" #[test] fn replace_settings_rejects_invalid_canonical_origin_and_keeps_previous_settings() { - for invalid in ["", "/relative/path", "ftp://fabro.example.com"] { + for invalid in [ + "", + "/relative/path", + "ftp://fabro.example.com", + "http://0.0.0.0:32276", + ] { let state = create_app_state_with_env_lookup( canonical_origin_settings("http://valid.example.com"), RunLayer::default(), diff --git a/lib/crates/fabro-server/tests/it/api/install.rs b/lib/crates/fabro-server/tests/it/api/install.rs index f4c0ec8a1..e372594b7 100644 --- a/lib/crates/fabro-server/tests/it/api/install.rs +++ b/lib/crates/fabro-server/tests/it/api/install.rs @@ -262,6 +262,27 @@ async fn install_session_requires_valid_install_token() { ); } +#[tokio::test] +async fn install_session_sanitizes_wildcard_host_prefill() { + let app = build_install_router(InstallAppState::for_test("test-install-token")).await; + + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri("/install/session") + .header("authorization", "Bearer test-install-token") + .header("host", "0.0.0.0:32276") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + let body = response_json(response, StatusCode::OK, "GET /install/session").await; + assert_eq!(body["prefill"]["canonical_url"], "http://localhost:32276"); +} + #[tokio::test] async fn install_endpoints_reject_missing_and_wrong_tokens() { let app = build_install_router(InstallAppState::for_test("test-install-token")).await; @@ -1719,6 +1740,44 @@ async fn install_server_rejects_trailing_slash_canonical_urls() { ); } +#[tokio::test] +async fn install_server_rejects_wildcard_canonical_urls() { + let app = build_install_router(InstallAppState::for_test("test-install-token")).await; + + for canonical_url in [ + "http://0.0.0.0:32276", + "http://[::]:32276", + "http://0:32276", + ] { + let response = app + .clone() + .oneshot( + Request::builder() + .method("PUT") + .uri("/install/server") + .header("authorization", "Bearer test-install-token") + .header("content-type", "application/json") + .body(Body::from(format!( + r#"{{"canonical_url":"{canonical_url}"}}"# + ))) + .unwrap(), + ) + .await + .unwrap(); + + let body = response_json( + response, + StatusCode::UNPROCESSABLE_ENTITY, + "PUT /install/server", + ) + .await; + assert_eq!( + body["errors"][0]["detail"], + "canonical_url must not use a wildcard host" + ); + } +} + #[tokio::test] async fn install_finish_failure_restores_settings_and_vault_but_leaves_env_keys() { let temp_dir = tempfile::tempdir().unwrap(); diff --git a/lib/crates/fabro-spa/assets/assets/entry-zcpgp9fa.js b/lib/crates/fabro-spa/assets/assets/entry-xf46xn8z.js similarity index 90% rename from lib/crates/fabro-spa/assets/assets/entry-zcpgp9fa.js rename to lib/crates/fabro-spa/assets/assets/entry-xf46xn8z.js index 69d5f29c9..a4b4127e9 100644 --- a/lib/crates/fabro-spa/assets/assets/entry-zcpgp9fa.js +++ b/lib/crates/fabro-spa/assets/assets/entry-xf46xn8z.js @@ -2,7 +2,7 @@ import{P as n,Q as T0,R as $8}from"./chunk-dep0g6mr.js";import{$ as k,aa as FZ,b 1. You might have mismatching versions of React and the renderer (such as React DOM) 2. You might be breaking the Rules of Hooks 3. You might have more than one copy of React in the same app -See https://react.dev/link/invalid-hook-call for tips about how to debug and fix this problem.`),N}typeof __REACT_DEVTOOLS_GLOBAL_HOOK__<"u"&&typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.registerInternalModuleStart==="function"&&__REACT_DEVTOOLS_GLOBAL_HOOK__.registerInternalModuleStart(Error());var W={d:{f:Z,r:function(){throw Error("Invalid form element. requestFormReset must be passed a form that was rendered by React.")},D:Z,C:Z,L:Z,m:Z,X:Z,S:Z,M:Z},p:0,findDOMNode:null},U=Symbol.for("react.portal"),w=bF.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE;typeof Map==="function"&&Map.prototype!=null&&typeof Map.prototype.forEach==="function"&&typeof Set==="function"&&Set.prototype!=null&&typeof Set.prototype.clear==="function"&&typeof Set.prototype.forEach==="function"||console.error("React depends on Map and Set built-in types. Make sure that you load a polyfill in older browsers. https://reactjs.org/link/react-polyfills"),Io.__DOM_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE=W,Io.createPortal=function(N,O){var _=2` tag.%s',_),typeof N==="string"&&typeof O==="object"&&O!==null&&typeof O.as==="string"){_=O.as;var A=q(_,O.crossOrigin);W.d.L(N,_,{crossOrigin:A,integrity:typeof O.integrity==="string"?O.integrity:void 0,nonce:typeof O.nonce==="string"?O.nonce:void 0,type:typeof O.type==="string"?O.type:void 0,fetchPriority:typeof O.fetchPriority==="string"?O.fetchPriority:void 0,referrerPolicy:typeof O.referrerPolicy==="string"?O.referrerPolicy:void 0,imageSrcSet:typeof O.imageSrcSet==="string"?O.imageSrcSet:void 0,imageSizes:typeof O.imageSizes==="string"?O.imageSizes:void 0,media:typeof O.media==="string"?O.media:void 0})}},Io.preloadModule=function(N,O){var _="";typeof N==="string"&&N||(_+=" The `href` argument encountered was "+K(N)+"."),O!==void 0&&typeof O!=="object"?_+=" The `options` argument encountered was "+K(O)+".":O&&("as"in O)&&typeof O.as!=="string"&&(_+=" The `as` option encountered was "+K(O.as)+"."),_&&console.error('ReactDOM.preloadModule(): Expected two arguments, a non-empty `href` string and, optionally, an `options` object with an `as` property valid for a `` tag.%s',_),typeof N==="string"&&(O?(_=q(O.as,O.crossOrigin),W.d.m(N,{as:typeof O.as==="string"&&O.as!=="script"?O.as:void 0,crossOrigin:_,integrity:typeof O.integrity==="string"?O.integrity:void 0})):W.d.m(N))},Io.requestFormReset=function(N){W.d.r(N)},Io.unstable_batchedUpdates=function(N,O){return N(O)},Io.useFormState=function(N,O,_){return $().useFormState(N,O,_)},Io.useFormStatus=function(){return $().useHostTransitionStatus()},Io.version="19.2.4",typeof __REACT_DEVTOOLS_GLOBAL_HOOK__<"u"&&typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.registerInternalModuleStop==="function"&&__REACT_DEVTOOLS_GLOBAL_HOOK__.registerInternalModuleStop(Error())})()});var g3=FZ((Pq0,JS)=>{var So=k(ZS());JS.exports=So});var YS=FZ((jo)=>{var p1=k(eI()),wX=k(n()),TF=k(g3());(function(){function Z(Q,X){for(Q=Q.memoizedState;Q!==null&&0=X.length)return G;var M=X[z],H=C2(Q)?Q.slice():P1({},Q);return H[M]=J(Q[M],X,z+1,G),H}function Y(Q,X,z){if(X.length!==z.length)console.warn("copyWithRename() expects paths of the same length");else{for(var G=0;GI8?console.error("Unexpected pop."):(X!==XA[I8]&&console.error("Unexpected Fiber popped."),Q.current=QA[I8],QA[I8]=null,XA[I8]=null,I8--)}function Q0(Q,X,z){I8++,QA[I8]=Q.current,XA[I8]=z,Q.current=X}function W0(Q){return Q===null&&console.error("Expected host context to exist. This error is likely caused by a bug in React. Please file an issue."),Q}function i(Q,X){Q0(a9,X,Q),Q0(Hz,Q,Q),Q0(r9,null,Q);var z=X.nodeType;switch(z){case 9:case 11:z=z===9?"#document":"#fragment",X=(X=X.documentElement)?(X=X.namespaceURI)?ky(X):r8:r8;break;default:if(z=X.tagName,X=X.namespaceURI)X=ky(X),X=fy(X,z);else switch(z){case"svg":X=GX;break;case"math":X=FU;break;default:X=r8}}z=z.toLowerCase(),z=Db(null,z),z={context:X,ancestorInfo:z},Y0(r9,Q),Q0(r9,z,Q)}function e(Q){Y0(r9,Q),Y0(Hz,Q),Y0(a9,Q)}function z0(){return W0(r9.current)}function o(Q){Q.memoizedState!==null&&Q0(vG,Q,Q);var X=W0(r9.current),z=Q.type,G=fy(X.context,z);z=Db(X.ancestorInfo,z),G={context:G,ancestorInfo:z},X!==G&&(Q0(Hz,Q,Q),Q0(r9,G,Q))}function q0(Q){Hz.current===Q&&(Y0(r9,Q),Y0(Hz,Q)),vG.current===Q&&(Y0(vG,Q),zB._currentValue=nJ)}function _0(){}function D0(){if(Oz===0){wD=console.log,MD=console.info,ND=console.warn,HD=console.error,OD=console.group,_D=console.groupCollapsed,AD=console.groupEnd;var Q={configurable:!0,enumerable:!0,value:_0,writable:!0};Object.defineProperties(console,{info:Q,log:Q,warn:Q,error:Q,group:Q,groupCollapsed:Q,groupEnd:Q})}Oz++}function A0(){if(Oz--,Oz===0){var Q={configurable:!0,enumerable:!0,writable:!0};Object.defineProperties(console,{log:P1({},Q,{value:wD}),info:P1({},Q,{value:MD}),warn:P1({},Q,{value:ND}),error:P1({},Q,{value:HD}),group:P1({},Q,{value:OD}),groupCollapsed:P1({},Q,{value:_D}),groupEnd:P1({},Q,{value:AD})})}0>Oz&&console.error("disabledDepth fell below zero. This is a bug in React. Please file an issue.")}function t(Q){var X=Error.prepareStackTrace;if(Error.prepareStackTrace=void 0,Q=Q.stack,Error.prepareStackTrace=X,Q.startsWith(`Error: react-stack-top-frame +See https://react.dev/link/invalid-hook-call for tips about how to debug and fix this problem.`),N}typeof __REACT_DEVTOOLS_GLOBAL_HOOK__<"u"&&typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.registerInternalModuleStart==="function"&&__REACT_DEVTOOLS_GLOBAL_HOOK__.registerInternalModuleStart(Error());var W={d:{f:Z,r:function(){throw Error("Invalid form element. requestFormReset must be passed a form that was rendered by React.")},D:Z,C:Z,L:Z,m:Z,X:Z,S:Z,M:Z},p:0,findDOMNode:null},U=Symbol.for("react.portal"),w=bF.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE;typeof Map==="function"&&Map.prototype!=null&&typeof Map.prototype.forEach==="function"&&typeof Set==="function"&&Set.prototype!=null&&typeof Set.prototype.clear==="function"&&typeof Set.prototype.forEach==="function"||console.error("React depends on Map and Set built-in types. Make sure that you load a polyfill in older browsers. https://reactjs.org/link/react-polyfills"),Io.__DOM_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE=W,Io.createPortal=function(N,O){var _=2` tag.%s',_),typeof N==="string"&&typeof O==="object"&&O!==null&&typeof O.as==="string"){_=O.as;var A=q(_,O.crossOrigin);W.d.L(N,_,{crossOrigin:A,integrity:typeof O.integrity==="string"?O.integrity:void 0,nonce:typeof O.nonce==="string"?O.nonce:void 0,type:typeof O.type==="string"?O.type:void 0,fetchPriority:typeof O.fetchPriority==="string"?O.fetchPriority:void 0,referrerPolicy:typeof O.referrerPolicy==="string"?O.referrerPolicy:void 0,imageSrcSet:typeof O.imageSrcSet==="string"?O.imageSrcSet:void 0,imageSizes:typeof O.imageSizes==="string"?O.imageSizes:void 0,media:typeof O.media==="string"?O.media:void 0})}},Io.preloadModule=function(N,O){var _="";typeof N==="string"&&N||(_+=" The `href` argument encountered was "+K(N)+"."),O!==void 0&&typeof O!=="object"?_+=" The `options` argument encountered was "+K(O)+".":O&&("as"in O)&&typeof O.as!=="string"&&(_+=" The `as` option encountered was "+K(O.as)+"."),_&&console.error('ReactDOM.preloadModule(): Expected two arguments, a non-empty `href` string and, optionally, an `options` object with an `as` property valid for a `` tag.%s',_),typeof N==="string"&&(O?(_=q(O.as,O.crossOrigin),W.d.m(N,{as:typeof O.as==="string"&&O.as!=="script"?O.as:void 0,crossOrigin:_,integrity:typeof O.integrity==="string"?O.integrity:void 0})):W.d.m(N))},Io.requestFormReset=function(N){W.d.r(N)},Io.unstable_batchedUpdates=function(N,O){return N(O)},Io.useFormState=function(N,O,_){return $().useFormState(N,O,_)},Io.useFormStatus=function(){return $().useHostTransitionStatus()},Io.version="19.2.4",typeof __REACT_DEVTOOLS_GLOBAL_HOOK__<"u"&&typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.registerInternalModuleStop==="function"&&__REACT_DEVTOOLS_GLOBAL_HOOK__.registerInternalModuleStop(Error())})()});var g3=FZ((Vq0,JS)=>{var So=k(ZS());JS.exports=So});var YS=FZ((jo)=>{var p1=k(eI()),wX=k(n()),TF=k(g3());(function(){function Z(Q,X){for(Q=Q.memoizedState;Q!==null&&0=X.length)return G;var M=X[z],H=y2(Q)?Q.slice():P1({},Q);return H[M]=J(Q[M],X,z+1,G),H}function Y(Q,X,z){if(X.length!==z.length)console.warn("copyWithRename() expects paths of the same length");else{for(var G=0;GI8?console.error("Unexpected pop."):(X!==XA[I8]&&console.error("Unexpected Fiber popped."),Q.current=QA[I8],QA[I8]=null,XA[I8]=null,I8--)}function Q0(Q,X,z){I8++,QA[I8]=Q.current,XA[I8]=z,Q.current=X}function W0(Q){return Q===null&&console.error("Expected host context to exist. This error is likely caused by a bug in React. Please file an issue."),Q}function i(Q,X){Q0(a9,X,Q),Q0(Hz,Q,Q),Q0(r9,null,Q);var z=X.nodeType;switch(z){case 9:case 11:z=z===9?"#document":"#fragment",X=(X=X.documentElement)?(X=X.namespaceURI)?ky(X):r8:r8;break;default:if(z=X.tagName,X=X.namespaceURI)X=ky(X),X=fy(X,z);else switch(z){case"svg":X=GX;break;case"math":X=FU;break;default:X=r8}}z=z.toLowerCase(),z=Db(null,z),z={context:X,ancestorInfo:z},Y0(r9,Q),Q0(r9,z,Q)}function e(Q){Y0(r9,Q),Y0(Hz,Q),Y0(a9,Q)}function z0(){return W0(r9.current)}function o(Q){Q.memoizedState!==null&&Q0(vG,Q,Q);var X=W0(r9.current),z=Q.type,G=fy(X.context,z);z=Db(X.ancestorInfo,z),G={context:G,ancestorInfo:z},X!==G&&(Q0(Hz,Q,Q),Q0(r9,G,Q))}function q0(Q){Hz.current===Q&&(Y0(r9,Q),Y0(Hz,Q)),vG.current===Q&&(Y0(vG,Q),zB._currentValue=nJ)}function _0(){}function D0(){if(Oz===0){wD=console.log,MD=console.info,ND=console.warn,HD=console.error,OD=console.group,_D=console.groupCollapsed,AD=console.groupEnd;var Q={configurable:!0,enumerable:!0,value:_0,writable:!0};Object.defineProperties(console,{info:Q,log:Q,warn:Q,error:Q,group:Q,groupCollapsed:Q,groupEnd:Q})}Oz++}function A0(){if(Oz--,Oz===0){var Q={configurable:!0,enumerable:!0,writable:!0};Object.defineProperties(console,{log:P1({},Q,{value:wD}),info:P1({},Q,{value:MD}),warn:P1({},Q,{value:ND}),error:P1({},Q,{value:HD}),group:P1({},Q,{value:OD}),groupCollapsed:P1({},Q,{value:_D}),groupEnd:P1({},Q,{value:AD})})}0>Oz&&console.error("disabledDepth fell below zero. This is a bug in React. Please file an issue.")}function t(Q){var X=Error.prepareStackTrace;if(Error.prepareStackTrace=void 0,Q=Q.stack,Error.prepareStackTrace=X,Q.startsWith(`Error: react-stack-top-frame `)&&(Q=Q.slice(29)),X=Q.indexOf(` `),X!==-1&&(Q=Q.slice(X+1)),X=Q.indexOf("react_stack_bottom_frame"),X!==-1&&(X=Q.lastIndexOf(` `,X)),X!==-1)Q=Q.slice(0,X);else return"";return Q}function B0(Q){if(qA===void 0)try{throw Error()}catch(z){var X=z.stack.trim().match(/\n( *(at )?)/);qA=X&&X[1]||"",FD=-1"u")return!1;var X=__REACT_DEVTOOLS_GLOBAL_HOOK__;if(X.isDisabled)return!0;if(!X.supportsFiber)return console.error("The installed version of React DevTools is too old and will not work with the current version of React. Please update React DevTools. https://react.dev/link/react-devtools"),!0;try{SQ=X.inject(Q),D7=X}catch(z){console.error("React instrumentation encountered an error: %o.",z)}return X.checkDCE?!0:!1}function h0(Q){if(typeof sa==="function"&&oa(Q),D7&&typeof D7.setStrictMode==="function")try{D7.setStrictMode(SQ,Q)}catch(X){D3||(D3=!0,console.error("React instrumentation encountered an error: %o",X))}}function R2(Q){return Q>>>=0,Q===0?32:31-(ia(Q)/ta|0)|0}function O5(Q){var X=Q&42;if(X!==0)return X;switch(Q&-Q){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:return Q&261888;case 262144:case 524288:case 1048576:case 2097152:return Q&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return Q&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return console.error("Should have found matching lanes. This is a bug in React."),Q}}function K5(Q,X,z){var G=Q.pendingLanes;if(G===0)return 0;var M=0,H=Q.suspendedLanes,F=Q.pingedLanes;Q=Q.warmLanes;var V=G&134217727;return V!==0?(G=V&~H,G!==0?M=O5(G):(F&=V,F!==0?M=O5(F):z||(z=V&~Q,z!==0&&(M=O5(z))))):(V=G&~H,V!==0?M=O5(V):F!==0?M=O5(F):z||(z=G&~Q,z!==0&&(M=O5(z)))),M===0?0:X!==0&&X!==M&&(X&H)===0&&(H=M&-M,z=X&-X,H>=z||H===32&&(z&4194048)!==0)?X:M}function W5(Q,X){return(Q.pendingLanes&~(Q.suspendedLanes&~Q.pingedLanes)&X)===0}function h7(Q,X){switch(Q){case 1:case 2:case 4:case 8:case 64:return X+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return X+5000;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return console.error("Should have found matching lanes. This is a bug in React."),-1}}function W4(){var Q=bG;return bG<<=1,(bG&62914560)===0&&(bG=4194304),Q}function $7(Q){for(var X=[],z=0;31>z;z++)X.push(Q);return X}function T5(Q,X){Q.pendingLanes|=X,X!==268435456&&(Q.suspendedLanes=0,Q.pingedLanes=0,Q.warmLanes=0)}function J5(Q,X,z,G,M,H){var F=Q.pendingLanes;Q.pendingLanes=z,Q.suspendedLanes=0,Q.pingedLanes=0,Q.warmLanes=0,Q.expiredLanes&=z,Q.entangledLanes&=z,Q.errorRecoveryDisabledLanes&=z,Q.shellSuspendCounter=0;var{entanglements:V,expirationTimes:C,hiddenUpdates:D}=Q;for(z=F&~z;0"u")return null;try{return Q.activeElement||Q.body}catch(X){return Q.body}}function y0(Q){return Q.replace(Ys,function(X){return"\\"+X.charCodeAt(0).toString(16)+" "})}function f0(Q,X){X.checked===void 0||X.defaultChecked===void 0||bD||(console.error("%s contains an input of type %s with both checked and defaultChecked props. Input elements must be either controlled or uncontrolled (specify either the checked prop, or the defaultChecked prop, but not both). Decide between using a controlled or uncontrolled input element and remove one of these props. More info: https://react.dev/link/controlled-components",b1()||"A component",X.type),bD=!0),X.value===void 0||X.defaultValue===void 0||RD||(console.error("%s contains an input of type %s with both value and defaultValue props. Input elements must be either controlled or uncontrolled (specify either the value prop, or the defaultValue prop, but not both). Decide between using a controlled or uncontrolled input element and remove one of these props. More info: https://react.dev/link/controlled-components",b1()||"A component",X.type),RD=!0)}function m0(Q,X,z,G,M,H,F,V){if(Q.name="",F!=null&&typeof F!=="function"&&typeof F!=="symbol"&&typeof F!=="boolean"?(U1(F,"type"),Q.type=F):Q.removeAttribute("type"),X!=null)if(F==="number"){if(X===0&&Q.value===""||Q.value!=X)Q.value=""+s(X)}else Q.value!==""+s(X)&&(Q.value=""+s(X));else F!=="submit"&&F!=="reset"||Q.removeAttribute("value");X!=null?u0(Q,F,s(X)):z!=null?u0(Q,F,s(z)):G!=null&&Q.removeAttribute("value"),M==null&&H!=null&&(Q.defaultChecked=!!H),M!=null&&(Q.checked=M&&typeof M!=="function"&&typeof M!=="symbol"),V!=null&&typeof V!=="function"&&typeof V!=="symbol"&&typeof V!=="boolean"?(U1(V,"name"),Q.name=""+s(V)):Q.removeAttribute("name")}function i0(Q,X,z,G,M,H,F,V){if(H!=null&&typeof H!=="function"&&typeof H!=="symbol"&&typeof H!=="boolean"&&(U1(H,"type"),Q.type=H),X!=null||z!=null){if(!(H!=="submit"&&H!=="reset"||X!==void 0&&X!==null)){H0(Q);return}z=z!=null?""+s(z):"",X=X!=null?""+s(X):z,V||X===Q.value||(Q.value=X),Q.defaultValue=X}G=G!=null?G:M,G=typeof G!=="function"&&typeof G!=="symbol"&&!!G,Q.checked=V?Q.checked:!!G,Q.defaultChecked=!!G,F!=null&&typeof F!=="function"&&typeof F!=="symbol"&&typeof F!=="boolean"&&(U1(F,"name"),Q.name=F),H0(Q)}function u0(Q,X,z){X==="number"&&C0(Q.ownerDocument)===Q||Q.defaultValue===""+z||(Q.defaultValue=""+z)}function f1(Q,X){X.value==null&&(typeof X.children==="object"&&X.children!==null?wX.Children.forEach(X.children,function(z){z==null||typeof z==="string"||typeof z==="number"||typeof z==="bigint"||CD||(CD=!0,console.error("Cannot infer the option value of complex children. Pass a `value` prop or use a plain string as children to