diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 3d95f132..0679acaa 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -6,6 +6,7 @@ import ( "crypto/rsa" "crypto/x509" "database/sql" + "encoding/json" "encoding/pem" "errors" "fmt" @@ -733,6 +734,17 @@ func registerHandlers(e *echo.Echo, cfg config.Config, db *sql.DB) (*sshinfra.SS rateLimitHandler := handler.NewRateLimitHandler() rootHandler := handler.NewRootHandler() + thirdPartyLicenses := loadLicensesFromFile(cfg.LicensesFilePath) + metaHandler := handler.NewMetaHandler(handler.BuildInfo{ + AppName: cfg.AppName, + Version: version, + GitCommit: commit, + BuildDate: buildTime, + LicenseName: cfg.LicenseName, + SourceURL: cfg.SourceURL, + }, thirdPartyLicenses) + metaHandler.RegisterRoutes(e) + gitHTTPHandler := handler.NewGitHTTPHandler( cfg.GitDataRoot, repoGitResolver, @@ -923,6 +935,22 @@ func registerHandlers(e *echo.Echo, cfg config.Config, db *sql.DB) (*sshinfra.SS return sshServer, nil } +func loadLicensesFromFile(path string) []handler.LicenseEntry { + log := logger.Global() + data, err := os.ReadFile(path) + if err != nil { + log.Warn().Err(err).Str("path", path).Msg("failed to load licenses file") + return []handler.LicenseEntry{} + } + + var entries []handler.LicenseEntry + if err := json.Unmarshal(data, &entries); err != nil { + log.Warn().Err(err).Str("path", path).Msg("failed to parse licenses file") + return []handler.LicenseEntry{} + } + return entries +} + type noopMCPEnqueuer struct{} func (noopMCPEnqueuer) EnqueueMCPVerification(context.Context, queue.MCPVerificationPayload) error { diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 64fc827e..798e07b4 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -44,6 +44,10 @@ type Config struct { TLSCertFile string TLSKeyFile string TrustedProxyCIDRs string + AppName string + LicenseName string + SourceURL string + LicensesFilePath string } func Load() Config { @@ -96,6 +100,10 @@ func Load() Config { TLSCertFile: os.Getenv("TLS_CERT_FILE"), TLSKeyFile: os.Getenv("TLS_KEY_FILE"), TrustedProxyCIDRs: os.Getenv("TRUSTED_PROXY_CIDRS"), + AppName: getenv("APP_NAME", "OpenGit"), + LicenseName: getenv("LICENSE_NAME", "Apache-2.0"), + SourceURL: getenv("SOURCE_URL", ""), + LicensesFilePath: getenv("LICENSES_FILE_PATH", "./licenses.json"), } } diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go index 1c3a160d..8a0edd92 100644 --- a/backend/internal/config/config_test.go +++ b/backend/internal/config/config_test.go @@ -242,6 +242,27 @@ func TestMetricsConfig(t *testing.T) { }) } +func TestLoadBrandingDefaults(t *testing.T) { + t.Setenv("APP_NAME", "") + t.Setenv("LICENSE_NAME", "") + t.Setenv("SOURCE_URL", "") + t.Setenv("LICENSES_FILE_PATH", "") + + cfg := config.Load() + if cfg.AppName != "OpenGit" { + t.Fatalf("AppName = %q, want OpenGit", cfg.AppName) + } + if cfg.LicenseName != "Apache-2.0" { + t.Fatalf("LicenseName = %q, want Apache-2.0", cfg.LicenseName) + } + if cfg.SourceURL != "" { + t.Fatalf("SourceURL = %q, want empty", cfg.SourceURL) + } + if cfg.LicensesFilePath != "./licenses.json" { + t.Fatalf("LicensesFilePath = %q, want ./licenses.json", cfg.LicensesFilePath) + } +} + func TestMaskDSN(t *testing.T) { masked := database.MaskDSN("postgres://user:secret@host/db") if strings.Contains(masked, "secret") { diff --git a/backend/internal/handler/meta_handler.go b/backend/internal/handler/meta_handler.go new file mode 100644 index 00000000..60868d73 --- /dev/null +++ b/backend/internal/handler/meta_handler.go @@ -0,0 +1,67 @@ +package handler + +import ( + "net/http" + + "github.com/labstack/echo/v4" +) + +type BuildInfo struct { + AppName string + Version string + GitCommit string + BuildDate string + LicenseName string + SourceURL string +} + +type LicenseEntry struct { + Name string `json:"name"` + Version string `json:"version"` + License string `json:"license"` + URL string `json:"url"` +} + +type MetaHandler struct { + info BuildInfo + thirdParty []LicenseEntry +} + +func NewMetaHandler(info BuildInfo, thirdParty []LicenseEntry) *MetaHandler { + entries := make([]LicenseEntry, 0) + if thirdParty != nil { + entries = append(entries, thirdParty...) + } + return &MetaHandler{ + info: info, + thirdParty: entries, + } +} + +func (h *MetaHandler) GetMeta(c echo.Context) error { + return c.JSON(http.StatusOK, map[string]string{ + "app_name": h.info.AppName, + "version": h.info.Version, + "git_commit": h.info.GitCommit, + "build_date": h.info.BuildDate, + "license": h.info.LicenseName, + "source_url": h.info.SourceURL, + }) +} + +type licensesResponse struct { + AppLicense string `json:"app_license"` + ThirdParty []LicenseEntry `json:"third_party"` +} + +func (h *MetaHandler) GetLicenses(c echo.Context) error { + return c.JSON(http.StatusOK, licensesResponse{ + AppLicense: h.info.LicenseName, + ThirdParty: h.thirdParty, + }) +} + +func (h *MetaHandler) RegisterRoutes(e *echo.Echo) { + e.GET("/api/meta", h.GetMeta) + e.GET("/api/licenses", h.GetLicenses) +} diff --git a/backend/internal/handler/meta_handler_test.go b/backend/internal/handler/meta_handler_test.go new file mode 100644 index 00000000..e2f5208d --- /dev/null +++ b/backend/internal/handler/meta_handler_test.go @@ -0,0 +1,144 @@ +package handler_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/labstack/echo/v4" + + "github.com/open-git/backend/internal/handler" +) + +func TestGetMetaOK(t *testing.T) { + e := echo.New() + metaHandler := handler.NewMetaHandler(handler.BuildInfo{ + AppName: "OpenGit", + Version: "1.0.0", + GitCommit: "abc1234", + BuildDate: "2025-01-01T00:00:00Z", + LicenseName: "Apache-2.0", + SourceURL: "https://example.org/org/repo", + }, nil) + metaHandler.RegisterRoutes(e) + + req := httptest.NewRequest(http.MethodGet, "/api/meta", nil) + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + + var body map[string]string + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + for _, key := range []string{"app_name", "version", "git_commit", "build_date", "license", "source_url"} { + if _, ok := body[key]; !ok { + t.Fatalf("%s key missing", key) + } + } +} + +func TestGetLicensesWithEntries(t *testing.T) { + e := echo.New() + entry := handler.LicenseEntry{ + Name: "github.com/labstack/echo/v4", + Version: "v4.11.0", + License: "MIT", + URL: "https://github.com/labstack/echo", + } + metaHandler := handler.NewMetaHandler(handler.BuildInfo{ + LicenseName: "Apache-2.0", + }, []handler.LicenseEntry{entry}) + metaHandler.RegisterRoutes(e) + + req := httptest.NewRequest(http.MethodGet, "/api/licenses", nil) + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + + var body struct { + AppLicense string `json:"app_license"` + ThirdParty []handler.LicenseEntry `json:"third_party"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if body.AppLicense != "Apache-2.0" { + t.Fatalf("app_license = %q, want Apache-2.0", body.AppLicense) + } + if len(body.ThirdParty) != 1 { + t.Fatalf("third_party len = %d, want 1", len(body.ThirdParty)) + } + if body.ThirdParty[0].Name != entry.Name { + t.Fatalf("third_party[0].name = %q, want %q", body.ThirdParty[0].Name, entry.Name) + } +} + +func TestGetLicensesEmpty(t *testing.T) { + e := echo.New() + metaHandler := handler.NewMetaHandler(handler.BuildInfo{ + LicenseName: "Apache-2.0", + }, nil) + metaHandler.RegisterRoutes(e) + + req := httptest.NewRequest(http.MethodGet, "/api/licenses", nil) + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + + raw := rec.Body.String() + if raw == "" { + t.Fatal("response body is empty") + } + if !json.Valid([]byte(raw)) { + t.Fatalf("response is not valid JSON: %s", raw) + } + + var body map[string]json.RawMessage + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("unmarshal: %v", err) + } + thirdParty, ok := body["third_party"] + if !ok { + t.Fatal("third_party key missing") + } + if string(thirdParty) != "[]" { + t.Fatalf("third_party = %s, want []", string(thirdParty)) + } +} + +func TestGetMetaDevVersion(t *testing.T) { + e := echo.New() + metaHandler := handler.NewMetaHandler(handler.BuildInfo{ + AppName: "OpenGit", + Version: "dev", + }, nil) + metaHandler.RegisterRoutes(e) + + req := httptest.NewRequest(http.MethodGet, "/api/meta", nil) + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + + var body map[string]string + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if body["version"] != "dev" { + t.Fatalf("version = %q, want dev", body["version"]) + } +} diff --git a/backend/licenses.json b/backend/licenses.json new file mode 100644 index 00000000..fe51488c --- /dev/null +++ b/backend/licenses.json @@ -0,0 +1 @@ +[]