From 56d639614246c07f6d902a5607d6b5068af16eef Mon Sep 17 00:00:00 2001 From: archipelago Date: Sat, 15 Aug 2026 08:24:27 -0400 Subject: [PATCH] feat(appgate): gate login gets the real dashboard badge + glass-button states MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The badge is now the dashboard login's AnimatedLogo, square for square: inline SVG (20 white rects, 100ms stagger, 3s loop) inside the same gradient ring. The old of favico-black-v2.svg baked a second ring into the ring and couldn't animate; the asset leaves the gate allowlist since nothing references it now. - The submit button is .glass-button longhand: hover lift + lightening + rim glow, active press, disabled dim — the flat darken-only hover read as broken next to /login. - Loading state: submitting flips the button to spinner + 'Signing in…'/ 'Verifying…' and disables it, via a single inline script admitted by CSP sha256 hash (not unsafe-inline; injected markup stays inert, and the page still works as a plain POST without JS). Co-Authored-By: Claude Fable 5 --- core/archipelago/src/appgate/mod.rs | 168 ++++++++++++++++++++++++---- 1 file changed, 147 insertions(+), 21 deletions(-) diff --git a/core/archipelago/src/appgate/mod.rs b/core/archipelago/src/appgate/mod.rs index 1baf0348..af4706fb 100644 --- a/core/archipelago/src/appgate/mod.rs +++ b/core/archipelago/src/appgate/mod.rs @@ -731,6 +731,71 @@ fn icon_markup(app: &GatedPort) -> String { format!(r#"
{inner}
"#) } +/// The dashboard login's badge, reproduced square-for-square: the same 20 +/// white rects AnimatedLogo.vue draws, with the same 100ms stagger, inside +/// the same gradient ring. Inline rather than an `` because the shipped +/// `favico-black-v2.svg` bakes its own ring into the artwork — wrapping it in +/// the CSS ring drew a ring inside a ring, which is not what /login shows. +/// (x, y, width, height) as they appear in AnimatedLogo.vue. +const LOGO_RECTS: [(f32, f32, f32, f32); 20] = [ + (357.614, 318.0, 71.007, 70.936), + (436.152, 318.0, 72.082, 70.936), + (515.766, 318.0, 72.082, 70.936), + (595.379, 318.0, 71.007, 70.936), + (595.379, 396.46, 71.007, 72.011), + (673.917, 396.46, 72.083, 72.011), + (278.0, 475.994, 72.083, 72.012), + (357.614, 475.994, 71.007, 72.012), + (436.152, 475.994, 72.082, 72.012), + (515.766, 475.994, 72.082, 72.012), + (595.379, 475.994, 71.007, 72.012), + (673.917, 475.994, 72.083, 72.012), + (278.0, 555.529, 72.083, 70.936), + (357.614, 555.529, 71.007, 70.936), + (595.379, 555.529, 71.007, 70.936), + (673.917, 555.529, 72.083, 70.936), + (357.614, 633.989, 71.007, 72.011), + (436.152, 633.989, 72.082, 72.011), + (515.766, 633.989, 72.082, 72.011), + (595.379, 633.989, 71.007, 72.011), +]; + +fn logo_markup() -> String { + let rects: String = LOGO_RECTS + .iter() + .enumerate() + .map(|(i, (x, y, w, h))| { + format!( + r#""#, + delay = i * 100, + ) + }) + .collect(); + format!( + r##""## + ) +} + +/// The main login's in-button spinner, verbatim from Login.vue. +const SPINNER_SVG: &str = r#""#; + +/// Submit feedback: flip the pressed button into its loading face and stop a +/// second press, exactly as /login does. This is the only script on the page, +/// and the CSP admits it by hash — not `'unsafe-inline'` — so an injected +/// `"#, title = esc(title), app_name = esc(&app.app_name), body = body, + submit_feedback = SUBMIT_FEEDBACK_JS, backgrounds = background_layers(), cycle = LOGIN_BACKGROUNDS.len() as u32 * 9, hold = 100 / LOGIN_BACKGROUNDS.len() as u32, @@ -961,10 +1055,16 @@ button:active {{ transform:translateY(1px); }} // login, on any port or scheme, which is exactly the dashboard. // Anything else — another site embedding it to harvest the node // password — is still refused. + // script-src admits exactly one script, by hash: the submit-feedback + // snippet above. Injected markup (an app name, an error string) still + // cannot execute — its hash would not match. .header( "Content-Security-Policy", - "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \ - form-action 'self'; frame-ancestors 'self' http://*:* https://*:*", + format!( + "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \ + script-src {hash}; form-action 'self'; frame-ancestors 'self' http://*:* https://*:*", + hash = submit_feedback_csp_hash(), + ), ) .body(Body::from(html)) .expect("static response builds") @@ -975,15 +1075,17 @@ button:active {{ transform:translateY(1px); }} /// password by an unexplained page. fn login_page(app: &GatedPort, error: Option<&str>, status: StatusCode) -> Response { let body = format!( - r#" + r#"{logo} {icon}

Sign in to open {name}

This app is protected by your node password.

{err}
- +
"#, + logo = logo_markup(), + spinner = SPINNER_SVG, icon = icon_markup(app), name = esc(&app.app_name), err = error @@ -1004,8 +1106,9 @@ fn totp_page(app: &GatedPort, error: Option<&str>, status: StatusCode) -> Respon {err}
- +
"#, + spinner = SPINNER_SVG, icon = icon_markup(app), name = esc(&app.app_name), err = error @@ -1200,25 +1303,48 @@ mod tests { } /// The login page must render entirely from the gate's own origin: the - /// CSP allows no external host, so a background or logo that 404s leaves - /// a black page rather than the dashboard's art. + /// CSP allows no external host, so a background that 404s leaves a black + /// page rather than the dashboard's art. The badge itself is inline SVG — + /// the same 20 squares as the dashboard login's AnimatedLogo — so it can + /// never 404 at all. #[tokio::test] async fn login_page_sources_its_art_from_the_gate() { let resp = login_page(&app(), None, StatusCode::UNAUTHORIZED); let body = hyper::body::to_bytes(resp.into_body()).await.unwrap(); let html = String::from_utf8_lossy(&body).to_string(); - assert!(html.contains(&format!("{GATE_PREFIX}asset/favico-black-v2.svg"))); + assert_eq!( + html.matches(r#"class="sq""#).count(), + LOGO_RECTS.len(), + "the badge must draw every AnimatedLogo square inline" + ); for name in LOGIN_BACKGROUNDS { assert!( html.contains(&format!("{GATE_PREFIX}asset/{name}")), "background {name} is not referenced" ); } - // Every referenced asset must be one the gate will actually serve. - // The logo is the sidebar A mark (favico-black-v2.svg) since the - // 2026-08-05 login-page rework — the old wordmark is off the - // allowlist on purpose. - assert!(read_ui_asset("favico-black-v2.svg").is_some() || cfg!(not(debug_assertions))); + } + + /// The only script the challenge pages may run is the submit-feedback + /// snippet, admitted by hash. The page must carry exactly that script, + /// and the CSP must name its hash — anything injected has a different + /// hash and stays inert. + #[tokio::test] + async fn submit_feedback_script_is_present_and_hash_pinned() { + let resp = login_page(&app(), None, StatusCode::UNAUTHORIZED); + let csp = resp.headers()["Content-Security-Policy"] + .to_str() + .unwrap() + .to_string(); + assert!(csp.contains(&format!("script-src {}", submit_feedback_csp_hash()))); + assert!(!csp.contains("script-src 'unsafe-inline'")); + let body = hyper::body::to_bytes(resp.into_body()).await.unwrap(); + let html = String::from_utf8_lossy(&body); + assert!(html.contains(&format!(""))); + // Both button faces render: idle label and the spinner face. + assert!(html.contains(r#"Sign in"#)); + assert!(html.contains("Signing in…")); + assert!(html.contains(r#"class="spin""#)); } /// The allowlist is the whole security boundary for asset serving: the