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:
@@ -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">
|
||||||
|
|||||||
@@ -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),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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{}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user