diff --git a/src/main.rs b/src/main.rs index 06f018b9..86f1e0b3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1396,7 +1396,13 @@ async fn run_server(config: Config, database: Db) { .route("/reports.html", get(serve_branded_page)) .route("/admin.html", get(serve_branded_page)) .route("/tokens.html", get(serve_branded_page)) - .route("/docs.html", get(serve_branded_page)); + .route("/docs.html", get(serve_branded_page)) + // Optional auth only: an anonymous request still gets the page (the + // page itself sends the user to log in), but a logged-in one gets + // the variant with its navigation already right. + .layer(middleware::from_fn(auth::optional_auth)) + .layer(Extension(ws_ticket_store.clone())) + .layer(Extension(database.clone())); // Build full router (all Router<()> at this point) let mut app: Router<()> = Router::new() @@ -1582,23 +1588,63 @@ fn build_guacd_tls(config: &Config) -> Option { Some(tokio_rustls::TlsConnector::from(Arc::new(tls_config))) } -/// One pre-branded HTML page and the ETag its bytes hash to. +/// One pre-branded HTML page, in one variant per role level, each with the +/// ETag its bytes hash to. /// /// The pages are branded once at startup and cannot change until a restart, -/// so the tag is computed there too -- serving one is a string compare. +/// so the tags are computed there too -- serving one is a string compare. +/// +/// The variants differ only in the `data-role-level` attribute written on +/// ``: the stylesheet gates navigation links and admin-only controls on +/// it, so the header is right on first paint for whoever the page is served +/// to, instead of hiding everything gated and showing it after `/api/me` +/// answers, which flashed and reflowed on every navigation. struct BrandedPage { + /// Indexed by role level, 0 (anonymous) to 4 (admin). + variants: Vec, +} + +struct BrandedVariant { html: String, etag: String, } +/// Highest role level, the index of the last page variant. +const MAX_ROLE_LEVEL: u8 = 4; + impl BrandedPage { fn new(html: String) -> Self { use sha2::{Digest, Sha256}; - // Quoted, because an ETag is a quoted-string on the wire. A bare hash - // tends to be ignored rather than rejected, which would look exactly - // like the stale-cache bug this exists to fix. - let etag = format!("\"{}\"", hex::encode(Sha256::digest(html.as_bytes()))); - Self { html, etag } + let variants = (0..=MAX_ROLE_LEVEL) + .map(|level| { + let html = role_variant_html(&html, level); + // Quoted, because an ETag is a quoted-string on the wire. A + // bare hash tends to be ignored rather than rejected, which + // would look exactly like the stale-cache bug this exists to + // fix. + let etag = format!("\"{}\"", hex::encode(Sha256::digest(html.as_bytes()))); + BrandedVariant { html, etag } + }) + .collect(); + Self { variants } + } + + fn variant(&self, level: u8) -> &BrandedVariant { + &self.variants[level.min(MAX_ROLE_LEVEL) as usize] + } +} + +/// Write the caller's role level onto the page's `` tag. A page that +/// has no bare `` start tag is returned unchanged. Extracted for test. +fn role_variant_html(html: &str, level: u8) -> String { + match html.find("") { + Some(i) => format!( + "{}{}", + &html[..i], + level, + &html[i + "".len()..] + ), + None => html.to_string(), } } @@ -1624,10 +1670,15 @@ fn etag_matches(if_none_match: &str, etag: &str) -> bool { /// costs a 304 and a rebuilt one lands on the next load. Without it these /// responses carry no freshness information at all and Chrome caches them /// heuristically, serving a stale page long after a restart. -fn page_response(page: &BrandedPage, headers: &axum::http::HeaderMap) -> axum::response::Response { +fn page_response( + page: &BrandedPage, + level: u8, + headers: &axum::http::HeaderMap, +) -> axum::response::Response { use axum::http::{header, StatusCode}; use axum::response::IntoResponse; + let page = page.variant(level); let cache_headers = [ (header::CACHE_CONTROL, "no-cache"), (header::ETAG, page.etag.as_str()), @@ -1745,7 +1796,12 @@ fn asset_hash( if !url.starts_with('/') || url.starts_with("//") || url.contains('?') || url.contains('#') { return None; } - if !(url.ends_with(".js") || url.ends_with(".css")) { + if !(url.ends_with(".js") + || url.ends_with(".css") + || url.ends_with(".svg") + || url.ends_with(".png") + || url.ends_with(".ico")) + { return None; } let rel = url.trim_start_matches('/'); @@ -1774,14 +1830,21 @@ fn asset_hash( /// Serve a branded HTML page from the pre-processed in-memory map. async fn serve_branded_page( Extension(pages): Extension, + identity: Option>, request: axum::extract::Request, ) -> axum::response::Response { use axum::http::StatusCode; use axum::response::IntoResponse; let path = request.uri().path().trim_start_matches('/'); let key = if path.is_empty() { "index.html" } else { path }; + // Whoever the page is for decides which navigation it carries. An API-key + // user sends no credential on the page GET and gets the anonymous + // variant; the page reconciles that from /api/me. + let level = identity + .map(|Extension(id)| auth::role_level(id.role())) + .unwrap_or(0); match pages.get(key) { - Some(page) => page_response(page, request.headers()), + Some(page) => page_response(page, level, request.headers()), None => StatusCode::NOT_FOUND.into_response(), } } @@ -1794,7 +1857,7 @@ async fn serve_client_page( ) -> axum::response::Response { use axum::response::IntoResponse; match pages.get("client.html") { - Some(page) => page_response(page, &headers), + Some(page) => page_response(page, 0, &headers), // Unreachable: the map always carries the embedded copy. None => Html(include_str!("../static/client.html")).into_response(), } @@ -1835,6 +1898,27 @@ mod tests { use super::*; + #[test] + fn role_variant_writes_level_on_html_tag_only() { + let page = "\n\nxtext"; + let v = role_variant_html(page, 4); + assert!(v.starts_with("\n\n")); + // only the first bare start tag is touched; the literal later on is content + assert_eq!(v.matches("data-role-level").count(), 1); + assert_eq!( + role_variant_html("

no html tag

", 2), + "

no html tag

" + ); + let branded = BrandedPage::new(page.to_string()); + assert_eq!(branded.variants.len(), 5); + assert_ne!(branded.variant(0).etag, branded.variant(4).etag); + assert_eq!( + branded.variant(9).etag, + branded.variant(4).etag, + "levels clamp to admin" + ); + } + #[test] fn test_csp_allows_data_and_blob_images() { // Safari and Firefox draw Guacamole image streams through data: URIs @@ -1870,6 +1954,21 @@ mod tests { assert!(out.ends_with(">")); } + #[test] + fn test_version_assets_versions_the_logo() { + // The logo used to be revalidated on every page load, which blanked + // it for a moment on each navigation in some browsers. It is content + // addressed like the scripts now. + let static_path = std::path::Path::new("static"); + let mut hashes = std::collections::HashMap::new(); + let out = version_assets( + "\"\"", + static_path, + &mut hashes, + ); + assert!(out.contains("src=\"/logo.svg?v="), "{out}"); + } + #[test] fn test_version_assets_leaves_everything_else_alone() { let static_path = std::path::Path::new("static"); @@ -1880,8 +1979,8 @@ mod tests { "", // Already says something about this URL. "", - // Not a kind that goes stale behind a stable name. - "", + // Not a kind we version (a page, a download). + "", // No such file: a hash of nothing is worse than none. "", // Traversal, which these paths have no business doing. @@ -1931,6 +2030,7 @@ mod tests { fn test_branded_page_etag_tracks_content() { let a = BrandedPage::new("one".to_string()); let b = BrandedPage::new("two".to_string()); + let (a, b) = (a.variant(0), b.variant(0)); assert_ne!(a.etag, b.etag); // Quoted, or caches ignore it. assert!(a.etag.starts_with('"') && a.etag.ends_with('"')); diff --git a/static/admin.html b/static/admin.html index 5dea3b79..c67657e8 100644 --- a/static/admin.html +++ b/static/admin.html @@ -16,12 +16,13 @@

