Add SSR for compliance page with dynamic title and meta tags

Implement server-side rendering of trust center page `<head>` with dynamic organization name and OG meta tags. Adds generic `FileRenderer` mechanism to statichandler for dynamic file rendering, allowing the trust server to inject templated content at request time.

Signed-off-by: Bryan Frimin <bryan@getprobo.com>
This commit is contained in:
Bryan Frimin
2026-03-18 15:55:18 +01:00
parent ea21f85887
commit 751d9eb1f3
5 changed files with 177 additions and 51 deletions

View File

@@ -38,7 +38,12 @@
<meta name="msapplication-square310x310logo" content="/favicons/mstile-310x310.png" /> <meta name="msapplication-square310x310logo" content="/favicons/mstile-310x310.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Compliance Page</title> <title>{{.Title}}</title>
<meta name="description" content="{{.Description}}">
<meta property="og:title" content="{{.Title}}">
<meta property="og:description" content="{{.Description}}">
<meta property="og:url" content="{{.OGURL}}">
<meta property="og:type" content="website">
</head> </head>
<body class="text-txt-primary bg-level-0"> <body class="text-txt-primary bg-level-0">

View File

@@ -37,6 +37,7 @@ import (
console_web "go.probo.inc/probo/pkg/server/web" console_web "go.probo.inc/probo/pkg/server/web"
"go.probo.inc/probo/pkg/slack" "go.probo.inc/probo/pkg/slack"
"go.probo.inc/probo/pkg/trust" "go.probo.inc/probo/pkg/trust"
"go.gearno.de/x/ref"
) )
type Config struct { type Config struct {
@@ -97,7 +98,7 @@ func NewServer(cfg Config) (*Server, error) {
return nil, err return nil, err
} }
trustWebServer, err := trust_web.NewServer() trustWebServer, err := trust_web.NewServer(compliancePageHeadData(cfg.Trust))
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -195,3 +196,25 @@ func (s *Server) TrustCenterHandler() http.Handler {
return r return r
} }
func compliancePageHeadData(trustService *trust.Service) trust_web.HeadDataFunc {
return func(r *http.Request) trust_web.HeadData {
tc := compliancepage.CompliancePageFromContext(r.Context())
if tc == nil {
return trust_web.HeadData{Title: "Compliance Page"}
}
org, err := trustService.GetOrganizationByTrustCenterID(r.Context(), tc.ID)
if err != nil || org == nil {
return trust_web.HeadData{Title: "Compliance Page"}
}
baseURL := compliancepage.CompliancePageBaseURLFromContext(r.Context())
return trust_web.HeadData{
Title: org.Name + " — Compliance",
Description: org.Name + " Compliance Page",
OGURL: ref.UnrefOrZero(baseURL),
}
}
}

View File

