diff --git a/apps/trust/index.html b/apps/trust/index.html index 5ca5c8958..dc311f698 100644 --- a/apps/trust/index.html +++ b/apps/trust/index.html @@ -38,7 +38,12 @@ - Compliance Page + {{.Title}} + + + + + diff --git a/pkg/server/server.go b/pkg/server/server.go index 3aa156e27..c0aa8bbdd 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -37,6 +37,7 @@ import ( console_web "go.probo.inc/probo/pkg/server/web" "go.probo.inc/probo/pkg/slack" "go.probo.inc/probo/pkg/trust" + "go.gearno.de/x/ref" ) type Config struct { @@ -97,7 +98,7 @@ func NewServer(cfg Config) (*Server, error) { return nil, err } - trustWebServer, err := trust_web.NewServer() + trustWebServer, err := trust_web.NewServer(compliancePageHeadData(cfg.Trust)) if err != nil { return nil, err } @@ -195,3 +196,25 @@ func (s *Server) TrustCenterHandler() http.Handler { 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), + } + } +} diff --git a/pkg/server/statichandler/statichandler.go b/pkg/server/statichandler/statichandler.go index b7e1bbd13..830cfa895 100644 --- a/pkg/server/statichandler/statichandler.go +++ b/pkg/server/statichandler/statichandler.go @@ -27,20 +27,39 @@ import ( "strings" ) -type GzipOptions struct { - EnableFileTypeCheck bool - FileTypes []string +type ( + // FileRenderer renders dynamic content for a given file path. When + // 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 { - spaFS http.FileSystem - etags map[string]string - indexETag string - indexContent []byte - gzipOptions GzipOptions -} - -func NewServer(staticFiles fs.FS, distPath string, gzipOptions GzipOptions) (*Server, error) { +func NewServer(staticFiles fs.FS, distPath string, gzipOptions GzipOptions, opts ...Option) (*Server, error) { subFS, err := fs.Sub(staticFiles, distPath) if err != nil { return nil, err @@ -104,13 +123,41 @@ func NewServer(staticFiles fs.FS, distPath string, gzipOptions GzipOptions) (*Se return nil, err } - return &Server{ - spaFS: http.FS(subFS), - indexETag: indexETag, - indexContent: indexContent, - etags: etags, - gzipOptions: gzipOptions, - }, nil + s := &Server{ + spaFS: http.FS(subFS), + indexETag: indexETag, + indexContent: indexContent, + etags: etags, + gzipOptions: gzipOptions, + 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) { @@ -118,18 +165,7 @@ func (s *Server) ServeSPA(w http.ResponseWriter, r *http.Request) { f, err := s.spaFS.Open(path) if err != nil { - 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.WriteHeader(http.StatusOK) - _, _ = w.Write(s.indexContent) + s.serveIndex(w, r) return } @@ -142,20 +178,15 @@ func (s *Server) ServeSPA(w http.ResponseWriter, r *http.Request) { } 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("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("Pragma", "no-cache") - w.Header().Set("Expires", "0") - w.WriteHeader(http.StatusOK) - _, _ = w.Write(s.indexContent) + _ = renderer(w, r) return } diff --git a/pkg/server/trust/trust.go b/pkg/server/trust/trust.go index ef6a4714a..3ef85a239 100644 --- a/pkg/server/trust/trust.go +++ b/pkg/server/trust/trust.go @@ -16,29 +16,72 @@ package trust import ( + "fmt" + "html/template" + "io" + "io/fs" "net/http" truststatics "go.probo.inc/probo/apps/trust" "go.probo.inc/probo/pkg/server/statichandler" ) -type Server struct { - *statichandler.Server -} +type ( + 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{ EnableFileTypeCheck: true, 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 { return nil, err } - return &Server{ - Server: spaServer, + return &Server{Server: spaServer}, nil +} + +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 } diff --git a/pkg/trust/service.go b/pkg/trust/service.go index 496ac0217..13b39e7be 100644 --- a/pkg/trust/service.go +++ b/pkg/trust/service.go @@ -273,6 +273,30 @@ func (s *Service) EmailPresenterConfigByOrganizationID(ctx context.Context, orgI 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) { membership := &coredata.TrustCenterAccess{}