rustguac

@@ -155,6 +156,7 @@

Connections Audit Log

return res.json(); }) .then(function(data) { + if (data && data.role) document.documentElement.setAttribute('data-role-level', String({admin:4,poweruser:3,operator:2,viewer:1}[data.role] || 0)); if (!data || data.role !== 'admin') { window.location.href = '/connections.html'; return; diff --git a/static/connections.html b/static/connections.html index 4b0f0b24..7ec87d3c 100644 --- a/static/connections.html +++ b/static/connections.html @@ -216,8 +216,6 @@ display: flex; gap: var(--s-1); align-items: center; } .session-card-info .card-meta .type-badge { font-size: 10px; min-width: 36px; } - /* Proxmox VE brand orange badge */ - .type-badge.type-proxmox { background: #e57000; color: #000; padding: 1px 6px; border-radius: 3px; font-weight: 600; } .session-card.dormant { opacity: 0.75; } .session-card.dormant .session-card-thumb { filter: brightness(0.7); } .card-actions { @@ -535,6 +533,78 @@ .folder-list li.filtered-out { display: none; } .folder-row:focus { outline: none; } .folder-row:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; } + + /* UX (#254) app shell: the header stays put, the sidebar and the + entries area are two independent scroll panes that always fit the + viewport. The earlier sticky sidebar was sized from the viewport, + not from where it starts below the header, so at the top of the + page its bottom hung off screen and the page had to be scrolled + before the list could be scrolled to its end. */ + html, body { height: 100%; } + /* overflow: clip, not hidden: hidden still lets scripts scroll the + body (scrollIntoView on anything inside), which moved the header + out of view; clip makes the body unscrollable for good. The border + box is the full height so the page padding is inside the viewport. */ + body { display: flex; flex-direction: column; overflow: clip; min-height: 0; box-sizing: border-box; } + #main-content { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; } + #main-content > :not(.layout) { flex: none; } + .layout { flex: 1 1 auto; min-height: 0; align-items: stretch; } + .sidebar { + position: static; top: auto; align-self: auto; + max-height: none; height: auto; min-height: 0; + display: flex; flex-direction: column; + overflow-y: auto; overflow-x: hidden; + padding: 0; margin: 0; + border: 1px solid var(--border); border-radius: var(--radius); + background: rgba(255,255,255,0.015); + } + .main { min-height: 0; overflow-y: auto; overflow-x: hidden; padding-right: var(--s-2); } + /* The folder header (name, description, search, actions, filters) + pins to the top of the entries pane, so it stays under the nav and + the active-sessions strip while a long table scrolls beneath it. */ + #entries-header { + position: sticky; top: 0; z-index: 30; + background: var(--bg); + padding-bottom: var(--s-2); + margin-bottom: var(--s-2); + box-shadow: 0 6px 10px -8px rgba(0, 0, 0, 0.6); + } + #entries-header .folder-actions { margin-bottom: 0; } + #entries-header #entries-filters { margin: var(--s-2) 0 0 0; } + + /* Sidebar panel: one pinned header band (label, new-folder button, + filter) on a surface tone, then the list with hairline dividers. + Every band shares the same 12px horizontal padding so edges line up. */ + .sidebar-pin { + background: var(--surface); + border-bottom: 1px solid var(--border); + padding: var(--s-2) var(--s-3); + gap: var(--s-2); + } + .sidebar-pin .sidebar-header { + margin: 0; padding: 0; border: 0; + min-height: 30px; + justify-content: space-between; + } + .sidebar-pin .sidebar-header strong { font-size: var(--fz-xs); letter-spacing: 0.14em; } + #btn-new-folder { height: 30px; padding: 0 var(--s-2); font-size: var(--fz-xs); } + #sidebar-tools { margin: 0; } + #sidebar-tools:not(:empty) { padding-bottom: 0; } + #folder-filter { + height: 34px; font-size: var(--fz-sm); + background: var(--bg); border: 1px solid var(--border); + } + .folder-list { padding: var(--s-1) 0 var(--s-2); } + .folder-list > li { margin: 0; } + .folder-list > li + li > .folder-row { border-top: 1px solid rgba(255,255,255,0.045); } + .folder-row { + padding: var(--s-2) var(--s-3) var(--s-2) calc(var(--s-3) - 3px); + min-height: 46px; + } + .folder-list li.selected > .folder-row { border-top-color: transparent; } + .folder-row-main { gap: 2px; } + .folder-desc { font-size: 12px; } + .tree-children { margin-left: var(--s-3) !important; } @@ -545,13 +615,13 @@

rustguac