mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-08-22 07:23:27 +00:00
Version portal favicon href for cache busting
This commit is contained in:
parent
78ec9d2946
commit
022a2be763
9 changed files with 36 additions and 11 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>' +
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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()))))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue