diff --git a/Makefile b/Makefile index 365bd56..2605c3a 100644 --- a/Makefile +++ b/Makefile @@ -310,6 +310,9 @@ doctor: ## Print environment diagnostics (shell, versions, generator availabilit $(OGC_BIN) version || true; \ } +fetch-pgweb: ## Fetch PGWeb Binaries for embedding + go run ./tools/pgweb_fetch.go + help: ## Show this help @grep -hE '^[a-zA-Z0-9_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | \ awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' diff --git a/cmd/db.go b/cmd/db.go new file mode 100644 index 0000000..16ebd8c --- /dev/null +++ b/cmd/db.go @@ -0,0 +1,51 @@ +package cmd + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "runtime" + "time" + + "github.com/glueops/autoglue/internal/config" + "github.com/spf13/cobra" +) + +var dbCmd = &cobra.Command{ + Use: "db", + Short: "Database utilities", +} + +var dbPsqlCmd = &cobra.Command{ + Use: "psql", + Short: "Open a psql session to the app database", + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := config.Load() + if err != nil { + return err + } + if cfg.DbURL == "" { + return errors.New("database.url is empty") + } + psql := "psql" + if runtime.GOOS == "windows" { + psql = "psql.exe" + } + + ctx, cancel := context.WithTimeout(context.Background(), 72*time.Hour) + defer cancel() + + psqlCmd := exec.CommandContext(ctx, psql, cfg.DbURL) + psqlCmd.Stdin, psqlCmd.Stdout, psqlCmd.Stderr = os.Stdin, os.Stdout, os.Stderr + fmt.Println("Launching psql…") + return psqlCmd.Run() + }, +} + +func init() { + dbCmd.AddCommand(dbPsqlCmd) + + rootCmd.AddCommand(dbCmd) +} diff --git a/cmd/serve.go b/cmd/serve.go index bf924a1..6fb9b0b 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -18,6 +18,7 @@ import ( "github.com/glueops/autoglue/internal/auth" "github.com/glueops/autoglue/internal/bg" "github.com/glueops/autoglue/internal/config" + "github.com/glueops/autoglue/internal/web" "github.com/google/uuid" "github.com/spf13/cobra" ) @@ -33,6 +34,8 @@ var serveCmd = &cobra.Command{ return err } + var pgwebInst *web.Pgweb + jobs, err := bg.NewJobs(rt.DB, cfg.DbURL) if err != nil { log.Fatalf("failed to init background jobs: %v", err) @@ -119,7 +122,31 @@ var serveCmd = &cobra.Command{ } }() - r := api.NewRouter(rt.DB, jobs) + var studioHandler http.Handler + r := api.NewRouter(rt.DB, jobs, nil) + + if cfg.DBStudioEnabled { + dbURL := cfg.DbURLRO + if dbURL == "" { + dbURL = cfg.DbURL + } + + pgwebInst, err = web.StartPgweb( + dbURL, + cfg.DBStudioBind, + cfg.DBStudioPort, + true, + cfg.DBStudioUser, + cfg.DBStudioPass, + ) + if err != nil { + log.Printf("pgweb failed to start: %v", err) + } else { + studioHandler = http.HandlerFunc(pgwebInst.Proxy()) + r = api.NewRouter(rt.DB, jobs, studioHandler) + log.Printf("pgweb running on http://%s:%s (proxied at /db-studio/)", cfg.DBStudioBind, pgwebInst.Port()) + } + } addr := fmt.Sprintf("%s:%s", cfg.Host, cfg.Port) @@ -143,6 +170,9 @@ var serveCmd = &cobra.Command{ <-ctx.Done() fmt.Println("\n⏳ Shutting down...") + if pgwebInst != nil { + _ = pgwebInst.Stop(context.Background()) + } shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() return srv.Shutdown(shutdownCtx) diff --git a/internal/api/httpmiddleware/auth.go b/internal/api/httpmiddleware/auth.go index 984c18d..efba117 100644 --- a/internal/api/httpmiddleware/auth.go +++ b/internal/api/httpmiddleware/auth.go @@ -49,6 +49,14 @@ func AuthMiddleware(db *gorm.DB, requireOrg bool) func(http.Handler) http.Handle } else if appKey := r.Header.Get("X-APP-KEY"); appKey != "" { secret := r.Header.Get("X-APP-SECRET") user = auth.ValidateAppKeyPair(appKey, secret, db) + } else if c, err := r.Cookie("ag_jwt"); err == nil { + tok := strings.TrimSpace(c.Value) + if strings.HasPrefix(strings.ToLower(tok), "bearer ") { + tok = tok[7:] + } + if tok != "" { + user = auth.ValidateJWT(tok, db) + } } if user == nil { diff --git a/internal/api/routes.go b/internal/api/routes.go index 8fbe262..6674d49 100644 --- a/internal/api/routes.go +++ b/internal/api/routes.go @@ -27,7 +27,7 @@ import ( httpSwagger "github.com/swaggo/http-swagger/v2" ) -func NewRouter(db *gorm.DB, jobs *bg.Jobs) http.Handler { +func NewRouter(db *gorm.DB, jobs *bg.Jobs, studio http.Handler) http.Handler { zerolog.TimeFieldFormat = time.RFC3339 l := log.Output(zerolog.ConsoleWriter{Out: os.Stdout, TimeFormat: "15:04:05"}) @@ -212,6 +212,17 @@ func NewRouter(db *gorm.DB, jobs *bg.Jobs) http.Handler { }) }) }) + + if studio != nil { + r.Group(func(gr chi.Router) { + authUser := httpmiddleware.AuthMiddleware(db, false) + adminOnly := httpmiddleware.RequirePlatformAdmin() + gr.Use(authUser) + gr.Use(adminOnly) + gr.Mount("/db-studio", http.StripPrefix("/db-studio", studio)) + }) + } + if config.IsDebug() { r.Route("/debug/pprof", func(pr chi.Router) { pr.Get("/", httpPprof.Index) @@ -251,6 +262,7 @@ func NewRouter(db *gorm.DB, jobs *bg.Jobs) http.Handler { mux.Handle("/api/", r) mux.Handle("/api", r) mux.Handle("/swagger/", r) + mux.Handle("/db-studio/", r) mux.Handle("/debug/pprof/", r) // Everything else (/, /brand-preview, assets) → proxy (no middlewares) mux.Handle("/", proxy) diff --git a/internal/config/config.go b/internal/config/config.go index 20e50da..7f7c051 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -13,6 +13,7 @@ import ( type Config struct { DbURL string + DbURLRO string Port string Host string JWTIssuer string @@ -29,6 +30,12 @@ type Config struct { Debug bool Swagger bool SwaggerHost string + + DBStudioEnabled bool + DBStudioBind string + DBStudioPort string + DBStudioUser string + DBStudioPass string } var ( @@ -48,6 +55,12 @@ func Load() (Config, error) { v.SetDefault("bind.address", "127.0.0.1") v.SetDefault("bind.port", "8080") v.SetDefault("database.url", "postgres://user:pass@localhost:5432/db?sslmode=disable") + v.SetDefault("database.url_ro", "") + v.SetDefault("db_studio.enabled", false) + v.SetDefault("db_studio.bind", "127.0.0.1") + v.SetDefault("db_studio.port", "0") // 0 = random + v.SetDefault("db_studio.user", "") + v.SetDefault("db_studio.pass", "") v.SetDefault("ui.dev", false) v.SetDefault("env", "development") @@ -63,6 +76,7 @@ func Load() (Config, error) { "bind.address", "bind.port", "database.url", + "database.url_ro", "jwt.issuer", "jwt.audience", "jwt.private.enc.key", @@ -76,6 +90,11 @@ func Load() (Config, error) { "debug", "swagger", "swagger.host", + "db_studio.enabled", + "db_studio.bind", + "db_studio.port", + "db_studio.user", + "db_studio.pass", } for _, k := range keys { _ = v.BindEnv(k) @@ -84,6 +103,7 @@ func Load() (Config, error) { // Build config cfg := Config{ DbURL: v.GetString("database.url"), + DbURLRO: v.GetString("database.url_ro"), Port: v.GetString("bind.port"), Host: v.GetString("bind.address"), JWTIssuer: v.GetString("jwt.issuer"), @@ -100,6 +120,12 @@ func Load() (Config, error) { Debug: v.GetBool("debug"), Swagger: v.GetBool("swagger"), SwaggerHost: v.GetString("swagger.host"), + + DBStudioEnabled: v.GetBool("db_studio.enabled"), + DBStudioBind: v.GetString("db_studio.bind"), + DBStudioPort: v.GetString("db_studio.port"), + DBStudioUser: v.GetString("db_studio.user"), + DBStudioPass: v.GetString("db_studio.pass"), } // Validate diff --git a/internal/handlers/auth.go b/internal/handlers/auth.go index fa690c4..13fe502 100644 --- a/internal/handlers/auth.go +++ b/internal/handlers/auth.go @@ -273,6 +273,21 @@ func AuthCallback(db *gorm.DB) http.HandlerFunc { return } + secure := strings.HasPrefix(cfg.OAuthRedirectBase, "https://") + if xf := r.Header.Get("X-Forwarded-Proto"); xf != "" { + secure = strings.EqualFold(xf, "https") + } + + http.SetCookie(w, &http.Cookie{ + Name: "ag_jwt", + Value: "Bearer " + access, + Path: "/", + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + Secure: secure, + MaxAge: int((time.Hour * 8).Seconds()), + }) + // If the state indicates SPA popup mode, postMessage tokens to the opener and close state := r.URL.Query().Get("state") if strings.Contains(state, "mode=spa") { @@ -377,6 +392,7 @@ func Refresh(db *gorm.DB) http.HandlerFunc { // @Router /auth/logout [post] func Logout(db *gorm.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { + cfg, _ := config.Load() var req dto.LogoutRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { utils.WriteError(w, 400, "invalid_json", err.Error()) @@ -385,13 +401,27 @@ func Logout(db *gorm.DB) http.HandlerFunc { rec, err := auth.ValidateRefreshToken(db, req.RefreshToken) if err != nil { w.WriteHeader(204) // already invalid/revoked - return + goto clearCookie } if err := auth.RevokeFamily(db, rec.FamilyID); err != nil { utils.WriteError(w, 500, "revoke_failed", err.Error()) return } + + clearCookie: + http.SetCookie(w, &http.Cookie{ + Name: "ag_jwt", + Value: "", + Path: "/", + HttpOnly: true, + MaxAge: -1, + Expires: time.Unix(0, 0), + SameSite: http.SameSiteLaxMode, + Secure: strings.HasPrefix(cfg.OAuthRedirectBase, "https"), + }) + w.WriteHeader(204) + } } diff --git a/internal/handlers/clusters.go b/internal/handlers/clusters.go index 28660bc..86619e4 100644 --- a/internal/handlers/clusters.go +++ b/internal/handlers/clusters.go @@ -106,20 +106,20 @@ func clusterToDTO(c models.Cluster) dto.ClusterResponse { } return dto.ClusterResponse{ - ID: c.ID, - Name: c.Name, - Provider: c.Provider, - Region: c.Region, - Status: c.Status, - CaptainDomain: c.CaptainDomain, - ClusterLoadBalancer: c.ClusterLoadBalancer, - RandomToken: c.RandomToken, - CertificateKey: c.CertificateKey, - ControlLoadBalancer: c.ControlLoadBalancer, - NodePools: nps, - BastionServer: bastion, - CreatedAt: c.CreatedAt, - UpdatedAt: c.UpdatedAt, + ID: c.ID, + Name: c.Name, + Provider: c.Provider, + Region: c.Region, + Status: c.Status, + CaptainDomain: c.CaptainDomain, + //ClusterLoadBalancer: c.ClusterLoadBalancer, + RandomToken: c.RandomToken, + CertificateKey: c.CertificateKey, + //ControlLoadBalancer: c.ControlLoadBalancer, + NodePools: nps, + BastionServer: bastion, + CreatedAt: c.CreatedAt, + UpdatedAt: c.UpdatedAt, } } diff --git a/internal/models/cluster.go b/internal/models/cluster.go index 496de26..71400c0 100644 --- a/internal/models/cluster.go +++ b/internal/models/cluster.go @@ -14,17 +14,20 @@ type Cluster struct { Provider string `json:"provider"` Region string `json:"region"` Status string `json:"status"` - CaptainDomain string `gorm:"not null" json:"captain_domain"` - ClusterLoadBalancer string `json:"cluster_load_balancer"` - ControlLoadBalancer string `json:"control_load_balancer"` - RandomToken string `json:"random_token"` - CertificateKey string `json:"certificate_key"` - EncryptedKubeconfig string `gorm:"type:text" json:"-"` - KubeIV string `json:"-"` - KubeTag string `json:"-"` - NodePools []NodePool `gorm:"many2many:cluster_node_pools;constraint:OnDelete:CASCADE" json:"node_pools,omitempty"` - BastionServerID *uuid.UUID `gorm:"type:uuid" json:"bastion_server_id,omitempty"` - BastionServer *Server `gorm:"foreignKey:BastionServerID" json:"bastion_server,omitempty"` - CreatedAt time.Time `json:"created_at,omitempty" gorm:"type:timestamptz;column:created_at;not null;default:now()"` - UpdatedAt time.Time `json:"updated_at,omitempty" gorm:"type:timestamptz;autoUpdateTime;column:updated_at;not null;default:now()"` + CaptainDomain string `gorm:"not null" json:"captain_domain"` // nonprod.earth.onglueops.rocks + AppsLoadBalancer string `json:"cluster_load_balancer"` // {public_ip: 1.2.3.4, private_ip: 10.0.30.1, name: apps.CaqptainDomain} + GlueOpsLoadBalancer string `json:"control_load_balancer"` // {public_ip: 5.6.7.8, private_ip: 10.0.22.1, name: CaptainDomain} + + ControlPlane string `json:"control_plane"` // <- dns cntlpn + + RandomToken string `json:"random_token"` + CertificateKey string `json:"certificate_key"` + EncryptedKubeconfig string `gorm:"type:text" json:"-"` + KubeIV string `json:"-"` + KubeTag string `json:"-"` + NodePools []NodePool `gorm:"many2many:cluster_node_pools;constraint:OnDelete:CASCADE" json:"node_pools,omitempty"` + BastionServerID *uuid.UUID `gorm:"type:uuid" json:"bastion_server_id,omitempty"` + BastionServer *Server `gorm:"foreignKey:BastionServerID" json:"bastion_server,omitempty"` + CreatedAt time.Time `json:"created_at,omitempty" gorm:"type:timestamptz;column:created_at;not null;default:now()"` + UpdatedAt time.Time `json:"updated_at,omitempty" gorm:"type:timestamptz;autoUpdateTime;column:updated_at;not null;default:now()"` } diff --git a/internal/models/dns.go b/internal/models/dns.go deleted file mode 100644 index 4c6c1d4..0000000 --- a/internal/models/dns.go +++ /dev/null @@ -1,20 +0,0 @@ -package models - -import ( - "time" - - "github.com/google/uuid" -) - -type Dns struct { - ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()"` - OrganizationID uuid.UUID `gorm:"type:uuid;not null;uniqueIndex:idx_credentials_org_provider" json:"organization_id"` - Organization Organization `gorm:"foreignKey:OrganizationID;constraint:OnDelete:CASCADE" json:"organization"` - ClusterID *uuid.UUID `gorm:"type:uuid" json:"cluster_id,omitempty"` - Cluster *Cluster `gorm:"foreignKey:ClusterID" json:"cluster,omitempty"` - Type string `gorm:"not null" json:"type,omitempty"` - Name string `gorm:"not null" json:"name,omitempty"` - Content string `gorm:"not null" json:"content,omitempty"` - CreatedAt time.Time `json:"created_at,omitempty" gorm:"type:timestamptz;column:created_at;not null;default:now()"` - UpdatedAt time.Time `json:"updated_at,omitempty" gorm:"type:timestamptz;autoUpdateTime;column:updated_at;not null;default:now()"` -} diff --git a/internal/models/domain.go b/internal/models/domain.go new file mode 100644 index 0000000..8c93835 --- /dev/null +++ b/internal/models/domain.go @@ -0,0 +1,21 @@ +package models + +import ( + "time" + + "github.com/google/uuid" +) + +type Domain struct { + ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()"` + OrganizationID uuid.UUID `gorm:"type:uuid;not null;uniqueIndex:idx_credentials_org_provider" json:"organization_id"` + Organization Organization `gorm:"foreignKey:OrganizationID;constraint:OnDelete:CASCADE" json:"organization"` + ClusterID *uuid.UUID `gorm:"type:uuid" json:"cluster_id,omitempty"` + Cluster *Cluster `gorm:"foreignKey:ClusterID" json:"cluster,omitempty"` + DomainName string `gorm:"not null;index" json:"domain_name,omitempty"` + DomainID string + CredentialID uuid.UUID `gorm:"type:uuid;not null" json:"credential_id"` + Credential Credential `gorm:"foreignKey:CredentialID" json:"credential,omitempty"` + CreatedAt time.Time `json:"created_at,omitempty" gorm:"type:timestamptz;column:created_at;not null;default:now()"` + UpdatedAt time.Time `json:"updated_at,omitempty" gorm:"type:timestamptz;autoUpdateTime;column:updated_at;not null;default:now()"` +} diff --git a/internal/web/pgweb_embed.go b/internal/web/pgweb_embed.go new file mode 100644 index 0000000..97ad673 --- /dev/null +++ b/internal/web/pgweb_embed.go @@ -0,0 +1,85 @@ +package web + +import ( + "crypto/sha256" + "embed" + "encoding/hex" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "runtime" +) + +//go:embed pgwebbin/* +var pgwebFS embed.FS + +type pgwebAsset struct { + Path string + SHA256 string +} + +var pgwebIndex = map[string]pgwebAsset{ + "linux/amd64": {Path: "pgwebbin/pgweb-linux-amd64", SHA256: ""}, + "linux/arm64": {Path: "pgwebbin/pgweb-linux-arm64", SHA256: ""}, + "darwin/amd64": {Path: "pgwebbin/pgweb-darwin-amd64", SHA256: ""}, + "darwin/arm64": {Path: "pgwebbin/pgweb-darwin-arm64", SHA256: ""}, +} + +func ExtractPgweb() (string, error) { + key := runtime.GOOS + "/" + runtime.GOARCH + as, ok := pgwebIndex[key] + if !ok { + return "", fmt.Errorf("pgweb not embedded for %s", key) + } + f, err := pgwebFS.Open(as.Path) + if err != nil { + return "", fmt.Errorf("embedded pgweb missing: %w", err) + } + defer f.Close() + + tmpDir, err := os.MkdirTemp("", "pgweb-*") + if err != nil { + return "", err + } + + filename := "pgweb" + if runtime.GOOS == "windows" { + filename += ".exe" + } + outPath := filepath.Join(tmpDir, filename) + + out, err := os.OpenFile(outPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o700) + if err != nil { + return "", err + } + defer out.Close() + + h := sha256.New() + if _, err = io.Copy(io.MultiWriter(out, h), f); err != nil { + return "", err + } + + if as.SHA256 != "" { + got := hex.EncodeToString(h.Sum(nil)) + if got != as.SHA256 { + return "", fmt.Errorf("pgweb checksum mismatch: got=%s want=%s", got, as.SHA256) + } + } + + // Make sure it’s executable on Unix; Windows ignores this. + _ = os.Chmod(outPath, 0o700) + return outPath, nil +} + +func CleanupPgweb(pgwebPath string) error { + if pgwebPath == "" { + return nil + } + dir := filepath.Dir(pgwebPath) + if dir == "" || dir == "/" || dir == "." { + return errors.New("refusing to remove suspicious directory") + } + return os.RemoveAll(dir) +} diff --git a/internal/web/pgweb_proxy.go b/internal/web/pgweb_proxy.go new file mode 100644 index 0000000..8f40e39 --- /dev/null +++ b/internal/web/pgweb_proxy.go @@ -0,0 +1,106 @@ +package web + +import ( + "context" + "fmt" + "net" + "net/http" + "net/http/httputil" + "net/url" + "os" + "os/exec" + "time" +) + +type Pgweb struct { + cmd *exec.Cmd + host string + port string + bin string +} + +func StartPgweb(dbURL, host, port string, readonly bool, user, pass string) (*Pgweb, error) { + // pick random port if 0/empty + if port == "" || port == "0" { + l, err := net.Listen("tcp", net.JoinHostPort(host, "0")) + if err != nil { + return nil, err + } + defer l.Close() + _, p, _ := net.SplitHostPort(l.Addr().String()) + port = p + } + + args := []string{ + "--url", dbURL, + "--bind", host, + "--listen", port, + "--skip-open", + } + if readonly { + args = append(args, "--readonly") + } + if user != "" && pass != "" { + args = append(args, "--auth-user", user, "--auth-pass", pass) + } + + pgwebBinary, err := ExtractPgweb() + if err != nil { + return nil, fmt.Errorf("pgweb extract: %w", err) + } + + cmd := exec.Command(pgwebBinary, args...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + if err := cmd.Start(); err != nil { + return nil, err + } + + // wait for port to be ready + deadline := time.Now().Add(4 * time.Second) + for time.Now().Before(deadline) { + c, err := net.DialTimeout("tcp", net.JoinHostPort(host, port), 200*time.Millisecond) + if err == nil { + _ = c.Close() + return &Pgweb{cmd: cmd, host: host, port: port}, nil + } + time.Sleep(120 * time.Millisecond) + } + // still return object so caller can Stop() + //return &Pgweb{cmd: cmd, host: host, port: port, bin: pgwebBinary}, nil + return nil, fmt.Errorf("pgweb did not become ready on %s:%s", host, port) +} + +func (p *Pgweb) Proxy() http.HandlerFunc { + target, _ := url.Parse("http://" + net.JoinHostPort(p.host, p.port)) + proxy := httputil.NewSingleHostReverseProxy(target) + proxy.FlushInterval = 100 * time.Millisecond + return func(w http.ResponseWriter, r *http.Request) { + r.Host = target.Host + // Let pgweb handle its paths; we mount it at a prefix. + proxy.ServeHTTP(w, r) + } +} + +func (p *Pgweb) Stop(ctx context.Context) error { + if p == nil || p.cmd == nil || p.cmd.Process == nil { + return nil + } + _ = p.cmd.Process.Kill() + done := make(chan struct{}) + go func() { _, _ = p.cmd.Process.Wait(); close(done) }() + select { + case <-done: + if p.bin != "" { + _ = CleanupPgweb(p.bin) + } + case <-ctx.Done(): + return ctx.Err() + } + return nil +} + +func (p *Pgweb) Port() string { + return p.port +} diff --git a/internal/web/pgwebbin/pgweb-darwin-amd64 b/internal/web/pgwebbin/pgweb-darwin-amd64 new file mode 100755 index 0000000..a8c76ca Binary files /dev/null and b/internal/web/pgwebbin/pgweb-darwin-amd64 differ diff --git a/internal/web/pgwebbin/pgweb-darwin-arm64 b/internal/web/pgwebbin/pgweb-darwin-arm64 new file mode 100755 index 0000000..4b181d8 Binary files /dev/null and b/internal/web/pgwebbin/pgweb-darwin-arm64 differ diff --git a/internal/web/pgwebbin/pgweb-linux-amd64 b/internal/web/pgwebbin/pgweb-linux-amd64 new file mode 100755 index 0000000..b2bc338 Binary files /dev/null and b/internal/web/pgwebbin/pgweb-linux-amd64 differ diff --git a/internal/web/pgwebbin/pgweb-linux-arm64 b/internal/web/pgwebbin/pgweb-linux-arm64 new file mode 100755 index 0000000..a3ec3cc Binary files /dev/null and b/internal/web/pgwebbin/pgweb-linux-arm64 differ diff --git a/internal/web/static.go b/internal/web/static.go index 1f7067f..4d5a652 100644 --- a/internal/web/static.go +++ b/internal/web/static.go @@ -61,6 +61,7 @@ func SPAHandler() (http.Handler, error) { if strings.HasPrefix(r.URL.Path, "/api/") || r.URL.Path == "/api" || strings.HasPrefix(r.URL.Path, "/swagger") || + strings.HasPrefix(r.URL.Path, "/db-studio") || strings.HasPrefix(r.URL.Path, "/debug/pprof") { http.NotFound(w, r) return diff --git a/tools/pgweb_fetch.go b/tools/pgweb_fetch.go new file mode 100644 index 0000000..8414d5d --- /dev/null +++ b/tools/pgweb_fetch.go @@ -0,0 +1,171 @@ +//go:build ignore +// +build ignore + +package main + +import ( + "archive/zip" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "net/http" + "os" + "path/filepath" +) + +type Target struct { + Name string + URL string + SHA256 string +} + +const version = "0.16.2" + +func main() { + targets := []Target{ + { + Name: "pgweb-linux-amd64", + URL: fmt.Sprintf("https://github.com/sosedoff/pgweb/releases/download/v%s/pgweb_linux_amd64.zip", version), + SHA256: "", + }, + { + Name: "pgweb-linux-arm64", + URL: fmt.Sprintf("https://github.com/sosedoff/pgweb/releases/download/v%s/pgweb_linux_arm64.zip", version), + SHA256: "", + }, + { + Name: "pgweb-darwin-amd64", + URL: fmt.Sprintf("https://github.com/sosedoff/pgweb/releases/download/v%s/pgweb_darwin_amd64.zip", version), + SHA256: "", + }, + { + Name: "pgweb-darwin-arm64", + URL: fmt.Sprintf("https://github.com/sosedoff/pgweb/releases/download/v%s/pgweb_darwin_arm64.zip", version), + SHA256: "", + }, + } + + outDir := filepath.Join("internal", "web", "pgwebbin") + _ = os.MkdirAll(outDir, 0o755) + + for _, t := range targets { + destZip := filepath.Join(outDir, t.Name+".zip") + fmt.Printf("Downloading %s...\n", t.URL) + if err := downloadFile(destZip, t.URL); err != nil { + panic(err) + } + binPath := filepath.Join(outDir, t.Name) + if err := unzipSingle(destZip, binPath); err != nil { + panic(err) + } + _ = os.Remove(destZip) + + // Make executable + if err := os.Chmod(binPath, 0o755); err != nil { + panic(err) + } + fmt.Printf("Saved %s\n", binPath) + + // Compute checksum + sum, _ := fileSHA256(binPath) + fmt.Printf(" SHA256: %s\n", sum) + } +} + +func downloadFile(dest, url string) error { + resp, err := http.Get(url) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + return fmt.Errorf("bad status: %s", resp.Status) + } + out, err := os.Create(dest) + if err != nil { + return err + } + defer out.Close() + _, err = io.Copy(out, resp.Body) + return err +} + +func fileSHA256(path string) (string, error) { + f, err := os.Open(path) + if err != nil { + return "", err + } + defer f.Close() + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return "", err + } + return hex.EncodeToString(h.Sum(nil)), nil +} + +func unzipSingle(zipPath, outPath string) error { + // minimal unzip: because pgweb zip has only one binary + r, err := os.Open(zipPath) + if err != nil { + return err + } + defer r.Close() + + // use archive/zip + stat, err := os.Stat(zipPath) + if err != nil { + return err + } + return unzipFile(zipPath, outPath, stat.Size()) +} + +func unzipFile(zipFile, outFile string, _ int64) error { + r, err := os.Open(zipFile) + if err != nil { + return err + } + defer r.Close() + fi, _ := r.Stat() + + // rely on standard zip reader + data, err := io.ReadAll(r) + if err != nil { + return err + } + tmpZip := filepath.Join(os.TempDir(), fi.Name()) + if err := os.WriteFile(tmpZip, data, 0o644); err != nil { + return err + } + defer os.Remove(tmpZip) + + zr, err := os.Open(tmpZip) + if err != nil { + return err + } + defer zr.Close() + // extract using standard lib + zr2, err := zip.OpenReader(tmpZip) + if err != nil { + return err + } + defer zr2.Close() + for _, f := range zr2.File { + rc, err := f.Open() + if err != nil { + return err + } + defer rc.Close() + out, err := os.Create(outFile) + if err != nil { + return err + } + if _, err := io.Copy(out, rc); err != nil { + out.Close() + return err + } + out.Close() + break + } + return nil +} diff --git a/ui/vite.config.ts b/ui/vite.config.ts index d183cfe..572ea19 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -1,7 +1,7 @@ import path from "path" import tailwindcss from "@tailwindcss/vite" -import { defineConfig } from 'vite' -import react from '@vitejs/plugin-react' +import { defineConfig } from "vite" +import react from "@vitejs/plugin-react" // https://vite.dev/config/ export default defineConfig({ @@ -16,6 +16,7 @@ export default defineConfig({ proxy: { "/api": "http://localhost:8080", "/swagger": "http://localhost:8080", + "/db-studio": "http://localhost:8080", }, allowedHosts: ['.getexposed.io'] },