Embded frontend inside go binary

This will simplify the self hosting of the platform.

Signed-off-by: gearnode <bryan@frimin.fr>
This commit is contained in:
gearnode
2025-03-09 15:30:28 +01:00
parent 2c82dbad39
commit 123a538d82
40 changed files with 527 additions and 262 deletions

View File

@@ -35,26 +35,25 @@ vet:
$(GO) vet ./...
.PHONY: build
build: bin/probod @probo/console docker-build
build: @probo/console bin/probod docker-build
.PHONY: docker-build
docker-build:
$(DOCKER_BUILD) --tag $(DOCKER_IMAGE_NAME):$(DOCKER_TAG_NAME) --file Dockerfile .
.PHONY: bin/probod
bin/probod: pkg/api/console/v1/schema/schema.go pkg/api/console/v1/types/types.go pkg/api/console/v1/v1_resolver.go vet
bin/probod: pkg/server/api/console/v1/schema/schema.go pkg/server/api/console/v1/types/types.go pkg/server/api/console/v1/v1_resolver.go vet
$(GO_BUILD) -o $(PROBOD_BIN) $(PROBOD_SRC)
.PHONY: @probo/console
@probo/console: NODE_ENV=production
@probo/console:
$(NPM) --workspace $@ run typecheck
$(NPM) --workspace $@ run build
pkg/api/console/v1/schema/schema.go \
pkg/api/console/v1/types/types.go \
pkg/api/console/v1/v1_resolver.go: pkg/api/console/v1/gqlgen.yaml pkg/api/console/v1/schema.graphql
$(GO_GENERATE) ./pkg/api/console/v1
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
$(GO_GENERATE) ./pkg/server/api/console/v1
.PHONY: fmt
fmt: fmt-markdown fmt-go
@@ -92,3 +91,4 @@ stack-ps:
.PHONY: psql
psql:
$(DOCKER_COMPOSE) exec postgres psql -U probod -d probod

View File

@@ -0,0 +1,6 @@
POSTHOG_HOST=
POSTHOG_KEY=
FARO_PUSH_URL=
API_SERVER_HOST=

View File

@@ -1 +1,2 @@
!dist/.keep
dist/

20
apps/console/console.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 console
import "embed"
//go:embed dist
var StaticFiles embed.FS

0
apps/console/dist/.keep vendored Normal file
View File

View File

