Version portal favicon href for cache busting

This commit is contained in:
rcourtman 2026-03-29 17:35:45 +01:00
parent 78ec9d2946
commit 022a2be763
9 changed files with 36 additions and 11 deletions

View file

@ -381,6 +381,10 @@ action or state (`Manage access`, `Hosted billing attached`, `Email support`,
generic alert labels. Support copy is part of the same typed contract:
escalation surfaces must render short literal path/account/action wording
instead of longer procedural prose.
That same typed portal page contract also owns favicon cache-busting: the
rendered `<link rel="icon">` must point at the shared `/favicon.svg` asset
through a versioned href so new portal icon revisions bypass browser cache on
deploy instead of waiting for asset expiry.
That same typed overview contract must also preserve a sharp, high-density enterprise visual aesthetic (e.g. Cloudflare/GCP density standards) across all portal scenarios, removing gradients and heavy box-shadows to ensure a calm, rigorous visual language with standard 256px sidebars, Inter-grade typography, clean text-transform rules, and cleanly unboxed typography without excessive pills or stacked metrics.
plus a package-local `tsc --noEmit` gate, so future account-shell work should
extend the typed source boundary instead of reviving opaque global runtime

View file

@ -270,7 +270,10 @@ instance, but local portal design work must not depend on redeploying
That same preview/runtime boundary also owns shared browser chrome such as the
portal favicon: the local preview must serve the same `/favicon.svg` asset as
the real control-plane route so icon changes can be reviewed locally before
deployment instead of appearing only after a live push.
deployment instead of appearing only after a live push. The portal page itself
must also reference that shared favicon through a versioned href so updated
icon revisions bypass browser cache on deploy instead of waiting for asset
expiry.
That same frontend delivery boundary must keep the account portal visual language sharp and high-density, avoiding gradients, heavy shadows, and decorative SaaS styling in favor of a clean, restrained, Cloudflare/GCP-grade baseline with flat inline workspace action rows and text-driven unboxed metadata instead of pills and absolutely inline row actions.
That same portal delivery boundary also owns the checked-in embedded bundle in
`internal/cloudcp/portal/dist/`. Visual or interaction changes are not

View file

@ -1,6 +1,7 @@
import fs from 'node:fs';
import http from 'node:http';
import path from 'node:path';
import { createHash } from 'node:crypto';
import { context } from 'esbuild';
import { createPortalBuildOptions, frontendRoot } from './build_config.mjs';
@ -10,6 +11,7 @@ const previewHost = process.env.PULSE_PORTAL_PREVIEW_HOST || '127.0.0.1';
const previewPort = Number(process.env.PULSE_PORTAL_PREVIEW_PORT || '8765');
const previewScenarios = ['managed', 'readonly', 'selfhosted', 'empty'];
const previewFaviconSVG = fs.readFileSync(path.join(frontendRoot, '..', '..', 'favicon.svg'), 'utf8');
const previewFaviconHref = '/favicon.svg?v=' + createHash('sha256').update(previewFaviconSVG).digest('hex').slice(0, 16);
function iso(value) {
return new Date(value).toISOString();
@ -253,7 +255,7 @@ function buildPreviewHTML(assets, bootstrap, previewToast) {
'<meta charset="utf-8">' +
'<meta name="viewport" content="width=device-width, initial-scale=1">' +
'<title>Pulse Account Preview</title>' +
'<link rel="icon" href="/favicon.svg" type="image/svg+xml">' +
'<link rel="icon" href="' + previewFaviconHref + '" type="image/svg+xml">' +
'<style>' + assets.css + '</style>' +
'</head>' +
'<body>' +

View file

@ -46,7 +46,7 @@ func doRequest(t *testing.T, h http.Handler, req *http.Request) *httptest.Respon
func renderPortalHTML(t *testing.T, bootstrap BootstrapData) string {
t.Helper()
rec := httptest.NewRecorder()
renderPortalPage(rec, "test-nonce", bootstrap)
renderPortalPage(rec, "test-nonce", "/favicon.svg?v=test-favicon", bootstrap)
if rec.Code != http.StatusOK {
t.Fatalf("renderPortalPage returned %d", rec.Code)
}
@ -782,7 +782,7 @@ func TestPortalPageTemplate_AccountServicesRendered(t *testing.T) {
mustContain := []string{
"<title>Pulse Account</title>",
`<link rel="icon" href="/favicon.svg" type="image/svg+xml">`,
`<link rel="icon" href="/favicon.svg?v=test-favicon" type="image/svg+xml">`,
`id="portal-user-info"`,
`id="portal-app-root"`,
`id="pulse-account-bootstrap"`,
@ -1133,7 +1133,7 @@ func TestPortalPageTemplate_UsesPulseAccountBrandingWhenSignedOut(t *testing.T)
mustContain := []string{
"<title>Pulse Account</title>",
`<link rel="icon" href="/favicon.svg" type="image/svg+xml">`,
`<link rel="icon" href="/favicon.svg?v=test-favicon" type="image/svg+xml">`,
"Pulse Account",
`id="portal-app-root"`,
"Enter the commercial email address for your Pulse account.",

View file

@ -46,6 +46,7 @@ type portalPageAccount struct {
// portalPageData is passed to the portal HTML template.
type portalPageData struct {
Nonce string
FaviconHref string
Styles template.CSS
ShellScript template.JS
BootstrapJSON template.JS
@ -67,7 +68,7 @@ var errPortalAuthRequired = errors.New("portal auth required")
// Route: GET /portal
// - No session or invalid session -> shows a magic-link login form
// - Valid session -> shows workspace list with management actions
func HandlePortalPage(sessionSvc *cpauth.Service, reg *registry.TenantRegistry, commercialLookup CommercialIdentityLookup) http.HandlerFunc {
func HandlePortalPage(sessionSvc *cpauth.Service, reg *registry.TenantRegistry, commercialLookup CommercialIdentityLookup, faviconHref string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
@ -85,9 +86,9 @@ func HandlePortalPage(sessionSvc *cpauth.Service, reg *registry.TenantRegistry,
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
renderPortalPage(w, nonce, BuildBootstrapData(true, claims.Email, accounts, resolveSelfHostedCommercial(r.Context(), commercialLookup, claims.Email, accounts)))
renderPortalPage(w, nonce, faviconHref, BuildBootstrapData(true, claims.Email, accounts, resolveSelfHostedCommercial(r.Context(), commercialLookup, claims.Email, accounts)))
case errors.Is(err, errPortalAuthRequired):
renderPortalPage(w, nonce, BuildAnonymousBootstrapData())
renderPortalPage(w, nonce, faviconHref, BuildAnonymousBootstrapData())
default:
log.Error().Err(err).Msg("cloudcp.portal.page: validate session")
http.Error(w, "internal error", http.StatusInternalServerError)
@ -246,7 +247,7 @@ func workspaceHealthStatus(healthy bool, lastHealthCheck *time.Time) string {
return "unhealthy"
}
func renderPortalPage(w http.ResponseWriter, nonce string, bootstrapData BootstrapData) {
func renderPortalPage(w http.ResponseWriter, nonce string, faviconHref string, bootstrapData BootstrapData) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusOK)
bootstrapJSON, err := MarshalBootstrapJSON(bootstrapData)
@ -256,6 +257,7 @@ func renderPortalPage(w http.ResponseWriter, nonce string, bootstrapData Bootstr
}
if err := portalPageTmpl.Execute(w, portalPageData{
Nonce: nonce,
FaviconHref: faviconHref,
Styles: portalStyles,
ShellScript: portalShellScript,
BootstrapJSON: bootstrapJSON,

View file

@ -4,7 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Pulse Account</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="icon" href="{{.FaviconHref}}" type="image/svg+xml">
<style nonce="{{.Nonce}}">{{.Styles}}</style>
</head>
<body>

View file

@ -257,5 +257,5 @@ func RegisterRoutes(mux *http.ServeMux, deps *Deps) {
// MSP/Cloud portal HTML page — self-authenticating (shows login form if no session)
portalPageLimiter := NewCPRateLimiter(60, time.Minute)
mux.Handle(portal.PortalPagePath, portalPageLimiter.Middleware(http.HandlerFunc(portal.HandlePortalPage(deps.MagicLinks, deps.Registry, portalCommercialLookup))))
mux.Handle(portal.PortalPagePath, portalPageLimiter.Middleware(http.HandlerFunc(portal.HandlePortalPage(deps.MagicLinks, deps.Registry, portalCommercialLookup, controlPlaneFaviconHref()))))
}

View file

@ -141,6 +141,9 @@ func TestRegisterRoutes_FaviconRouteParity(t *testing.T) {
if got := icoRec.Header().Get("Location"); got != "/favicon.svg" {
t.Fatalf("GET /favicon.ico location=%q, want %q", got, "/favicon.svg")
}
if got := controlPlaneFaviconHref(); !strings.HasPrefix(got, "/favicon.svg?v=") {
t.Fatalf("controlPlaneFaviconHref=%q, want versioned favicon href", got)
}
}
func TestRegisterRoutes_TrialSignupRoutes(t *testing.T) {

View file

@ -1,13 +1,24 @@
package cloudcp
import (
"crypto/sha256"
_ "embed"
"encoding/hex"
"net/http"
)
//go:embed favicon.svg
var controlPlaneFaviconSVG []byte
var controlPlaneFaviconVersion = func() string {
sum := sha256.Sum256(controlPlaneFaviconSVG)
return hex.EncodeToString(sum[:8])
}()
func controlPlaneFaviconHref() string {
return "/favicon.svg?v=" + controlPlaneFaviconVersion
}
func handleControlPlaneFaviconSVG(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)