diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 8baf33cc..b8b9d4ae 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -736,7 +736,11 @@ as the desired state: - **Branding (`branding_settings.go`, migrations 00029/00050):** instance-wide `business_name` + `logo_url` + `banner_url` on the singleton row. Business name is the wordmark fallback (defaults to "Calnode") + public-page header; the logo is the email - header image + public-page header. `GET/PATCH /v1/settings/branding` (name + opacity + header image + public-page header. Booking, manage, person (`/u/{handle}`) and team + (`/team/{slug}`) directories share the `businessHeader` partial, using the configured + logo height (scaled by `pageLogoHeight`) and opacity, or the business name when no + logo is set. Directory avatars remain inside the person/team card. + `GET/PATCH /v1/settings/branding` (name + opacity settings only); the logo and banner are each an **upload** (`POST/DELETE /v1/settings/branding/logo` and `.../banner`, public serve at `GET /branding/logo` / `GET /branding/banner`) reusing the avatar pipeline: @@ -877,7 +881,10 @@ Each account can set `booking_accent` in its profile. The default preserves the 8. **Public-page CSP is dynamic** — `publicCSP()` (tracking_settings.go) returns the strict default and relaxes only when head code-injection is configured (broad `https:` or the operator's `tracking_csp_allow`). Don't re-hardcode the CSP on the - `book`/`manage` handlers — route it through `publicCSP`. + `book`/`manage`/directory handlers — route it through `publicCSP`. Person and + team directories render the admin-configured head HTML once, after the shared + tracking partial, matching booking/manage pages. Native GA4/GTM tags use the + same consent banner on directories, including restoring stored consent. 9. **`FRAME_ANCESTORS` is the admin SPA's only, and it must stay that way.** Set it (space-separated `https://host[:port]` / `'self'`) and the handler under `/admin/` sends `Content-Security-Policy: frame-ancestors ` so an operator can embed the diff --git a/internal/handler/branding_settings.go b/internal/handler/branding_settings.go index 98e3f188..42017919 100644 --- a/internal/handler/branding_settings.go +++ b/internal/handler/branding_settings.go @@ -27,7 +27,7 @@ const logoServePath = "/branding/logo" const bannerServePath = "/branding/banner" // brandingSettings is the instance-wide brand identity used in emails and on the -// public booking/manage pages. +// public booking/manage/directory pages. type brandingSettings struct { BusinessName string LogoURL string // served path (relative), e.g. "/branding/logo?v=123"; empty = no logo @@ -69,7 +69,7 @@ func (h *Handler) loadBranding(ctx context.Context) brandingSettings { } // pageLogoHeight scales the email logo height up ~1.3× for the roomier public -// booking/manage page headers. +// booking/manage/directory page headers. func pageLogoHeight(emailPx int) int { if emailPx <= 0 { emailPx = 28 diff --git a/internal/handler/directory.go b/internal/handler/directory.go index cb76df94..e8d52f0b 100644 --- a/internal/handler/directory.go +++ b/internal/handler/directory.go @@ -46,6 +46,8 @@ type directoryPageData struct { GA4MeasurementID string BusinessName string LogoURL string + LogoHeight int + LogoOpacity string PrivacyURL string TermsURL string DemoMode bool @@ -98,10 +100,13 @@ func (h *Handler) renderDirectory(w http.ResponseWriter, r *http.Request, data d data.GA4MeasurementID = track.GA4MeasurementID data.BusinessName = brand.BusinessName data.LogoURL = brand.LogoURL + data.LogoHeight = pageLogoHeight(brand.LogoHeight) + data.LogoOpacity = opacityCSS(brand.LogoOpacity) data.PrivacyURL = brand.PrivacyURL data.TermsURL = brand.TermsURL data.DemoMode = h.demoMode w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Content-Security-Policy", publicCSP(track)) w.Header().Set("Vary", "Accept-Language, Cookie") // see the same header in book.go's BookPage if err := directoryTmpl.Execute(w, data); err != nil { h.logger.ErrorContext(r.Context(), "directory: render", "error", err) diff --git a/internal/handler/directory_tracking_test.go b/internal/handler/directory_tracking_test.go new file mode 100644 index 00000000..319fb2c5 --- /dev/null +++ b/internal/handler/directory_tracking_test.go @@ -0,0 +1,128 @@ +package handler_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestDirectoryPages_headHTMLAndCSP(t *testing.T) { + const injectedHTML = `` + const strictCSP = "default-src 'self'; script-src 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self'; frame-ancestors 'none'" + for _, tc := range []struct { + name, headHTML, allow, gtm, ga4 string + strict bool + }{ + {"empty", "", "", "", "", true}, + {"whitespace", " \n\t", "https://custom.example", "", "", true}, + {"headHTML", injectedHTML, "", "", "", false}, + {"allowlistedHeadHTML", injectedHTML, "https://custom.example", "", "", false}, + {"nativeTags", "", "https://custom.example", "GTM-TEST123", "G-TEST123", false}, + {"headHTMLAndNativeTags", injectedHTML, "https://custom.example", "GTM-TEST123", "G-TEST123", false}, + } { + t.Run(tc.name, func(t *testing.T) { + h, database, key, userID := setupWorkspaceWithDB(t) + // Use the authenticated admin setting endpoint, the only source of trusted HTML. + settings, err := json.Marshal(map[string]string{ + "head_html": tc.headHTML, "csp_allow": tc.allow, + "gtm_container_id": tc.gtm, "ga4_measurement_id": tc.ga4, + }) + if err != nil { + t.Fatal(err) + } + rec := httptest.NewRecorder() + h.RequireAuth(h.PatchTrackingSettings)(rec, authReq(http.MethodPatch, "/v1/settings/tracking", string(settings), key)) + if rec.Code != http.StatusOK { + t.Fatalf("configure tracking: %d — %s", rec.Code, rec.Body.String()) + } + if _, err := database.Exec(`UPDATE users SET handle = 'host' WHERE id = ?`, userID); err != nil { + t.Fatal(err) + } + if _, err := database.Exec(`INSERT INTO teams (id, name, slug) VALUES ('t1', 'Design', 'design')`); err != nil { + t.Fatal(err) + } + if _, err := database.Exec(`INSERT INTO team_members (id, team_id, user_id) VALUES ('tm1', 't1', ?)`, userID); err != nil { + t.Fatal(err) + } + slug, etID := seedEventTypeHTTP(t, h, key) + if _, err := database.Exec(`UPDATE event_types SET team_id = 't1' WHERE id = ?`, etID); err != nil { + t.Fatal(err) + } + + bookRec := httptest.NewRecorder() + bookReq := httptest.NewRequest(http.MethodGet, "/book/"+slug, nil) + bookReq.SetPathValue("slug", slug) + h.BookPage(bookRec, bookReq) + manageRec := httptest.NewRecorder() + manageReq := httptest.NewRequest(http.MethodGet, "/manage/invalid", nil) + manageReq.SetPathValue("token", "invalid") + h.ManagePage(manageRec, manageReq) + wantCSP := bookRec.Header().Get("Content-Security-Policy") + if bookRec.Code != http.StatusOK || manageRec.Code != http.StatusOK || wantCSP == "" || manageRec.Header().Get("Content-Security-Policy") != wantCSP { + t.Fatal("booking/manage CSP baseline missing or inconsistent") + } + if tc.strict && wantCSP != strictCSP { + t.Fatalf("empty settings must preserve strict CSP: %q", wantCSP) + } + + for _, page := range []struct { + name, path, pathKey, pathValue, title string + serve http.HandlerFunc + }{ + {"person", "/u/host", "handle", "host", "Test Host", h.PersonPage}, + {"team", "/team/design", "slug", "design", "Design", h.TeamPage}, + } { + t.Run(page.name, func(t *testing.T) { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, page.path+"?head_html=untrusted-url-marker", nil) + req.SetPathValue(page.pathKey, page.pathValue) + page.serve(rec, req) + body := rec.Body.String() + if rec.Code != http.StatusOK || !strings.Contains(body, "") { + t.Fatalf("incomplete directory: %d — %.200s", rec.Code, body) + } + if got := rec.Header().Get("Content-Security-Policy"); got != wantCSP { + t.Errorf("CSP = %q; want booking/manage policy %q", got, wantCSP) + } + headStart, headEnd := strings.Index(body, ""), strings.Index(body, "") + if headStart < 0 || headEnd <= headStart { + t.Fatal("missing head element") + } + if strings.TrimSpace(tc.headHTML) != "" { + if strings.Count(body, tc.headHTML) != 1 || !strings.Contains(body[headStart:headEnd], tc.headHTML) { + t.Error("configured HTML must appear unescaped exactly once, inside head") + } + if strings.Index(body, tc.headHTML) > strings.Index(body, `` + page.title + ``, `href="/book/` + slug + `"`} { + if !strings.Contains(body, want) { + t.Errorf("normal directory content missing %q", want) + } + } + if page.name == "team" && !strings.Contains(body, `href="/u/host"`) { + t.Error("team member directory link missing") + } + if tc.gtm != "" { + for _, want := range []string{`gtm: "` + tc.gtm + `"`, `ga4: "` + tc.ga4 + `"`, `id="cookie-banner"`} { + if !strings.Contains(body, want) { + t.Errorf("native tracking markup missing %q", want) + } + } + if tc.headHTML != "" && strings.Index(body, "window.__CALNODE_TRACK") > strings.Index(body, tc.headHTML) { + t.Error("configured head HTML should follow native tracking markup") + } + } + }) + } + }) + } +} diff --git a/internal/handler/public_branding_test.go b/internal/handler/public_branding_test.go new file mode 100644 index 00000000..69a0c7d7 --- /dev/null +++ b/internal/handler/public_branding_test.go @@ -0,0 +1,101 @@ +package handler_test + +import ( + "html" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestPublicPages_businessBranding(t *testing.T) { + for _, tc := range []struct { + name, businessName, logoURL string + height, opacity int + wantHeight, wantOpacity string + }{ + {"logo", `Acme "Studio" & Co`, "/branding/logo?v=123", 40, 60, "52", "0.6"}, + {"logoWithoutName", "", "/branding/logo?v=123", 28, 100, "36", "1"}, + {"nameOnly", `Acme "Studio" & Co`, "", 28, 100, "", ""}, + {"neither", "", "", 28, 100, "", ""}, + {"normalizedSettings", "Acme", "/branding/logo?v=123", 0, 200, "36", "1"}, + } { + t.Run(tc.name, func(t *testing.T) { + h, database, key, userID := setupWorkspaceWithDB(t) + if _, err := database.Exec(`UPDATE server_settings SET business_name = ?, logo_url = ?, logo_height = ?, logo_opacity = ? WHERE id = 1`, + tc.businessName, tc.logoURL, tc.height, tc.opacity); err != nil { + t.Fatal(err) + } + if _, err := database.Exec(`UPDATE users SET handle = 'host', avatar_url = '/avatars/host.png' WHERE id = ?`, userID); err != nil { + t.Fatal(err) + } + if _, err := database.Exec(`INSERT INTO teams (id, name, slug) VALUES ('brand-team', 'Design', 'design')`); err != nil { + t.Fatal(err) + } + slug, etID := seedEventTypeHTTP(t, h, key) + seedBooking(t, database, "brand-booking", etID, userID, "2026-06-20T09:00:00Z", "2026-06-20T09:30:00Z", "confirmed") + token := issueTestToken(t, database, "brand-booking") + for _, page := range []struct { + name, path, pathKey, pathValue string + serve http.HandlerFunc + }{ + {"person", "/u/host", "handle", "host", h.PersonPage}, + {"team", "/team/design", "slug", "design", h.TeamPage}, + {"booking", "/book/" + slug, "slug", slug, h.BookPage}, + {"manage", "/manage/" + token, "token", token, h.ManagePage}, + } { + t.Run(page.name, func(t *testing.T) { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, page.path, nil) + req.SetPathValue(page.pathKey, page.pathValue) + page.serve(rec, req) + body := rec.Body.String() + if rec.Code != http.StatusOK || !strings.Contains(body, "") { + t.Fatalf("incomplete page: status %d, body %.200s", rec.Code, body) + } + start := strings.Index(body, "
") + if end == -1 { + t.Fatal("incomplete business header") + } + header := body[start : start+end] + if tc.logoURL != "" { + alt := tc.businessName + if alt == "" { + alt = "Business logo" + } + for _, want := range []string{ + `src="` + tc.logoURL + `"`, `alt="` + html.EscapeString(alt) + `"`, + "height:" + tc.wantHeight + "px;width:auto;max-width:100%;opacity:" + tc.wantOpacity, + "max-width:100%;box-sizing:border-box;", + } { + if !strings.Contains(header, want) { + t.Errorf("header missing %q: %s", want, header) + } + } + } else if strings.Contains(header, ""+html.EscapeString(tc.businessName)+"") { + t.Errorf("incorrect name-only header: %s", header) + } + } + if page.name == "person" && !strings.Contains(body, ``) { + t.Error("person avatar missing from directory card") + } + if page.name == "team" && !strings.Contains(body, ``) { + t.Error("team avatar missing from directory card") + } + if (page.name == "person" || page.name == "team") && start > strings.Index(body, `
`) { + t.Error("business header should precede the directory card") + } + }) + } + }) + } +} diff --git a/internal/handler/shared_partials.go b/internal/handler/shared_partials.go index fbb7ef0d..38dd9c32 100644 --- a/internal/handler/shared_partials.go +++ b/internal/handler/shared_partials.go @@ -2,11 +2,10 @@ package handler import _ "embed" -// sharedPartialsSrc holds the template partials shared by the book and manage public -// pages (see templates/_shared.html): the consent/tracking/footer chrome +// sharedPartialsSrc holds the template partials shared by the book, manage and directory +// public pages (see templates/_shared.html): the business header and consent/tracking/footer chrome // (trackingHead, consentBanner, legalFooter) plus structural parts (calendarGrid). -// It is parsed into BOTH the book and manage template sets so these pieces have a -// single source and the two surfaces can't drift apart. +// It is parsed into all three template sets so these pieces have a single source. // //go:embed templates/_shared.html var sharedPartialsSrc string diff --git a/internal/handler/templates/_shared.html b/internal/handler/templates/_shared.html index a415e4f1..c4855008 100644 --- a/internal/handler/templates/_shared.html +++ b/internal/handler/templates/_shared.html @@ -1,8 +1,7 @@ {{/* - Partials SHARED by the book and manage public pages — parsed into BOTH template - sets (see book.go / manage_handler.go) so these pieces live in ONE place and the - two surfaces can't drift apart: - - chrome: trackingHead, consentBanner, legalFooter (consent/analytics/footer) + Partials SHARED by the book, manage and directory public pages — parsed into all + three template sets so these pieces live in ONE place: + - chrome: businessHeader, trackingHead, consentBanner, legalFooter - structural: calendarGrid (month nav + the JS-filled day grid) The embed widget (embed.js) is deliberately NOT included: it's a vanilla-JS web @@ -14,6 +13,14 @@ booking-logic.js at runtime). */}} +{{define "businessHeader"}} +{{if or .LogoURL .BusinessName}} +
+ {{if .LogoURL}}{{if .BusinessName}}{{.BusinessName}}{{else}}Business logo{{end}}{{else}}{{.BusinessName}}{{end}} +
+{{end}} +{{end}} + {{define "trackingHead"}}{{if or .GTMContainerID .GA4MeasurementID}}