@@ -70,7 +70,7 @@
"relay": {
"src": "src",
"language": "typescript",
"schema": "../../pkg/api/console/v1/schema.graphql",
"schema": "../../pkg/server/api/console/v1/schema.graphql",
"noFutureProofEnums": true,
"customScalarTypes": {
"Datetime": "string",

View File

@@ -11,7 +11,7 @@ const fetchRelay: FetchFunction = async (
request,
variables,
_,
uploadables,
uploadables
) => {
const requestInit: RequestInit = {
method: "POST",
@@ -27,7 +27,7 @@ const fetchRelay: FetchFunction = async (
operationName: request.name,
query: request.text,
variables: variables,
}),
})
);
const uploadableMap: {
@@ -59,7 +59,10 @@ const fetchRelay: FetchFunction = async (
});
}
const response = await fetch(buildEndpoint("/console/v1/query"), requestInit);
const response = await fetch(
buildEndpoint("/api/console/v1/query"),
requestInit
);
const json = await response.json();
@@ -68,8 +71,8 @@ const fetchRelay: FetchFunction = async (
`Error fetching GraphQL query '${
request.name
}' with variables '${JSON.stringify(variables)}': ${JSON.stringify(
json.errors,
)}`,
json.errors
)}`
);
}

View File

@@ -21,7 +21,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const checkAuth = async (): Promise<boolean> => {
try {
// Make a request to an endpoint that requires authentication
const response = await fetch(buildEndpoint("/console/v1/query"), {
const response = await fetch(buildEndpoint("/api/console/v1/query"), {
method: "POST",
credentials: "include",
headers: {

View File

@@ -20,7 +20,7 @@ export default function LoginPage() {
setIsLoading(true);
try {
const response = await fetch(buildEndpoint("/console/v1/auth/login"), {
const response = await fetch(buildEndpoint("/api/console/v1/auth/login"), {
method: "POST",
headers: {
"Content-Type": "application/json",

View File

@@ -32,7 +32,7 @@ export default function RegisterPage() {
setIsLoading(true);
try {
const response = await fetch(buildEndpoint("/console/v1/auth/register"), {
const response = await fetch(buildEndpoint("/api/console/v1/auth/register"), {
method: "POST",
headers: {
"Content-Type": "application/json",

View File

@@ -1,9 +1,15 @@
export function buildEndpoint(path: string): string {
const host = process.env.API_SERVER_HOST!;
if (!host) {
return path;
}
const formattedHost =
host.startsWith("http://") || host.startsWith("https://")
? host
: `https://${host}`;
const url = new URL(formattedHost);
if (path) {

View File

@@ -0,0 +1,47 @@
ALTER TABLE organizations ADD COLUMN tenant_id TEXT;
UPDATE organizations SET tenant_id = id;
ALTER TABLE organizations ALTER COLUMN tenant_id SET NOT NULL;
ALTER TABLE frameworks ADD COLUMN tenant_id TEXT;
UPDATE frameworks f SET tenant_id = f.organization_id;
ALTER TABLE frameworks ALTER COLUMN tenant_id SET NOT NULL;
ALTER TABLE controls ADD COLUMN tenant_id TEXT;
UPDATE controls c SET tenant_id = (SELECT organization_id FROM frameworks f WHERE f.id = c.framework_id);
ALTER TABLE controls ALTER COLUMN tenant_id SET NOT NULL;
ALTER TABLE tasks ADD COLUMN tenant_id TEXT;
UPDATE tasks t SET tenant_id = (SELECT c.tenant_id FROM controls_tasks ct JOIN controls c ON ct.control_id = c.id WHERE ct.task_id = t.id LIMIT 1);
ALTER TABLE tasks ALTER COLUMN tenant_id SET NOT NULL;
ALTER TABLE evidences ADD COLUMN tenant_id TEXT;
UPDATE evidences e SET tenant_id = (SELECT t.tenant_id FROM tasks t WHERE t.id = e.task_id);
ALTER TABLE evidences ALTER COLUMN tenant_id SET NOT NULL;
ALTER TABLE controls_tasks ADD COLUMN tenant_id TEXT;
UPDATE controls_tasks ct SET tenant_id = (SELECT f.tenant_id FROM frameworks f JOIN controls c ON c.framework_id = f.id WHERE c.id = ct.control_id);
ALTER TABLE controls_tasks ALTER COLUMN tenant_id SET NOT NULL;
ALTER TABLE control_state_transitions ADD COLUMN tenant_id TEXT;
UPDATE control_state_transitions cst SET tenant_id = (SELECT c.tenant_id FROM controls c WHERE c.id = cst.control_id);
ALTER TABLE control_state_transitions ALTER COLUMN tenant_id SET NOT NULL;
ALTER TABLE task_state_transitions ADD COLUMN tenant_id TEXT;
UPDATE task_state_transitions tst SET tenant_id = (SELECT t.tenant_id FROM tasks t WHERE t.id = tst.task_id);
ALTER TABLE task_state_transitions ALTER COLUMN tenant_id SET NOT NULL;
ALTER TABLE evidence_state_transitions ADD COLUMN tenant_id TEXT;
UPDATE evidence_state_transitions est SET tenant_id = (SELECT e.tenant_id FROM evidences e WHERE e.id = est.evidence_id);
ALTER TABLE evidence_state_transitions ALTER COLUMN tenant_id SET NOT NULL;
ALTER TABLE peoples ADD COLUMN tenant_id TEXT;
UPDATE peoples p SET tenant_id = p.organization_id;
ALTER TABLE peoples ALTER COLUMN tenant_id SET NOT NULL;
ALTER TABLE vendors ADD COLUMN tenant_id TEXT;
UPDATE vendors v SET tenant_id = v.organization_id;
ALTER TABLE vendors ALTER COLUMN tenant_id SET NOT NULL;
ALTER TABLE policies ADD COLUMN tenant_id TEXT;
UPDATE policies p SET tenant_id = p.organization_id;
ALTER TABLE policies ALTER COLUMN tenant_id SET NOT NULL;

View File

@@ -24,10 +24,10 @@ import (
"time"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/getprobo/probo/pkg/api"
console_v1 "github.com/getprobo/probo/pkg/api/console/v1"
"github.com/getprobo/probo/pkg/awsconfig"
"github.com/getprobo/probo/pkg/probo"
"github.com/getprobo/probo/pkg/server"
console_v1 "github.com/getprobo/probo/pkg/server/api/console/v1"
"github.com/getprobo/probo/pkg/usrmgr"
"github.com/prometheus/client_golang/prometheus"
"go.gearno.de/kit/httpclient"
@@ -157,11 +157,11 @@ func (impl *Implm) Run(
return fmt.Errorf("cannot create probo service: %w", err)
}
apiServer, err := api.NewServer(
api.Config{
serverHandler, err := server.NewServer(
server.Config{
AllowedOrigins: impl.cfg.Api.Cors.AllowedOrigins,
Probo: proboService,
Usrmgr: usrmgrService,
AllowedOrigins: impl.cfg.Api.Cors.AllowedOrigins,
Auth: console_v1.AuthConfig{
CookieName: impl.cfg.Auth.CookieName,
CookieSecure: impl.cfg.Auth.CookieSecure,
@@ -174,7 +174,7 @@ func (impl *Implm) Run(
},
)
if err != nil {
return fmt.Errorf("cannot create api server: %w", err)
return fmt.Errorf("cannot create server: %w", err)
}
apiServerCtx, stopApiServer := context.WithCancel(context.Background())
@@ -182,7 +182,7 @@ func (impl *Implm) Run(
wg.Add(1)
go func() {
defer wg.Done()
if err := impl.runApiServer(apiServerCtx, l, r, tp, apiServer); err != nil {
if err := impl.runApiServer(apiServerCtx, l, r, tp, serverHandler); err != nil {
cancel(fmt.Errorf("api server crashed: %w", err))
}
}()

View File

@@ -18,8 +18,8 @@ import (
"errors"
"net/http"
console_v1 "github.com/getprobo/probo/pkg/api/console/v1"
"github.com/getprobo/probo/pkg/probo"
console_v1 "github.com/getprobo/probo/pkg/server/api/console/v1"
"github.com/getprobo/probo/pkg/usrmgr"
"github.com/go-chi/chi/v5"
"github.com/go-chi/cors"

View File

@@ -21,10 +21,10 @@ call_argument_directives_with_null: true
models:
ID:
model:
- "github.com/getprobo/probo/pkg/api/console/v1/types.GIDScalar"
- "github.com/getprobo/probo/pkg/server/api/console/v1/types.GIDScalar"
Datetime:
model:
- "github.com/99designs/gqlgen/graphql.Time"
CursorKey:
model:
- "github.com/getprobo/probo/pkg/api/console/v1/types.CursorKeyScalar"
- "github.com/getprobo/probo/pkg/server/api/console/v1/types.CursorKeyScalar"

View File

@@ -27,9 +27,9 @@ import (
"github.com/99designs/gqlgen/graphql/handler/extension"
"github.com/99designs/gqlgen/graphql/handler/transport"
"github.com/99designs/gqlgen/graphql/playground"
"github.com/getprobo/probo/pkg/api/console/v1/schema"
"github.com/getprobo/probo/pkg/gid"
"github.com/getprobo/probo/pkg/probo"
"github.com/getprobo/probo/pkg/server/api/console/v1/schema"
"github.com/getprobo/probo/pkg/usrmgr"
"github.com/getprobo/probo/pkg/usrmgr/coredata"
"github.com/go-chi/chi/v5"

View File

@@ -9,12 +9,12 @@ import (
"fmt"
"time"
"github.com/getprobo/probo/pkg/api/console/v1/schema"
"github.com/getprobo/probo/pkg/api/console/v1/types"
"github.com/getprobo/probo/pkg/gid"
"github.com/getprobo/probo/pkg/page"
"github.com/getprobo/probo/pkg/probo"
"github.com/getprobo/probo/pkg/probo/coredata"
"github.com/getprobo/probo/pkg/server/api/console/v1/schema"
"github.com/getprobo/probo/pkg/server/api/console/v1/types"
"github.com/vektah/gqlparser/v2/gqlerror"
)

99
pkg/server/server.go Normal file
View File

@@ -0,0 +1,99 @@
// 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 server provides functionality for serving the SPA frontend.
package server
import (
"net/http"
"strings"
"github.com/getprobo/probo/pkg/probo"
"github.com/getprobo/probo/pkg/server/api"
console_v1 "github.com/getprobo/probo/pkg/server/api/console/v1"
"github.com/getprobo/probo/pkg/server/web"
"github.com/getprobo/probo/pkg/usrmgr"
"github.com/go-chi/chi/v5"
)
// Config holds the configuration for the server
type Config struct {
AllowedOrigins []string
Probo *probo.Service
Usrmgr *usrmgr.Service
Auth console_v1.AuthConfig
}
// Server represents the main server that handles both API and frontend requests
type Server struct {
apiServer *api.Server
webServer *web.Server
router *chi.Mux
}
// NewServer creates a new server instance
func NewServer(cfg Config) (*Server, error) {
// Create API server
apiCfg := api.Config{
AllowedOrigins: cfg.AllowedOrigins,
Probo: cfg.Probo,
Usrmgr: cfg.Usrmgr,
Auth: cfg.Auth,
}
apiServer, err := api.NewServer(apiCfg)
if err != nil {
return nil, err
}
// Create web server for SPA
webServer, err := web.NewServer()
if err != nil {
return nil, err
}
// Create main router
router := chi.NewRouter()
server := &Server{
apiServer: apiServer,
webServer: webServer,
router: router,
}
// Set up routes
server.setupRoutes()
return server, nil
}
// setupRoutes configures the routing for the server
func (s *Server) setupRoutes() {
// API routes under /api
s.router.Mount("/api", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Strip the /api prefix from the path
r.URL.Path = strings.TrimPrefix(r.URL.Path, "/api")
if r.URL.Path == "" {
r.URL.Path = "/"
}
s.apiServer.ServeHTTP(w, r)
}))
// All other routes go to the SPA frontend
s.router.Mount("/", s.webServer)
}
// ServeHTTP implements the http.Handler interface
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
s.router.ServeHTTP(w, r)
}

83
pkg/server/web/web.go Normal file
View File

@@ -0,0 +1,83 @@
// 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 web
import (
"io/fs"
"log"
"net/http"
"github.com/getprobo/probo/apps/console"
)
type Server struct {
spaFS http.FileSystem
}
func NewServer() (*Server, error) {
subFS, err := fs.Sub(console.StaticFiles, "dist")
if err != nil {
return nil, err
}
log.Printf("Using embedded SPA")
return &Server{
spaFS: http.FS(subFS),
}, nil
}
func (s *Server) ServeSPA(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
if path == "/" {
path = "/index.html"
}
f, err := s.spaFS.Open(path)
if err == nil {
defer f.Close()
http.FileServer(s.spaFS).ServeHTTP(w, r)
return
}
indexFile, err := s.spaFS.Open("/index.html")
if err != nil {
http.Error(w, "Not Found", http.StatusNotFound)
return
}
defer indexFile.Close()
stat, err := indexFile.Stat()
if err != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
content := make([]byte, stat.Size())
_, err = indexFile.Read(content)
if err != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusOK)
w.Write(content)
}
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
s.ServeSPA(w, r)
}