From f3194def05fdc949f0dfc0e073b624f8a41973e1 Mon Sep 17 00:00:00 2001 From: Sacha Al Himdani Date: Tue, 30 Sep 2025 16:36:31 +0200 Subject: [PATCH] Embed trust center v2 in the go backend Signed-off-by: Sacha Al Himdani --- .goreleaser.yaml | 2 +- GNUmakefile | 11 +- apps/trust/trust.go | 20 ++ apps/trust/vite.config.ts | 4 + pkg/server/server.go | 28 ++- pkg/server/statichandler/statichandler.go | 237 ++++++++++++++++++++++ pkg/server/trust/trust.go | 51 +++++ pkg/server/web/web.go | 176 +--------------- 8 files changed, 356 insertions(+), 173 deletions(-) create mode 100644 apps/trust/trust.go create mode 100644 pkg/server/statichandler/statichandler.go create mode 100644 pkg/server/trust/trust.go diff --git a/.goreleaser.yaml b/.goreleaser.yaml index de5c6c844..f9636d6f4 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -5,7 +5,7 @@ project_name: probod before: hooks: - - make @probo/console + - make @probo/console @probo/trust builds: - id: probod diff --git a/GNUmakefile b/GNUmakefile index 987769df0..37e547397 100644 --- a/GNUmakefile +++ b/GNUmakefile @@ -69,7 +69,7 @@ test-bench: TEST_FLAGS+=-bench=. test-bench: test ## Run benchmark tests .PHONY: build -build: @probo/console bin/probod +build: @probo/console @probo/trust bin/probod .PHONY: sbom-docker sbom-docker: docker-build @@ -120,6 +120,12 @@ bin/probod: pkg/server/api/console/v1/schema/schema.go \ $(NPM) --workspace $@ run check $(NPM) --workspace $@ run build +.PHONY: @probo/trust +@probo/trust: NODE_ENV=production +@probo/trust: + $(NPM) --workspace $@ run check + $(NPM) --workspace $@ run build + pkg/server/api/console/v1/schema/schema.go \ pkg/server/api/console/v1/types/types.go \ pkg/server/api/console/v1/v1_resolver.go: pkg/server/api/console/v1/gqlgen.yaml pkg/server/api/console/v1/schema.graphql @@ -149,7 +155,7 @@ fmt-go: ## Format Go code clean: ## Clean the project (node_modules and build artifacts) $(RM) -rf bin/* $(RM) -rf node_modules - $(RM) -rf apps/console/{dist,node_modules} + $(RM) -rf apps/{console,trust}/{dist,node_modules} $(RM) -rf sbom-docker.json sbom.json $(RM) -rf coverage.out coverage.html @@ -176,4 +182,3 @@ goreleaser-snapshot: ## Build a snapshot release with goreleaser .PHONY: goreleaser-check goreleaser-check: ## Check goreleaser configuration goreleaser check - diff --git a/apps/trust/trust.go b/apps/trust/trust.go new file mode 100644 index 000000000..d1605f4c3 --- /dev/null +++ b/apps/trust/trust.go @@ -0,0 +1,20 @@ +// Copyright (c) 2025 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package truststatics + +import "embed" + +//go:embed dist +var StaticFiles embed.FS diff --git a/apps/trust/vite.config.ts b/apps/trust/vite.config.ts index 068c8cdbd..abc29b15d 100644 --- a/apps/trust/vite.config.ts +++ b/apps/trust/vite.config.ts @@ -6,6 +6,10 @@ import { fileURLToPath, URL } from "node:url"; // https://vite.dev/config/ export default defineConfig({ plugins: [react({ babel: { plugins: ["relay"] } }), tailwindcss()], + build: { + assetsDir: "assets", + }, + base: "/trust/", server: { port: 5174, proxy: { diff --git a/pkg/server/server.go b/pkg/server/server.go index ee22ab2db..6d91e20e1 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -24,8 +24,9 @@ import ( "github.com/getprobo/probo/pkg/probo" "github.com/getprobo/probo/pkg/saferedirect" "github.com/getprobo/probo/pkg/server/api" + "github.com/getprobo/probo/pkg/server/trust" "github.com/getprobo/probo/pkg/server/web" - "github.com/getprobo/probo/pkg/trust" + trust_pkg "github.com/getprobo/probo/pkg/trust" "github.com/getprobo/probo/pkg/usrmgr" "github.com/go-chi/chi/v5" "go.gearno.de/kit/log" @@ -37,7 +38,7 @@ type Config struct { ExtraHeaderFields map[string]string Probo *probo.Service Usrmgr *usrmgr.Service - Trust *trust.Service + Trust *trust_pkg.Service Auth api.ConsoleAuthConfig TrustAuth api.TrustAuthConfig ConnectorRegistry *connector.ConnectorRegistry @@ -50,6 +51,7 @@ type Config struct { type Server struct { apiServer *api.Server webServer *web.Server + trustServer *trust.Server router *chi.Mux extraHeaderFields map[string]string } @@ -73,18 +75,25 @@ func NewServer(cfg Config) (*Server, error) { return nil, err } - // Create web server for SPA + // Create web server for console SPA webServer, err := web.NewServer() if err != nil { return nil, err } + // Create trust server for trust SPA + trustServer, err := trust.NewServer() + if err != nil { + return nil, err + } + // Create main router router := chi.NewRouter() server := &Server{ apiServer: apiServer, webServer: webServer, + trustServer: trustServer, router: router, extraHeaderFields: cfg.ExtraHeaderFields, } @@ -107,7 +116,18 @@ func (s *Server) setupRoutes() { s.apiServer.ServeHTTP(w, r) })) - // All other routes go to the SPA frontend + // Trust routes go to the trust SPA + s.router.Route("/trust", func(r chi.Router) { + r.Mount("/", http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + req.URL.Path = strings.TrimPrefix(req.URL.Path, "/trust") + if req.URL.Path == "" { + req.URL.Path = "/" + } + s.trustServer.ServeHTTP(w, req) + })) + }) + + // All other routes go to the console SPA frontend s.router.Mount("/", s.webServer) } diff --git a/pkg/server/statichandler/statichandler.go b/pkg/server/statichandler/statichandler.go new file mode 100644 index 000000000..2df25b713 --- /dev/null +++ b/pkg/server/statichandler/statichandler.go @@ -0,0 +1,237 @@ +// Copyright (c) 2025 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +// Package statichandler provides functionality for serving SPA (Single Page Application) frontends. +package statichandler + +import ( + "compress/gzip" + "crypto/md5" + "encoding/hex" + "errors" + "fmt" + "io" + "io/fs" + "net/http" + "strings" +) + +type GzipOptions struct { + EnableFileTypeCheck bool + FileTypes []string +} + +func DefaultGzipOptions() GzipOptions { + return GzipOptions{ + EnableFileTypeCheck: true, + FileTypes: []string{".js", ".css", ".html"}, + } +} + +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) { + subFS, err := fs.Sub(staticFiles, distPath) + if err != nil { + return nil, err + } + + etags := make(map[string]string) + + err = fs.WalkDir( + subFS, + ".", + func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + + if d.IsDir() { + return nil + } + + info, err := d.Info() + if err != nil { + return err + } + + content := make([]byte, info.Size()) + file, err := subFS.Open(path) + if err != nil { + return err + } + defer file.Close() + + _, err = file.Read(content) + if err != nil { + return err + } + + hash := md5.Sum(content) + etag := hex.EncodeToString(hash[:]) + etags["/"+path] = etag + + return nil + }, + ) + + if err != nil { + return nil, fmt.Errorf("cannot generate etags: %w", err) + } + + indexETag, ok := etags["/index.html"] + if !ok { + return nil, errors.New("index.html not found") + } + + indexFile, err := subFS.Open("index.html") + if err != nil { + return nil, err + } + + indexContent, err := io.ReadAll(indexFile) + if err != nil { + return nil, err + } + + return &Server{ + spaFS: http.FS(subFS), + indexETag: indexETag, + indexContent: indexContent, + etags: etags, + gzipOptions: gzipOptions, + }, nil +} + +func (s *Server) ServeSPA(w http.ResponseWriter, r *http.Request) { + path := r.URL.Path + + 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.Header().Set("Pragma", "no-cache") + w.Header().Set("Expires", "0") + + w.WriteHeader(http.StatusOK) + w.Write(s.indexContent) + return + } + + defer f.Close() + + info, err := f.Stat() + if err != nil { + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + if info.IsDir() { + 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) + return + } + + etag, ok := s.etags[path] + if !ok { + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + w.Header().Set("ETag", etag) + + if matchETag := r.Header.Get("If-None-Match"); matchETag != "" { + if matchETag == etag { + w.WriteHeader(http.StatusNotModified) + return + } + } + + if strings.HasSuffix(path, ".js") || strings.HasSuffix(path, ".css") || + strings.HasSuffix(path, ".png") || strings.HasSuffix(path, ".jpg") || + strings.HasSuffix(path, ".svg") || strings.HasSuffix(path, ".woff2") { + w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") + } else { + w.Header().Set("Cache-Control", "public, max-age=3600") + } + + http.FileServer(s.spaFS).ServeHTTP(w, r) +} + +type gzipResponseWriter struct { + io.Writer + http.ResponseWriter +} + +func (w gzipResponseWriter) Write(b []byte) (int, error) { + return w.Writer.Write(b) +} + +func (s *Server) shouldCompressWithGzip(r *http.Request) bool { + if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") { + return false + } + + if !s.gzipOptions.EnableFileTypeCheck { + return true + } + + path := r.URL.Path + for _, fileType := range s.gzipOptions.FileTypes { + if strings.HasSuffix(path, fileType) { + return true + } + } + + return false +} + +func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if s.shouldCompressWithGzip(r) { + w.Header().Set("Content-Encoding", "gzip") + gz := gzip.NewWriter(w) + defer gz.Close() + + gzw := gzipResponseWriter{Writer: gz, ResponseWriter: w} + s.ServeSPA(gzw, r) + return + } + + s.ServeSPA(w, r) +} diff --git a/pkg/server/trust/trust.go b/pkg/server/trust/trust.go new file mode 100644 index 000000000..173ce03a8 --- /dev/null +++ b/pkg/server/trust/trust.go @@ -0,0 +1,51 @@ +// Copyright (c) 2025 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +// Package trust provides functionality for serving the trust center SPA frontend. +package trust + +import ( + "net/http" + + truststatics "github.com/getprobo/probo/apps/trust" + "github.com/getprobo/probo/pkg/server/statichandler" +) + +type Server struct { + *statichandler.Server +} + +func NewServer() (*Server, error) { + gzipOptions := statichandler.GzipOptions{ + EnableFileTypeCheck: true, + FileTypes: []string{".js", ".css", ".html"}, + } + + spaServer, err := statichandler.NewServer(truststatics.StaticFiles, "dist", gzipOptions) + if err != nil { + return nil, err + } + + return &Server{ + Server: spaServer, + }, nil +} + +func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { + s.Server.ServeHTTP(w, r) +} + +func (s *Server) ServeSPA(w http.ResponseWriter, r *http.Request) { + s.Server.ServeSPA(w, r) +} diff --git a/pkg/server/web/web.go b/pkg/server/web/web.go index 49cba15a3..7c6647cfe 100644 --- a/pkg/server/web/web.go +++ b/pkg/server/web/web.go @@ -15,189 +15,35 @@ package web import ( - "compress/gzip" - "crypto/md5" - "encoding/hex" - "errors" - "fmt" - "io" - "io/fs" "net/http" - "strings" "github.com/getprobo/probo/apps/console" + "github.com/getprobo/probo/pkg/server/statichandler" ) type Server struct { - spaFS http.FileSystem - etags map[string]string - indexETag string - indexContent []byte + *statichandler.Server } func NewServer() (*Server, error) { - subFS, err := fs.Sub(console.StaticFiles, "dist") - if err != nil { - return nil, err + gzipOptions := statichandler.GzipOptions{ + EnableFileTypeCheck: false, } - etags := make(map[string]string) - - err = fs.WalkDir( - subFS, - ".", - func(path string, d fs.DirEntry, err error) error { - if err != nil { - return err - } - - if d.IsDir() { - return nil - } - - info, err := d.Info() - if err != nil { - return err - } - - content := make([]byte, info.Size()) - file, err := subFS.Open(path) - if err != nil { - return err - } - defer file.Close() - - _, err = file.Read(content) - if err != nil { - return err - } - - hash := md5.Sum(content) - etag := hex.EncodeToString(hash[:]) - etags["/"+path] = etag - - return nil - }, - ) - - if err != nil { - return nil, fmt.Errorf("cannot generate etags: %w", err) - } - - indexETag, ok := etags["/index.html"] - if !ok { - return nil, errors.New("index.html not found") - } - - indexFile, err := subFS.Open("index.html") - if err != nil { - return nil, err - } - - indexContent, err := io.ReadAll(indexFile) + spaServer, err := statichandler.NewServer(console.StaticFiles, "dist", gzipOptions) if err != nil { return nil, err } return &Server{ - spaFS: http.FS(subFS), - indexETag: indexETag, - indexContent: indexContent, - etags: etags, + Server: spaServer, }, nil } -func (s *Server) ServeSPA(w http.ResponseWriter, r *http.Request) { - path := r.URL.Path - - 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.Header().Set("Pragma", "no-cache") - w.Header().Set("Expires", "0") - - w.WriteHeader(http.StatusOK) - w.Write(s.indexContent) - return - } - - defer f.Close() - - info, err := f.Stat() - if err != nil { - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - return - } - - if info.IsDir() { - 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) - return - } - - etag, ok := s.etags[path] - if !ok { - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - return - } - - w.Header().Set("ETag", etag) - - if matchETag := r.Header.Get("If-None-Match"); matchETag != "" { - if matchETag == etag { - w.WriteHeader(http.StatusNotModified) - return - } - } - - if strings.HasSuffix(path, ".js") || strings.HasSuffix(path, ".css") || - strings.HasSuffix(path, ".png") || strings.HasSuffix(path, ".jpg") || - strings.HasSuffix(path, ".svg") || strings.HasSuffix(path, ".woff2") { - w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") - } else { - w.Header().Set("Cache-Control", "public, max-age=3600") - } - - http.FileServer(s.spaFS).ServeHTTP(w, r) -} - -type gzipResponseWriter struct { - io.Writer - http.ResponseWriter -} - -func (w gzipResponseWriter) Write(b []byte) (int, error) { - return w.Writer.Write(b) -} - func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { - if strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") { - w.Header().Set("Content-Encoding", "gzip") - gz := gzip.NewWriter(w) - defer gz.Close() - s.ServeSPA(gzipResponseWriter{Writer: gz, ResponseWriter: w}, r) - return - } - - s.ServeSPA(w, r) + s.Server.ServeHTTP(w, r) +} + +func (s *Server) ServeSPA(w http.ResponseWriter, r *http.Request) { + s.Server.ServeSPA(w, r) }