@@ -27,20 +27,39 @@ import (
"strings" "strings"
) )
type GzipOptions struct { type (
EnableFileTypeCheck bool // FileRenderer renders dynamic content for a given file path. When
FileTypes []string // registered via WithFileRenderer, the server calls it instead of serving
// the static embedded bytes. The renderer writes the response body to w.
FileRenderer func(w io.Writer, r *http.Request) error
Option func(*Server)
GzipOptions struct {
EnableFileTypeCheck bool
FileTypes []string
}
Server struct {
spaFS http.FileSystem
etags map[string]string
indexETag string
indexContent []byte
gzipOptions GzipOptions
fileRenderers map[string]FileRenderer
}
)
// WithFileRenderer registers a dynamic renderer for the given path (e.g.
// "/index.html"). When the server would serve that file, it calls the
// renderer instead. ETag-based caching is disabled for rendered files.
func WithFileRenderer(path string, renderer FileRenderer) Option {
return func(s *Server) {
s.fileRenderers[path] = renderer
}
} }
type Server struct { func NewServer(staticFiles fs.FS, distPath string, gzipOptions GzipOptions, opts ...Option) (*Server, error) {
spaFS http.FileSystem
etags map[string]string
indexETag string
indexContent []byte
gzipOptions GzipOptions
}
func NewServer(staticFiles fs.FS, distPath string, gzipOptions GzipOptions) (*Server, error) {
subFS, err := fs.Sub(staticFiles, distPath) subFS, err := fs.Sub(staticFiles, distPath)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -104,13 +123,41 @@ func NewServer(staticFiles fs.FS, distPath string, gzipOptions GzipOptions) (*Se
return nil, err return nil, err
} }
return &Server{ s := &Server{
spaFS: http.FS(subFS), spaFS: http.FS(subFS),
indexETag: indexETag, indexETag: indexETag,
indexContent: indexContent, indexContent: indexContent,
etags: etags, etags: etags,
gzipOptions: gzipOptions, gzipOptions: gzipOptions,
}, nil fileRenderers: make(map[string]FileRenderer),
}
for _, opt := range opts {
opt(s)
}
return s, nil
}
func (s *Server) serveIndex(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
if renderer, ok := s.fileRenderers["/index.html"]; ok {
w.WriteHeader(http.StatusOK)
_ = renderer(w, r)
return
}
w.Header().Set("ETag", `"`+s.indexETag+`"`)
if r.Header.Get("If-None-Match") == `"`+s.indexETag+`"` {
w.WriteHeader(http.StatusNotModified)
return
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write(s.indexContent)
} }
func (s *Server) ServeSPA(w http.ResponseWriter, r *http.Request) { func (s *Server) ServeSPA(w http.ResponseWriter, r *http.Request) {
@@ -118,18 +165,7 @@ func (s *Server) ServeSPA(w http.ResponseWriter, r *http.Request) {
f, err := s.spaFS.Open(path) f, err := s.spaFS.Open(path)
if err != nil { if err != nil {
w.Header().Set("Content-Type", "text/html; charset=utf-8") s.serveIndex(w, r)
w.Header().Set("ETag", `"`+s.indexETag+`"`)
if r.Header.Get("If-None-Match") == `"`+s.indexETag+`"` {
w.WriteHeader(http.StatusNotModified)
return
}
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(s.indexContent)
return return
} }
@@ -142,20 +178,15 @@ func (s *Server) ServeSPA(w http.ResponseWriter, r *http.Request) {
} }
if info.IsDir() { if info.IsDir() {
s.serveIndex(w, r)
return
}
if renderer, ok := s.fileRenderers[path]; ok {
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("ETag", `"`+s.indexETag+`"`)
if r.Header.Get("If-None-Match") == `"`+s.indexETag+`"` {
w.WriteHeader(http.StatusNotModified)
return
}
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
w.Header().Set("Pragma", "no-cache")
w.Header().Set("Expires", "0")
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
_, _ = w.Write(s.indexContent) _ = renderer(w, r)
return return
} }

View File

@@ -16,29 +16,72 @@
package trust package trust
import ( import (
"fmt"
"html/template"
"io"
"io/fs"
"net/http" "net/http"
truststatics "go.probo.inc/probo/apps/trust" truststatics "go.probo.inc/probo/apps/trust"
"go.probo.inc/probo/pkg/server/statichandler" "go.probo.inc/probo/pkg/server/statichandler"
) )
type Server struct { type (
*statichandler.Server HeadData struct {
} Title string
Description string
OGURL string
}
HeadDataFunc func(r *http.Request) HeadData
Server struct {
*statichandler.Server
}
)
func NewServer(headDataFunc HeadDataFunc) (*Server, error) {
renderer, err := buildIndexRenderer(headDataFunc)
if err != nil {
return nil, err
}
func NewServer() (*Server, error) {
gzipOptions := statichandler.GzipOptions{ gzipOptions := statichandler.GzipOptions{
EnableFileTypeCheck: true, EnableFileTypeCheck: true,
FileTypes: []string{".js", ".css", ".html"}, FileTypes: []string{".js", ".css", ".html"},
} }
spaServer, err := statichandler.NewServer(truststatics.StaticFiles, "dist", gzipOptions) spaServer, err := statichandler.NewServer(
truststatics.StaticFiles,
"dist",
gzipOptions,
statichandler.WithFileRenderer("/index.html", renderer),
)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return &Server{ return &Server{Server: spaServer}, nil
Server: spaServer, }
func buildIndexRenderer(headDataFunc HeadDataFunc) (statichandler.FileRenderer, error) {
subFS, err := fs.Sub(truststatics.StaticFiles, "dist")
if err != nil {
return nil, fmt.Errorf("cannot open dist: %w", err)
}
indexBytes, err := fs.ReadFile(subFS, "index.html")
if err != nil {
return nil, fmt.Errorf("cannot read index.html: %w", err)
}
tmpl, err := template.New("index").Parse(string(indexBytes))
if err != nil {
return nil, fmt.Errorf("cannot parse index.html template: %w", err)
}
return func(w io.Writer, r *http.Request) error {
return tmpl.Execute(w, headDataFunc(r))
}, nil }, nil
} }

View File

@@ -273,6 +273,30 @@ func (s *Service) EmailPresenterConfigByOrganizationID(ctx context.Context, orgI
return s.WithTenant(orgID.TenantID()).TrustCenters.EmailPresenterConfig(ctx, trustCenter.ID) return s.WithTenant(orgID.TenantID()).TrustCenters.EmailPresenterConfig(ctx, trustCenter.ID)
} }
func (s *Service) GetOrganizationByTrustCenterID(
ctx context.Context,
trustCenterID gid.GID,
) (*coredata.Organization, error) {
trustCenter, err := s.Get(ctx, trustCenterID)
if err != nil {
return nil, fmt.Errorf("cannot load trust center: %w", err)
}
org := &coredata.Organization{}
err = s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
return org.LoadByID(ctx, conn, coredata.NewNoScope(), trustCenter.OrganizationID)
},
)
if err != nil {
return nil, fmt.Errorf("cannot load organization: %w", err)
}
return org, nil
}
func (s *Service) GetMembershipByCompliancePageIDAndIdentityID(ctx context.Context, compliancePageID gid.GID, identityID gid.GID) (*coredata.TrustCenterAccess, error) { func (s *Service) GetMembershipByCompliancePageIDAndIdentityID(ctx context.Context, compliancePageID gid.GID, identityID gid.GID) (*coredata.TrustCenterAccess, error) {
membership := &coredata.TrustCenterAccess{} membership := &coredata.TrustCenterAccess{}