Embed trust center v2 in the go backend

Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
Sacha Al Himdani
2025-09-30 16:36:31 +02:00
parent e32927ccf6
commit f3194def05
8 changed files with 356 additions and 173 deletions

View File

@@ -5,7 +5,7 @@ project_name: probod
before:
hooks:
- make @probo/console
- make @probo/console @probo/trust
builds:
- id: probod

View File

@@ -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

20
apps/trust/trust.go Normal file
View File

@@ -0,0 +1,20 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// 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

View File

@@ -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: {

View File

@@ -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)
}

View File

@@ -0,0 +1,237 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// 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)
}

51
pkg/server/trust/trust.go Normal file
View File

@@ -0,0 +1,51 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// 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)
}

View File

@@ -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)
}