diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..972f59f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,17 @@ +.git +.github +bin +coverage.out +node_modules +.tmp +.vscode +.idea +.DS_Store +README.md +.codex +.gocache +.gomodcache +dist +tmp +*_test.go +tests diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 9919c45..be04421 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -12,7 +12,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: '1.24.2' + go-version: '1.26.2' - name: Start PostgreSQL run: | @@ -108,8 +108,9 @@ jobs: -port 8080 \ -qdrant-api-key "your_qdrant_api_key" \ -qdrant-host localhost \ - -qdrant-port 6334 & - + -qdrant-port 6334 \ + -git-data-dir /tmp/gecko-git & + # Wait for application to be ready echo "Waiting for application to be ready..." for i in {1..30}; do diff --git a/.gitignore b/.gitignore index 78e7c13..1d34ae4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ *.DS_Store -bin/gecko \ No newline at end of file +bin/gecko +gecko \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 0c6201d..e26e5be 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,22 +1,38 @@ -FROM golang:1.24.2-alpine AS build-deps -RUN apk add make git bash build-base libc-dev binutils-gold curl postgresql-client +# syntax=docker/dockerfile:1.7 +FROM golang:1.26.3-alpine3.22 AS builder +RUN apk add --no-cache git ca-certificates tzdata ENV CGO_ENABLED=0 -ENV GOOS=linux -ENV GOARCH=amd64 -ENV GOPATH=/go -ENV PATH="/go/bin:${PATH}" -WORKDIR $GOPATH/src/github.com/calypr/gecko/ +WORKDIR /src -COPY go.mod . -COPY go.sum . -RUN go mod download +COPY go.mod go.sum ./ +RUN --mount=type=cache,target=/go/pkg/mod \ + --mount=type=cache,target=/root/.cache/go-build \ + go mod download COPY . . -RUN GITCOMMIT=$(git rev-parse HEAD) \ - GITVERSION=$(git describe --always --tags) \ - && go build \ - -ldflags="-X 'github.com/calypr/gecko/gecko/version.GitCommit=${GITCOMMIT}' -X 'github.com/calypr/gecko/gecko/version.GitVersion=${GITVERSION}'" \ - -o bin/gecko +ARG TARGETOS=linux +ARG TARGETARCH=amd64 +RUN --mount=type=cache,target=/go/pkg/mod \ + --mount=type=cache,target=/root/.cache/go-build \ + GOOS="$TARGETOS" GOARCH="$TARGETARCH" \ + go build \ + -trimpath \ + -ldflags="-s -w" \ + -o /out/gecko + +FROM alpine:3.22 +RUN apk add --no-cache ca-certificates tzdata && \ + addgroup -S gecko && \ + adduser -S -G gecko -h /app gecko + +WORKDIR /app + +COPY --from=builder /out/gecko /app/gecko +COPY --from=builder /src/docs/swagger.json /app/docs/swagger.json + +USER gecko +EXPOSE 8080 +ENTRYPOINT ["/app/gecko"] diff --git a/Makefile b/Makefile index 915c2a3..18c7057 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,11 @@ +.PHONY: _default clean swagger + _default: bin/gecko - @: # if we have a command this silences "nothing to be done" + @: -bin/gecko: main.go gecko/*.go # help: run the server +# Simply depend on main.go, or leave the prerequisites blank. +# Go handles the rest. +bin/gecko: main.go go build -o bin/gecko . clean: diff --git a/apierror/types.go b/apierror/types.go new file mode 100644 index 0000000..f3e2b1c --- /dev/null +++ b/apierror/types.go @@ -0,0 +1,46 @@ +package apierror + +type Type string + +const ( + TypeUnauthorized Type = "unauthorized" + TypeForbidden Type = "forbidden" + TypeNotFound Type = "not_found" + TypeMethodNotAllowed Type = "method_not_allowed" + TypeInvalidConfigType Type = "invalid_config_type" + TypeConfigNotFound Type = "config_not_found" + TypeInvalidJSON Type = "invalid_json" + TypeEmptyRequestBody Type = "empty_request_body" + TypeInvalidRequestBody Type = "invalid_request_body" + TypeValidationFailed Type = "validation_failed" + TypeMissingAuthorization Type = "missing_authorization" + TypeInvalidAuthorizationResponse Type = "invalid_authorization_response" + TypeInvalidJWTHandler Type = "invalid_jwt_handler" + TypeInvalidProjectID Type = "invalid_project_id" + TypeMissingProjectID Type = "missing_project_id" + TypeProjectIDMismatch Type = "project_id_mismatch" + TypeInvalidDirectory Type = "invalid_directory" + TypeDatabaseError Type = "database_error" + TypeDatabaseUnavailable Type = "database_unavailable" + TypeGraphQueryFailed Type = "graph_query_failed" + TypeInvalidDistance Type = "invalid_distance" + TypeInvalidVectorRequest Type = "invalid_vector_request" + TypeInvalidPointData Type = "invalid_point_data" + TypeMissingIdentifier Type = "missing_identifier" + TypeInvalidUUID Type = "invalid_uuid" + TypeInvalidQueryParameter Type = "invalid_query_parameter" + TypePointNotFound Type = "point_not_found" + TypeVectorCollectionNotFound Type = "vector_collection_not_found" + TypeVectorCollectionAlreadyExists Type = "vector_collection_already_exists" + TypeVectorStoreUnavailable Type = "vector_store_unavailable" + TypeVectorOperationFailed Type = "vector_operation_failed" + TypeAuthorizationServiceError Type = "authorization_service_error" + TypeAppCardNotFound Type = "app_card_not_found" +) + +type Error struct { + Type Type `json:"type"` + Message string `json:"message"` + Code int `json:"code"` + Details map[string]any `json:"details,omitempty"` +} diff --git a/gecko/config/configurable.go b/config/configurable.go similarity index 72% rename from gecko/config/configurable.go rename to config/configurable.go index a3ea9ea..aa8a456 100644 --- a/gecko/config/configurable.go +++ b/config/configurable.go @@ -4,15 +4,10 @@ type Configurable interface { IsZero() bool } -// Update config.Config to implement the interface func (c Config) IsZero() bool { return len(c.ExplorerConfig) == 0 } -func (ap AppsConfig) IsZero() bool { - return len(ap.AppCards) == 0 -} - func (n NavPageLayoutProps) IsZero() bool { return len(n.HeaderProps.LeftNav) == 0 && len(n.FooterProps.RightSection.Columns) == 0 && @@ -24,3 +19,12 @@ func (n NavPageLayoutProps) IsZero() bool { func (fs FilesummaryConfig) IsZero() bool { return len(fs.Config) == 0 } + +func (p ProjectConfig) IsZero() bool { + return p.Title == "" && + p.ContactEmail == "" && + p.SrcRepo == "" && + p.OrgTitle == "" && + p.Description == "" && + p.ProjectTitle == "" +} diff --git a/gecko/config/explorerConfig.go b/config/explorerConfig.go similarity index 100% rename from gecko/config/explorerConfig.go rename to config/explorerConfig.go diff --git a/config/explorerConfig_test.go b/config/explorerConfig_test.go new file mode 100644 index 0000000..3115460 --- /dev/null +++ b/config/explorerConfig_test.go @@ -0,0 +1,166 @@ +package config + +import ( + "encoding/json" + "reflect" + "testing" +) + +const explorerJSON = `{ + "sharedFilters": { + "defined": { + "proj": [ + { + "index": "file", + "field": "project_id" + } + ] + } + }, + "explorerConfig": [ + { + "tabTitle": "test", + "guppyConfig": { + "dataType": "file", + "nodeCountTitle": "file Count", + "fieldMapping": [ + { + "field": "file_id", + "name": "ID" + } + ], + "manifestMapping": { + "resourceIndexType": "file", + "resourceIdField": "file_id" + } + }, + "charts": { + "a": { + "chartType": "bar", + "title": "a" + } + }, + "filters": { + "tabs": [ + { + "title": "Filters", + "fields": [ + "project_id" + ] + } + ] + }, + "table": { + "enabled": true, + "fields": [ + "project_id" + ], + "detailsConfig": { + "panel": "details", + "title": "Details" + } + }, + "buttons": [ + { + "enabled": true, + "type": "manifest", + "action": "download", + "title": "Download Manifest", + "actionArgs": { + "resourceIndexType": "file" + } + } + ], + "preFilters": { + "project_id": ["proj1"] + } + } + ], + "fileActions": { + "extensions": { + "tiff": ["file_download", "file_image"], + "tif": ["file_download", "file_image"], + "default": ["file_download"] + }, + "actions": { + "file_download": "/user/data/download", + "file_image": "/image-viewer/view" + } + } +}` + +func TestExplorerConfig_RoundTrip(t *testing.T) { + var cfg Config + if err := json.Unmarshal([]byte(explorerJSON), &cfg); err != nil { + t.Fatalf("unmarshal failed: %v", err) + } + + marshaled, err := json.Marshal(cfg) + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + + var originalMap any + if err := json.Unmarshal([]byte(explorerJSON), &originalMap); err != nil { + t.Fatalf("Unmarshal original map failed: %v", err) + } + + var marshaledMap any + if err := json.Unmarshal(marshaled, &marshaledMap); err != nil { + t.Fatalf("Unmarshal marshaled map failed: %v", err) + } + + if !reflect.DeepEqual(originalMap, marshaledMap) { + t.Errorf("Round-trip mismatch") + wantJSON, _ := json.MarshalIndent(originalMap, "", " ") + gotJSON, _ := json.MarshalIndent(marshaledMap, "", " ") + t.Errorf("--- want ---\n%s\n--- got ---\n%s\n", string(wantJSON), string(gotJSON)) + } +} + +func TestExplorerConfig_Fields(t *testing.T) { + var cfg Config + if err := json.Unmarshal([]byte(explorerJSON), &cfg); err != nil { + t.Fatalf("unmarshal failed: %v", err) + } + + if len(cfg.ExplorerConfig) != 1 { + t.Fatalf("expected 1 explorerConfig, got %d", len(cfg.ExplorerConfig)) + } + + item := cfg.ExplorerConfig[0] + if item.TabTitle != "test" { + t.Errorf("TabTitle = %q, want %q", item.TabTitle, "test") + } + + if len(item.Buttons) != 1 { + t.Fatalf("expected 1 button, got %d", len(item.Buttons)) + } + + btn := item.Buttons[0] + if btn.Type != "manifest" || btn.Title != "Download Manifest" { + t.Errorf("button mismatch: %+v", btn) + } + + if btn.ActionArgs.ResourceIndexType != "file" { + t.Errorf("ActionArgs.ResourceIndexType = %q, want %q", btn.ActionArgs.ResourceIndexType, "file") + } + + if val, ok := item.PreFilters["project_id"]; !ok { + t.Error("missing preFilters project_id") + } else { + // val will be []any since item.PreFilters is map[string]any + slice, ok := val.([]any) + if !ok || len(slice) != 1 || slice[0] != "proj1" { + t.Errorf("preFilters project_id = %v, want [proj1]", val) + } + } + + if got := cfg.FileActions.Extensions["tiff"]; !reflect.DeepEqual(got, []string{"file_download", "file_image"}) { + t.Errorf("FileActions.Extensions[tiff] = %v, want %v", got, []string{"file_download", "file_image"}) + } + + if got := cfg.FileActions.Actions["file_image"]; got != "/image-viewer/view" { + t.Errorf("FileActions.Actions[file_image] = %q, want %q", got, "/image-viewer/view") + } +} diff --git a/gecko/config/fileSummaryConfig.go b/config/fileSummaryConfig.go similarity index 100% rename from gecko/config/fileSummaryConfig.go rename to config/fileSummaryConfig.go diff --git a/gecko/config/fileSummaryConfig_test.go b/config/fileSummaryConfig_test.go similarity index 100% rename from gecko/config/fileSummaryConfig_test.go rename to config/fileSummaryConfig_test.go diff --git a/gecko/config/footerConfig.go b/config/footerConfig.go similarity index 100% rename from gecko/config/footerConfig.go rename to config/footerConfig.go diff --git a/gecko/config/navConfig.go b/config/navConfig.go similarity index 100% rename from gecko/config/navConfig.go rename to config/navConfig.go diff --git a/gecko/config/navConfig_test.go b/config/navConfig_test.go similarity index 100% rename from gecko/config/navConfig_test.go rename to config/navConfig_test.go diff --git a/config/projectConfig.go b/config/projectConfig.go new file mode 100644 index 0000000..53e07be --- /dev/null +++ b/config/projectConfig.go @@ -0,0 +1,137 @@ +package config + +import ( + "context" + "fmt" + "net/mail" + "net/url" + "strings" +) + +type ProjectConfig struct { + Title string `json:"title"` + ContactEmail string `json:"contact_email"` + SrcRepo string `json:"src_repo"` + OrgTitle string `json:"org_title"` + Description string `json:"description"` + ProjectTitle string `json:"project_title"` +} + +var ValidateProjectRepository = func(_ context.Context, raw string) (string, error) { + return NormalizeProjectRepositoryURL(raw) +} + +func (p *ProjectConfig) Validate() error { + return p.validate(true) +} + +func (p *ProjectConfig) ValidateInitialization() error { + return p.validate(false) +} + +func (p *ProjectConfig) validate(requireRepository bool) error { + if p == nil { + return fmt.Errorf("project config is required") + } + + p.Title = strings.TrimSpace(p.Title) + p.ContactEmail = strings.TrimSpace(p.ContactEmail) + p.SrcRepo = strings.TrimSpace(p.SrcRepo) + p.OrgTitle = strings.TrimSpace(p.OrgTitle) + p.Description = strings.TrimSpace(p.Description) + p.ProjectTitle = strings.TrimSpace(p.ProjectTitle) + + requiredFields := []struct { + name string + value string + }{ + {name: "title", value: p.Title}, + {name: "contact_email", value: p.ContactEmail}, + {name: "org_title", value: p.OrgTitle}, + {name: "description", value: p.Description}, + {name: "project_title", value: p.ProjectTitle}, + } + if requireRepository { + requiredFields = append(requiredFields, struct { + name string + value string + }{name: "src_repo", value: p.SrcRepo}) + } + for _, field := range requiredFields { + if field.value == "" { + return fmt.Errorf("%s is required", field.name) + } + } + + if _, err := mail.ParseAddress(p.ContactEmail); err != nil { + return fmt.Errorf("contact_email must be a valid email address: %w", err) + } + + if strings.TrimSpace(p.SrcRepo) != "" { + normalized, err := ValidateProjectRepository(context.Background(), p.SrcRepo) + if err != nil { + return err + } + p.SrcRepo = normalized + } + return nil +} + +func NormalizeProjectRepositoryURL(raw string) (string, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return "", fmt.Errorf("src_repo is required") + } + + host, path, err := splitProjectRepositoryURL(raw) + if err != nil { + return "", err + } + + host = strings.ToLower(strings.TrimSpace(host)) + path = strings.Trim(path, "/") + path = strings.TrimSuffix(path, ".git") + parts := strings.Split(path, "/") + + if host == "ssh.github.com" || host == "altssh.github.com" { + host = "github.com" + if len(parts) == 3 && parts[0] == "443" { + parts = parts[1:] + } + } + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return "", fmt.Errorf("src_repo must point to a GitHub-style owner/repo path") + } + + return host + "/" + parts[0] + "/" + parts[1], nil +} + +func splitProjectRepositoryURL(raw string) (string, string, error) { + if strings.Contains(raw, "://") { + parsed, err := url.Parse(raw) + if err != nil { + return "", "", fmt.Errorf("invalid src_repo URL: %w", err) + } + if parsed.Host == "" { + return "", "", fmt.Errorf("src_repo host is required") + } + return parsed.Hostname(), parsed.EscapedPath(), nil + } + + if strings.Contains(raw, "@") && strings.Contains(raw, ":") { + atIdx := strings.LastIndex(raw, "@") + colonIdx := strings.Index(raw[atIdx+1:], ":") + if colonIdx >= 0 { + host := raw[atIdx+1 : atIdx+1+colonIdx] + path := raw[atIdx+1+colonIdx+1:] + return host, path, nil + } + } + + parts := strings.Split(strings.Trim(raw, "/"), "/") + if len(parts) >= 3 { + return parts[0], strings.Join(parts[1:], "/"), nil + } + + return "", "", fmt.Errorf("invalid src_repo URL: %s", raw) +} diff --git a/config/projectConfig_test.go b/config/projectConfig_test.go new file mode 100644 index 0000000..5ba214a --- /dev/null +++ b/config/projectConfig_test.go @@ -0,0 +1,59 @@ +package config + +import ( + "context" + "strings" + "testing" +) + +func TestProjectConfigValidateNormalizesAndValidates(t *testing.T) { + old := ValidateProjectRepository + ValidateProjectRepository = func(ctx context.Context, raw string) (string, error) { + if raw != "https://github.com/calypr/gecko.git" { + t.Fatalf("unexpected raw repo: %q", raw) + } + return "github.com/calypr/gecko", nil + } + defer func() { ValidateProjectRepository = old }() + + cfg := &ProjectConfig{ + Title: " Title ", + ContactEmail: " person@example.org ", + SrcRepo: "https://github.com/calypr/gecko.git", + OrgTitle: " Org ", + Description: " Desc ", + ProjectTitle: " Project ", + } + + if err := cfg.Validate(); err != nil { + t.Fatalf("Validate failed: %v", err) + } + if cfg.SrcRepo != "github.com/calypr/gecko" { + t.Fatalf("expected normalized repo, got %q", cfg.SrcRepo) + } + if cfg.ContactEmail != "person@example.org" || cfg.Title != "Title" { + t.Fatalf("expected trimmed fields, got %+v", cfg) + } +} + +func TestProjectConfigValidateRejectsInvalidEmail(t *testing.T) { + cfg := &ProjectConfig{ + Title: "Title", + ContactEmail: "not-an-email", + SrcRepo: "https://github.com/calypr/gecko", + OrgTitle: "Org", + Description: "Desc", + ProjectTitle: "Project", + } + + if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "contact_email") { + t.Fatalf("expected contact_email validation error, got %v", err) + } +} + +func TestProjectConfigValidateRejectsMissingFields(t *testing.T) { + cfg := &ProjectConfig{} + if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "title is required") { + t.Fatalf("expected missing title error, got %v", err) + } +} diff --git a/config/types.go b/config/types.go new file mode 100644 index 0000000..61e1891 --- /dev/null +++ b/config/types.go @@ -0,0 +1,32 @@ +package config + +type Type string + +const ( + TypeExplorer Type = "explorer" + TypeNav Type = "nav" + TypeFileSummary Type = "file_summary" + TypeProject Type = "project" + TypeProjects Type = "projects" + + DefaultConfigID = "default" +) + +func KnownTypes() []string { + return []string{ + string(TypeExplorer), + string(TypeNav), + string(TypeFileSummary), + string(TypeProject), + string(TypeProjects), + } +} + +func IsKnownType(t string) bool { + switch Type(t) { + case TypeExplorer, TypeNav, TypeFileSummary, TypeProject, TypeProjects: + return true + default: + return false + } +} diff --git a/docs/docs.go b/docs/docs.go index 6b05dee..f26d636 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -398,7 +398,7 @@ const docTemplate = `{ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/adapter.CreateCollectionRequest" + "$ref": "#/definitions/vectoradapter.CreateCollectionRequest" } } ], @@ -553,7 +553,7 @@ const docTemplate = `{ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/adapter.UpsertRequest" + "$ref": "#/definitions/vectoradapter.UpsertRequest" } } ], @@ -606,7 +606,7 @@ const docTemplate = `{ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/adapter.DeletePoints" + "$ref": "#/definitions/vectoradapter.DeletePoints" } } ], @@ -659,7 +659,7 @@ const docTemplate = `{ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/adapter.QueryPointsRequest" + "$ref": "#/definitions/vectoradapter.QueryPointsRequest" } } ], @@ -667,7 +667,7 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/adapter.QueryPointsResponseItem" + "$ref": "#/definitions/vectoradapter.QueryPointsResponseItem" } }, "400": { @@ -750,22 +750,22 @@ const docTemplate = `{ } }, "definitions": { - "adapter.CreateCollectionRequest": { + "vectoradapter.CreateCollectionRequest": { "type": "object", "properties": { "vectors": { "type": "object", "additionalProperties": { - "$ref": "#/definitions/adapter.VectorParams" + "$ref": "#/definitions/vectoradapter.VectorParams" } } } }, - "adapter.DeletePoints": { + "vectoradapter.DeletePoints": { "type": "object", "properties": { "filter": { - "$ref": "#/definitions/adapter.HeadFilter" + "$ref": "#/definitions/vectoradapter.HeadFilter" }, "points": { "type": "array", @@ -775,35 +775,35 @@ const docTemplate = `{ } } }, - "adapter.HeadFilter": { + "vectoradapter.HeadFilter": { "type": "object", "properties": { "must": { "type": "array", "items": { - "$ref": "#/definitions/adapter.IndFilter" + "$ref": "#/definitions/vectoradapter.IndFilter" } } } }, - "adapter.IndFilter": { + "vectoradapter.IndFilter": { "type": "object", "properties": { "key": { "type": "string" }, "match": { - "$ref": "#/definitions/adapter.MatchFilter" + "$ref": "#/definitions/vectoradapter.MatchFilter" } } }, - "adapter.MatchFilter": { + "vectoradapter.MatchFilter": { "type": "object", "properties": { "value": {} } }, - "adapter.Point": { + "vectoradapter.Point": { "type": "object", "properties": { "id": { @@ -825,7 +825,7 @@ const docTemplate = `{ } } }, - "adapter.QueryPointsRequest": { + "vectoradapter.QueryPointsRequest": { "description": "Request body for querying points in a Qdrant collection", "type": "object", "properties": { @@ -833,7 +833,7 @@ const docTemplate = `{ "description": "Optional filter for narrowing search", "allOf": [ { - "$ref": "#/definitions/adapter.HeadFilter" + "$ref": "#/definitions/vectoradapter.HeadFilter" } ] }, @@ -859,7 +859,7 @@ const docTemplate = `{ "description": "Additional search parameters", "allOf": [ { - "$ref": "#/definitions/adapter.SearchParamsRequest" + "$ref": "#/definitions/vectoradapter.SearchParamsRequest" } ] }, @@ -895,7 +895,7 @@ const docTemplate = `{ } } }, - "adapter.QueryPointsResponseItem": { + "vectoradapter.QueryPointsResponseItem": { "description": "Simplified Qdrant point response", "type": "object", "properties": { @@ -916,7 +916,7 @@ const docTemplate = `{ } } }, - "adapter.SearchParamsRequest": { + "vectoradapter.SearchParamsRequest": { "type": "object", "properties": { "exact": { @@ -933,18 +933,18 @@ const docTemplate = `{ } } }, - "adapter.UpsertRequest": { + "vectoradapter.UpsertRequest": { "type": "object", "properties": { "points": { "type": "array", "items": { - "$ref": "#/definitions/adapter.Point" + "$ref": "#/definitions/vectoradapter.Point" } } } }, - "adapter.VectorParams": { + "vectoradapter.VectorParams": { "type": "object", "properties": { "distance": { diff --git a/gecko b/gecko new file mode 100755 index 0000000..9148a98 Binary files /dev/null and b/gecko differ diff --git a/gecko/config/appsPageConfig.go b/gecko/config/appsPageConfig.go deleted file mode 100644 index 4fd95a3..0000000 --- a/gecko/config/appsPageConfig.go +++ /dev/null @@ -1,13 +0,0 @@ -package config - -type AppCard struct { - Title string `json:"title"` - Description string `json:"description"` - Icon string `json:"icon"` - Href string `json:"href"` - Perms string `json:"perms"` -} - -type AppsConfig struct { - AppCards []AppCard `json:"appCards"` -} diff --git a/gecko/handleAppCard.go b/gecko/handleAppCard.go deleted file mode 100644 index f405579..0000000 --- a/gecko/handleAppCard.go +++ /dev/null @@ -1,215 +0,0 @@ -package gecko - -import ( - "database/sql" - "errors" - "fmt" - "net/http" - - "github.com/calypr/gecko/gecko/config" - "github.com/kataras/iris/v12" -) - -// handleAppCardGET godoc -// @Summary Get a specific AppCard by projectId (perms) -// @Description Retrieves a single AppCard from the apps_page configuration by its perms value (used as projectId). -// @Tags Config -// @Produce json -// @Param projectId path string true "Project ID (AppCard perms value, e.g., HTAN_INT-BForePC)" -// @Success 200 {object} config.AppCard "The requested AppCard" -// @Failure 404 {object} ErrorResponse "AppCard not found" -// @Failure 500 {object} ErrorResponse "Server error" -// @Router /config/apps_page/appcard/{projectId} [get] -func (server *Server) handleAppCardGET(ctx iris.Context) { - configType := "apps_page" - configId := "1" // Matches the ID used in helm chart bootstrap - - projectId := ctx.Params().Get("projectId") - if projectId == "" { - errResponse := newErrorResponse("Missing projectId parameter", 400, nil) - errResponse.log.write(server.Logger) - _ = errResponse.write(ctx) - return - } - - var currentCfg config.AppsConfig - err := configGETGeneric(server.db, configId, configType, ¤tCfg) - if errors.Is(err, sql.ErrNoRows) { - // No config exists yet → no AppCards - msg := fmt.Sprintf("AppCard with projectId (perms) %s not found (no config exists)", projectId) - errResponse := newErrorResponse(msg, 404, nil) - errResponse.log.write(server.Logger) - _ = errResponse.write(ctx) - return - } - if err != nil { - msg := fmt.Sprintf("Failed to retrieve apps_page config: %s", err) - errResponse := newErrorResponse(msg, 500, nil) - errResponse.log.write(server.Logger) - _ = errResponse.write(ctx) - return - } - - // Find the matching AppCard by Perms - for _, card := range currentCfg.AppCards { - if card.Perms == projectId { - jsonResponseFrom(card, http.StatusOK).write(ctx) - return - } - } - - // Not found - msg := fmt.Sprintf("AppCard with projectId (perms) %s not found", projectId) - errResponse := newErrorResponse(msg, 404, nil) - errResponse.log.write(server.Logger) - _ = errResponse.write(ctx) -} - -// handleAppCardPOST godoc -// @Summary Add or update an AppCard -// @Description Adds a new AppCard to the apps_page configuration or updates an existing one if the perms matches. Assumes a fixed configId "default" for apps_page. -// @Tags Config -// @Accept json -// @Produce json -// @Param projectId path string true "Project ID (AppCard perms value, e.g., HTAN_INT-BForePC)" -// @Param body body config.AppCard true "AppCard details" -// @Success 200 {object} map[string]interface{} "AppCard added or updated" -// @Failure 400 {object} ErrorResponse "Invalid request body or ID mismatch" -// @Failure 404 {object} ErrorResponse "Config not found (if required)" -// @Failure 500 {object} ErrorResponse "Server error" -// @Router /config/apps_page/appcard/{projectId} [post] -func (server *Server) handleAppCardPOST(ctx iris.Context) { - configType := "apps_page" - configId := "1" // Matches the ID used in helm chart bootstrap - - var currentCfg config.AppsConfig - err := configGETGeneric(server.db, configId, configType, ¤tCfg) - if errors.Is(err, sql.ErrNoRows) { - // Initialize empty if not found - currentCfg = config.AppsConfig{AppCards: []config.AppCard{}} - } else if err != nil { - msg := fmt.Sprintf("Failed to get apps_page config: %s", err) - errResponse := newErrorResponse(msg, 500, nil) - errResponse.log.write(server.Logger) - _ = errResponse.write(ctx) - return - } - - projectId := ctx.Params().Get("projectId") - var newCard config.AppCard - if err := ctx.ReadJSON(&newCard); err != nil { - msg := "Invalid JSON format" - errResponse := newErrorResponse(msg, 400, &err) - errResponse.log.write(server.Logger) - _ = errResponse.write(ctx) - return - } - - if newCard.Perms != projectId { - msg := fmt.Sprintf("Project ID in path (%s) does not match perms in body (%s)", projectId, newCard.Perms) - errResponse := newErrorResponse(msg, 400, nil) - errResponse.log.write(server.Logger) - _ = errResponse.write(ctx) - return - } - - // Update if Perms exists, else append - updated := false - for i := range currentCfg.AppCards { - if currentCfg.AppCards[i].Perms == newCard.Perms { - currentCfg.AppCards[i] = newCard - updated = true - break - } - } - if !updated { - currentCfg.AppCards = append(currentCfg.AppCards, newCard) - } - - // Save the updated config - err = configPUTGeneric(server.db, configId, configType, ¤tCfg) - if err != nil { - msg := fmt.Sprintf("Failed to update apps_page config: %s", err) - errResponse := newErrorResponse(msg, 500, nil) - errResponse.log.write(server.Logger) - _ = errResponse.write(ctx) - return - } - - jsonResponseFrom( - map[string]any{ - "code": 200, - "message": fmt.Sprintf("AppCard with perms %s added or updated", newCard.Perms), - }, - http.StatusOK, - ).write(ctx) -} - -// handleAppCardDELETE godoc -// @Summary Delete an AppCard -// @Description Deletes an AppCard from the apps_page configuration by projectId (perms). Assumes a fixed configId "default" for apps_page. -// @Tags Config -// @Produce json -// @Param projectId path string true "Project ID (AppCard perms value, e.g., HTAN_INT-BForePC)" -// @Success 200 {object} map[string]interface{} "AppCard deleted" -// @Failure 404 {object} ErrorResponse "AppCard or config not found" -// @Failure 500 {object} ErrorResponse "Server error" -// @Router /config/apps_page/appcard/{projectId} [delete] -func (server *Server) handleAppCardDELETE(ctx iris.Context) { - configType := "apps_page" - configId := "1" // Matches the ID used in helm chart bootstrap - projectId := ctx.Params().Get("projectId") - - var currentCfg config.AppsConfig - err := configGETGeneric(server.db, configId, configType, ¤tCfg) - if errors.Is(err, sql.ErrNoRows) { - msg := "No apps_page config found" - errResponse := newErrorResponse(msg, 404, nil) - errResponse.log.write(server.Logger) - _ = errResponse.write(ctx) - return - } else if err != nil { - msg := fmt.Sprintf("Failed to get apps_page config: %s", err) - errResponse := newErrorResponse(msg, 500, nil) - errResponse.log.write(server.Logger) - _ = errResponse.write(ctx) - return - } - - // Remove the matching AppCard by Perms - newCards := []config.AppCard{} - found := false - for _, card := range currentCfg.AppCards { - if card.Perms == projectId { - found = true - continue - } - newCards = append(newCards, card) - } - if !found { - msg := fmt.Sprintf("AppCard with projectId (perms) %s not found", projectId) - errResponse := newErrorResponse(msg, 404, nil) - errResponse.log.write(server.Logger) - _ = errResponse.write(ctx) - return - } - currentCfg.AppCards = newCards - - // Save the updated config - err = configPUTGeneric(server.db, configId, configType, ¤tCfg) - if err != nil { - msg := fmt.Sprintf("Failed to update apps_page config: %s", err) - errResponse := newErrorResponse(msg, 500, nil) - errResponse.log.write(server.Logger) - _ = errResponse.write(ctx) - return - } - - jsonResponseFrom( - map[string]any{ - "code": 200, - "message": fmt.Sprintf("AppCard with perms %s deleted", projectId), - }, - http.StatusOK, - ).write(ctx) -} diff --git a/gecko/handleAppCard_test.go b/gecko/handleAppCard_test.go deleted file mode 100644 index e1d6cd9..0000000 --- a/gecko/handleAppCard_test.go +++ /dev/null @@ -1,195 +0,0 @@ -package gecko - -import ( - "bytes" - "log" - "net/http" - "net/http/httptest" - "os" - "testing" - - "github.com/DATA-DOG/go-sqlmock" - "github.com/jmoiron/sqlx" - "github.com/kataras/iris/v12" - "github.com/stretchr/testify/assert" -) - -func TestHandleAppCardPOST_Update(t *testing.T) { - db, mock, err := sqlmock.New() - if err != nil { - t.Fatalf("an error '%s' was not expected when opening a stub database connection", err) - } - defer db.Close() - sqlxDB := sqlx.NewDb(db, "sqlmock") - - srv := &Server{ - db: sqlxDB, - Logger: &LogHandler{Logger: log.New(os.Stdout, "", 0)}, - } - - // Initial state: one card in config ID '1' - rows := sqlmock.NewRows([]string{"name", "content"}). - AddRow("1", []byte(`{"appCards": [{"perms": "PROG-PROJ", "title": "Old Title"}]}`)) - - // The handler now uses ID '1' - mock.ExpectQuery("^SELECT name, content FROM config_schema.apps_page WHERE name=.+"). - WithArgs("1"). - WillReturnRows(rows) - - // Expect UPDATE (Upsert) back to ID '1' - mock.ExpectExec("INSERT INTO config_schema.apps_page"). - WithArgs("1", sqlmock.AnyArg()). - WillReturnResult(sqlmock.NewResult(1, 1)) - - body := `{"perms": "PROG-PROJ", "title": "New Title"}` - req := httptest.NewRequest(http.MethodPost, "/config/apps_page/appcard/PROG-PROJ", bytes.NewBufferString(body)) - rec := httptest.NewRecorder() - app := iris.New() - ctx := app.ContextPool.Acquire(rec, req) - ctx.Params().Set("projectId", "PROG-PROJ") - - srv.handleAppCardPOST(ctx) - - assert.Equal(t, http.StatusOK, rec.Code) - assert.Contains(t, rec.Body.String(), "added or updated") - assert.Contains(t, rec.Body.String(), "perms PROG-PROJ") - - if err := mock.ExpectationsWereMet(); err != nil { - t.Errorf("unfulfilled expectations: %s", err) - } -} - -func TestHandleAppCardDELETE_Success(t *testing.T) { - db, mock, err := sqlmock.New() - if err != nil { - t.Fatalf("error opening mock db: %s", err) - } - defer db.Close() - sqlxDB := sqlx.NewDb(db, "sqlmock") - - srv := &Server{ - db: sqlxDB, - Logger: &LogHandler{Logger: log.New(os.Stdout, "", 0)}, - } - - rows := sqlmock.NewRows([]string{"name", "content"}). - AddRow("1", []byte(`{"appCards": [{"perms": "PROG-PROJ", "title": "Title"}]}`)) - - mock.ExpectQuery("^SELECT name, content FROM config_schema.apps_page WHERE name=.+"). - WithArgs("1"). - WillReturnRows(rows) - - mock.ExpectExec("INSERT INTO config_schema.apps_page"). - WithArgs("1", sqlmock.AnyArg()). - WillReturnResult(sqlmock.NewResult(1, 1)) - - req := httptest.NewRequest(http.MethodDelete, "/config/apps_page/appcard/PROG-PROJ", nil) - rec := httptest.NewRecorder() - app := iris.New() - ctx := app.ContextPool.Acquire(rec, req) - ctx.Params().Set("projectId", "PROG-PROJ") - - srv.handleAppCardDELETE(ctx) - - assert.Equal(t, http.StatusOK, rec.Code) - assert.Contains(t, rec.Body.String(), "deleted") - - if err := mock.ExpectationsWereMet(); err != nil { - t.Errorf("unfulfilled expectations: %s", err) - } -} - -func TestHandleAppCardPOST_Integration(t *testing.T) { - db, mock, err := sqlmock.New() - if err != nil { - t.Fatalf("an error '%s' was not expected when opening a stub database connection", err) - } - defer db.Close() - sqlxDB := sqlx.NewDb(db, "sqlmock") - - srv := &Server{ - db: sqlxDB, - Logger: &LogHandler{Logger: log.New(os.Stdout, "", 0)}, - } - - // Mock for initial config read - rows := sqlmock.NewRows([]string{"name", "content"}). - AddRow("1", []byte(`{"appCards": []}`)) - - mock.ExpectQuery("^SELECT name, content FROM config_schema.apps_page WHERE name=.+"). - WithArgs("1"). - WillReturnRows(rows) - - // Mock for final config save - mock.ExpectExec("INSERT INTO config_schema.apps_page"). - WithArgs("1", sqlmock.AnyArg()). - WillReturnResult(sqlmock.NewResult(1, 1)) - - app := iris.New() - - // Using the actual middleware pattern (simple projectId from Param) - app.Post("/config/apps_page/appcard/{projectId}", func(ctx iris.Context) { - projectId := ctx.Params().Get("projectId") - if projectId == "" { - ctx.StatusCode(400) - return - } - ctx.Next() - }, srv.handleAppCardPOST) - - if err := app.Build(); err != nil { - t.Fatalf("Failed to build iris app: %v", err) - } - - body := `{"perms": "PROG-PROJ", "title": "New Title"}` - // New URL format: /config/apps_page/appcard/{projectId} - req := httptest.NewRequest(http.MethodPost, "/config/apps_page/appcard/PROG-PROJ", bytes.NewBufferString(body)) - rec := httptest.NewRecorder() - - app.ServeHTTP(rec, req) - - assert.Equal(t, http.StatusOK, rec.Code) - assert.Contains(t, rec.Body.String(), "added or updated") - assert.Contains(t, rec.Body.String(), "perms PROG-PROJ") - - if err := mock.ExpectationsWereMet(); err != nil { - t.Errorf("unfulfilled expectations: %s", err) - } -} - -func TestHandleAppCardGET_Success(t *testing.T) { - db, mock, err := sqlmock.New() - if err != nil { - t.Fatalf("error opening mock db: %s", err) - } - defer db.Close() - sqlxDB := sqlx.NewDb(db, "sqlmock") - - srv := &Server{ - db: sqlxDB, - Logger: &LogHandler{Logger: log.New(os.Stdout, "", 0)}, - } - - rows := sqlmock.NewRows([]string{"name", "content"}). - AddRow("1", []byte(`{"appCards": [{"perms": "PROG-PROJ", "title": "Title"}]}`)) - - mock.ExpectQuery("^SELECT name, content FROM config_schema.apps_page WHERE name=.+"). - WithArgs("1"). - WillReturnRows(rows) - - req := httptest.NewRequest(http.MethodGet, "/config/apps_page/appcard/PROG-PROJ", nil) - rec := httptest.NewRecorder() - app := iris.New() - ctx := app.ContextPool.Acquire(rec, req) - ctx.Params().Set("projectId", "PROG-PROJ") - - srv.handleAppCardGET(ctx) - - assert.Equal(t, http.StatusOK, rec.Code) - assert.Contains(t, rec.Body.String(), "PROG-PROJ") - assert.Contains(t, rec.Body.String(), "Title") - - if err := mock.ExpectationsWereMet(); err != nil { - t.Errorf("unfulfilled expectations: %s", err) - } -} diff --git a/gecko/handleConfig.go b/gecko/handleConfig.go deleted file mode 100644 index 71e9f3e..0000000 --- a/gecko/handleConfig.go +++ /dev/null @@ -1,263 +0,0 @@ -package gecko - -import ( - "database/sql" - "encoding/json" - "errors" - "fmt" - "net/http" - - "github.com/calypr/gecko/gecko/config" - "github.com/kataras/iris/v12" -) - -func isKnownType(t string) bool { - switch t { - case "explorer", "nav", "file_summary", "apps_page": - return true - } - return false -} - -func (server *Server) resolveConfigParams(ctx iris.Context) (string, string) { - configType := ctx.Params().Get("configType") - configId := ctx.Params().Get("configId") - - // Fallback to 'explorer' if type is completely missing (path /config/{configId}) - if configType == "" { - configType = "explorer" - } - - // Fallback to '1' for apps_page for backwards compatibility, otherwise 'default' - if configId == "" { - if configType == "apps_page" { - configId = "1" - } else { - configId = "default" - } - } - - return configType, configId -} - -// handleConfigListGET godoc -// @Summary List all configuration IDs for a specific type -// @Description Retrieve a list of all available configuration IDs for the given type (table). -// @Tags Config -// @Accept json -// @Produce json -// @Param configType path string false "Configuration Type (table name)" -// @Success 200 {array} string "List of config IDs" -// @Failure 404 {object} ErrorResponse "No configs found for this type" -// @Failure 500 {object} ErrorResponse "Server error" -// @Router /config/list [get] -// @Router /config/{configType}/list [get] -func (server *Server) handleConfigListGET(ctx iris.Context) { - configType := ctx.Params().Get("configType") - if configType == "" { - configType = ctx.URLParamDefault("type", "explorer") - } - - if !isKnownType(configType) { - msg := fmt.Sprintf("Unknown config type: %s", configType) - errResponse := newErrorResponse(msg, http.StatusBadRequest, nil) - errResponse.log.write(server.Logger) - _ = errResponse.write(ctx) - return - } - - server.Logger.Info("Listing configs for type: %s", configType) - - configList, err := configListByType(server.db, configType) - if err != nil { - errResponse := newErrorResponse(fmt.Sprintf("Database error: %s", err), 500, nil) - errResponse.log.write(server.Logger) - _ = errResponse.write(ctx) - return - } - - if configList == nil { - configList = []string{} - } - - jsonResponseFrom(configList, http.StatusOK).write(ctx) -} - -// handleConfigTypesGET returns a list of all supported configuration types. -func (server *Server) handleConfigTypesGET(ctx iris.Context) { - types := []string{"explorer", "nav", "file_summary", "apps_page"} - jsonResponseFrom(types, http.StatusOK).write(ctx) -} - -// handleConfigGET godoc -// @Summary Get a specific configuration -// @Description Retrieve configuration by configType and configId -// @Tags Config -// @Produce json -// @Param configType path string true "Configuration Type (table name)" -// @Param configId path string true "Configuration ID" -// @Success 200 {object} config.Config "Configuration details" -// @Failure 404 {object} ErrorResponse "Config not found" -// @Failure 500 {object} ErrorResponse "Server error" -// @Router /config/{configType}/{configId} [get] -func (server *Server) handleConfigGET(ctx iris.Context) { - configType, configId := server.resolveConfigParams(ctx) - server.Logger.Info("Fetching config: type=%s, id=%s", configType, configId) - - var cfg config.Configurable // Use the interface type - - switch configType { - case "explorer": - cfg = &config.Config{} - case "nav": - cfg = &config.NavPageLayoutProps{} - case "file_summary": - cfg = &config.FilesummaryConfig{} - case "apps_page": - cfg = &config.AppsConfig{} - default: - msg := fmt.Sprintf("Unknown config type: %s", configType) - errResponse := newErrorResponse(msg, 400, nil) - errResponse.log.write(server.Logger) - _ = errResponse.write(ctx) - return - } - - // Pass configType to the generic GET function - err := configGETGeneric(server.db, configId, configType, cfg) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - msg := fmt.Sprintf("no config found with configId: %s of type: %s", configId, configType) - errResponse := newErrorResponse(msg, 404, nil) - errResponse.log.write(server.Logger) - _ = errResponse.write(ctx) - return - } - msg := fmt.Sprintf("config query failed: %s", err.Error()) - errResponse := newErrorResponse(msg, 500, nil) - errResponse.log.write(server.Logger) - _ = errResponse.write(ctx) - return - } - - // Send back the populated config struct - jsonResponseFrom(cfg, http.StatusOK).write(ctx) -} - -// handleConfigDELETE godoc -// @Summary Delete a configuration -// @Description Delete configuration by configType and configId -// @Tags Config -// @Produce json -// @Param configType path string true "Configuration Type (table name)" -// @Param configId path string true "Configuration ID" example:"config_123" -// @Success 200 {object} map[string]interface{} "Configuration deleted" -// @Failure 404 {object} ErrorResponse "Config not found" -// @Failure 500 {object} ErrorResponse "Server error" -// @Router /config/{configType}/{configId} [delete] -func (server *Server) handleConfigDELETE(ctx iris.Context) { - configType, configId := server.resolveConfigParams(ctx) - - // Pass configType to the generic DELETE function - deleted, err := configDELETEGeneric(server.db, configId, configType) - if deleted == false && err == nil { - msg := fmt.Sprintf("no configId found with configId: %s in type: %s", configId, configType) - errResponse := newErrorResponse(msg, 404, nil) - errResponse.log.write(server.Logger) - _ = errResponse.write(ctx) - return - } - if err != nil { - msg := fmt.Sprintf("config query failed: %s", err.Error()) - errResponse := newErrorResponse(msg, 500, nil) - errResponse.log.write(server.Logger) - _ = errResponse.write(ctx) - return - } - - jsonResponseFrom( - map[string]any{ - "code": 200, - "message": fmt.Sprintf("DELETED: %s from type: %s", configId, configType), - }, - http.StatusOK, - ).write(ctx) -} - -// handleConfigPUT updates a configuration by ID. -// @Summary Update configuration -// @Description Replaces or updates the configuration items for a given config ID in a specific type (table) -// @Tags Config -// @Accept json -// @Produce json -// @Param configType path string true "Configuration Type (table name)" -// @Param configId path string true "Configuration ID" -// @Param body body config.Config true "Configuration items to set" -// @Success 200 {object} jsonResponse "Configuration successfully updated" -// @Failure 400 {object} ErrorResponse "Invalid request body" -// @Failure 500 {object} ErrorResponse "Internal server error" -// @Router /config/{configType}/{configId} [put] -func (server *Server) handleConfigPUT(ctx iris.Context) { - configType, configId := server.resolveConfigParams(ctx) - - var cfg config.Configurable // Use the interface type - - switch configType { - case "explorer": - cfg = &config.Config{} - case "nav": - cfg = &config.NavPageLayoutProps{} - case "file_summary": - cfg = &config.FilesummaryConfig{} - case "apps_page": - cfg = &config.AppsConfig{} - default: - msg := fmt.Sprintf("Unknown config type: %s", configType) - errResponse := newErrorResponse(msg, 400, nil) - errResponse.log.write(server.Logger) - _ = errResponse.write(ctx) - return - } - - body, err := ctx.GetBody() - if err != nil { - msg := fmt.Sprintf("GetBody() failed: %s", err.Error()) - errResponse := newErrorResponse(msg, 500, nil) - errResponse.log.write(server.Logger) - _ = errResponse.write(ctx) - return - } - if !json.Valid(body) { - msg := "Invalid JSON format" - errResponse := newErrorResponse(msg, 400, nil) - errResponse.log.write(server.Logger) - _ = errResponse.write(ctx) - return - } - // Note: We unmarshal into the specific struct pointer held by cfg - errResponse := unmarshal(body, cfg) - if errResponse != nil { - msg := fmt.Sprintf("body data unmarshal failed: %s", errResponse.err) - errResponse := newErrorResponse(msg, 400, nil) - errResponse.log.write(server.Logger) - _ = errResponse.write(ctx) - return - } - // Pass configType to the generic PUT function - err = configPUTGeneric(server.db, configId, configType, cfg) - if err != nil { - msg := fmt.Sprintf("configPut failed: %s", err.Error()) - errResponse := newErrorResponse(msg, 500, nil) - errResponse.log.write(server.Logger) - _ = errResponse.write(ctx) - return - } - - jsonResponseFrom( - map[string]any{ - "code": 200, - "message": fmt.Sprintf("ACCEPTED: %s for type: %s", configId, configType), - }, - http.StatusOK, - ).write(ctx) -} diff --git a/gecko/handleDir.go b/gecko/handleDir.go deleted file mode 100644 index 868b72c..0000000 --- a/gecko/handleDir.go +++ /dev/null @@ -1,159 +0,0 @@ -package gecko - -import ( - "fmt" - "net/http" - "path" - "strings" - - "github.com/bmeg/grip-graphql/middleware" - "github.com/bmeg/grip/gripql" - "github.com/kataras/iris/v12" -) - -// handleListProjects godoc -// @Summary Retrieve directory information for a project -// @Description Retrieve directory details for the given project ID and Directory path -// @Tags Directory -// @Produce json -// @Param projectId path string true "Project ID (format: program-project)" -// @Success 200 {object} map[string]interface{} "Directory information" -// @Failure 400 {object} ErrorResponse "Invalid request body or Directory path" -// @Failure 500 {object} ErrorResponse "Server error" -// @Router /dir/{projectId} [get] -func (server *Server) handleListProjects(ctx iris.Context) { - projs, errResponse := server.GetProjectsFromToken(ctx, &middleware.ProdJWTHandler{}, "read", "*") - if errResponse != nil { - errResponse.log.write(server.Logger) - _ = errResponse.write(ctx) - ctx.StopExecution() - return - } - server.Logger.Info("projects: %s", projs) - q := buildListProjectsQuery(projs) - - res, err := server.gripqlClient.Traversal( - ctx, - &gripql.GraphQuery{Graph: server.gripGraphName, Query: q.Statements}, - ) - if err != nil { - errResponse = newErrorResponse("internal server error", http.StatusInternalServerError, &err) - errResponse.log.write(server.Logger) - _ = errResponse.write(ctx) - ctx.StopExecution() - return - } - out := []string{} - for r := range res { - renda, ok := r.GetRender().GetStructValue().AsMap()["project"].(string) - if !ok { - continue - } - out = append(out, renda) - } - jsonResponseFrom(out, 200).write(ctx) -} - -// handleDirGet godoc -// @Summary Retrieve directory information for a project -// @Description Retrieve directory details for the given project ID and Directory path -// @Tags Directory -// @Produce json -// @Param projectId path string true "Project ID (format: program-project)" -// @Param directory_path query string true "Directory Path (e.g., /data/my-dir)" -// @Success 200 {object} map[string]interface{} "Directory information" -// @Failure 400 {object} ErrorResponse "Invalid request body or Directory path" -// @Failure 403 {object} ErrorResponse "User is not allowed on any resource path" -// @Failure 500 {object} ErrorResponse "Server error" -// @Router /dir/{projectId} [get] -func (server *Server) handleDirGet(ctx iris.Context) { - projectId := ctx.Params().Get("projectId") - dirPath := ctx.URLParam("directory") - - if dirPath == "" || !isValidPosixPath(&dirPath) { - errResponse := newErrorResponse(fmt.Sprintf("Invalid or missing Directory path: '%s'", dirPath), http.StatusBadRequest, nil) - errResponse.log.write(server.Logger) - _ = errResponse.write(ctx) - return - } - - project_split := strings.Split(projectId, "-") - if len(project_split) != 2 { - errResponse := newErrorResponse(fmt.Sprintf("Failed to parse request body: %v", fmt.Sprintf("incorrect path %s", ctx.Request().URL)), http.StatusNotFound, nil) - errResponse.log.write(server.Logger) - _ = errResponse.write(ctx) - ctx.StopExecution() - return - } - projectId = "/programs/" + project_split[0] + "/projects/" + project_split[1] - - q := buildDirGetQuery(projectId, dirPath) - - server.Logger.Info("Executing query: %s", q.String()) - - res, err := server.gripqlClient.Traversal(ctx, &gripql.GraphQuery{Graph: server.gripGraphName, Query: q.Statements}) - if err != nil { - errResponse := newErrorResponse("internal server error", http.StatusInternalServerError, &err) - errResponse.log.write(server.Logger) - _ = errResponse.write(ctx) - ctx.StopExecution() - return - } - out := []any{} - for r := range res { - out = append(out, r.GetVertex()) - } - - jsonResponseFrom(out, 200).write(ctx) -} - -func isValidPosixPath(p *string) bool { - if strings.ContainsRune(*p, '\000') { - return false - } - if !path.IsAbs(*p) { - return false - } - cleaned := path.Clean(*p) - if *p == "" || cleaned == "." { - return false - } - if cleaned == ".." || strings.HasPrefix(cleaned, "/..") { - return false - } - if strings.Contains(*p, "\\") { - return false - } - return true -} - -func buildListProjectsQuery(projs []any) *gripql.Query { - return gripql.V(). - HasLabel("ResearchStudy"). - Has(gripql.Within("auth_resource_path", projs...)). - As("project"). - OutE("rootDir_Directory"). // Only keep projects that have a root directory - Select("project"). // Go back to project - Distinct("auth_resource_path"). - Render(map[string]any{"project": "$project.auth_resource_path"}) -} - -func buildDirGetQuery(projectId, dirPath string) *gripql.Query { - // Shouldn't have to filter on base query because rootDir_Directory edge only ever connects to the root directory - // Start traversal from the project - q := gripql.V().HasLabel("ResearchStudy").Has(gripql.Eq("auth_resource_path", projectId)).OutE("rootDir_Directory").OutNull().OutNull() - if dirPath != "/" { - for splStr := range strings.SplitSeq(strings.Trim(dirPath, "/"), "/") { - // Traverse to child directory - // IMPORTANT: Filter by auth_resource_path at EACH step to ensure we stay within the project's ownership. - // This prevents bleeding into directories with the same name but different project ownership. - q = q.Has(gripql.Eq("name", splStr)). - Has(gripql.Eq("auth_resource_path", projectId)). - OutNull() - } - } else { - // Even for root, ensure the returned node belongs to the project (extra safety) - q = q.Has(gripql.Eq("auth_resource_path", projectId)) - } - return q -} diff --git a/gecko/handleVector.go b/gecko/handleVector.go deleted file mode 100644 index edf5925..0000000 --- a/gecko/handleVector.go +++ /dev/null @@ -1,370 +0,0 @@ -package gecko - -import ( - "fmt" - "net/http" - - "github.com/calypr/gecko/gecko/adapter" - "github.com/google/uuid" - "github.com/kataras/iris/v12" - "github.com/qdrant/go-client/qdrant" -) - -// handleListCollections godoc -// @Summary List all collections -// @Description Retrieve all collections -// @Tags Vector Collections -// @Produce json -// @Success 200 {object} map[string]interface{} "Collections listed" -// @Failure 500 {object} ErrorResponse "Server error" -// @Router /vector/collections [get] -func (server *Server) handleListCollections(ctx iris.Context) { - resp, err := server.qdrantClient.ListCollections(ctx.Request().Context()) - if err != nil { - msg := fmt.Sprintf("failed to list collections: %s", err.Error()) - errResponse := newErrorResponse(msg, adapter.MapQdrantErrorToHTTPStatus(err), nil) - errResponse.log.write(server.Logger) - _ = errResponse.write(ctx) - return - } - - successResponse := map[string]any{ - "result": resp, - "status": "ok", - } - - jsonResponseFrom(successResponse, http.StatusOK).write(ctx) -} - -// handleCreateCollection godoc -// @Summary Create a new collection -// @Description Create a collection with vector configuration -// @Tags Vector Collections -// @Accept json -// @Produce json -// @Param collection path string true "Collection name" -// @Param body body adapter.CreateCollectionRequest true "Collection configuration" -// @Success 200 {object} map[string]bool "Collection created" -// @Failure 400 {object} ErrorResponse "Invalid request" -// @Failure 500 {object} ErrorResponse "Server error" -// @Router /vector/collections/{collection} [put] -func (server *Server) handleCreateCollection(ctx iris.Context) { - collection := ctx.Params().Get("collection") - - var reqBody adapter.CreateCollectionRequest - if err := ctx.ReadJSON(&reqBody); err != nil { - msg := fmt.Sprintf("invalid request body: JSON parsing failed: %s", err.Error()) - errResponse := newErrorResponse(msg, http.StatusBadRequest, &err) - errResponse.log.write(server.Logger) - _ = errResponse.write(ctx) - return - } - - namedVectorsMap := map[string]*qdrant.VectorParams{} - for name, params := range reqBody.Vectors { - distanceVal, ok := qdrant.Distance_value[params.Distance] - if !ok { - msg := fmt.Sprintf("invalid distance: %s", params.Distance) - errResponse := newErrorResponse(msg, http.StatusBadRequest, nil) - errResponse.log.write(server.Logger) - _ = errResponse.write(ctx) - return - } - namedVectorsMap[name] = &qdrant.VectorParams{ - Size: params.Size, - Distance: qdrant.Distance(distanceVal), - } - } - - // 3. Construct the gRPC Vector Configuration using the available helper. - var vectorsConfig *qdrant.VectorsConfig - if len(namedVectorsMap) > 0 { - vectorsConfig = qdrant.NewVectorsConfigMap(namedVectorsMap) - } - - qdrantReq := &qdrant.CreateCollection{ - CollectionName: collection, - VectorsConfig: vectorsConfig, - } - - err := server.qdrantClient.CreateCollection(ctx.Request().Context(), qdrantReq) - if err != nil { - msg := fmt.Sprintf("failed to create collection: %s", err.Error()) - errResponse := newErrorResponse(msg, adapter.MapQdrantErrorToHTTPStatus(err), nil) - errResponse.log.write(server.Logger) - _ = errResponse.write(ctx) - return - } - - jsonResponseFrom(map[string]bool{"result": true}, http.StatusOK).write(ctx) -} - -// handleGetCollection retrieves metadata/info for a specific collection. -// @Summary Get collection info -// @Description Returns information about a collection by name -// @Tags Vector Collections -// @Accept json -// @Produce json -// @Param collection path string true "Collection name" -// @Success 200 {object} jsonResponse "Collection info" -// @Failure 400 {object} ErrorResponse "Invalid collection name" -// @Failure 404 {object} ErrorResponse "Collection not found" -// @Failure 500 {object} ErrorResponse "Internal server error" -// @Router /vector/collections/{collection} [get] -func (server *Server) handleGetCollection(ctx iris.Context) { - - collection := ctx.Params().Get("collection") - resp, err := server.qdrantClient.GetCollectionInfo(ctx.Request().Context(), collection) - if err != nil { - msg := fmt.Sprintf("failed to get collection info: %s", err.Error()) - errResponse := newErrorResponse(msg, adapter.MapQdrantErrorToHTTPStatus(err), nil) - errResponse.log.write(server.Logger) - _ = errResponse.write(ctx) - return - } - - jsonResponse := jsonResponseFrom(resp, http.StatusOK) - server.Logger.Info("%#v", jsonResponse) - jsonResponse.write(ctx) -} - -// handleUpdateCollection updates metadata/settings for a specific collection. -// @Summary Update collection -// @Description Updates an existing collection by name -// @Tags Vector Collections -// @Accept json -// @Produce json -// @Param collection path string true "Collection name" -// @Param body body qdrant.UpdateCollection true "Update collection request" -// @Success 200 {object} jsonResponse "Update successful" -// @Failure 400 {object} ErrorResponse "Invalid request" -// @Failure 404 {object} ErrorResponse "Collection not found" -// @Failure 500 {object} ErrorResponse "Internal server error" -// @Router /vector/collections/{collection} [patch] -func (server *Server) handleUpdateCollection(ctx iris.Context) { - collection := ctx.Params().Get("collection") - var req qdrant.UpdateCollection - if err := ctx.ReadJSON(&req); err != nil { - msg := fmt.Sprintf("invalid request body: JSON parsing failed: %s", err.Error()) - errResponse := newErrorResponse(msg, http.StatusBadRequest, &err) - errResponse.log.write(server.Logger) - _ = errResponse.write(ctx) - return - } - req.CollectionName = collection - err := server.qdrantClient.UpdateCollection(ctx.Request().Context(), &req) - if err != nil { - msg := fmt.Sprintf("failed to update collection: %s", err.Error()) - errResponse := newErrorResponse(msg, adapter.MapQdrantErrorToHTTPStatus(err), nil) - errResponse.log.write(server.Logger) - _ = errResponse.write(ctx) - return - } - jsonResponseFrom(map[string]bool{"result": true}, http.StatusOK).write(ctx) -} - -// handleDeleteCollection deletes a collection by name. -// @Summary Delete collection -// @Description Deletes a collection and all its points -// @Tags Vector Collections -// @Accept json -// @Produce json -// @Param collection path string true "Collection name" -// @Success 200 {object} jsonResponse "Delete successful" -// @Failure 404 {object} ErrorResponse "Collection not found" -// @Failure 500 {object} ErrorResponse "Internal server error" -// @Router /vector/collections/{collection} [delete] -func (server *Server) handleDeleteCollection(ctx iris.Context) { - collection := ctx.Params().Get("collection") - err := server.qdrantClient.DeleteCollection(ctx.Request().Context(), collection) - if err != nil { - msg := fmt.Sprintf("failed to delete collection: %s", err.Error()) - errResponse := newErrorResponse(msg, adapter.MapQdrantErrorToHTTPStatus(err), nil) - errResponse.log.write(server.Logger) - _ = errResponse.write(ctx) - return - } - jsonResponseFrom(map[string]bool{"result": true}, http.StatusOK).write(ctx) -} - -// handleGetPoint retrieves a single point from a collection. -// @Summary Get point -// @Description Returns a single point (with vectors and payload) by ID -// @Tags Vector -// @Accept json -// @Produce json -// @Param collection path string true "Collection name" -// @Param id path string true "Point UUID" -// @Success 200 {object} jsonResponse "Point found" -// @Failure 400 {object} ErrorResponse "Invalid request or ID" -// @Failure 404 {object} ErrorResponse "Point not found" -// @Failure 500 {object} ErrorResponse "Internal server error" -// @Router /vector/collections/{collection}/points/{id} [get] -func (server *Server) handleGetPoint(ctx iris.Context) { - collection := ctx.Params().Get("collection") - idStr := ctx.Params().Get("id") - if idStr == "" || collection == "" { - err := fmt.Errorf("collection or id not provide") - errResponse := newErrorResponse("collection or id is not provided", http.StatusBadRequest, &err) - errResponse.log.write(server.Logger) - _ = errResponse.write(ctx) - return - } - - _, err := uuid.Parse(idStr) - if err != nil { - errResponse := newErrorResponse("invalid UUID", http.StatusBadRequest, &err) - errResponse.log.write(server.Logger) - _ = errResponse.write(ctx) - return - } - req := &qdrant.GetPoints{ - CollectionName: collection, - Ids: []*qdrant.PointId{qdrant.NewIDUUID(idStr)}, - WithPayload: qdrant.NewWithPayload(true), - WithVectors: qdrant.NewWithVectors(true), - } - - resp, err := server.qdrantClient.Get(ctx.Request().Context(), req) - if err != nil { - msg := fmt.Sprintf("failed to get point: %s", err.Error()) - errResponse := newErrorResponse(msg, adapter.MapQdrantErrorToHTTPStatus(err), nil) - errResponse.log.write(server.Logger) - _ = errResponse.write(ctx) - return - } - if len(resp) == 0 { - errResponse := newErrorResponse("point not found", http.StatusNotFound, nil) - errResponse.log.write(server.Logger) - _ = errResponse.write(ctx) - return - } - jsonResponseFrom(adapter.ConvertQdrantRetrievedPointsResponse(resp), http.StatusOK).write(ctx) -} - -// handleQueryPoints godoc -// @Summary Query points in a collection -// @Description Executes a kNN or recommendation query against a collection -// @Tags Vector Search -// @Accept json -// @Produce json -// @Param collection path string true "Collection name" -// @Param request body adapter.QueryPointsRequest true "Query request body" -// @Success 200 {object} adapter.QueryPointsResponseItem -// @Failure 400 {object} ErrorResponse -// @Failure 404 {object} ErrorResponse -// @Failure 500 {object} ErrorResponse -// @Router /vector/collections/{collection}/points/search [post] -func (server *Server) handleQueryPoints(ctx iris.Context) { - collection := ctx.Params().Get("collection") - var req adapter.QueryPointsRequest - if err := ctx.ReadJSON(&req); err != nil { - msg := fmt.Sprintf("invalid request body: JSON parsing failed: %s", err.Error()) - errResponse := newErrorResponse(msg, http.StatusBadRequest, &err) - errResponse.log.write(server.Logger) - _ = errResponse.write(ctx) - return - } - qdrantReq, err := adapter.ToQdrantQuery(req, collection) - if err != nil { - errResponse := newErrorResponse(fmt.Sprintf("invalid query parameter: %s", err.Error()), http.StatusBadRequest, &err) - errResponse.log.write(server.Logger) - _ = errResponse.write(ctx) - return - } - resp, err := server.qdrantClient.Query(ctx.Request().Context(), qdrantReq) - if err != nil { - msg := fmt.Sprintf("failed to query points: %s", err.Error()) - errResponse := newErrorResponse(msg, adapter.MapQdrantErrorToHTTPStatus(err), nil) - errResponse.log.write(server.Logger) - _ = errResponse.write(ctx) - return - } - - jsonResponseFrom(adapter.ConvertQdrantPointsResponse(resp), http.StatusOK).write(ctx) -} - -// handleUpsertPoints inserts or updates points in a collection. -// @Summary Upsert points -// @Description Inserts new points or updates existing ones -// @Tags Vector -// @Accept json -// @Produce json -// @Param collection path string true "Collection name" -// @Param body body adapter.UpsertRequest true "Upsert request" -// @Success 200 {object} jsonResponse "Upsert successful" -// @Failure 400 {object} ErrorResponse "Invalid request" -// @Failure 500 {object} ErrorResponse "Internal server error" -// @Router /vector/collections/{collection}/points [put] -func (server *Server) handleUpsertPoints(ctx iris.Context) { - collection := ctx.Params().Get("collection") - - var reqBody adapter.UpsertRequest - if err := ctx.ReadJSON(&reqBody); err != nil { - msg := fmt.Sprintf("invalid request body: JSON parsing failed: %s", err.Error()) - errResponse := newErrorResponse(msg, http.StatusBadRequest, nil) - errResponse.log.write(server.Logger) - _ = errResponse.write(ctx) - return - } - upsertReq, err := adapter.ToQdrantUpsert(reqBody, collection) - if err != nil { - errResponse := newErrorResponse(err.Error(), http.StatusBadRequest, nil) - errResponse.log.write(server.Logger) - _ = errResponse.write(ctx) - return - } - resp, err := server.qdrantClient.Upsert(ctx.Request().Context(), upsertReq) - if err != nil { - msg := fmt.Sprintf("failed to upsert points: %s", err.Error()) - errResponse := newErrorResponse(msg, adapter.MapQdrantErrorToHTTPStatus(err), nil) - errResponse.log.write(server.Logger) - _ = errResponse.write(ctx) - return - } - jsonResponseFrom(resp, http.StatusOK).write(ctx) -} - -// handleDeletePoints deletes one or more points from a collection. -// @Summary Delete points -// @Description Deletes points in a collection based on filter or IDs -// @Tags Vector -// @Accept json -// @Produce json -// @Param collection path string true "Collection name" -// @Param body body adapter.DeletePoints true "Delete points request" -// @Success 200 {object} jsonResponse "Delete successful" -// @Failure 400 {object} ErrorResponse "Invalid request" -// @Failure 500 {object} ErrorResponse "Internal server error" -// @Router /vector/collections/{collection}/points/delete [post] -func (server *Server) handleDeletePoints(ctx iris.Context) { - collection := ctx.Params().Get("collection") - var req adapter.DeletePoints - if err := ctx.ReadJSON(&req); err != nil { - msg := fmt.Sprintf("invalid request body: JSON parsing failed: %s", err.Error()) - errResponse := newErrorResponse(msg, http.StatusBadRequest, &err) - errResponse.log.write(server.Logger) - _ = errResponse.write(ctx) - return - } - - Deletereq, err := adapter.ToQdrantDelete(req, collection) - if err != nil { - msg := fmt.Sprintf("failed to delete points: %s", err.Error()) - errResponse := newErrorResponse(msg, adapter.MapQdrantErrorToHTTPStatus(err), nil) - errResponse.log.write(server.Logger) - _ = errResponse.write(ctx) - return - } - - Deletereq.CollectionName = collection - _, err = server.qdrantClient.Delete(ctx.Request().Context(), Deletereq) - if err != nil { - msg := fmt.Sprintf("failed to delete points: %s", err.Error()) - errResponse := newErrorResponse(msg, adapter.MapQdrantErrorToHTTPStatus(err), nil) - errResponse.log.write(server.Logger) - _ = errResponse.write(ctx) - return - } - jsonResponseFrom(map[string]bool{"result": true}, http.StatusOK).write(ctx) -} diff --git a/gecko/middleware.go b/gecko/middleware.go deleted file mode 100644 index 6291782..0000000 --- a/gecko/middleware.go +++ /dev/null @@ -1,273 +0,0 @@ -package gecko - -import ( - "fmt" - "net/http" - "slices" - "strings" - "time" - - "github.com/bmeg/grip-graphql/middleware" - "github.com/kataras/iris/v12" -) - -func (server *Server) logRequestMiddleware(ctx iris.Context) { - start := time.Now() - ctx.Next() - latency := time.Since(start) - method := ctx.Request().Method - path := ctx.Request().URL.Path - status := ctx.ResponseWriter().StatusCode() - - server.Logger.Info("%s %s - Status: %d - Latency: %s", method, path, status, latency) -} - -func (server *Server) GetProjectsFromToken(ctx iris.Context, jwtHandler middleware.JWTHandler, method string, service string) ([]any, *ErrorResponse) { - Token := ctx.GetHeader("Authorization") - if Token != "" { - anyList, err := jwtHandler.GetAllowedResources(Token, method, service) - if err != nil { - fmt.Println("ERR: ", err) - val, ok := err.(*middleware.ServerError) - if !ok { - return nil, newErrorResponse("expecting error to be serverError type", http.StatusNotFound, nil) - - } - return nil, newErrorResponse(val.Message, val.StatusCode, nil) - } - return anyList, nil - } - return nil, newErrorResponse("Auth Token not provided", 401, nil) -} - -func ParseAccess(resourceList []string, resource string, method string) *ErrorResponse { - /* Iterates through a list of Gen3 resoures and returns true if - resource matches the allowable list of resource types for the provided method */ - - if len(resourceList) == 0 { - return newErrorResponse(fmt.Sprintf("User is not allowed to %s on any resource path", method), 403, nil) - } - if slices.Contains(resourceList, resource) { - return nil - } - return newErrorResponse(fmt.Sprintf("User is not allowed to %s on resource path: %s", method, resource), 403, nil) -} - -func convertAnyToStringSlice(anySlice []any) ([]string, *ErrorResponse) { - /* converts []any to []string */ - var stringSlice []string - for _, v := range anySlice { - str, ok := v.(string) - if !ok { - return nil, newErrorResponse(fmt.Sprintf("Element %v is not a string", v), 500, nil) - } - stringSlice = append(stringSlice, str) - } - return stringSlice, nil -} - -func (server *Server) ConfigAuthMiddleware(jwtHandler middleware.JWTHandler) iris.Handler { - return func(ctx iris.Context) { - method := ctx.Method() - configType, configID := server.resolveConfigParams(ctx) - - if configType == "explorer" { - var permMethod string - switch method { - case "GET": - permMethod = "read" - case "PUT", "DELETE": - permMethod = "create" - default: - errResp := newErrorResponse( - fmt.Sprintf("Unsupported HTTP method %s on %s", method, ctx.Request().URL), - http.StatusMethodNotAllowed, nil, - ) - errResp.log.write(server.Logger) - _ = errResp.write(ctx) - return - } - - ctx.Params().Set("projectId", configID) - - explorerAuthHandler := server.GeneralAuthMware(jwtHandler, permMethod, "*") - explorerAuthHandler(ctx) - if ctx.IsStopped() { - return - } - ctx.Next() - - } else { - // Non-explorer path (nav, apps_page, etc.) - if method == "GET" { - ctx.Next() - return - } - - if method == "PUT" || method == "DELETE" { - // Base config edit requires broader permission - baseAuthHandler := server.BaseConfigsAuthMiddleware(jwtHandler, "*", "*", "/programs") - baseAuthHandler(ctx) - - if ctx.IsStopped() { - return - } - ctx.Next() - return - } - - errResp := newErrorResponse( - fmt.Sprintf("Unsupported HTTP method %s on %s", method, ctx.Request().URL), - http.StatusMethodNotAllowed, nil, - ) - errResp.log.write(server.Logger) - _ = errResp.write(ctx) - } - } -} - -func (server *Server) GeneralAuthMware(jwtHandler middleware.JWTHandler, method, service string) iris.Handler { - return func(ctx iris.Context) { - authorizationHeader := ctx.GetHeader("Authorization") - if authorizationHeader == "" { - errResponse := newErrorResponse("Authorization token not provided", http.StatusUnauthorized, nil) - errResponse.log.write(server.Logger) - _ = errResponse.write(ctx) - ctx.StopExecution() - return - } - - projectId := ctx.Params().Get("projectId") - project_split := strings.Split(projectId, "-") - if len(project_split) != 2 { - errResponse := newErrorResponse(fmt.Sprintf("Failed to parse request body: %v", fmt.Sprintf("incorrect path %s", ctx.Request().URL)), http.StatusNotFound, nil) - errResponse.log.write(server.Logger) - _ = errResponse.write(ctx) - ctx.StopExecution() - return - } - - anyList, err := jwtHandler.GetAllowedResources(authorizationHeader, method, service) - if err != nil { - val, ok := err.(*middleware.ServerError) - if !ok { - errResponse := newErrorResponse("expecting error to be serverError type", http.StatusNotFound, nil) - errResponse.log.write(server.Logger) - _ = errResponse.write(ctx) - ctx.StopExecution() - return - } - errResponse := newErrorResponse(val.Message, val.StatusCode, nil) - errResponse.log.write(server.Logger) - _ = errResponse.write(ctx) - ctx.StopExecution() - return - } - - resourceList, convErr := convertAnyToStringSlice(anyList) - if convErr != nil { - convErr.log.write(server.Logger) - _ = convErr.write(ctx) - ctx.StopExecution() - return - } - - convErr = ParseAccess(resourceList, "/programs/"+project_split[0]+"/projects/"+project_split[1], method) - if convErr != nil { - convErr.log.write(server.Logger) - _ = convErr.write(ctx) - ctx.StopExecution() - return - } - ctx.Next() - } -} - -func (server *Server) BaseConfigsAuthMiddleware(jwtHandler middleware.JWTHandler, method, service, resourcePath string) iris.Handler { - return func(ctx iris.Context) { - authorizationHeader := ctx.GetHeader("Authorization") - if authorizationHeader == "" { - errResponse := newErrorResponse("Authorization token not provided", http.StatusUnauthorized, nil) - errResponse.log.write(server.Logger) - _ = errResponse.write(ctx) - ctx.StopExecution() - return - } - Token := authorizationHeader - prodHandler, ok := jwtHandler.(*middleware.ProdJWTHandler) - if !ok { - errResponse := newErrorResponse("Internal server error: Invalid JWT handler configuration for this route", http.StatusInternalServerError, nil) - errResponse.log.write(server.Logger) - _ = errResponse.write(ctx) - ctx.StopExecution() - return - } - allowed, err := prodHandler.CheckResourceServiceAccess(Token, method, service, resourcePath) - if err != nil { - val, ok := err.(*middleware.ServerError) - if !ok { - errResponse := newErrorResponse("expecting error to be serverError type", http.StatusNotFound, nil) - errResponse.log.write(server.Logger) - _ = errResponse.write(ctx) - ctx.StopExecution() - return - } - errResponse := newErrorResponse(val.Message, val.StatusCode, nil) - errResponse.log.write(server.Logger) - _ = errResponse.write(ctx) - ctx.StopExecution() - return - } - if !allowed { - errResponse := newErrorResponse(fmt.Sprintf("User does not have required %s permission on resource %s", method, "/programs"), http.StatusForbidden, nil) - errResponse.log.write(server.Logger) - _ = errResponse.write(ctx) - ctx.StopExecution() - return - } - ctx.Next() - } -} - -func (server *Server) AppCardAuthMiddleware(jwtHandler middleware.JWTHandler) iris.Handler { - return func(ctx iris.Context) { - method := ctx.Method() - - var permMethod string - switch method { - case "GET": - permMethod = "read" - case "POST", "DELETE": - permMethod = "create" - default: - errResp := newErrorResponse( - fmt.Sprintf("Unsupported HTTP method %s", method), - http.StatusMethodNotAllowed, nil, - ) - errResp.log.write(server.Logger) - _ = errResp.write(ctx) - return - } - - projectId := ctx.Params().Get("projectId") - - if projectId == "" { - errResponse := newErrorResponse("Missing or empty projectId", 400, nil) - errResponse.log.write(server.Logger) - _ = errResponse.write(ctx) - return - } - - // Set projectId for GeneralAuthMware to check permissions - ctx.Params().Set("projectId", projectId) - - authHandler := server.GeneralAuthMware(jwtHandler, permMethod, "*") - authHandler(ctx) - - if ctx.IsStopped() { - return - } - - ctx.Next() - } -} diff --git a/gecko/response.go b/gecko/response.go deleted file mode 100644 index ebbb5a9..0000000 --- a/gecko/response.go +++ /dev/null @@ -1,153 +0,0 @@ -package gecko - -import ( - "encoding/json" - "net/http" - - "github.com/kataras/iris/v12" - "github.com/uc-cdis/arborist/arborist" - "google.golang.org/protobuf/encoding/protojson" - "google.golang.org/protobuf/proto" -) - -// jsonResponse represents a generic JSON response structure -// @Schema -type jsonResponse struct { - content any - code int -} - -// ErrorResponse represents an error response structure -// @Schema -type ErrorResponse struct { - HTTPError arborist.HTTPError `json:"error"` - // err stores an internal representation of an error in case it needs to be - // tracked along with the http-ish version in `HTTPError`. - err error - // log embeds a LogCache so we can log things to the response and write it - // out later to the server's logger. - log LogCache -} - -// jsonResponseFrom creates a JSON response -// @Summary Create a JSON response -// @Description Constructs a JSON response with content and HTTP status code, supporting both JSON and Protobuf marshaling -// @Param content body any true "Response content (JSON or Protobuf message)" -// @Param code body int true "HTTP status code" -// @Return jsonResponse -func jsonResponseFrom(content any, code int) *jsonResponse { - return &jsonResponse{ - content: content, - code: code, - } -} - -// write serializes and writes the JSON response to the Iris context -// @Summary Write JSON response -// @Description Serializes the response content (JSON or Protobuf) and writes it to the HTTP response -// @Param ctx body iris.Context true "Iris context" -// @Return error -func (response *jsonResponse) write(ctx iris.Context) error { - ctx.ContentType("application/json") - if response.code > 0 { - ctx.StatusCode(response.code) - } else { - ctx.StatusCode(http.StatusOK) - } - - var bytes []byte - var err error - - if msg, ok := response.content.(proto.Message); ok { - opts := protojson.MarshalOptions{ - EmitUnpopulated: true, - UseProtoNames: true, - Indent: "", - } - if wantPrettyJSON(ctx.Request()) { - opts.Indent = " " - } - bytes, err = opts.Marshal(msg) - } else { - if wantPrettyJSON(ctx.Request()) { - bytes, err = json.MarshalIndent(response.content, "", " ") - } else { - bytes, err = json.Marshal(response.content) - } - } - if err != nil { - return err - } - - _, err = ctx.Write(bytes) - if err != nil { - return err - } - return nil -} - -func wantPrettyJSON(r *http.Request) bool { - prettyJSON := false - if r.Method == "GET" { - prettyJSON = prettyJSON || r.URL.Query().Get("pretty") == "true" - prettyJSON = prettyJSON || r.URL.Query().Get("prettyJSON") == "true" - } - return prettyJSON -} - -// newErrorResponse creates an error response -// @Summary Create an error response -// @Description Constructs an error response with a message, HTTP status code, and optional error -// @Param message body string true "Error message" -// @Param code body int true "HTTP status code" -// @Param err body error false "Optional internal error" -// @Return ErrorResponse -func newErrorResponse(message string, code int, err *error) *ErrorResponse { - response := &ErrorResponse{ - HTTPError: arborist.HTTPError{ - Message: message, - Code: code, - }, - } - if err != nil { - response.err = *err - } - if code >= 500 { - response.log.Error("%s", message) - } else { - response.log.Info("%s", message) - } - return response -} - -// write serializes and writes the error response to the Iris context -// @Summary Write error response -// @Description Serializes the error response and writes it to the HTTP response -// @Param ctx body iris.Context true "Iris context" -// @Return error -func (errorResponse *ErrorResponse) write(ctx iris.Context) error { - var bytes []byte - var err error - - prettyJSON := false - if ctx.Method() == "GET" { - prettyJSON = prettyJSON || ctx.URLParamDefault("pretty", "false") == "true" - prettyJSON = prettyJSON || ctx.URLParamDefault("pretty", "false") == "true" - } - - if prettyJSON { - bytes, err = json.MarshalIndent(errorResponse, "", " ") - } else { - bytes, err = json.Marshal(errorResponse) - } - if err != nil { - return err - } - ctx.ContentType("application/json") - ctx.StatusCode(errorResponse.HTTPError.Code) - _, err = ctx.Write(bytes) - if err != nil { - return err - } - return nil -} diff --git a/gecko/server.go b/gecko/server.go deleted file mode 100644 index d0bd7f7..0000000 --- a/gecko/server.go +++ /dev/null @@ -1,314 +0,0 @@ -package gecko - -import ( - "bytes" - "encoding/json" - "errors" - "fmt" - "log" - "net/http" - "reflect" - "regexp" - "strings" - - "github.com/bmeg/grip-graphql/middleware" - "github.com/bmeg/grip/gripql" - "github.com/iris-contrib/swagger" - "github.com/iris-contrib/swagger/swaggerFiles" - "github.com/jmoiron/sqlx" - "github.com/kataras/iris/v12" - "github.com/qdrant/go-client/qdrant" - "github.com/uc-cdis/arborist/arborist" -) - -type LogHandler struct { - Logger *log.Logger -} - -type Server struct { - iris *iris.Application - db *sqlx.DB - jwtApp arborist.JWTDecoder - Logger *LogHandler - stmts *arborist.CachedStmts - qdrantClient *qdrant.Client - gripqlClient *gripql.Client - gripGraphName string -} - -func NewServer() *Server { - return &Server{} -} - -func (server *Server) WithLogger(logger *log.Logger) *Server { - server.Logger = &LogHandler{Logger: logger} - return server -} - -func (server *Server) WithJWTApp(jwtApp arborist.JWTDecoder) *Server { - server.jwtApp = jwtApp - return server -} - -func (server *Server) WithDB(db *sqlx.DB) *Server { - server.db = db - server.stmts = arborist.NewCachedStmts(db) - return server -} - -func (server *Server) WithQdrantClient(client *qdrant.Client) *Server { - server.qdrantClient = client - return server -} - -func (server *Server) WithGripqlClient(client *gripql.Client, gripGraphName string) *Server { - server.gripqlClient = client - server.gripGraphName = gripGraphName - return server -} - -func (server *Server) Init() (*Server, error) { - - if server.jwtApp == nil { - return nil, errors.New("gecko server initialized without JWT app") - } - if server.Logger == nil { - return nil, errors.New("gecko server initialized without logger") - } - if server.db == nil { - server.Logger.Warning("Database endpoints will be disabled.") - } - if server.qdrantClient == nil { - server.Logger.Warning("Qdrant endpoints will be disabled.") - } - if server.gripqlClient == nil || server.gripGraphName == "" { - server.Logger.Warning("Grip endpoints will be disabled.") - } - server.Logger.Info("Gecko server initialized successfully.") - return server, nil -} - -func (server *Server) MakeRouter() *iris.Application { - router := iris.New() - router.Get("/swagger/doc.json", func(ctx iris.Context) { - ctx.ServeFile("./docs/swagger.json") - }) - router.Use(recoveryMiddleware) - router.Use(server.logRequestMiddleware) - router.OnErrorCode(iris.StatusNotFound, handleNotFound) - router.Get("/health", server.handleHealth) - - if server.gripqlClient != nil { - router.Get("/dir", server.handleListProjects) - router.Get("/dir/{projectId}", server.GeneralAuthMware(&middleware.ProdJWTHandler{}, "read", "*"), server.handleDirGet) - } else { - server.Logger.Warning("Skipping gripql Directory endpoints — no database configured") - } - - // project id must be in the form [program-project] if not permissions checking will not work and you won't be able to view the project - if server.db != nil { - // Configuration Endpoints - Explicit Type Mapping to avoid route collisions - configGroup := router.Party("/config") - { - configGroup.Get("/types", server.handleConfigTypesGET) - configGroup.Get("/list", server.handleConfigListGET) - - // Explorer Config Party - explorer := configGroup.Party("/explorer", func(ctx iris.Context) { ctx.Params().Set("configType", "explorer"); ctx.Next() }) - { - explorer.Get("/list", server.handleConfigListGET) - explorer.Get("/{configId}", server.ConfigAuthMiddleware(&middleware.ProdJWTHandler{}), server.handleConfigGET) - explorer.Put("/{configId}", server.ConfigAuthMiddleware(&middleware.ProdJWTHandler{}), server.handleConfigPUT) - explorer.Delete("/{configId}", server.ConfigAuthMiddleware(&middleware.ProdJWTHandler{}), server.handleConfigDELETE) - } - - // Apps Page Config Party - appsPage := configGroup.Party("/apps_page", func(ctx iris.Context) { ctx.Params().Set("configType", "apps_page"); ctx.Next() }) - { - appsPage.Get("/list", server.handleConfigListGET) - appsPage.Get("/", server.ConfigAuthMiddleware(&middleware.ProdJWTHandler{}), server.handleConfigGET) - appsPage.Get("/{configId}", server.ConfigAuthMiddleware(&middleware.ProdJWTHandler{}), server.handleConfigGET) - appsPage.Put("/{configId}", server.ConfigAuthMiddleware(&middleware.ProdJWTHandler{}), server.handleConfigPUT) - appsPage.Delete("/{configId}", server.ConfigAuthMiddleware(&middleware.ProdJWTHandler{}), server.handleConfigDELETE) - - // AppCard Special Operation Endpoints (Nested under apps_page/appcard) - appcard := appsPage.Party("/appcard") - { - appcard.Get("/{projectId}", server.AppCardAuthMiddleware(&middleware.ProdJWTHandler{}), server.handleAppCardGET) - appcard.Post("/{projectId}", server.AppCardAuthMiddleware(&middleware.ProdJWTHandler{}), server.handleAppCardPOST) - appcard.Delete("/{projectId}", server.AppCardAuthMiddleware(&middleware.ProdJWTHandler{}), server.handleAppCardDELETE) - } - } - - // Nav Config Party - nav := configGroup.Party("/nav", func(ctx iris.Context) { ctx.Params().Set("configType", "nav"); ctx.Next() }) - { - nav.Get("/list", server.handleConfigListGET) - nav.Get("/{configId}", server.ConfigAuthMiddleware(&middleware.ProdJWTHandler{}), server.handleConfigGET) - nav.Put("/{configId}", server.ConfigAuthMiddleware(&middleware.ProdJWTHandler{}), server.handleConfigPUT) - nav.Delete("/{configId}", server.ConfigAuthMiddleware(&middleware.ProdJWTHandler{}), server.handleConfigDELETE) - } - - // File Summary Config Party - fs := configGroup.Party("/file_summary", func(ctx iris.Context) { ctx.Params().Set("configType", "file_summary"); ctx.Next() }) - { - fs.Get("/list", server.handleConfigListGET) - fs.Get("/{configId}", server.ConfigAuthMiddleware(&middleware.ProdJWTHandler{}), server.handleConfigGET) - fs.Put("/{configId}", server.ConfigAuthMiddleware(&middleware.ProdJWTHandler{}), server.handleConfigPUT) - fs.Delete("/{configId}", server.ConfigAuthMiddleware(&middleware.ProdJWTHandler{}), server.handleConfigDELETE) - } - } - } else { - server.Logger.Warning("Skipping DB endpoints — no database configured") - } - - if server.qdrantClient != nil { - vectorRouter := router.Party("/vector") - { - swaggerUI := swagger.Handler(swaggerFiles.Handler, - swagger.URL("/vector/swagger/doc.json"), - swagger.DeepLinking(true), - swagger.Prefix("/vector/swagger"), - ) - - vectorRouter.Get("/swagger/doc.json", func(ctx iris.Context) { - ctx.ServeFile("./docs/swagger.json") - }) - vectorRouter.Get("/swagger", swaggerUI) - vectorRouter.Get("/swagger/{any:path}", swaggerUI) - - collections := vectorRouter.Party("/collections") - { - collections.Get("", server.handleListCollections) - collections.Put("/{collection}", server.handleCreateCollection) - collections.Get("/{collection}", server.handleGetCollection) - collections.Patch("/{collection}", server.handleUpdateCollection) - collections.Delete("/{collection}", server.handleDeleteCollection) - - points := collections.Party("/{collection}/points") - { - points.Put("", server.handleUpsertPoints) - points.Get("/{id}", server.handleGetPoint) - points.Post("/search", server.handleQueryPoints) - points.Post("/delete", server.handleDeletePoints) - } - } - } - } else { - server.Logger.Warning("Skipping Qdrant endpoints — no vector store configured") - } - - if server.gripqlClient != nil && server.gripGraphName != "" { - // register your Grip routes here - } else { - server.Logger.Warning("Skipping Grip endpoints — no graph configured") - } - - // Final trim/slash middleware and build - router.UseRouter(func(ctx iris.Context) { - req := ctx.Request() - if req == nil || req.URL == nil { - server.Logger.Warning("Request or URL is nil") - ctx.StatusCode(http.StatusInternalServerError) - ctx.WriteString("Internal Server Error") - return - } - req.URL.Path = strings.TrimSuffix(req.URL.Path, "/") - ctx.Next() - }) - - if err := router.Build(); err != nil { - server.Logger.Error("Failed to build Iris router: %v", err) - } - - return router -} - -func recoveryMiddleware(ctx iris.Context) { - defer func() { - if r := recover(); r != nil { - ctx.Application().Logger().Errorf("panic recovered: %v", r) - ctx.StatusCode(iris.StatusInternalServerError) - ctx.WriteString("Internal Server Error") - } - }() - ctx.Next() -} - -// handleHealth godoc -// @Summary Health check endpoint -// @Description Checks the database connection and returns the server status -// @Tags Health -// @Produce json -// @Success 200 {string} string "Healthy" -// @Failure 500 {object} ErrorResponse "Database unavailable" -// @Router /health [get] -func (server *Server) handleHealth(ctx iris.Context) { - if server.db != nil { - err := server.db.Ping() - if err != nil { - server.Logger.Error("Database ping failed: %v", err) - response := newErrorResponse("database unavailable", 500, nil) - _ = response.write(ctx) - return - } - } else { - server.Logger.Warning("Health check: Database connection not configured.") - } - server.Logger.Info("Health check passed") - _ = jsonResponseFrom("Healthy", http.StatusOK).write(ctx) -} - -func handleNotFound(ctx iris.Context) { - response := struct { - Error struct { - Message string `json:"message"` - Code int `json:"code"` - } `json:"error"` - }{ - Error: struct { - Message string `json:"message"` - Code int `json:"code"` - }{ - Message: "not found", - Code: 404, - }, - } - _ = jsonResponseFrom(response, 404).write(ctx) -} - -func unmarshal(body []byte, x any) *ErrorResponse { - if len(body) == 0 { - return newErrorResponse("empty request body", http.StatusBadRequest, nil) - } - - dec := json.NewDecoder(bytes.NewReader(body)) - dec.DisallowUnknownFields() - err := dec.Decode(x) - if err != nil { - structType := reflect.TypeOf(x) - if structType.Kind() == reflect.Ptr { - structType = structType.Elem() - } - - msg := fmt.Sprintf( - "could not parse %s from JSON; make sure input has correct types", - structType, - ) - response := newErrorResponse(msg, http.StatusBadRequest, &err) - response.log.Info( - "tried to create %s but input was invalid; offending JSON: %s", - structType, - loggableJSON(body), - ) - return response - } - - return nil -} - -func loggableJSON(bytes []byte) []byte { - return regWhitespace.ReplaceAll(bytes, []byte("")) -} - -var regWhitespace *regexp.Regexp = regexp.MustCompile(`\s`) diff --git a/go.mod b/go.mod index 0509524..4a2b032 100644 --- a/go.mod +++ b/go.mod @@ -1,98 +1,107 @@ module github.com/calypr/gecko -go 1.24.2 +go 1.26.3 require ( github.com/DATA-DOG/go-sqlmock v1.5.2 - github.com/bmeg/grip v0.0.0-20251106174949-7f0784126fbb - github.com/bmeg/grip-graphql v0.0.0-20251106183540-8b2f286248b3 + github.com/bmeg/grip v0.0.0-20260315213055-7b57be8d0a5d + github.com/bmeg/grip-graphql v0.0.0-20260324182301-b2fbe9969997 + github.com/calypr/syfon/apigen v0.2.7 + github.com/calypr/syfon/client v0.2.8 + github.com/go-git/go-git/v5 v5.16.3 + github.com/gofiber/fiber/v3 v3.2.0 + github.com/golang-jwt/jwt/v5 v5.3.1 + github.com/google/go-github/v87 v87.0.0 github.com/google/uuid v1.6.0 - github.com/iris-contrib/swagger v0.0.0-20230820002204-56b041d3471a github.com/jmoiron/sqlx v1.4.0 - github.com/kataras/iris/v12 v12.2.11 - github.com/lib/pq v1.10.9 - github.com/qdrant/go-client v1.15.0 + github.com/lib/pq v1.12.3 + github.com/qdrant/go-client v1.18.1 github.com/stretchr/testify v1.11.1 github.com/swaggo/swag v1.16.6 - github.com/uc-cdis/arborist v0.0.0-20241016192742-6190d06f1061 - github.com/uc-cdis/go-authutils v0.1.2 - google.golang.org/grpc v1.71.0 - google.golang.org/protobuf v1.36.7 + github.com/uc-cdis/arborist v0.0.0-20260324212054-708d91019bea + github.com/uc-cdis/go-authutils v0.1.3-0.20251210162059-6e78e9723952 + google.golang.org/grpc v1.81.1 + google.golang.org/protobuf v1.36.11 ) require ( - github.com/BurntSushi/toml v1.3.2 // indirect - github.com/CloudyKit/fastprinter v0.0.0-20200109182630-33d98a066a53 // indirect - github.com/CloudyKit/jet/v6 v6.2.0 // indirect - github.com/Joker/jade v1.1.3 // indirect + github.com/gofiber/schema v1.7.1 // indirect + github.com/gofiber/utils/v2 v2.0.5 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.22 // indirect + github.com/philhofer/fwd v1.2.0 // indirect + github.com/tinylib/msgp v1.6.4 // indirect + github.com/valyala/fasthttp v1.71.0 // indirect +) + +require ( + dario.cat/mergo v1.0.2 // indirect github.com/KyleBanks/depth v1.2.1 // indirect - github.com/PuerkitoBio/purell v1.1.1 // indirect - github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect - github.com/Shopify/goreferrer v0.0.0-20220729165902-8cddb4f5de06 // indirect - github.com/andybalholm/brotli v1.1.0 // indirect - github.com/aymerick/douceur v0.2.0 // indirect - github.com/bytedance/sonic v1.14.0 // indirect - github.com/bytedance/sonic/loader v0.3.0 // indirect - github.com/cloudwego/base64x v0.1.5 // indirect - github.com/davecgh/go-spew v1.1.1 // indirect - github.com/fatih/structs v1.1.0 // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/ProtonMail/go-crypto v1.1.6 // indirect + github.com/andybalholm/brotli v1.2.1 // indirect + github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect + github.com/bytedance/gopkg v0.1.4 // indirect + github.com/bytedance/sonic v1.15.1 // indirect + github.com/bytedance/sonic/loader v0.5.1 // indirect + github.com/calypr/syfon v0.3.0 // indirect + github.com/cloudflare/circl v1.6.1 // indirect + github.com/cloudwego/base64x v0.1.7 // indirect + github.com/cyphar/filepath-securejoin v0.4.1 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/emirpasic/gods v1.18.1 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect - github.com/flosch/pongo2/v4 v4.0.2 // indirect - github.com/go-openapi/jsonpointer v0.19.5 // indirect - github.com/go-openapi/jsonreference v0.19.6 // indirect - github.com/go-openapi/spec v0.20.4 // indirect - github.com/go-openapi/swag v0.19.15 // indirect - github.com/golang-jwt/jwt/v5 v5.2.1 // indirect - github.com/golang/snappy v0.0.5-0.20231225225746-43d5d4cd4e0e // indirect - github.com/gomarkdown/markdown v0.0.0-20240328165702-4d01890c35c0 // indirect - github.com/gorilla/css v1.0.1 // indirect - github.com/gorilla/handlers v1.5.1 // indirect - github.com/gorilla/mux v1.8.0 // indirect + github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect + github.com/go-git/go-billy/v5 v5.6.2 // indirect + github.com/go-jose/go-jose/v3 v3.0.5 // indirect + github.com/go-openapi/jsonpointer v0.23.1 // indirect + github.com/go-openapi/jsonreference v0.21.5 // indirect + github.com/go-openapi/spec v0.22.4 // indirect + github.com/go-openapi/swag/conv v0.26.0 // indirect + github.com/go-openapi/swag/jsonname v0.26.0 // indirect + github.com/go-openapi/swag/jsonutils v0.26.0 // indirect + github.com/go-openapi/swag/loading v0.26.0 // indirect + github.com/go-openapi/swag/stringutils v0.26.0 // indirect + github.com/go-openapi/swag/typeutils v0.26.0 // indirect + github.com/go-openapi/swag/yamlutils v0.26.0 // indirect + github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect + github.com/google/go-querystring v1.2.0 // indirect + github.com/gorilla/handlers v1.5.2 // indirect + github.com/gorilla/mux v1.8.1 // indirect github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 // indirect - github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 // indirect - github.com/iris-contrib/schema v0.0.6 // indirect - github.com/josharian/intern v1.0.0 // indirect - github.com/kataras/blocks v0.0.8 // indirect - github.com/kataras/golog v0.1.11 // indirect - github.com/kataras/pio v0.0.13 // indirect - github.com/kataras/sitemap v0.0.6 // indirect - github.com/kataras/tunnel v0.0.4 // indirect - github.com/klauspost/compress v1.18.0 // indirect - github.com/klauspost/cpuid/v2 v2.2.8 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect + github.com/hashicorp/go-retryablehttp v0.7.8 // indirect + github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect + github.com/kevinburke/ssh_config v1.2.0 // indirect + github.com/klauspost/compress v1.18.6 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/kr/pretty v0.3.1 // indirect github.com/kr/text v0.2.0 // indirect github.com/logrusorgru/aurora v2.0.3+incompatible // indirect - github.com/mailgun/raymond/v2 v2.0.48 // indirect - github.com/mailru/easyjson v0.7.7 // indirect - github.com/microcosm-cc/bluemonday v1.0.27 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/rogpeppe/go-internal v1.12.0 // indirect - github.com/russross/blackfriday/v2 v2.1.0 // indirect - github.com/schollz/closestmatch v2.1.0+incompatible // indirect - github.com/sergi/go-diff v1.1.0 // indirect - github.com/sirupsen/logrus v1.9.3 // indirect - github.com/tdewolff/minify/v2 v2.20.19 // indirect - github.com/tdewolff/parse/v2 v2.7.12 // indirect + github.com/oapi-codegen/runtime v1.4.0 // indirect + github.com/pjbgf/sha1cd v0.3.2 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/rogpeppe/go-internal v1.14.1 // indirect + github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect + github.com/sirupsen/logrus v1.9.4 // indirect + github.com/skeema/knownhosts v1.3.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect - github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect - github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect - github.com/yosssi/ace v0.0.5 // indirect - golang.org/x/arch v0.0.0-20210923205945-b76863e36670 // indirect - golang.org/x/crypto v0.36.0 // indirect - golang.org/x/exp v0.0.0-20240707233637-46b078467d37 // indirect - golang.org/x/mod v0.22.0 // indirect - golang.org/x/net v0.37.0 // indirect - golang.org/x/sync v0.12.0 // indirect - golang.org/x/sys v0.33.0 // indirect - golang.org/x/term v0.30.0 // indirect - golang.org/x/text v0.23.0 // indirect - golang.org/x/time v0.5.0 // indirect - golang.org/x/tools v0.28.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20250811230008-5f3141c8851a // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20250811230008-5f3141c8851a // indirect - gopkg.in/ini.v1 v1.67.0 // indirect - gopkg.in/square/go-jose.v2 v2.6.0 // indirect - gopkg.in/yaml.v2 v2.4.0 // indirect + github.com/xanzy/ssh-agent v0.3.3 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/arch v0.27.0 // indirect + golang.org/x/crypto v0.51.0 // indirect + golang.org/x/mod v0.36.0 // indirect + golang.org/x/net v0.54.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.44.0 // indirect + golang.org/x/term v0.43.0 // indirect + golang.org/x/text v0.37.0 // indirect + golang.org/x/tools v0.45.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260519071638-aa98bba5eb94 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260519071638-aa98bba5eb94 // indirect + gopkg.in/ini.v1 v1.67.1 // indirect + gopkg.in/warnings.v0 v0.1.2 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index da74376..6d6c2f6 100644 --- a/go.sum +++ b/go.sum @@ -1,153 +1,181 @@ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= +dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= -github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8= -github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= -github.com/CloudyKit/fastprinter v0.0.0-20200109182630-33d98a066a53 h1:sR+/8Yb4slttB4vD+b9btVEnWgL3Q00OBTzVT8B9C0c= -github.com/CloudyKit/fastprinter v0.0.0-20200109182630-33d98a066a53/go.mod h1:+3IMCy2vIlbG1XG/0ggNQv0SvxCAIpPM5b1nCz56Xno= -github.com/CloudyKit/jet/v6 v6.2.0 h1:EpcZ6SR9n28BUGtNJSvlBqf90IpjeFr36Tizxhn/oME= -github.com/CloudyKit/jet/v6 v6.2.0/go.mod h1:d3ypHeIRNo2+XyqnGA8s+aphtcVpjP5hPwP/Lzo7Ro4= github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= -github.com/Joker/hpp v1.0.0 h1:65+iuJYdRXv/XyN62C1uEmmOx3432rNG/rKlX6V7Kkc= -github.com/Joker/hpp v1.0.0/go.mod h1:8x5n+M1Hp5hC0g8okX3sR3vFQwynaX/UgSOM9MeBKzY= -github.com/Joker/jade v1.1.3 h1:Qbeh12Vq6BxURXT1qZBRHsDxeURB8ztcL6f3EXSGeHk= -github.com/Joker/jade v1.1.3/go.mod h1:T+2WLyt7VH6Lp0TRxQrUYEs64nRc83wkMQrfeIQKduM= github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= -github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI= -github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= -github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M= -github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= -github.com/Shopify/goreferrer v0.0.0-20220729165902-8cddb4f5de06 h1:KkH3I3sJuOLP3TjA/dfr4NAY8bghDwnXiU7cTKxQqo0= -github.com/Shopify/goreferrer v0.0.0-20220729165902-8cddb4f5de06/go.mod h1:7erjKLwalezA0k99cWs5L11HWOAPNjdUZ6RxH1BXbbM= -github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU= -github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY= -github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M= -github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= -github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= -github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= +github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw= +github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= +github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= +github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro= +github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= +github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= +github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= -github.com/bmeg/grip v0.0.0-20251106174949-7f0784126fbb h1:GYQ0Tfj36h8m+6dZolHDQJyVnjjqT3pgBZlFGHT+HOE= -github.com/bmeg/grip v0.0.0-20251106174949-7f0784126fbb/go.mod h1:BxpaUuXbymKkEPvSDslziCzU17akkBo1ubu9nAFsI1A= -github.com/bmeg/grip-graphql v0.0.0-20251106183540-8b2f286248b3 h1:rWKYGUcCdStTQxCjKZU1e3/0PioP12WQPchsRZFSe5M= -github.com/bmeg/grip-graphql v0.0.0-20251106183540-8b2f286248b3/go.mod h1:YcZY4w597zXAzi5iA9A48KIRGpnSSCb66ZFyN88LRKA= -github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ= -github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA= -github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= -github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= -github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= +github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= +github.com/bmeg/grip v0.0.0-20260315213055-7b57be8d0a5d h1:PjEFC1Wexn/BFpYYQk3TW8Q2ZP3lXvxpWd5Ot8VE+Xo= +github.com/bmeg/grip v0.0.0-20260315213055-7b57be8d0a5d/go.mod h1:zViWUxIRoCtLxaYRln/EL661fsvar0SrAPXgljehe8I= +github.com/bmeg/grip-graphql v0.0.0-20260324182301-b2fbe9969997 h1:LoSf3yRABUh/blzeYSRF1GcuRps3OFWwvL9DKhrxP5o= +github.com/bmeg/grip-graphql v0.0.0-20260324182301-b2fbe9969997/go.mod h1:SCRXWgPeHmgpEqvq5kgxAVmZfJIWETYHnOAdjtaVMZc= +github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM= +github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4= +github.com/bytedance/sonic v1.15.1 h1:nJD5PmM0vY7J8CT6MxoqbVAAMhkSmV2HgRAUrrpLoOw= +github.com/bytedance/sonic v1.15.1/go.mod h1:mT2NbXunuaEbnZ+mRIX/vYqKISmgEuHFDI4UzmKx2SA= +github.com/bytedance/sonic/loader v0.5.1 h1:Ygpfa9zwRCCKSlrp5bBP/b/Xzc3VxsAW+5NIYXrOOpI= +github.com/bytedance/sonic/loader v0.5.1/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/calypr/syfon v0.3.0 h1:IScccee8OgyGcxJnPrURnVPyYk6e/50esfMmgDglkPA= +github.com/calypr/syfon v0.3.0/go.mod h1:LCfNhrG3i6zRe5PwwC7dkOJUSJ1Un/uVBZPklZupMio= +github.com/calypr/syfon/apigen v0.2.7 h1:h5ZcxoLFPuLXt+8EUWICbKQgE/MMu2jym4oUshV3m8k= +github.com/calypr/syfon/apigen v0.2.7/go.mod h1:VrRZ2A17YV91Zsm7CF/u1/Z+DfcZAk8Q4Pk1xklb5xU= +github.com/calypr/syfon/client v0.2.8 h1:xMq5pZNnvkY9UnfrQVvJXe8Ic/Y0D2OLyreMaqNVP5U= +github.com/calypr/syfon/client v0.2.8/go.mod h1:9MTLDQ5clwDHcDuKCEuVaV7bebsCIUtUQxTegW3RDVo= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= -github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4= -github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= -github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= +github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0= +github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= +github.com/cloudwego/base64x v0.1.7 h1:NppS+Fgzg5ovhn4NkUXaDT3x9jldgH5ToMCqzBSi2zI= +github.com/cloudwego/base64x v0.1.7/go.mod h1:Cu1PV9zfrSf7ET2tIbWbbEy7jO7HHJ13q4X2SQ8aWYg= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s= +github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o= +github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= +github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= +github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/fatih/color v1.17.0 h1:GlRw1BRJxkpqUCBKzKOw098ed57fEsKeNjpTe3cSjK4= github.com/fatih/color v1.17.0/go.mod h1:YZ7TlrGPkiz6ku9fK3TLD/pl3CpsiFyu8N92HLgmosI= -github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= -github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= -github.com/felixge/httpsnoop v1.0.1/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= -github.com/flosch/pongo2/v4 v4.0.2 h1:gv+5Pe3vaSVmiJvh/BZa82b7/00YUGm0PIyVVLop0Hw= -github.com/flosch/pongo2/v4 v4.0.2/go.mod h1:B5ObFANs/36VwxxlgKpdchIJHMvHB562PW+BWPhwZD8= +github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78= +github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= +github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= +github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= +github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= +github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM= +github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU= +github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4= +github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= +github.com/go-git/go-git/v5 v5.16.3 h1:Z8BtvxZ09bYm/yYNgPKCzgWtaRqDTgIKRgIRHBfU6Z8= +github.com/go-git/go-git/v5 v5.16.3/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8= +github.com/go-jose/go-jose/v3 v3.0.5 h1:BLLJWbC4nMZOfuPVxoZIxeYsn6Nl2r1fITaJ78UQlVQ= +github.com/go-jose/go-jose/v3 v3.0.5/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ= github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= -github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= -github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= -github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= -github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY= -github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= -github.com/go-openapi/jsonreference v0.19.6 h1:UBIxjkht+AWIgYzCDSv2GN+E/togfwXUJFRTWhl2Jjs= -github.com/go-openapi/jsonreference v0.19.6/go.mod h1:diGHMEHg2IqXZGKxqyvWdfWU/aim5Dprw5bqpKkTvns= -github.com/go-openapi/spec v0.20.4 h1:O8hJrt0UMnhHcluhIdUgCLRWyM2x7QkBXRvOs7m+O1M= -github.com/go-openapi/spec v0.20.4/go.mod h1:faYFR1CvsJZ0mNsmsphTMSoRrNV3TEDoAM7FOEWeq8I= -github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= +github.com/go-openapi/jsonpointer v0.23.1 h1:1HBACs7XIwR2RcmItfdSFlALhGbe6S92p0ry4d1GWg4= +github.com/go-openapi/jsonpointer v0.23.1/go.mod h1:iWRmZTrGn7XwYhtPt/fvdSFj1OfNBngqRT2UG3BxSqY= +github.com/go-openapi/jsonreference v0.21.5 h1:6uCGVXU/aNF13AQNggxfysJ+5ZcU4nEAe+pJyVWRdiE= +github.com/go-openapi/jsonreference v0.21.5/go.mod h1:u25Bw85sX4E2jzFodh1FOKMTZLcfifd1Q+iKKOUxExw= +github.com/go-openapi/spec v0.22.4 h1:4pxGjipMKu0FzFiu/DPwN3CTBRlVM2yLf/YTWorYfDQ= +github.com/go-openapi/spec v0.22.4/go.mod h1:WQ6Ai0VPWMZgMT4XySjlRIE6GP1bGQOtEThn3gcWLtQ= github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM= -github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ= +github.com/go-openapi/swag/conv v0.26.0 h1:5yGGsPYI1ZCva93U0AoKi/iZrNhaJEjr324YVsiD89I= +github.com/go-openapi/swag/conv v0.26.0/go.mod h1:tpAmIL7X58VPnHHiSO4uE3jBeRamGsFsfdDeDtb5ECE= +github.com/go-openapi/swag/jsonname v0.26.0 h1:gV1NFX9M8avo0YSpmWogqfQISigCmpaiNci8cGECU5w= +github.com/go-openapi/swag/jsonname v0.26.0/go.mod h1:urBBR8bZNoDYGr653ynhIx+gTeIz0ARZxHkAPktJK2M= +github.com/go-openapi/swag/jsonutils v0.26.0 h1:FawFML2iAXsPqmERscuMPIHmFsoP1tOqWkxBaKNMsnA= +github.com/go-openapi/swag/jsonutils v0.26.0/go.mod h1:2VmA0CJlyFqgawOaPI9psnjFDqzyivIqLYN34t9p91E= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.0 h1:apqeINu/ICHouqiRZbyFvuDge5jCmmLTqGQ9V95EaOM= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.0/go.mod h1:AyM6QT8uz5IdKxk5akv0y6u4QvcL9GWERt0Jx/F/R8Y= +github.com/go-openapi/swag/loading v0.26.0 h1:Apg6zaKhCJurpJer0DCxq99qwmhFddBhaMX7kilDcko= +github.com/go-openapi/swag/loading v0.26.0/go.mod h1:dBxQ/6V2uBaAQdevN18VELE6xSpJWZxLX4txe12JwDg= +github.com/go-openapi/swag/stringutils v0.26.0 h1:qZQngLxs5s7SLijc3N2ZO+fUq2o8LjuWAASSrJuh+xg= +github.com/go-openapi/swag/stringutils v0.26.0/go.mod h1:sWn5uY+QIIspwPhvgnqJsH8xqFT2ZbYcvbcFanRyhFE= +github.com/go-openapi/swag/typeutils v0.26.0 h1:2kdEwdiNWy+JJdOvu5MA2IIg2SylWAFuuyQIKYybfq4= +github.com/go-openapi/swag/typeutils v0.26.0/go.mod h1:oovDuIUvTrEHVMqWilQzKzV4YlSKgyZmFh7AlfABNVE= +github.com/go-openapi/swag/yamlutils v0.26.0 h1:H7O8l/8NJJQ/oiReEN+oMpnGMyt8G0hl460nRZxhLMQ= +github.com/go-openapi/swag/yamlutils v0.26.0/go.mod h1:1evKEGAtP37Pkwcc7EWMF0hedX0/x3Rkvei2wtG/TbU= +github.com/go-openapi/testify/enable/yaml/v2 v2.4.2 h1:5zRca5jw7lzVREKCZVNBpysDNBjj74rBh0N2BGQbSR0= +github.com/go-openapi/testify/enable/yaml/v2 v2.4.2/go.mod h1:XVevPw5hUXuV+5AkI1u1PeAm27EQVrhXTTCPAF85LmE= +github.com/go-openapi/testify/v2 v2.4.2 h1:tiByHpvE9uHrrKjOszax7ZvKB7QOgizBWGBLuq0ePx4= +github.com/go-openapi/testify/v2 v2.4.2/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw= github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= -github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= -github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= +github.com/gofiber/fiber/v3 v3.2.0 h1:g9+09D320foINPpCnR3ibQ5oBEFHjAWRRfDG1te54u8= +github.com/gofiber/fiber/v3 v3.2.0/go.mod h1:FHOsc2Db7HhHpsE62QAaJlXVV1pNkbZEptZ4jtti7m4= +github.com/gofiber/schema v1.7.1 h1:oSJBKdgP8JeIME4TQSAqlNKTU2iBB+2RNmKi8Nsc+TI= +github.com/gofiber/schema v1.7.1/go.mod h1:A/X5Ffyru4p9eBdp99qu+nzviHzQiZ7odLT+TwxWhbk= +github.com/gofiber/utils/v2 v2.0.5 h1:IMXoI2A5Dao/aMMBURTNxnhbtQO4kUwUFOgcwFSIjLU= +github.com/gofiber/utils/v2 v2.0.5/go.mod h1:FwwopfzwAQsoXLCHhOT24eH2jQfBgrrra9S5p0+luxg= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= -github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= -github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= -github.com/golang/snappy v0.0.5-0.20231225225746-43d5d4cd4e0e h1:4bw4WeyTYPp0smaXiJZCNnLrvVBqirQVreixayXezGc= -github.com/golang/snappy v0.0.5-0.20231225225746-43d5d4cd4e0e/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= -github.com/gomarkdown/markdown v0.0.0-20240328165702-4d01890c35c0 h1:4gjrh/PN2MuWCCElk8/I4OCKRKWCCo2zEct3VKCbibU= -github.com/gomarkdown/markdown v0.0.0-20240328165702-4d01890c35c0/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= -github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= +github.com/google/go-github/v87 v87.0.0 h1:9Ck3dcOxWJyfsN8tzdah4YvmqB/7ZsstMglv/PkOsl0= +github.com/google/go-github/v87 v87.0.0/go.mod h1:hGUoT5pwm/ck5uLL+wroSVQfg8mpe+buxllCcGV4VaM= +github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0= +github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= -github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= -github.com/gorilla/handlers v1.5.1 h1:9lRY6j8DEeeBT10CvO9hGW0gmky0BprnvDI5vfhUHH4= -github.com/gorilla/handlers v1.5.1/go.mod h1:t8XrUpc4KVXb7HGyJ4/cEnwQiaxrX/hz1Zv/4g96P1Q= -github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= -github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= -github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY= -github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY= +github.com/gorilla/handlers v1.5.2 h1:cLTUSsNkgcwhgRqvCNmdbRWG0A3N4F+M2nWKdScwyEE= +github.com/gorilla/handlers v1.5.2/go.mod h1:dX+xVpaxdSw+q0Qek8SSsl3dfMk3jNddUkMzo0GtH0w= +github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= +github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 h1:UH//fgunKIs4JdUbpDl1VZCDaL56wXCB/5+wF6uHfaI= github.com/grpc-ecosystem/go-grpc-middleware v1.4.0/go.mod h1:g5qyo/la0ALbONm6Vbp88Yd8NsDy6rZz+RcrMPxvld8= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI= -github.com/imkira/go-interpol v1.1.0 h1:KIiKr0VSG2CUW1hl1jpiyuzuJeKUUpC8iM1AIE7N1Vk= -github.com/imkira/go-interpol v1.1.0/go.mod h1:z0h2/2T3XF8kyEPpRgJ3kmNv+C43p+I/CoI+jC3w2iA= -github.com/iris-contrib/httpexpect/v2 v2.15.2 h1:T9THsdP1woyAqKHwjkEsbCnMefsAFvk8iJJKokcJ3Go= -github.com/iris-contrib/httpexpect/v2 v2.15.2/go.mod h1:JLDgIqnFy5loDSUv1OA2j0mb6p/rDhiCqigP22Uq9xE= -github.com/iris-contrib/schema v0.0.6 h1:CPSBLyx2e91H2yJzPuhGuifVRnZBBJ3pCOMbOvPZaTw= -github.com/iris-contrib/schema v0.0.6/go.mod h1:iYszG0IOsuIsfzjymw1kMzTL8YQcCWlm65f3wX8J5iA= -github.com/iris-contrib/swagger v0.0.0-20230820002204-56b041d3471a h1:EC+WWO8I4TU9jkmTO/pZciMTNTz/LHQHfM4vSypPDGQ= -github.com/iris-contrib/swagger v0.0.0-20230820002204-56b041d3471a/go.mod h1:z3K0yN8DqDkwlfqSzR8HHqiZCb57W/8QaPOMBmJiMLU= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs= +github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= +github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48= +github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= -github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= -github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= -github.com/kataras/blocks v0.0.8 h1:MrpVhoFTCR2v1iOOfGng5VJSILKeZZI+7NGfxEh3SUM= -github.com/kataras/blocks v0.0.8/go.mod h1:9Jm5zx6BB+06NwA+OhTbHW1xkMOYxahnqTN5DveZ2Yg= -github.com/kataras/golog v0.1.11 h1:dGkcCVsIpqiAMWTlebn/ZULHxFvfG4K43LF1cNWSh20= -github.com/kataras/golog v0.1.11/go.mod h1:mAkt1vbPowFUuUGvexyQ5NFW6djEgGyxQBIARJ0AH4A= -github.com/kataras/iris/v12 v12.2.11 h1:sGgo43rMPfzDft8rjVhPs6L3qDJy3TbBrMD/zGL1pzk= -github.com/kataras/iris/v12 v12.2.11/go.mod h1:uMAeX8OqG9vqdhyrIPv8Lajo/wXTtAF43wchP9WHt2w= -github.com/kataras/pio v0.0.13 h1:x0rXVX0fviDTXOOLOmr4MUxOabu1InVSTu5itF8CXCM= -github.com/kataras/pio v0.0.13/go.mod h1:k3HNuSw+eJ8Pm2lA4lRhg3DiCjVgHlP8hmXApSej3oM= -github.com/kataras/sitemap v0.0.6 h1:w71CRMMKYMJh6LR2wTgnk5hSgjVNB9KL60n5e2KHvLY= -github.com/kataras/sitemap v0.0.6/go.mod h1:dW4dOCNs896OR1HmG+dMLdT7JjDk7mYBzoIRwuj5jA4= -github.com/kataras/tunnel v0.0.4 h1:sCAqWuJV7nPzGrlb0os3j49lk2JhILT0rID38NHNLpA= -github.com/kataras/tunnel v0.0.4/go.mod h1:9FkU4LaeifdMWqZu7o20ojmW4B7hdhv2CMLwfnHGpYw= +github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= +github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= +github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE= -github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= -github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= -github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= -github.com/klauspost/cpuid/v2 v2.2.8 h1:+StwCXwm9PdpiEkPyzBXIy+M9KUb4ODm0Zarf1kS5BM= -github.com/klauspost/cpuid/v2 v2.2.8/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= -github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= +github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= +github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -156,161 +184,155 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ= +github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= github.com/logrusorgru/aurora v2.0.3+incompatible h1:tOpm7WcpBTn4fjmVfgpQq0EfczGlG91VSDkswnjF5A8= github.com/logrusorgru/aurora v2.0.3+incompatible/go.mod h1:7rIyQOR62GCctdiQpZ/zOJlFyk6y+94wXzv6RNZgaR4= -github.com/mailgun/raymond/v2 v2.0.48 h1:5dmlB680ZkFG2RN/0lvTAghrSxIESeu9/2aeDqACtjw= -github.com/mailgun/raymond/v2 v2.0.48/go.mod h1:lsgvL50kgt1ylcFJYZiULi5fjPBkkhNfj4KA0W54Z18= -github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= -github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= -github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= -github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= -github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= -github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= -github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= +github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= -github.com/mattn/go-sqlite3 v1.14.23 h1:gbShiuAP1W5j9UOksQ06aiiqPMxYecovVGwmTxWtuw0= -github.com/mattn/go-sqlite3 v1.14.23/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= -github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= -github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= -github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= -github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= -github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/mattn/go-sqlite3 v1.14.42 h1:MigqEP4ZmHw3aIdIT7T+9TLa90Z6smwcthx+Azv4Cgo= +github.com/mattn/go-sqlite3 v1.14.42/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ= +github.com/oapi-codegen/runtime v1.4.0 h1:KLOSFOp7UzkbS7Cs1ms6NBEKYr0WmH2wZG0KKbd2er4= +github.com/oapi-codegen/runtime v1.4.0/go.mod h1:5sw5fxCDmnOzKNYmkVNF8d34kyUeejJEY8HNT2WaPec= +github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= +github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= github.com/opentracing/opentracing-go v1.1.0/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o= +github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= +github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= +github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4= +github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/qdrant/go-client v1.15.0 h1:4BvoSJSK1mLjGBRhhbwMvG+0+QFkCqG89DZs4NwrGTM= -github.com/qdrant/go-client v1.15.0/go.mod h1:iO8ts78jL4x6LDHFOViyYWELVtIBDTjOykBmiOTHLnQ= +github.com/qdrant/go-client v1.18.1 h1:o/dDmSl6ONAlaAFtjdlzztcs3NH0tJY3l5C/z/Uu0bE= +github.com/qdrant/go-client v1.18.1/go.mod h1:Xkfp+r89uNOgSbvilVAhCZ3wKI4G+hB/r9Zr2m4zifI= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= -github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= -github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= -github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= -github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/sanity-io/litter v1.5.5 h1:iE+sBxPBzoK6uaEP5Lt3fHNgpKcHXc/A2HGETy0uJQo= -github.com/sanity-io/litter v1.5.5/go.mod h1:9gzJgR2i4ZpjZHsKvUXIRQVk7P+yM3e+jAF7bU2UI5U= -github.com/schollz/closestmatch v2.1.0+incompatible h1:Uel2GXEpJqOWBrlyI+oY9LTiyyjYS17cCYRqP13/SHk= -github.com/schollz/closestmatch v2.1.0+incompatible/go.mod h1:RtP1ddjLong6gTkbtmuhtR2uUrrJOpYzYRvbcPAid+g= -github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= -github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= +github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= +github.com/shamaton/msgpack/v3 v3.1.0 h1:jsk0vEAqVvvS9+fTZ5/EcQ9tz860c9pWxJ4Iwecz8gU= +github.com/shamaton/msgpack/v3 v3.1.0/go.mod h1:DcQG8jrdrQCIxr3HlMYkiXdMhK+KfN2CitkyzsQV4uc= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= -github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= -github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= -github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= +github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8= +github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY= +github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= -github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI= github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg= -github.com/tdewolff/minify/v2 v2.20.19 h1:tX0SR0LUrIqGoLjXnkIzRSIbKJ7PaNnSENLD4CyH6Xo= -github.com/tdewolff/minify/v2 v2.20.19/go.mod h1:ulkFoeAVWMLEyjuDz1ZIWOA31g5aWOawCFRp9R/MudM= -github.com/tdewolff/parse/v2 v2.7.12 h1:tgavkHc2ZDEQVKy1oWxwIyh5bP4F5fEh/JmBwPP/3LQ= -github.com/tdewolff/parse/v2 v2.7.12/go.mod h1:3FbJWZp3XT9OWVN3Hmfp0p/a08v4h8J9W1aghka0soA= -github.com/tdewolff/test v1.0.11-0.20231101010635-f1265d231d52/go.mod h1:6DAvZliBAAnD7rhVgwaM7DE5/d9NMOAJ09SqYqeK4QE= -github.com/tdewolff/test v1.0.11-0.20240106005702-7de5f7df4739 h1:IkjBCtQOOjIn03u/dMQK9g+Iw9ewps4mCl1nB8Sscbo= -github.com/tdewolff/test v1.0.11-0.20240106005702-7de5f7df4739/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8= +github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ= +github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= -github.com/uc-cdis/arborist v0.0.0-20241016192742-6190d06f1061 h1:OwOYKPYN8Jw7GA2wL0F5gy4EDQiz9ER8JZuUbZZ9i3w= -github.com/uc-cdis/arborist v0.0.0-20241016192742-6190d06f1061/go.mod h1:163E0gn2kR7Q2cGswNQZ2ScTUfsYPzk57fEDCtC6Ykc= -github.com/uc-cdis/go-authutils v0.1.2 h1:ts9Q1jHs0YIzeErZ6MAsbTrQwfNL4RjE9Wcx/+TFSd0= -github.com/uc-cdis/go-authutils v0.1.2/go.mod h1:NT4wNQiGGq9K/vhZoaJQmPwmTAQXz+haWSBB5QyL4Mc= +github.com/uc-cdis/arborist v0.0.0-20260324212054-708d91019bea h1:zP5vXVML/88Qf9XTB5WSo1UDQ/A9+126if1gYsqcWKU= +github.com/uc-cdis/arborist v0.0.0-20260324212054-708d91019bea/go.mod h1:zpgBBxoHOSpFw0KYS1ZQDVwUdlCfw5vWffmTMVSRzUM= +github.com/uc-cdis/go-authutils v0.1.3-0.20251210162059-6e78e9723952 h1:LGY5D3llPGcfUJymb2HJyVkAaqMUTBvVISCeI21p+p0= +github.com/uc-cdis/go-authutils v0.1.3-0.20251210162059-6e78e9723952/go.mod h1:yltnlkJAiTx42kFA7lHGO56gUl6n2MJT3s6LyX57rAo= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= -github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8= -github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= -github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= -github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= -github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f h1:J9EGpcZtP0E/raorCMxlFGSTBrsSlaDGf3jU/qvAE2c= -github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= -github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= -github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= -github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= -github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= -github.com/yalp/jsonpath v0.0.0-20180802001716-5cc68e5049a0 h1:6fRhSjgLCkTD3JnJxvaJ4Sj+TYblw757bqYgZaOq5ZY= -github.com/yalp/jsonpath v0.0.0-20180802001716-5cc68e5049a0/go.mod h1:/LWChgwKmvncFJFHJ7Gvn9wZArjbV5/FppcK2fKk/tI= -github.com/yosssi/ace v0.0.5 h1:tUkIP/BLdKqrlrPwcmH0shwEEhTRHoGnc1wFIWmaBUA= -github.com/yosssi/ace v0.0.5/go.mod h1:ALfIzm2vT7t5ZE7uoIZqF3TQ7SAOyupFZnkrF5id+K0= -github.com/yudai/gojsondiff v1.0.0 h1:27cbfqXLVEJ1o8I6v3y9lg8Ydm53EKqHXAOMxEGlCOA= -github.com/yudai/gojsondiff v1.0.0/go.mod h1:AY32+k2cwILAkW1fbgxQ5mUmMiZFgLIV+FBNExI05xg= -github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 h1:BHyfKlQyqbsFN5p3IfnEUduWvb9is428/nNb5L3U01M= -github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82/go.mod h1:lgjkn3NuSvDfVJdfcVVdX+jpBxNmX4rDAzaS45IcYoM= +github.com/valyala/fasthttp v1.71.0 h1:tepR7H+Guh9VUqxxcPggYi8R3lGUu2Rsdh+z7/FCY3k= +github.com/valyala/fasthttp v1.71.0/go.mod h1:z1sDUvOShhXq/C9mwH/fSm1Vb71tUJwmQdgkBrBNwnA= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= +github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= +github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= -go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= -go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= -go.opentelemetry.io/otel v1.34.0 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY= -go.opentelemetry.io/otel v1.34.0/go.mod h1:OWFPOQ+h4G8xpyjgqo4SxJYdDQ/qmRH+wivy7zzx9oI= -go.opentelemetry.io/otel/metric v1.34.0 h1:+eTR3U0MyfWjRDhmFMxe2SsW64QrZ84AOhvqS7Y+PoQ= -go.opentelemetry.io/otel/metric v1.34.0/go.mod h1:CEDrp0fy2D0MvkXE+dPV7cMi8tWZwX3dmaIhwPOaqHE= -go.opentelemetry.io/otel/sdk v1.34.0 h1:95zS4k/2GOy069d321O8jWgYsW3MzVV+KuSPKp7Wr1A= -go.opentelemetry.io/otel/sdk v1.34.0/go.mod h1:0e/pNiaMAqaykJGKbi+tSjWfNNHMTxoC9qANsCzbyxU= -go.opentelemetry.io/otel/sdk/metric v1.34.0 h1:5CeK9ujjbFVL5c1PhLuStg1wxA7vQv7ce1EK0Gyvahk= -go.opentelemetry.io/otel/sdk/metric v1.34.0/go.mod h1:jQ/r8Ze28zRKoNRdkjCZxfs6YvBTG1+YIqyFVFYec5w= -go.opentelemetry.io/otel/trace v1.34.0 h1:+ouXS2V8Rd4hp4580a8q23bg0azF2nI8cqLYnC8mh/k= -go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= +go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= +go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= +go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= +go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/goleak v1.1.10/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A= go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= go.uber.org/zap v1.18.1/go.mod h1:xg/QME4nWcxGxrpdeYfq7UvYrLh66cuVKdrbD1XF/NI= -golang.org/x/arch v0.0.0-20210923205945-b76863e36670 h1:18EFjUmQOcUvxNYSkA6jO9VAiXCnxFY6NyDX0bHDmkU= -golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/arch v0.27.0 h1:0WNVcR8u9yFz8j5FvdHpgwNp3FS5U4guYdzHwEiGjoU= +golang.org/x/arch v0.27.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= -golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= +golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp v0.0.0-20240707233637-46b078467d37 h1:uLDX+AfeFCct3a2C7uIWBKMJIR3CJMhcgfrUAqjRK6w= -golang.org/x/exp v0.0.0-20240707233637-46b078467d37/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= +golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= +golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.5.1/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro= -golang.org/x/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4= -golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= +golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190327091125-710a502c58a2/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM= -golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c= -golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= +golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= -golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -318,25 +340,34 @@ golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= -golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= +golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y= -golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= +golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= -golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= -golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= -golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= @@ -346,54 +377,49 @@ golang.org/x/tools v0.0.0-20191108193012-7d206e10da11/go.mod h1:b+2E5dAYhXwXZwtn golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.1.9/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU= -golang.org/x/tools v0.28.0 h1:WuB6qZ4RPCQo5aP3WdKZS7i595EdWqWR8vqJTlwTVK8= -golang.org/x/tools v0.28.0/go.mod h1:dcIOrVd3mfQKTgrDVQHqCPMWy6lnhfhtX3hLXYVLfRw= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= +golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto v0.0.0-20200423170343-7949de9c1215/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto/googleapis/api v0.0.0-20250811230008-5f3141c8851a h1:DMCgtIAIQGZqJXMVzJF4MV8BlWoJh2ZuFiRdAleyr58= -google.golang.org/genproto/googleapis/api v0.0.0-20250811230008-5f3141c8851a/go.mod h1:y2yVLIE/CSMCPXaHnSKXxu1spLPnglFLegmgdY23uuE= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250811230008-5f3141c8851a h1:tPE/Kp+x9dMSwUm/uM0JKK0IfdiJkwAbSMSeZBXXJXc= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250811230008-5f3141c8851a/go.mod h1:gw1tLEfykwDz2ET4a12jcXt4couGAm7IwsVaTy0Sflo= +google.golang.org/genproto/googleapis/api v0.0.0-20260519071638-aa98bba5eb94 h1:DddG61lE5LkX6144z22i0gma9BMBs5aZ9B8lZLobxyw= +google.golang.org/genproto/googleapis/api v0.0.0-20260519071638-aa98bba5eb94/go.mod h1:1dCETSCY2YKZNXQE3h4fun3TYwF5p8jejRKZgfWAgAY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260519071638-aa98bba5eb94 h1:eZCjr/aAF8c5ccm5pb6T4EXgIei5MlAAPWPJk+5ArfY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260519071638-aa98bba5eb94/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= -google.golang.org/grpc v1.71.0 h1:kF77BGdPTQ4/JZWMlb9VpJ5pa25aqvVqogsxNHHdeBg= -google.golang.org/grpc v1.71.0/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec= -google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A= -google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= +google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= -gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= -gopkg.in/square/go-jose.v2 v2.6.0 h1:NGk74WTnPKBNUhNzQX7PYcTLUjoq7mzKk2OKbvwk2iI= -gopkg.in/square/go-jose.v2 v2.6.0/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI= +gopkg.in/ini.v1 v1.67.1 h1:tVBILHy0R6e4wkYOn3XmiITt/hEVH4TFMYvAX2Ytz6k= +gopkg.in/ini.v1 v1.67.1/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss= +gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= +gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -moul.io/http2curl/v2 v2.3.0 h1:9r3JfDzWPcbIklMOs2TnIFzDYvfAZvjeavG6EzP7jYs= -moul.io/http2curl/v2 v2.3.0/go.mod h1:RW4hyBjTWSYDOxapodpNEtX0g5Eb16sxklBqmd2RHcE= -nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= diff --git a/init_postgres.sh b/init_postgres.sh index f3f4449..77a3de6 100644 --- a/init_postgres.sh +++ b/init_postgres.sh @@ -6,7 +6,7 @@ brew services start postgresql # Wait for PostgreSQL to start (adjust sleep time if needed) sleep 5 -psql postgres < 0 { + args = append(args, installationID) + query += fmt.Sprintf(" AND installation_id = $%d", len(args)) + } + if setupSessionID != "" { + args = append(args, setupSessionID) + query += fmt.Sprintf(" AND setup_session_id = $%d", len(args)) + } + query += " ORDER BY added_at, repo_full_name" + if err := db.Select(&records, query, args...); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return []GitPendingRepository{}, nil + } + return nil, err + } + return records, nil +} + +func ListGitPendingRepositories(db *sqlx.DB) ([]GitPendingRepository, error) { + if db == nil { + return []GitPendingRepository{}, nil + } + records := []GitPendingRepository{} + if err := db.Select(&records, gitPendingRepositorySelectSQL()+` + WHERE resolved_at IS NULL AND removed_at IS NULL + ORDER BY added_at, repo_full_name + `); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return []GitPendingRepository{}, nil + } + return nil, err + } + return records, nil +} + +func GitPendingRepositoryByID(db *sqlx.DB, id string) (*GitPendingRepository, error) { + if db == nil || id == "" { + return nil, nil + } + var pending GitPendingRepository + if err := db.Get(&pending, gitPendingRepositorySelectSQL()+` WHERE id = $1`, id); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, fmt.Errorf("get git pending repository by id: %w", err) + } + return &pending, nil +} + +func ResolveGitPendingRepositoryByID(db *sqlx.DB, id string) error { + if db == nil || id == "" { + return nil + } + _, err := db.Exec(`UPDATE config_schema.git_pending_repository SET resolved_at = NOW(), updated_at = NOW() WHERE id = $1`, id) + if err != nil { + return fmt.Errorf("resolve git pending repository by id: %w", err) + } + return nil +} + +func ResolveGitPendingRepositoriesByRepo(db *sqlx.DB, installationID int64, repoHost string, repoOwner string, repoPath string) error { + if db == nil { + return nil + } + _, err := db.Exec(` + UPDATE config_schema.git_pending_repository + SET resolved_at = NOW(), updated_at = NOW() + WHERE installation_id = $1 + AND repo_host = $2 + AND repo_owner = $3 + AND repo_path = $4 + AND resolved_at IS NULL + AND removed_at IS NULL + `, installationID, repoHost, repoOwner, repoPath) + if err != nil { + return fmt.Errorf("resolve git pending repositories by repo: %w", err) + } + return nil +} + +func ResolveGitPendingRepositoriesByRepositoryIdentity(db *sqlx.DB, repoHost string, repoOwner string, repoPath string) error { + if db == nil { + return nil + } + _, err := db.Exec(` + UPDATE config_schema.git_pending_repository + SET resolved_at = NOW(), updated_at = NOW() + WHERE repo_host = $1 + AND repo_owner = $2 + AND repo_path = $3 + AND resolved_at IS NULL + AND removed_at IS NULL + `, repoHost, repoOwner, repoPath) + if err != nil { + return fmt.Errorf("resolve git pending repositories by repository identity: %w", err) + } + return nil +} + +func RemoveGitPendingRepository(db *sqlx.DB, installationID int64, repoID int64) error { + if db == nil { + return nil + } + _, err := db.Exec(` + UPDATE config_schema.git_pending_repository + SET removed_at = NOW(), updated_at = NOW() + WHERE installation_id = $1 AND repo_id = $2 AND removed_at IS NULL + `, installationID, repoID) + if err != nil { + return fmt.Errorf("remove git pending repository: %w", err) + } + return nil +} + +func UpsertGitSetupSession(db *sqlx.DB, session GitSetupSession) error { + if db == nil { + return nil + } + _, err := db.NamedExec(` + INSERT INTO config_schema.git_setup_session ( + id, created_by_user_id, organization, installation_id, before_repo_ids, created_at, updated_at, completed_at + ) VALUES ( + :id, :created_by_user_id, :organization, :installation_id, :before_repo_ids, :created_at, :updated_at, :completed_at + ) + ON CONFLICT (id) DO UPDATE SET + created_by_user_id = EXCLUDED.created_by_user_id, + organization = EXCLUDED.organization, + installation_id = EXCLUDED.installation_id, + before_repo_ids = EXCLUDED.before_repo_ids, + updated_at = EXCLUDED.updated_at, + completed_at = EXCLUDED.completed_at + `, session) + if err != nil { + return fmt.Errorf("upsert git setup session: %w", err) + } + return nil +} + +func GitSetupSessionByID(db *sqlx.DB, id string) (*GitSetupSession, error) { + if db == nil || id == "" { + return nil, nil + } + var session GitSetupSession + err := db.Get(&session, `SELECT id, created_by_user_id, organization, installation_id, before_repo_ids, created_at, updated_at, completed_at FROM config_schema.git_setup_session WHERE id = $1`, id) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, err + } + return &session, nil +} + +func EncodeRepoIDs(repoIDs []int64) string { + body, err := json.Marshal(repoIDs) + if err != nil { + return "[]" + } + return string(body) +} + +func DecodeRepoIDs(raw string) map[int64]struct{} { + repoIDs := []int64{} + _ = json.Unmarshal([]byte(raw), &repoIDs) + indexed := make(map[int64]struct{}, len(repoIDs)) + for _, repoID := range repoIDs { + indexed[repoID] = struct{}{} + } + return indexed +} diff --git a/internal/db/git_project.go b/internal/db/git_project.go new file mode 100644 index 0000000..39121ef --- /dev/null +++ b/internal/db/git_project.go @@ -0,0 +1,234 @@ +package db + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strings" + + "github.com/jmoiron/sqlx" +) + +func EnsureGitProjectStateTable(db *sqlx.DB) error { + _, err := db.Exec(` + CREATE TABLE IF NOT EXISTS config_schema.git_project_state ( + project_id TEXT PRIMARY KEY, + repo_host TEXT NOT NULL, + repo_owner TEXT NOT NULL, + repo_name TEXT NOT NULL, + installation_id BIGINT NULL, + installation_target_type TEXT NULL, + installation_target TEXT NULL, + mirror_path TEXT NOT NULL, + sync_state TEXT NOT NULL DEFAULT 'never_synced', + default_branch TEXT NULL, + last_refreshed_at TIMESTAMPTZ NULL, + last_error TEXT NULL + ); + CREATE TABLE IF NOT EXISTS config_schema.git_organization_state ( + organization TEXT PRIMARY KEY, + installed BOOLEAN NOT NULL DEFAULT FALSE, + installation_id BIGINT NULL, + installation_target_type TEXT NULL, + installation_target TEXT NULL, + html_url TEXT NULL, + repository_selection TEXT NULL, + configured_at TIMESTAMPTZ NULL, + last_seen_at TIMESTAMPTZ NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_error TEXT NULL + ); + CREATE TABLE IF NOT EXISTS config_schema.git_upload_session ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL, + organization TEXT NOT NULL, + project TEXT NOT NULL, + repo_host TEXT NOT NULL, + repo_owner TEXT NOT NULL, + repo_name TEXT NOT NULL, + base_branch TEXT NOT NULL, + target_subdirectory TEXT NULL, + branch_name TEXT NOT NULL, + pr_title TEXT NOT NULL, + pr_body TEXT NOT NULL, + status TEXT NOT NULL, + pull_request_url TEXT NULL, + commit_sha TEXT NULL, + last_error TEXT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + CREATE TABLE IF NOT EXISTS config_schema.git_upload_session_file ( + session_id TEXT NOT NULL, + file_name TEXT NOT NULL, + target_path TEXT NOT NULL, + size BIGINT NOT NULL, + checksum TEXT NULL, + drs_object_id TEXT NULL, + status TEXT NOT NULL, + error TEXT NULL, + PRIMARY KEY (session_id, target_path) + ); + CREATE TABLE IF NOT EXISTS config_schema.git_pending_repository ( + id TEXT PRIMARY KEY, + installation_id BIGINT NOT NULL, + setup_session_id TEXT NULL, + created_by_user_id TEXT NULL, + source TEXT NOT NULL DEFAULT 'webhook', + organization TEXT NOT NULL, + repo_id BIGINT NOT NULL, + repo_name TEXT NOT NULL, + repo_full_name TEXT NOT NULL, + repo_html_url TEXT NULL, + repo_clone_url TEXT NULL, + repo_host TEXT NOT NULL, + repo_owner TEXT NOT NULL, + repo_path TEXT NOT NULL, + added_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + resolved_at TIMESTAMPTZ NULL, + removed_at TIMESTAMPTZ NULL + ); + CREATE TABLE IF NOT EXISTS config_schema.git_setup_session ( + id TEXT PRIMARY KEY, + created_by_user_id TEXT NOT NULL, + organization TEXT NOT NULL, + installation_id BIGINT NULL, + before_repo_ids TEXT NOT NULL DEFAULT '[]', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + completed_at TIMESTAMPTZ NULL + ); + ALTER TABLE config_schema.git_pending_repository ADD COLUMN IF NOT EXISTS setup_session_id TEXT NULL; + ALTER TABLE config_schema.git_pending_repository ADD COLUMN IF NOT EXISTS created_by_user_id TEXT NULL; + ALTER TABLE config_schema.git_pending_repository ADD COLUMN IF NOT EXISTS source TEXT NOT NULL DEFAULT 'webhook'; + ALTER TABLE config_schema.git_pending_repository DROP CONSTRAINT IF EXISTS git_pending_repository_installation_id_repo_id_key; + CREATE UNIQUE INDEX IF NOT EXISTS git_pending_repository_webhook_repo_key + ON config_schema.git_pending_repository (installation_id, repo_id) + WHERE created_by_user_id IS NULL; + CREATE UNIQUE INDEX IF NOT EXISTS git_pending_repository_user_repo_key + ON config_schema.git_pending_repository (installation_id, repo_id, created_by_user_id) + WHERE created_by_user_id IS NOT NULL; + CREATE INDEX IF NOT EXISTS git_pending_repository_user_unresolved_idx + ON config_schema.git_pending_repository (created_by_user_id, added_at) + WHERE resolved_at IS NULL AND removed_at IS NULL; + `) + if err != nil { + return fmt.Errorf("ensure git state tables: %w", err) + } + return nil +} + +func GitProjectStateByProjectID(db *sqlx.DB, projectID string) (*GitProjectState, error) { + return GitProjectStateByProjectIDContext(context.Background(), db, projectID) +} + +func GitProjectStateByProjectIDContext(ctx context.Context, db *sqlx.DB, projectID string) (*GitProjectState, error) { + if db == nil { + return nil, nil + } + var state GitProjectState + err := db.GetContext(ctx, &state, `SELECT project_id, repo_host, repo_owner, repo_name, installation_id, installation_target_type, installation_target, mirror_path, sync_state, default_branch, last_refreshed_at, last_error FROM config_schema.git_project_state WHERE project_id = $1`, projectID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, err + } + return &state, nil +} + +func UpsertGitProjectState(db *sqlx.DB, state GitProjectState) error { + return UpsertGitProjectStateContext(context.Background(), db, state) +} + +func UpsertGitProjectStateTx(tx *sqlx.Tx, state GitProjectState) error { + return UpsertGitProjectStateTxContext(context.Background(), tx, state) +} + +func UpsertGitProjectStateContext(ctx context.Context, db *sqlx.DB, state GitProjectState) error { + if db == nil { + return nil + } + return upsertGitProjectStateContext(ctx, db.NamedExecContext, state) +} + +func UpsertGitProjectStateTxContext(ctx context.Context, tx *sqlx.Tx, state GitProjectState) error { + if tx == nil { + return nil + } + return upsertGitProjectStateContext(ctx, tx.NamedExecContext, state) +} + +func upsertGitProjectStateContext(ctx context.Context, namedExecFn func(context.Context, string, any) (sql.Result, error), state GitProjectState) error { + _, err := namedExecFn(ctx, ` + INSERT INTO config_schema.git_project_state ( + project_id, repo_host, repo_owner, repo_name, installation_id, installation_target_type, installation_target, mirror_path, sync_state, default_branch, last_refreshed_at, last_error + ) VALUES ( + :project_id, :repo_host, :repo_owner, :repo_name, :installation_id, :installation_target_type, :installation_target, :mirror_path, :sync_state, :default_branch, :last_refreshed_at, :last_error + ) + ON CONFLICT (project_id) DO UPDATE SET + repo_host = EXCLUDED.repo_host, + repo_owner = EXCLUDED.repo_owner, + repo_name = EXCLUDED.repo_name, + installation_id = EXCLUDED.installation_id, + installation_target_type = EXCLUDED.installation_target_type, + installation_target = EXCLUDED.installation_target, + mirror_path = EXCLUDED.mirror_path, + sync_state = EXCLUDED.sync_state, + default_branch = EXCLUDED.default_branch, + last_refreshed_at = EXCLUDED.last_refreshed_at, + last_error = EXCLUDED.last_error; + `, state) + if err != nil { + return fmt.Errorf("upsert git project state: %w", err) + } + return nil +} + +func ListGitProjectStates(db *sqlx.DB) (map[string]GitProjectState, error) { + states := []GitProjectState{} + if err := db.Select(&states, `SELECT project_id, repo_host, repo_owner, repo_name, installation_id, installation_target_type, installation_target, mirror_path, sync_state, default_branch, last_refreshed_at, last_error FROM config_schema.git_project_state`); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return map[string]GitProjectState{}, nil + } + return nil, err + } + indexed := make(map[string]GitProjectState, len(states)) + for _, state := range states { + indexed[state.ProjectID] = state + } + return indexed, nil +} + +func DeleteGitProjectArtifacts(db *sqlx.DB, projectID string) error { + if db == nil || strings.TrimSpace(projectID) == "" { + return nil + } + tx, err := db.Beginx() + if err != nil { + return fmt.Errorf("begin delete git project artifacts transaction: %w", err) + } + defer func() { + _ = tx.Rollback() + }() + if _, err := tx.Exec(` + DELETE FROM config_schema.git_upload_session_file + WHERE session_id IN ( + SELECT id FROM config_schema.git_upload_session WHERE project_id = $1 + ) + `, projectID); err != nil { + return fmt.Errorf("delete git upload session files for %s: %w", projectID, err) + } + if _, err := tx.Exec(`DELETE FROM config_schema.git_upload_session WHERE project_id = $1`, projectID); err != nil { + return fmt.Errorf("delete git upload sessions for %s: %w", projectID, err) + } + if _, err := tx.Exec(`DELETE FROM config_schema.git_project_state WHERE project_id = $1`, projectID); err != nil { + return fmt.Errorf("delete git project state for %s: %w", projectID, err) + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit delete git project artifacts transaction: %w", err) + } + return nil +} diff --git a/internal/db/git_types.go b/internal/db/git_types.go new file mode 100644 index 0000000..c94ee7b --- /dev/null +++ b/internal/db/git_types.go @@ -0,0 +1,106 @@ +package db + +import ( + "database/sql" + "time" +) + +type GitProjectState struct { + ProjectID string `db:"project_id"` + RepoHost string `db:"repo_host"` + RepoOwner string `db:"repo_owner"` + RepoName string `db:"repo_name"` + InstallationID sql.NullInt64 `db:"installation_id"` + InstallationTargetType sql.NullString `db:"installation_target_type"` + InstallationTarget sql.NullString `db:"installation_target"` + MirrorPath string `db:"mirror_path"` + SyncState string `db:"sync_state"` + DefaultBranch sql.NullString `db:"default_branch"` + LastRefreshedAt sql.NullTime `db:"last_refreshed_at"` + LastError sql.NullString `db:"last_error"` +} + +type GitOrganizationState struct { + Organization string `db:"organization"` + Installed bool `db:"installed"` + InstallationID sql.NullInt64 `db:"installation_id"` + InstallationTargetType sql.NullString `db:"installation_target_type"` + InstallationTarget sql.NullString `db:"installation_target"` + HTMLURL sql.NullString `db:"html_url"` + RepositorySelection sql.NullString `db:"repository_selection"` + ConfiguredAt sql.NullTime `db:"configured_at"` + LastSeenAt sql.NullTime `db:"last_seen_at"` + UpdatedAt time.Time `db:"updated_at"` + LastError sql.NullString `db:"last_error"` +} + +type GitPendingRepository struct { + ID string `db:"id"` + InstallationID int64 `db:"installation_id"` + SetupSessionID sql.NullString `db:"setup_session_id"` + CreatedByUserID sql.NullString `db:"created_by_user_id"` + Source string `db:"source"` + Organization string `db:"organization"` + RepoID int64 `db:"repo_id"` + RepoName string `db:"repo_name"` + RepoFullName string `db:"repo_full_name"` + RepoHTMLURL sql.NullString `db:"repo_html_url"` + RepoCloneURL sql.NullString `db:"repo_clone_url"` + RepoHost string `db:"repo_host"` + RepoOwner string `db:"repo_owner"` + RepoPath string `db:"repo_path"` + AddedAt time.Time `db:"added_at"` + UpdatedAt time.Time `db:"updated_at"` + ResolvedAt sql.NullTime `db:"resolved_at"` + RemovedAt sql.NullTime `db:"removed_at"` +} + +type GitSetupSession struct { + ID string `db:"id"` + CreatedByUserID string `db:"created_by_user_id"` + Organization string `db:"organization"` + InstallationID sql.NullInt64 `db:"installation_id"` + BeforeRepoIDs string `db:"before_repo_ids"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` + CompletedAt sql.NullTime `db:"completed_at"` +} + +type GitUploadSession struct { + ID string `db:"id"` + ProjectID string `db:"project_id"` + Organization string `db:"organization"` + Project string `db:"project"` + RepoHost string `db:"repo_host"` + RepoOwner string `db:"repo_owner"` + RepoName string `db:"repo_name"` + BaseBranch string `db:"base_branch"` + TargetSubdir sql.NullString `db:"target_subdirectory"` + BranchName string `db:"branch_name"` + PRTitle string `db:"pr_title"` + PRBody string `db:"pr_body"` + Status string `db:"status"` + PullRequestURL sql.NullString `db:"pull_request_url"` + CommitSHA sql.NullString `db:"commit_sha"` + LastError sql.NullString `db:"last_error"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` +} + +type GitUploadSessionFile struct { + SessionID string `db:"session_id"` + FileName string `db:"file_name"` + TargetPath string `db:"target_path"` + Size int64 `db:"size"` + Checksum sql.NullString `db:"checksum"` + DRSObjectID sql.NullString `db:"drs_object_id"` + Status string `db:"status"` + Error sql.NullString `db:"error"` +} + +func (state GitProjectState) RefreshedAt() *time.Time { + if !state.LastRefreshedAt.Valid { + return nil + } + return &state.LastRefreshedAt.Time +} diff --git a/internal/db/git_upload.go b/internal/db/git_upload.go new file mode 100644 index 0000000..7b5ecee --- /dev/null +++ b/internal/db/git_upload.go @@ -0,0 +1,103 @@ +package db + +import ( + "database/sql" + "errors" + "fmt" + + "github.com/jmoiron/sqlx" +) + +func GitUploadSessionByID(db *sqlx.DB, sessionID string) (*GitUploadSession, error) { + if db == nil { + return nil, nil + } + var session GitUploadSession + err := db.Get(&session, `SELECT id, project_id, organization, project, repo_host, repo_owner, repo_name, base_branch, target_subdirectory, branch_name, pr_title, pr_body, status, pull_request_url, commit_sha, last_error, created_at, updated_at FROM config_schema.git_upload_session WHERE id = $1`, sessionID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, err + } + return &session, nil +} + +func UpsertGitUploadSession(db *sqlx.DB, session GitUploadSession) error { + if db == nil { + return nil + } + _, err := db.NamedExec(` + INSERT INTO config_schema.git_upload_session ( + id, project_id, organization, project, repo_host, repo_owner, repo_name, base_branch, target_subdirectory, branch_name, pr_title, pr_body, status, pull_request_url, commit_sha, last_error, created_at, updated_at + ) VALUES ( + :id, :project_id, :organization, :project, :repo_host, :repo_owner, :repo_name, :base_branch, :target_subdirectory, :branch_name, :pr_title, :pr_body, :status, :pull_request_url, :commit_sha, :last_error, :created_at, :updated_at + ) + ON CONFLICT (id) DO UPDATE SET + project_id = EXCLUDED.project_id, + organization = EXCLUDED.organization, + project = EXCLUDED.project, + repo_host = EXCLUDED.repo_host, + repo_owner = EXCLUDED.repo_owner, + repo_name = EXCLUDED.repo_name, + base_branch = EXCLUDED.base_branch, + target_subdirectory = EXCLUDED.target_subdirectory, + branch_name = EXCLUDED.branch_name, + pr_title = EXCLUDED.pr_title, + pr_body = EXCLUDED.pr_body, + status = EXCLUDED.status, + pull_request_url = EXCLUDED.pull_request_url, + commit_sha = EXCLUDED.commit_sha, + last_error = EXCLUDED.last_error, + updated_at = EXCLUDED.updated_at; + `, session) + if err != nil { + return fmt.Errorf("upsert git upload session: %w", err) + } + return nil +} + +func ReplaceGitUploadSessionFiles(db *sqlx.DB, sessionID string, files []GitUploadSessionFile) error { + if db == nil { + return nil + } + tx, err := db.Beginx() + if err != nil { + return fmt.Errorf("begin git upload session file transaction: %w", err) + } + defer func() { + _ = tx.Rollback() + }() + if _, err := tx.Exec(`DELETE FROM config_schema.git_upload_session_file WHERE session_id = $1`, sessionID); err != nil { + return fmt.Errorf("delete git upload session files: %w", err) + } + for _, file := range files { + if _, err := tx.NamedExec(` + INSERT INTO config_schema.git_upload_session_file ( + session_id, file_name, target_path, size, checksum, drs_object_id, status, error + ) VALUES ( + :session_id, :file_name, :target_path, :size, :checksum, :drs_object_id, :status, :error + ) + `, file); err != nil { + return fmt.Errorf("insert git upload session file: %w", err) + } + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit git upload session file transaction: %w", err) + } + return nil +} + +func ListGitUploadSessionFiles(db *sqlx.DB, sessionID string) ([]GitUploadSessionFile, error) { + if db == nil { + return []GitUploadSessionFile{}, nil + } + files := []GitUploadSessionFile{} + if err := db.Select(&files, `SELECT session_id, file_name, target_path, size, checksum, drs_object_id, status, error FROM config_schema.git_upload_session_file WHERE session_id = $1 ORDER BY target_path`, sessionID); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return []GitUploadSessionFile{}, nil + } + return nil, err + } + return files, nil +} diff --git a/internal/git/domain/types.go b/internal/git/domain/types.go new file mode 100644 index 0000000..8804f0d --- /dev/null +++ b/internal/git/domain/types.go @@ -0,0 +1,71 @@ +package domain + +import "fmt" + +type GitRepositoryIdentity struct { + Host string `json:"host"` + Owner string `json:"owner"` + Repo string `json:"repo"` + URL string `json:"url"` +} + +type GitRepositoryInstallationStatus struct { + Installed bool `json:"installed"` + InstallationID *int64 `json:"installation_id,omitempty"` + Target string `json:"target,omitempty"` + TargetType string `json:"target_type,omitempty"` + HTMLURL string `json:"html_url,omitempty"` + RepositorySelection string `json:"repository_selection,omitempty"` +} + +type GitHubInstallationRepository struct { + ID int64 `json:"id"` + Name string `json:"name"` + FullName string `json:"full_name"` + HTMLURL string `json:"html_url"` + CloneURL string `json:"clone_url"` +} + +type GitHubRepositoryMetadata struct { + DefaultBranch string `json:"default_branch"` + HTMLURL string `json:"html_url"` +} + +type StorageBucket struct { + Bucket string + Provider string + Endpoint string + Region string + Resources []string +} + +type StorageConfig struct { + Bucket string + Provider string + Endpoint string + Region string + AccessKey string + SecretKey string + Organization string + ProjectID string + Path string + PathPrefix string + OrganizationSubPath string + ProjectSubPath string +} + +type HTTPStatusError struct { + StatusCode int + Code string + Message string +} + +func (err *HTTPStatusError) Error() string { + if err == nil { + return "" + } + if err.Message != "" { + return err.Message + } + return fmt.Sprintf("HTTP Status %d", err.StatusCode) +} diff --git a/internal/git/errors.go b/internal/git/errors.go new file mode 100644 index 0000000..35d7f60 --- /dev/null +++ b/internal/git/errors.go @@ -0,0 +1,67 @@ +package git + +import ( + "fmt" + "net/http" +) + +type ErrorKind string + +const ( + ErrorKindValidation ErrorKind = "validation" + ErrorKindForbidden ErrorKind = "forbidden" + ErrorKindIntegration ErrorKind = "integration" + ErrorKindNotFound ErrorKind = "not_found" + ErrorKindDatabase ErrorKind = "database" + ErrorKindUnauthorized ErrorKind = "unauthorized" +) + +type Error struct { + Kind ErrorKind + Message string + StatusCode int + Details map[string]any + Err error +} + +func (err *Error) Error() string { + if err == nil { + return "" + } + if err.Message != "" { + return err.Message + } + if err.Err != nil { + return err.Err.Error() + } + return http.StatusText(err.StatusCode) +} + +func (err *Error) Unwrap() error { + if err == nil { + return nil + } + return err.Err +} + +func NewError(kind ErrorKind, statusCode int, message string, details map[string]any) *Error { + return &Error{ + Kind: kind, + StatusCode: statusCode, + Message: message, + Details: details, + } +} + +func WrapError(kind ErrorKind, statusCode int, message string, cause error, details map[string]any) *Error { + if cause == nil { + return NewError(kind, statusCode, message, details) + } + return &Error{ + Kind: kind, + StatusCode: statusCode, + Message: fmt.Sprintf("%s: %s", message, cause), + Details: details, + Err: cause, + } +} diff --git a/internal/git/reconcile.go b/internal/git/reconcile.go new file mode 100644 index 0000000..b98497a --- /dev/null +++ b/internal/git/reconcile.go @@ -0,0 +1,447 @@ +package git + +import ( + "context" + "database/sql" + "fmt" + "net/http" + "sort" + "strings" + "time" + + appconfig "github.com/calypr/gecko/config" + geckodb "github.com/calypr/gecko/internal/db" + "github.com/calypr/gecko/internal/integrations/syfon" + servermw "github.com/calypr/gecko/internal/server/middleware" + "github.com/jmoiron/sqlx" +) + +type ReconcileService struct { + db *sqlx.DB + storage *syfon.Manager + git *GitService +} + +func NewReconcileService(db *sqlx.DB, storage *syfon.Manager, gitService *GitService) *ReconcileService { + return &ReconcileService{ + db: db, + storage: storage, + git: gitService, + } +} + +func (service *ReconcileService) ReconcileOrganizations(ctx context.Context, authorizationHeader string, projectIDs []string) error { + for _, organization := range projectConfigOrganizations(projectIDs) { + if err := service.ReconcileOrganization(ctx, organization, authorizationHeader, projectIDs); err != nil { + return err + } + } + return nil +} + +func (service *ReconcileService) ReconcileOrganization(ctx context.Context, organization string, authorizationHeader string, projectIDs []string) error { + now := time.Now().UTC() + existingOrgState, _ := geckodb.GitOrganizationStateByOrganization(service.db, organization) + projects := make([]trackedProject, 0) + owners := make(map[string]struct{}) + for _, projectID := range projectIDs { + parts := strings.SplitN(projectID, "/", 2) + if len(parts) != 2 || parts[0] != organization { + continue + } + var cfg appconfig.ProjectConfig + if err := geckodb.ConfigGETGeneric(service.db, projectID, string(appconfig.TypeProjects), &cfg); err != nil { + continue + } + identity, err := ParseRepositoryIdentity(cfg.SrcRepo) + if err != nil { + projectState, _ := geckodb.GitProjectStateByProjectID(service.db, projectID) + if projectState != nil { + _ = geckodb.UpsertGitProjectState(service.db, *projectState) + } + continue + } + owners[identity.Owner] = struct{}{} + projects = append(projects, trackedProject{projectID: projectID, cfg: cfg, identity: identity}) + } + + orgInstallation := GitRepositoryInstallationStatus{} + if len(owners) > 0 { + sortedOwners := make([]string, 0, len(owners)) + for owner := range owners { + sortedOwners = append(sortedOwners, owner) + } + sort.Strings(sortedOwners) + + for _, owner := range sortedOwners { + installation, err := service.git.RequestOrganizationInstallationStatus(ctx, authorizationHeader, organization, owner) + if err != nil { + if statusErr, ok := err.(*HTTPStatusError); ok { + if statusErr.StatusCode == http.StatusNotFound { + continue + } + return NewError(ErrorKindIntegration, statusErr.StatusCode, statusErr.Message, map[string]any{"organization": organization, "github_owner": owner}) + } + return WrapError(ErrorKindIntegration, http.StatusBadGateway, "failed to load GitHub organization installation status", err, map[string]any{"organization": organization, "github_owner": owner}) + } + if installation.Installed { + orgInstallation = installation + break + } + } + } else if existingOrgState != nil && existingOrgState.Installed { + orgInstallation.Installed = true + if existingOrgState.InstallationID.Valid { + installationID := existingOrgState.InstallationID.Int64 + orgInstallation.InstallationID = &installationID + } + if existingOrgState.InstallationTarget.Valid { + orgInstallation.Target = existingOrgState.InstallationTarget.String + } + if existingOrgState.InstallationTargetType.Valid { + orgInstallation.TargetType = existingOrgState.InstallationTargetType.String + } + if existingOrgState.HTMLURL.Valid { + orgInstallation.HTMLURL = existingOrgState.HTMLURL.String + } + if existingOrgState.RepositorySelection.Valid { + orgInstallation.RepositorySelection = existingOrgState.RepositorySelection.String + } + } + + orgState := geckodb.GitOrganizationState{ + Organization: organization, + Installed: orgInstallation.Installed, + UpdatedAt: now, + LastSeenAt: sql.NullTime{Time: now, Valid: true}, + ConfiguredAt: sql.NullTime{Time: now, Valid: orgInstallation.Installed}, + LastError: sql.NullString{}, + } + if orgInstallation.Installed { + if orgInstallation.InstallationID != nil { + orgState.InstallationID = sql.NullInt64{Int64: *orgInstallation.InstallationID, Valid: true} + } + if orgInstallation.Target != "" { + orgState.InstallationTarget = sql.NullString{String: orgInstallation.Target, Valid: true} + } + if orgInstallation.TargetType != "" { + orgState.InstallationTargetType = sql.NullString{String: orgInstallation.TargetType, Valid: true} + } + if orgInstallation.HTMLURL != "" { + orgState.HTMLURL = sql.NullString{String: orgInstallation.HTMLURL, Valid: true} + } + if orgInstallation.RepositorySelection != "" { + orgState.RepositorySelection = sql.NullString{String: orgInstallation.RepositorySelection, Valid: true} + } + } + if err := geckodb.UpsertGitOrganizationState(service.db, orgState); err != nil { + return WrapError(ErrorKindDatabase, http.StatusInternalServerError, "failed to persist git organization state", err, map[string]any{"organization": organization}) + } + + for _, tracked := range projects { + if err := service.reconcileProject(ctx, authorizationHeader, organization, tracked, orgInstallation); err != nil { + return err + } + } + return nil +} + +func (service *ReconcileService) BuildOrganizationsStatus(ctx context.Context, authorizationHeader string, projectIDs []string, allowedResources []string) (GitOrganizationsStatusResponse, error) { + projectIDs = filterProjectIDsByAllowedResources(projectIDs, allowedResources) + organizations := projectConfigOrganizations(projectIDs) + buckets, bucketsErr := service.storage.ListBuckets(ctx, authorizationHeader) + projectStates, err := geckodb.ListGitProjectStates(service.db) + if err != nil { + return GitOrganizationsStatusResponse{}, WrapError(ErrorKindDatabase, http.StatusInternalServerError, "failed to list git project states", err, nil) + } + organizationStates, err := geckodb.ListGitOrganizationStates(service.db) + if err != nil { + return GitOrganizationsStatusResponse{}, WrapError(ErrorKindDatabase, http.StatusInternalServerError, "failed to list git organization states", err, nil) + } + responsePayload := GitOrganizationsStatusResponse{Organizations: make([]GitOrganizationStatusResponse, 0, len(organizations))} + for _, organization := range organizations { + organizationStatus, err := service.BuildOrganizationStatus(ctx, organization, projectIDs, projectStates, organizationStates, allowedResources, buckets, bucketsErr) + if err != nil { + return GitOrganizationsStatusResponse{}, err + } + responsePayload.Organizations = append(responsePayload.Organizations, organizationStatus) + responsePayload.TotalProjects += organizationStatus.TotalProjects + responsePayload.ConnectedProjects += organizationStatus.ConnectedProjects + responsePayload.ConfiguredProjects += organizationStatus.ConfiguredProjects + if organizationStatus.Connected { + responsePayload.ConnectedOrganizations++ + } + if organizationStatus.AppInstalled { + responsePayload.InstalledOrganizations++ + } + } + responsePayload.TotalOrganizations = len(responsePayload.Organizations) + responsePayload.AppInstalled = responsePayload.InstalledOrganizations > 0 + responsePayload.Connected = responsePayload.AppInstalled + responsePayload.ConfigurationState = OrganizationConfigurationState(responsePayload.AppInstalled, responsePayload.ConfiguredProjects, responsePayload.TotalProjects) + return responsePayload, nil +} + +func (service *ReconcileService) BuildSingleOrganizationStatus(ctx context.Context, authorizationHeader string, organization string, projectIDs []string, allowedResources []string) (GitOrganizationStatusResponse, error) { + projectStates, err := geckodb.ListGitProjectStates(service.db) + if err != nil { + return GitOrganizationStatusResponse{}, WrapError(ErrorKindDatabase, http.StatusInternalServerError, "failed to list git project states", err, map[string]any{"organization": organization}) + } + organizationStates, err := geckodb.ListGitOrganizationStates(service.db) + if err != nil { + return GitOrganizationStatusResponse{}, WrapError(ErrorKindDatabase, http.StatusInternalServerError, "failed to list git organization states", err, map[string]any{"organization": organization}) + } + buckets, bucketsErr := service.storage.ListBuckets(ctx, authorizationHeader) + return service.BuildOrganizationStatus(ctx, organization, projectIDs, projectStates, organizationStates, allowedResources, buckets, bucketsErr) +} + +func (service *ReconcileService) BuildOrganizationStatus(ctx context.Context, organization string, projectIDs []string, projectStates map[string]geckodb.GitProjectState, organizationStates map[string]geckodb.GitOrganizationState, allowedResources []string, buckets map[string]StorageBucket, bucketsErr error) (GitOrganizationStatusResponse, error) { + responsePayload := GitOrganizationStatusResponse{ + Organization: organization, + Projects: make([]GitOrganizationProjectStatus, 0), + } + orgState, hasOrgState := organizationStates[organization] + if hasOrgState { + responsePayload.AppInstalled = orgState.Installed + responsePayload.Connected = orgState.Installed + if orgState.InstallationID.Valid { + installationID := orgState.InstallationID.Int64 + responsePayload.InstallationID = &installationID + } + if orgState.HTMLURL.Valid { + responsePayload.HTMLURL = orgState.HTMLURL.String + } + if orgState.RepositorySelection.Valid { + responsePayload.RepositorySelection = orgState.RepositorySelection.String + } + } + + for _, projectID := range projectIDs { + parts := strings.SplitN(projectID, "/", 2) + if len(parts) != 2 || parts[0] != organization { + continue + } + if !servermw.ResourceListAllowsProject(allowedResources, parts[0], parts[1]) { + continue + } + var cfg appconfig.ProjectConfig + if err := geckodb.ConfigGETGeneric(service.db, projectID, string(appconfig.TypeProjects), &cfg); err != nil { + continue + } + identity, _ := ParseRepositoryIdentity(cfg.SrcRepo) + state, hasProjectState := projectStates[projectID] + if !hasProjectState { + state = geckodb.GitProjectState{ + ProjectID: projectID, + RepoHost: identity.Host, + RepoOwner: identity.Owner, + RepoName: identity.Repo, + SyncState: GitSyncNeverSynced, + } + } + installation := buildInstallationStatus(responsePayload, state, identity.Owner) + integrations := ProjectIntegrationStatus{ + GitHub: ProjectIntegrationCheck{ + Pass: installation.Installed, + }, + Storage: deriveStorageIntegrationCheck(buckets, bucketsErr, parts[0], parts[1]), + } + if strings.TrimSpace(cfg.SrcRepo) == "" { + integrations.GitHub.Reason = "missing_repository_link" + if responsePayload.AppInstalled { + integrations.GitHub.Details = "GitHub App is installed for this organization, but this project is not linked to a repository yet" + } else { + integrations.GitHub.Details = "No GitHub repository is linked to this project" + } + } else if !installation.Installed { + integrations.GitHub.Reason = "missing_github_connection" + integrations.GitHub.Details = "GitHub App is not connected to this repository" + } + configured := integrations.GitHub.Pass && integrations.Storage.Pass + readable := servermw.ResourceListAllowsProject(allowedResources, parts[0], parts[1]) + responsePayload.Projects = append(responsePayload.Projects, GitOrganizationProjectStatus{ + ProjectID: projectID, + Project: parts[1], + ResourcePath: ProgramProjectResourcePath(parts[0], parts[1]), + Repository: identity, + Configured: configured, + Integrations: integrations, + Accessible: readable, + RequestAccess: !readable, + RequestAccessResourcePath: ProgramProjectResourcePath(parts[0], parts[1]), + Installation: installation, + }) + } + responsePayload.TotalProjects = len(responsePayload.Projects) + for _, projectStatus := range responsePayload.Projects { + if projectStatus.Installation.Installed { + responsePayload.ConnectedProjects++ + } + if projectStatus.Configured { + responsePayload.ConfiguredProjects++ + } + } + responsePayload.ConfigurationState = OrganizationConfigurationState(responsePayload.AppInstalled, responsePayload.ConfiguredProjects, responsePayload.TotalProjects) + return responsePayload, nil +} + +type trackedProject struct { + projectID string + cfg appconfig.ProjectConfig + identity GitRepositoryIdentity +} + +func (service *ReconcileService) reconcileProject(ctx context.Context, authorizationHeader, organization string, tracked trackedProject, ownerInstallation GitRepositoryInstallationStatus) error { + _, project := splitProjectID(tracked.projectID) + projectState, _ := geckodb.GitProjectStateByProjectID(service.db, tracked.projectID) + if projectState == nil { + projectState = &geckodb.GitProjectState{ + ProjectID: tracked.projectID, + RepoHost: tracked.identity.Host, + RepoOwner: tracked.identity.Owner, + RepoName: tracked.identity.Repo, + SyncState: GitSyncNeverSynced, + } + } + if ownerInstallation.Installed && ownerInstallation.RepositorySelection == "all" { + applyInstalledState(projectState, ownerInstallation.InstallationID, tracked.identity.Owner) + _ = geckodb.UpsertGitProjectState(service.db, *projectState) + return nil + } + accessToken, err := service.git.RequestInstallationToken(ctx, authorizationHeader, organization, project, tracked.identity, "read") + if err != nil { + if statusErr, ok := err.(*HTTPStatusError); ok && (statusErr.StatusCode == http.StatusForbidden || statusErr.StatusCode == http.StatusNotFound) { + clearInstalledState(projectState) + _ = geckodb.UpsertGitProjectState(service.db, *projectState) + return nil + } + return WrapError(ErrorKindIntegration, http.StatusBadGateway, "failed to obtain GitHub installation token", err, map[string]any{"organization": organization, "project_id": tracked.projectID, "repository": tracked.cfg.SrcRepo}) + } + if _, err := service.git.FetchRepositoryMetadata(ctx, accessToken, tracked.identity); err != nil { + clearInstalledState(projectState) + _ = geckodb.UpsertGitProjectState(service.db, *projectState) + return nil + } + applyInstalledState(projectState, ownerInstallation.InstallationID, tracked.identity.Owner) + _ = geckodb.UpsertGitProjectState(service.db, *projectState) + return nil +} + +func splitProjectID(projectID string) (string, string) { + organization, project, _ := strings.Cut(projectID, "/") + return strings.TrimSpace(organization), strings.TrimSpace(project) +} + +func deriveStorageIntegrationCheck(buckets map[string]StorageBucket, bucketsErr error, organization string, project string) ProjectIntegrationCheck { + check := ProjectIntegrationCheck{ + Pass: false, + Reason: "missing_storage_scope", + } + if bucketsErr != nil { + check.Details = bucketsErr.Error() + return check + } + expectedPrograms := fmt.Sprintf("/programs/%s/projects/%s", strings.TrimSpace(organization), strings.TrimSpace(project)) + expectedOrganization := fmt.Sprintf("/organization/%s/project/%s", strings.TrimSpace(organization), strings.TrimSpace(project)) + for _, metadata := range buckets { + for _, resource := range metadata.Resources { + normalized := strings.TrimSpace(resource) + if normalized == expectedPrograms || normalized == expectedOrganization { + check.Pass = true + check.Reason = "" + return check + } + } + } + check.Details = "No Syfon bucket scope matched this project" + return check +} + +func buildInstallationStatus(organizationStatus GitOrganizationStatusResponse, state geckodb.GitProjectState, owner string) GitRepositoryInstallationStatus { + installation := GitRepositoryInstallationStatus{} + if organizationStatus.AppInstalled && organizationStatus.RepositorySelection == "all" { + installation.Installed = true + installation.InstallationID = organizationStatus.InstallationID + installation.Target = owner + installation.TargetType = "Organization" + installation.HTMLURL = organizationStatus.HTMLURL + installation.RepositorySelection = organizationStatus.RepositorySelection + return installation + } + if state.InstallationID.Valid || state.InstallationTarget.Valid { + installation.Installed = true + if state.InstallationID.Valid { + installationID := state.InstallationID.Int64 + installation.InstallationID = &installationID + } + if state.InstallationTarget.Valid { + installation.Target = state.InstallationTarget.String + } + if state.InstallationTargetType.Valid { + installation.TargetType = state.InstallationTargetType.String + } + if organizationStatus.HTMLURL != "" { + installation.HTMLURL = organizationStatus.HTMLURL + } + if organizationStatus.RepositorySelection != "" { + installation.RepositorySelection = organizationStatus.RepositorySelection + } + } + return installation +} + +func clearInstalledState(state *geckodb.GitProjectState) { + state.InstallationID = sql.NullInt64{} + state.InstallationTarget = sql.NullString{} + state.InstallationTargetType = sql.NullString{} +} + +func applyInstalledState(state *geckodb.GitProjectState, installationID *int64, owner string) { + if installationID != nil { + state.InstallationID = sql.NullInt64{Int64: *installationID, Valid: true} + } else { + state.InstallationID = sql.NullInt64{} + } + state.InstallationTarget = sql.NullString{String: owner, Valid: owner != ""} + state.InstallationTargetType = sql.NullString{String: "Organization", Valid: true} +} + +func filterProjectIDsByAllowedResources(projectIDs []string, allowedResources []string) []string { + if len(allowedResources) == 0 { + return []string{} + } + filtered := make([]string, 0, len(projectIDs)) + for _, projectID := range projectIDs { + parts := strings.SplitN(projectID, "/", 2) + if len(parts) != 2 { + continue + } + projectParts := strings.SplitN(parts[1], "/", 2) + if len(projectParts) != 1 || projectParts[0] == "" { + continue + } + if servermw.ResourceListAllowsProject(allowedResources, parts[0], projectParts[0]) { + filtered = append(filtered, projectID) + } + } + sort.Strings(filtered) + return filtered +} + +func projectConfigOrganizations(projectIDs []string) []string { + organizations := make([]string, 0) + seen := make(map[string]struct{}) + for _, projectID := range projectIDs { + parts := strings.SplitN(projectID, "/", 2) + if len(parts) != 2 || parts[0] == "" { + continue + } + if _, ok := seen[parts[0]]; ok { + continue + } + seen[parts[0]] = struct{}{} + organizations = append(organizations, parts[0]) + } + sort.Strings(organizations) + return organizations +} diff --git a/internal/git/repository.go b/internal/git/repository.go new file mode 100644 index 0000000..397afb1 --- /dev/null +++ b/internal/git/repository.go @@ -0,0 +1,203 @@ +package git + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "time" + + gogit "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/config" + "github.com/go-git/go-git/v5/plumbing" + "github.com/go-git/go-git/v5/plumbing/storer" + githttp "github.com/go-git/go-git/v5/plumbing/transport/http" +) + +var gitLFSPointerOIDPattern = regexp.MustCompile(`^oid sha256:([a-fA-F0-9]{64})$`) + +func SyncRepositoryMirror(ctx context.Context, remoteURL string, mirrorPath string, auth *githttp.BasicAuth) error { + if err := os.MkdirAll(filepath.Dir(mirrorPath), 0o755); err != nil { + return fmt.Errorf("create repository parent dir: %w", err) + } + if _, err := os.Stat(mirrorPath); errors.Is(err, os.ErrNotExist) { + _, err = gogit.PlainCloneContext(ctx, mirrorPath, false, &gogit.CloneOptions{URL: remoteURL, Auth: auth, Tags: gogit.AllTags}) + if err != nil { + if isEmptyRemoteRepositoryError(err) { + repo, initErr := gogit.PlainInit(mirrorPath, false) + if initErr != nil { + return fmt.Errorf("initialize empty repository: %w", initErr) + } + if _, remoteErr := repo.CreateRemote(&config.RemoteConfig{ + Name: gogit.DefaultRemoteName, + URLs: []string{remoteURL}, + }); remoteErr != nil && !strings.Contains(strings.ToLower(remoteErr.Error()), "remote already exists") { + return fmt.Errorf("create remote for empty repository: %w", remoteErr) + } + return nil + } + return fmt.Errorf("clone repository: %w", err) + } + return nil + } + repo, err := gogit.PlainOpen(mirrorPath) + if err != nil { + return fmt.Errorf("open repository: %w", err) + } + worktree, err := repo.Worktree() + if err != nil { + err = repo.FetchContext(ctx, &gogit.FetchOptions{RemoteName: gogit.DefaultRemoteName, Auth: auth, Force: true, Prune: true, Tags: gogit.AllTags}) + if err != nil && !errors.Is(err, gogit.NoErrAlreadyUpToDate) { + return fmt.Errorf("fetch existing bare repository: %w", err) + } + return nil + } + err = repo.FetchContext(ctx, &gogit.FetchOptions{RemoteName: gogit.DefaultRemoteName, Auth: auth, Force: true, Prune: true, Tags: gogit.AllTags}) + if err != nil && !errors.Is(err, gogit.NoErrAlreadyUpToDate) { + return fmt.Errorf("fetch repository: %w", err) + } + err = worktree.PullContext(ctx, &gogit.PullOptions{RemoteName: gogit.DefaultRemoteName, Auth: auth, Force: true}) + if err != nil && !errors.Is(err, gogit.NoErrAlreadyUpToDate) { + return fmt.Errorf("pull repository: %w", err) + } + return nil +} + +func isEmptyRemoteRepositoryError(err error) bool { + if err == nil { + return false + } + return strings.Contains(strings.ToLower(err.Error()), "remote repository is empty") +} + +func RepositoryIsEmpty(repo *gogit.Repository) bool { + if repo == nil { + return true + } + iter, err := repo.References() + if err != nil { + return true + } + hasRefs := false + _ = iter.ForEach(func(reference *plumbing.Reference) error { + name := reference.Name() + if name.IsBranch() || name.IsRemote() || name.IsTag() { + hasRefs = true + return storer.ErrStop + } + return nil + }) + return !hasRefs +} + +func ResolveGitReference(repo *gogit.Repository, requestedRef string, defaultBranch string) (string, plumbing.Hash, error) { + candidates := []string{} + if requestedRef != "" { + candidates = append( + candidates, + requestedRef, + "refs/heads/"+requestedRef, + "refs/remotes/origin/"+requestedRef, + "refs/tags/"+requestedRef, + ) + } + if defaultBranch != "" && requestedRef == "" { + candidates = append( + candidates, + defaultBranch, + "refs/heads/"+defaultBranch, + "refs/remotes/origin/"+defaultBranch, + ) + } + candidates = append(candidates, "HEAD") + for _, candidate := range candidates { + hash, err := repo.ResolveRevision(plumbing.Revision(candidate)) + if err == nil && hash != nil { + resolved := candidate + if strings.HasPrefix(candidate, "refs/heads/") { + resolved = strings.TrimPrefix(candidate, "refs/heads/") + } + if strings.HasPrefix(candidate, "refs/remotes/origin/") { + resolved = strings.TrimPrefix(candidate, "refs/remotes/origin/") + } + if strings.HasPrefix(candidate, "refs/tags/") { + resolved = strings.TrimPrefix(candidate, "refs/tags/") + } + return resolved, *hash, nil + } + } + return "", plumbing.ZeroHash, fmt.Errorf("could not resolve git ref %q", requestedRef) +} + +func OpenRepository(path string) (*gogit.Repository, error) { + repo, err := gogit.PlainOpen(path) + if err != nil { + return nil, fmt.Errorf("open git repository at %s: %w", path, err) + } + return repo, nil +} + +func lookupGitPathLastModified(repo *gogit.Repository, from plumbing.Hash, path string) (*time.Time, error) { + normalizedPath := strings.Trim(strings.TrimSpace(path), "/") + if normalizedPath == "" { + return nil, nil + } + + iter, err := repo.Log(&gogit.LogOptions{ + From: from, + Order: gogit.LogOrderCommitterTime, + PathFilter: func(candidate string) bool { + trimmed := strings.Trim(strings.TrimSpace(candidate), "/") + return trimmed == normalizedPath || strings.HasPrefix(trimmed, normalizedPath+"/") + }, + }) + if err != nil { + return nil, err + } + defer iter.Close() + + commit, err := iter.Next() + if err != nil { + return nil, err + } + lastModifiedAt := commit.Committer.When.UTC() + return &lastModifiedAt, nil +} + +func ParseGitLFSPointer(content []byte) *GitLFSPointerInfo { + trimmed := strings.TrimSpace(string(content)) + if trimmed == "" { + return nil + } + lines := strings.Split(trimmed, "\n") + if len(lines) < 3 { + return nil + } + + versionLine := strings.TrimSpace(lines[0]) + if versionLine != "version https://git-lfs.github.com/spec/v1" { + return nil + } + oidMatch := gitLFSPointerOIDPattern.FindStringSubmatch(strings.TrimSpace(lines[1])) + if len(oidMatch) != 2 { + return nil + } + sizeLine := strings.TrimSpace(lines[2]) + if !strings.HasPrefix(sizeLine, "size ") { + return nil + } + size, err := strconv.ParseInt(strings.TrimSpace(strings.TrimPrefix(sizeLine, "size ")), 10, 64) + if err != nil { + return nil + } + + return &GitLFSPointerInfo{ + Version: "https://git-lfs.github.com/spec/v1", + OID: strings.ToLower(oidMatch[1]), + Size: size, + } +} diff --git a/internal/git/response.go b/internal/git/response.go new file mode 100644 index 0000000..d3615cd --- /dev/null +++ b/internal/git/response.go @@ -0,0 +1,252 @@ +package git + +import ( + "context" + "fmt" + "io" + "net/http" + "path/filepath" + "sort" + "strings" + + gogit "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing" + "github.com/go-git/go-git/v5/plumbing/filemode" + "github.com/google/go-github/v87/github" + servermw "github.com/calypr/gecko/internal/server/middleware" +) + +func BuildGitTreeResponse(projectID string, ref string, path string, repo *gogit.Repository, hash plumbing.Hash) (*GitProjectTreeResponse, error) { + commit, err := repo.CommitObject(hash) + if err != nil { + return nil, fmt.Errorf("load commit for ref %s: %w", ref, err) + } + tree, err := commit.Tree() + if err != nil { + return nil, fmt.Errorf("load git tree for ref %s: %w", ref, err) + } + normalizedPath := strings.Trim(strings.TrimSpace(path), "/") + if normalizedPath != "" { + tree, err = tree.Tree(normalizedPath) + if err != nil { + return nil, fmt.Errorf("load git tree path %s: %w", normalizedPath, err) + } + } + entries := make([]GitTreeEntry, 0, len(tree.Entries)) + for _, entry := range tree.Entries { + entryPath := entry.Name + if normalizedPath != "" { + entryPath = normalizedPath + "/" + entry.Name + } + gitEntry := GitTreeEntry{Name: entry.Name, Path: entryPath, Hash: entry.Hash.String()} + if entry.Mode == filemode.Dir { + gitEntry.Type = "tree" + } else { + gitEntry.Type = "blob" + if file, err := tree.File(entry.Name); err == nil { + gitEntry.Size = file.Size + if reader, err := file.Reader(); err == nil { + contentBytes, readErr := io.ReadAll(io.LimitReader(reader, 2048)) + _ = reader.Close() + if readErr == nil { + gitEntry.LFSPointer = ParseGitLFSPointer(contentBytes) + } + } + } + } + if lastModifiedAt, err := lookupGitPathLastModified(repo, hash, entryPath); err == nil && lastModifiedAt != nil { + gitEntry.LastModifiedAt = lastModifiedAt + } + entries = append(entries, gitEntry) + } + sort.Slice(entries, func(i, j int) bool { + if entries[i].Type != entries[j].Type { + return entries[i].Type == "tree" + } + return strings.ToLower(entries[i].Name) < strings.ToLower(entries[j].Name) + }) + return &GitProjectTreeResponse{ProjectID: projectID, Ref: ref, Path: normalizedPath, Entries: entries}, nil +} + +func BuildGitRefsResponse(projectID string, defaultBranch string, repo *gogit.Repository) (*GitProjectRefsResponse, error) { + refs := make([]GitRef, 0) + seenBranches := make(map[string]struct{}) + seenTags := make(map[string]struct{}) + iter, err := repo.References() + if err != nil { + return nil, fmt.Errorf("list git refs: %w", err) + } + err = iter.ForEach(func(reference *plumbing.Reference) error { + name := reference.Name() + switch { + case name.IsBranch(): + branchName := name.Short() + if _, ok := seenBranches[branchName]; ok { + return nil + } + seenBranches[branchName] = struct{}{} + refs = append(refs, GitRef{Name: branchName, Type: "branch", Hash: reference.Hash().String(), Default: branchName == defaultBranch}) + case name.IsRemote() && strings.HasPrefix(name.String(), "refs/remotes/origin/"): + branchName := strings.TrimPrefix(name.String(), "refs/remotes/origin/") + if branchName == "HEAD" { + return nil + } + if _, ok := seenBranches[branchName]; ok { + return nil + } + seenBranches[branchName] = struct{}{} + refs = append(refs, GitRef{Name: branchName, Type: "branch", Hash: reference.Hash().String(), Default: branchName == defaultBranch}) + case name.IsTag(): + tagName := name.Short() + if _, ok := seenTags[tagName]; ok { + return nil + } + seenTags[tagName] = struct{}{} + refs = append(refs, GitRef{Name: tagName, Type: "tag", Hash: reference.Hash().String()}) + } + return nil + }) + if err != nil { + return nil, fmt.Errorf("iterate git refs: %w", err) + } + sort.Slice(refs, func(i, j int) bool { + if refs[i].Default != refs[j].Default { + return refs[i].Default + } + if refs[i].Type != refs[j].Type { + return refs[i].Type == "branch" + } + return strings.ToLower(refs[i].Name) < strings.ToLower(refs[j].Name) + }) + return &GitProjectRefsResponse{ProjectID: projectID, DefaultBranch: defaultBranch, Refs: refs}, nil +} + +func BuildGitFileResponse(projectID string, ref string, path string, repo *gogit.Repository, hash plumbing.Hash) (*GitProjectFileResponse, error) { + commit, err := repo.CommitObject(hash) + if err != nil { + return nil, fmt.Errorf("load commit for ref %s: %w", ref, err) + } + tree, err := commit.Tree() + if err != nil { + return nil, fmt.Errorf("load git tree for ref %s: %w", ref, err) + } + normalizedPath := strings.Trim(strings.TrimSpace(path), "/") + if normalizedPath == "" { + return nil, fmt.Errorf("file path is required") + } + file, err := tree.File(normalizedPath) + if err != nil { + return nil, fmt.Errorf("load git file %s: %w", normalizedPath, err) + } + reader, err := file.Reader() + if err != nil { + return nil, fmt.Errorf("open git file %s: %w", normalizedPath, err) + } + defer reader.Close() + const inlineLimit = 256 * 1024 + contentBytes, err := io.ReadAll(io.LimitReader(reader, inlineLimit+1)) + if err != nil { + return nil, fmt.Errorf("read git file content for %s: %w", normalizedPath, err) + } + if len(contentBytes) > inlineLimit { + contentBytes = contentBytes[:inlineLimit] + } + return &GitProjectFileResponse{ + ProjectID: projectID, + Ref: ref, + Path: normalizedPath, + Name: filepath.Base(normalizedPath), + Hash: file.Hash.String(), + Size: file.Size, + LFSPointer: ParseGitLFSPointer(contentBytes), + }, nil +} + +func BuildGitHubFileResponse(projectID string, ref string, path string, metadata *github.RepositoryContent, contentBytes []byte) *GitProjectFileResponse { + name := filepath.Base(strings.Trim(strings.TrimSpace(path), "/")) + hash := "" + size := int64(len(contentBytes)) + htmlURL := "" + downloadURL := "" + if metadata != nil { + if metadata.GetName() != "" { + name = metadata.GetName() + } + if metadata.GetSHA() != "" { + hash = metadata.GetSHA() + } + if metadata.GetSize() > 0 { + size = int64(metadata.GetSize()) + } + htmlURL = metadata.GetHTMLURL() + downloadURL = metadata.GetDownloadURL() + } + return &GitProjectFileResponse{ + ProjectID: projectID, + Ref: ref, + Path: strings.Trim(strings.TrimSpace(path), "/"), + Name: name, + Hash: hash, + Size: size, + HTMLURL: htmlURL, + DownloadURL: downloadURL, + LFSPointer: ParseGitLFSPointer(contentBytes), + } +} + +func (service *GitService) GetGitHubFileMetadata(ctx context.Context, authorizationHeader string, organization string, project string, identity GitRepositoryIdentity, ref string, path string) (*github.RepositoryContent, []byte, error) { + authorizationHeader, err := servermw.ValidateAuthorizationHeader(authorizationHeader) + if err != nil { + return nil, nil, &HTTPStatusError{ + StatusCode: http.StatusUnauthorized, + Code: "missing_authorization", + Message: err.Error(), + } + } + accessToken, err := service.RequestInstallationToken(ctx, authorizationHeader, organization, project, identity, "read") + if err != nil { + return nil, nil, err + } + client, err := service.githubClient(accessToken) + if err != nil { + return nil, nil, err + } + opts := &github.RepositoryContentGetOptions{} + if strings.TrimSpace(ref) != "" { + opts.Ref = strings.TrimSpace(ref) + } + metadata, _, response, err := client.Repositories.GetContents(ctx, identity.Owner, identity.Repo, path, opts) + if err != nil { + statusCode := http.StatusBadGateway + if response != nil && response.StatusCode > 0 { + statusCode = response.StatusCode + } + if statusCode == http.StatusBadGateway && strings.Contains(strings.ToLower(err.Error()), "not found") { + statusCode = http.StatusNotFound + } + return nil, nil, &HTTPStatusError{ + StatusCode: statusCode, + Code: "integration_error", + Message: fmt.Sprintf("GitHub file lookup failed: %s", err), + } + } + if response != nil && response.Response != nil && response.StatusCode >= http.StatusBadRequest { + return metadata, nil, &HTTPStatusError{ + StatusCode: response.StatusCode, + Code: "integration_error", + Message: fmt.Sprintf("GitHub file lookup failed with status %d", response.StatusCode), + } + } + if metadata == nil { + return nil, nil, &HTTPStatusError{ + StatusCode: http.StatusNotFound, + Code: "not_found", + Message: fmt.Sprintf("GitHub file %s was not found", path), + } + } + contentString, err := metadata.GetContent() + if err != nil || contentString == "" { + return metadata, nil, nil + } + return metadata, []byte(contentString), nil +} diff --git a/internal/git/service.go b/internal/git/service.go new file mode 100644 index 0000000..9ec8f54 --- /dev/null +++ b/internal/git/service.go @@ -0,0 +1,215 @@ +package git + +import ( + "context" + "database/sql" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + appconfig "github.com/calypr/gecko/config" + geckodb "github.com/calypr/gecko/internal/db" + githttp "github.com/go-git/go-git/v5/plumbing/transport/http" + "github.com/google/go-github/v87/github" +) + +func (service *GitService) githubClient(accessToken string) (*github.Client, error) { + options := []github.ClientOptionsFunc{ + github.WithAuthToken(accessToken), + github.WithHTTPClient(service.client), + } + if strings.TrimRight(service.config.GitHubAPIBase, "/") != "https://api.github.com" { + apiBase := strings.TrimRight(service.config.GitHubAPIBase, "/") + "/" + options = append(options, github.WithEnterpriseURLs(apiBase, apiBase)) + } + client, err := github.NewClient(options...) + if err != nil { + return nil, fmt.Errorf("create github client: %w", err) + } + return client, nil +} + +func (service *GitService) EnsureDataDir() error { + if err := os.MkdirAll(service.config.DataDir, 0o755); err != nil { + return fmt.Errorf("create git data dir: %w", err) + } + return nil +} + +func (service *GitService) MirrorPathForIdentity(identity GitRepositoryIdentity) string { + return filepath.Join(service.config.DataDir, sanitizePathPart(identity.Host), sanitizePathPart(identity.Owner), sanitizePathPart(identity.Repo)+".git") +} + +func (service *GitService) FetchRepositoryMetadata(ctx context.Context, accessToken string, identity GitRepositoryIdentity) (*GitHubRepositoryMetadata, error) { + if service.githubAPI == nil { + return nil, fmt.Errorf("github client is not initialized") + } + return service.githubAPI.FetchRepositoryMetadata(ctx, accessToken, identity) +} + +func (service *GitService) RefreshProject(ctx context.Context, projectID string, identity GitRepositoryIdentity, state *geckodb.GitProjectState, accessToken string) (*GitProjectRefreshResponse, *geckodb.GitProjectState, error) { + repoMetadata, err := service.FetchRepositoryMetadata(ctx, accessToken, identity) + if err != nil { + return nil, state, err + } + if state == nil { + state = &geckodb.GitProjectState{ProjectID: projectID} + } + if state.MirrorPath == "" { + state.MirrorPath = service.MirrorPathForIdentity(identity) + } + cloneURL := fmt.Sprintf("https://%s/%s/%s.git", identity.Host, identity.Owner, identity.Repo) + if err := SyncRepositoryMirror(ctx, cloneURL, state.MirrorPath, &githttp.BasicAuth{Username: "x-access-token", Password: accessToken}); err != nil { + return nil, state, err + } + updated := *state + updated.InstallationTarget = sql.NullString{String: identity.Owner, Valid: identity.Owner != ""} + updated.InstallationTargetType = sql.NullString{String: "Organization", Valid: identity.Owner != ""} + updated.DefaultBranch = sql.NullString{String: repoMetadata.DefaultBranch, Valid: repoMetadata.DefaultBranch != ""} + updated.SyncState = GitSyncReady + updated.LastRefreshedAt = sql.NullTime{Time: time.Now().UTC(), Valid: true} + updated.LastError = sql.NullString{} + return &GitProjectRefreshResponse{Success: true, ProjectID: projectID, SyncState: GitSyncReady, DefaultBranch: repoMetadata.DefaultBranch, LastFetchedRef: repoMetadata.DefaultBranch}, &updated, nil +} + +func (service *GitService) StatusFromState(projectID string, organization string, project string, cfg appconfig.ProjectConfig, identity GitRepositoryIdentity, state *geckodb.GitProjectState, orgState *geckodb.GitOrganizationState) GitProjectStatusResponse { + response := GitProjectStatusResponse{ + ProjectID: projectID, + Organization: organization, + Project: project, + ResourcePath: ProgramProjectResourcePath(organization, project), + RequestAccessResourcePath: ProgramProjectResourcePath(organization, project), + Config: cfg, + Repository: identity, + InstallationState: GitInstallationNotConnected, + SyncState: GitSyncNeverSynced, + } + if orgState != nil { + response.OrganizationAppInstalled = orgState.Installed + if orgState.HTMLURL.Valid { + response.OrganizationHTMLURL = orgState.HTMLURL.String + } + if orgState.RepositorySelection.Valid { + response.OrganizationRepositorySelection = orgState.RepositorySelection.String + } + } + if state == nil { + return response + } + if state.InstallationID.Valid || state.InstallationTarget.Valid { + response.InstallationState = GitInstallationConnected + } + if state.InstallationID.Valid { + installationID := state.InstallationID.Int64 + response.InstallationID = &installationID + } + if state.InstallationTarget.Valid { + response.InstallationTarget = state.InstallationTarget.String + } + if state.InstallationTargetType.Valid { + response.InstallationTargetType = state.InstallationTargetType.String + } + if state.SyncState != "" { + response.SyncState = state.SyncState + } + if state.DefaultBranch.Valid { + response.DefaultBranch = state.DefaultBranch.String + } + if state.LastRefreshedAt.Valid { + refreshedAt := state.LastRefreshedAt.Time + response.LastRefreshedAt = &refreshedAt + } + if state.LastError.Valid { + response.LastError = state.LastError.String + } + if state.MirrorPath != "" { + if info, err := os.Stat(state.MirrorPath); err == nil && info.IsDir() { + response.MirrorReady = true + } + } + return response +} + +func OrganizationConfigurationState(appInstalled bool, configuredProjects int, totalProjects int) string { + switch { + case !appInstalled: + return "not_connected" + case totalProjects == 0: + return "connected" + case configuredProjects <= 0: + return "installed_unconfigured" + case configuredProjects < totalProjects: + return "partially_configured" + default: + return "connected" + } +} +func (service *GitService) RequestInstallationURL(ctx context.Context, authorizationHeader string, owner string, redirectPath string) (string, error) { + if service.fenceAPI == nil { + return "", fmt.Errorf("fence client is not initialized") + } + return service.fenceAPI.RequestInstallationURL(ctx, authorizationHeader, owner, redirectPath) +} + +func (service *GitService) ResolveTargetAndRepositoryIDs(ctx context.Context, identity GitRepositoryIdentity) (int64, int64, error) { + client, err := service.publicGitHubClient() + if err != nil { + return 0, 0, err + } + + repo, _, err := client.Repositories.Get(ctx, identity.Owner, identity.Repo) + if err != nil { + return 0, 0, fmt.Errorf("github repository lookup failed for %s/%s: %w", identity.Owner, identity.Repo, err) + } + + if repo.GetOwner() == nil { + return 0, 0, fmt.Errorf("github repository %s/%s has no owner details", identity.Owner, identity.Repo) + } + + return repo.GetOwner().GetID(), repo.GetID(), nil +} + +func (service *GitService) publicGitHubClient() (*github.Client, error) { + options := []github.ClientOptionsFunc{ + github.WithHTTPClient(service.client), + } + if strings.TrimRight(service.config.GitHubAPIBase, "/") != "https://api.github.com" { + apiBase := strings.TrimRight(service.config.GitHubAPIBase, "/") + "/" + options = append(options, github.WithEnterpriseURLs(apiBase, apiBase)) + } + client, err := github.NewClient(options...) + if err != nil { + return nil, fmt.Errorf("create public github client: %w", err) + } + return client, nil +} + +func (service *GitService) RequestOrganizationInstallationStatus(ctx context.Context, authorizationHeader string, organization string, owner string) (GitRepositoryInstallationStatus, error) { + if service.fenceAPI == nil { + return GitRepositoryInstallationStatus{}, fmt.Errorf("fence client is not initialized") + } + return service.fenceAPI.RequestOrganizationInstallationStatus(ctx, authorizationHeader, organization, owner) +} + +func (service *GitService) ListInstallationRepositories(ctx context.Context, authorizationHeader string, installationID int64) ([]GitHubInstallationRepository, error) { + if service.fenceAPI == nil { + return nil, fmt.Errorf("fence client is not initialized") + } + return service.fenceAPI.ListInstallationRepositories(ctx, authorizationHeader, installationID) +} + +func (service *GitService) RequestInstallationStatus(ctx context.Context, authorizationHeader string, organization string, identity GitRepositoryIdentity) (GitRepositoryInstallationStatus, error) { + if service.fenceAPI == nil { + return GitRepositoryInstallationStatus{}, fmt.Errorf("fence client is not initialized") + } + return service.fenceAPI.RequestInstallationStatus(ctx, authorizationHeader, organization, identity) +} + +func (service *GitService) RequestInstallationToken(ctx context.Context, authorizationHeader string, organization string, project string, identity GitRepositoryIdentity, access string) (string, error) { + if service.fenceAPI == nil { + return "", fmt.Errorf("fence client is not initialized") + } + return service.fenceAPI.RequestInstallationToken(ctx, authorizationHeader, organization, project, identity, access) +} diff --git a/internal/git/service_test.go b/internal/git/service_test.go new file mode 100644 index 0000000..fc7f5e2 --- /dev/null +++ b/internal/git/service_test.go @@ -0,0 +1,256 @@ +package git + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + gogit "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing" + "github.com/go-git/go-git/v5/plumbing/object" +) + +func TestSyncRepositoryMirrorPullsUpdatesAndReadsTree(t *testing.T) { + tempDir := t.TempDir() + sourcePath := filepath.Join(tempDir, "source") + repo, err := gogit.PlainInit(sourcePath, false) + if err != nil { + t.Fatalf("init source repo: %v", err) + } + if err := os.WriteFile(filepath.Join(sourcePath, "README.md"), []byte("hello gecko"), 0o644); err != nil { + t.Fatalf("write repo file: %v", err) + } + worktree, err := repo.Worktree() + if err != nil { + t.Fatalf("load worktree: %v", err) + } + if _, err := worktree.Add("README.md"); err != nil { + t.Fatalf("add file: %v", err) + } + if _, err := worktree.Commit("initial commit", &gogit.CommitOptions{Author: &object.Signature{Name: "Test", Email: "test@example.org", When: time.Now()}}); err != nil { + t.Fatalf("commit file: %v", err) + } + + mirrorPath := filepath.Join(tempDir, "mirror.git") + if err := SyncRepositoryMirror(context.Background(), sourcePath, mirrorPath, nil); err != nil { + t.Fatalf("sync mirror: %v", err) + } + mirrorRepo, err := OpenRepository(mirrorPath) + if err != nil { + t.Fatalf("open mirror: %v", err) + } + refName, hash, err := ResolveGitReference(mirrorRepo, "", "") + if err != nil { + t.Fatalf("resolve HEAD: %v", err) + } + treeResponse, err := BuildGitTreeResponse("org-a/proj-a", refName, "", mirrorRepo, hash) + if err != nil { + t.Fatalf("build tree response: %v", err) + } + if len(treeResponse.Entries) != 1 || treeResponse.Entries[0].Name != "README.md" { + t.Fatalf("unexpected tree entries: %+v", treeResponse.Entries) + } + + if err := os.WriteFile(filepath.Join(sourcePath, "README.md"), []byte("hello gecko updated"), 0o644); err != nil { + t.Fatalf("update repo file: %v", err) + } + if _, err := worktree.Add("README.md"); err != nil { + t.Fatalf("add updated file: %v", err) + } + if _, err := worktree.Commit("update readme", &gogit.CommitOptions{Author: &object.Signature{Name: "Test", Email: "test@example.org", When: time.Now()}}); err != nil { + t.Fatalf("commit updated file: %v", err) + } + if err := SyncRepositoryMirror(context.Background(), sourcePath, mirrorPath, nil); err != nil { + t.Fatalf("pull mirror update: %v", err) + } + mirrorRepo, err = OpenRepository(mirrorPath) + if err != nil { + t.Fatalf("open updated mirror: %v", err) + } + refName, hash, err = ResolveGitReference(mirrorRepo, "", "") + if err != nil { + t.Fatalf("resolve updated HEAD: %v", err) + } + fileResponse, err := BuildGitFileResponse("org-a/proj-a", refName, "README.md", mirrorRepo, hash) + if err != nil { + t.Fatalf("build updated file response: %v", err) + } + if fileResponse.Name != "README.md" { + t.Fatalf("expected README.md file name, got %q", fileResponse.Name) + } + if fileResponse.Hash == "" { + t.Fatal("expected file hash to be populated") + } +} + +func TestBuildGitRefsResponseIncludesRemoteBranches(t *testing.T) { + tempDir := t.TempDir() + sourcePath := filepath.Join(tempDir, "source") + repo, err := gogit.PlainInit(sourcePath, false) + if err != nil { + t.Fatalf("init source repo: %v", err) + } + worktree, err := repo.Worktree() + if err != nil { + t.Fatalf("load worktree: %v", err) + } + if err := os.WriteFile(filepath.Join(sourcePath, "README.md"), []byte("main branch"), 0o644); err != nil { + t.Fatalf("write readme: %v", err) + } + if _, err := worktree.Add("README.md"); err != nil { + t.Fatalf("add readme: %v", err) + } + if _, err := worktree.Commit("main commit", &gogit.CommitOptions{Author: &object.Signature{Name: "Test", Email: "test@example.org", When: time.Now()}}); err != nil { + t.Fatalf("commit readme: %v", err) + } + headRef, err := repo.Head() + if err != nil { + t.Fatalf("read initial head: %v", err) + } + defaultBranch := headRef.Name().Short() + if err := worktree.Checkout(&gogit.CheckoutOptions{ + Branch: plumbing.NewBranchReferenceName("benchmarking"), + Create: true, + }); err != nil { + t.Fatalf("checkout benchmarking branch: %v", err) + } + if err := os.WriteFile(filepath.Join(sourcePath, "benchmark.txt"), []byte("branch file"), 0o644); err != nil { + t.Fatalf("write branch file: %v", err) + } + if _, err := worktree.Add("benchmark.txt"); err != nil { + t.Fatalf("add branch file: %v", err) + } + if _, err := worktree.Commit("benchmark branch commit", &gogit.CommitOptions{Author: &object.Signature{Name: "Test", Email: "test@example.org", When: time.Now()}}); err != nil { + t.Fatalf("commit branch file: %v", err) + } + if err := worktree.Checkout(&gogit.CheckoutOptions{ + Branch: plumbing.NewBranchReferenceName(defaultBranch), + }); err != nil { + t.Fatalf("checkout default branch: %v", err) + } + + mirrorPath := filepath.Join(tempDir, "mirror.git") + if err := SyncRepositoryMirror(context.Background(), sourcePath, mirrorPath, nil); err != nil { + t.Fatalf("sync mirror: %v", err) + } + mirrorRepo, err := OpenRepository(mirrorPath) + if err != nil { + t.Fatalf("open mirror: %v", err) + } + refsResponse, err := BuildGitRefsResponse("org-a/proj-a", defaultBranch, mirrorRepo) + if err != nil { + t.Fatalf("build refs response: %v", err) + } + branchNames := make([]string, 0, len(refsResponse.Refs)) + for _, ref := range refsResponse.Refs { + if ref.Type == "branch" { + branchNames = append(branchNames, ref.Name) + } + } + if len(branchNames) < 2 { + t.Fatalf("expected multiple branches in refs response, got %+v", refsResponse.Refs) + } + foundBenchmarking := false + for _, branchName := range branchNames { + if branchName == "benchmarking" { + foundBenchmarking = true + break + } + } + if !foundBenchmarking { + t.Fatalf("expected benchmarking branch in refs response, got %+v", refsResponse.Refs) + } + + refName, hash, err := ResolveGitReference(mirrorRepo, "benchmarking", defaultBranch) + if err != nil { + t.Fatalf("resolve benchmarking branch: %v", err) + } + if refName != "benchmarking" { + t.Fatalf("expected resolved ref name benchmarking, got %q", refName) + } + fileResponse, err := BuildGitFileResponse("org-a/proj-a", refName, "benchmark.txt", mirrorRepo, hash) + if err != nil { + t.Fatalf("build branch file response: %v", err) + } + if fileResponse.Name != "benchmark.txt" { + t.Fatalf("expected benchmark.txt file name, got %q", fileResponse.Name) + } + if fileResponse.Hash == "" { + t.Fatal("expected branch file hash to be populated") + } +} + +func TestBuildGitResponsesDetectLFSPointers(t *testing.T) { + tempDir := t.TempDir() + sourcePath := filepath.Join(tempDir, "source") + repo, err := gogit.PlainInit(sourcePath, false) + if err != nil { + t.Fatalf("init source repo: %v", err) + } + worktree, err := repo.Worktree() + if err != nil { + t.Fatalf("load worktree: %v", err) + } + pointerContent := strings.Join([]string{ + "version https://git-lfs.github.com/spec/v1", + "oid sha256:0bfab2917ce05007ff6297c0ec93ef575209210e4ca998dbd243a270e2f9ca83", + "size 3780184021", + "", + }, "\n") + if err := os.MkdirAll(filepath.Join(sourcePath, "data"), 0o755); err != nil { + t.Fatalf("create data dir: %v", err) + } + if err := os.WriteFile(filepath.Join(sourcePath, "data", "tcga.tumor.ensembl.tsv"), []byte(pointerContent), 0o644); err != nil { + t.Fatalf("write lfs pointer file: %v", err) + } + if _, err := worktree.Add("data/tcga.tumor.ensembl.tsv"); err != nil { + t.Fatalf("add lfs pointer file: %v", err) + } + if _, err := worktree.Commit("add lfs pointer", &gogit.CommitOptions{Author: &object.Signature{Name: "Test", Email: "test@example.org", When: time.Now()}}); err != nil { + t.Fatalf("commit lfs pointer file: %v", err) + } + + mirrorPath := filepath.Join(tempDir, "mirror.git") + if err := SyncRepositoryMirror(context.Background(), sourcePath, mirrorPath, nil); err != nil { + t.Fatalf("sync mirror: %v", err) + } + mirrorRepo, err := OpenRepository(mirrorPath) + if err != nil { + t.Fatalf("open mirror: %v", err) + } + refName, hash, err := ResolveGitReference(mirrorRepo, "", "") + if err != nil { + t.Fatalf("resolve HEAD: %v", err) + } + treeResponse, err := BuildGitTreeResponse("org-a/proj-a", refName, "data", mirrorRepo, hash) + if err != nil { + t.Fatalf("build tree response: %v", err) + } + if len(treeResponse.Entries) != 1 { + t.Fatalf("expected one tree entry, got %+v", treeResponse.Entries) + } + treePointer := treeResponse.Entries[0].LFSPointer + if treePointer == nil { + t.Fatalf("expected tree entry to be marked as lfs pointer, got %+v", treeResponse.Entries[0]) + } + if treePointer.OID != "0bfab2917ce05007ff6297c0ec93ef575209210e4ca998dbd243a270e2f9ca83" { + t.Fatalf("unexpected lfs oid: %q", treePointer.OID) + } + if treePointer.Size != 3780184021 { + t.Fatalf("unexpected lfs size: %d", treePointer.Size) + } + + fileResponse, err := BuildGitFileResponse("org-a/proj-a", refName, "data/tcga.tumor.ensembl.tsv", mirrorRepo, hash) + if err != nil { + t.Fatalf("build file response: %v", err) + } + if fileResponse.LFSPointer == nil { + t.Fatalf("expected file response to include lfs pointer metadata") + } + if fileResponse.LFSPointer.OID != treePointer.OID { + t.Fatalf("expected matching lfs oid, got %q and %q", fileResponse.LFSPointer.OID, treePointer.OID) + } +} diff --git a/internal/git/setup.go b/internal/git/setup.go new file mode 100644 index 0000000..3df2140 --- /dev/null +++ b/internal/git/setup.go @@ -0,0 +1,316 @@ +package git + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + + "github.com/calypr/gecko/config" + geckodb "github.com/calypr/gecko/internal/db" + "github.com/calypr/gecko/internal/integrations/syfon" + servermw "github.com/calypr/gecko/internal/server/middleware" + "github.com/golang-jwt/jwt/v5" + "github.com/jmoiron/sqlx" +) + +type SetupService struct { + db *sqlx.DB + gitService *GitService + storage *syfon.Manager + accessChecks *servermw.FenceUserAccessHandler +} + +func NewSetupService(db *sqlx.DB, gitService *GitService, storage *syfon.Manager, accessChecks *servermw.FenceUserAccessHandler) *SetupService { + return &SetupService{ + db: db, + gitService: gitService, + storage: storage, + accessChecks: accessChecks, + } +} + +func (service *SetupService) InitializeProject(ctx context.Context, authorizationHeader, organization, project string, request CalyprProjectSetupRequest) (*CalyprProjectInitializeResponse, error) { + projectID := strings.TrimSpace(organization) + "/" + strings.TrimSpace(project) + request.Config.OrgTitle = organization + if strings.TrimSpace(request.Config.ProjectTitle) == "" { + request.Config.ProjectTitle = project + } + if strings.TrimSpace(request.Config.Title) == "" { + request.Config.Title = project + } + if err := request.Config.ValidateInitialization(); err != nil { + return nil, NewError(ErrorKindValidation, http.StatusBadRequest, fmt.Sprintf("body data validation failed: %s", err), map[string]any{"project_id": projectID}) + } + + createdResourcePaths, err := service.ensureProjectOwnershipResources(ctx, authorizationHeader, organization, project) + if err != nil { + return nil, WrapError(ErrorKindForbidden, http.StatusForbidden, "failed to ensure arborist ownership resources", err, map[string]any{"project_id": projectID}) + } + + if request.Storage != nil { + request.Storage.Organization = organization + request.Storage.ProjectID = project + if err := service.PopulateStorageIntent(ctx, authorizationHeader, request.Storage); err != nil { + if len(createdResourcePaths) > 0 { + bestEffortDeleteAuthzResources(ctx, authorizationHeader, createdResourcePaths) + } + return nil, err + } + } + + if err := geckodb.ConfigPUTGeneric(service.db, projectID, string(config.TypeProjects), &request.Config); err != nil { + if len(createdResourcePaths) > 0 { + bestEffortDeleteAuthzResources(ctx, authorizationHeader, createdResourcePaths) + } + return nil, WrapError(ErrorKindDatabase, http.StatusInternalServerError, "configPut failed", err, map[string]any{"config_type": string(config.TypeProjects), "config_id": projectID}) + } + + return &CalyprProjectInitializeResponse{ + Success: true, + ProjectID: projectID, + ResourcePath: ProgramProjectResourcePath(organization, project), + }, nil +} + +func (service *SetupService) PopulateStorage(ctx context.Context, authorizationHeader, organization, project string, request CalyprProjectStorageRequest) (*CalyprProjectStorageResponse, error) { + if request.Storage == nil { + return nil, NewError(ErrorKindValidation, http.StatusBadRequest, "storage configuration is required", map[string]any{"organization": organization, "project": project}) + } + request.Storage.Organization = organization + request.Storage.ProjectID = project + if err := service.PopulateStorageIntent(ctx, authorizationHeader, request.Storage); err != nil { + return nil, err + } + storageStatus, err := service.StorageCheck(ctx, authorizationHeader, organization, project) + if err != nil { + return nil, err + } + return &CalyprProjectStorageResponse{ + Success: true, + ProjectID: organization + "/" + project, + ResourcePath: ProgramProjectResourcePath(organization, project), + Storage: storageStatus, + }, nil +} + +func (service *SetupService) PopulateStorageIntent(ctx context.Context, authorizationHeader string, intent *CalyprProjectStorageIntent) error { + if intent == nil { + return nil + } + storageConfig := storageConfigFromIntent(intent) + if err := service.storage.PutBucket(ctx, authorizationHeader, storageConfig); err != nil { + return WrapError(ErrorKindIntegration, http.StatusBadGateway, "failed to configure syfon bucket", err, map[string]any{"project_id": strings.TrimSpace(intent.Organization) + "/" + strings.TrimSpace(intent.ProjectID)}) + } + if err := service.storage.AddScope(ctx, authorizationHeader, storageConfig); err != nil { + return WrapError(ErrorKindIntegration, http.StatusBadGateway, "failed to configure syfon scope", err, map[string]any{"project_id": strings.TrimSpace(intent.Organization) + "/" + strings.TrimSpace(intent.ProjectID)}) + } + return nil +} + +func storageConfigFromIntent(intent *CalyprProjectStorageIntent) StorageConfig { + if intent == nil { + return StorageConfig{} + } + return StorageConfig{ + Bucket: strings.TrimSpace(intent.Bucket), + Provider: strings.TrimSpace(intent.Provider), + Endpoint: strings.TrimSpace(intent.Endpoint), + Region: strings.TrimSpace(intent.Region), + AccessKey: strings.TrimSpace(intent.AccessKey), + SecretKey: strings.TrimSpace(intent.SecretKey), + Organization: strings.TrimSpace(intent.Organization), + ProjectID: strings.TrimSpace(intent.ProjectID), + Path: strings.TrimSpace(intent.Path), + PathPrefix: strings.TrimSpace(intent.PathPrefix), + OrganizationSubPath: strings.TrimSpace(intent.OrganizationSubPath), + ProjectSubPath: strings.TrimSpace(intent.ProjectSubPath), + } +} + +func (service *SetupService) StorageCheck(ctx context.Context, authorizationHeader, organization, project string) (ProjectIntegrationCheck, error) { + buckets, err := service.storage.ListBuckets(ctx, authorizationHeader) + if err != nil { + return ProjectIntegrationCheck{ + Pass: false, + Reason: "missing_storage_scope", + Details: err.Error(), + }, nil + } + return deriveStorageSetupCheck(buckets, organization, project), nil +} + +func (service *SetupService) CleanupProjectStorage(ctx context.Context, authorizationHeader, organization, project string) error { + if err := service.storage.CleanupProject(ctx, authorizationHeader, organization, project); err != nil { + return WrapError(ErrorKindIntegration, http.StatusBadGateway, "failed to delete syfon project state", err, map[string]any{"project_id": organization + "/" + project}) + } + return nil +} + +func deriveStorageSetupCheck(buckets map[string]StorageBucket, organization string, project string) ProjectIntegrationCheck { + check := ProjectIntegrationCheck{ + Pass: false, + Reason: "missing_storage_scope", + } + expectedPrograms := fmt.Sprintf("/programs/%s/projects/%s", strings.TrimSpace(organization), strings.TrimSpace(project)) + expectedOrganization := fmt.Sprintf("/organization/%s/project/%s", strings.TrimSpace(organization), strings.TrimSpace(project)) + for _, metadata := range buckets { + for _, resource := range metadata.Resources { + normalized := strings.TrimSpace(resource) + if normalized == expectedPrograms || normalized == expectedOrganization { + check.Pass = true + check.Reason = "" + check.Details = "" + return check + } + } + } + check.Details = "No Syfon bucket scope matched this project" + return check +} + +type arboristOwnedDescendantRequest struct { + Name string `json:"name"` + ParentPath string `json:"parent_path"` + Template string `json:"template"` +} + +func fenceIssuerBaseURL(authorizationHeader string) (string, error) { + token := servermw.CleanAccessToken(authorizationHeader) + if token == "" { + return "", fmt.Errorf("authorization header is required") + } + parser := jwt.NewParser(jwt.WithoutClaimsValidation()) + claims := jwt.MapClaims{} + if _, _, err := parser.ParseUnverified(token, claims); err != nil { + return "", fmt.Errorf("failed to parse authorization token: %w", err) + } + iss, _ := claims["iss"].(string) + iss = strings.TrimSpace(iss) + if iss == "" { + return "", fmt.Errorf("authorization token does not include iss") + } + return strings.TrimSuffix(strings.TrimSuffix(iss, "/user"), "/"), nil +} + +func arboristOwnedDescendantURL(authorizationHeader string) (string, error) { + baseURL, err := fenceIssuerBaseURL(authorizationHeader) + if err != nil { + return "", err + } + return baseURL + "/authz/ownership/descendant", nil +} + +func arboristOwnershipResourceURL(authorizationHeader, resourcePath string) (string, error) { + baseURL, err := fenceIssuerBaseURL(authorizationHeader) + if err != nil { + return "", err + } + return baseURL + "/authz/ownership/resource?resource_path=" + url.QueryEscape(strings.TrimSpace(resourcePath)), nil +} + +func createAuthzOwnedDescendant(ctx context.Context, authorizationHeader string, request arboristOwnedDescendantRequest) error { + requestBody, err := json.Marshal(request) + if err != nil { + return fmt.Errorf("marshal arborist descendant request: %w", err) + } + endpoint, err := arboristOwnedDescendantURL(authorizationHeader) + if err != nil { + return err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(requestBody)) + if err != nil { + return fmt.Errorf("build arborist descendant request: %w", err) + } + req.Header.Set("Authorization", authorizationHeader) + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("request arborist descendant create: %w", err) + } + defer resp.Body.Close() + responseBody, _ := io.ReadAll(resp.Body) + if resp.StatusCode >= http.StatusBadRequest { + return fmt.Errorf("arborist descendant create failed with status %d: %s", resp.StatusCode, strings.TrimSpace(string(responseBody))) + } + return nil +} + +func DeleteAuthzResource(ctx context.Context, authorizationHeader, resourcePath string) error { + endpoint, err := arboristOwnershipResourceURL(authorizationHeader, resourcePath) + if err != nil { + return err + } + req, err := http.NewRequestWithContext(ctx, http.MethodDelete, endpoint, nil) + if err != nil { + return fmt.Errorf("build arborist resource delete request: %w", err) + } + req.Header.Set("Authorization", authorizationHeader) + resp, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("request arborist resource delete: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode >= http.StatusBadRequest && resp.StatusCode != http.StatusNotFound { + responseBody, _ := io.ReadAll(resp.Body) + return fmt.Errorf("arborist resource delete failed with status %d: %s", resp.StatusCode, strings.TrimSpace(string(responseBody))) + } + return nil +} + +func bestEffortDeleteAuthzResources(ctx context.Context, authorizationHeader string, resourcePaths []string) { + for i := len(resourcePaths) - 1; i >= 0; i-- { + _ = DeleteAuthzResource(ctx, authorizationHeader, resourcePaths[i]) + } +} + +func (service *SetupService) ensureProjectOwnershipResources(ctx context.Context, authorizationHeader, organization, project string) ([]string, error) { + created := []string{} + projectResource := ProgramProjectResourcePath(organization, project) + orgProjectsResource := fmt.Sprintf("/programs/%s/projects", organization) + orgResource := fmt.Sprintf("/programs/%s", organization) + + projectReadable, err := service.accessChecks.CheckResourceServiceAccess(authorizationHeader, "read", "*", projectResource) + if err != nil { + return nil, err + } + if projectReadable { + return created, nil + } + + orgCanCreateProject, err := service.accessChecks.CheckResourceServiceAccess(authorizationHeader, "create-descendant", "arborist", orgProjectsResource) + if err != nil { + return nil, err + } + orgManageOwners, err := service.accessChecks.CheckResourceServiceAccess(authorizationHeader, "manage-owners", "arborist", orgResource) + if err != nil { + return nil, err + } + if !orgCanCreateProject && !orgManageOwners { + if err := createAuthzOwnedDescendant(ctx, authorizationHeader, arboristOwnedDescendantRequest{ + Name: organization, + ParentPath: "/programs", + Template: "gen3-program", + }); err != nil { + return created, err + } + created = append(created, orgResource) + } + + if err := createAuthzOwnedDescendant(ctx, authorizationHeader, arboristOwnedDescendantRequest{ + Name: project, + ParentPath: fmt.Sprintf("/programs/%s/projects", organization), + Template: "gen3-project", + }); err != nil { + if len(created) > 0 { + bestEffortDeleteAuthzResources(ctx, authorizationHeader, created) + } + return nil, err + } + created = append(created, projectResource) + return created, nil +} diff --git a/internal/git/types.go b/internal/git/types.go new file mode 100644 index 0000000..9f8b4d4 --- /dev/null +++ b/internal/git/types.go @@ -0,0 +1,379 @@ +package git + +import ( + "fmt" + "net/http" + "strings" + "time" + + appconfig "github.com/calypr/gecko/config" + geckodb "github.com/calypr/gecko/internal/db" + "github.com/calypr/gecko/internal/git/domain" + "github.com/calypr/gecko/internal/integrations/fence" + gitapi "github.com/calypr/gecko/internal/integrations/github" + "github.com/jmoiron/sqlx" +) + +const ( + GitSyncNeverSynced = "never_synced" + GitSyncReady = "ready" + GitSyncUpdating = "updating" + GitSyncError = "error" + + GitInstallationNotConnected = "not_connected" + GitInstallationConnected = "connected" +) + +type GitServiceConfig struct { + DataDir string + GitHubAPIBase string + FenceBaseURL string + HTTPClient *http.Client + FenceClient *fence.Client + GitHubClient *gitapi.Client +} + +type GitService struct { + config GitServiceConfig + client *http.Client + fenceAPI *fence.Client + githubAPI *gitapi.Client +} + +// GitRepositoryIdentity is an alias for domain.GitRepositoryIdentity. +type GitRepositoryIdentity = domain.GitRepositoryIdentity + +type GitProjectStatusResponse struct { + ProjectID string `json:"project_id"` + Organization string `json:"organization"` + Project string `json:"project"` + ResourcePath string `json:"resource_path"` + Accessible bool `json:"accessible"` + RequestAccess bool `json:"request_access"` + RequestAccessResourcePath string `json:"request_access_resource_path,omitempty"` + Config appconfig.ProjectConfig `json:"config"` + Repository GitRepositoryIdentity `json:"repository"` + InstallationState string `json:"installation_state"` + InstallationID *int64 `json:"installation_id,omitempty"` + InstallationTarget string `json:"installation_target,omitempty"` + InstallationTargetType string `json:"installation_target_type,omitempty"` + OrganizationAppInstalled bool `json:"organization_app_installed"` + OrganizationHTMLURL string `json:"organization_html_url,omitempty"` + OrganizationRepositorySelection string `json:"organization_repository_selection,omitempty"` + SyncState string `json:"sync_state"` + DefaultBranch string `json:"default_branch,omitempty"` + LastRefreshedAt *time.Time `json:"last_refreshed_at,omitempty"` + LastError string `json:"last_error,omitempty"` + MirrorReady bool `json:"mirror_ready"` +} + +type GitOrganizationConnectResponse struct { + Mode string `json:"mode"` + RedirectURL string `json:"redirect_url,omitempty"` + InstallationID *int64 `json:"installation_id,omitempty"` + Repositories []GitHubInstallationRepository `json:"repositories,omitempty"` +} + +// GitRepositoryInstallationStatus is an alias for domain.GitRepositoryInstallationStatus. +type GitRepositoryInstallationStatus = domain.GitRepositoryInstallationStatus + + +type ProjectIntegrationCheck struct { + Pass bool `json:"pass"` + Reason string `json:"reason,omitempty"` + Details string `json:"details,omitempty"` +} + +type ProjectIntegrationStatus struct { + GitHub ProjectIntegrationCheck `json:"github"` + Storage ProjectIntegrationCheck `json:"storage"` +} + +type GitOrganizationProjectStatus struct { + ProjectID string `json:"project_id"` + Project string `json:"project"` + ResourcePath string `json:"resource_path"` + Repository GitRepositoryIdentity `json:"repository"` + Configured bool `json:"configured"` + Readiness *CalyprProjectReadiness `json:"readiness,omitempty"` + Integrations ProjectIntegrationStatus `json:"integrations"` + Accessible bool `json:"accessible"` + CanManageSettings bool `json:"can_manage_settings"` + RequestAccess bool `json:"request_access"` + RequestAccessResourcePath string `json:"request_access_resource_path,omitempty"` + Installation GitRepositoryInstallationStatus `json:"installation"` +} + +type CalyprProjectStorageIntent struct { + Bucket string `json:"bucket"` + Provider string `json:"provider"` + Endpoint string `json:"endpoint"` + Region string `json:"region"` + AccessKey string `json:"access_key"` + SecretKey string `json:"secret_key"` + Organization string `json:"organization"` + ProjectID string `json:"project_id"` + Path string `json:"path,omitempty"` + PathPrefix string `json:"path_prefix,omitempty"` + OrganizationSubPath string `json:"organization_sub_path,omitempty"` + ProjectSubPath string `json:"project_sub_path,omitempty"` +} + +type CalyprProjectSetupRequest struct { + Config appconfig.ProjectConfig `json:"config"` + Storage *CalyprProjectStorageIntent `json:"storage,omitempty"` +} + +type CalyprProjectInitializeResponse struct { + Success bool `json:"success"` + ProjectID string `json:"project_id"` + ResourcePath string `json:"resource_path"` +} + +type CalyprProjectStorageRequest struct { + Storage *CalyprProjectStorageIntent `json:"storage"` +} + +type CalyprProjectStorageResponse struct { + Success bool `json:"success"` + ProjectID string `json:"project_id"` + ResourcePath string `json:"resource_path"` + Storage ProjectIntegrationCheck `json:"storage"` +} + +type CalyprReadinessCheck struct { + Pass bool `json:"pass"` + Reason string `json:"reason,omitempty"` + Details string `json:"details,omitempty"` +} + +type CalyprProjectReadiness struct { + Git CalyprReadinessCheck `json:"git"` + Syfon CalyprReadinessCheck `json:"syfon"` + Config CalyprReadinessCheck `json:"config"` +} + +type CalyprProjectSetupResponse struct { + ProjectID string `json:"project_id"` + ResourcePath string `json:"resource_path"` + Configured bool `json:"configured"` + Readiness CalyprProjectReadiness `json:"readiness"` +} + +// GitHubInstallationRepository is an alias for domain.GitHubInstallationRepository. +type GitHubInstallationRepository = domain.GitHubInstallationRepository + + + +type GitOrganizationStatusResponse struct { + Organization string `json:"organization"` + Connected bool `json:"connected"` + AppInstalled bool `json:"app_installed"` + CanAccessSettings bool `json:"can_access_settings"` + CanCreateProjects bool `json:"can_create_projects"` + CanManagePeople bool `json:"can_manage_people"` + CanDeleteOrg bool `json:"can_delete_org"` + InstallationID *int64 `json:"installation_id,omitempty"` + HTMLURL string `json:"html_url,omitempty"` + RepositorySelection string `json:"repository_selection,omitempty"` + ConfigurationState string `json:"configuration_state"` + ConnectedProjects int `json:"connected_projects"` + ConfiguredProjects int `json:"configured_projects"` + TotalProjects int `json:"total_projects"` + Projects []GitOrganizationProjectStatus `json:"projects"` +} + +type GitOrganizationsStatusResponse struct { + Connected bool `json:"connected"` + AppInstalled bool `json:"app_installed"` + ConnectedOrganizations int `json:"connected_organizations"` + InstalledOrganizations int `json:"installed_organizations"` + TotalOrganizations int `json:"total_organizations"` + ConnectedProjects int `json:"connected_projects"` + ConfiguredProjects int `json:"configured_projects"` + TotalProjects int `json:"total_projects"` + ConfigurationState string `json:"configuration_state"` + Organizations []GitOrganizationStatusResponse `json:"organizations"` +} + +type GitProjectRefreshResponse struct { + Success bool `json:"success"` + ProjectID string `json:"project_id"` + SyncState string `json:"sync_state"` + DefaultBranch string `json:"default_branch,omitempty"` + LastFetchedRef string `json:"last_fetched_ref,omitempty"` + Error string `json:"error,omitempty"` +} + +type GitRef struct { + Name string `json:"name"` + Type string `json:"type"` + Hash string `json:"hash"` + Default bool `json:"default"` +} + +type GitProjectRefsResponse struct { + ProjectID string `json:"project_id"` + DefaultBranch string `json:"default_branch,omitempty"` + Refs []GitRef `json:"refs"` +} + +type GitTreeEntry struct { + Name string `json:"name"` + Path string `json:"path"` + Type string `json:"type"` + Hash string `json:"hash"` + Size int64 `json:"size,omitempty"` + LastModifiedAt *time.Time `json:"last_modified_at,omitempty"` + LFSPointer *GitLFSPointerInfo `json:"lfs_pointer,omitempty"` +} + +type GitProjectTreeResponse struct { + ProjectID string `json:"project_id"` + Ref string `json:"ref"` + Path string `json:"path"` + Entries []GitTreeEntry `json:"entries"` +} + +type GitProjectFileResponse struct { + ProjectID string `json:"project_id"` + Ref string `json:"ref"` + Path string `json:"path"` + Name string `json:"name"` + Hash string `json:"hash"` + Size int64 `json:"size"` + HTMLURL string `json:"html_url,omitempty"` + DownloadURL string `json:"download_url,omitempty"` + LFSPointer *GitLFSPointerInfo `json:"lfs_pointer,omitempty"` +} + +type GitLFSPointerInfo struct { + Version string `json:"version"` + OID string `json:"oid"` + Size int64 `json:"size"` +} + +type GitUploadSessionFileManifest struct { + Name string `json:"name"` + Size int64 `json:"size"` +} + +type GitUploadSessionCreateRequest struct { + BaseBranch string `json:"base_branch"` + TargetSubdir string `json:"target_subdirectory"` + Files []GitUploadSessionFileManifest `json:"files"` +} + +type GitUploadSessionFileAttachment struct { + FileName string `json:"file_name"` + TargetPath string `json:"target_path"` + Checksum string `json:"checksum"` + DRSObjectID string `json:"drs_object_id"` + Size int64 `json:"size"` +} + +type GitUploadSessionAttachFilesRequest struct { + Files []GitUploadSessionFileAttachment `json:"files"` +} + +type GitUploadSessionFinalizeRequest struct { + PRTitle string `json:"pr_title"` + PRBody string `json:"pr_body"` +} + +type GitUploadSessionFileStatus struct { + FileName string `json:"file_name"` + TargetPath string `json:"target_path"` + Size int64 `json:"size"` + Checksum string `json:"checksum,omitempty"` + DRSObjectID string `json:"drs_object_id,omitempty"` + Status string `json:"status"` + Error string `json:"error,omitempty"` + Collision bool `json:"collision"` +} + +type GitUploadSessionResponse struct { + SessionID string `json:"session_id"` + ProjectID string `json:"project_id"` + BaseBranch string `json:"base_branch"` + TargetSubdir string `json:"target_subdirectory,omitempty"` + BranchName string `json:"branch_name"` + PRTitle string `json:"pr_title"` + PRBody string `json:"pr_body"` + Status string `json:"status"` + PullRequestURL string `json:"pull_request_url,omitempty"` + CommitSHA string `json:"commit_sha,omitempty"` + Files []GitUploadSessionFileStatus `json:"files"` + HasConflicts bool `json:"has_conflicts"` +} + +// GitHubRepositoryMetadata is an alias for domain.GitHubRepositoryMetadata. +type GitHubRepositoryMetadata = domain.GitHubRepositoryMetadata + + +// HTTPStatusError is an alias for domain.HTTPStatusError. +type HTTPStatusError = domain.HTTPStatusError + + +func NewGitService(config GitServiceConfig) *GitService { + if config.GitHubAPIBase == "" { + config.GitHubAPIBase = "https://api.github.com" + } + client := config.HTTPClient + if client == nil { + client = &http.Client{Timeout: 20 * time.Second} + } + return &GitService{ + config: config, + client: client, + fenceAPI: config.FenceClient, + githubAPI: config.GitHubClient, + } +} + +func (service *GitService) Init(db *sqlx.DB) error { + if strings.TrimSpace(service.config.DataDir) == "" { + return fmt.Errorf("git data dir is required; set GIT_DATA_DIR or --git-data-dir") + } + if err := service.EnsureDataDir(); err != nil { + return err + } + if db == nil { + return nil + } + return geckodb.EnsureGitProjectStateTable(db) +} + +func ParseRepositoryIdentity(raw string) (GitRepositoryIdentity, error) { + normalized, err := appconfig.NormalizeProjectRepositoryURL(raw) + if err != nil { + return GitRepositoryIdentity{}, err + } + parts := strings.Split(normalized, "/") + if len(parts) != 3 { + return GitRepositoryIdentity{}, fmt.Errorf("expected normalized host/owner/repo path, got %q", normalized) + } + return GitRepositoryIdentity{ + Host: parts[0], + Owner: parts[1], + Repo: parts[2], + URL: fmt.Sprintf("https://%s/%s/%s", parts[0], parts[1], parts[2]), + }, nil +} + +func sanitizePathPart(value string) string { + replacer := strings.NewReplacer("/", "_", "\\", "_", ":", "_", " ", "_") + return replacer.Replace(value) +} + +// StorageBucket is an alias for domain.StorageBucket. +type StorageBucket = domain.StorageBucket + +// StorageConfig is an alias for domain.StorageConfig. +type StorageConfig = domain.StorageConfig + + +func ProgramProjectResourcePath(organization, project string) string { + return fmt.Sprintf("/programs/%s/projects/%s", organization, project) +} + diff --git a/internal/git/upload.go b/internal/git/upload.go new file mode 100644 index 0000000..4a0c70b --- /dev/null +++ b/internal/git/upload.go @@ -0,0 +1,219 @@ +package git + +import ( + "context" + "fmt" + "net/http" + "strings" + "time" + + geckodb "github.com/calypr/gecko/internal/db" + gogit "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing" + "github.com/google/go-github/v87/github" + "github.com/google/uuid" +) + +const ( + GitUploadSessionPending = "pending_upload" + GitUploadSessionReady = "ready_for_pr" + GitUploadSessionFinalized = "finalized" + + GitUploadFilePending = "pending_upload" + GitUploadFileUploaded = "uploaded" + GitUploadFileCollision = "collision" +) + +func NormalizeGitUploadSubdirectory(value string) string { + return strings.Trim(strings.TrimSpace(value), "/") +} + +func NormalizeGitUploadFileName(value string) (string, error) { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return "", fmt.Errorf("file name is required") + } + if strings.Contains(trimmed, "/") || strings.Contains(trimmed, "\\") { + return "", fmt.Errorf("file name must not include path separators") + } + return trimmed, nil +} + +func BuildGitUploadBranchName(project string) string { + slug := strings.ToLower(strings.ReplaceAll(strings.TrimSpace(project), "_", "-")) + if slug == "" { + slug = "upload" + } + short := strings.ToLower(strings.ReplaceAll(uuid.NewString(), "-", ""))[:8] + return fmt.Sprintf("calypr/upload-%s-%s-%s", slug, time.Now().UTC().Format("20060102-150405"), short) +} + +func BuildLFSPointerContent(checksum string, size int64) string { + return fmt.Sprintf("version https://git-lfs.github.com/spec/v1\noid sha256:%s\nsize %d\n", strings.ToLower(strings.TrimSpace(checksum)), size) +} + +func BuildDefaultUploadPRTitle(project string, fileCount int) string { + if fileCount == 1 { + return fmt.Sprintf("Add 1 LFS file to %s", project) + } + return fmt.Sprintf("Add %d LFS files to %s", fileCount, project) +} + +func BuildDefaultUploadPRBody(baseBranch string, subdirectory string) string { + if subdirectory == "" { + return fmt.Sprintf("Upload Git LFS-backed files into `%s`.", baseBranch) + } + return fmt.Sprintf("Upload Git LFS-backed files into `%s` under `%s`.", baseBranch, subdirectory) +} + +func BuildGitUploadTargetPath(subdirectory string, fileName string) string { + if subdirectory == "" { + return fileName + } + return strings.Trim(strings.TrimSpace(subdirectory), "/") + "/" + fileName +} + +func BuildGitUploadSessionResponse(session geckodb.GitUploadSession, files []geckodb.GitUploadSessionFile) GitUploadSessionResponse { + response := GitUploadSessionResponse{ + SessionID: session.ID, + ProjectID: session.ProjectID, + BaseBranch: session.BaseBranch, + BranchName: session.BranchName, + PRTitle: session.PRTitle, + PRBody: session.PRBody, + Status: session.Status, + Files: make([]GitUploadSessionFileStatus, 0, len(files)), + TargetSubdir: session.TargetSubdir.String, + PullRequestURL: session.PullRequestURL.String, + CommitSHA: session.CommitSHA.String, + } + for _, file := range files { + status := GitUploadSessionFileStatus{ + FileName: file.FileName, + TargetPath: file.TargetPath, + Size: file.Size, + Status: file.Status, + Collision: file.Status == GitUploadFileCollision, + } + if file.Checksum.Valid { + status.Checksum = file.Checksum.String + } + if file.DRSObjectID.Valid { + status.DRSObjectID = file.DRSObjectID.String + } + if file.Error.Valid { + status.Error = file.Error.String + } + if status.Collision { + response.HasConflicts = true + } + response.Files = append(response.Files, status) + } + return response +} + +func GitPathExistsInRef(repo *gogit.Repository, hash plumbing.Hash, path string) (bool, error) { + commit, err := repo.CommitObject(hash) + if err != nil { + return false, fmt.Errorf("load commit: %w", err) + } + tree, err := commit.Tree() + if err != nil { + return false, fmt.Errorf("load tree: %w", err) + } + normalized := strings.Trim(strings.TrimSpace(path), "/") + if normalized == "" { + return false, nil + } + if _, err := tree.File(normalized); err == nil { + return true, nil + } + if _, err := tree.Tree(normalized); err == nil { + return true, nil + } + return false, nil +} + +func githubWriteStatusError(message string, response *github.Response, err error) *HTTPStatusError { + statusCode := http.StatusBadGateway + if response != nil && response.StatusCode > 0 { + statusCode = response.StatusCode + } + return &HTTPStatusError{ + StatusCode: statusCode, + Code: "integration_error", + Message: fmt.Sprintf("%s: %s", message, err), + } +} + +func (service *GitService) CreateGitHubUploadPullRequest( + ctx context.Context, + authorizationHeader string, + organization string, + project string, + identity GitRepositoryIdentity, + baseBranch string, + branchName string, + title string, + body string, + files []geckodb.GitUploadSessionFile, +) (string, string, error) { + accessToken, err := service.RequestInstallationToken(ctx, authorizationHeader, organization, project, identity, "write") + if err != nil { + return "", "", err + } + client, err := service.githubClient(accessToken) + if err != nil { + return "", "", err + } + baseRef, response, err := client.Git.GetRef(ctx, identity.Owner, identity.Repo, "refs/heads/"+baseBranch) + if err != nil { + return "", "", githubWriteStatusError("failed to load GitHub base branch ref", response, err) + } + baseCommitSHA := baseRef.GetObject().GetSHA() + baseCommit, response, err := client.Git.GetCommit(ctx, identity.Owner, identity.Repo, baseCommitSHA) + if err != nil { + return "", "", githubWriteStatusError("failed to load GitHub base commit", response, err) + } + entries := make([]*github.TreeEntry, 0, len(files)) + for _, file := range files { + if !file.Checksum.Valid { + return "", "", fmt.Errorf("missing checksum for %s", file.TargetPath) + } + entries = append(entries, &github.TreeEntry{ + Path: github.Ptr(file.TargetPath), + Mode: github.Ptr("100644"), + Type: github.Ptr("blob"), + Content: github.Ptr(BuildLFSPointerContent(file.Checksum.String, file.Size)), + }) + } + tree, response, err := client.Git.CreateTree(ctx, identity.Owner, identity.Repo, baseCommit.GetTree().GetSHA(), entries) + if err != nil { + return "", "", githubWriteStatusError("failed to create GitHub tree", response, err) + } + commit, response, err := client.Git.CreateCommit(ctx, identity.Owner, identity.Repo, github.Commit{ + Message: github.Ptr(title), + Tree: &github.Tree{SHA: github.Ptr(tree.GetSHA())}, + Parents: []*github.Commit{{SHA: github.Ptr(baseCommitSHA)}}, + }, nil) + if err != nil { + return "", "", githubWriteStatusError("failed to create GitHub commit", response, err) + } + _, response, err = client.Git.CreateRef(ctx, identity.Owner, identity.Repo, github.CreateRef{ + Ref: "refs/heads/" + branchName, + SHA: commit.GetSHA(), + }) + if err != nil { + return "", "", githubWriteStatusError("failed to create GitHub branch", response, err) + } + pr, response, err := client.PullRequests.Create(ctx, identity.Owner, identity.Repo, &github.NewPullRequest{ + Title: github.Ptr(title), + Body: github.Ptr(body), + Base: github.Ptr(baseBranch), + Head: github.Ptr(branchName), + }) + if err != nil { + return "", "", githubWriteStatusError("failed to create GitHub pull request", response, err) + } + return commit.GetSHA(), pr.GetHTMLURL(), nil +} diff --git a/internal/git/upload_test.go b/internal/git/upload_test.go new file mode 100644 index 0000000..fd8aa3f --- /dev/null +++ b/internal/git/upload_test.go @@ -0,0 +1,82 @@ +package git_test + +import ( + "context" + "database/sql" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + geckodb "github.com/calypr/gecko/internal/db" + "github.com/calypr/gecko/internal/git" + integrationfence "github.com/calypr/gecko/internal/integrations/fence" +) + +func TestCreateGitHubUploadPullRequest_PropagatesGitHub403(t *testing.T) { + var tokenRequest map[string]string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodPost && r.URL.Path == "/credentials/github": + if err := json.NewDecoder(r.Body).Decode(&tokenRequest); err != nil { + t.Fatalf("decode token request: %v", err) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"token":"test-token","expires_at":"2030-01-01T00:00:00Z","repository":{"owner":"EllrottLab","repo":"git_drs_test"}}`)) + case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/api/v3/repos/EllrottLab/git_drs_test/git/ref/heads/main"): + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"object":{"sha":"base-sha"}}`)) + case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/api/v3/repos/EllrottLab/git_drs_test/git/commits/base-sha"): + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"tree":{"sha":"tree-sha"}}`)) + case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/api/v3/repos/EllrottLab/git_drs_test/git/trees"): + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"message":"Resource not accessible by integration"}`)) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + service := git.NewGitService(git.GitServiceConfig{ + FenceBaseURL: server.URL, + GitHubAPIBase: server.URL + "/api/v3", + HTTPClient: server.Client(), + FenceClient: integrationfence.NewClient(server.Client(), integrationfence.Config{BaseURL: server.URL}), + }) + + _, _, err := service.CreateGitHubUploadPullRequest( + context.Background(), + "Bearer user-token", + "Ellrott_Lab", + "test", + git.GitRepositoryIdentity{Owner: "EllrottLab", Repo: "git_drs_test"}, + "main", + "feature/test", + "title", + "body", + []geckodb.GitUploadSessionFile{{ + TargetPath: "dir/file.txt", + Size: 123, + Checksum: sql.NullString{String: "abc123", Valid: true}, + }}, + ) + if err == nil { + t.Fatal("expected error") + } + statusErr, ok := err.(*git.HTTPStatusError) + if !ok { + t.Fatalf("expected HTTPStatusError, got %T: %v", err, err) + } + if statusErr.StatusCode != http.StatusForbidden { + t.Fatalf("expected 403, got %d", statusErr.StatusCode) + } + if !strings.Contains(statusErr.Message, "failed to create GitHub tree") { + t.Fatalf("unexpected message: %q", statusErr.Message) + } + if tokenRequest["access"] != "write" { + t.Fatalf("expected write access token request, got %#v", tokenRequest) + } +} diff --git a/internal/httputil/response.go b/internal/httputil/response.go new file mode 100644 index 0000000..08c5a34 --- /dev/null +++ b/internal/httputil/response.go @@ -0,0 +1,176 @@ +package httputil + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "reflect" + "regexp" + + "github.com/calypr/gecko/apierror" + geckologging "github.com/calypr/gecko/internal/logging" + "github.com/gofiber/fiber/v3" + "github.com/uc-cdis/arborist/arborist" + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/proto" +) + +type JSONResponse struct { + content any + code int +} + +type ErrorResponse struct { + Error apierror.Error `json:"error"` + Err error `json:"-"` + Log geckologging.Cache `json:"-"` +} + +func JSON(content any, code int) *JSONResponse { + return &JSONResponse{content: content, code: code} +} + +func (response *JSONResponse) Write(ctx fiber.Ctx) error { + ctx.Set(fiber.HeaderContentType, fiber.MIMEApplicationJSON) + ctx.Status(response.code) + + var body []byte + var err error + if msg, ok := response.content.(proto.Message); ok { + opts := protojson.MarshalOptions{EmitUnpopulated: true, UseProtoNames: true} + if WantPrettyJSON(ctx) { + opts.Indent = " " + } + body, err = opts.Marshal(msg) + } else { + if WantPrettyJSON(ctx) { + body, err = json.MarshalIndent(response.content, "", " ") + } else { + body, err = json.Marshal(response.content) + } + } + if err != nil { + return err + } + return ctx.Send(body) +} + +func WantPrettyJSON(ctx fiber.Ctx) bool { + if ctx.Method() != fiber.MethodGet { + return false + } + return ctx.Query("pretty") == "true" || ctx.Query("prettyJSON") == "true" +} + +func NewError(errorType apierror.Type, message string, code int, details map[string]any, err *error) *ErrorResponse { + response := &ErrorResponse{ + Error: apierror.Error{ + Type: errorType, + Message: message, + Code: code, + Details: details, + }, + } + if err != nil { + response.Err = *err + } + if code >= http.StatusInternalServerError { + response.Log.Error("%s", message) + } else { + response.Log.Info("%s", message) + } + return response +} + +func (response *ErrorResponse) WriteLog(logger arborist.Logger) { + response.Log.Write(logger) +} + +func (response *ErrorResponse) Write(ctx fiber.Ctx) error { + ctx.Set(fiber.HeaderContentType, fiber.MIMEApplicationJSON) + ctx.Status(response.Error.Code) + + var body []byte + var err error + if WantPrettyJSON(ctx) { + body, err = json.MarshalIndent(response, "", " ") + } else { + body, err = json.Marshal(response) + } + if err != nil { + return err + } + return ctx.Send(body) +} + +func NotFound(ctx fiber.Ctx) error { + return NewError(apierror.TypeNotFound, "not found", http.StatusNotFound, nil, nil).Write(ctx) +} + +func ParseJSONBody(body []byte, x any, details map[string]any) *ErrorResponse { + if !json.Valid(body) { + return NewError(apierror.TypeInvalidJSON, "Invalid JSON format", http.StatusBadRequest, details, nil) + } + if errResponse := unmarshal(body, x); errResponse != nil { + errResponse.Error.Details = MergeErrorDetails(errResponse.Error.Details, details) + return errResponse + } + return nil +} + +func unmarshal(body []byte, x any) *ErrorResponse { + if len(body) == 0 { + return NewError(apierror.TypeEmptyRequestBody, "empty request body", http.StatusBadRequest, nil, nil) + } + + dec := json.NewDecoder(bytes.NewReader(body)) + dec.DisallowUnknownFields() + err := dec.Decode(x) + if err != nil { + structType := reflect.TypeOf(x) + if structType.Kind() == reflect.Ptr { + structType = structType.Elem() + } + + msg := fmt.Sprintf("could not parse %s from JSON; make sure input has correct types", structType) + errDetails := map[string]any{ + "target_type": structType.String(), + "body": string(loggableJSON(body)), + } + + switch e := err.(type) { + case *json.SyntaxError: + msg = "Malformed JSON syntax" + errDetails["offset"] = e.Offset + return NewError(apierror.TypeInvalidJSON, msg, http.StatusBadRequest, errDetails, &err) + case *json.UnmarshalTypeError: + msg = fmt.Sprintf("Invalid type for field %q; expected %s", e.Field, e.Type) + errDetails["field"] = e.Field + errDetails["expected_type"] = e.Type.String() + return NewError(apierror.TypeInvalidRequestBody, msg, http.StatusBadRequest, errDetails, &err) + default: + return NewError(apierror.TypeInvalidRequestBody, msg, http.StatusBadRequest, errDetails, &err) + } + } + return nil +} + +func MergeErrorDetails(base map[string]any, extra map[string]any) map[string]any { + if len(extra) == 0 { + return base + } + if base == nil { + base = map[string]any{} + } + for k, v := range extra { + base[k] = v + } + return base +} + +func loggableJSON(body []byte) []byte { + return regWhitespace.ReplaceAll(body, []byte("")) +} + +var regWhitespace = regexp.MustCompile(`\s`) diff --git a/internal/integrations/fence/client.go b/internal/integrations/fence/client.go new file mode 100644 index 0000000..2de6dba --- /dev/null +++ b/internal/integrations/fence/client.go @@ -0,0 +1,231 @@ +package fence + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + "github.com/calypr/gecko/internal/git/domain" + servermw "github.com/calypr/gecko/internal/server/middleware" +) + +type Config struct { + BaseURL string +} + +type Client struct { + client *http.Client + config Config +} + +func NewClient(client *http.Client, config Config) *Client { + if client == nil { + client = http.DefaultClient + } + return &Client{client: client, config: config} +} + +type fenceGitHubInstallURLResponse struct { + InstallURL string `json:"install_url"` +} + +type fenceGitHubInstallationStatusResponse struct { + Installed bool `json:"installed"` + InstallationID *int64 `json:"installation_id"` + Target string `json:"target"` + TargetType string `json:"target_type"` + HTMLURL string `json:"html_url"` + RepositorySelection string `json:"repository_selection"` +} + +type fenceGitHubInstallationRepositoriesResponse struct { + Repositories []domain.GitHubInstallationRepository `json:"repositories"` +} + +type fenceGitHubTokenResponse struct { + Token string `json:"token"` +} + +func (c *Client) requestFenceGitHubBroker(ctx context.Context, authorizationHeader string, requestPayload map[string]any, responsePayload any) error { + if strings.TrimSpace(c.config.BaseURL) == "" { + return &domain.HTTPStatusError{ + StatusCode: http.StatusBadGateway, + Code: "integration_error", + Message: "Fence base URL is not configured for GitHub App broker requests", + } + } + validAuthorizationHeader, err := servermw.ValidateAuthorizationHeader(authorizationHeader) + if err != nil { + return &domain.HTTPStatusError{ + StatusCode: http.StatusUnauthorized, + Code: "missing_authorization", + Message: err.Error(), + } + } + requestBody, err := json.Marshal(requestPayload) + if err != nil { + return fmt.Errorf("marshal fence github broker request: %w", err) + } + req, err := http.NewRequestWithContext( + ctx, + http.MethodPost, + strings.TrimRight(c.config.BaseURL, "/")+"/credentials/github", + bytes.NewReader(requestBody), + ) + if err != nil { + return fmt.Errorf("build fence github broker request: %w", err) + } + req.Header.Set("Authorization", validAuthorizationHeader) + req.Header.Set("Content-Type", "application/json") + + resp, err := c.client.Do(req) + if err != nil { + return &domain.HTTPStatusError{ + StatusCode: http.StatusBadGateway, + Code: "integration_error", + Message: fmt.Sprintf("Fence github broker request failed: %s", err), + } + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("read fence github broker response: %w", err) + } + if resp.StatusCode >= 400 { + message := decodeFenceErrorResponse(body) + if message == "" { + message = fmt.Sprintf("Fence github broker request failed with status %d", resp.StatusCode) + } + code := "integration_error" + switch resp.StatusCode { + case http.StatusUnauthorized: + code = "missing_authorization" + case http.StatusForbidden: + code = "forbidden" + case http.StatusNotFound: + code = "not_found" + } + return &domain.HTTPStatusError{ + StatusCode: resp.StatusCode, + Code: code, + Message: message, + } + } + if err := json.Unmarshal(body, responsePayload); err != nil { + return &domain.HTTPStatusError{ + StatusCode: http.StatusBadGateway, + Code: "integration_error", + Message: fmt.Sprintf("invalid Fence github broker response: %s", err), + } + } + return nil +} + +func (c *Client) RequestInstallationURL(ctx context.Context, authorizationHeader string, owner string, redirectPath string) (string, error) { + var payload fenceGitHubInstallURLResponse + if err := c.requestFenceGitHubBroker(ctx, authorizationHeader, map[string]any{ + "action": "install_url", + "organization": owner, + "owner": owner, + "redirect_path": redirectPath, + }, &payload); err != nil { + return "", err + } + installURL := strings.TrimSpace(payload.InstallURL) + if installURL == "" { + return "", &domain.HTTPStatusError{ + StatusCode: http.StatusBadGateway, + Code: "integration_error", + Message: "Fence github install URL response did not include install_url", + } + } + return installURL, nil +} + +func (c *Client) RequestOrganizationInstallationStatus(ctx context.Context, authorizationHeader string, organization string, owner string) (domain.GitRepositoryInstallationStatus, error) { + var payload fenceGitHubInstallationStatusResponse + if err := c.requestFenceGitHubBroker(ctx, authorizationHeader, map[string]any{ + "action": "organization_installation", + "organization": organization, + "owner": owner, + }, &payload); err != nil { + return domain.GitRepositoryInstallationStatus{}, err + } + return domain.GitRepositoryInstallationStatus{ + Installed: payload.Installed, + InstallationID: payload.InstallationID, + Target: strings.TrimSpace(payload.Target), + TargetType: strings.TrimSpace(payload.TargetType), + HTMLURL: strings.TrimSpace(payload.HTMLURL), + RepositorySelection: strings.TrimSpace(payload.RepositorySelection), + }, nil +} + +func (c *Client) ListInstallationRepositories(ctx context.Context, authorizationHeader string, installationID int64) ([]domain.GitHubInstallationRepository, error) { + var payload fenceGitHubInstallationRepositoriesResponse + if err := c.requestFenceGitHubBroker(ctx, authorizationHeader, map[string]any{ + "action": "installation_repositories", + "installation_id": installationID, + }, &payload); err != nil { + return nil, err + } + return payload.Repositories, nil +} + +func (c *Client) RequestInstallationStatus(ctx context.Context, authorizationHeader string, organization string, identity domain.GitRepositoryIdentity) (domain.GitRepositoryInstallationStatus, error) { + var payload fenceGitHubInstallationStatusResponse + if err := c.requestFenceGitHubBroker(ctx, authorizationHeader, map[string]any{ + "action": "repository_installation", + "owner": identity.Owner, + "repo": identity.Repo, + "organization": organization, + }, &payload); err != nil { + return domain.GitRepositoryInstallationStatus{}, err + } + return domain.GitRepositoryInstallationStatus{ + Installed: payload.Installed, + InstallationID: payload.InstallationID, + Target: strings.TrimSpace(payload.Target), + TargetType: strings.TrimSpace(payload.TargetType), + HTMLURL: strings.TrimSpace(payload.HTMLURL), + RepositorySelection: strings.TrimSpace(payload.RepositorySelection), + }, nil +} + +func (c *Client) RequestInstallationToken(ctx context.Context, authorizationHeader string, organization string, project string, identity domain.GitRepositoryIdentity, access string) (string, error) { + requestedAccess := strings.TrimSpace(access) + if requestedAccess == "" { + requestedAccess = "read" + } + var payload fenceGitHubTokenResponse + if err := c.requestFenceGitHubBroker(ctx, authorizationHeader, map[string]any{ + "action": "installation_token", + "owner": identity.Owner, + "repo": identity.Repo, + "organization": organization, + "project": strings.TrimSpace(project), + "access": requestedAccess, + }, &payload); err != nil { + return "", err + } + return servermw.ValidateAccessToken(payload.Token) +} + +func decodeFenceErrorResponse(body []byte) string { + if len(body) == 0 { + return "" + } + var payload map[string]any + if err := json.Unmarshal(body, &payload); err != nil { + return "" + } + if message, ok := payload["message"].(string); ok { + return message + } + return "" +} diff --git a/internal/integrations/fence/client_test.go b/internal/integrations/fence/client_test.go new file mode 100644 index 0000000..0387314 --- /dev/null +++ b/internal/integrations/fence/client_test.go @@ -0,0 +1,312 @@ +package fence + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/calypr/gecko/internal/git/domain" +) + +func TestRequestOrganizationInstallationStatusForwardsAuthorizationAndParsesStatus(t *testing.T) { + var receivedAuth string + var receivedBody map[string]any + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + receivedAuth = request.Header.Get("Authorization") + if request.URL.Path != "/credentials/github" { + t.Fatalf("unexpected request path: %s", request.URL.Path) + } + if request.Method != http.MethodPost { + t.Fatalf("unexpected request method: %s", request.Method) + } + if err := json.NewDecoder(request.Body).Decode(&receivedBody); err != nil { + t.Fatalf("decode request body: %v", err) + } + writer.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(writer).Encode(map[string]any{ + "installed": true, + "organization": "HTAN_INT", + "installation_id": 42, + "target": "HTAN_INT", + "target_type": "Organization", + "html_url": "https://github.com/organizations/HTAN_INT/settings/installations/42", + "repository_selection": "selected", + }) + })) + defer server.Close() + + client := NewClient(server.Client(), Config{BaseURL: server.URL}) + status, err := client.RequestOrganizationInstallationStatus(context.Background(), "Bearer user-token", "HTAN_INT", "htan-int-github") + if err != nil { + t.Fatalf("request organization installation status: %v", err) + } + if !status.Installed { + t.Fatal("expected installed status") + } + if status.InstallationID == nil || *status.InstallationID != 42 { + t.Fatalf("unexpected installation id: %+v", status.InstallationID) + } + if status.RepositorySelection != "selected" { + t.Fatalf("unexpected repository selection: %q", status.RepositorySelection) + } + if receivedAuth != "Bearer user-token" { + t.Fatalf("expected forwarded authorization header, got %q", receivedAuth) + } + if receivedBody["action"] != "organization_installation" { + t.Fatalf("expected organization_installation action, got %#v", receivedBody) + } + if receivedBody["organization"] != "HTAN_INT" { + t.Fatalf("expected organization payload, got %#v", receivedBody) + } + if receivedBody["owner"] != "htan-int-github" { + t.Fatalf("expected owner payload, got %#v", receivedBody) + } +} + +func TestRequestInstallationStatusForwardsAuthorizationAndParsesStatus(t *testing.T) { + var receivedAuth string + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + receivedAuth = request.Header.Get("Authorization") + if request.URL.Path != "/credentials/github" { + t.Fatalf("unexpected request path: %s", request.URL.Path) + } + if request.Method != http.MethodPost { + t.Fatalf("unexpected request method: %s", request.Method) + } + writer.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(writer).Encode(map[string]any{ + "installed": true, + "installation_id": 42, + "target": "HTAN_INT", + "target_type": "Organization", + "html_url": "https://github.com/organizations/HTAN_INT/settings/installations/42", + }) + })) + defer server.Close() + + client := NewClient(server.Client(), Config{BaseURL: server.URL}) + status, err := client.RequestInstallationStatus(context.Background(), "Bearer user-token", "HTAN_INT", domain.GitRepositoryIdentity{ + Owner: "HTAN_INT", + Repo: "BForePC", + }) + if err != nil { + t.Fatalf("request installation status: %v", err) + } + if !status.Installed { + t.Fatal("expected installed status") + } + if status.InstallationID == nil || *status.InstallationID != 42 { + t.Fatalf("unexpected installation id: %+v", status.InstallationID) + } + if status.Target != "HTAN_INT" { + t.Fatalf("unexpected target: %q", status.Target) + } + if receivedAuth != "Bearer user-token" { + t.Fatalf("expected forwarded authorization header, got %q", receivedAuth) + } +} + +func TestListInstallationRepositoriesForwardsAuthorizationAndParsesRepositories(t *testing.T) { + var receivedAuth string + var receivedBody map[string]any + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + receivedAuth = request.Header.Get("Authorization") + if request.URL.Path != "/credentials/github" { + t.Fatalf("unexpected request path: %s", request.URL.Path) + } + if request.Method != http.MethodPost { + t.Fatalf("unexpected request method: %s", request.Method) + } + if err := json.NewDecoder(request.Body).Decode(&receivedBody); err != nil { + t.Fatalf("decode request body: %v", err) + } + writer.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(writer).Encode(map[string]any{ + "installation_id": 42, + "repositories": []map[string]any{ + { + "id": 101, + "name": "git_drs_test", + "full_name": "Ellrott_Lab/git_drs_test", + "html_url": "https://github.com/EllrottLab/git_drs_test", + "clone_url": "https://github.com/EllrottLab/git_drs_test.git", + }, + }, + }) + })) + defer server.Close() + + client := NewClient(server.Client(), Config{BaseURL: server.URL}) + repositories, err := client.ListInstallationRepositories(context.Background(), "Bearer user-token", 42) + if err != nil { + t.Fatalf("list installation repositories: %v", err) + } + if receivedAuth != "Bearer user-token" { + t.Fatalf("expected forwarded authorization header, got %q", receivedAuth) + } + if receivedBody["action"] != "installation_repositories" { + t.Fatalf("expected installation_repositories action, got %#v", receivedBody) + } + if receivedBody["installation_id"] != float64(42) { + t.Fatalf("expected installation id in request body, got %#v", receivedBody) + } + if len(repositories) != 1 { + t.Fatalf("expected one repository, got %+v", repositories) + } + if repositories[0].FullName != "Ellrott_Lab/git_drs_test" { + t.Fatalf("unexpected repository: %+v", repositories[0]) + } +} + +func TestRequestInstallationTokenForwardsAuthorizationAndParsesToken(t *testing.T) { + var receivedAuth string + var receivedBody map[string]string + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + receivedAuth = request.Header.Get("Authorization") + if request.URL.Path != "/credentials/github" { + t.Fatalf("unexpected request path: %s", request.URL.Path) + } + if request.Method != http.MethodPost { + t.Fatalf("unexpected request method: %s", request.Method) + } + if err := json.NewDecoder(request.Body).Decode(&receivedBody); err != nil { + t.Fatalf("decode request body: %v", err) + } + writer.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(writer).Encode(map[string]any{ + "token": "ghs_test", + "expires_at": "2026-05-20T18:00:00Z", + "repository": map[string]string{"owner": "HTAN_INT", "repo": "BForePC"}, + }) + })) + defer server.Close() + + client := NewClient(server.Client(), Config{BaseURL: server.URL}) + token, err := client.RequestInstallationToken(context.Background(), "Bearer user-token", "HTAN_INT", "BForePC", domain.GitRepositoryIdentity{ + Owner: "HTAN_INT", + Repo: "BForePC", + }, "write") + if err != nil { + t.Fatalf("request installation token: %v", err) + } + if token != "ghs_test" { + t.Fatalf("expected token ghs_test, got %q", token) + } + if receivedAuth != "Bearer user-token" { + t.Fatalf("expected forwarded authorization header, got %q", receivedAuth) + } + if receivedBody["access"] != "write" { + t.Fatalf("expected write access request, got %#v", receivedBody) + } + if receivedBody["action"] != "installation_token" { + t.Fatalf("expected installation_token action, got %#v", receivedBody) + } + if receivedBody["organization"] != "HTAN_INT" { + t.Fatalf("expected organization HTAN_INT, got %#v", receivedBody) + } + if receivedBody["project"] != "BForePC" { + t.Fatalf("expected project BForePC, got %#v", receivedBody) + } +} + +func TestRequestInstallationTokenReturnsFenceStatusErrors(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + writer.Header().Set("Content-Type", "application/json") + writer.WriteHeader(http.StatusForbidden) + _ = json.NewEncoder(writer).Encode(map[string]any{"message": "forbidden by fence"}) + })) + defer server.Close() + + client := NewClient(server.Client(), Config{BaseURL: server.URL}) + _, err := client.RequestInstallationToken(context.Background(), "Bearer user-token", "HTAN_INT", "BForePC", domain.GitRepositoryIdentity{ + Owner: "HTAN_INT", + Repo: "BForePC", + }, "read") + if err == nil { + t.Fatal("expected error") + } + statusErr, ok := err.(*domain.HTTPStatusError) + if !ok { + t.Fatalf("expected HTTPStatusError, got %T", err) + } + if statusErr.StatusCode != http.StatusForbidden { + t.Fatalf("expected 403, got %d", statusErr.StatusCode) + } + if statusErr.Message != "forbidden by fence" { + t.Fatalf("unexpected status error message: %q", statusErr.Message) + } +} + +func TestRequestInstallationURLForwardsAuthorizationAndParsesInstallURL(t *testing.T) { + var receivedAuth string + var receivedBody map[string]string + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + receivedAuth = request.Header.Get("Authorization") + if request.URL.Path != "/credentials/github" { + t.Fatalf("unexpected request path: %s", request.URL.Path) + } + if request.Method != http.MethodPost { + t.Fatalf("unexpected request method: %s", request.Method) + } + if err := json.NewDecoder(request.Body).Decode(&receivedBody); err != nil { + t.Fatalf("decode request body: %v", err) + } + writer.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(writer).Encode(map[string]any{ + "install_url": "https://github.com/apps/calypr-github/installations/new?state=abc", + "owner": "HTAN_INT", + }) + })) + defer server.Close() + + client := NewClient(server.Client(), Config{BaseURL: server.URL}) + redirectURL, err := client.RequestInstallationURL(context.Background(), "Bearer user-token", "HTAN_INT", "/git/HTAN_INT") + if err != nil { + t.Fatalf("request installation URL: %v", err) + } + if redirectURL != "https://github.com/apps/calypr-github/installations/new?state=abc" { + t.Fatalf("unexpected redirect URL: %q", redirectURL) + } + if receivedAuth != "Bearer user-token" { + t.Fatalf("expected forwarded authorization header, got %q", receivedAuth) + } + if receivedBody["owner"] != "HTAN_INT" { + t.Fatalf("expected owner HTAN_INT, got %q", receivedBody["owner"]) + } + if receivedBody["organization"] != "HTAN_INT" { + t.Fatalf("expected organization HTAN_INT, got %q", receivedBody["organization"]) + } + if receivedBody["action"] != "install_url" { + t.Fatalf("expected install_url action, got %#v", receivedBody) + } + if receivedBody["redirect_path"] != "/git/HTAN_INT" { + t.Fatalf("expected redirect_path /git/HTAN_INT, got %q", receivedBody["redirect_path"]) + } +} + +func TestRequestInstallationURLReturnsFenceStatusErrors(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + writer.Header().Set("Content-Type", "application/json") + writer.WriteHeader(http.StatusForbidden) + _ = json.NewEncoder(writer).Encode(map[string]any{"message": "forbidden by fence"}) + })) + defer server.Close() + + client := NewClient(server.Client(), Config{BaseURL: server.URL}) + _, err := client.RequestInstallationURL(context.Background(), "Bearer user-token", "HTAN_INT", "/git/HTAN_INT") + if err == nil { + t.Fatal("expected error") + } + statusErr, ok := err.(*domain.HTTPStatusError) + if !ok { + t.Fatalf("expected HTTPStatusError, got %T", err) + } + if statusErr.StatusCode != http.StatusForbidden { + t.Fatalf("expected 403, got %d", statusErr.StatusCode) + } + if statusErr.Message != "forbidden by fence" { + t.Fatalf("unexpected status error message: %q", statusErr.Message) + } +} diff --git a/internal/integrations/github/client.go b/internal/integrations/github/client.go new file mode 100644 index 0000000..e434851 --- /dev/null +++ b/internal/integrations/github/client.go @@ -0,0 +1,68 @@ +package github + +import ( + "context" + "fmt" + "net/http" + "strings" + + "github.com/calypr/gecko/internal/git/domain" + google_github "github.com/google/go-github/v87/github" +) + +type Config struct { + APIBase string +} + +type Client struct { + client *http.Client + config Config +} + +type GitHubRepositoryMetadata struct { + DefaultBranch string `json:"default_branch"` + HTMLURL string `json:"html_url"` +} + +func NewClient(client *http.Client, config Config) *Client { + if client == nil { + client = http.DefaultClient + } + if config.APIBase == "" { + config.APIBase = "https://api.github.com" + } + return &Client{client: client, config: config} +} + +func (c *Client) FetchRepositoryMetadata(ctx context.Context, accessToken string, identity domain.GitRepositoryIdentity) (*domain.GitHubRepositoryMetadata, error) { + githubClient, err := c.githubClient(accessToken) + if err != nil { + return nil, err + } + repo, _, err := githubClient.Repositories.Get(ctx, identity.Owner, identity.Repo) + if err != nil { + return nil, fmt.Errorf("github repository metadata lookup failed for %s/%s: %w", identity.Owner, identity.Repo, err) + } + defaultBranch := repo.GetDefaultBranch() + htmlURL := repo.GetHTMLURL() + if htmlURL == "" { + htmlURL = identity.URL + } + return &domain.GitHubRepositoryMetadata{DefaultBranch: defaultBranch, HTMLURL: htmlURL}, nil +} + +func (c *Client) githubClient(accessToken string) (*google_github.Client, error) { + options := []google_github.ClientOptionsFunc{ + google_github.WithAuthToken(accessToken), + google_github.WithHTTPClient(c.client), + } + if strings.TrimRight(c.config.APIBase, "/") != "https://api.github.com" { + apiBase := strings.TrimRight(c.config.APIBase, "/") + "/" + options = append(options, google_github.WithEnterpriseURLs(apiBase, apiBase)) + } + client, err := google_github.NewClient(options...) + if err != nil { + return nil, fmt.Errorf("create github client: %w", err) + } + return client, nil +} diff --git a/internal/integrations/syfon/adapter.go b/internal/integrations/syfon/adapter.go new file mode 100644 index 0000000..453137a --- /dev/null +++ b/internal/integrations/syfon/adapter.go @@ -0,0 +1,225 @@ +package syfon + +import ( + "context" + "fmt" + "net/http" + "net/url" + "path" + "strings" + + "github.com/calypr/gecko/internal/git/domain" + "github.com/calypr/syfon/apigen/client/bucketapi" + syfonservices "github.com/calypr/syfon/client/services" +) + +const refreshAuthzHeader = "X-Syfon-Refresh-Authz" + +type Manager struct { + baseURL string + client *http.Client +} + +func NewManager(baseURL string, client *http.Client) *Manager { + httpClient := client + if httpClient == nil { + httpClient = http.DefaultClient + } + return &Manager{ + baseURL: strings.TrimRight(strings.TrimSpace(baseURL), "/"), + client: httpClient, + } +} + +func (manager *Manager) ListBuckets(ctx context.Context, authorizationHeader string) (map[string]domain.StorageBucket, error) { + service, err := manager.bucketsService(authorizationHeader) + if err != nil { + return nil, err + } + response, err := service.List(ctx) + if err != nil { + return nil, fmt.Errorf("request syfon bucket list: %w", err) + } + buckets := make(map[string]domain.StorageBucket, len(response.S3BUCKETS)) + for name, metadata := range response.S3BUCKETS { + bucket := domain.StorageBucket{Bucket: name} + if metadata.Provider != nil { + bucket.Provider = strings.TrimSpace(*metadata.Provider) + } + if metadata.EndpointUrl != nil { + bucket.Endpoint = strings.TrimSpace(*metadata.EndpointUrl) + } + if metadata.Region != nil { + bucket.Region = strings.TrimSpace(*metadata.Region) + } + if metadata.Programs != nil { + for _, resource := range *metadata.Programs { + bucket.Resources = append(bucket.Resources, strings.TrimSpace(resource)) + } + } + buckets[name] = bucket + } + return buckets, nil +} + +func (manager *Manager) PutBucket(ctx context.Context, authorizationHeader string, config domain.StorageConfig) error { + service, err := manager.bucketsService(authorizationHeader) + if err != nil { + return err + } + request := bucketapi.PutBucketRequest{ + Bucket: strings.TrimSpace(config.Bucket), + Organization: strings.TrimSpace(config.Organization), + ProjectId: strings.TrimSpace(config.ProjectID), + } + if value := strings.TrimSpace(config.AccessKey); value != "" { + request.AccessKey = &value + } + if value := strings.TrimSpace(config.Endpoint); value != "" { + request.Endpoint = &value + } + if value := strings.TrimSpace(config.Provider); value != "" { + request.Provider = &value + } + if value := strings.TrimSpace(config.Region); value != "" { + request.Region = &value + } + if value := strings.TrimSpace(config.SecretKey); value != "" { + request.SecretKey = &value + } + if value := strings.TrimSpace(config.Path); value != "" { + request.Path = &value + } + if err := service.Put(ctx, request); err != nil { + return fmt.Errorf("request syfon bucket upsert: %w", err) + } + return nil +} + +func (manager *Manager) AddScope(ctx context.Context, authorizationHeader string, config domain.StorageConfig) error { + service, err := manager.bucketsService(authorizationHeader) + if err != nil { + return err + } + request := bucketapi.AddBucketScopeRequest{ + Organization: strings.TrimSpace(config.Organization), + ProjectId: strings.TrimSpace(config.ProjectID), + } + if value := manager.scopePath(config); value != "" { + request.Path = &value + } + if err := service.AddScope(ctx, strings.TrimSpace(config.Bucket), request); err != nil { + return fmt.Errorf("request syfon add bucket scope: %w", err) + } + return nil +} + +func (manager *Manager) CleanupProject(ctx context.Context, authorizationHeader string, organization string, project string) error { + dataBaseURL, err := manager.dataAPIBaseURL() + if err != nil { + return err + } + cleanupURL := dataBaseURL + + "/projects/" + + url.PathEscape(strings.TrimSpace(organization)) + + "/" + + url.PathEscape(strings.TrimSpace(project)) + req, err := http.NewRequestWithContext(ctx, http.MethodDelete, cleanupURL, nil) + if err != nil { + return fmt.Errorf("build syfon project cleanup request: %w", err) + } + req.Header.Set("Authorization", authorizationHeader) + req.Header.Set(refreshAuthzHeader, "true") + resp, err := manager.client.Do(req) + if err != nil { + return fmt.Errorf("request syfon project cleanup: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode >= http.StatusBadRequest { + return fmt.Errorf("syfon project cleanup failed with status %d", resp.StatusCode) + } + return nil +} + +func (manager *Manager) bucketsService(authorizationHeader string) (*syfonservices.BucketsService, error) { + clientBaseURL, err := manager.clientBaseURL() + if err != nil { + return nil, err + } + client, err := bucketapi.NewClientWithResponses(clientBaseURL, + bucketapi.WithHTTPClient(manager.client), + bucketapi.WithRequestEditorFn(func(ctx context.Context, req *http.Request) error { + req.Header.Set("Authorization", authorizationHeader) + req.Header.Set(refreshAuthzHeader, "true") + return nil + }), + ) + if err != nil { + return nil, fmt.Errorf("create syfon bucket client: %w", err) + } + return syfonservices.NewBucketsService(client), nil +} + +func (manager *Manager) clientBaseURL() (string, error) { + if manager.baseURL == "" { + return "", fmt.Errorf("SYFON_DATA_API_BASE_URL is not configured") + } + baseURL := strings.TrimRight(strings.TrimSpace(manager.baseURL), "/") + if strings.HasSuffix(baseURL, "/data") { + return strings.TrimSuffix(baseURL, "/data"), nil + } + return baseURL, nil +} + +func (manager *Manager) dataAPIBaseURL() (string, error) { + if manager.baseURL == "" { + return "", fmt.Errorf("SYFON_DATA_API_BASE_URL is not configured") + } + baseURL := strings.TrimRight(strings.TrimSpace(manager.baseURL), "/") + if strings.HasSuffix(baseURL, "/data") { + return baseURL, nil + } + return baseURL + "/data", nil +} + +func (manager *Manager) scopePath(config domain.StorageConfig) string { + if explicitPath := strings.TrimSpace(config.Path); explicitPath != "" { + return explicitPath + } + if pathPrefix := strings.Trim(strings.TrimSpace(config.PathPrefix), "/"); pathPrefix != "" { + return bucketPath(config.Provider, config.Bucket, pathPrefix) + } + organizationSubPath := strings.Trim(strings.TrimSpace(config.OrganizationSubPath), "/") + projectSubPath := strings.Trim(strings.TrimSpace(config.ProjectSubPath), "/") + if organizationSubPath == "" && projectSubPath == "" { + return "" + } + return bucketPath(config.Provider, config.Bucket, path.Join(organizationSubPath, projectSubPath)) +} + +func bucketPath(provider string, bucket string, prefix string) string { + cleanBucket := strings.TrimSpace(bucket) + cleanPrefix := strings.Trim(strings.TrimSpace(prefix), "/") + switch strings.ToLower(strings.TrimSpace(provider)) { + case "gcs", "gs": + if cleanPrefix == "" { + return "gs://" + cleanBucket + } + return "gs://" + cleanBucket + "/" + cleanPrefix + case "azure", "azblob", "az": + if cleanPrefix == "" { + return "azblob://" + cleanBucket + } + return "azblob://" + cleanBucket + "/" + cleanPrefix + case "file": + if cleanPrefix == "" { + return "file://" + cleanBucket + } + return "file://" + cleanBucket + "/" + cleanPrefix + default: + if cleanPrefix == "" { + return "s3://" + cleanBucket + } + return "s3://" + cleanBucket + "/" + cleanPrefix + } +} diff --git a/gecko/logging.go b/internal/logging/logging.go similarity index 70% rename from gecko/logging.go rename to internal/logging/logging.go index 8a1881b..f919d99 100644 --- a/gecko/logging.go +++ b/internal/logging/logging.go @@ -1,7 +1,8 @@ -package gecko +package logging import ( "fmt" + "log" "os" "runtime" "strings" @@ -16,17 +17,21 @@ const ( LogLevelError arborist.LogLevel = "ERROR" ) -type LogCache struct { - logs []Log +type Handler struct { + Logger *log.Logger } -type Log struct { +type Cache struct { + logs []Entry +} + +type Entry struct { lvl arborist.LogLevel msg string } -func (cache *LogCache) write(logger arborist.Logger) { - if l, ok := logger.(*LogHandler); ok { +func (cache *Cache) Write(logger arborist.Logger) { + if l, ok := logger.(*Handler); ok { for _, log := range cache.logs { switch log.lvl { case LogLevelDebug: @@ -44,55 +49,55 @@ func (cache *LogCache) write(logger arborist.Logger) { } } -func (cache *LogCache) Debug(format string, a ...any) { - log := Log{ +func (cache *Cache) Debug(format string, a ...any) { + log := Entry{ lvl: LogLevelDebug, msg: logMsg(LogLevelDebug, format, a...), } cache.logs = append(cache.logs, log) } -func (cache *LogCache) Info(format string, a ...any) { - log := Log{ +func (cache *Cache) Info(format string, a ...any) { + log := Entry{ lvl: LogLevelInfo, msg: logMsg(LogLevelInfo, format, a...), } cache.logs = append(cache.logs, log) } -func (cache *LogCache) Warning(format string, a ...any) { - log := Log{ +func (cache *Cache) Warning(format string, a ...any) { + log := Entry{ lvl: LogLevelWarning, msg: logMsg(LogLevelWarning, format, a...), } cache.logs = append(cache.logs, log) } -func (cache *LogCache) Error(format string, a ...any) { - log := Log{ +func (cache *Cache) Error(format string, a ...any) { + log := Entry{ lvl: LogLevelError, msg: logMsg(LogLevelError, format, a...), } cache.logs = append(cache.logs, log) } -func (handler *LogHandler) Print(format string, a ...any) { +func (handler *Handler) Print(format string, a ...any) { handler.Logger.Print(sprintf(format, a...)) } -func (handler *LogHandler) Debug(format string, a ...any) { +func (handler *Handler) Debug(format string, a ...any) { handler.Logger.Print(logMsg(LogLevelDebug, format, a...)) } -func (handler *LogHandler) Info(format string, a ...any) { +func (handler *Handler) Info(format string, a ...any) { handler.Logger.Print(logMsg(LogLevelInfo, format, a...)) } -func (handler *LogHandler) Warning(format string, a ...any) { +func (handler *Handler) Warning(format string, a ...any) { handler.Logger.Print(logMsg(LogLevelWarning, format, a...)) } -func (handler *LogHandler) Error(format string, a ...any) { +func (handler *Handler) Error(format string, a ...any) { handler.Logger.Print(logMsg(LogLevelError, format, a...)) } @@ -100,7 +105,7 @@ func logMsg(lvl arborist.LogLevel, format string, a ...any) string { msg := sprintf(format, a...) msg = fmt.Sprintf("%s: %s", lvl, msg) // get the call from 2 stack frames above this - // (one call up is the LogCache method, so go one more above that) + // (one call up is the Cache method, so go one more above that) _, fn, line, ok := runtime.Caller(2) if ok { // shorten the filepath to only the basename diff --git a/internal/server/http/config/config.go b/internal/server/http/config/config.go new file mode 100644 index 0000000..ca871e3 --- /dev/null +++ b/internal/server/http/config/config.go @@ -0,0 +1,160 @@ +package config + +import ( + "fmt" + "net/http" + "strings" + + "github.com/calypr/gecko/apierror" + "github.com/calypr/gecko/config" + geckodb "github.com/calypr/gecko/internal/db" + "github.com/calypr/gecko/internal/git" + "github.com/calypr/gecko/internal/httputil" + servermw "github.com/calypr/gecko/internal/server/middleware" + "github.com/gofiber/fiber/v3" +) + +type ProjectSummaryResponse struct { + Organization string `json:"organization"` + Project string `json:"project"` + Title string `json:"title"` + ContactEmail string `json:"contact_email"` + Description string `json:"description"` + ThumbnailURL string `json:"thumbnail_url,omitempty"` +} + +type ProjectListResponse struct { + ResourcePath string `json:"resourcePath"` + ConfigData config.ProjectConfig `json:"configData"` + Organization string `json:"organization"` + Project string `json:"project"` + Title string `json:"title"` + ContactEmail string `json:"contact_email"` + Description string `json:"description"` + ThumbnailURL string `json:"thumbnail_url,omitempty"` +} + +func isKnownType(t string) bool { + return config.IsKnownType(t) +} + +func (handler *Handler) resolveConfigParams(ctx fiber.Ctx) (string, string) { + return servermw.ResolveConfigParams(ctx) +} + +// handleConfigListGET godoc +// @Summary List configuration IDs +// @Description Retrieve a list of configuration IDs for a specific type. When mounted under a typed route, the route type is used; otherwise the `type` query parameter is used. +// @Tags Config +// @Accept json +// @Produce json +// @Param type query string false "Configuration Type" +// @Success 200 {array} string "List of config IDs" +// @Failure 400 {object} ErrorResponse "Invalid config type" +// @Failure 500 {object} ErrorResponse "Server error" +// @Router /config/list [get] +func (handler *Handler) handleConfigListGET(ctx fiber.Ctx) error { + configType, _ := ctx.Locals("configType").(string) + if configType == "" { + configType = ctx.Query("type", string(config.TypeExplorer)) + } + + if !isKnownType(configType) { + errResponse := httputil.NewError(apierror.TypeInvalidConfigType, fmt.Sprintf("Unknown config type: %s", configType), http.StatusBadRequest, map[string]any{"config_type": configType}, nil) + errResponse.WriteLog(handler.logger) + return errResponse.Write(ctx) + } + + configList, err := geckodb.ConfigListByType(handler.db, configType) + if err != nil { + errResponse := httputil.NewError(apierror.TypeDatabaseError, fmt.Sprintf("Database error: %s", err), http.StatusInternalServerError, map[string]any{"config_type": configType}, nil) + errResponse.WriteLog(handler.logger) + return errResponse.Write(ctx) + } + if configList == nil { + configList = []string{} + } + if configType == string(config.TypeProjects) { + allowedResources, errResponse := gitAllowedReadResources(strings.TrimSpace(ctx.Get("Authorization"))) + if errResponse != nil { + errResponse.WriteLog(handler.logger) + return errResponse.Write(ctx) + } + configList = filterProjectIDsByAllowedResources(configList, allowedResources) + + projects := make([]ProjectListResponse, 0, len(configList)) + for _, projectID := range configList { + var cfg config.ProjectConfig + if err := geckodb.ConfigGETGeneric(handler.db, projectID, string(config.TypeProjects), &cfg); err != nil { + continue + } + + summary, ok := handler.buildProjectSummaryResponse(projectID, cfg) + if !ok { + continue + } + + projects = append(projects, ProjectListResponse{ + ResourcePath: git.ProgramProjectResourcePath(summary.Organization, summary.Project), + ConfigData: cfg, + Organization: summary.Organization, + Project: summary.Project, + Title: summary.Title, + ContactEmail: summary.ContactEmail, + Description: summary.Description, + ThumbnailURL: summary.ThumbnailURL, + }) + } + + return httputil.JSON(projects, http.StatusOK).Write(ctx) + } + return httputil.JSON(configList, http.StatusOK).Write(ctx) +} + +// handleConfigTypesGET godoc +// @Summary List supported configuration types +// @Description Retrieve the set of supported config types. +// @Tags Config +// @Produce json +// @Success 200 {array} string "Supported config types" +// @Router /config/types [get] +func (handler *Handler) handleConfigTypesGET(ctx fiber.Ctx) error { + return httputil.JSON(config.KnownTypes(), http.StatusOK).Write(ctx) +} + +func configForType(configType string) (config.Configurable, *httputil.ErrorResponse) { + switch configType { + case string(config.TypeExplorer): + return &config.Config{}, nil + case string(config.TypeNav): + return &config.NavPageLayoutProps{}, nil + case string(config.TypeFileSummary): + return &config.FilesummaryConfig{}, nil + case string(config.TypeProject), string(config.TypeProjects): + return &config.ProjectConfig{}, nil + default: + return nil, httputil.NewError(apierror.TypeInvalidConfigType, fmt.Sprintf("Unknown config type: %s", configType), http.StatusBadRequest, map[string]any{"config_type": configType}, nil) + } +} + +func (handler *Handler) resolveProjectConfigParams(ctx fiber.Ctx) (string, string) { + orgTitle := ctx.Params("orgTitle") + projectTitle := ctx.Params("projectTitle") + if orgTitle != "" && projectTitle != "" { + return string(config.TypeProjects), orgTitle + "/" + projectTitle + } + return handler.resolveConfigParams(ctx) +} + +func mergeErrorDetails(base map[string]any, extra map[string]any) map[string]any { + if len(extra) == 0 { + return base + } + if base == nil { + base = map[string]any{} + } + for k, v := range extra { + base[k] = v + } + return base +} diff --git a/internal/server/http/config/config_crud.go b/internal/server/http/config/config_crud.go new file mode 100644 index 0000000..b5c173a --- /dev/null +++ b/internal/server/http/config/config_crud.go @@ -0,0 +1,128 @@ +package config + +import ( + "database/sql" + "errors" + "fmt" + "net/http" + + "github.com/calypr/gecko/apierror" + geckodb "github.com/calypr/gecko/internal/db" + "github.com/calypr/gecko/internal/httputil" + "github.com/gofiber/fiber/v3" +) + +// handleConfigGET godoc +// @Summary Get a specific configuration +// @Description Retrieve configuration by config type and config ID. +// @Tags Config +// @Produce json +// @Param configType path string true "Configuration Type" +// @Param configId path string true "Configuration ID" +// @Success 200 {object} map[string]interface{} "Configuration details" +// @Failure 400 {object} ErrorResponse "Invalid config type" +// @Failure 404 {object} ErrorResponse "Config not found" +// @Failure 500 {object} ErrorResponse "Server error" +// @Router /config/{configType}/{configId} [get] +func (handler *Handler) handleConfigGET(ctx fiber.Ctx) error { + configType, configID := handler.resolveConfigParams(ctx) + return handler.handleConfigGETByID(ctx, configType, configID) +} + +func (handler *Handler) handleConfigGETByID(ctx fiber.Ctx, configType string, configID string) error { + cfg, errResponse := configForType(configType) + if errResponse != nil { + errResponse.WriteLog(handler.logger) + return errResponse.Write(ctx) + } + + err := geckodb.ConfigGETGeneric(handler.db, configID, configType, cfg) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + errResponse = httputil.NewError(apierror.TypeConfigNotFound, fmt.Sprintf("no config found with configId: %s of type: %s", configID, configType), http.StatusNotFound, map[string]any{"config_type": configType, "config_id": configID}, nil) + errResponse.WriteLog(handler.logger) + return errResponse.Write(ctx) + } + errResponse = httputil.NewError(apierror.TypeDatabaseError, fmt.Sprintf("config query failed: %s", err), http.StatusInternalServerError, map[string]any{"config_type": configType, "config_id": configID}, nil) + errResponse.WriteLog(handler.logger) + return errResponse.Write(ctx) + } + + return httputil.JSON(cfg, http.StatusOK).Write(ctx) +} + +// handleConfigDELETE godoc +// @Summary Delete a configuration +// @Description Delete configuration by config type and config ID. +// @Tags Config +// @Produce json +// @Param configType path string true "Configuration Type" +// @Param configId path string true "Configuration ID" +// @Success 200 {object} map[string]interface{} "Configuration deleted" +// @Failure 400 {object} ErrorResponse "Invalid config type" +// @Failure 404 {object} ErrorResponse "Config not found" +// @Failure 500 {object} ErrorResponse "Server error" +// @Router /config/{configType}/{configId} [delete] +func (handler *Handler) handleConfigDELETE(ctx fiber.Ctx) error { + configType, configID := handler.resolveConfigParams(ctx) + return handler.handleConfigDELETEByID(ctx, configType, configID) +} + +func (handler *Handler) handleConfigDELETEByID(ctx fiber.Ctx, configType string, configID string) error { + deleted, err := geckodb.ConfigDELETEGeneric(handler.db, configID, configType) + if !deleted && err == nil { + errResponse := httputil.NewError(apierror.TypeConfigNotFound, fmt.Sprintf("no configId found with configId: %s in type: %s", configID, configType), http.StatusNotFound, map[string]any{"config_type": configType, "config_id": configID}, nil) + errResponse.WriteLog(handler.logger) + return errResponse.Write(ctx) + } + if err != nil { + errResponse := httputil.NewError(apierror.TypeDatabaseError, fmt.Sprintf("config query failed: %s", err), http.StatusInternalServerError, map[string]any{"config_type": configType, "config_id": configID}, nil) + errResponse.WriteLog(handler.logger) + return errResponse.Write(ctx) + } + return httputil.JSON(map[string]any{"code": http.StatusOK, "message": fmt.Sprintf("DELETED: %s from type: %s", configID, configType)}, http.StatusOK).Write(ctx) +} + +// handleConfigPUT godoc +// @Summary Update configuration +// @Description Replaces or updates the configuration for a given config ID in a specific type. +// @Tags Config +// @Accept json +// @Produce json +// @Param configType path string true "Configuration Type" +// @Param configId path string true "Configuration ID" +// @Param body body map[string]interface{} true "Configuration payload" +// @Success 200 {object} map[string]interface{} "Configuration successfully updated" +// @Failure 400 {object} ErrorResponse "Invalid request body" +// @Failure 500 {object} ErrorResponse "Internal server error" +// @Router /config/{configType}/{configId} [put] +func (handler *Handler) handleConfigPUT(ctx fiber.Ctx) error { + configType, configID := handler.resolveConfigParams(ctx) + return handler.handleConfigPUTByID(ctx, configType, configID) +} + +func (handler *Handler) handleConfigPUTByID(ctx fiber.Ctx, configType string, configID string) error { + cfg, errResponse := configForType(configType) + if errResponse != nil { + errResponse.WriteLog(handler.logger) + return errResponse.Write(ctx) + } + + if errResponse = httputil.ParseJSONBody(ctx.Body(), cfg, map[string]any{"config_type": configType, "config_id": configID}); errResponse != nil { + errResponse.WriteLog(handler.logger) + return errResponse.Write(ctx) + } + if validatable, ok := cfg.(interface{ Validate() error }); ok { + if err := validatable.Validate(); err != nil { + errResponse = httputil.NewError(apierror.TypeValidationFailed, fmt.Sprintf("body data validation failed: %s", err), http.StatusBadRequest, map[string]any{"config_type": configType, "config_id": configID}, nil) + errResponse.WriteLog(handler.logger) + return errResponse.Write(ctx) + } + } + if err := geckodb.ConfigPUTGeneric(handler.db, configID, configType, cfg); err != nil { + errResponse = httputil.NewError(apierror.TypeDatabaseError, fmt.Sprintf("configPut failed: %s", err), http.StatusInternalServerError, map[string]any{"config_type": configType, "config_id": configID}, nil) + errResponse.WriteLog(handler.logger) + return errResponse.Write(ctx) + } + return httputil.JSON(map[string]any{"code": http.StatusOK, "message": fmt.Sprintf("ACCEPTED: %s for type: %s", configID, configType)}, http.StatusOK).Write(ctx) +} diff --git a/internal/server/http/config/handler.go b/internal/server/http/config/handler.go new file mode 100644 index 0000000..a63d12e --- /dev/null +++ b/internal/server/http/config/handler.go @@ -0,0 +1,29 @@ +package config + +import ( + "github.com/calypr/gecko/internal/git" + "github.com/calypr/gecko/internal/server/http/shared" + "github.com/calypr/gecko/internal/thumbnail" + "github.com/jmoiron/sqlx" + "github.com/uc-cdis/arborist/arborist" +) + +type Handler struct { + *shared.Handler + db *sqlx.DB + logger arborist.Logger + gitService *git.GitService + projectSetup *git.SetupService + thumbnailStore thumbnail.Manager +} + +func NewHandler(sharedHandler *shared.Handler) *Handler { + return &Handler{ + Handler: sharedHandler, + db: sharedHandler.DB, + logger: sharedHandler.Logger, + gitService: sharedHandler.GitService, + projectSetup: sharedHandler.ProjectSetup, + thumbnailStore: sharedHandler.ThumbnailStore, + } +} diff --git a/internal/server/http/config/helpers.go b/internal/server/http/config/helpers.go new file mode 100644 index 0000000..19b664a --- /dev/null +++ b/internal/server/http/config/helpers.go @@ -0,0 +1,71 @@ +package config + +import ( + "net/http" + "strings" + + "github.com/calypr/gecko/apierror" + "github.com/calypr/gecko/internal/git" + "github.com/calypr/gecko/internal/httputil" + servermw "github.com/calypr/gecko/internal/server/middleware" + "github.com/gofiber/fiber/v3" +) + +func writeAppError(ctx fiber.Ctx, logger any, err error) error { + _ = logger + if appErr, ok := err.(*git.Error); ok { + statusCode := appErr.StatusCode + if statusCode == 0 { + statusCode = http.StatusInternalServerError + } + errorType := apierror.Type("internal_error") + switch appErr.Kind { + case git.ErrorKindValidation: + errorType = apierror.TypeValidationFailed + case git.ErrorKindForbidden: + errorType = apierror.TypeForbidden + case git.ErrorKindIntegration: + errorType = apierror.Type("integration_error") + case git.ErrorKindNotFound: + errorType = apierror.TypeNotFound + case git.ErrorKindDatabase: + errorType = apierror.TypeDatabaseError + case git.ErrorKindUnauthorized: + errorType = apierror.TypeMissingAuthorization + } + return httputil.NewError(errorType, appErr.Error(), statusCode, appErr.Details, nil).Write(ctx) + } + return httputil.NewError(apierror.Type("internal_error"), err.Error(), http.StatusInternalServerError, nil, nil).Write(ctx) +} + +func gitAllowedReadResources(token string) ([]string, *httputil.ErrorResponse) { + if token == "" { + return nil, nil + } + resources, err := servermw.GitAllowedResources(servermw.NewFenceUserAccessHandler(nil), token, "read") + if err != nil { + return nil, err + } + return resources, nil +} + +func filterProjectIDsByAllowedResources(projectIDs []string, allowedResources []string) []string { + if len(allowedResources) == 0 { + return []string{} + } + filtered := make([]string, 0, len(projectIDs)) + for _, projectID := range projectIDs { + parts := strings.SplitN(projectID, "/", 2) + if len(parts) != 2 { + continue + } + projectParts := strings.SplitN(parts[1], "/", 2) + if len(projectParts) != 1 || projectParts[0] == "" { + continue + } + if servermw.GitProjectReadable(allowedResources, parts[0], projectParts[0]) || servermw.ResourceListAllowsProject(allowedResources, parts[0], projectParts[0]) { + filtered = append(filtered, projectID) + } + } + return filtered +} diff --git a/internal/server/http/config/project_config_test.go b/internal/server/http/config/project_config_test.go new file mode 100644 index 0000000..6e12751 --- /dev/null +++ b/internal/server/http/config/project_config_test.go @@ -0,0 +1,144 @@ +package config + +import ( + "bytes" + "context" + "encoding/json" + "log" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/calypr/gecko/config" + geckologging "github.com/calypr/gecko/internal/logging" + "github.com/calypr/gecko/internal/server/http/shared" + servermw "github.com/calypr/gecko/internal/server/middleware" + "github.com/gofiber/fiber/v3" + "github.com/jmoiron/sqlx" +) + +func newProjectConfigTestServer(t *testing.T) (*Handler, sqlmock.Sqlmock, func()) { + t.Helper() + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("failed to create sqlmock: %v", err) + } + srv := &Handler{db: sqlx.NewDb(db, "sqlmock"), logger: &geckologging.Handler{Logger: log.New(os.Stdout, "", 0)}} + return srv, mock, func() { _ = db.Close() } +} + +func runProjectConfigRequest(t *testing.T, app *fiber.App, req *http.Request) *http.Response { + t.Helper() + resp, err := app.Test(req, fiber.TestConfig{Timeout: 0, FailOnTimeout: false}) + if err != nil { + t.Fatalf("fiber test request failed: %v", err) + } + return resp +} + +func TestProjectConfigListGET_PluralProjects(t *testing.T) { + srv, mock, cleanup := newProjectConfigTestServer(t) + defer cleanup() + + rows := sqlmock.NewRows([]string{"name"}).AddRow("HTAN_INT/BForePC") + mock.ExpectQuery(`SELECT name FROM config_schema\.projects`).WillReturnRows(rows) + + app := fiber.New() + projects := app.Group("/config/projects", shared.ConfigTypeMiddleware(string(config.TypeProjects))) + projects.Get("/list", srv.handleConfigListGET) + + resp := runProjectConfigRequest(t, app, httptest.NewRequest(http.MethodGet, "/config/projects/list", nil)) + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected status 200, got %d", resp.StatusCode) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("unmet sql expectations: %v", err) + } +} + +func TestProjectConfigGET_ByOrganizationAndProject(t *testing.T) { + srv, mock, cleanup := newProjectConfigTestServer(t) + defer cleanup() + + project := config.ProjectConfig{ + Title: "BForePC", + ContactEmail: "sanati@ohsu.edu", + SrcRepo: "https://source.ohsu.edu/CBDS/BForePC.git", + OrgTitle: "HTAN_INT", + Description: "BForePC collaboration", + ProjectTitle: "BForePC", + } + content, err := json.Marshal(project) + if err != nil { + t.Fatalf("failed to marshal project fixture: %v", err) + } + + rows := sqlmock.NewRows([]string{"name", "content"}).AddRow("HTAN_INT/BForePC", content) + mock.ExpectQuery(`SELECT name, content FROM config_schema\.projects WHERE name=\$1`). + WithArgs("HTAN_INT/BForePC"). + WillReturnRows(rows) + + app := fiber.New() + projects := app.Group("/config/projects", shared.ConfigTypeMiddleware(string(config.TypeProjects))) + projects.Get("/:orgTitle/:projectTitle", servermw.ConfigAuth(srv.logger, nil), srv.handleProjectConfigGET) + + resp := runProjectConfigRequest(t, app, httptest.NewRequest(http.MethodGet, "/config/projects/HTAN_INT/BForePC", nil)) + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected status 200, got %d", resp.StatusCode) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("unmet sql expectations: %v", err) + } +} + +func TestProjectConfigPUT_ByOrganizationAndProject(t *testing.T) { + srv, mock, cleanup := newProjectConfigTestServer(t) + defer cleanup() + originalValidator := config.ValidateProjectRepository + config.ValidateProjectRepository = func(_ context.Context, raw string) (string, error) { + return raw, nil + } + defer func() { + config.ValidateProjectRepository = originalValidator + }() + + project := config.ProjectConfig{ + Title: "BForePC", + ContactEmail: "sanati@ohsu.edu", + SrcRepo: "github.com/example/BForePC", + OrgTitle: "HTAN_INT", + Description: "BForePC collaboration", + ProjectTitle: "BForePC", + } + + content, err := json.Marshal(project) + if err != nil { + t.Fatalf("failed to marshal project fixture: %v", err) + } + + mock.ExpectExec(`INSERT INTO config_schema\.projects`). + WithArgs("HTAN_INT/BForePC", content). + WillReturnResult(sqlmock.NewResult(1, 1)) + + app := fiber.New() + projects := app.Group("/config/projects", shared.ConfigTypeMiddleware(string(config.TypeProjects))) + projects.Put("/:orgTitle/:projectTitle", srv.handleProjectConfigPUT) + + req := httptest.NewRequest(http.MethodPut, "/config/projects/HTAN_INT/BForePC", bytes.NewReader(content)) + req.Header.Set("Content-Type", "application/json") + resp := runProjectConfigRequest(t, app, req) + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected status 200, got %d", resp.StatusCode) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("unmet sql expectations: %v", err) + } +} diff --git a/internal/server/http/config/project_crud.go b/internal/server/http/config/project_crud.go new file mode 100644 index 0000000..1a03cf7 --- /dev/null +++ b/internal/server/http/config/project_crud.go @@ -0,0 +1,343 @@ +package config + +import ( + "errors" + "fmt" + "net/http" + "net/url" + "sort" + "strings" + + "github.com/calypr/gecko/apierror" + "github.com/calypr/gecko/config" + geckodb "github.com/calypr/gecko/internal/db" + "github.com/calypr/gecko/internal/git" + "github.com/calypr/gecko/internal/httputil" + servermw "github.com/calypr/gecko/internal/server/middleware" + "github.com/calypr/gecko/internal/thumbnail" + "github.com/gofiber/fiber/v3" +) + +func (handler *Handler) buildProjectSummaryResponse(projectID string, cfg config.ProjectConfig) (ProjectSummaryResponse, bool) { + projectOrganization, projectName, found := strings.Cut(projectID, "/") + if !found { + return ProjectSummaryResponse{}, false + } + + organization := strings.TrimSpace(projectOrganization) + project := strings.TrimSpace(projectName) + title := strings.TrimSpace(cfg.Title) + if title == "" { + title = strings.TrimSpace(cfg.ProjectTitle) + } + if title == "" { + title = project + } + + summary := ProjectSummaryResponse{ + Organization: organization, + Project: project, + Title: title, + ContactEmail: strings.TrimSpace(cfg.ContactEmail), + Description: strings.TrimSpace(cfg.Description), + } + + if handler.thumbnailStore != nil { + if _, _, err := handler.thumbnailStore.GetPath(organization, project); err == nil { + summary.ThumbnailURL = fmt.Sprintf( + "/gecko/git/projects/%s/%s/thumbnail", + url.PathEscape(organization), + url.PathEscape(project), + ) + } + } + + return summary, true +} + +func (handler *Handler) handleProjectConfigGET(ctx fiber.Ctx) error { + configType, configID := handler.resolveProjectConfigParams(ctx) + return handler.handleConfigGETByID(ctx, configType, configID) +} + +func (handler *Handler) handleProjectSummaryGET(ctx fiber.Ctx) error { + projectIDs, err := geckodb.ConfigListByType(handler.db, string(config.TypeProjects)) + if err != nil { + errResponse := httputil.NewError(apierror.TypeDatabaseError, fmt.Sprintf("Database error: %s", err), http.StatusInternalServerError, map[string]any{"config_type": string(config.TypeProjects)}, nil) + errResponse.WriteLog(handler.logger) + return errResponse.Write(ctx) + } + + summaries := make([]ProjectSummaryResponse, 0, len(projectIDs)) + for _, projectID := range projectIDs { + var cfg config.ProjectConfig + if err := geckodb.ConfigGETGeneric(handler.db, projectID, string(config.TypeProjects), &cfg); err != nil { + continue + } + summary, ok := handler.buildProjectSummaryResponse(projectID, cfg) + if !ok { + continue + } + summaries = append(summaries, summary) + } + + sort.Slice(summaries, func(i, j int) bool { + leftTitle := strings.ToLower(strings.TrimSpace(summaries[i].Title)) + rightTitle := strings.ToLower(strings.TrimSpace(summaries[j].Title)) + if leftTitle != rightTitle { + return leftTitle < rightTitle + } + leftOrg := strings.ToLower(strings.TrimSpace(summaries[i].Organization)) + rightOrg := strings.ToLower(strings.TrimSpace(summaries[j].Organization)) + if leftOrg != rightOrg { + return leftOrg < rightOrg + } + return strings.ToLower(strings.TrimSpace(summaries[i].Project)) < + strings.ToLower(strings.TrimSpace(summaries[j].Project)) + }) + + return httputil.JSON(summaries, http.StatusOK).Write(ctx) +} + +func (handler *Handler) handleProjectConfigPUT(ctx fiber.Ctx) error { + configType, configID := handler.resolveProjectConfigParams(ctx) + cfg, errResponse := configForType(configType) + if errResponse != nil { + errResponse.WriteLog(handler.logger) + return errResponse.Write(ctx) + } + + if errResponse = httputil.ParseJSONBody(ctx.Body(), cfg, map[string]any{"config_type": configType, "config_id": configID}); errResponse != nil { + errResponse.WriteLog(handler.logger) + return errResponse.Write(ctx) + } + switch typed := cfg.(type) { + case *config.ProjectConfig: + projectOrganization, _, found := strings.Cut(configID, "/") + if !found { + errResponse = httputil.NewError(apierror.TypeValidationFailed, fmt.Sprintf("invalid project config id: %s", configID), http.StatusBadRequest, map[string]any{"config_type": configType, "config_id": configID}, nil) + errResponse.WriteLog(handler.logger) + return errResponse.Write(ctx) + } + typed.OrgTitle = strings.TrimSpace(projectOrganization) + if err := typed.ValidateInitialization(); err != nil { + errResponse = httputil.NewError(apierror.TypeValidationFailed, fmt.Sprintf("body data validation failed: %s", err), http.StatusBadRequest, map[string]any{"config_type": configType, "config_id": configID}, nil) + errResponse.WriteLog(handler.logger) + return errResponse.Write(ctx) + } + default: + if validatable, ok := cfg.(interface{ Validate() error }); ok { + if err := validatable.Validate(); err != nil { + errResponse = httputil.NewError(apierror.TypeValidationFailed, fmt.Sprintf("body data validation failed: %s", err), http.StatusBadRequest, map[string]any{"config_type": configType, "config_id": configID}, nil) + errResponse.WriteLog(handler.logger) + return errResponse.Write(ctx) + } + } + } + if err := geckodb.ConfigPUTGeneric(handler.db, configID, configType, cfg); err != nil { + errResponse = httputil.NewError(apierror.TypeDatabaseError, fmt.Sprintf("configPut failed: %s", err), http.StatusInternalServerError, map[string]any{"config_type": configType, "config_id": configID}, nil) + errResponse.WriteLog(handler.logger) + return errResponse.Write(ctx) + } + + if projectCfg, ok := cfg.(*config.ProjectConfig); ok { + if handler.gitService != nil { + if identity, err := git.ParseRepositoryIdentity(projectCfg.SrcRepo); err == nil { + if existingState, existingErr := geckodb.GitProjectStateByProjectID(handler.db, configID); existingErr != nil { + errResponse = httputil.NewError(apierror.TypeDatabaseError, fmt.Sprintf("read existing git project state failed: %s", existingErr), http.StatusInternalServerError, map[string]any{"config_type": configType, "config_id": configID}, nil) + errResponse.WriteLog(handler.logger) + return errResponse.Write(ctx) + } else if existingState != nil && + existingState.RepoHost == identity.Host && + existingState.RepoOwner == identity.Owner && + existingState.RepoName == identity.Repo { + return httputil.JSON(map[string]any{"code": http.StatusOK, "message": fmt.Sprintf("ACCEPTED: %s for type: %s", configID, configType)}, http.StatusOK).Write(ctx) + } + state := geckodb.GitProjectState{ + ProjectID: configID, + RepoHost: identity.Host, + RepoOwner: identity.Owner, + RepoName: identity.Repo, + MirrorPath: handler.gitService.MirrorPathForIdentity(identity), + SyncState: git.GitSyncNeverSynced, + } + if upsertErr := geckodb.UpsertGitProjectState(handler.db, state); upsertErr != nil { + errResponse = httputil.NewError(apierror.TypeDatabaseError, fmt.Sprintf("upsert git project state failed: %s", upsertErr), http.StatusInternalServerError, map[string]any{"config_type": configType, "config_id": configID}, nil) + errResponse.WriteLog(handler.logger) + return errResponse.Write(ctx) + } + } + } + } + + return httputil.JSON(map[string]any{"code": http.StatusOK, "message": fmt.Sprintf("ACCEPTED: %s for type: %s", configID, configType)}, http.StatusOK).Write(ctx) +} + +func (handler *Handler) handleProjectConfigDELETE(ctx fiber.Ctx) error { + configType, configID := handler.resolveProjectConfigParams(ctx) + organization := strings.TrimSpace(ctx.Params("orgTitle")) + project := strings.TrimSpace(ctx.Params("projectTitle")) + authorizationHeader, tokenErr := servermw.ValidateAuthorizationHeader(ctx.Get("Authorization")) + if tokenErr != nil { + errResponse := httputil.NewError(apierror.TypeMissingAuthorization, tokenErr.Error(), http.StatusUnauthorized, map[string]any{"config_type": configType, "config_id": configID}, nil) + errResponse.WriteLog(handler.logger) + return errResponse.Write(ctx) + } + if errResponse := handler.deleteProject(ctx, authorizationHeader, configType, configID, organization, project); errResponse != nil { + return writeAppError(ctx, handler.logger, errResponse) + } + return handler.handleConfigDELETEByID(ctx, configType, configID) +} + +func (handler *Handler) deleteProject(ctx fiber.Ctx, authorizationHeader, configType, configID, organization, project string) error { + resourcePath := git.ProgramProjectResourcePath(organization, project) + handler.logger.Info( + "starting project delete workflow: config_id=%s resource_path=%s organization=%s project=%s", + configID, + resourcePath, + organization, + project, + ) + if handler.projectSetup != nil { + if err := handler.projectSetup.CleanupProjectStorage(ctx.Context(), authorizationHeader, organization, project); err != nil { + handler.logger.Error( + "project delete failed during storage cleanup: config_id=%s resource_path=%s organization=%s project=%s err=%v", + configID, + resourcePath, + organization, + project, + err, + ) + return err + } + } + if err := geckodb.DeleteGitProjectArtifacts(handler.db, configID); err != nil { + handler.logger.Error( + "project delete failed during gecko git artifact cleanup: config_id=%s resource_path=%s organization=%s project=%s err=%v", + configID, + resourcePath, + organization, + project, + err, + ) + return git.WrapError( + git.ErrorKindDatabase, + http.StatusInternalServerError, + "failed during project deletion step gecko_git_artifact_cleanup", + err, + map[string]any{ + "config_type": configType, + "config_id": configID, + "organization": organization, + "project": project, + "resource_path": resourcePath, + "delete_step": "gecko_git_artifact_cleanup", + }, + ) + } + if handler.thumbnailStore != nil { + if err := handler.thumbnailStore.Delete(organization, project); err != nil && !errors.Is(err, thumbnail.ErrNoThumbnail) { + handler.logger.Error( + "project delete failed during thumbnail cleanup: config_id=%s resource_path=%s organization=%s project=%s err=%v", + configID, + resourcePath, + organization, + project, + err, + ) + return git.WrapError( + git.ErrorKindIntegration, + http.StatusInternalServerError, + "failed during project deletion step thumbnail_cleanup", + err, + map[string]any{ + "config_type": configType, + "config_id": configID, + "organization": organization, + "project": project, + "resource_path": resourcePath, + "delete_step": "thumbnail_cleanup", + }, + ) + } + } + if err := git.DeleteAuthzResource(ctx.Context(), authorizationHeader, resourcePath); err != nil { + handler.logger.Error( + "project delete failed during arborist resource delete: config_id=%s resource_path=%s organization=%s project=%s err=%v", + configID, + resourcePath, + organization, + project, + err, + ) + return git.WrapError( + git.ErrorKindIntegration, + http.StatusBadGateway, + "failed during project deletion step arborist_resource_delete", + err, + map[string]any{ + "config_type": configType, + "config_id": configID, + "organization": organization, + "project": project, + "resource_path": resourcePath, + "delete_step": "arborist_resource_delete", + "upstream_error": err.Error(), + }, + ) + } + handler.logger.Info( + "project delete downstream cleanup succeeded: config_id=%s resource_path=%s organization=%s project=%s", + configID, + resourcePath, + organization, + project, + ) + return nil +} + +func (handler *Handler) handleProjectOrganizationDELETE(ctx fiber.Ctx) error { + organization := strings.TrimSpace(ctx.Params("orgTitle")) + if organization == "" { + errResponse := httputil.NewError("invalid_request", "organization is required", http.StatusBadRequest, nil, nil) + errResponse.WriteLog(handler.logger) + return errResponse.Write(ctx) + } + authorizationHeader, tokenErr := servermw.ValidateAuthorizationHeader(ctx.Get("Authorization")) + if tokenErr != nil { + errResponse := httputil.NewError(apierror.TypeMissingAuthorization, tokenErr.Error(), http.StatusUnauthorized, map[string]any{"organization": organization}, nil) + errResponse.WriteLog(handler.logger) + return errResponse.Write(ctx) + } + projectIDs, err := geckodb.ConfigListByType(handler.db, string(config.TypeProjects)) + if err != nil { + errResponse := httputil.NewError(apierror.TypeDatabaseError, fmt.Sprintf("failed to list projects for organization delete: %s", err), http.StatusInternalServerError, map[string]any{"organization": organization}, nil) + errResponse.WriteLog(handler.logger) + return errResponse.Write(ctx) + } + for _, projectID := range projectIDs { + projectOrganization, projectName, found := strings.Cut(projectID, "/") + if !found || strings.TrimSpace(projectOrganization) != organization { + continue + } + projectName = strings.TrimSpace(projectName) + if projectName == "" { + continue + } + if appErr := handler.deleteProject(ctx, authorizationHeader, string(config.TypeProjects), projectID, organization, projectName); appErr != nil { + return writeAppError(ctx, handler.logger, appErr) + } + if _, err := geckodb.ConfigDELETEGeneric(handler.db, projectID, string(config.TypeProjects)); err != nil { + errResponse := httputil.NewError(apierror.TypeDatabaseError, fmt.Sprintf("failed to delete project config %s during organization delete: %s", projectID, err), http.StatusInternalServerError, map[string]any{"organization": organization, "project_id": projectID}, nil) + errResponse.WriteLog(handler.logger) + return errResponse.Write(ctx) + } + } + resourcePath := fmt.Sprintf("/programs/%s", organization) + if err := git.DeleteAuthzResource(ctx.Context(), authorizationHeader, resourcePath); err != nil { + errResponse := httputil.NewError("integration_error", fmt.Sprintf("failed to delete arborist organization resource: %s", err), http.StatusBadGateway, map[string]any{"organization": organization, "resource_path": resourcePath}, nil) + errResponse.WriteLog(handler.logger) + return errResponse.Write(ctx) + } + return httputil.JSON(map[string]any{"success": true}, http.StatusOK).Write(ctx) +} diff --git a/internal/server/http/config/register.go b/internal/server/http/config/register.go new file mode 100644 index 0000000..1c411f5 --- /dev/null +++ b/internal/server/http/config/register.go @@ -0,0 +1,45 @@ +package config + +import ( + "github.com/calypr/gecko/internal/server/http/shared" + servermw "github.com/calypr/gecko/internal/server/middleware" + "github.com/gofiber/fiber/v3" +) + +func RegisterRoutes(app *fiber.App, sharedHandler *shared.Handler, authzHandler servermw.ResourceAccessHandler) { + handler := NewHandler(sharedHandler) + if handler.DB == nil { + handler.Logger.Warning("Skipping DB endpoints — no database configured") + return + } + + configGroup := app.Group("/config") + configGroup.Get("/types", handler.handleConfigTypesGET) + configGroup.Get("/list", handler.handleConfigListGET) + + handler.registerTypedConfigRoutes(configGroup.Group("/explorer", shared.ConfigTypeMiddleware("explorer")), true, authzHandler) + handler.registerTypedConfigRoutes(configGroup.Group("/nav", shared.ConfigTypeMiddleware("nav")), false, authzHandler) + handler.registerTypedConfigRoutes(configGroup.Group("/file_summary", shared.ConfigTypeMiddleware("file_summary")), false, authzHandler) + handler.registerTypedConfigRoutes(configGroup.Group("/project", shared.ConfigTypeMiddleware("project")), false, authzHandler) + handler.registerProjectConfigRoutes(configGroup.Group("/projects", shared.ConfigTypeMiddleware("projects")), authzHandler) +} + +func (handler *Handler) registerTypedConfigRoutes(group fiber.Router, includeDefaultGet bool, authzHandler servermw.ResourceAccessHandler) { + group.Get("/list", handler.handleConfigListGET) + if includeDefaultGet { + group.Get("/", servermw.ConfigAuth(handler.Logger, authzHandler), handler.handleConfigGET) + } + group.Get("/:configId", servermw.ConfigAuth(handler.Logger, authzHandler), handler.handleConfigGET) + group.Put("/:configId", servermw.ConfigAuth(handler.Logger, authzHandler), handler.handleConfigPUT) + group.Delete("/:configId", servermw.ConfigAuth(handler.Logger, authzHandler), handler.handleConfigDELETE) +} + +func (handler *Handler) registerProjectConfigRoutes(projects fiber.Router, authzHandler servermw.ResourceAccessHandler) { + projects.Get("", handler.handleConfigListGET) + projects.Get("/list", handler.handleConfigListGET) + projects.Get("/summary", handler.handleProjectSummaryGET) + projects.Delete("/:orgTitle", handler.handleProjectOrganizationDELETE) + projects.Get("/:orgTitle/:projectTitle", servermw.ProjectConfigAuth(handler.Logger, authzHandler, "read"), handler.handleProjectConfigGET) + projects.Put("/:orgTitle/:projectTitle", servermw.ProjectConfigAuth(handler.Logger, authzHandler, "update"), handler.handleProjectConfigPUT) + projects.Delete("/:orgTitle/:projectTitle", servermw.ProjectConfigAuth(handler.Logger, authzHandler, "delete"), handler.handleProjectConfigDELETE) +} diff --git a/internal/server/http/directory/directory.go b/internal/server/http/directory/directory.go new file mode 100644 index 0000000..6ec7174 --- /dev/null +++ b/internal/server/http/directory/directory.go @@ -0,0 +1,128 @@ +package directory + +import ( + "fmt" + "net/http" + "path" + "strings" + + "github.com/bmeg/grip/gripql" + "github.com/calypr/gecko/apierror" + "github.com/calypr/gecko/internal/httputil" + servermw "github.com/calypr/gecko/internal/server/middleware" + "github.com/gofiber/fiber/v3" +) + +func (handler *Handler) registerDirectoryHandlers(app fiber.Router, authMiddleware fiber.Handler) { + app.Get("/dir", handler.handleListProjects) + app.Get("/dir/:projectId", authMiddleware, handler.handleDirGet) +} + +// handleListProjects godoc +// @Summary List authorized projects +// @Description Retrieve the set of projects visible to the current user. +// @Tags Directory +// @Produce json +// @Success 200 {array} string "Project resource paths" +// @Failure 401 {object} ErrorResponse "Unauthorized" +// @Failure 500 {object} ErrorResponse "Server error" +// @Router /dir [get] +func (handler *Handler) handleListProjects(ctx fiber.Ctx) error { + projs, errResponse := servermw.GetProjectsFromToken(ctx, servermw.NewFenceUserAccessHandler(nil), "read", "*") + if errResponse != nil { + errResponse.WriteLog(handler.logger) + return errResponse.Write(ctx) + } + q := buildListProjectsQuery(projs) + res, err := handler.gripqlClient.Traversal(ctx, &gripql.GraphQuery{Graph: handler.gripGraphName, Query: q.Statements}) + if err != nil { + errResponse = httputil.NewError(apierror.TypeGraphQueryFailed, "graph query failed", http.StatusInternalServerError, nil, &err) + errResponse.WriteLog(handler.logger) + return errResponse.Write(ctx) + } + out := []string{} + for r := range res { + renda, ok := r.GetRender().GetStructValue().AsMap()["project"].(string) + if ok { + out = append(out, renda) + } + } + return httputil.JSON(out, http.StatusOK).Write(ctx) +} + +// handleDirGet godoc +// @Summary Retrieve directory information for a project +// @Description Retrieve directory details for the given project ID and directory path. +// @Tags Directory +// @Produce json +// @Param projectId path string true "Project ID (format: program-project)" +// @Param directory query string true "Directory path (e.g. /data/my-dir)" +// @Success 200 {array} map[string]interface{} "Directory information" +// @Failure 400 {object} ErrorResponse "Invalid request body or directory path" +// @Failure 403 {object} ErrorResponse "User is not allowed on the resource path" +// @Failure 500 {object} ErrorResponse "Server error" +// @Router /dir/{projectId} [get] +func (handler *Handler) handleDirGet(ctx fiber.Ctx) error { + projectID := ctx.Params("projectId") + dirPath := ctx.Query("directory") + if dirPath == "" || !isValidPosixPath(&dirPath) { + errResponse := httputil.NewError(apierror.TypeInvalidDirectory, fmt.Sprintf("Invalid or missing Directory path: %s", dirPath), http.StatusBadRequest, map[string]any{"directory": dirPath}, nil) + errResponse.WriteLog(handler.logger) + return errResponse.Write(ctx) + } + + projectSplit := strings.Split(projectID, "-") + if len(projectSplit) != 2 { + errResponse := httputil.NewError(apierror.TypeInvalidProjectID, fmt.Sprintf("Failed to parse request body: %v", fmt.Sprintf("incorrect path %s", ctx.Path())), http.StatusNotFound, map[string]any{"project_id": projectID}, nil) + errResponse.WriteLog(handler.logger) + return errResponse.Write(ctx) + } + projectID = "/programs/" + projectSplit[0] + "/projects/" + projectSplit[1] + + q := buildDirGetQuery(projectID, dirPath) + res, err := handler.gripqlClient.Traversal(ctx, &gripql.GraphQuery{Graph: handler.gripGraphName, Query: q.Statements}) + if err != nil { + errResponse := httputil.NewError(apierror.TypeGraphQueryFailed, "graph query failed", http.StatusInternalServerError, nil, &err) + errResponse.WriteLog(handler.logger) + return errResponse.Write(ctx) + } + out := []any{} + for r := range res { + out = append(out, r.GetVertex()) + } + return httputil.JSON(out, http.StatusOK).Write(ctx) +} + +func isValidPosixPath(p *string) bool { + if strings.ContainsRune(*p, 000) || !path.IsAbs(*p) || strings.Contains(*p, "\\") { + return false + } + cleaned := path.Clean(*p) + if *p == "" || cleaned == "." || cleaned == ".." || strings.HasPrefix(cleaned, "/..") { + return false + } + return true +} + +func buildListProjectsQuery(projs []any) *gripql.Query { + return gripql.V(). + HasLabel("ResearchStudy"). + Has(gripql.Within("auth_resource_path", projs...)). + As("project"). + OutE("rootDir_Directory"). + Select("project"). + Distinct("auth_resource_path"). + Render(map[string]any{"project": "$project.auth_resource_path"}) +} + +func buildDirGetQuery(projectID, dirPath string) *gripql.Query { + q := gripql.V().HasLabel("ResearchStudy").Has(gripql.Eq("auth_resource_path", projectID)).OutE("rootDir_Directory").OutNull().OutNull() + if dirPath != "/" { + for splStr := range strings.SplitSeq(strings.Trim(dirPath, "/"), "/") { + q = q.Has(gripql.Eq("name", splStr)).Has(gripql.Eq("auth_resource_path", projectID)).OutNull() + } + } else { + q = q.Has(gripql.Eq("auth_resource_path", projectID)) + } + return q +} diff --git a/gecko/handleDir_test.go b/internal/server/http/directory/directory_test.go similarity index 98% rename from gecko/handleDir_test.go rename to internal/server/http/directory/directory_test.go index 044d3f5..0237e40 100644 --- a/gecko/handleDir_test.go +++ b/internal/server/http/directory/directory_test.go @@ -1,4 +1,4 @@ -package gecko +package directory import ( "encoding/json" diff --git a/internal/server/http/directory/handler.go b/internal/server/http/directory/handler.go new file mode 100644 index 0000000..c2351a8 --- /dev/null +++ b/internal/server/http/directory/handler.go @@ -0,0 +1,23 @@ +package directory + +import ( + "github.com/bmeg/grip/gripql" + "github.com/calypr/gecko/internal/server/http/shared" + "github.com/uc-cdis/arborist/arborist" +) + +type Handler struct { + *shared.Handler + logger arborist.Logger + gripqlClient *gripql.Client + gripGraphName string +} + +func NewHandler(sharedHandler *shared.Handler) *Handler { + return &Handler{ + Handler: sharedHandler, + logger: sharedHandler.Logger, + gripqlClient: sharedHandler.GripqlClient, + gripGraphName: sharedHandler.GripGraphName, + } +} diff --git a/internal/server/http/directory/register.go b/internal/server/http/directory/register.go new file mode 100644 index 0000000..d361e5a --- /dev/null +++ b/internal/server/http/directory/register.go @@ -0,0 +1,18 @@ +package directory + +import ( + "github.com/calypr/gecko/internal/server/http/shared" + servermw "github.com/calypr/gecko/internal/server/middleware" + "github.com/gofiber/fiber/v3" +) + +func RegisterRoutes(app *fiber.App, sharedHandler *shared.Handler, authzHandler servermw.ResourceAccessHandler) { + handler := NewHandler(sharedHandler) + if handler.GripqlClient == nil { + handler.Logger.Warning("Skipping gripql Directory endpoints — no database configured") + return + } + authMiddleware := servermw.GeneralAuth(handler.Logger, authzHandler, "read", "*") + app.Get("/dir", handler.handleListProjects) + app.Get("/dir/:projectId", authMiddleware, handler.handleDirGet) +} diff --git a/internal/server/http/git/calypr_project.go b/internal/server/http/git/calypr_project.go new file mode 100644 index 0000000..539be6b --- /dev/null +++ b/internal/server/http/git/calypr_project.go @@ -0,0 +1,73 @@ +package git + +import ( + "context" + "net/http" + "strings" + "time" + + "github.com/calypr/gecko/apierror" + "github.com/calypr/gecko/internal/git" + "github.com/calypr/gecko/internal/httputil" + servermw "github.com/calypr/gecko/internal/server/middleware" + "github.com/gofiber/fiber/v3" +) + +func (handler *Handler) handleCalyprProjectSetupPUT(ctx fiber.Ctx) error { + organization := strings.TrimSpace(ctx.Params("orgTitle")) + project := strings.TrimSpace(ctx.Params("projectTitle")) + if organization == "" || project == "" { + response := httputil.NewError("invalid_request", "organization and project are required", http.StatusBadRequest, nil, nil) + response.WriteLog(handler.logger) + return response.Write(ctx) + } + authorizationHeader, tokenErr := servermw.ValidateAuthorizationHeader(ctx.Get("Authorization")) + if tokenErr != nil { + response := httputil.NewError(apierror.TypeMissingAuthorization, tokenErr.Error(), http.StatusUnauthorized, nil, nil) + response.WriteLog(handler.logger) + return response.Write(ctx) + } + + var request git.CalyprProjectSetupRequest + if errResponse := httputil.ParseJSONBody(ctx.Body(), &request, map[string]any{"organization": organization, "project": project}); errResponse != nil { + errResponse.WriteLog(handler.logger) + return errResponse.Write(ctx) + } + + setupCtx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + response, err := handler.projectSetup.InitializeProject(setupCtx, authorizationHeader, organization, project, request) + if err != nil { + return writeAppError(ctx, handler.logger, err) + } + return httputil.JSON(response, http.StatusOK).Write(ctx) +} + +func (handler *Handler) handleCalyprProjectStoragePUT(ctx fiber.Ctx) error { + organization := strings.TrimSpace(ctx.Params("orgTitle")) + project := strings.TrimSpace(ctx.Params("projectTitle")) + if organization == "" || project == "" { + response := httputil.NewError("invalid_request", "organization and project are required", http.StatusBadRequest, nil, nil) + response.WriteLog(handler.logger) + return response.Write(ctx) + } + authorizationHeader, tokenErr := servermw.ValidateAuthorizationHeader(ctx.Get("Authorization")) + if tokenErr != nil { + response := httputil.NewError(apierror.TypeMissingAuthorization, tokenErr.Error(), http.StatusUnauthorized, nil, nil) + response.WriteLog(handler.logger) + return response.Write(ctx) + } + var request git.CalyprProjectStorageRequest + if errResponse := httputil.ParseJSONBody(ctx.Body(), &request, map[string]any{"organization": organization, "project": project}); errResponse != nil { + errResponse.WriteLog(handler.logger) + return errResponse.Write(ctx) + } + + setupCtx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + response, err := handler.projectSetup.PopulateStorage(setupCtx, authorizationHeader, organization, project, request) + if err != nil { + return writeAppError(ctx, handler.logger, err) + } + return httputil.JSON(response, http.StatusOK).Write(ctx) +} diff --git a/internal/server/http/git/handler.go b/internal/server/http/git/handler.go new file mode 100644 index 0000000..6ee4aa0 --- /dev/null +++ b/internal/server/http/git/handler.go @@ -0,0 +1,41 @@ +package git + +import ( + "github.com/bmeg/grip/gripql" + "github.com/calypr/gecko/internal/git" + "github.com/calypr/gecko/internal/server/http/shared" + "github.com/calypr/gecko/internal/thumbnail" + "github.com/jmoiron/sqlx" + "github.com/qdrant/go-client/qdrant" + "github.com/uc-cdis/arborist/arborist" +) + +type Handler struct { + *shared.Handler + db *sqlx.DB + logger arborist.Logger + jwtApp arborist.JWTDecoder + qdrantClient *qdrant.Client + gripqlClient *gripql.Client + gripGraphName string + gitService *git.GitService + projectSetup *git.SetupService + projectSync *git.ReconcileService + thumbnailStore thumbnail.Manager +} + +func NewHandler(sharedHandler *shared.Handler) *Handler { + return &Handler{ + Handler: sharedHandler, + db: sharedHandler.DB, + logger: sharedHandler.Logger, + jwtApp: sharedHandler.JWTApp, + qdrantClient: sharedHandler.QdrantClient, + gripqlClient: sharedHandler.GripqlClient, + gripGraphName: sharedHandler.GripGraphName, + gitService: sharedHandler.GitService, + projectSetup: sharedHandler.ProjectSetup, + projectSync: sharedHandler.ProjectSync, + thumbnailStore: sharedHandler.ThumbnailStore, + } +} diff --git a/internal/server/http/git/helpers.go b/internal/server/http/git/helpers.go new file mode 100644 index 0000000..dd0d127 --- /dev/null +++ b/internal/server/http/git/helpers.go @@ -0,0 +1,45 @@ +package git + +import ( + "net/http" + + "github.com/calypr/gecko/apierror" + "github.com/calypr/gecko/internal/git" + "github.com/calypr/gecko/internal/httputil" + "github.com/gofiber/fiber/v3" +) + +func (handler *Handler) authenticatedUserID(ctx fiber.Ctx) (string, *httputil.ErrorResponse) { + return handler.AuthenticatedUserID(ctx) +} + +func (handler *Handler) writeAppError(ctx fiber.Ctx, err error) error { + return handler.WriteAppError(ctx, err) +} + +func writeAppError(ctx fiber.Ctx, logger any, err error) error { + _ = logger + if appErr, ok := err.(*git.Error); ok { + statusCode := appErr.StatusCode + if statusCode == 0 { + statusCode = http.StatusInternalServerError + } + errorType := apierror.Type("internal_error") + switch appErr.Kind { + case git.ErrorKindValidation: + errorType = apierror.TypeValidationFailed + case git.ErrorKindForbidden: + errorType = apierror.TypeForbidden + case git.ErrorKindIntegration: + errorType = apierror.Type("integration_error") + case git.ErrorKindNotFound: + errorType = apierror.TypeNotFound + case git.ErrorKindDatabase: + errorType = apierror.TypeDatabaseError + case git.ErrorKindUnauthorized: + errorType = apierror.TypeMissingAuthorization + } + return httputil.NewError(errorType, appErr.Error(), statusCode, appErr.Details, nil).Write(ctx) + } + return httputil.NewError(apierror.Type("internal_error"), err.Error(), http.StatusInternalServerError, nil, nil).Write(ctx) +} diff --git a/internal/server/http/git/installation.go b/internal/server/http/git/installation.go new file mode 100644 index 0000000..2f25cc4 --- /dev/null +++ b/internal/server/http/git/installation.go @@ -0,0 +1,416 @@ +package git + +import ( + "context" + "database/sql" + "fmt" + "net/http" + "strings" + "time" + + "github.com/calypr/gecko/apierror" + appconfig "github.com/calypr/gecko/config" + geckodb "github.com/calypr/gecko/internal/db" + "github.com/calypr/gecko/internal/git" + "github.com/calypr/gecko/internal/httputil" + servermw "github.com/calypr/gecko/internal/server/middleware" + "github.com/gofiber/fiber/v3" +) + +func (handler *Handler) handleGitOrganizationInitConnectPOST(ctx fiber.Ctx) error { + organization := ctx.Params("orgTitle") + if organization == "" { + response := httputil.NewError(apierror.Type("invalid_request"), "organization is required", http.StatusBadRequest, nil, nil) + response.WriteLog(handler.logger) + return response.Write(ctx) + } + authorizationHeader, tokenErr := servermw.ValidateAuthorizationHeader(ctx.Get("Authorization")) + if tokenErr != nil { + response := httputil.NewError(apierror.TypeMissingAuthorization, tokenErr.Error(), http.StatusUnauthorized, nil, nil) + response.WriteLog(handler.logger) + return response.Write(ctx) + } + type initConnectRequest struct { + RedirectPath string `json:"redirect_path"` + Project string `json:"project"` + RepositoryFullName string `json:"repository_full_name"` + } + requestBody := initConnectRequest{} + if len(ctx.Body()) > 0 { + if errResponse := httputil.ParseJSONBody(ctx.Body(), &requestBody, map[string]any{"organization": organization}); errResponse != nil { + errResponse.WriteLog(handler.logger) + return errResponse.Write(ctx) + } + } + redirectPath := strings.TrimSpace(requestBody.RedirectPath) + if redirectPath == "" { + redirectPath = "/git" + } + connectCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + redirectURL, err := handler.gitService.RequestInstallationURL( + connectCtx, + authorizationHeader, + organization, + redirectPath, + ) + if err != nil { + if statusErr, ok := err.(*git.HTTPStatusError); ok { + response := httputil.NewError(apierror.Type(statusErr.Code), statusErr.Message, statusErr.StatusCode, map[string]any{"organization": organization}, nil) + response.WriteLog(handler.logger) + return response.Write(ctx) + } + response := httputil.NewError(apierror.Type("integration_error"), fmt.Sprintf("failed to request GitHub App install URL from Fence: %s", err), http.StatusBadGateway, map[string]any{"organization": organization}, nil) + response.WriteLog(handler.logger) + return response.Write(ctx) + } + + if strings.TrimSpace(requestBody.RepositoryFullName) != "" { + identity, parseErr := parseRequestedRepositoryIdentity(requestBody.RepositoryFullName) + if parseErr != nil { + response := httputil.NewError(apierror.Type("invalid_request"), fmt.Sprintf("invalid repository_full_name %q: %s", requestBody.RepositoryFullName, parseErr), http.StatusBadRequest, map[string]any{"organization": organization}, nil) + response.WriteLog(handler.logger) + return response.Write(ctx) + } + + targetID, repoID, resolveErr := handler.gitService.ResolveTargetAndRepositoryIDs(connectCtx, identity) + if resolveErr != nil { + handler.logger.Warning(fmt.Sprintf("skipping GitHub install redirect optimization for %s/%s: %v", identity.Owner, identity.Repo, resolveErr)) + if settingsURL, ok := handler.organizationInstallationSettingsURL(connectCtx, authorizationHeader, organization, identity.Owner); ok { + redirectURL = settingsURL + } + } else { + redirectURL = decorateInstallationRedirectURL(redirectURL, targetID, repoID) + } + } + + return httputil.JSON(git.GitOrganizationConnectResponse{ + Mode: "redirect", + RedirectURL: redirectURL, + }, http.StatusOK).Write(ctx) +} + +func (handler *Handler) organizationInstallationSettingsURL(ctx context.Context, authorizationHeader string, organization string, owner string) (string, bool) { + installation, err := handler.gitService.RequestOrganizationInstallationStatus(ctx, authorizationHeader, organization, owner) + if err != nil { + handler.logger.Warning(fmt.Sprintf("failed to load GitHub organization installation for %s: %v", owner, err)) + return "", false + } + if !installation.Installed { + return "", false + } + if installation.InstallationID == nil || *installation.InstallationID <= 0 { + return "", false + } + owner = strings.TrimSpace(owner) + if owner == "" { + return "", false + } + return fmt.Sprintf("https://github.com/organizations/%s/settings/installations/%d", owner, *installation.InstallationID), true +} + +func (handler *Handler) handleGitOrganizationConnectPOST(ctx fiber.Ctx) error { + organization := ctx.Params("orgTitle") + if organization == "" { + response := httputil.NewError(apierror.Type("invalid_request"), "organization is required", http.StatusBadRequest, nil, nil) + response.WriteLog(handler.logger) + return response.Write(ctx) + } + authorizationHeader, tokenErr := servermw.ValidateAuthorizationHeader(ctx.Get("Authorization")) + if tokenErr != nil { + response := httputil.NewError(apierror.TypeMissingAuthorization, tokenErr.Error(), http.StatusUnauthorized, nil, nil) + response.WriteLog(handler.logger) + return response.Write(ctx) + } + type connectRequest struct { + InstallationID *int64 `json:"installation_id"` + } + requestBody := connectRequest{} + if len(ctx.Body()) > 0 { + if errResponse := httputil.ParseJSONBody(ctx.Body(), &requestBody, map[string]any{"organization": organization}); errResponse != nil { + errResponse.WriteLog(handler.logger) + return errResponse.Write(ctx) + } + } + if requestBody.InstallationID == nil || *requestBody.InstallationID <= 0 { + response := httputil.NewError(apierror.Type("invalid_request"), "installation_id is required", http.StatusBadRequest, map[string]any{"organization": organization}, nil) + response.WriteLog(handler.logger) + return response.Write(ctx) + } + connectCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + repositories, err := handler.gitService.ListInstallationRepositories( + connectCtx, + authorizationHeader, + *requestBody.InstallationID, + ) + if err != nil { + if statusErr, ok := err.(*git.HTTPStatusError); ok { + response := httputil.NewError(apierror.Type(statusErr.Code), statusErr.Message, statusErr.StatusCode, map[string]any{"organization": organization, "installation_id": *requestBody.InstallationID}, nil) + response.WriteLog(handler.logger) + return response.Write(ctx) + } + response := httputil.NewError(apierror.Type("integration_error"), fmt.Sprintf("failed to list GitHub installation repositories: %s", err), http.StatusBadGateway, map[string]any{"organization": organization, "installation_id": *requestBody.InstallationID}, nil) + response.WriteLog(handler.logger) + return response.Write(ctx) + } + return httputil.JSON(git.GitOrganizationConnectResponse{ + Mode: "select_repository", + InstallationID: requestBody.InstallationID, + Repositories: repositories, + }, http.StatusOK).Write(ctx) +} + +func (handler *Handler) handleGitProjectEditConnectPOST(ctx fiber.Ctx) error { + organization := strings.TrimSpace(ctx.Params("orgTitle")) + project := strings.TrimSpace(ctx.Params("projectTitle")) + if organization == "" || project == "" { + response := httputil.NewError(apierror.Type("invalid_request"), "organization and project are required", http.StatusBadRequest, nil, nil) + response.WriteLog(handler.logger) + return response.Write(ctx) + } + type editConnectRequest struct { + RepositoryFullName string `json:"repository_full_name"` + } + requestBody := editConnectRequest{} + if len(ctx.Body()) > 0 { + if errResponse := httputil.ParseJSONBody(ctx.Body(), &requestBody, map[string]any{"organization": organization, "project": project}); errResponse != nil { + errResponse.WriteLog(handler.logger) + return errResponse.Write(ctx) + } + } + if strings.TrimSpace(requestBody.RepositoryFullName) == "" { + response := httputil.NewError(apierror.Type("invalid_request"), "repository_full_name is required", http.StatusBadRequest, map[string]any{"organization": organization, "project": project}, nil) + response.WriteLog(handler.logger) + return response.Write(ctx) + } + projectID := organization + "/" + project + authorizationHeader, tokenErr := servermw.ValidateAuthorizationHeader(ctx.Get("Authorization")) + if tokenErr != nil { + response := httputil.NewError(apierror.TypeMissingAuthorization, tokenErr.Error(), http.StatusUnauthorized, nil, nil) + response.WriteLog(handler.logger) + return response.Write(ctx) + } + connectCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + projectCfg, errResponse := handler.loadProjectConfig(connectCtx, projectID) + if errResponse != nil { + errResponse.WriteLog(handler.logger) + return errResponse.Write(ctx) + } + orgState, errResponse := handler.loadConnectedOrganizationState(connectCtx, organization, project) + if errResponse != nil { + errResponse.WriteLog(handler.logger) + return errResponse.Write(ctx) + } + repositories, errResponse := handler.listConnectedInstallationRepositories(connectCtx, authorizationHeader, organization, project, orgState.InstallationID.Int64) + if errResponse != nil { + errResponse.WriteLog(handler.logger) + return errResponse.Write(ctx) + } + selectedIdentity, found, errResponse := normalizeInstallationRepository(requestBody.RepositoryFullName, repositories, organization, project) + if errResponse != nil { + errResponse.WriteLog(handler.logger) + return errResponse.Write(ctx) + } + if !found { + response := httputil.NewError(apierror.Type("conflict"), fmt.Sprintf("GitHub App is not connected to repository %q", requestBody.RepositoryFullName), http.StatusConflict, map[string]any{"organization": organization, "project": project, "repository": requestBody.RepositoryFullName}, nil) + response.WriteLog(handler.logger) + return response.Write(ctx) + } + + if err := handler.bindProjectRepository(connectCtx, projectID, projectCfg, orgState, selectedIdentity); err != nil { + response := httputil.NewError(apierror.TypeDatabaseError, fmt.Sprintf("failed to bind project repository: %s", err), http.StatusInternalServerError, map[string]any{"project_id": projectID}, nil) + response.WriteLog(handler.logger) + return response.Write(ctx) + } + + return httputil.JSON(git.GitOrganizationConnectResponse{Mode: "connected"}, http.StatusOK).Write(ctx) +} + +func normalizeInstallationRepository(repositoryFullName string, repositories []git.GitHubInstallationRepository, organization string, project string) (git.GitRepositoryIdentity, bool, *httputil.ErrorResponse) { + requested := strings.TrimSpace(repositoryFullName) + for _, repository := range repositories { + if !strings.EqualFold(repository.FullName, requested) { + continue + } + repoURL := strings.TrimSpace(repository.CloneURL) + if repoURL == "" { + repoURL = strings.TrimSpace(repository.HTMLURL) + } + if repoURL == "" { + repoURL = "https://github.com/" + strings.TrimSpace(repository.FullName) + } + identity, err := git.ParseRepositoryIdentity(repoURL) + if err != nil { + response := httputil.NewError(apierror.Type("integration_error"), fmt.Sprintf("failed to normalize installation repository %q: %s", repository.FullName, err), http.StatusBadGateway, map[string]any{"organization": organization, "project": project, "repository": repository.FullName}, nil) + return git.GitRepositoryIdentity{}, false, response + } + return identity, true, nil + } + return git.GitRepositoryIdentity{}, false, nil +} + +func parseRequestedRepositoryIdentity(repositoryFullName string) (git.GitRepositoryIdentity, error) { + repoURL := strings.TrimSpace(repositoryFullName) + if !strings.HasPrefix(repoURL, "http://") && !strings.HasPrefix(repoURL, "https://") && !strings.HasPrefix(repoURL, "git@") { + repoURL = "https://github.com/" + repoURL + } + return git.ParseRepositoryIdentity(repoURL) +} + +func decorateInstallationRedirectURL(redirectURL string, targetID int64, repoID int64) string { + hasNew := strings.Contains(redirectURL, "/installations/new") + hasSelectTarget := strings.Contains(redirectURL, "/installations/select_target") + if !hasNew && !hasSelectTarget { + return redirectURL + } + if hasNew { + redirectURL = strings.Replace(redirectURL, "/installations/new", "/installations/new/permissions", 1) + } else { + redirectURL = strings.Replace(redirectURL, "/installations/select_target", "/installations/new/permissions", 1) + } + separator := "?" + if strings.Contains(redirectURL, "?") { + separator = "&" + } + redirectURL = fmt.Sprintf("%s%ssuggested_target_id=%d", redirectURL, separator, targetID) + return fmt.Sprintf("%s&repository_ids[]=%d", redirectURL, repoID) +} + +func (handler *Handler) loadProjectConfig(ctx context.Context, projectID string) (appconfig.ProjectConfig, *httputil.ErrorResponse) { + var projectCfg appconfig.ProjectConfig + if err := geckodb.ConfigGETGenericContext(ctx, handler.db, projectID, string(appconfig.TypeProjects), &projectCfg); err != nil { + if err == sql.ErrNoRows { + response := httputil.NewError(apierror.Type("not_found"), fmt.Sprintf("no project config found for %s", projectID), http.StatusNotFound, map[string]any{"project_id": projectID}, nil) + return appconfig.ProjectConfig{}, response + } + response := httputil.NewError(apierror.TypeDatabaseError, fmt.Sprintf("failed to load project config: %s", err), http.StatusInternalServerError, map[string]any{"project_id": projectID}, nil) + return appconfig.ProjectConfig{}, response + } + return projectCfg, nil +} + +func (handler *Handler) loadConnectedOrganizationState(ctx context.Context, organization string, project string) (*geckodb.GitOrganizationState, *httputil.ErrorResponse) { + orgState, err := geckodb.GitOrganizationStateByOrganizationContext(ctx, handler.db, organization) + if err != nil { + response := httputil.NewError(apierror.TypeDatabaseError, fmt.Sprintf("failed to load organization git state: %s", err), http.StatusInternalServerError, map[string]any{"organization": organization}, nil) + return nil, response + } + if orgState == nil || !orgState.Installed || !orgState.InstallationID.Valid { + response := httputil.NewError(apierror.Type("conflict"), "organization is not connected to the GitHub App", http.StatusConflict, map[string]any{"organization": organization, "project": project}, nil) + return nil, response + } + return orgState, nil +} + +func (handler *Handler) listConnectedInstallationRepositories(ctx context.Context, authorizationHeader string, organization string, project string, installationID int64) ([]git.GitHubInstallationRepository, *httputil.ErrorResponse) { + repositories, err := handler.gitService.ListInstallationRepositories(ctx, authorizationHeader, installationID) + if err != nil { + if statusErr, ok := err.(*git.HTTPStatusError); ok { + response := httputil.NewError(apierror.Type(statusErr.Code), statusErr.Message, statusErr.StatusCode, map[string]any{"organization": organization, "project": project, "installation_id": installationID}, nil) + return nil, response + } + response := httputil.NewError(apierror.Type("integration_error"), fmt.Sprintf("failed to list GitHub installation repositories: %s", err), http.StatusBadGateway, map[string]any{"organization": organization, "project": project, "installation_id": installationID}, nil) + return nil, response + } + return repositories, nil +} + +func (handler *Handler) bindProjectRepository(ctx context.Context, projectID string, projectCfg appconfig.ProjectConfig, orgState *geckodb.GitOrganizationState, identity git.GitRepositoryIdentity) error { + projectState, err := geckodb.GitProjectStateByProjectIDContext(ctx, handler.db, projectID) + if err != nil { + return fmt.Errorf("load project git state: %w", err) + } + if projectState == nil { + projectState = &geckodb.GitProjectState{ProjectID: projectID} + } + projectCfg.SrcRepo = identity.URL + projectState.RepoHost = identity.Host + projectState.RepoOwner = identity.Owner + projectState.RepoName = identity.Repo + projectState.InstallationID = orgState.InstallationID + projectState.InstallationTarget = orgState.InstallationTarget + projectState.InstallationTargetType = orgState.InstallationTargetType + projectState.MirrorPath = handler.gitService.MirrorPathForIdentity(identity) + projectState.SyncState = git.GitSyncNeverSynced + projectState.DefaultBranch = sql.NullString{} + projectState.LastRefreshedAt = sql.NullTime{} + projectState.LastError = sql.NullString{} + + tx, err := handler.db.BeginTxx(ctx, nil) + if err != nil { + return fmt.Errorf("begin project repository bind transaction: %w", err) + } + defer func() { + _ = tx.Rollback() + }() + + if err := geckodb.ConfigPUTGenericTxContext(ctx, tx, projectID, string(appconfig.TypeProjects), &projectCfg); err != nil { + return fmt.Errorf("update project config: %w", err) + } + if err := geckodb.UpsertGitProjectStateTxContext(ctx, tx, *projectState); err != nil { + return fmt.Errorf("update project git state: %w", err) + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit project repository bind transaction: %w", err) + } + return nil +} + +func (handler *Handler) handleGitProjectUpdatePOST(ctx fiber.Ctx) error { + organization, project, projectID, cfg, identity, errResponse := handler.resolveGitProject(ctx) + if errResponse != nil { + return errResponse.Write(ctx) + } + state, err := handler.loadGitProjectState(projectID, identity) + if err != nil { + response := httputil.NewError(apierror.TypeDatabaseError, fmt.Sprintf("failed to read git state: %s", err), http.StatusInternalServerError, map[string]any{"project_id": projectID}, nil) + response.WriteLog(handler.logger) + return response.Write(ctx) + } + if state == nil { + state = &geckodb.GitProjectState{ProjectID: projectID, RepoHost: identity.Host, RepoOwner: identity.Owner, RepoName: identity.Repo, MirrorPath: handler.gitService.MirrorPathForIdentity(identity), SyncState: git.GitSyncNeverSynced} + } + authorizationHeader, tokenErr := servermw.ValidateAuthorizationHeader(ctx.Get("Authorization")) + if tokenErr != nil { + response := httputil.NewError(apierror.TypeMissingAuthorization, tokenErr.Error(), http.StatusUnauthorized, nil, nil) + response.WriteLog(handler.logger) + return response.Write(ctx) + } + refreshCtx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + accessToken, err := handler.gitService.RequestInstallationToken(refreshCtx, authorizationHeader, organization, project, identity, "read") + if err != nil { + if statusErr, ok := err.(*git.HTTPStatusError); ok { + response := httputil.NewError(apierror.Type(statusErr.Code), statusErr.Message, statusErr.StatusCode, map[string]any{"project_id": projectID, "repository": cfg.SrcRepo}, nil) + response.WriteLog(handler.logger) + return response.Write(ctx) + } + response := httputil.NewError(apierror.Type("integration_error"), fmt.Sprintf("failed to exchange GitHub token with Fence: %s", err), http.StatusBadGateway, map[string]any{"project_id": projectID, "repository": cfg.SrcRepo}, nil) + response.WriteLog(handler.logger) + return response.Write(ctx) + } + state.SyncState = git.GitSyncUpdating + state.LastError = sql.NullString{} + if err := geckodb.UpsertGitProjectState(handler.db, *state); err != nil { + response := httputil.NewError(apierror.TypeDatabaseError, fmt.Sprintf("failed to persist updating git state: %s", err), http.StatusInternalServerError, map[string]any{"project_id": projectID}, nil) + response.WriteLog(handler.logger) + return response.Write(ctx) + } + refreshResponse, updatedState, err := handler.gitService.RefreshProject(refreshCtx, projectID, identity, state, accessToken) + if err != nil { + state.SyncState = git.GitSyncError + state.LastError = sql.NullString{String: err.Error(), Valid: true} + _ = geckodb.UpsertGitProjectState(handler.db, *state) + response := httputil.NewError(apierror.Type("integration_error"), fmt.Sprintf("failed to update git checkout for %s/%s: %s", organization, project, err), http.StatusBadGateway, map[string]any{"project_id": projectID, "repository": cfg.SrcRepo}, nil) + response.WriteLog(handler.logger) + return response.Write(ctx) + } + if err := geckodb.UpsertGitProjectState(handler.db, *updatedState); err != nil { + response := httputil.NewError(apierror.TypeDatabaseError, fmt.Sprintf("failed to persist updated git state: %s", err), http.StatusInternalServerError, map[string]any{"project_id": projectID}, nil) + response.WriteLog(handler.logger) + return response.Write(ctx) + } + return httputil.JSON(refreshResponse, http.StatusOK).Write(ctx) +} diff --git a/internal/server/http/git/installation_test.go b/internal/server/http/git/installation_test.go new file mode 100644 index 0000000..fef4da9 --- /dev/null +++ b/internal/server/http/git/installation_test.go @@ -0,0 +1,443 @@ +package git + +import ( + "bytes" + "database/sql" + "encoding/json" + "io" + "log" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + "time" + + "github.com/DATA-DOG/go-sqlmock" + appconfig "github.com/calypr/gecko/config" + gitservice "github.com/calypr/gecko/internal/git" + intfence "github.com/calypr/gecko/internal/integrations/fence" + geckologging "github.com/calypr/gecko/internal/logging" + "github.com/calypr/gecko/internal/server/http/shared" + "github.com/gofiber/fiber/v3" + "github.com/jmoiron/sqlx" +) + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (fn roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return fn(req) +} + +func newGitHandlerTestServer(t *testing.T, fenceServer *httptest.Server, githubTransport http.RoundTripper) (*Handler, sqlmock.Sqlmock, func()) { + t.Helper() + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("failed to create sqlmock: %v", err) + } + logger := &geckologging.Handler{Logger: log.New(os.Stdout, "", 0)} + githubClient := &http.Client{Timeout: 5 * time.Second} + if githubTransport != nil { + githubClient.Transport = githubTransport + } + gitSvc := gitservice.NewGitService(gitservice.GitServiceConfig{ + DataDir: t.TempDir(), + GitHubAPIBase: "https://api.github.com", + HTTPClient: githubClient, + FenceClient: intfence.NewClient(fenceServer.Client(), intfence.Config{BaseURL: fenceServer.URL}), + }) + handler := &Handler{ + Handler: &shared.Handler{}, + db: sqlx.NewDb(db, "sqlmock"), + logger: logger, + gitService: gitSvc, + } + return handler, mock, func() { _ = db.Close() } +} + +func runGitRequest(t *testing.T, app *fiber.App, req *http.Request) *http.Response { + t.Helper() + resp, err := app.Test(req, fiber.TestConfig{Timeout: 0, FailOnTimeout: false}) + if err != nil { + t.Fatalf("fiber test request failed: %v", err) + } + return resp +} + +func TestGitOrganizationInitConnectReturnsRedirectWithoutRepository(t *testing.T) { + fenceServer := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + writer.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(writer).Encode(map[string]any{ + "install_url": "https://github.com/apps/calypr-github/installations/new?state=%2Fgit", + }) + })) + defer fenceServer.Close() + + handler, mock, cleanup := newGitHandlerTestServer(t, fenceServer, nil) + defer cleanup() + + app := fiber.New() + app.Post("/git/organizations/:orgTitle/init-connect", handler.handleGitOrganizationInitConnectPOST) + req := httptest.NewRequest(http.MethodPost, "/git/organizations/TEST/init-connect", nil) + req.Header.Set("Authorization", "Bearer test") + + resp := runGitRequest(t, app, req) + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + t.Fatalf("expected 200, got %d: %s", resp.StatusCode, body) + } + var payload gitservice.GitOrganizationConnectResponse + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + t.Fatalf("decode response: %v", err) + } + if payload.Mode != "redirect" { + t.Fatalf("expected redirect mode, got %+v", payload) + } + if payload.RedirectURL != "https://github.com/apps/calypr-github/installations/new?state=%2Fgit" { + t.Fatalf("unexpected redirect url: %q", payload.RedirectURL) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("unmet sql expectations: %v", err) + } +} + +func TestGitOrganizationInitConnectAppendsRepositorySuggestionWhenResolvable(t *testing.T) { + fenceServer := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + writer.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(writer).Encode(map[string]any{ + "install_url": "https://github.com/apps/calypr-github/installations/new?state=%2Fgit%2FTEST", + }) + })) + defer fenceServer.Close() + + githubTransport := roundTripFunc(func(request *http.Request) (*http.Response, error) { + if request.URL.Host != "api.github.com" || request.URL.Path != "/repos/EllrottLab/git_drs_test" { + t.Fatalf("unexpected github request: %s %s", request.Method, request.URL.String()) + } + body := `{"id":456,"owner":{"id":123}}` + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(body)), + }, nil + }) + + handler, mock, cleanup := newGitHandlerTestServer(t, fenceServer, githubTransport) + defer cleanup() + + app := fiber.New() + app.Post("/git/organizations/:orgTitle/init-connect", handler.handleGitOrganizationInitConnectPOST) + body := bytes.NewBufferString(`{"repository_full_name":"EllrottLab/git_drs_test"}`) + req := httptest.NewRequest(http.MethodPost, "/git/organizations/TEST/init-connect", body) + req.Header.Set("Authorization", "Bearer test") + req.Header.Set("Content-Type", "application/json") + + resp := runGitRequest(t, app, req) + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + payload, _ := io.ReadAll(resp.Body) + t.Fatalf("expected 200, got %d: %s", resp.StatusCode, payload) + } + var payload gitservice.GitOrganizationConnectResponse + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + t.Fatalf("decode response: %v", err) + } + if !strings.Contains(payload.RedirectURL, "/installations/new/permissions") { + t.Fatalf("expected permissions redirect, got %q", payload.RedirectURL) + } + if !strings.Contains(payload.RedirectURL, "suggested_target_id=123") || !strings.Contains(payload.RedirectURL, "repository_ids[]=456") { + t.Fatalf("expected redirect optimization params, got %q", payload.RedirectURL) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("unmet sql expectations: %v", err) + } +} + +func TestGitOrganizationInitConnectFallsBackToPlainRedirectWhenRepositoryLookupFails(t *testing.T) { + fenceServer := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + writer.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(writer).Encode(map[string]any{ + "install_url": "https://github.com/apps/calypr-github/installations/new?state=%2Fgit%2FTEST", + }) + })) + defer fenceServer.Close() + + githubTransport := roundTripFunc(func(request *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusNotFound, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"message":"Not Found"}`)), + }, nil + }) + + handler, mock, cleanup := newGitHandlerTestServer(t, fenceServer, githubTransport) + defer cleanup() + + app := fiber.New() + app.Post("/git/organizations/:orgTitle/init-connect", handler.handleGitOrganizationInitConnectPOST) + body := bytes.NewBufferString(`{"repository_full_name":"EllrottLab/git_drs_test"}`) + req := httptest.NewRequest(http.MethodPost, "/git/organizations/TEST/init-connect", body) + req.Header.Set("Authorization", "Bearer test") + req.Header.Set("Content-Type", "application/json") + + resp := runGitRequest(t, app, req) + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + payload, _ := io.ReadAll(resp.Body) + t.Fatalf("expected 200, got %d: %s", resp.StatusCode, payload) + } + var payload gitservice.GitOrganizationConnectResponse + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + t.Fatalf("decode response: %v", err) + } + if payload.RedirectURL != "https://github.com/apps/calypr-github/installations/new?state=%2Fgit%2FTEST" { + t.Fatalf("expected plain redirect url, got %q", payload.RedirectURL) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("unmet sql expectations: %v", err) + } +} + +func TestGitOrganizationInitConnectFallsBackToCleanOrgSettingsURLWhenRepositoryLookupFails(t *testing.T) { + fenceServer := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + var receivedBody map[string]any + if err := json.NewDecoder(request.Body).Decode(&receivedBody); err != nil { + t.Fatalf("decode fence request body: %v", err) + } + writer.Header().Set("Content-Type", "application/json") + switch receivedBody["action"] { + case "install_url": + _ = json.NewEncoder(writer).Encode(map[string]any{ + "install_url": "https://github.com/apps/calypr-github/installations/select_target?state=%2Fgit%2FTEST", + }) + case "organization_installation": + if receivedBody["owner"] != "EllrottLab" { + t.Fatalf("expected organization installation lookup for EllrottLab, got %#v", receivedBody["owner"]) + } + _ = json.NewEncoder(writer).Encode(map[string]any{ + "installed": true, + "organization": "EllrottLab", + "installation_id": 134470697, + "target": "EllrottLab", + "target_type": "Organization", + "html_url": "https://github.com/organizations/EllrottLab/settings/installations/134470697?repository_ids=", + "repository_selection": "selected", + }) + default: + t.Fatalf("unexpected fence action: %#v", receivedBody["action"]) + } + })) + defer fenceServer.Close() + + githubTransport := roundTripFunc(func(request *http.Request) (*http.Response, error) { + if request.URL.Host == "api.github.com" && request.URL.Path == "/repos/EllrottLab/git_drs_test" { + return &http.Response{ + StatusCode: http.StatusNotFound, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"message":"Not Found"}`)), + }, nil + } + t.Fatalf("unexpected github request: %s %s", request.Method, request.URL.String()) + return nil, nil + }) + + handler, mock, cleanup := newGitHandlerTestServer(t, fenceServer, githubTransport) + defer cleanup() + + app := fiber.New() + app.Post("/git/organizations/:orgTitle/init-connect", handler.handleGitOrganizationInitConnectPOST) + body := bytes.NewBufferString(`{"repository_full_name":"EllrottLab/git_drs_test"}`) + req := httptest.NewRequest(http.MethodPost, "/git/organizations/TEST/init-connect", body) + req.Header.Set("Authorization", "Bearer test") + req.Header.Set("Content-Type", "application/json") + + resp := runGitRequest(t, app, req) + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + payload, _ := io.ReadAll(resp.Body) + t.Fatalf("expected 200, got %d: %s", resp.StatusCode, payload) + } + var payload gitservice.GitOrganizationConnectResponse + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + t.Fatalf("decode response: %v", err) + } + if payload.RedirectURL != "https://github.com/organizations/EllrottLab/settings/installations/134470697" { + t.Fatalf("expected clean organization settings redirect when repo lookup fails, got %q", payload.RedirectURL) + } + if strings.Contains(payload.RedirectURL, "suggested_target_id=") || strings.Contains(payload.RedirectURL, "repository_ids") { + t.Fatalf("did not expect partial redirect optimization or empty repository_ids, got %q", payload.RedirectURL) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("unmet sql expectations: %v", err) + } +} + +func TestGitProjectEditConnectUpdatesProjectConfigAndState(t *testing.T) { + var receivedBody map[string]any + fenceServer := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if err := json.NewDecoder(request.Body).Decode(&receivedBody); err != nil { + t.Fatalf("decode fence request body: %v", err) + } + writer.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(writer).Encode(map[string]any{ + "installation_id": 42, + "repositories": []map[string]any{{ + "id": 101, + "name": "git_drs_test", + "full_name": "EllrottLab/git_drs_test", + "html_url": "https://github.com/EllrottLab/git_drs_test", + "clone_url": "https://github.com/EllrottLab/git_drs_test.git", + }}, + }) + })) + defer fenceServer.Close() + + handler, mock, cleanup := newGitHandlerTestServer(t, fenceServer, nil) + defer cleanup() + + projectCfg := appconfig.ProjectConfig{Title: "proj-a", OrgTitle: "TEST", ProjectTitle: "proj-a", SrcRepo: ""} + projectContent, err := json.Marshal(projectCfg) + if err != nil { + t.Fatalf("marshal project config: %v", err) + } + mock.ExpectQuery(`SELECT name, content FROM config_schema\.projects WHERE name=\$1`). + WithArgs("TEST/proj-a"). + WillReturnRows(sqlmock.NewRows([]string{"name", "content"}).AddRow("TEST/proj-a", projectContent)) + mock.ExpectQuery(`SELECT organization, installed, installation_id, installation_target_type, installation_target, html_url, repository_selection, configured_at, last_seen_at, updated_at, last_error FROM config_schema\.git_organization_state WHERE organization = \$1`). + WithArgs("TEST"). + WillReturnRows(sqlmock.NewRows([]string{"organization", "installed", "installation_id", "installation_target_type", "installation_target", "html_url", "repository_selection", "configured_at", "last_seen_at", "updated_at", "last_error"}). + AddRow("TEST", true, 42, "Organization", "EllrottLab", "", "selected", nil, nil, time.Now(), nil)) + updatedCfg := projectCfg + updatedCfg.SrcRepo = "https://github.com/EllrottLab/git_drs_test" + updatedContent, err := json.Marshal(&updatedCfg) + if err != nil { + t.Fatalf("marshal updated project config: %v", err) + } + mock.ExpectQuery(`SELECT project_id, repo_host, repo_owner, repo_name, installation_id, installation_target_type, installation_target, mirror_path, sync_state, default_branch, last_refreshed_at, last_error FROM config_schema\.git_project_state WHERE project_id = \$1`). + WithArgs("TEST/proj-a"). + WillReturnError(sql.ErrNoRows) + mock.ExpectBegin() + mock.ExpectExec(`INSERT INTO config_schema\.projects`). + WithArgs("TEST/proj-a", updatedContent). + WillReturnResult(sqlmock.NewResult(1, 1)) + mock.ExpectExec(`INSERT INTO config_schema\.git_project_state`).WillReturnResult(sqlmock.NewResult(1, 1)) + mock.ExpectCommit() + + app := fiber.New() + app.Post("/git/projects/:orgTitle/:projectTitle/edit-connect", handler.handleGitProjectEditConnectPOST) + body := bytes.NewBufferString(`{"repository_full_name":"EllrottLab/git_drs_test"}`) + req := httptest.NewRequest(http.MethodPost, "/git/projects/TEST/proj-a/edit-connect", body) + req.Header.Set("Authorization", "Bearer test") + req.Header.Set("Content-Type", "application/json") + + resp := runGitRequest(t, app, req) + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + payload, _ := io.ReadAll(resp.Body) + t.Fatalf("expected 200, got %d: %s", resp.StatusCode, payload) + } + if receivedBody["action"] != "installation_repositories" { + t.Fatalf("expected installation_repositories action, got %#v", receivedBody) + } + var payload gitservice.GitOrganizationConnectResponse + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + t.Fatalf("decode response: %v", err) + } + if payload.Mode != "connected" { + t.Fatalf("expected connected mode, got %+v", payload) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("unmet sql expectations: %v", err) + } +} + +func TestGitProjectEditConnectRejectsRepositoryOutsideInstallation(t *testing.T) { + fenceServer := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + writer.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(writer).Encode(map[string]any{ + "installation_id": 42, + "repositories": []map[string]any{{ + "id": 101, + "name": "other-repo", + "full_name": "EllrottLab/other-repo", + "html_url": "https://github.com/EllrottLab/other-repo", + "clone_url": "https://github.com/EllrottLab/other-repo.git", + }}, + }) + })) + defer fenceServer.Close() + + handler, mock, cleanup := newGitHandlerTestServer(t, fenceServer, nil) + defer cleanup() + + projectCfg := appconfig.ProjectConfig{Title: "proj-a", OrgTitle: "TEST", ProjectTitle: "proj-a", SrcRepo: ""} + projectContent, err := json.Marshal(projectCfg) + if err != nil { + t.Fatalf("marshal project config: %v", err) + } + mock.ExpectQuery(`SELECT name, content FROM config_schema\.projects WHERE name=\$1`). + WithArgs("TEST/proj-a"). + WillReturnRows(sqlmock.NewRows([]string{"name", "content"}).AddRow("TEST/proj-a", projectContent)) + mock.ExpectQuery(`SELECT organization, installed, installation_id, installation_target_type, installation_target, html_url, repository_selection, configured_at, last_seen_at, updated_at, last_error FROM config_schema\.git_organization_state WHERE organization = \$1`). + WithArgs("TEST"). + WillReturnRows(sqlmock.NewRows([]string{"organization", "installed", "installation_id", "installation_target_type", "installation_target", "html_url", "repository_selection", "configured_at", "last_seen_at", "updated_at", "last_error"}). + AddRow("TEST", true, 42, "Organization", "EllrottLab", "", "selected", nil, nil, time.Now(), nil)) + + app := fiber.New() + app.Post("/git/projects/:orgTitle/:projectTitle/edit-connect", handler.handleGitProjectEditConnectPOST) + body := bytes.NewBufferString(`{"repository_full_name":"EllrottLab/git_drs_test"}`) + req := httptest.NewRequest(http.MethodPost, "/git/projects/TEST/proj-a/edit-connect", body) + req.Header.Set("Authorization", "Bearer test") + req.Header.Set("Content-Type", "application/json") + + resp := runGitRequest(t, app, req) + defer resp.Body.Close() + if resp.StatusCode != http.StatusConflict { + payload, _ := io.ReadAll(resp.Body) + t.Fatalf("expected 409, got %d: %s", resp.StatusCode, payload) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("unmet sql expectations: %v", err) + } +} + +func TestGitProjectEditConnectRequiresConnectedOrganization(t *testing.T) { + fenceServer := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + writer.WriteHeader(http.StatusInternalServerError) + })) + defer fenceServer.Close() + + handler, mock, cleanup := newGitHandlerTestServer(t, fenceServer, nil) + defer cleanup() + + projectCfg := appconfig.ProjectConfig{Title: "proj-a", OrgTitle: "TEST", ProjectTitle: "proj-a", SrcRepo: ""} + projectContent, err := json.Marshal(projectCfg) + if err != nil { + t.Fatalf("marshal project config: %v", err) + } + mock.ExpectQuery(`SELECT name, content FROM config_schema\.projects WHERE name=\$1`). + WithArgs("TEST/proj-a"). + WillReturnRows(sqlmock.NewRows([]string{"name", "content"}).AddRow("TEST/proj-a", projectContent)) + mock.ExpectQuery(`SELECT organization, installed, installation_id, installation_target_type, installation_target, html_url, repository_selection, configured_at, last_seen_at, updated_at, last_error FROM config_schema\.git_organization_state WHERE organization = \$1`). + WithArgs("TEST"). + WillReturnRows(sqlmock.NewRows([]string{"organization", "installed", "installation_id", "installation_target_type", "installation_target", "html_url", "repository_selection", "configured_at", "last_seen_at", "updated_at", "last_error"}). + AddRow("TEST", false, nil, nil, nil, nil, nil, nil, nil, time.Now(), nil)) + + app := fiber.New() + app.Post("/git/projects/:orgTitle/:projectTitle/edit-connect", handler.handleGitProjectEditConnectPOST) + body := bytes.NewBufferString(`{"repository_full_name":"EllrottLab/git_drs_test"}`) + req := httptest.NewRequest(http.MethodPost, "/git/projects/TEST/proj-a/edit-connect", body) + req.Header.Set("Authorization", "Bearer test") + req.Header.Set("Content-Type", "application/json") + + resp := runGitRequest(t, app, req) + defer resp.Body.Close() + if resp.StatusCode != http.StatusConflict { + payload, _ := io.ReadAll(resp.Body) + t.Fatalf("expected 409, got %d: %s", resp.StatusCode, payload) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("unmet sql expectations: %v", err) + } +} diff --git a/internal/server/http/git/project.go b/internal/server/http/git/project.go new file mode 100644 index 0000000..2ce4be7 --- /dev/null +++ b/internal/server/http/git/project.go @@ -0,0 +1,435 @@ +package git + +import ( + "context" + "database/sql" + "errors" + "fmt" + "net/http" + "os" + "sort" + "strings" + "time" + + "github.com/calypr/gecko/apierror" + appconfig "github.com/calypr/gecko/config" + geckodb "github.com/calypr/gecko/internal/db" + "github.com/calypr/gecko/internal/git" + "github.com/calypr/gecko/internal/httputil" + servermw "github.com/calypr/gecko/internal/server/middleware" + "github.com/gofiber/fiber/v3" +) + +func filterProjectIDsByAllowedResources(projectIDs []string, allowedResources []string) []string { + if len(allowedResources) == 0 { + return []string{} + } + filtered := make([]string, 0, len(projectIDs)) + for _, projectID := range projectIDs { + parts := strings.SplitN(projectID, "/", 2) + if len(parts) != 2 { + continue + } + projectParts := strings.SplitN(parts[1], "/", 2) + if len(projectParts) != 1 || projectParts[0] == "" { + continue + } + if servermw.GitProjectReadable(allowedResources, parts[0], projectParts[0]) { + filtered = append(filtered, projectID) + } + } + sort.Strings(filtered) + return filtered +} + +func organizationAllowedByResources(organization string, allowedResources []string) bool { + return servermw.ResourceListAllowsOrganization(allowedResources, organization) +} + +func (handler *Handler) resolveGitProject(ctx fiber.Ctx) (string, string, string, appconfig.ProjectConfig, git.GitRepositoryIdentity, *httputil.ErrorResponse) { + organization := strings.TrimSpace(ctx.Params("orgTitle")) + project := strings.TrimSpace(ctx.Params("projectTitle")) + if organization == "" || project == "" { + response := httputil.NewError("invalid_request", "organization and project are required", http.StatusBadRequest, nil, nil) + response.WriteLog(handler.logger) + return "", "", "", appconfig.ProjectConfig{}, git.GitRepositoryIdentity{}, response + } + projectID := organization + "/" + project + var cfg appconfig.ProjectConfig + if err := geckodb.ConfigGETGeneric(handler.db, projectID, string(appconfig.TypeProjects), &cfg); err != nil { + if errors.Is(err, sql.ErrNoRows) { + response := httputil.NewError("not_found", fmt.Sprintf("no project config found for %s", projectID), http.StatusNotFound, map[string]any{"project_id": projectID}, nil) + response.WriteLog(handler.logger) + return "", "", "", appconfig.ProjectConfig{}, git.GitRepositoryIdentity{}, response + } + response := httputil.NewError("database_error", fmt.Sprintf("failed to load project config: %s", err), http.StatusInternalServerError, map[string]any{"project_id": projectID}, nil) + response.WriteLog(handler.logger) + return "", "", "", appconfig.ProjectConfig{}, git.GitRepositoryIdentity{}, response + } + identity, err := git.ParseRepositoryIdentity(cfg.SrcRepo) + if err != nil { + response := httputil.NewError("validation_failed", fmt.Sprintf("invalid src_repo for %s: %s", projectID, err), http.StatusBadRequest, map[string]any{"project_id": projectID, "src_repo": cfg.SrcRepo}, nil) + response.WriteLog(handler.logger) + return "", "", "", appconfig.ProjectConfig{}, git.GitRepositoryIdentity{}, response + } + return organization, project, projectID, cfg, identity, nil +} + +func (handler *Handler) loadGitProjectState(projectID string, identity git.GitRepositoryIdentity) (*geckodb.GitProjectState, error) { + state, err := geckodb.GitProjectStateByProjectID(handler.db, projectID) + if err != nil || state == nil { + return state, err + } + expectedMirrorPath := handler.gitService.MirrorPathForIdentity(identity) + if state.RepoHost == identity.Host && + state.RepoOwner == identity.Owner && + state.RepoName == identity.Repo && + state.MirrorPath == expectedMirrorPath { + return state, nil + } + state.RepoHost = identity.Host + state.RepoOwner = identity.Owner + state.RepoName = identity.Repo + state.MirrorPath = expectedMirrorPath + if err := geckodb.UpsertGitProjectState(handler.db, *state); err != nil { + return nil, fmt.Errorf("persist git project state mirror path: %w", err) + } + return state, nil +} + +func (handler *Handler) ensureMirrorReadyForRead(ctx context.Context, authorizationHeader string, projectID string, identity git.GitRepositoryIdentity, state *geckodb.GitProjectState) (*geckodb.GitProjectState, error) { + if state == nil || !state.InstallationID.Valid { + return state, nil + } + if strings.TrimSpace(state.MirrorPath) == "" { + return state, nil + } + if _, err := os.Stat(state.MirrorPath); err == nil { + return state, nil + } + org, project, _ := strings.Cut(projectID, "/") + accessToken, err := handler.gitService.RequestInstallationToken(ctx, authorizationHeader, org, project, identity, "read") + if err != nil { + state.SyncState = git.GitSyncError + state.LastError = sql.NullString{String: err.Error(), Valid: true} + _ = geckodb.UpsertGitProjectState(handler.db, *state) + return state, err + } + _, updatedState, err := handler.gitService.RefreshProject(ctx, projectID, identity, state, accessToken) + if err != nil { + state.SyncState = git.GitSyncError + state.LastError = sql.NullString{String: err.Error(), Valid: true} + _ = geckodb.UpsertGitProjectState(handler.db, *state) + return state, err + } + if err := geckodb.UpsertGitProjectState(handler.db, *updatedState); err != nil { + return nil, fmt.Errorf("persist refreshed git project state: %w", err) + } + return updatedState, nil +} + +func (handler *Handler) handleGitProjectsGET(ctx fiber.Ctx) error { + states, err := geckodb.ListGitProjectStates(handler.db) + if err != nil { + response := httputil.NewError("database_error", fmt.Sprintf("failed to list git state: %s", err), http.StatusInternalServerError, nil, nil) + response.WriteLog(handler.logger) + return response.Write(ctx) + } + projectIDs, err := geckodb.ConfigListByType(handler.db, string(appconfig.TypeProjects)) + if err != nil { + response := httputil.NewError("database_error", fmt.Sprintf("failed to list project configs: %s", err), http.StatusInternalServerError, nil, nil) + response.WriteLog(handler.logger) + return response.Write(ctx) + } + allowedResources, _ := gitAllowedReadResources(strings.TrimSpace(ctx.Get("Authorization"))) + projectIDs = filterProjectIDsByAllowedResources(projectIDs, allowedResources) + responses := make([]git.GitProjectStatusResponse, 0, len(projectIDs)) + for _, projectID := range projectIDs { + parts := strings.SplitN(projectID, "/", 2) + if len(parts) != 2 { + continue + } + var cfg appconfig.ProjectConfig + if err := geckodb.ConfigGETGeneric(handler.db, projectID, string(appconfig.TypeProjects), &cfg); err != nil { + continue + } + identity, err := git.ParseRepositoryIdentity(cfg.SrcRepo) + if err != nil { + continue + } + var statePtr *geckodb.GitProjectState + if state, ok := states[projectID]; ok { + copyState := state + statePtr = ©State + } + orgState, _ := geckodb.GitOrganizationStateByOrganization(handler.db, parts[0]) + status := handler.gitService.StatusFromState(projectID, parts[0], parts[1], cfg, identity, statePtr, orgState) + if len(allowedResources) > 0 { + status.Accessible = servermw.GitProjectReadable(allowedResources, parts[0], parts[1]) + status.RequestAccess = !status.Accessible + status.RequestAccessResourcePath = git.ProgramProjectResourcePath(parts[0], parts[1]) + } + responses = append(responses, status) + } + return httputil.JSON(responses, http.StatusOK).Write(ctx) +} + +func (handler *Handler) handleGitProjectGET(ctx fiber.Ctx) error { + organization := strings.TrimSpace(ctx.Params("orgTitle")) + project := strings.TrimSpace(ctx.Params("projectTitle")) + if organization == "" || project == "" { + response := httputil.NewError("invalid_request", "organization and project are required", http.StatusBadRequest, nil, nil) + response.WriteLog(handler.logger) + return response.Write(ctx) + } + projectID := organization + "/" + project + var cfg appconfig.ProjectConfig + if err := geckodb.ConfigGETGeneric(handler.db, projectID, string(appconfig.TypeProjects), &cfg); err != nil { + if errors.Is(err, sql.ErrNoRows) { + response := httputil.NewError("not_found", fmt.Sprintf("no project config found for %s", projectID), http.StatusNotFound, map[string]any{"project_id": projectID}, nil) + response.WriteLog(handler.logger) + return response.Write(ctx) + } + response := httputil.NewError("database_error", fmt.Sprintf("failed to load project config: %s", err), http.StatusInternalServerError, map[string]any{"project_id": projectID}, nil) + response.WriteLog(handler.logger) + return response.Write(ctx) + } + identity, identityErr := git.ParseRepositoryIdentity(cfg.SrcRepo) + if identityErr != nil { + orgState, _ := geckodb.GitOrganizationStateByOrganization(handler.db, organization) + status := handler.gitService.StatusFromState(projectID, organization, project, cfg, git.GitRepositoryIdentity{}, nil, orgState) + status.Accessible = true + return httputil.JSON(status, http.StatusOK).Write(ctx) + } + state, err := handler.loadGitProjectState(projectID, identity) + if err != nil { + response := httputil.NewError("database_error", fmt.Sprintf("failed to read git state: %s", err), http.StatusInternalServerError, map[string]any{"project_id": projectID}, nil) + response.WriteLog(handler.logger) + return response.Write(ctx) + } + authorizationHeader := strings.TrimSpace(ctx.Get("Authorization")) + if authorizationHeader != "" { + refreshCtx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + state, err = handler.ensureMirrorReadyForRead(refreshCtx, authorizationHeader, projectID, identity, state) + if err != nil { + handler.logger.Warning("failed to warm git mirror for %s: %v", projectID, err) + } + } + orgState, _ := geckodb.GitOrganizationStateByOrganization(handler.db, organization) + status := handler.gitService.StatusFromState(projectID, organization, project, cfg, identity, state, orgState) + status.Accessible = true + return httputil.JSON(status, http.StatusOK).Write(ctx) +} + +func (handler *Handler) handleGitOrganizationStatusGET(ctx fiber.Ctx) error { + organization := strings.TrimSpace(ctx.Params("orgTitle")) + if organization == "" { + response := httputil.NewError("invalid_request", "organization is required", http.StatusBadRequest, nil, nil) + response.WriteLog(handler.logger) + return response.Write(ctx) + } + projectIDs, err := geckodb.ConfigListByType(handler.db, string(appconfig.TypeProjects)) + if err != nil { + response := httputil.NewError("database_error", fmt.Sprintf("failed to list project configs: %s", err), http.StatusInternalServerError, map[string]any{"organization": organization}, nil) + response.WriteLog(handler.logger) + return response.Write(ctx) + } + authorizationHeader := strings.TrimSpace(ctx.Get("Authorization")) + statusAccess, errResponse := gitStatusAccessSnapshot(authorizationHeader) + if errResponse != nil { + errResponse.WriteLog(handler.logger) + return errResponse.Write(ctx) + } + allowedResources := statusAccess.readableResources + if !organizationAllowedByResources(organization, allowedResources) { + response := httputil.NewError("forbidden", fmt.Sprintf("User is not allowed to read organization %s", organization), http.StatusForbidden, map[string]any{"organization": organization, "method": "read"}, nil) + response.WriteLog(handler.logger) + return response.Write(ctx) + } + responsePayload, err := handler.projectSync.BuildSingleOrganizationStatus(context.Background(), authorizationHeader, organization, projectIDs, allowedResources) + if err != nil { + return writeAppError(ctx, handler.logger, err) + } + applyOrganizationStatusCapabilities(&responsePayload, statusAccess.snapshot) + return httputil.JSON(responsePayload, http.StatusOK).Write(ctx) +} + +func (handler *Handler) handleGitOrganizationsStatusGET(ctx fiber.Ctx) error { + projectIDs, err := geckodb.ConfigListByType(handler.db, string(appconfig.TypeProjects)) + if err != nil { + response := httputil.NewError("database_error", fmt.Sprintf("failed to list project configs: %s", err), http.StatusInternalServerError, nil, nil) + response.WriteLog(handler.logger) + return response.Write(ctx) + } + authorizationHeader := strings.TrimSpace(ctx.Get("Authorization")) + statusAccess, errResponse := gitStatusAccessSnapshot(authorizationHeader) + if errResponse != nil { + errResponse.WriteLog(handler.logger) + return errResponse.Write(ctx) + } + allowedResources := statusAccess.readableResources + responsePayload, err := handler.projectSync.BuildOrganizationsStatus(context.Background(), authorizationHeader, projectIDs, allowedResources) + if err != nil { + return writeAppError(ctx, handler.logger, err) + } + for i := range responsePayload.Organizations { + applyOrganizationStatusCapabilities(&responsePayload.Organizations[i], statusAccess.snapshot) + } + return httputil.JSON(responsePayload, http.StatusOK).Write(ctx) +} + +func gitAllowedReadResources(token string) ([]string, *httputil.ErrorResponse) { + if token == "" { + return nil, nil + } + resources, err := servermw.GitAllowedResources(servermw.NewFenceUserAccessHandler(nil), token, "read") + if err != nil { + return nil, err + } + return resources, nil +} + +type gitStatusAccess struct { + readableResources []string + snapshot servermw.ResourceAccessSnapshot +} + +func gitStatusAccessSnapshot(token string) (*gitStatusAccess, *httputil.ErrorResponse) { + if token == "" { + return &gitStatusAccess{}, nil + } + authzHandler := servermw.NewFenceUserAccessHandler(nil) + snapshot, err := authzHandler.GetResourceAccess(token) + if err != nil { + if serverErr, ok := err.(*servermw.AccessError); ok { + return nil, httputil.NewError(gitStatusServiceErrorType(serverErr.StatusCode), serverErr.Message, serverErr.StatusCode, nil, nil) + } + return nil, httputil.NewError("authorization_service_error", fmt.Sprintf("authorization lookup failed: %s", err), http.StatusForbidden, nil, nil) + } + readableResources := make([]string, 0, len(snapshot)) + for resourcePath := range snapshot { + if servermw.ResourceAccessAllows(snapshot, resourcePath, "read", "*") { + readableResources = append(readableResources, resourcePath) + } + } + sort.Strings(readableResources) + return &gitStatusAccess{ + readableResources: readableResources, + snapshot: snapshot, + }, nil +} + +func gitStatusServiceErrorType(code int) apierror.Type { + switch code { + case http.StatusUnauthorized: + return apierror.TypeUnauthorized + case http.StatusForbidden: + return apierror.TypeForbidden + case http.StatusNotFound: + return apierror.TypeNotFound + case http.StatusMethodNotAllowed: + return apierror.TypeMethodNotAllowed + default: + return apierror.TypeAuthorizationServiceError + } +} + +func applyOrganizationStatusCapabilities(response *git.GitOrganizationStatusResponse, snapshot servermw.ResourceAccessSnapshot) { + if response == nil { + return + } + response.CanAccessSettings = true + orgResource := fmt.Sprintf("/programs/%s", response.Organization) + orgProjectsResource := fmt.Sprintf("/programs/%s/projects", response.Organization) + response.CanCreateProjects = servermw.ResourceAccessAllows(snapshot, orgProjectsResource, "create-descendant", "arborist") + response.CanManagePeople = servermw.ResourceAccessAllows(snapshot, orgResource, "manage-owners", "arborist") + response.CanDeleteOrg = response.CanManagePeople || servermw.ResourceAccessAllows(snapshot, orgResource, "delete", "*") + for i := range response.Projects { + response.Projects[i].CanManageSettings = servermw.ResourceAccessAllows(snapshot, response.Projects[i].ResourcePath, "update", "*") + } +} + +func (handler *Handler) handleGitOrganizationReconcilePOST(ctx fiber.Ctx) error { + organization := strings.TrimSpace(ctx.Params("orgTitle")) + if organization == "" { + response := httputil.NewError("invalid_request", "organization is required", http.StatusBadRequest, nil, nil) + response.WriteLog(handler.logger) + return response.Write(ctx) + } + authorizationHeader, tokenErr := servermw.ValidateAuthorizationHeader(ctx.Get("Authorization")) + if tokenErr != nil { + response := httputil.NewError("missing_authorization", tokenErr.Error(), http.StatusUnauthorized, nil, nil) + response.WriteLog(handler.logger) + return response.Write(ctx) + } + statusAccess, errResponse := gitStatusAccessSnapshot(authorizationHeader) + if errResponse != nil { + errResponse.WriteLog(handler.logger) + return errResponse.Write(ctx) + } + allowedResources := statusAccess.readableResources + if !organizationAllowedByResources(organization, allowedResources) { + response := httputil.NewError("forbidden", fmt.Sprintf("User is not allowed to read organization %s", organization), http.StatusForbidden, map[string]any{"organization": organization, "method": "read"}, nil) + response.WriteLog(handler.logger) + return response.Write(ctx) + } + projectIDs, err := geckodb.ConfigListByType(handler.db, string(appconfig.TypeProjects)) + if err != nil { + response := httputil.NewError("database_error", fmt.Sprintf("failed to list project configs: %s", err), http.StatusInternalServerError, map[string]any{"organization": organization}, nil) + response.WriteLog(handler.logger) + return response.Write(ctx) + } + reconcileCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + if err := handler.projectSync.ReconcileOrganization(reconcileCtx, organization, authorizationHeader, projectIDs); err != nil { + return writeAppError(ctx, handler.logger, err) + } + statusAccess, errResponse = gitStatusAccessSnapshot(authorizationHeader) + if errResponse != nil { + errResponse.WriteLog(handler.logger) + return errResponse.Write(ctx) + } + allowedResources = statusAccess.readableResources + responsePayload, err := handler.projectSync.BuildSingleOrganizationStatus(context.Background(), authorizationHeader, organization, projectIDs, allowedResources) + if err != nil { + return writeAppError(ctx, handler.logger, err) + } + applyOrganizationStatusCapabilities(&responsePayload, statusAccess.snapshot) + return httputil.JSON(responsePayload, http.StatusOK).Write(ctx) +} + +func (handler *Handler) handleGitOrganizationsReconcilePOST(ctx fiber.Ctx) error { + authorizationHeader, tokenErr := servermw.ValidateAuthorizationHeader(ctx.Get("Authorization")) + if tokenErr != nil { + response := httputil.NewError("missing_authorization", tokenErr.Error(), http.StatusUnauthorized, nil, nil) + response.WriteLog(handler.logger) + return response.Write(ctx) + } + projectIDs, err := geckodb.ConfigListByType(handler.db, string(appconfig.TypeProjects)) + if err != nil { + response := httputil.NewError("database_error", fmt.Sprintf("failed to list project configs: %s", err), http.StatusInternalServerError, nil, nil) + response.WriteLog(handler.logger) + return response.Write(ctx) + } + statusAccess, errResponse := gitStatusAccessSnapshot(authorizationHeader) + if errResponse != nil { + errResponse.WriteLog(handler.logger) + return errResponse.Write(ctx) + } + allowedResources := statusAccess.readableResources + projectIDs = filterProjectIDsByAllowedResources(projectIDs, allowedResources) + reconcileCtx, cancel := context.WithTimeout(context.Background(), 45*time.Second) + defer cancel() + if err := handler.projectSync.ReconcileOrganizations(reconcileCtx, authorizationHeader, projectIDs); err != nil { + return writeAppError(ctx, handler.logger, err) + } + responsePayload, err := handler.projectSync.BuildOrganizationsStatus(context.Background(), authorizationHeader, projectIDs, allowedResources) + if err != nil { + return writeAppError(ctx, handler.logger, err) + } + for i := range responsePayload.Organizations { + applyOrganizationStatusCapabilities(&responsePayload.Organizations[i], statusAccess.snapshot) + } + return httputil.JSON(responsePayload, http.StatusOK).Write(ctx) +} diff --git a/internal/server/http/git/register.go b/internal/server/http/git/register.go new file mode 100644 index 0000000..4571184 --- /dev/null +++ b/internal/server/http/git/register.go @@ -0,0 +1,43 @@ +package git + +import ( + "github.com/calypr/gecko/internal/server/http/shared" + servermw "github.com/calypr/gecko/internal/server/middleware" + "github.com/gofiber/fiber/v3" +) + +func RegisterRoutes(app *fiber.App, sharedHandler *shared.Handler, authzHandler servermw.ResourceAccessHandler) { + handler := NewHandler(sharedHandler) + if handler.GitService == nil { + return + } + gitGroup := app.Group("/git") + gitGroup.Get("/projects", servermw.RequireAuthorization(handler.Logger), handler.handleGitProjectsGET) + gitGroup.Get("/organizations/status", servermw.RequireAuthorization(handler.Logger), handler.handleGitOrganizationsStatusGET) + gitGroup.Post("/organizations/reconcile", servermw.RequireAuthorization(handler.Logger), handler.handleGitOrganizationsReconcilePOST) + gitGroup.Post("/organizations/:orgTitle/init-connect", servermw.RequireAuthorization(handler.Logger), handler.handleGitOrganizationInitConnectPOST) + gitGroup.Post("/organizations/:orgTitle/connect", servermw.RequireAuthorization(handler.Logger), handler.handleGitOrganizationConnectPOST) + gitGroup.Get("/organizations/:orgTitle/status", servermw.GitOrganizationAuth(handler.Logger, authzHandler), handler.handleGitOrganizationStatusGET) + gitGroup.Post("/organizations/:orgTitle/reconcile", servermw.GitOrganizationAuth(handler.Logger, authzHandler), handler.handleGitOrganizationReconcilePOST) + + projectReadAuth := servermw.GitProjectAuth(handler.Logger, authzHandler) + gitGroup.Get("/projects/:orgTitle/:projectTitle", projectReadAuth, handler.handleGitProjectGET) + gitGroup.Get("/projects/:orgTitle/:projectTitle/refs", projectReadAuth, handler.handleGitProjectRefsGET) + gitGroup.Get("/projects/:orgTitle/:projectTitle/tree", projectReadAuth, handler.handleGitProjectTreeGET) + gitGroup.Get("/projects/:orgTitle/:projectTitle/tree/*", projectReadAuth, handler.handleGitProjectTreeGET) + gitGroup.Get("/projects/:orgTitle/:projectTitle/file/*", projectReadAuth, handler.handleGitProjectFileGET) + gitGroup.Get("/projects/:orgTitle/:projectTitle/download/*", projectReadAuth, handler.handleGitProjectDownloadGET) + gitGroup.Get("/projects/:orgTitle/:projectTitle/thumbnail", handler.handleGitProjectThumbnailGET) + + projectGitWrite := gitGroup.Group("/projects/:orgTitle/:projectTitle", servermw.RequireAuthorization(handler.Logger)) + projectGitWrite.Put("/setup", handler.handleCalyprProjectSetupPUT) + projectGitWrite.Put("/storage", handler.handleCalyprProjectStoragePUT) + projectGitWrite.Put("/thumbnail", handler.handleGitProjectThumbnailPUT) + projectGitWrite.Delete("/thumbnail", handler.handleGitProjectThumbnailDELETE) + projectGitWrite.Post("/edit-connect", handler.handleGitProjectEditConnectPOST) + projectGitWrite.Post("/update", handler.handleGitProjectUpdatePOST) + projectGitWrite.Post("/uploads/session", handler.handleGitProjectUploadSessionPOST) + projectGitWrite.Get("/uploads/session/:sessionID", handler.handleGitProjectUploadSessionGET) + projectGitWrite.Post("/uploads/session/:sessionID/files", handler.handleGitProjectUploadSessionFilesPOST) + projectGitWrite.Post("/uploads/session/:sessionID/finalize", handler.handleGitProjectUploadSessionFinalizePOST) +} diff --git a/internal/server/http/git/repository.go b/internal/server/http/git/repository.go new file mode 100644 index 0000000..7863777 --- /dev/null +++ b/internal/server/http/git/repository.go @@ -0,0 +1,180 @@ +package git + +import ( + "context" + "fmt" + "net/http" + "strings" + "time" + + "github.com/calypr/gecko/apierror" + "github.com/calypr/gecko/internal/git" + "github.com/calypr/gecko/internal/httputil" + servermw "github.com/calypr/gecko/internal/server/middleware" + "github.com/gofiber/fiber/v3" +) + +func (handler *Handler) handleGitProjectRefsGET(ctx fiber.Ctx) error { + _, _, projectID, _, identity, errResponse := handler.resolveGitProject(ctx) + if errResponse != nil { + return errResponse.Write(ctx) + } + state, err := handler.loadGitProjectState(projectID, identity) + if err != nil { + response := httputil.NewError("database_error", fmt.Sprintf("failed to read git state: %s", err), http.StatusInternalServerError, map[string]any{"project_id": projectID}, nil) + response.WriteLog(handler.logger) + return response.Write(ctx) + } + if state == nil || state.MirrorPath == "" { + response := httputil.NewError("conflict", fmt.Sprintf("project %s has not been refreshed yet", projectID), http.StatusConflict, map[string]any{"project_id": projectID}, nil) + response.WriteLog(handler.logger) + return response.Write(ctx) + } + authorizationHeader := strings.TrimSpace(ctx.Get("Authorization")) + if authorizationHeader != "" { + refreshCtx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + state, err = handler.ensureMirrorReadyForRead(refreshCtx, authorizationHeader, projectID, identity, state) + if err != nil { + handler.logger.Warning("failed to warm git mirror for %s refs: %v", projectID, err) + } + } + repo, err := git.OpenRepository(state.MirrorPath) + if err != nil { + response := httputil.NewError("integration_error", fmt.Sprintf("failed to open git mirror: %s", err), http.StatusBadGateway, map[string]any{"project_id": projectID}, nil) + response.WriteLog(handler.logger) + return response.Write(ctx) + } + refsResponse, err := git.BuildGitRefsResponse(projectID, state.DefaultBranch.String, repo) + if err != nil { + response := httputil.NewError("integration_error", fmt.Sprintf("failed to read git refs: %s", err), http.StatusBadGateway, map[string]any{"project_id": projectID}, nil) + response.WriteLog(handler.logger) + return response.Write(ctx) + } + return httputil.JSON(refsResponse, http.StatusOK).Write(ctx) +} + +func (handler *Handler) handleGitProjectTreeGET(ctx fiber.Ctx) error { + _, _, projectID, _, identity, errResponse := handler.resolveGitProject(ctx) + if errResponse != nil { + return errResponse.Write(ctx) + } + state, err := handler.loadGitProjectState(projectID, identity) + if err != nil { + response := httputil.NewError("database_error", fmt.Sprintf("failed to read git state: %s", err), http.StatusInternalServerError, map[string]any{"project_id": projectID}, nil) + response.WriteLog(handler.logger) + return response.Write(ctx) + } + if state == nil || state.MirrorPath == "" { + response := httputil.NewError("conflict", fmt.Sprintf("project %s has not been refreshed yet", projectID), http.StatusConflict, map[string]any{"project_id": projectID}, nil) + response.WriteLog(handler.logger) + return response.Write(ctx) + } + authorizationHeader := strings.TrimSpace(ctx.Get("Authorization")) + if authorizationHeader != "" { + refreshCtx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + state, err = handler.ensureMirrorReadyForRead(refreshCtx, authorizationHeader, projectID, identity, state) + if err != nil { + handler.logger.Warning("failed to warm git mirror for %s tree: %v", projectID, err) + } + } + repo, err := git.OpenRepository(state.MirrorPath) + if err != nil { + response := httputil.NewError("integration_error", fmt.Sprintf("failed to open git mirror: %s", err), http.StatusBadGateway, map[string]any{"project_id": projectID}, nil) + response.WriteLog(handler.logger) + return response.Write(ctx) + } + if git.RepositoryIsEmpty(repo) { + refName := strings.TrimSpace(ctx.Query("ref")) + if refName == "" { + refName = state.DefaultBranch.String + } + return httputil.JSON(&git.GitProjectTreeResponse{ + ProjectID: projectID, + Ref: refName, + Path: strings.Trim(ctx.Params("*"), "/"), + Entries: []git.GitTreeEntry{}, + }, http.StatusOK).Write(ctx) + } + refName, hash, err := git.ResolveGitReference(repo, strings.TrimSpace(ctx.Query("ref")), state.DefaultBranch.String) + if err != nil { + response := httputil.NewError("not_found", fmt.Sprintf("failed to resolve git ref: %s", err), http.StatusNotFound, map[string]any{"project_id": projectID, "ref": ctx.Query("ref")}, nil) + response.WriteLog(handler.logger) + return response.Write(ctx) + } + path := strings.Trim(ctx.Params("*"), "/") + treeResponse, err := git.BuildGitTreeResponse(projectID, refName, path, repo, hash) + if err != nil { + response := httputil.NewError("not_found", fmt.Sprintf("failed to read git tree: %s", err), http.StatusNotFound, map[string]any{"project_id": projectID, "ref": refName, "path": path}, nil) + response.WriteLog(handler.logger) + return response.Write(ctx) + } + return httputil.JSON(treeResponse, http.StatusOK).Write(ctx) +} + +func (handler *Handler) handleGitProjectFileGET(ctx fiber.Ctx) error { + organization, project, projectID, _, identity, errResponse := handler.resolveGitProject(ctx) + if errResponse != nil { + return errResponse.Write(ctx) + } + authorizationHeader, tokenErr := servermw.ValidateAuthorizationHeader(ctx.Get("Authorization")) + if tokenErr != nil { + response := httputil.NewError("missing_authorization", tokenErr.Error(), http.StatusUnauthorized, map[string]any{"project_id": projectID}, nil) + response.WriteLog(handler.logger) + return response.Write(ctx) + } + path := strings.Trim(ctx.Params("*"), "/") + requestedRef := strings.TrimSpace(ctx.Query("ref")) + metadata, contentBytes, err := handler.gitService.GetGitHubFileMetadata(ctx.Context(), authorizationHeader, organization, project, identity, requestedRef, path) + if err != nil { + statusCode := http.StatusNotFound + code := "not_found" + message := fmt.Sprintf("failed to read git file: %s", err) + if statusErr, ok := err.(*git.HTTPStatusError); ok { + statusCode = statusErr.StatusCode + code = statusErr.Code + message = statusErr.Message + } + response := httputil.NewError(apierror.Type(code), message, statusCode, map[string]any{"project_id": projectID, "ref": requestedRef, "path": path}, nil) + response.WriteLog(handler.logger) + return response.Write(ctx) + } + fileResponse := git.BuildGitHubFileResponse(projectID, requestedRef, path, metadata, contentBytes) + return httputil.JSON(fileResponse, http.StatusOK).Write(ctx) +} + +func (handler *Handler) handleGitProjectDownloadGET(ctx fiber.Ctx) error { + organization, project, projectID, _, identity, errResponse := handler.resolveGitProject(ctx) + if errResponse != nil { + return errResponse.Write(ctx) + } + authorizationHeader, tokenErr := servermw.ValidateAuthorizationHeader(ctx.Get("Authorization")) + if tokenErr != nil { + response := httputil.NewError("missing_authorization", tokenErr.Error(), http.StatusUnauthorized, map[string]any{"project_id": projectID}, nil) + response.WriteLog(handler.logger) + return response.Write(ctx) + } + path := strings.Trim(ctx.Params("*"), "/") + requestedRef := strings.TrimSpace(ctx.Query("ref")) + metadata, _, err := handler.gitService.GetGitHubFileMetadata(ctx.Context(), authorizationHeader, organization, project, identity, requestedRef, path) + if err != nil { + statusCode := http.StatusNotFound + code := "not_found" + message := fmt.Sprintf("failed to download git file: %s", err) + if statusErr, ok := err.(*git.HTTPStatusError); ok { + statusCode = statusErr.StatusCode + code = statusErr.Code + message = statusErr.Message + } + response := httputil.NewError(apierror.Type(code), message, statusCode, map[string]any{"project_id": projectID, "ref": requestedRef, "path": path}, nil) + response.WriteLog(handler.logger) + return response.Write(ctx) + } + if metadata == nil || strings.TrimSpace(metadata.GetDownloadURL()) == "" { + response := httputil.NewError("integration_error", "github download url is unavailable for this file", http.StatusBadGateway, map[string]any{"project_id": projectID, "ref": requestedRef, "path": path}, nil) + response.WriteLog(handler.logger) + return response.Write(ctx) + } + return ctx.Redirect().To(strings.TrimSpace(metadata.GetDownloadURL())) +} diff --git a/internal/server/http/git/thumbnail.go b/internal/server/http/git/thumbnail.go new file mode 100644 index 0000000..fb08ca7 --- /dev/null +++ b/internal/server/http/git/thumbnail.go @@ -0,0 +1,132 @@ +package git + +import ( + "database/sql" + "errors" + "fmt" + "io" + "net/http" + "strings" + + appconfig "github.com/calypr/gecko/config" + geckodb "github.com/calypr/gecko/internal/db" + "github.com/calypr/gecko/internal/httputil" + "github.com/calypr/gecko/internal/thumbnail" + "github.com/gofiber/fiber/v3" +) + +func (handler *Handler) resolveExistingProject(ctx fiber.Ctx) (string, string, string, *httputil.ErrorResponse) { + organization := strings.TrimSpace(ctx.Params("orgTitle")) + project := strings.TrimSpace(ctx.Params("projectTitle")) + if organization == "" || project == "" { + response := httputil.NewError("invalid_request", "organization and project are required", http.StatusBadRequest, nil, nil) + response.WriteLog(handler.logger) + return "", "", "", response + } + projectID := organization + "/" + project + var cfg appconfig.ProjectConfig + if err := geckodb.ConfigGETGeneric(handler.db, projectID, string(appconfig.TypeProjects), &cfg); err != nil { + if errors.Is(err, sql.ErrNoRows) { + response := httputil.NewError("not_found", fmt.Sprintf("no project config found for %s", projectID), http.StatusNotFound, map[string]any{"project_id": projectID}, nil) + response.WriteLog(handler.logger) + return "", "", "", response + } + response := httputil.NewError("database_error", fmt.Sprintf("failed to load project config: %s", err), http.StatusInternalServerError, map[string]any{"project_id": projectID}, nil) + response.WriteLog(handler.logger) + return "", "", "", response + } + return organization, project, projectID, nil +} + +func (handler *Handler) handleGitProjectThumbnailGET(ctx fiber.Ctx) error { + organization, project, projectID, errResponse := handler.resolveExistingProject(ctx) + if errResponse != nil { + return errResponse.Write(ctx) + } + path, contentType, err := handler.thumbnailStore.GetPath(organization, project) + if err != nil { + if errors.Is(err, thumbnail.ErrNoThumbnail) { + response := httputil.NewError("not_found", fmt.Sprintf("no thumbnail found for %s", projectID), http.StatusNotFound, map[string]any{"project_id": projectID}, nil) + response.WriteLog(handler.logger) + return response.Write(ctx) + } + response := httputil.NewError("internal_error", fmt.Sprintf("failed to load project thumbnail: %s", err), http.StatusInternalServerError, map[string]any{"project_id": projectID}, nil) + response.WriteLog(handler.logger) + return response.Write(ctx) + } + if strings.TrimSpace(contentType) != "" { + ctx.Set(fiber.HeaderContentType, contentType) + } + return ctx.SendFile(path) +} + +func (handler *Handler) handleGitProjectThumbnailPUT(ctx fiber.Ctx) error { + organization, project, projectID, errResponse := handler.resolveExistingProject(ctx) + if errResponse != nil { + return errResponse.Write(ctx) + } + fileHeader, err := ctx.FormFile("thumbnail") + if err != nil { + response := httputil.NewError("validation_failed", "thumbnail image is required", http.StatusBadRequest, map[string]any{"project_id": projectID}, nil) + response.WriteLog(handler.logger) + return response.Write(ctx) + } + if fileHeader.Size > int64(thumbnail.MaxProjectThumbnailBytes) { + response := httputil.NewError("validation_failed", fmt.Sprintf("thumbnail image must be %d bytes or smaller", thumbnail.MaxProjectThumbnailBytes), http.StatusRequestEntityTooLarge, map[string]any{"project_id": projectID}, nil) + response.WriteLog(handler.logger) + return response.Write(ctx) + } + file, err := fileHeader.Open() + if err != nil { + response := httputil.NewError("invalid_request", fmt.Sprintf("failed to open thumbnail upload: %s", err), http.StatusBadRequest, map[string]any{"project_id": projectID}, nil) + response.WriteLog(handler.logger) + return response.Write(ctx) + } + defer file.Close() + + data, err := io.ReadAll(io.LimitReader(file, int64(thumbnail.MaxProjectThumbnailBytes)+1)) + if err != nil { + response := httputil.NewError("invalid_request", fmt.Sprintf("failed to read thumbnail upload: %s", err), http.StatusBadRequest, map[string]any{"project_id": projectID}, nil) + response.WriteLog(handler.logger) + return response.Write(ctx) + } + if len(data) > thumbnail.MaxProjectThumbnailBytes { + response := httputil.NewError("validation_failed", fmt.Sprintf("thumbnail image must be %d bytes or smaller", thumbnail.MaxProjectThumbnailBytes), http.StatusRequestEntityTooLarge, map[string]any{"project_id": projectID}, nil) + response.WriteLog(handler.logger) + return response.Write(ctx) + } + + _, contentType, err := handler.thumbnailStore.Save(organization, project, data) + if err != nil { + response := httputil.NewError( + "validation_failed", + fmt.Sprintf( + "failed to save project thumbnail: %s. Allowed formats: PNG or JPG. Allowed dimensions: %dpx to %dpx. Maximum size: %d bytes", + err, + thumbnail.MinProjectThumbnailPixels, + thumbnail.MaxProjectThumbnailPixels, + thumbnail.MaxProjectThumbnailBytes, + ), + http.StatusBadRequest, + map[string]any{"project_id": projectID}, + nil, + ) + response.WriteLog(handler.logger) + return response.Write(ctx) + } + return httputil.JSON(map[string]any{"success": true, "project_id": projectID, "content_type": contentType}, http.StatusOK).Write(ctx) +} + +func (handler *Handler) handleGitProjectThumbnailDELETE(ctx fiber.Ctx) error { + organization, project, projectID, errResponse := handler.resolveExistingProject(ctx) + if errResponse != nil { + return errResponse.Write(ctx) + } + err := handler.thumbnailStore.Delete(organization, project) + if err != nil && !errors.Is(err, thumbnail.ErrNoThumbnail) { + response := httputil.NewError("integration_error", fmt.Sprintf("failed to delete project thumbnail: %s", err), http.StatusBadGateway, map[string]any{"project_id": projectID}, nil) + response.WriteLog(handler.logger) + return response.Write(ctx) + } + return httputil.JSON(map[string]any{"success": true, "project_id": projectID}, http.StatusOK).Write(ctx) +} diff --git a/internal/server/http/git/upload.go b/internal/server/http/git/upload.go new file mode 100644 index 0000000..13eb04a --- /dev/null +++ b/internal/server/http/git/upload.go @@ -0,0 +1,367 @@ +package git + +import ( + "context" + "database/sql" + "fmt" + "net/http" + "os" + "strings" + "time" + + "github.com/calypr/gecko/apierror" + geckodb "github.com/calypr/gecko/internal/db" + "github.com/calypr/gecko/internal/git" + "github.com/calypr/gecko/internal/httputil" + servermw "github.com/calypr/gecko/internal/server/middleware" + "github.com/gofiber/fiber/v3" + "github.com/google/uuid" +) + +func (handler *Handler) ensureConnectedMirrorProject(projectID string, identity git.GitRepositoryIdentity) (*geckodb.GitProjectState, *httputil.ErrorResponse) { + state, err := handler.loadGitProjectState(projectID, identity) + if err != nil { + response := httputil.NewError(apierror.TypeDatabaseError, fmt.Sprintf("failed to read git project state: %s", err), http.StatusInternalServerError, map[string]any{"project_id": projectID}, nil) + response.WriteLog(handler.logger) + return nil, response + } + if state == nil || state.InstallationID.Valid == false { + response := httputil.NewError("conflict", "project is not connected to the GitHub App", http.StatusConflict, map[string]any{"project_id": projectID}, nil) + response.WriteLog(handler.logger) + return nil, response + } + if strings.TrimSpace(state.MirrorPath) == "" { + response := httputil.NewError("conflict", "project mirror is not ready", http.StatusConflict, map[string]any{"project_id": projectID}, nil) + response.WriteLog(handler.logger) + return nil, response + } + return state, nil +} + +func (handler *Handler) ensureMirrorReadyForUpload(ctx context.Context, authorizationHeader string, projectID string, identity git.GitRepositoryIdentity, state *geckodb.GitProjectState) (*geckodb.GitProjectState, error) { + if state == nil { + return nil, fmt.Errorf("git project state is required") + } + if _, err := git.OpenRepository(state.MirrorPath); err == nil { + return state, nil + } + if _, err := os.Stat(state.MirrorPath); err == nil { + return nil, fmt.Errorf("open git repository at %s failed and mirror path exists", state.MirrorPath) + } + org, project, _ := strings.Cut(projectID, "/") + accessToken, err := handler.gitService.RequestInstallationToken(ctx, authorizationHeader, org, project, identity, "read") + if err != nil { + return nil, err + } + _, updatedState, err := handler.gitService.RefreshProject(ctx, projectID, identity, state, accessToken) + if err != nil { + return nil, err + } + if err := geckodb.UpsertGitProjectState(handler.db, *updatedState); err != nil { + return nil, fmt.Errorf("persist refreshed git project state: %w", err) + } + return updatedState, nil +} + +func sessionFilesFromManifest(sessionID string, subdirectory string, baseBranch string, files []git.GitUploadSessionFileManifest, mirrorState *geckodb.GitProjectState) ([]geckodb.GitUploadSessionFile, bool, error) { + openedRepo, err := git.OpenRepository(mirrorState.MirrorPath) + if err != nil { + return nil, false, err + } + refName, hash, err := git.ResolveGitReference(openedRepo, baseBranch, mirrorState.DefaultBranch.String) + if err != nil { + return nil, false, err + } + _ = refName + sessionFiles := make([]geckodb.GitUploadSessionFile, 0, len(files)) + hasConflicts := false + seenPaths := make(map[string]struct{}, len(files)) + for _, manifest := range files { + fileName, err := git.NormalizeGitUploadFileName(manifest.Name) + if err != nil { + return nil, false, err + } + targetPath := git.BuildGitUploadTargetPath(subdirectory, fileName) + targetPath = strings.Trim(strings.TrimSpace(targetPath), "/") + if _, ok := seenPaths[targetPath]; ok { + sessionFiles = append(sessionFiles, geckodb.GitUploadSessionFile{ + SessionID: sessionID, + FileName: fileName, + TargetPath: targetPath, + Size: manifest.Size, + Status: git.GitUploadFileCollision, + Error: sql.NullString{String: "duplicate target path in upload batch", Valid: true}, + }) + hasConflicts = true + continue + } + seenPaths[targetPath] = struct{}{} + exists, err := git.GitPathExistsInRef(openedRepo, hash, targetPath) + if err != nil { + return nil, false, err + } + fileState := geckodb.GitUploadSessionFile{ + SessionID: sessionID, + FileName: fileName, + TargetPath: targetPath, + Size: manifest.Size, + Status: git.GitUploadFilePending, + } + if exists { + fileState.Status = git.GitUploadFileCollision + fileState.Error = sql.NullString{String: "target path already exists on base branch", Valid: true} + hasConflicts = true + } + sessionFiles = append(sessionFiles, fileState) + } + return sessionFiles, hasConflicts, nil +} + +func (handler *Handler) handleGitProjectUploadSessionPOST(ctx fiber.Ctx) error { + organization, project, projectID, _, identity, errResponse := handler.resolveGitProject(ctx) + if errResponse != nil { + return errResponse.Write(ctx) + } + authorizationHeader, tokenErr := servermw.ValidateAuthorizationHeader(ctx.Get("Authorization")) + if tokenErr != nil { + response := httputil.NewError("missing_authorization", tokenErr.Error(), http.StatusUnauthorized, map[string]any{"project_id": projectID}, nil) + response.WriteLog(handler.logger) + return response.Write(ctx) + } + state, errResponse := handler.ensureConnectedMirrorProject(projectID, identity) + if errResponse != nil { + return errResponse.Write(ctx) + } + var requestBody git.GitUploadSessionCreateRequest + if errResponse := httputil.ParseJSONBody(ctx.Body(), &requestBody, map[string]any{"project_id": projectID}); errResponse != nil { + errResponse.WriteLog(handler.logger) + return errResponse.Write(ctx) + } + baseBranch := strings.TrimSpace(requestBody.BaseBranch) + if baseBranch == "" { + baseBranch = strings.TrimSpace(state.DefaultBranch.String) + } + if baseBranch == "" { + response := httputil.NewError("conflict", "repository has no default branch yet. Initialize your repo first before uploading files.", http.StatusConflict, map[string]any{"project_id": projectID}, nil) + response.WriteLog(handler.logger) + return response.Write(ctx) + } + if len(requestBody.Files) == 0 { + response := httputil.NewError("invalid_request", "at least one file is required", http.StatusBadRequest, map[string]any{"project_id": projectID}, nil) + response.WriteLog(handler.logger) + return response.Write(ctx) + } + targetSubdir := git.NormalizeGitUploadSubdirectory(requestBody.TargetSubdir) + sessionID := uuid.NewString() + prepareCtx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + state, err := handler.ensureMirrorReadyForUpload(prepareCtx, authorizationHeader, projectID, identity, state) + if err != nil { + if statusErr, ok := err.(*git.HTTPStatusError); ok { + response := httputil.NewError(apierror.Type(statusErr.Code), statusErr.Message, statusErr.StatusCode, map[string]any{"project_id": projectID}, nil) + response.WriteLog(handler.logger) + return response.Write(ctx) + } + response := httputil.NewError("integration_error", fmt.Sprintf("failed to prepare upload session: %s", err), http.StatusBadGateway, map[string]any{"project_id": projectID}, nil) + response.WriteLog(handler.logger) + return response.Write(ctx) + } + files, hasConflicts, err := sessionFilesFromManifest(sessionID, targetSubdir, baseBranch, requestBody.Files, state) + if err != nil { + response := httputil.NewError("integration_error", fmt.Sprintf("failed to prepare upload session: %s", err), http.StatusBadGateway, map[string]any{"project_id": projectID}, nil) + response.WriteLog(handler.logger) + return response.Write(ctx) + } + now := time.Now().UTC() + session := geckodb.GitUploadSession{ + ID: sessionID, + ProjectID: projectID, + Organization: organization, + Project: project, + RepoHost: identity.Host, + RepoOwner: identity.Owner, + RepoName: identity.Repo, + BaseBranch: baseBranch, + TargetSubdir: sql.NullString{String: targetSubdir, Valid: targetSubdir != ""}, + BranchName: git.BuildGitUploadBranchName(project), + PRTitle: git.BuildDefaultUploadPRTitle(project, len(requestBody.Files)), + PRBody: git.BuildDefaultUploadPRBody(baseBranch, targetSubdir), + Status: git.GitUploadSessionPending, + CreatedAt: now, + UpdatedAt: now, + } + if hasConflicts { + session.Status = git.GitUploadSessionPending + } + if err := geckodb.UpsertGitUploadSession(handler.db, session); err != nil { + response := httputil.NewError(apierror.TypeDatabaseError, fmt.Sprintf("failed to persist upload session: %s", err), http.StatusInternalServerError, map[string]any{"project_id": projectID}, nil) + response.WriteLog(handler.logger) + return response.Write(ctx) + } + if err := geckodb.ReplaceGitUploadSessionFiles(handler.db, session.ID, files); err != nil { + response := httputil.NewError(apierror.TypeDatabaseError, fmt.Sprintf("failed to persist upload session files: %s", err), http.StatusInternalServerError, map[string]any{"project_id": projectID, "session_id": session.ID}, nil) + response.WriteLog(handler.logger) + return response.Write(ctx) + } + return httputil.JSON(git.BuildGitUploadSessionResponse(session, files), http.StatusOK).Write(ctx) +} + +func (handler *Handler) resolveGitUploadSession(projectID string, sessionID string) (*geckodb.GitUploadSession, []geckodb.GitUploadSessionFile, *httputil.ErrorResponse) { + session, err := geckodb.GitUploadSessionByID(handler.db, sessionID) + if err != nil { + response := httputil.NewError(apierror.TypeDatabaseError, fmt.Sprintf("failed to read upload session: %s", err), http.StatusInternalServerError, map[string]any{"project_id": projectID, "session_id": sessionID}, nil) + response.WriteLog(handler.logger) + return nil, nil, response + } + if session == nil || session.ProjectID != projectID { + response := httputil.NewError("not_found", "upload session was not found", http.StatusNotFound, map[string]any{"project_id": projectID, "session_id": sessionID}, nil) + response.WriteLog(handler.logger) + return nil, nil, response + } + files, err := geckodb.ListGitUploadSessionFiles(handler.db, sessionID) + if err != nil { + response := httputil.NewError(apierror.TypeDatabaseError, fmt.Sprintf("failed to read upload session files: %s", err), http.StatusInternalServerError, map[string]any{"project_id": projectID, "session_id": sessionID}, nil) + response.WriteLog(handler.logger) + return nil, nil, response + } + return session, files, nil +} + +func (handler *Handler) handleGitProjectUploadSessionGET(ctx fiber.Ctx) error { + _, _, projectID, _, _, errResponse := handler.resolveGitProject(ctx) + if errResponse != nil { + return errResponse.Write(ctx) + } + sessionID := strings.TrimSpace(ctx.Params("sessionID")) + session, files, errResponse := handler.resolveGitUploadSession(projectID, sessionID) + if errResponse != nil { + return errResponse.Write(ctx) + } + return httputil.JSON(git.BuildGitUploadSessionResponse(*session, files), http.StatusOK).Write(ctx) +} + +func (handler *Handler) handleGitProjectUploadSessionFilesPOST(ctx fiber.Ctx) error { + _, _, projectID, _, _, errResponse := handler.resolveGitProject(ctx) + if errResponse != nil { + return errResponse.Write(ctx) + } + sessionID := strings.TrimSpace(ctx.Params("sessionID")) + session, files, errResponse := handler.resolveGitUploadSession(projectID, sessionID) + if errResponse != nil { + return errResponse.Write(ctx) + } + var requestBody git.GitUploadSessionAttachFilesRequest + if errResponse := httputil.ParseJSONBody(ctx.Body(), &requestBody, map[string]any{"project_id": projectID, "session_id": sessionID}); errResponse != nil { + errResponse.WriteLog(handler.logger) + return errResponse.Write(ctx) + } + fileMap := make(map[string]*geckodb.GitUploadSessionFile, len(files)) + for i := range files { + fileMap[files[i].TargetPath] = &files[i] + } + for _, attachment := range requestBody.Files { + targetPath := strings.Trim(strings.TrimSpace(attachment.TargetPath), "/") + fileState, ok := fileMap[targetPath] + if !ok { + response := httputil.NewError("invalid_request", fmt.Sprintf("upload session does not contain target path %s", targetPath), http.StatusBadRequest, map[string]any{"project_id": projectID, "session_id": sessionID}, nil) + response.WriteLog(handler.logger) + return response.Write(ctx) + } + fileState.Size = attachment.Size + fileState.Checksum = sql.NullString{String: strings.ToLower(strings.TrimSpace(attachment.Checksum)), Valid: strings.TrimSpace(attachment.Checksum) != ""} + fileState.DRSObjectID = sql.NullString{String: strings.TrimSpace(attachment.DRSObjectID), Valid: strings.TrimSpace(attachment.DRSObjectID) != ""} + if fileState.Status != git.GitUploadFileCollision { + fileState.Status = git.GitUploadFileUploaded + fileState.Error = sql.NullString{} + } + } + session.Status = git.GitUploadSessionReady + for _, file := range files { + if file.Status == git.GitUploadFileCollision { + session.Status = git.GitUploadSessionPending + break + } + if !file.Checksum.Valid || !file.DRSObjectID.Valid { + session.Status = git.GitUploadSessionPending + } + } + session.UpdatedAt = time.Now().UTC() + if err := geckodb.UpsertGitUploadSession(handler.db, *session); err != nil { + response := httputil.NewError(apierror.TypeDatabaseError, fmt.Sprintf("failed to update upload session: %s", err), http.StatusInternalServerError, map[string]any{"project_id": projectID, "session_id": sessionID}, nil) + response.WriteLog(handler.logger) + return response.Write(ctx) + } + if err := geckodb.ReplaceGitUploadSessionFiles(handler.db, sessionID, files); err != nil { + response := httputil.NewError(apierror.TypeDatabaseError, fmt.Sprintf("failed to update upload session files: %s", err), http.StatusInternalServerError, map[string]any{"project_id": projectID, "session_id": sessionID}, nil) + response.WriteLog(handler.logger) + return response.Write(ctx) + } + return httputil.JSON(git.BuildGitUploadSessionResponse(*session, files), http.StatusOK).Write(ctx) +} + +func (handler *Handler) handleGitProjectUploadSessionFinalizePOST(ctx fiber.Ctx) error { + _, _, projectID, _, identity, errResponse := handler.resolveGitProject(ctx) + if errResponse != nil { + return errResponse.Write(ctx) + } + authorizationHeader, tokenErr := servermw.ValidateAuthorizationHeader(ctx.Get("Authorization")) + if tokenErr != nil { + response := httputil.NewError("missing_authorization", tokenErr.Error(), http.StatusUnauthorized, map[string]any{"project_id": projectID}, nil) + response.WriteLog(handler.logger) + return response.Write(ctx) + } + sessionID := strings.TrimSpace(ctx.Params("sessionID")) + session, files, errResponse := handler.resolveGitUploadSession(projectID, sessionID) + if errResponse != nil { + return errResponse.Write(ctx) + } + var requestBody git.GitUploadSessionFinalizeRequest + if len(ctx.Body()) > 0 { + if errResponse := httputil.ParseJSONBody(ctx.Body(), &requestBody, map[string]any{"project_id": projectID, "session_id": sessionID}); errResponse != nil { + errResponse.WriteLog(handler.logger) + return errResponse.Write(ctx) + } + } + if strings.TrimSpace(requestBody.PRTitle) != "" { + session.PRTitle = strings.TrimSpace(requestBody.PRTitle) + } + if strings.TrimSpace(requestBody.PRBody) != "" { + session.PRBody = strings.TrimSpace(requestBody.PRBody) + } + for _, file := range files { + if file.Status == git.GitUploadFileCollision { + response := httputil.NewError("conflict", "upload session contains target path conflicts", http.StatusConflict, map[string]any{"project_id": projectID, "session_id": sessionID}, nil) + response.WriteLog(handler.logger) + return response.Write(ctx) + } + if !file.Checksum.Valid || !file.DRSObjectID.Valid { + response := httputil.NewError("conflict", fmt.Sprintf("upload session file %s is not fully attached", file.TargetPath), http.StatusConflict, map[string]any{"project_id": projectID, "session_id": sessionID}, nil) + response.WriteLog(handler.logger) + return response.Write(ctx) + } + } + finalizeCtx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + org, project, _ := strings.Cut(projectID, "/") + commitSHA, prURL, err := handler.gitService.CreateGitHubUploadPullRequest(finalizeCtx, authorizationHeader, org, project, identity, session.BaseBranch, session.BranchName, session.PRTitle, session.PRBody, files) + if err != nil { + if statusErr, ok := err.(*git.HTTPStatusError); ok { + response := httputil.NewError(apierror.Type(statusErr.Code), statusErr.Message, statusErr.StatusCode, map[string]any{"project_id": projectID, "session_id": sessionID}, nil) + response.WriteLog(handler.logger) + return response.Write(ctx) + } + response := httputil.NewError("integration_error", fmt.Sprintf("failed to create upload pull request: %s", err), http.StatusBadGateway, map[string]any{"project_id": projectID, "session_id": sessionID}, nil) + response.WriteLog(handler.logger) + return response.Write(ctx) + } + session.Status = git.GitUploadSessionFinalized + session.CommitSHA = sql.NullString{String: commitSHA, Valid: commitSHA != ""} + session.PullRequestURL = sql.NullString{String: prURL, Valid: prURL != ""} + session.UpdatedAt = time.Now().UTC() + if err := geckodb.UpsertGitUploadSession(handler.db, *session); err != nil { + response := httputil.NewError(apierror.TypeDatabaseError, fmt.Sprintf("failed to persist finalized upload session: %s", err), http.StatusInternalServerError, map[string]any{"project_id": projectID, "session_id": sessionID}, nil) + response.WriteLog(handler.logger) + return response.Write(ctx) + } + return httputil.JSON(git.BuildGitUploadSessionResponse(*session, files), http.StatusOK).Write(ctx) +} diff --git a/internal/server/http/health/handler.go b/internal/server/http/health/handler.go new file mode 100644 index 0000000..62a2b23 --- /dev/null +++ b/internal/server/http/health/handler.go @@ -0,0 +1,21 @@ +package health + +import ( + "github.com/calypr/gecko/internal/server/http/shared" + "github.com/jmoiron/sqlx" + "github.com/uc-cdis/arborist/arborist" +) + +type Handler struct { + *shared.Handler + db *sqlx.DB + logger arborist.Logger +} + +func NewHandler(sharedHandler *shared.Handler) *Handler { + return &Handler{ + Handler: sharedHandler, + db: sharedHandler.DB, + logger: sharedHandler.Logger, + } +} diff --git a/internal/server/http/health/health.go b/internal/server/http/health/health.go new file mode 100644 index 0000000..0a65b31 --- /dev/null +++ b/internal/server/http/health/health.go @@ -0,0 +1,21 @@ +package health + +import ( + "net/http" + + "github.com/calypr/gecko/apierror" + "github.com/calypr/gecko/internal/httputil" + "github.com/gofiber/fiber/v3" +) + +func (handler *Handler) handleHealth(ctx fiber.Ctx) error { + if handler.db != nil { + if err := handler.db.Ping(); err != nil { + handler.logger.Error("Database ping failed: %v", err) + return httputil.NewError(apierror.TypeDatabaseUnavailable, "database unavailable", http.StatusInternalServerError, nil, nil).Write(ctx) + } + } else { + handler.logger.Warning("Health check: Database connection not configured.") + } + return httputil.JSON("Healthy", http.StatusOK).Write(ctx) +} diff --git a/internal/server/http/health/register.go b/internal/server/http/health/register.go new file mode 100644 index 0000000..e52016a --- /dev/null +++ b/internal/server/http/health/register.go @@ -0,0 +1,11 @@ +package health + +import ( + "github.com/calypr/gecko/internal/server/http/shared" + "github.com/gofiber/fiber/v3" +) + +func RegisterRoutes(app *fiber.App, sharedHandler *shared.Handler) { + handler := NewHandler(sharedHandler) + app.Get("/health", handler.handleHealth) +} diff --git a/internal/server/http/register.go b/internal/server/http/register.go new file mode 100644 index 0000000..d9417ae --- /dev/null +++ b/internal/server/http/register.go @@ -0,0 +1,38 @@ +package httpapi + +import ( + "net/http" + "strings" + + "github.com/calypr/gecko/internal/httputil" + "github.com/calypr/gecko/internal/server/http/config" + "github.com/calypr/gecko/internal/server/http/directory" + "github.com/calypr/gecko/internal/server/http/git" + "github.com/calypr/gecko/internal/server/http/health" + "github.com/calypr/gecko/internal/server/http/shared" + "github.com/calypr/gecko/internal/server/http/vector" + servermw "github.com/calypr/gecko/internal/server/middleware" + "github.com/gofiber/fiber/v3" +) + +type Dependencies = shared.Dependencies + +func Register(app *fiber.App, deps Dependencies) { + handler := shared.NewHandler(deps) + authzHandler := servermw.NewFenceUserAccessHandler(http.DefaultClient) + + app.Get("/swagger/doc.json", func(ctx fiber.Ctx) error { + return ctx.SendFile("./docs/swagger.json") + }) + + health.RegisterRoutes(app, handler) + directory.RegisterRoutes(app, handler, authzHandler) + config.RegisterRoutes(app, handler, authzHandler) + git.RegisterRoutes(app, handler, authzHandler) + vector.RegisterRoutes(app, handler) + + app.Use(func(ctx fiber.Ctx) error { + ctx.Path(strings.TrimSuffix(ctx.Path(), "/")) + return httputil.NotFound(ctx) + }) +} diff --git a/internal/server/http/shared/handler.go b/internal/server/http/shared/handler.go new file mode 100644 index 0000000..fdceeab --- /dev/null +++ b/internal/server/http/shared/handler.go @@ -0,0 +1,67 @@ +package shared + +import ( + "net/http" + "os" + "strings" + + "github.com/bmeg/grip/gripql" + "github.com/calypr/gecko/internal/git" + gintegrationsyfon "github.com/calypr/gecko/internal/integrations/syfon" + servermw "github.com/calypr/gecko/internal/server/middleware" + "github.com/calypr/gecko/internal/thumbnail" + "github.com/jmoiron/sqlx" + "github.com/qdrant/go-client/qdrant" + "github.com/uc-cdis/arborist/arborist" +) + +type Dependencies struct { + DB *sqlx.DB + Logger arborist.Logger + JWTApp arborist.JWTDecoder + QdrantClient *qdrant.Client + GripqlClient *gripql.Client + GripGraphName string + GitService *git.GitService + ThumbnailStore thumbnail.Manager +} + +type Handler struct { + DB *sqlx.DB + Logger arborist.Logger + JWTApp arborist.JWTDecoder + QdrantClient *qdrant.Client + GripqlClient *gripql.Client + GripGraphName string + GitService *git.GitService + ProjectSetup *git.SetupService + ProjectSync *git.ReconcileService + ThumbnailStore thumbnail.Manager +} + +func NewHandler(deps Dependencies) *Handler { + var projectSetup *git.SetupService + var projectSync *git.ReconcileService + if deps.GitService != nil { + storageManager := gintegrationsyfon.NewManager(strings.TrimSpace(os.Getenv("SYFON_DATA_API_BASE_URL")), http.DefaultClient) + projectSetup = git.NewSetupService(deps.DB, deps.GitService, storageManager, servermw.NewFenceUserAccessHandler(nil)) + projectSync = git.NewReconcileService( + deps.DB, + storageManager, + deps.GitService, + ) + } + return &Handler{ + DB: deps.DB, + Logger: deps.Logger, + JWTApp: deps.JWTApp, + QdrantClient: deps.QdrantClient, + GripqlClient: deps.GripqlClient, + GripGraphName: deps.GripGraphName, + GitService: deps.GitService, + ProjectSetup: projectSetup, + ProjectSync: projectSync, + ThumbnailStore: deps.ThumbnailStore, + } +} + diff --git a/internal/server/http/shared/helpers.go b/internal/server/http/shared/helpers.go new file mode 100644 index 0000000..a71d4dc --- /dev/null +++ b/internal/server/http/shared/helpers.go @@ -0,0 +1,74 @@ +package shared + +import ( + "fmt" + "net/http" + "strings" + + "github.com/calypr/gecko/apierror" + "github.com/calypr/gecko/internal/git" + "github.com/calypr/gecko/internal/httputil" + servermw "github.com/calypr/gecko/internal/server/middleware" + "github.com/gofiber/fiber/v3" +) + +func ConfigTypeMiddleware(configType string) fiber.Handler { + return func(ctx fiber.Ctx) error { + ctx.Locals("configType", configType) + return ctx.Next() + } +} + +func (handler *Handler) WriteAppError(ctx fiber.Ctx, err error) error { + if err == nil { + return nil + } + appErr, ok := err.(*git.Error) + if !ok { + response := httputil.NewError(apierror.Type("internal_error"), err.Error(), http.StatusInternalServerError, nil, nil) + response.WriteLog(handler.Logger) + return response.Write(ctx) + } + errorType := apierror.Type("internal_error") + switch appErr.Kind { + case git.ErrorKindValidation: + errorType = apierror.TypeValidationFailed + case git.ErrorKindForbidden: + errorType = apierror.TypeForbidden + case git.ErrorKindIntegration: + errorType = apierror.Type("integration_error") + case git.ErrorKindNotFound: + errorType = apierror.TypeNotFound + case git.ErrorKindDatabase: + errorType = apierror.TypeDatabaseError + case git.ErrorKindUnauthorized: + errorType = apierror.TypeMissingAuthorization + } + statusCode := appErr.StatusCode + if statusCode == 0 { + statusCode = http.StatusInternalServerError + } + response := httputil.NewError(errorType, appErr.Error(), statusCode, appErr.Details, nil) + response.WriteLog(handler.Logger) + return response.Write(ctx) +} + +func (handler *Handler) AuthenticatedUserID(ctx fiber.Ctx) (string, *httputil.ErrorResponse) { + authorizationHeader, tokenErr := servermw.ValidateAuthorizationHeader(ctx.Get("Authorization")) + if tokenErr != nil { + return "", httputil.NewError(apierror.TypeMissingAuthorization, tokenErr.Error(), http.StatusUnauthorized, nil, nil) + } + if handler.JWTApp == nil { + return "", httputil.NewError(apierror.TypeInvalidJWTHandler, "JWT validation is not configured", http.StatusUnauthorized, nil, nil) + } + claims, err := handler.JWTApp.Decode(servermw.CleanAccessToken(authorizationHeader)) + if err != nil { + return "", httputil.NewError(apierror.TypeUnauthorized, fmt.Sprintf("failed to decode authorization token: %s", err), http.StatusUnauthorized, nil, nil) + } + for _, claim := range []string{"sub", "username", "email"} { + if value, ok := (*claims)[claim].(string); ok && strings.TrimSpace(value) != "" { + return strings.TrimSpace(value), nil + } + } + return "", httputil.NewError(apierror.TypeUnauthorized, "authorization token does not include a stable user id", http.StatusUnauthorized, nil, nil) +} diff --git a/internal/server/http/vector/handler.go b/internal/server/http/vector/handler.go new file mode 100644 index 0000000..1213568 --- /dev/null +++ b/internal/server/http/vector/handler.go @@ -0,0 +1,21 @@ +package vector + +import ( + "github.com/calypr/gecko/internal/server/http/shared" + "github.com/qdrant/go-client/qdrant" + "github.com/uc-cdis/arborist/arborist" +) + +type Handler struct { + *shared.Handler + logger arborist.Logger + qdrantClient *qdrant.Client +} + +func NewHandler(sharedHandler *shared.Handler) *Handler { + return &Handler{ + Handler: sharedHandler, + logger: sharedHandler.Logger, + qdrantClient: sharedHandler.QdrantClient, + } +} diff --git a/internal/server/http/vector/register.go b/internal/server/http/vector/register.go new file mode 100644 index 0000000..356f913 --- /dev/null +++ b/internal/server/http/vector/register.go @@ -0,0 +1,15 @@ +package vector + +import ( + "github.com/calypr/gecko/internal/server/http/shared" + "github.com/gofiber/fiber/v3" +) + +func RegisterRoutes(app *fiber.App, sharedHandler *shared.Handler) { + handler := NewHandler(sharedHandler) + if handler.QdrantClient == nil { + handler.Logger.Warning("Skipping Qdrant endpoints — no vector store configured") + return + } + handler.registerVectorHandlers(app) +} diff --git a/internal/server/http/vector/vector.go b/internal/server/http/vector/vector.go new file mode 100644 index 0000000..67ec0ed --- /dev/null +++ b/internal/server/http/vector/vector.go @@ -0,0 +1,344 @@ +package vector + +import ( + "fmt" + "net/http" + + "github.com/calypr/gecko/apierror" + "github.com/calypr/gecko/internal/httputil" + "github.com/calypr/gecko/internal/vectoradapter" + "github.com/gofiber/fiber/v3" + "github.com/google/uuid" + "github.com/qdrant/go-client/qdrant" +) + +func (handler *Handler) registerVectorHandlers(app fiber.Router) { + vector := app.Group("/vector") + vector.Get("/swagger/doc.json", func(ctx fiber.Ctx) error { return ctx.SendFile("./docs/swagger.json") }) + vector.Get("/swagger", func(ctx fiber.Ctx) error { + return ctx.Redirect().Status(fiber.StatusTemporaryRedirect).To("/vector/swagger/doc.json") + }) + vector.Get("/swagger/*", func(ctx fiber.Ctx) error { + return ctx.Redirect().Status(fiber.StatusTemporaryRedirect).To("/vector/swagger/doc.json") + }) + + collections := vector.Group("/collections") + collections.Get("", handler.handleListCollections) + collections.Put("/:collection", handler.handleCreateCollection) + collections.Get("/:collection", handler.handleGetCollection) + collections.Patch("/:collection", handler.handleUpdateCollection) + collections.Delete("/:collection", handler.handleDeleteCollection) + + points := collections.Group("/:collection/points") + points.Put("", handler.handleUpsertPoints) + points.Get("/:id", handler.handleGetPoint) + points.Post("/search", handler.handleQueryPoints) + points.Post("/delete", handler.handleDeletePoints) +} + +// handleListCollections godoc +// @Summary List all collections +// @Description Retrieve all vector collections. +// @Tags Vector Collections +// @Produce json +// @Success 200 {object} map[string]interface{} "Collections listed" +// @Failure 500 {object} ErrorResponse "Server error" +// @Router /vector/collections [get] +func (handler *Handler) handleListCollections(ctx fiber.Ctx) error { + resp, err := handler.qdrantClient.ListCollections(ctx) + if err != nil { + errResponse := newVectorBackendErrorResponse("list collections", "", err) + errResponse.WriteLog(handler.logger) + return errResponse.Write(ctx) + } + return httputil.JSON(map[string]any{"result": resp, "status": "ok"}, http.StatusOK).Write(ctx) +} + +// handleCreateCollection godoc +// @Summary Create a new collection +// @Description Create a collection with vector configuration. +// @Tags Vector Collections +// @Accept json +// @Produce json +// @Param collection path string true "Collection name" +// @Param body body vectoradapter.CreateCollectionRequest true "Collection configuration" +// @Success 200 {object} map[string]bool "Collection created" +// @Failure 400 {object} ErrorResponse "Invalid request" +// @Failure 500 {object} ErrorResponse "Server error" +// @Router /vector/collections/{collection} [put] +func (handler *Handler) handleCreateCollection(ctx fiber.Ctx) error { + collection := ctx.Params("collection") + var reqBody vectoradapter.CreateCollectionRequest + if errResponse := httputil.ParseJSONBody(ctx.Body(), &reqBody, map[string]any{"collection": collection}); errResponse != nil { + errResponse.WriteLog(handler.logger) + return errResponse.Write(ctx) + } + + namedVectorsMap := map[string]*qdrant.VectorParams{} + for name, params := range reqBody.Vectors { + distanceVal, ok := qdrant.Distance_value[params.Distance] + if !ok { + errResponse := httputil.NewError(apierror.TypeInvalidDistance, fmt.Sprintf("invalid distance: %s", params.Distance), http.StatusBadRequest, map[string]any{"collection": collection}, nil) + errResponse.WriteLog(handler.logger) + return errResponse.Write(ctx) + } + namedVectorsMap[name] = &qdrant.VectorParams{Size: params.Size, Distance: qdrant.Distance(distanceVal)} + } + + var vectorsConfig *qdrant.VectorsConfig + if len(namedVectorsMap) > 0 { + vectorsConfig = qdrant.NewVectorsConfigMap(namedVectorsMap) + } + if err := handler.qdrantClient.CreateCollection(ctx, &qdrant.CreateCollection{CollectionName: collection, VectorsConfig: vectorsConfig}); err != nil { + errResponse := newVectorBackendErrorResponse("create collection", collection, err) + errResponse.WriteLog(handler.logger) + return errResponse.Write(ctx) + } + return httputil.JSON(map[string]bool{"result": true}, http.StatusOK).Write(ctx) +} + +// handleGetCollection godoc +// @Summary Get collection info +// @Description Returns information about a collection by name. +// @Tags Vector Collections +// @Produce json +// @Param collection path string true "Collection name" +// @Success 200 {object} map[string]interface{} "Collection info" +// @Failure 404 {object} ErrorResponse "Collection not found" +// @Failure 500 {object} ErrorResponse "Internal server error" +// @Router /vector/collections/{collection} [get] +func (handler *Handler) handleGetCollection(ctx fiber.Ctx) error { + collection := ctx.Params("collection") + resp, err := handler.qdrantClient.GetCollectionInfo(ctx, collection) + if err != nil { + errResponse := newVectorBackendErrorResponse("get collection info", collection, err) + errResponse.WriteLog(handler.logger) + return errResponse.Write(ctx) + } + return httputil.JSON(resp, http.StatusOK).Write(ctx) +} + +// handleUpdateCollection godoc +// @Summary Update collection +// @Description Updates an existing collection by name. +// @Tags Vector Collections +// @Accept json +// @Produce json +// @Param collection path string true "Collection name" +// @Param body body qdrant.UpdateCollection true "Update collection request" +// @Success 200 {object} map[string]bool "Update successful" +// @Failure 400 {object} ErrorResponse "Invalid request" +// @Failure 404 {object} ErrorResponse "Collection not found" +// @Failure 500 {object} ErrorResponse "Internal server error" +// @Router /vector/collections/{collection} [patch] +func (handler *Handler) handleUpdateCollection(ctx fiber.Ctx) error { + collection := ctx.Params("collection") + var req qdrant.UpdateCollection + if errResponse := httputil.ParseJSONBody(ctx.Body(), &req, map[string]any{"collection": collection}); errResponse != nil { + errResponse.WriteLog(handler.logger) + return errResponse.Write(ctx) + } + req.CollectionName = collection + if err := handler.qdrantClient.UpdateCollection(ctx, &req); err != nil { + errResponse := newVectorBackendErrorResponse("update collection", collection, err) + errResponse.WriteLog(handler.logger) + return errResponse.Write(ctx) + } + return httputil.JSON(map[string]bool{"result": true}, http.StatusOK).Write(ctx) +} + +// handleDeleteCollection godoc +// @Summary Delete collection +// @Description Deletes a collection and all its points. +// @Tags Vector Collections +// @Produce json +// @Param collection path string true "Collection name" +// @Success 200 {object} map[string]bool "Delete successful" +// @Failure 404 {object} ErrorResponse "Collection not found" +// @Failure 500 {object} ErrorResponse "Internal server error" +// @Router /vector/collections/{collection} [delete] +func (handler *Handler) handleDeleteCollection(ctx fiber.Ctx) error { + collection := ctx.Params("collection") + if err := handler.qdrantClient.DeleteCollection(ctx, collection); err != nil { + errResponse := newVectorBackendErrorResponse("delete collection", collection, err) + errResponse.WriteLog(handler.logger) + return errResponse.Write(ctx) + } + return httputil.JSON(map[string]bool{"result": true}, http.StatusOK).Write(ctx) +} + +// handleGetPoint godoc +// @Summary Get point +// @Description Returns a single point, including vectors and payload, by ID. +// @Tags Vector +// @Produce json +// @Param collection path string true "Collection name" +// @Param id path string true "Point UUID" +// @Success 200 {object} map[string]interface{} "Point found" +// @Failure 400 {object} ErrorResponse "Invalid request or ID" +// @Failure 404 {object} ErrorResponse "Point not found" +// @Failure 500 {object} ErrorResponse "Internal server error" +// @Router /vector/collections/{collection}/points/{id} [get] +func (handler *Handler) handleGetPoint(ctx fiber.Ctx) error { + collection := ctx.Params("collection") + idStr := ctx.Params("id") + if idStr == "" || collection == "" { + err := fmt.Errorf("collection or id not provide") + errResponse := httputil.NewError(apierror.TypeMissingIdentifier, "collection or id is not provided", http.StatusBadRequest, map[string]any{"collection": collection, "id": idStr}, &err) + errResponse.WriteLog(handler.logger) + return errResponse.Write(ctx) + } + if _, err := uuid.Parse(idStr); err != nil { + errResponse := httputil.NewError(apierror.TypeInvalidUUID, "invalid UUID", http.StatusBadRequest, map[string]any{"collection": collection, "id": idStr}, &err) + errResponse.WriteLog(handler.logger) + return errResponse.Write(ctx) + } + resp, err := handler.qdrantClient.Get(ctx, &qdrant.GetPoints{CollectionName: collection, Ids: []*qdrant.PointId{qdrant.NewIDUUID(idStr)}, WithPayload: qdrant.NewWithPayload(true), WithVectors: qdrant.NewWithVectors(true)}) + if err != nil { + errResponse := newVectorBackendErrorResponse("get point", collection, err) + errResponse.WriteLog(handler.logger) + return errResponse.Write(ctx) + } + if len(resp) == 0 { + errResponse := httputil.NewError(apierror.TypePointNotFound, "point not found", http.StatusNotFound, map[string]any{"collection": collection, "id": idStr}, nil) + errResponse.WriteLog(handler.logger) + return errResponse.Write(ctx) + } + return httputil.JSON(vectoradapter.ConvertQdrantRetrievedPointsResponse(resp), http.StatusOK).Write(ctx) +} + +// handleQueryPoints godoc +// @Summary Query points in a collection +// @Description Executes a kNN or recommendation query against a collection. +// @Tags Vector Search +// @Accept json +// @Produce json +// @Param collection path string true "Collection name" +// @Param request body vectoradapter.QueryPointsRequest true "Query request body" +// @Success 200 {array} vectoradapter.QueryPointsResponseItem +// @Failure 400 {object} ErrorResponse +// @Failure 404 {object} ErrorResponse +// @Failure 500 {object} ErrorResponse +// @Router /vector/collections/{collection}/points/search [post] +func (handler *Handler) handleQueryPoints(ctx fiber.Ctx) error { + collection := ctx.Params("collection") + var req vectoradapter.QueryPointsRequest + if errResponse := httputil.ParseJSONBody(ctx.Body(), &req, map[string]any{"collection": collection}); errResponse != nil { + errResponse.WriteLog(handler.logger) + return errResponse.Write(ctx) + } + qdrantReq, err := vectoradapter.ToQdrantQuery(req, collection) + if err != nil { + errResponse := httputil.NewError(apierror.TypeInvalidQueryParameter, fmt.Sprintf("invalid query parameter: %s", err), http.StatusBadRequest, map[string]any{"collection": collection}, &err) + errResponse.WriteLog(handler.logger) + return errResponse.Write(ctx) + } + resp, err := handler.qdrantClient.Query(ctx, qdrantReq) + if err != nil { + errResponse := newVectorBackendErrorResponse("query points", collection, err) + errResponse.WriteLog(handler.logger) + return errResponse.Write(ctx) + } + return httputil.JSON(vectoradapter.ConvertQdrantPointsResponse(resp), http.StatusOK).Write(ctx) +} + +// handleUpsertPoints godoc +// @Summary Upsert points +// @Description Inserts new points or updates existing ones. +// @Tags Vector +// @Accept json +// @Produce json +// @Param collection path string true "Collection name" +// @Param body body vectoradapter.UpsertRequest true "Upsert request" +// @Success 200 {object} map[string]interface{} "Upsert successful" +// @Failure 400 {object} ErrorResponse "Invalid request" +// @Failure 500 {object} ErrorResponse "Internal server error" +// @Router /vector/collections/{collection}/points [put] +func (handler *Handler) handleUpsertPoints(ctx fiber.Ctx) error { + collection := ctx.Params("collection") + var reqBody vectoradapter.UpsertRequest + if errResponse := httputil.ParseJSONBody(ctx.Body(), &reqBody, map[string]any{"collection": collection}); errResponse != nil { + errResponse.WriteLog(handler.logger) + return errResponse.Write(ctx) + } + upsertReq, err := vectoradapter.ToQdrantUpsert(reqBody, collection) + if err != nil { + errResponse := httputil.NewError(apierror.TypeInvalidPointData, err.Error(), http.StatusBadRequest, map[string]any{"collection": collection}, nil) + errResponse.WriteLog(handler.logger) + return errResponse.Write(ctx) + } + resp, err := handler.qdrantClient.Upsert(ctx, upsertReq) + if err != nil { + errResponse := newVectorBackendErrorResponse("upsert points", collection, err) + errResponse.WriteLog(handler.logger) + return errResponse.Write(ctx) + } + return httputil.JSON(resp, http.StatusOK).Write(ctx) +} + +// handleDeletePoints godoc +// @Summary Delete points +// @Description Deletes points from a collection based on the provided selector. +// @Tags Vector +// @Accept json +// @Produce json +// @Param collection path string true "Collection name" +// @Param body body vectoradapter.DeletePoints true "Delete request" +// @Success 200 {object} map[string]bool "Delete successful" +// @Failure 400 {object} ErrorResponse "Invalid request" +// @Failure 500 {object} ErrorResponse "Internal server error" +// @Router /vector/collections/{collection}/points/delete [post] +func (handler *Handler) handleDeletePoints(ctx fiber.Ctx) error { + collection := ctx.Params("collection") + var req vectoradapter.DeletePoints + if errResponse := httputil.ParseJSONBody(ctx.Body(), &req, map[string]any{"collection": collection}); errResponse != nil { + errResponse.WriteLog(handler.logger) + return errResponse.Write(ctx) + } + deleteReq, err := vectoradapter.ToQdrantDelete(req, collection) + if err != nil { + errResponse := newVectorBackendErrorResponse("delete points", collection, err) + errResponse.WriteLog(handler.logger) + return errResponse.Write(ctx) + } + deleteReq.CollectionName = collection + if _, err := handler.qdrantClient.Delete(ctx, deleteReq); err != nil { + errResponse := newVectorBackendErrorResponse("delete points", collection, err) + errResponse.WriteLog(handler.logger) + return errResponse.Write(ctx) + } + return httputil.JSON(map[string]bool{"result": true}, http.StatusOK).Write(ctx) +} + +func vectorBackendErrorType(err error) apierror.Type { + statusCode := vectoradapter.MapQdrantErrorToHTTPStatus(err) + switch statusCode { + case http.StatusNotFound: + return apierror.TypeVectorCollectionNotFound + case http.StatusConflict: + return apierror.TypeVectorCollectionAlreadyExists + case http.StatusServiceUnavailable: + return apierror.TypeVectorStoreUnavailable + case http.StatusBadRequest: + return apierror.TypeInvalidVectorRequest + default: + return apierror.TypeVectorOperationFailed + } +} + +func newVectorBackendErrorResponse(action, collection string, err error) *httputil.ErrorResponse { + details := map[string]any{} + if collection != "" { + details["collection"] = collection + } + if len(details) == 0 { + details = nil + } + return httputil.NewError( + vectorBackendErrorType(err), + fmt.Sprintf("failed to %s: %s", action, err), + vectoradapter.MapQdrantErrorToHTTPStatus(err), + details, + &err, + ) +} diff --git a/internal/server/middleware/access.go b/internal/server/middleware/access.go new file mode 100644 index 0000000..bf6faba --- /dev/null +++ b/internal/server/middleware/access.go @@ -0,0 +1,80 @@ +package middleware + +import ( + "fmt" + "net/url" + "sort" + "strings" +) + +func ProgramProjectResourcePath(organization, project string) string { + return fmt.Sprintf("/programs/%s/projects/%s", organization, project) +} + +func ProjectAccessResourcePaths(organization, project string) []string { + return []string{ProgramProjectResourcePath(organization, project)} +} + +func NormalizeResourcePath(resource string) string { + trimmed := strings.TrimSpace(resource) + if trimmed == "" { + return "" + } + if parsed, err := url.Parse(trimmed); err == nil && parsed.Path != "" { + trimmed = parsed.Path + } + trimmed = "/" + strings.Trim(trimmed, "/") + return strings.TrimSuffix(trimmed, "/") +} + +func ResourceListAllowsProject(resources []string, organization, project string) bool { + expected := ProjectAccessResourcePaths(organization, project) + for _, resource := range resources { + normalized := NormalizeResourcePath(resource) + for _, candidate := range expected { + if normalized == candidate { + return true + } + } + } + return false +} + +func ResourcePathOrganization(resource string) (string, bool) { + normalized := NormalizeResourcePath(resource) + parts := strings.Split(normalized, "/") + if len(parts) < 3 { + return "", false + } + switch parts[1] { + case "programs": + if parts[2] != "" { + return parts[2], true + } + } + return "", false +} + +func ResourceListAllowsOrganization(resources []string, organization string) bool { + for _, resource := range resources { + if resourceOrganization, ok := ResourcePathOrganization(resource); ok && resourceOrganization == organization { + return true + } + } + return false +} + +func ResourceListAllowedOrganizations(resources []string) []string { + seen := make(map[string]struct{}) + for _, resource := range resources { + if organization, ok := ResourcePathOrganization(resource); ok { + seen[organization] = struct{}{} + } + } + organizations := make([]string, 0, len(seen)) + for organization := range seen { + organizations = append(organizations, organization) + } + sort.Strings(organizations) + return organizations +} diff --git a/internal/server/middleware/access_test.go b/internal/server/middleware/access_test.go new file mode 100644 index 0000000..a4a3afb --- /dev/null +++ b/internal/server/middleware/access_test.go @@ -0,0 +1,34 @@ +package middleware + +import "testing" + +func TestResourceListAllowsProjectMatchesProgramResource(t *testing.T) { + resources := []string{ + "/programs/Ellrott_Lab/projects/embedding_rotation", + "/programs/Ellrott_Lab/projects/git_drs_test", + } + if !ResourceListAllowsProject(resources, "Ellrott_Lab", "git_drs_test") { + t.Fatal("expected project access to be allowed") + } + if ResourceListAllowsProject(resources, "Ellrott_Lab", "missing") { + t.Fatal("expected unrelated project access to be denied") + } +} + +func TestResourceListAllowsOrganizationMatchesProgramResources(t *testing.T) { + resources := []string{ + "/programs/Ellrott_Lab/projects", + "/programs/Ellrott_Lab/projects/git_drs_test", + "/organization/HTAN_INT/project/BForePC", + } + + if !ResourceListAllowsOrganization(resources, "Ellrott_Lab") { + t.Fatal("expected program-scoped organization access to be allowed") + } + if ResourceListAllowsOrganization(resources, "HTAN_INT") { + t.Fatal("expected legacy organization access to be denied") + } + if ResourceListAllowsOrganization(resources, "gdc_mirror") { + t.Fatal("expected unrelated organization access to be denied") + } +} diff --git a/internal/server/middleware/auth.go b/internal/server/middleware/auth.go new file mode 100644 index 0000000..c64abb1 --- /dev/null +++ b/internal/server/middleware/auth.go @@ -0,0 +1,365 @@ +package middleware + +import ( + "fmt" + "net/http" + "strings" + + ggmw "github.com/bmeg/grip-graphql/middleware" + "github.com/calypr/gecko/apierror" + "github.com/calypr/gecko/config" + "github.com/calypr/gecko/internal/httputil" + "github.com/gofiber/fiber/v3" + "github.com/uc-cdis/arborist/arborist" +) + +func ResolveConfigParams(ctx fiber.Ctx) (string, string) { + configType, _ := ctx.Locals("configType").(string) + if configType == "" { + configType = ctx.Params("configType") + } + configID := ctx.Params("configId") + + if configType == "" { + configType = string(config.TypeExplorer) + } + if configID == "" { + configID = config.DefaultConfigID + } + + return configType, configID +} + +func ConfigAuth(logger arborist.Logger, authzHandler ResourceAccessHandler) fiber.Handler { + return func(ctx fiber.Ctx) error { + method := ctx.Method() + configType, configID := ResolveConfigParams(ctx) + + if configType == string(config.TypeExplorer) { + var permMethod string + switch method { + case fiber.MethodGet: + permMethod = "read" + case fiber.MethodPut, fiber.MethodDelete: + permMethod = "create" + default: + return writeError(ctx, logger, httputil.NewError(apierror.TypeMethodNotAllowed, fmt.Sprintf("Unsupported HTTP method %s on %s", method, ctx.Path()), http.StatusMethodNotAllowed, map[string]any{"method": method}, nil)) + } + ctx.Locals("projectId", configID) + return GeneralAuth(logger, authzHandler, permMethod, "*")(ctx) + } + + if method == fiber.MethodGet { + return ctx.Next() + } + if method == fiber.MethodPut || method == fiber.MethodDelete { + return writeError(ctx, logger, httputil.NewError( + apierror.TypeForbidden, + fmt.Sprintf("Route %s %s must use route-specific authorization; refusing global /programs fallback", method, ctx.Path()), + http.StatusForbidden, + map[string]any{"method": method, "path": ctx.Path(), "config_type": configType}, + nil, + )) + } + + return writeError(ctx, logger, httputil.NewError(apierror.TypeMethodNotAllowed, fmt.Sprintf("Unsupported HTTP method %s on %s", method, ctx.Path()), http.StatusMethodNotAllowed, map[string]any{"method": method}, nil)) + } +} + +func GeneralAuth(logger arborist.Logger, authzHandler ResourceAccessHandler, method, service string) fiber.Handler { + return func(ctx fiber.Ctx) error { + authorizationHeader := ctx.Get("Authorization") + if authorizationHeader == "" { + return writeError(ctx, logger, httputil.NewError(apierror.TypeMissingAuthorization, "Authorization token not provided", http.StatusUnauthorized, nil, nil)) + } + + projectID, _ := ctx.Locals("projectId").(string) + if projectID == "" { + projectID = ctx.Params("projectId") + } + projectSplit := strings.Split(projectID, "-") + if len(projectSplit) != 2 { + return writeError(ctx, logger, httputil.NewError(apierror.TypeInvalidProjectID, fmt.Sprintf("Failed to parse request body: %v", fmt.Sprintf("incorrect path %s", ctx.Path())), http.StatusNotFound, map[string]any{"project_id": projectID}, nil)) + } + + anyList, err := authzHandler.GetAllowedResources(authorizationHeader, method, service) + if err != nil { + if serverErr, ok := err.(*ggmw.ServerError); ok { + return writeError(ctx, logger, httputil.NewError(serviceErrorType(serverErr.StatusCode), serverErr.Message, serverErr.StatusCode, nil, nil)) + } + if accessErr, ok := err.(*AccessError); ok { + return writeError(ctx, logger, httputil.NewError(serviceErrorType(accessErr.StatusCode), accessErr.Message, accessErr.StatusCode, nil, nil)) + } + return writeError(ctx, logger, httputil.NewError(apierror.TypeNotFound, "expecting error to be serverError type", http.StatusNotFound, nil, nil)) + } + + resourceList, convErr := convertAnyToStringSlice(anyList) + if convErr != nil { + return writeError(ctx, logger, convErr) + } + resource := "/programs/" + projectSplit[0] + "/projects/" + projectSplit[1] + if len(resourceList) == 0 { + return writeError(ctx, logger, httputil.NewError(apierror.TypeForbidden, fmt.Sprintf("User is not allowed to %s on any resource path", method), http.StatusForbidden, map[string]any{"resource": resource, "method": method}, nil)) + } + allowed := false + for _, candidate := range resourceList { + if candidate == resource { + allowed = true + break + } + } + if !allowed { + return writeError(ctx, logger, httputil.NewError(apierror.TypeForbidden, fmt.Sprintf("User is not allowed to %s on resource path: %s", method, resource), http.StatusForbidden, map[string]any{"resource": resource, "method": method}, nil)) + } + return ctx.Next() + } +} + +func BaseConfigsAuth(logger arborist.Logger, authzHandler ResourceAccessHandler, method, service, resourcePath string) fiber.Handler { + return func(ctx fiber.Ctx) error { + authorizationHeader := ctx.Get("Authorization") + if authorizationHeader == "" { + return writeError(ctx, logger, httputil.NewError(apierror.TypeMissingAuthorization, "Authorization token not provided", http.StatusUnauthorized, nil, nil)) + } + + if _, ok := authzHandler.(*FenceUserAccessHandler); !ok { + return writeError(ctx, logger, httputil.NewError("internal_server_error", "Invalid JWT handler configuration", http.StatusInternalServerError, nil, nil)) + } + + allowed, err := authzHandler.CheckResourceServiceAccess(authorizationHeader, method, service, resourcePath) + if err != nil { + if serverErr, ok := err.(*ggmw.ServerError); ok { + return writeError(ctx, logger, httputil.NewError(serviceErrorType(serverErr.StatusCode), serverErr.Message, serverErr.StatusCode, nil, nil)) + } + if accessErr, ok := err.(*AccessError); ok { + return writeError(ctx, logger, httputil.NewError(serviceErrorType(accessErr.StatusCode), accessErr.Message, accessErr.StatusCode, nil, nil)) + } + return writeError(ctx, logger, httputil.NewError(apierror.TypeAuthorizationServiceError, err.Error(), http.StatusForbidden, nil, nil)) + } + if !allowed { + return writeError(ctx, logger, httputil.NewError(apierror.TypeForbidden, fmt.Sprintf("User does not have required %s permission on resource %s", method, "/programs"), http.StatusForbidden, map[string]any{"resource": resourcePath, "method": method}, nil)) + } + return ctx.Next() + } +} + +func ProjectConfigAuth(logger arborist.Logger, authzHandler ResourceAccessHandler, method string) fiber.Handler { + return func(ctx fiber.Ctx) error { + authorizationHeader := ctx.Get("Authorization") + if authorizationHeader == "" { + return writeError(ctx, logger, httputil.NewError(apierror.TypeMissingAuthorization, "Authorization token not provided", http.StatusUnauthorized, nil, nil)) + } + organization := strings.TrimSpace(ctx.Params("orgTitle")) + project := strings.TrimSpace(ctx.Params("projectTitle")) + if organization == "" || project == "" { + return writeError(ctx, logger, httputil.NewError("invalid_request", "organization and project are required", http.StatusBadRequest, nil, nil)) + } + resourcePath := ProgramProjectResourcePath(organization, project) + allowed, err := authzHandler.CheckResourceServiceAccess(authorizationHeader, method, "*", resourcePath) + if err != nil { + if serverErr, ok := err.(*AccessError); ok { + return writeError(ctx, logger, httputil.NewError(serviceErrorType(serverErr.StatusCode), serverErr.Message, serverErr.StatusCode, nil, nil)) + } + return writeError(ctx, logger, httputil.NewError(apierror.TypeAuthorizationServiceError, err.Error(), http.StatusForbidden, nil, nil)) + } + if !allowed { + anyList, listErr := authzHandler.GetAllowedResources(authorizationHeader, method, "*") + if listErr != nil { + if serverErr, ok := listErr.(*AccessError); ok { + return writeError(ctx, logger, httputil.NewError(serviceErrorType(serverErr.StatusCode), serverErr.Message, serverErr.StatusCode, nil, nil)) + } + return writeError(ctx, logger, httputil.NewError(apierror.TypeAuthorizationServiceError, listErr.Error(), http.StatusForbidden, nil, nil)) + } + resources, conversionErr := convertAnyToStringSlice(anyList) + if conversionErr != nil { + return writeError(ctx, logger, conversionErr) + } + allowed = resourceListAllowsProjectAdminAction(resources, organization, project) + } + if !allowed { + return writeError(ctx, logger, httputil.NewError(apierror.TypeForbidden, fmt.Sprintf("User does not have required %s permission on resource %s", method, resourcePath), http.StatusForbidden, map[string]any{ + "resource": resourcePath, + "method": method, + "organization": organization, + "project": project, + }, nil)) + } + return ctx.Next() + } +} + +func resourceListAllowsProjectAdminAction(resources []string, organization string, project string) bool { + projectResource := ProgramProjectResourcePath(organization, project) + projectCollectionResource := fmt.Sprintf("/programs/%s/projects", organization) + organizationResource := fmt.Sprintf("/programs/%s", organization) + + for _, resource := range resources { + switch resource { + case "*", "/", "/programs", organizationResource, projectCollectionResource, projectResource: + return true + } + } + return false +} + +func RequireAuthorization(logger arborist.Logger) fiber.Handler { + return func(ctx fiber.Ctx) error { + authorizationHeader := strings.TrimSpace(ctx.Get("Authorization")) + if authorizationHeader == "" { + return writeError(ctx, logger, httputil.NewError(apierror.TypeMissingAuthorization, "Authorization token not provided", http.StatusUnauthorized, nil, nil)) + } + return ctx.Next() + } +} + +func GitProjectAuth(logger arborist.Logger, jwtHandler ResourceAccessHandler) fiber.Handler { + return func(ctx fiber.Ctx) error { + if jwtHandler == nil { + return ctx.Next() + } + permission := "read" + token := ctx.Get("Authorization") + if token == "" { + return writeError(ctx, logger, httputil.NewError("missing_authorization", "Authorization token not provided", http.StatusUnauthorized, nil, nil)) + } + organization := strings.TrimSpace(ctx.Params("orgTitle")) + project := strings.TrimSpace(ctx.Params("projectTitle")) + if organization == "" || project == "" { + return writeError(ctx, logger, httputil.NewError("invalid_request", "organization and project are required", http.StatusBadRequest, nil, nil)) + } + resources, conversionErr := GitAllowedResources(jwtHandler, token, permission) + if conversionErr != nil { + return writeError(ctx, logger, conversionErr) + } + if GitProjectReadable(resources, organization, project) { + return ctx.Next() + } + return writeError(ctx, logger, httputil.NewError("forbidden", fmt.Sprintf("User is not allowed to %s on project %s/%s", permission, organization, project), http.StatusForbidden, map[string]any{ + "organization": organization, + "project": project, + "method": permission, + "resource_path": ProgramProjectResourcePath(organization, project), + "request_access": true, + "request_access_resource_path": ProgramProjectResourcePath(organization, project), + }, nil)) + } +} + +func GitOrganizationAuth(logger arborist.Logger, jwtHandler ResourceAccessHandler) fiber.Handler { + return func(ctx fiber.Ctx) error { + if jwtHandler == nil { + return ctx.Next() + } + token := ctx.Get("Authorization") + if token == "" { + return writeError(ctx, logger, httputil.NewError("missing_authorization", "Authorization token not provided", http.StatusUnauthorized, nil, nil)) + } + organization := strings.TrimSpace(ctx.Params("orgTitle")) + if organization == "" { + return writeError(ctx, logger, httputil.NewError("invalid_request", "organization is required", http.StatusBadRequest, nil, nil)) + } + allowed, err := jwtHandler.GetAllowedResources(token, "read", "*") + if err != nil { + return writeError(ctx, logger, httputil.NewError("authorization_service_error", fmt.Sprintf("authorization lookup failed: %s", err), http.StatusForbidden, nil, nil)) + } + resources, conversionErr := convertAnyToStringSlice(allowed) + if conversionErr != nil { + return writeError(ctx, logger, conversionErr) + } + if ResourceListAllowsOrganization(resources, organization) { + return ctx.Next() + } + return writeError(ctx, logger, httputil.NewError("forbidden", fmt.Sprintf("User is not allowed to read organization %s", organization), http.StatusForbidden, map[string]any{"organization": organization, "method": "read"}, nil)) + } +} + +func GitAllowedResources(jwtHandler ResourceAccessHandler, token string, permission string) ([]string, *httputil.ErrorResponse) { + allowed, err := jwtHandler.GetAllowedResources(token, permission, "*") + if err != nil { + if serverErr, ok := err.(*AccessError); ok { + return nil, httputil.NewError(serviceErrorType(serverErr.StatusCode), serverErr.Message, serverErr.StatusCode, nil, nil) + } + return nil, httputil.NewError("authorization_service_error", fmt.Sprintf("authorization lookup failed: %s", err), http.StatusForbidden, nil, nil) + } + return convertAnyToStringSlice(allowed) +} + +func GitProjectReadable(resources []string, organization string, project string) bool { + return ResourceListAllowsProject(resources, organization, project) +} + +func serviceErrorType(code int) apierror.Type { + switch code { + case http.StatusUnauthorized: + return apierror.TypeUnauthorized + case http.StatusForbidden: + return apierror.TypeForbidden + case http.StatusNotFound: + return apierror.TypeNotFound + case http.StatusMethodNotAllowed: + return apierror.TypeMethodNotAllowed + default: + return apierror.TypeAuthorizationServiceError + } +} + +func writeError(ctx fiber.Ctx, logger arborist.Logger, response *httputil.ErrorResponse) error { + response.WriteLog(logger) + return response.Write(ctx) +} + +// Migrated from internal/authz +func GetProjectsFromToken(ctx fiber.Ctx, authzHandler ResourceAccessHandler, method string, service string) ([]any, *httputil.ErrorResponse) { + token := ctx.Get("Authorization") + if token == "" { + return nil, httputil.NewError(apierror.TypeMissingAuthorization, "Authorization token not provided", http.StatusUnauthorized, nil, nil) + } + anyList, err := authzHandler.GetAllowedResources(token, method, service) + if err != nil { + if serverErr, ok := err.(*AccessError); ok { + return nil, httputil.NewError(serviceErrorType(serverErr.StatusCode), serverErr.Message, serverErr.StatusCode, nil, nil) + } + return nil, httputil.NewError(apierror.TypeAuthorizationServiceError, err.Error(), http.StatusForbidden, nil, nil) + } + return anyList, nil +} + +// Migrated from internal/authz +func ParseAccess(resourceList []string, resource string, method string) *httputil.ErrorResponse { + if len(resourceList) == 0 { + return httputil.NewError(apierror.TypeForbidden, fmt.Sprintf("User is not allowed to %s on any resource path", method), http.StatusForbidden, map[string]any{"resource": resource, "method": method}, nil) + } + for _, v := range resourceList { + if v == resource { + return nil + } + } + return httputil.NewError(apierror.TypeForbidden, fmt.Sprintf("User is not allowed to %s on resource path: %s", method, resource), http.StatusForbidden, map[string]any{"resource": resource, "method": method}, nil) +} + +func CleanAccessToken(raw string) string { + token := strings.TrimSpace(raw) + if strings.HasPrefix(strings.ToLower(token), "bearer ") { + token = strings.TrimSpace(token[len("bearer "):]) + } + return token +} + +func ValidateAccessToken(raw string) (string, error) { + token := CleanAccessToken(raw) + if token == "" { + return "", fmt.Errorf("git access token is required") + } + return token, nil +} + +func ValidateAuthorizationHeader(raw string) (string, error) { + token := strings.TrimSpace(raw) + if token == "" { + return "", fmt.Errorf("authorization header is required") + } + if !strings.HasPrefix(strings.ToLower(token), "bearer ") { + return "", fmt.Errorf("authorization header must use bearer auth") + } + return token, nil +} diff --git a/internal/server/middleware/fence_user_test.go b/internal/server/middleware/fence_user_test.go new file mode 100644 index 0000000..7eb7fa5 --- /dev/null +++ b/internal/server/middleware/fence_user_test.go @@ -0,0 +1,30 @@ +package middleware + +import "testing" + +func TestSnapshotAllows(t *testing.T) { + raw := []any{ + map[string]any{"service": "*", "method": "read"}, + map[string]any{"service": "arborist", "method": "create-descendant"}, + } + if !snapshotAllows(raw, "read", "*") { + t.Fatalf("expected wildcard read to match") + } + if !snapshotAllows(raw, "create-descendant", "arborist") { + t.Fatalf("expected arborist create-descendant to match") + } + if snapshotAllows(raw, "update", "*") { + t.Fatalf("did not expect update to match") + } +} + +func TestFenceUserEndpoint(t *testing.T) { + token := "Bearer eyJhbGciOiJub25lIn0.eyJpc3MiOiJodHRwczovL2V4YW1wbGUub3JnL3VzZXIifQ." + endpoint, err := fenceUserEndpoint(token) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if endpoint != "https://example.org/user/user" { + t.Fatalf("unexpected endpoint %q", endpoint) + } +} diff --git a/internal/server/middleware/git_test.go b/internal/server/middleware/git_test.go new file mode 100644 index 0000000..bfcae95 --- /dev/null +++ b/internal/server/middleware/git_test.go @@ -0,0 +1,332 @@ +package middleware + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "errors" + "io" + "log" + "net/http" + "net/http/httptest" + "testing" + + geckologging "github.com/calypr/gecko/internal/logging" + "github.com/gofiber/fiber/v3" +) + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (fn roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return fn(req) +} + +type fakeJWTAllowedResourceHandler struct { + resources []any +} + +func (handler fakeJWTAllowedResourceHandler) GetAllowedResources(_ string, _ string, _ string) ([]any, error) { + return handler.resources, nil +} + +func (handler fakeJWTAllowedResourceHandler) CheckResourceServiceAccess(_ string, _ string, _ string, resourcePath string) (bool, error) { + for _, resource := range handler.resources { + if value, ok := resource.(string); ok && value == resourcePath { + return true, nil + } + } + return false, nil +} + +func TestGitProjectAuthAllowsProgramProjectResource(t *testing.T) { + logger := &geckologging.Handler{Logger: log.New(io.Discard, "", 0)} + app := fiber.New() + app.Get("/git/projects/:orgTitle/:projectTitle", GitProjectAuth(logger, fakeJWTAllowedResourceHandler{resources: []any{"/programs/org-a/projects/proj-a"}}), func(ctx fiber.Ctx) error { + return ctx.SendStatus(fiber.StatusOK) + }) + + req := httptest.NewRequest("GET", "/git/projects/org-a/proj-a", nil) + req.Header.Set("Authorization", "Bearer test") + resp, err := app.Test(req) + if err != nil { + t.Fatalf("unexpected app.Test error: %v", err) + } + if resp.StatusCode != fiber.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } +} + +func buildUnverifiedJWT(t *testing.T, claims map[string]any) string { + t.Helper() + headerBody, err := json.Marshal(map[string]any{"alg": "none", "typ": "JWT"}) + if err != nil { + t.Fatalf("marshal header: %v", err) + } + body, err := json.Marshal(claims) + if err != nil { + t.Fatalf("marshal claims: %v", err) + } + return base64.RawURLEncoding.EncodeToString(headerBody) + "." + base64.RawURLEncoding.EncodeToString(body) + ".sig" +} + +func TestGitProjectAuthRejectsLegacyOrganizationResource(t *testing.T) { + logger := &geckologging.Handler{Logger: log.New(io.Discard, "", 0)} + app := fiber.New() + app.Get("/git/projects/:orgTitle/:projectTitle", GitProjectAuth(logger, fakeJWTAllowedResourceHandler{resources: []any{"/organization/org-a/project/proj-a"}}), func(ctx fiber.Ctx) error { + return ctx.SendStatus(fiber.StatusOK) + }) + + req := httptest.NewRequest("GET", "/git/projects/org-a/proj-a", nil) + req.Header.Set("Authorization", "Bearer test") + resp, err := app.Test(req) + if err != nil { + t.Fatalf("unexpected app.Test error: %v", err) + } + if resp.StatusCode != fiber.StatusForbidden { + t.Fatalf("expected 403, got %d", resp.StatusCode) + } +} + +func TestGitOrganizationAuthAllowsProjectResource(t *testing.T) { + logger := &geckologging.Handler{Logger: log.New(io.Discard, "", 0)} + app := fiber.New() + app.Post("/git/organizations/:orgTitle/init-connect", GitOrganizationAuth(logger, fakeJWTAllowedResourceHandler{resources: []any{"/programs/org-a"}}), func(ctx fiber.Ctx) error { + return ctx.SendStatus(fiber.StatusOK) + }) + + req := httptest.NewRequest("POST", "/git/organizations/org-a/init-connect", nil) + req.Header.Set("Authorization", "Bearer test") + resp, err := app.Test(req) + if err != nil { + t.Fatalf("unexpected app.Test error: %v", err) + } + if resp.StatusCode != fiber.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } +} + +func TestGitOrganizationAuthRejectsLegacyOrganizationResourcePath(t *testing.T) { + logger := &geckologging.Handler{Logger: log.New(io.Discard, "", 0)} + app := fiber.New() + app.Post("/git/organizations/:orgTitle/init-connect", GitOrganizationAuth(logger, fakeJWTAllowedResourceHandler{resources: []any{"/organization/org-a/project/proj-a"}}), func(ctx fiber.Ctx) error { + return ctx.SendStatus(fiber.StatusOK) + }) + + req := httptest.NewRequest("POST", "/git/organizations/org-a/init-connect", nil) + req.Header.Set("Authorization", "Bearer test") + resp, err := app.Test(req) + if err != nil { + t.Fatalf("unexpected app.Test error: %v", err) + } + if resp.StatusCode != fiber.StatusForbidden { + t.Fatalf("expected 403, got %d", resp.StatusCode) + } +} + +func TestRequireAuthorizationRejectsMissingHeader(t *testing.T) { + logger := &geckologging.Handler{Logger: log.New(io.Discard, "", 0)} + app := fiber.New() + app.Post("/git/organizations/:orgTitle/init-connect", RequireAuthorization(logger), func(ctx fiber.Ctx) error { + return ctx.SendStatus(fiber.StatusOK) + }) + + req := httptest.NewRequest("POST", "/git/organizations/org-a/init-connect", nil) + resp, err := app.Test(req) + if err != nil { + t.Fatalf("unexpected app.Test error: %v", err) + } + if resp.StatusCode != fiber.StatusUnauthorized { + t.Fatalf("expected 401, got %d", resp.StatusCode) + } +} + +func TestRequireAuthorizationAllowsBearerHeader(t *testing.T) { + logger := &geckologging.Handler{Logger: log.New(io.Discard, "", 0)} + app := fiber.New() + app.Post("/git/projects/:orgTitle/:projectTitle/edit-connect", RequireAuthorization(logger), func(ctx fiber.Ctx) error { + return ctx.SendStatus(fiber.StatusOK) + }) + + req := httptest.NewRequest("POST", "/git/projects/org-a/proj-a/edit-connect", nil) + req.Header.Set("Authorization", "Bearer test") + resp, err := app.Test(req) + if err != nil { + t.Fatalf("unexpected app.Test error: %v", err) + } + if resp.StatusCode != fiber.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } +} + +func TestParseResourceAccessSnapshotPrefersAuthzBlock(t *testing.T) { + snapshot, err := parseResourceAccessSnapshot(map[string]any{ + "authz": map[string]any{ + "/programs/org-a": []any{ + map[string]any{"service": "arborist", "method": "manage-owners"}, + }, + "/programs/org-a/projects/proj-a": []any{ + map[string]any{"service": "*", "method": "read"}, + map[string]any{"service": "*", "method": "update"}, + }, + }, + }) + if err != nil { + t.Fatalf("unexpected parse error: %v", err) + } + if !ResourceAccessAllows(snapshot, "/programs/org-a", "manage-owners", "arborist") { + t.Fatal("expected org manage-owners access to be present") + } + if !ResourceAccessAllows(snapshot, "/programs/org-a/projects/proj-a", "update", "*") { + t.Fatal("expected project update access to be present") + } + if ResourceAccessAllows(snapshot, "/programs/org-a/projects/proj-b", "update", "*") { + t.Fatal("did not expect unrelated project access") + } +} + +func TestFenceUserAccessHandlerGetResourceAccessUsesProjectAccessFallback(t *testing.T) { + responseBody, err := json.Marshal(map[string]any{ + "project_access": map[string]any{ + "/programs/org-a/projects/proj-a": []any{ + map[string]any{"service": "*", "method": "read"}, + map[string]any{"service": "*", "method": "update"}, + }, + }, + }) + if err != nil { + t.Fatalf("marshal response body: %v", err) + } + client := &http.Client{ + Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(bytes.NewReader(responseBody)), + }, nil + }), + } + + token := "Bearer " + buildUnverifiedJWT(t, map[string]any{"iss": "https://example.test"}) + handler := NewFenceUserAccessHandler(client) + snapshot, err := handler.GetResourceAccess(token) + if err != nil { + t.Fatalf("unexpected access lookup error: %v", err) + } + if !ResourceAccessAllows(snapshot, "/programs/org-a/projects/proj-a", "read", "*") { + t.Fatal("expected read access from project_access fallback") + } + if !ResourceAccessAllows(snapshot, "/programs/org-a/projects/proj-a", "update", "*") { + t.Fatal("expected update access from project_access fallback") + } +} + +func TestParseResourceAccessSnapshotRejectsMissingBlocks(t *testing.T) { + _, err := parseResourceAccessSnapshot(map[string]any{}) + var accessErr *AccessError + if !errors.As(err, &accessErr) { + t.Fatalf("expected AccessError, got %T", err) + } + if accessErr.StatusCode != http.StatusBadGateway { + t.Fatalf("expected 502-style access error, got %d", accessErr.StatusCode) + } +} + +func TestProjectConfigAuthAllowsExactProjectResource(t *testing.T) { + logger := &geckologging.Handler{Logger: log.New(io.Discard, "", 0)} + app := fiber.New() + app.Delete("/config/projects/:orgTitle/:projectTitle", ProjectConfigAuth(logger, fakeJWTAllowedResourceHandler{resources: []any{"/programs/org-a/projects/proj-a"}}, "delete"), func(ctx fiber.Ctx) error { + return ctx.SendStatus(fiber.StatusOK) + }) + + req := httptest.NewRequest("DELETE", "/config/projects/org-a/proj-a", nil) + req.Header.Set("Authorization", "Bearer test") + resp, err := app.Test(req) + if err != nil { + t.Fatalf("unexpected app.Test error: %v", err) + } + if resp.StatusCode != fiber.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } +} + +func TestProjectConfigAuthRejectsDifferentProjectResource(t *testing.T) { + logger := &geckologging.Handler{Logger: log.New(io.Discard, "", 0)} + app := fiber.New() + app.Delete("/config/projects/:orgTitle/:projectTitle", ProjectConfigAuth(logger, fakeJWTAllowedResourceHandler{resources: []any{"/programs/org-a/projects/other"}}, "delete"), func(ctx fiber.Ctx) error { + return ctx.SendStatus(fiber.StatusOK) + }) + + req := httptest.NewRequest("DELETE", "/config/projects/org-a/proj-a", nil) + req.Header.Set("Authorization", "Bearer test") + resp, err := app.Test(req) + if err != nil { + t.Fatalf("unexpected app.Test error: %v", err) + } + if resp.StatusCode != fiber.StatusForbidden { + t.Fatalf("expected 403, got %d", resp.StatusCode) + } +} + +func TestProjectConfigAuthAllowsAdminWildcardResource(t *testing.T) { + logger := &geckologging.Handler{Logger: log.New(io.Discard, "", 0)} + app := fiber.New() + app.Delete("/config/projects/:orgTitle/:projectTitle", ProjectConfigAuth(logger, fakeJWTAllowedResourceHandler{resources: []any{"*"}}, "delete"), func(ctx fiber.Ctx) error { + return ctx.SendStatus(fiber.StatusOK) + }) + + req := httptest.NewRequest("DELETE", "/config/projects/org-a/proj-a", nil) + req.Header.Set("Authorization", "Bearer test") + resp, err := app.Test(req) + if err != nil { + t.Fatalf("unexpected app.Test error: %v", err) + } + if resp.StatusCode != fiber.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } +} + +func TestSnapshotAllowsAcceptsWildcardMethod(t *testing.T) { + raw := []any{ + map[string]any{ + "method": "*", + "service": "*", + }, + } + + if !snapshotAllows(raw, "delete", "*") { + t.Fatalf("expected wildcard method/service snapshot entry to allow delete") + } +} + +func TestGitProjectAuthForbiddenIncludesRequestAccessDetails(t *testing.T) { + logger := &geckologging.Handler{Logger: log.New(io.Discard, "", 0)} + app := fiber.New() + app.Get("/git/projects/:orgTitle/:projectTitle", GitProjectAuth(logger, fakeJWTAllowedResourceHandler{resources: []any{"/programs/org-a/projects/other"}}), func(ctx fiber.Ctx) error { + return ctx.SendStatus(fiber.StatusOK) + }) + + req := httptest.NewRequest("GET", "/git/projects/org-a/proj-a", nil) + req.Header.Set("Authorization", "Bearer test") + resp, err := app.Test(req) + if err != nil { + t.Fatalf("unexpected app.Test error: %v", err) + } + if resp.StatusCode != fiber.StatusForbidden { + t.Fatalf("expected 403, got %d", resp.StatusCode) + } + + var payload struct { + Error struct { + Details map[string]any `json:"details"` + } `json:"error"` + } + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + t.Fatalf("decode response: %v", err) + } + if got := payload.Error.Details["request_access"]; got != true { + t.Fatalf("expected request_access=true, got %#v", got) + } + if got := payload.Error.Details["request_access_resource_path"]; got != "/programs/org-a/projects/proj-a" { + t.Fatalf("unexpected request_access_resource_path: %#v", got) + } +} diff --git a/internal/server/middleware/interface.go b/internal/server/middleware/interface.go new file mode 100644 index 0000000..ab4ad24 --- /dev/null +++ b/internal/server/middleware/interface.go @@ -0,0 +1,6 @@ +package middleware + +type ResourceAccessHandler interface { + GetAllowedResources(token, method, service string) ([]any, error) + CheckResourceServiceAccess(token, method, service, resourcePath string) (bool, error) +} diff --git a/internal/server/middleware/logging.go b/internal/server/middleware/logging.go new file mode 100644 index 0000000..7141b37 --- /dev/null +++ b/internal/server/middleware/logging.go @@ -0,0 +1,50 @@ +package middleware + +import ( + "time" + + "github.com/gofiber/fiber/v3" + "github.com/uc-cdis/arborist/arborist" +) + +func RequestLogger(logger arborist.Logger) fiber.Handler { + return func(ctx fiber.Ctx) error { + start := time.Now() + err := ctx.Next() + latency := time.Since(start) + + routePattern := "" + if route := ctx.Route(); route != nil && route.Path != "" { + routePattern = route.Path + } + + logger.Info( + "%s %s - Status: %d - Latency: %s - Host: %s - IP: %s - Path: %s - Query: %s - Route: %s - Params: %v - RequestID: %s", + ctx.Method(), + ctx.OriginalURL(), + ctx.Response().StatusCode(), + latency, + ctx.Hostname(), + ctx.IP(), + ctx.Path(), + string(ctx.Request().URI().QueryString()), + routePattern, + routeParams(ctx), + ctx.Get("X-Request-Id"), + ) + return err + } +} + +func routeParams(ctx fiber.Ctx) map[string]string { + params := make(map[string]string) + for _, name := range []string{"configType", "configId", "orgTitle", "projectTitle", "projectId", "collection", "id"} { + if value := ctx.Params(name); value != "" { + params[name] = value + } + } + if len(params) == 0 { + return nil + } + return params +} diff --git a/internal/server/middleware/middleware.go b/internal/server/middleware/middleware.go new file mode 100644 index 0000000..75cd8c8 --- /dev/null +++ b/internal/server/middleware/middleware.go @@ -0,0 +1,211 @@ +package middleware + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + "github.com/calypr/gecko/apierror" + "github.com/calypr/gecko/internal/httputil" + "github.com/golang-jwt/jwt/v5" +) + +type AccessError struct { + StatusCode int + Message string +} + +func (e *AccessError) Error() string { + return e.Message +} + +type ResourceAccessRecord struct { + Method string + Service string +} + +type ResourceAccessSnapshot map[string][]ResourceAccessRecord + +type FenceUserAccessHandler struct { + client *http.Client +} + +func NewFenceUserAccessHandler(client *http.Client) *FenceUserAccessHandler { + if client == nil { + client = http.DefaultClient + } + return &FenceUserAccessHandler{client: client} +} + +func (h *FenceUserAccessHandler) CheckResourceServiceAccess(token, method, service, resourcePath string) (bool, error) { + allowed, err := h.GetAllowedResources(token, method, service) + if err != nil { + return false, err + } + resources, convErr := convertAnyToStringSlice(allowed) + if convErr != nil { + return false, &AccessError{StatusCode: http.StatusInternalServerError, Message: "authorization snapshot returned a non-string resource"} + } + for _, resource := range resources { + if resource == resourcePath { + return true, nil + } + } + return false, nil +} + +func (h *FenceUserAccessHandler) GetAllowedResources(token, method, service string) ([]any, error) { + snapshot, err := h.GetResourceAccess(token) + if err != nil { + return nil, err + } + out := make([]any, 0, len(snapshot)) + for resource := range snapshot { + if ResourceAccessAllows(snapshot, resource, method, service) { + out = append(out, resource) + } + } + return out, nil +} + +func (h *FenceUserAccessHandler) GetResourceAccess(token string) (ResourceAccessSnapshot, error) { + endpoint, err := fenceUserEndpoint(token) + if err != nil { + return nil, &AccessError{StatusCode: http.StatusUnauthorized, Message: err.Error()} + } + validAuthorizationHeader, err := ValidateAuthorizationHeader(token) + if err != nil { + return nil, &AccessError{StatusCode: http.StatusUnauthorized, Message: err.Error()} + } + + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, endpoint, nil) + if err != nil { + return nil, &AccessError{StatusCode: http.StatusBadGateway, Message: fmt.Sprintf("failed to build authorization snapshot request: %s", err)} + } + req.Header.Set("Authorization", validAuthorizationHeader) + + resp, err := h.client.Do(req) + if err != nil { + return nil, &AccessError{StatusCode: http.StatusBadGateway, Message: fmt.Sprintf("authorization snapshot request failed: %s", err)} + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, &AccessError{StatusCode: http.StatusBadGateway, Message: fmt.Sprintf("failed to read authorization snapshot response: %s", err)} + } + if resp.StatusCode >= 400 { + message := strings.TrimSpace(string(body)) + if message == "" { + message = fmt.Sprintf("authorization snapshot request failed with status %d", resp.StatusCode) + } + return nil, &AccessError{StatusCode: resp.StatusCode, Message: message} + } + + var payload map[string]any + if err := json.Unmarshal(body, &payload); err != nil { + return nil, &AccessError{StatusCode: http.StatusBadGateway, Message: fmt.Sprintf("invalid authorization snapshot response: %s", err)} + } + return parseResourceAccessSnapshot(payload) +} + +func parseResourceAccessSnapshot(payload map[string]any) (ResourceAccessSnapshot, error) { + resourceAccess, ok := payload["authz"].(map[string]any) + if !ok || len(resourceAccess) == 0 { + resourceAccess, ok = payload["project_access"].(map[string]any) + if !ok { + return nil, &AccessError{StatusCode: http.StatusBadGateway, Message: "authorization snapshot response did not include authz/project_access"} + } + } + + snapshot := make(ResourceAccessSnapshot, len(resourceAccess)) + for resource, raw := range resourceAccess { + entries, ok := raw.([]any) + if !ok { + continue + } + records := make([]ResourceAccessRecord, 0, len(entries)) + for _, entry := range entries { + record, ok := entry.(map[string]any) + if !ok { + continue + } + method, _ := record["method"].(string) + service, _ := record["service"].(string) + records = append(records, ResourceAccessRecord{ + Method: method, + Service: service, + }) + } + snapshot[resource] = records + } + return snapshot, nil +} + +func snapshotAllows(raw any, method, service string) bool { + entries, ok := raw.([]any) + if !ok { + return false + } + for _, entry := range entries { + record, ok := entry.(map[string]any) + if !ok { + continue + } + entryMethod, _ := record["method"].(string) + entryService, _ := record["service"].(string) + if entryMethod != method && entryMethod != "*" { + continue + } + if entryService == "*" || service == "*" || entryService == service { + return true + } + } + return false +} + +func ResourceAccessAllows(snapshot ResourceAccessSnapshot, resourcePath, method, service string) bool { + entries := snapshot[resourcePath] + for _, entry := range entries { + if entry.Method != method && entry.Method != "*" { + continue + } + if entry.Service == "*" || service == "*" || entry.Service == service { + return true + } + } + return false +} + +func fenceUserEndpoint(authorizationHeader string) (string, error) { + token := CleanAccessToken(authorizationHeader) + if token == "" { + return "", fmt.Errorf("authorization header is required") + } + parser := jwt.NewParser(jwt.WithoutClaimsValidation()) + claims := jwt.MapClaims{} + if _, _, err := parser.ParseUnverified(token, claims); err != nil { + return "", fmt.Errorf("failed to parse authorization token: %w", err) + } + iss, _ := claims["iss"].(string) + iss = strings.TrimSpace(iss) + if iss == "" { + return "", fmt.Errorf("authorization token does not include iss") + } + return strings.TrimRight(iss, "/") + "/user", nil +} + +func convertAnyToStringSlice(anySlice []any) ([]string, *httputil.ErrorResponse) { + var stringSlice []string + for _, v := range anySlice { + str, ok := v.(string) + if !ok { + return nil, httputil.NewError(apierror.TypeInvalidAuthorizationResponse, fmt.Sprintf("Element %v is not a string", v), http.StatusInternalServerError, nil, nil) + } + stringSlice = append(stringSlice, str) + } + return stringSlice, nil +} diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..df8a45e --- /dev/null +++ b/internal/server/server.go @@ -0,0 +1,129 @@ +package server + +import ( + "errors" + "log" + "net/http" + "time" + + "github.com/bmeg/grip/gripql" + "github.com/calypr/gecko/internal/git" + geckologging "github.com/calypr/gecko/internal/logging" + httpapi "github.com/calypr/gecko/internal/server/http" + servermw "github.com/calypr/gecko/internal/server/middleware" + "github.com/calypr/gecko/internal/thumbnail" + "github.com/gofiber/fiber/v3" + "github.com/jmoiron/sqlx" + "github.com/qdrant/go-client/qdrant" + "github.com/uc-cdis/arborist/arborist" +) + +type Server struct { + db *sqlx.DB + jwtApp arborist.JWTDecoder + Logger *geckologging.Handler + stmts *arborist.CachedStmts + qdrantClient *qdrant.Client + gripqlClient *gripql.Client + gripGraphName string + gitService *git.GitService + thumbnailStore thumbnail.Manager +} + +func NewServer() *Server { return &Server{} } + +func (server *Server) WithLogger(logger *log.Logger) *Server { + server.Logger = &geckologging.Handler{Logger: logger} + return server +} + +func (server *Server) WithJWTApp(jwtApp arborist.JWTDecoder) *Server { + server.jwtApp = jwtApp + return server +} + +func (server *Server) WithDB(db *sqlx.DB) *Server { + server.db = db + server.stmts = arborist.NewCachedStmts(db) + return server +} + +func (server *Server) WithQdrantClient(client *qdrant.Client) *Server { + server.qdrantClient = client + return server +} + +func (server *Server) WithGripqlClient(client *gripql.Client, gripGraphName string) *Server { + server.gripqlClient = client + server.gripGraphName = gripGraphName + return server +} + +func (server *Server) WithGitService(service *git.GitService) *Server { + server.gitService = service + return server +} + +func (server *Server) WithThumbnailStore(store thumbnail.Manager) *Server { + server.thumbnailStore = store + return server +} + +func (server *Server) Init() (*Server, error) { + if server.jwtApp == nil { + return nil, errors.New("gecko server initialized without JWT app") + } + if server.Logger == nil { + return nil, errors.New("gecko server initialized without logger") + } + if server.db == nil { + server.Logger.Warning("Database endpoints will be disabled.") + } + if server.qdrantClient == nil { + server.Logger.Warning("Qdrant endpoints will be disabled.") + } + if server.gripqlClient == nil || server.gripGraphName == "" { + server.Logger.Warning("Grip endpoints will be disabled.") + } + if server.gitService != nil { + if err := server.gitService.Init(server.db); err != nil { + return nil, err + } + } else { + server.Logger.Warning("Git endpoints will be disabled.") + } + server.Logger.Info("Gecko server initialized successfully.") + return server, nil +} + +func (server *Server) MakeRouter() *fiber.App { + app := fiber.New(fiber.Config{ + ReadBufferSize: 32 * 1024, + ReadTimeout: 10 * time.Second, + WriteTimeout: 10 * time.Second, + }) + + app.Use(func(ctx fiber.Ctx) error { + defer func() { + if r := recover(); r != nil { + server.Logger.Error("panic recovered: %v", r) + ctx.Status(http.StatusInternalServerError) + _ = ctx.SendString("Internal Server Error") + } + }() + return ctx.Next() + }) + app.Use(servermw.RequestLogger(server.Logger)) + + httpapi.Register(app, httpapi.Dependencies{ + DB: server.db, + Logger: server.Logger, + JWTApp: server.jwtApp, + QdrantClient: server.qdrantClient, + GripqlClient: server.gripqlClient, + GripGraphName: server.gripGraphName, + GitService: server.gitService, + ThumbnailStore: server.thumbnailStore, + }) + return app +} diff --git a/internal/thumbnail/interface.go b/internal/thumbnail/interface.go new file mode 100644 index 0000000..c57c04d --- /dev/null +++ b/internal/thumbnail/interface.go @@ -0,0 +1,9 @@ +package thumbnail + +// Manager defines the interface for project thumbnail storage and retrieval. +type Manager interface { + GetPath(organization string, project string) (path string, contentType string, err error) + Save(organization string, project string, data []byte) (path string, contentType string, err error) + Delete(organization string, project string) error + ProjectThumbnailDir(organization string, project string) string +} diff --git a/internal/thumbnail/store.go b/internal/thumbnail/store.go new file mode 100644 index 0000000..8b5bb80 --- /dev/null +++ b/internal/thumbnail/store.go @@ -0,0 +1,129 @@ +package thumbnail + +import ( + "fmt" + "io" + "mime" + "net/http" + "os" + "path/filepath" + "sort" + "strings" +) + +type FilesystemStore struct { + dataDir string +} + +// NewFilesystemStore constructs a disk-backed thumbnail store. +func NewFilesystemStore(dataDir string) *FilesystemStore { + return &FilesystemStore{ + dataDir: strings.TrimSpace(dataDir), + } +} + +func (s *FilesystemStore) ProjectThumbnailDir(organization string, project string) string { + return filepath.Join( + s.dataDir, + ProjectThumbnailDirectory, + sanitizePathPart(strings.TrimSpace(organization)), + sanitizePathPart(strings.TrimSpace(project)), + ) +} + +func (s *FilesystemStore) projectThumbnailPath(organization string, project string, extension string) string { + return filepath.Join( + s.ProjectThumbnailDir(organization, project), + ProjectThumbnailBaseName+extension, + ) +} + +func (s *FilesystemStore) GetPath(organization string, project string) (string, string, error) { + if s.dataDir == "" { + return "", "", ErrDataDirRequired + } + dir := s.ProjectThumbnailDir(organization, project) + matches, err := filepath.Glob(filepath.Join(dir, ProjectThumbnailBaseName+".*")) + if err != nil { + return "", "", fmt.Errorf("resolve thumbnail path: %w", err) + } + if len(matches) == 0 { + return "", "", ErrNoThumbnail + } + sort.Strings(matches) + path := matches[0] + contentType := mime.TypeByExtension(strings.ToLower(filepath.Ext(path))) + if contentType == "" { + file, openErr := os.Open(path) + if openErr != nil { + return "", "", fmt.Errorf("open thumbnail: %w", openErr) + } + defer file.Close() + header := make([]byte, 512) + n, readErr := file.Read(header) + if readErr != nil && readErr != io.EOF { + return "", "", fmt.Errorf("read thumbnail header: %w", readErr) + } + contentType = http.DetectContentType(header[:n]) + } + return path, contentType, nil +} + +func (s *FilesystemStore) Save(organization string, project string, data []byte) (string, string, error) { + if s.dataDir == "" { + return "", "", ErrDataDirRequired + } + extension, err := ValidateThumbnail(data) + if err != nil { + return "", "", err + } + + dir := s.ProjectThumbnailDir(organization, project) + if err := os.MkdirAll(dir, 0o755); err != nil { + return "", "", fmt.Errorf("create thumbnail directory: %w", err) + } + + // Delete old thumbnail files if any exist + _ = s.Delete(organization, project) + + path := s.projectThumbnailPath(organization, project, extension) + tmpPath := path + ".tmp" + if err := os.WriteFile(tmpPath, data, 0o644); err != nil { + return "", "", fmt.Errorf("write thumbnail: %w", err) + } + if err := os.Rename(tmpPath, path); err != nil { + _ = os.Remove(tmpPath) + return "", "", fmt.Errorf("persist thumbnail: %w", err) + } + + contentType := "image/png" + if extension == ".jpg" { + contentType = "image/jpeg" + } + return path, contentType, nil +} + +func (s *FilesystemStore) Delete(organization string, project string) error { + if s.dataDir == "" { + return ErrDataDirRequired + } + dir := s.ProjectThumbnailDir(organization, project) + matches, err := filepath.Glob(filepath.Join(dir, ProjectThumbnailBaseName+".*")) + if err != nil { + return fmt.Errorf("list thumbnails: %w", err) + } + if len(matches) == 0 { + return ErrNoThumbnail + } + for _, path := range matches { + if removeErr := os.Remove(path); removeErr != nil && !os.IsNotExist(removeErr) { + return fmt.Errorf("delete thumbnail %s: %w", path, removeErr) + } + } + return nil +} + +func sanitizePathPart(value string) string { + replacer := strings.NewReplacer("/", "_", "\\", "_", ":", "_", " ", "_") + return replacer.Replace(value) +} diff --git a/internal/thumbnail/store_test.go b/internal/thumbnail/store_test.go new file mode 100644 index 0000000..687218f --- /dev/null +++ b/internal/thumbnail/store_test.go @@ -0,0 +1,148 @@ +package thumbnail + +import ( + "image" + "image/color" + "image/jpeg" + "image/png" + "io" + "os" + "path/filepath" + "testing" +) + +func generateImageBytes(t *testing.T, format string, width, height int) []byte { + t.Helper() + img := image.NewRGBA(image.Rect(0, 0, width, height)) + // Fill with color + for y := 0; y < height; y++ { + for x := 0; x < width; x++ { + img.Set(x, y, color.RGBA{R: 255, G: 0, B: 0, A: 255}) + } + } + + tempFile, err := os.CreateTemp("", "test-img-*") + if err != nil { + t.Fatal(err) + } + defer os.Remove(tempFile.Name()) + defer tempFile.Close() + + if format == "png" { + if err := png.Encode(tempFile, img); err != nil { + t.Fatal(err) + } + } else { + if err := jpeg.Encode(tempFile, img, nil); err != nil { + t.Fatal(err) + } + } + + if _, err := tempFile.Seek(0, 0); err != nil { + t.Fatal(err) + } + data, err := io.ReadAll(tempFile) + if err != nil { + t.Fatal(err) + } + return data +} + +func TestValidateThumbnail(t *testing.T) { + t.Run("valid png", func(t *testing.T) { + data := generateImageBytes(t, "png", 150, 150) + ext, err := ValidateThumbnail(data) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if ext != ".png" { + t.Errorf("expected extension .png, got %s", ext) + } + }) + + t.Run("valid jpeg", func(t *testing.T) { + data := generateImageBytes(t, "jpeg", 150, 150) + ext, err := ValidateThumbnail(data) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if ext != ".jpg" { + t.Errorf("expected extension .jpg, got %s", ext) + } + }) + + t.Run("too small", func(t *testing.T) { + data := generateImageBytes(t, "png", 50, 150) + _, err := ValidateThumbnail(data) + if err == nil || err != ErrImageTooSmall && !filepath.HasPrefix(err.Error(), ErrImageTooSmall.Error()) { + t.Fatalf("expected ErrImageTooSmall, got %v", err) + } + }) + + t.Run("too large", func(t *testing.T) { + data := generateImageBytes(t, "png", 3500, 150) + _, err := ValidateThumbnail(data) + if err == nil || err != ErrImageTooLarge && !filepath.HasPrefix(err.Error(), ErrImageTooLarge.Error()) { + t.Fatalf("expected ErrImageTooLarge, got %v", err) + } + }) + + t.Run("invalid format text", func(t *testing.T) { + _, err := ValidateThumbnail([]byte("not-an-image")) + if err == nil || err != ErrInvalidFormat && !filepath.HasPrefix(err.Error(), ErrInvalidFormat.Error()) { + t.Fatalf("expected ErrInvalidFormat, got %v", err) + } + }) +} + +func TestFilesystemStore(t *testing.T) { + tempDir, err := os.MkdirTemp("", "thumbnail-store-test-*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tempDir) + + store := NewFilesystemStore(tempDir) + org := "TestOrg" + proj := "TestProj" + + t.Run("get non-existent thumbnail", func(t *testing.T) { + _, _, err := store.GetPath(org, proj) + if err != ErrNoThumbnail { + t.Fatalf("expected ErrNoThumbnail, got %v", err) + } + }) + + t.Run("save and get valid thumbnail", func(t *testing.T) { + data := generateImageBytes(t, "png", 120, 120) + path, contentType, err := store.Save(org, proj, data) + if err != nil { + t.Fatalf("failed to save: %v", err) + } + if contentType != "image/png" { + t.Errorf("expected image/png, got %s", contentType) + } + + retPath, retType, err := store.GetPath(org, proj) + if err != nil { + t.Fatalf("failed to get: %v", err) + } + if retPath != path { + t.Errorf("expected path %s, got %s", path, retPath) + } + if retType != contentType { + t.Errorf("expected type %s, got %s", contentType, retType) + } + }) + + t.Run("delete thumbnail", func(t *testing.T) { + err := store.Delete(org, proj) + if err != nil { + t.Fatalf("failed to delete: %v", err) + } + _, _, err = store.GetPath(org, proj) + if err != ErrNoThumbnail { + t.Fatalf("expected ErrNoThumbnail after delete, got %v", err) + } + }) +} diff --git a/internal/thumbnail/types.go b/internal/thumbnail/types.go new file mode 100644 index 0000000..5b9facd --- /dev/null +++ b/internal/thumbnail/types.go @@ -0,0 +1,21 @@ +package thumbnail + +import "errors" + +const ( + ProjectThumbnailDirectory = "_project_thumbnails" + ProjectThumbnailBaseName = "thumbnail" + MaxProjectThumbnailBytes = 1 << 20 // 1 MB + MinProjectThumbnailPixels = 100 + MaxProjectThumbnailPixels = 3000 +) + +var ( + ErrInvalidFormat = errors.New("thumbnail image must be a PNG or JPG file") + ErrImageTooSmall = errors.New("thumbnail image is too small") + ErrImageTooLarge = errors.New("thumbnail image is too large") + ErrLimitExceeded = errors.New("thumbnail image size limit exceeded") + ErrNoThumbnail = errors.New("project has no thumbnail") + ErrDataDirRequired = errors.New("thumbnail storage directory is required") + ErrThumbnailIsEmpty = errors.New("thumbnail image data is empty") +) diff --git a/internal/thumbnail/validation.go b/internal/thumbnail/validation.go new file mode 100644 index 0000000..d54135b --- /dev/null +++ b/internal/thumbnail/validation.go @@ -0,0 +1,49 @@ +package thumbnail + +import ( + "bytes" + "fmt" + "image" + "net/http" + + _ "image/jpeg" + _ "image/png" +) + +var thumbnailExtensionByContentType = map[string]string{ + "image/jpeg": ".jpg", + "image/png": ".png", +} + +// ValidateThumbnail verifies the image format, file size, and pixel dimensions. +func ValidateThumbnail(data []byte) (string, error) { + if len(data) == 0 { + return "", ErrThumbnailIsEmpty + } + if len(data) > MaxProjectThumbnailBytes { + return "", fmt.Errorf("%w: exceeds %d bytes", ErrLimitExceeded, MaxProjectThumbnailBytes) + } + + contentType := http.DetectContentType(data) + extension, ok := thumbnailExtensionByContentType[contentType] + if !ok { + return "", ErrInvalidFormat + } + + config, format, err := image.DecodeConfig(bytes.NewReader(data)) + if err != nil { + return "", fmt.Errorf("invalid image data: %w", err) + } + if format != "png" && format != "jpeg" { + return "", ErrInvalidFormat + } + + if config.Width < MinProjectThumbnailPixels || config.Height < MinProjectThumbnailPixels { + return "", fmt.Errorf("%w: must be at least %dx%d pixels, got %dx%d", ErrImageTooSmall, MinProjectThumbnailPixels, MinProjectThumbnailPixels, config.Width, config.Height) + } + if config.Width > MaxProjectThumbnailPixels || config.Height > MaxProjectThumbnailPixels { + return "", fmt.Errorf("%w: must be at most %dx%d pixels, got %dx%d", ErrImageTooLarge, MaxProjectThumbnailPixels, MaxProjectThumbnailPixels, config.Width, config.Height) + } + + return extension, nil +} diff --git a/gecko/adapter/convert.go b/internal/vectoradapter/convert.go similarity index 99% rename from gecko/adapter/convert.go rename to internal/vectoradapter/convert.go index a065a46..1ca7481 100644 --- a/gecko/adapter/convert.go +++ b/internal/vectoradapter/convert.go @@ -1,4 +1,4 @@ -package adapter +package vectoradapter import ( "fmt" diff --git a/gecko/adapter/response.go b/internal/vectoradapter/response.go similarity index 99% rename from gecko/adapter/response.go rename to internal/vectoradapter/response.go index 5fdb8e3..f9ea025 100644 --- a/gecko/adapter/response.go +++ b/internal/vectoradapter/response.go @@ -1,4 +1,4 @@ -package adapter +package vectoradapter import "github.com/qdrant/go-client/qdrant" diff --git a/gecko/adapter/types.go b/internal/vectoradapter/types.go similarity index 99% rename from gecko/adapter/types.go rename to internal/vectoradapter/types.go index 0335416..ee53379 100644 --- a/gecko/adapter/types.go +++ b/internal/vectoradapter/types.go @@ -1,4 +1,4 @@ -package adapter +package vectoradapter import ( "net/http" diff --git a/main.go b/main.go index 62dd171..1237b71 100644 --- a/main.go +++ b/main.go @@ -4,17 +4,22 @@ import ( "flag" "fmt" "log" - "net/http" "os" "strconv" - "time" + "strings" "github.com/bmeg/grip/gripql" "github.com/bmeg/grip/util/rpc" - "github.com/calypr/gecko/gecko" + "github.com/gofiber/fiber/v3" "github.com/jmoiron/sqlx" "github.com/qdrant/go-client/qdrant" "github.com/uc-cdis/go-authutils/authutils" + + "github.com/calypr/gecko/internal/git" + integrationfence "github.com/calypr/gecko/internal/integrations/fence" + integrationgithub "github.com/calypr/gecko/internal/integrations/github" + server "github.com/calypr/gecko/internal/server" + "github.com/calypr/gecko/internal/thumbnail" ) // @title Gecko API @@ -28,125 +33,78 @@ import ( // @description JWT token for authentication func main() { logger := log.New(os.Stdout, "", log.Ldate|log.Ltime) - - var port = flag.Uint("port", 80, "port on which to expose the API") + var port = flag.Uint("port", 8080, "port on which to expose the API") var jwkEndpoint = flag.String("jwks", "", "endpoint for JWKS") - var dbUrl = flag.String("db", "", "URL to connect to database") - + var dbURL = flag.String("db", "", "URL to connect to database") var qdrantHostFlag = flag.String("qdrant-host", "", "Qdrant host (overrides QDRANT_HOST env var)") var qdrantPortFlag = flag.Int("qdrant-port", 0, "Qdrant port (overrides QDRANT_PORT env var)") var qdrantAPIKeyFlag = flag.String("qdrant-api-key", "", "Qdrant API Key (overrides QDRANT_API_KEY env var)") - var gripGraphName = flag.String("grip-graph-zname", "", "The graph name to use when querying Grip (overrides GRIP_GRAPH env var)") var gripPort = flag.String("grip-port", "", "The rpc port to be used for connecting to Grip (overrides GRIP_PORT env var)") var gripHost = flag.String("grip-host", "", "The hostname to be usd for connecting to Grip (overrides GRIP_HOST env var)") + var githubAPIBaseFlag = flag.String("github-api-base-url", "", "GitHub API base URL (overrides GITHUB_API_BASE_URL env var)") + var fenceBaseURLFlag = flag.String("fence-base-url", "", "Fence base URL for GitHub App token exchange (overrides FENCE_BASE_URL env var)") + var gitDataDirFlag = flag.String("git-data-dir", "", "Directory for local git mirrors (overrides GIT_DATA_DIR env var)") flag.Parse() - gripGraph := *gripGraphName - if gripGraph == "" { - gripGraph = os.Getenv("GRIP_GRAPH") - } - - gripPortVar := *gripPort - if gripPortVar == "" { - gripPortVar = os.Getenv("GRIP_PORT") - } - - gripHostvar := *gripHost - if gripHostvar == "" { - gripHostvar = os.Getenv("GRIP_HOST") - } - - qdrantHost := *qdrantHostFlag - if qdrantHost == "" { - qdrantHost = os.Getenv("QDRANT_HOST") - } - + gripGraph := firstNonEmpty(*gripGraphName, os.Getenv("GRIP_GRAPH")) + gripPortVar := firstNonEmpty(*gripPort, os.Getenv("GRIP_PORT")) + gripHostVar := firstNonEmpty(*gripHost, os.Getenv("GRIP_HOST")) + qdrantHost := firstNonEmpty(*qdrantHostFlag, os.Getenv("QDRANT_HOST")) qdrantPort := *qdrantPortFlag if qdrantPort == 0 { - portStr := os.Getenv("QDRANT_PORT") - if portStr != "" { - parsedPort, err := strconv.Atoi(portStr) - if err == nil { + if portStr := os.Getenv("QDRANT_PORT"); portStr != "" { + if parsedPort, err := strconv.Atoi(portStr); err == nil { qdrantPort = parsedPort } } } - - qdrantAPIKey := *qdrantAPIKeyFlag - if qdrantAPIKey == "" { - qdrantAPIKey = os.Getenv("QDRANT_API_KEY") - } - - finalJwkEndpoint := *jwkEndpoint - if finalJwkEndpoint == "" { - finalJwkEndpoint = os.Getenv("JWKS_ENDPOINT") - } - if finalJwkEndpoint == "" { + qdrantAPIKey := firstNonEmpty(*qdrantAPIKeyFlag, os.Getenv("QDRANT_API_KEY")) + finalJWK := firstNonEmpty(*jwkEndpoint, os.Getenv("JWKS_ENDPOINT")) + if finalJWK == "" { logger.Println("WARNING: no $JWKS_ENDPOINT or --jwks specified; endpoints requiring JWT validation will error") } - jwtApp := authutils.NewJWTApplication(finalJwkEndpoint) - serverBuilder := gecko.NewServer(). - WithLogger(logger). - WithJWTApp(jwtApp) - - db, err := sqlx.Open("postgres", *dbUrl) - if err != nil { - logger.Printf("WARNING: Failed to open database connection with URL %s: %v. Database endpoints will not be available.", *dbUrl, err) + serverBuilder := server.NewServer().WithLogger(logger).WithJWTApp(authutils.NewJWTApplication(finalJWK)) + if db, err := sqlx.Open("postgres", *dbURL); err != nil { + logger.Printf("WARNING: Failed to open database connection with URL %s: %v. Database endpoints will not be available.", *dbURL, err) + } else if err = db.Ping(); err != nil { + logger.Printf("WARNING: DB ping failed for URL %s: %v. Database endpoints will not be available.", *dbURL, err) + _ = db.Close() } else { - if err = db.Ping(); err != nil { - logger.Printf("WARNING: DB ping failed for URL %s: %v. Database endpoints will not be available.", *dbUrl, err) - db.Close() - } else { - logger.Println("Successfully connected to PostgreSQL database.") - serverBuilder = serverBuilder.WithDB(db) - } + logger.Println("Successfully connected to PostgreSQL database.") + serverBuilder = serverBuilder.WithDB(db) + githubAPIBase := firstNonEmpty(*githubAPIBaseFlag, os.Getenv("GITHUB_API_BASE_URL")) + fenceBaseURL := firstNonEmpty(*fenceBaseURLFlag, os.Getenv("FENCE_BASE_URL")) + gitDataDir := firstNonEmpty(*gitDataDirFlag, os.Getenv("GIT_DATA_DIR")) + gitService := git.NewGitService(git.GitServiceConfig{ + GitHubAPIBase: githubAPIBase, + FenceBaseURL: fenceBaseURL, + DataDir: gitDataDir, + FenceClient: integrationfence.NewClient(nil, integrationfence.Config{BaseURL: fenceBaseURL}), + GitHubClient: integrationgithub.NewClient(nil, integrationgithub.Config{APIBase: githubAPIBase}), + }) + serverBuilder = serverBuilder.WithGitService(gitService) + serverBuilder = serverBuilder.WithThumbnailStore(thumbnail.NewFilesystemStore(gitDataDir)) } if qdrantHost != "" && qdrantPort != 0 { - if qdrantHost == "localhost" && *qdrantHostFlag == "" && os.Getenv("QDRANT_HOST") == "" { - // Skip connection attempt if only default values would be used and no flag/env was set - // This logic is slightly complex due to your existing defaults; - // A simpler approach is to only check if the host was explicitly set. - // Let's rely on the user to provide *at least* the host flag if they want Qdrant. + logger.Printf("Attempting to connect to Qdrant at %s:%d", qdrantHost, qdrantPort) + if qdrantClient, err := qdrant.NewClient(&qdrant.Config{Host: qdrantHost, Port: qdrantPort, APIKey: qdrantAPIKey}); err != nil { + logger.Printf("WARNING: Failed to initialize Qdrant client at %s:%d: %v. Qdrant endpoints will not be available.", qdrantHost, qdrantPort, err) } else { - // Re-apply final defaults only if we decide to connect - if qdrantHost == "" { - qdrantHost = "localhost" // Final default - } - if qdrantPort == 0 { - qdrantPort = 6334 - } - - logger.Printf("Attempting to connect to Qdrant at %s:%d", qdrantHost, qdrantPort) - qdrantConfig := &qdrant.Config{ - Host: qdrantHost, - Port: qdrantPort, - APIKey: qdrantAPIKey, - } - - qdrantClient, err := qdrant.NewClient(qdrantConfig) - if err != nil { - logger.Printf("WARNING: Failed to initialize Qdrant client at %s:%d: %v. Qdrant endpoints will not be available.", qdrantHost, qdrantPort, err) - } else { - logger.Println("Successfully connected to Qdrant.") - serverBuilder = serverBuilder.WithQdrantClient(qdrantClient) - } + logger.Println("Successfully connected to Qdrant.") + serverBuilder = serverBuilder.WithQdrantClient(qdrantClient) } } else { logger.Println("INFO: Qdrant configuration (--qdrant-host or QDRANT_HOST) not fully specified. Qdrant endpoints will not be available.") } - if gripHostvar != "" && gripPortVar != "" { - logger.Printf("Attempting to connect to Grip at %s:%s using graph %s", gripHostvar, gripPortVar, gripGraph) - gripqlClient, err := gripql.Connect(rpc.ConfigWithDefaults(gripHostvar+":"+gripPortVar), false) - if err != nil { + if gripHostVar != "" && gripPortVar != "" { + logger.Printf("Attempting to connect to Grip at %s:%s using graph %s", gripHostVar, gripPortVar, gripGraph) + if gripqlClient, err := gripql.Connect(rpc.ConfigWithDefaults(gripHostVar+":"+gripPortVar), false); err != nil { logger.Printf("WARNING: Failed to initialize Grip client: %v. Grip endpoints will not be available.", err) } else { - if gripGraph == "" { - logger.Println("WARNING: Connected to Grip but no --grip-graph-name or GRIP_GRAPH specified. Grip endpoints may fail.") - } logger.Println("Successfully connected to Grip.") serverBuilder = serverBuilder.WithGripqlClient(&gripqlClient, gripGraph) } @@ -156,23 +114,21 @@ func main() { geckoServer, err := serverBuilder.Init() if err != nil { - // Log fatal only if the core server initialization fails, independent of the clients log.Fatalf("Failed to initialize gecko server: %v", err) } - app := geckoServer.MakeRouter() - httpLogger := log.New(os.Stdout, "", log.LstdFlags) - app.Logger().SetOutput(httpLogger.Writer()) - httpServer := &http.Server{ - Addr: fmt.Sprintf(":%d", *port), - ReadTimeout: 10 * time.Second, - WriteTimeout: 10 * time.Second, - ErrorLog: httpLogger, - Handler: app, + addr := fmt.Sprintf(":%d", *port) + logger.Println("gecko serving at", addr) + if err := app.Listen(addr, fiber.ListenConfig{DisableStartupMessage: true}); err != nil { + log.Fatal("Server failed to start:", err) } +} - httpLogger.Println("gecko serving at", httpServer.Addr) - if err = httpServer.ListenAndServe(); err != nil { - log.Fatal("Server failed to start:", err) +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } } + return "" } diff --git a/tests/integration/api_test.go b/tests/integration/api_test.go index 8daf2ca..c036ef4 100644 --- a/tests/integration/api_test.go +++ b/tests/integration/api_test.go @@ -19,7 +19,8 @@ func makeRequest(method, url string, payload []byte) *http.Request { } func TestHealthCheck(t *testing.T) { - resp, err := http.DefaultClient.Do(makeRequest("GET", "http://localhost:8080/health", nil)) + baseURL := requireIntegrationServer(t) + resp, err := http.DefaultClient.Do(makeRequest("GET", baseURL+"/health", nil)) assert.NoError(t, err) if resp != nil && resp.Body != nil { defer resp.Body.Close() diff --git a/tests/integration/helpers_test.go b/tests/integration/helpers_test.go new file mode 100644 index 0000000..a5cdedd --- /dev/null +++ b/tests/integration/helpers_test.go @@ -0,0 +1,47 @@ +package main + +import ( + "fmt" + "net" + "net/url" + "os" + "testing" + "time" +) + +const defaultBaseURL = "http://localhost:8080" + +func integrationBaseURL() string { + if base := os.Getenv("GECKO_INTEGRATION_BASE_URL"); base != "" { + return base + } + return defaultBaseURL +} + +func requireIntegrationServer(t *testing.T) string { + t.Helper() + baseURL := integrationBaseURL() + parsed, err := url.Parse(baseURL) + if err != nil { + t.Fatalf("invalid integration base URL %q: %v", baseURL, err) + } + host := parsed.Host + if parsed.Port() == "" { + switch parsed.Scheme { + case "https": + host = net.JoinHostPort(parsed.Hostname(), "443") + default: + host = net.JoinHostPort(parsed.Hostname(), "80") + } + } + conn, err := net.DialTimeout("tcp", host, 2*time.Second) + if err != nil { + t.Skipf("skipping integration test: gecko server is not reachable at %s: %v", baseURL, err) + } + _ = conn.Close() + return baseURL +} + +func integrationURL(path string) string { + return fmt.Sprintf("%s%s", integrationBaseURL(), path) +} diff --git a/tests/integration/middleware_test.go b/tests/integration/middleware_test.go index 126abc3..e5a4dfc 100644 --- a/tests/integration/middleware_test.go +++ b/tests/integration/middleware_test.go @@ -1,8 +1,8 @@ package main import ( - "bytes" "fmt" + "io" "log" "net/http" "net/http/httptest" @@ -10,8 +10,10 @@ import ( "testing" "github.com/bmeg/grip-graphql/middleware" - "github.com/calypr/gecko/gecko" - "github.com/kataras/iris/v12" + geckologging "github.com/calypr/gecko/internal/logging" + server "github.com/calypr/gecko/internal/server" + servermw "github.com/calypr/gecko/internal/server/middleware" + "github.com/gofiber/fiber/v3" "github.com/stretchr/testify/assert" ) @@ -43,383 +45,140 @@ func (m *MockJWTHandler) CheckResourceServiceAccess(token, resource, service, me return false, nil } -func setupServer() *gecko.Server { - return &gecko.Server{ - Logger: &gecko.LogHandler{Logger: log.New(os.Stdout, "", log.Ldate|log.Ltime)}, +func setupServer() *server.Server { + return &server.Server{Logger: &geckologging.Handler{Logger: log.New(os.Stdout, "", log.Ldate|log.Ltime)}} +} + +func runFiber(app *fiber.App, req *http.Request, t *testing.T) (*http.Response, string) { + t.Helper() + resp, err := app.Test(req, fiber.TestConfig{Timeout: 0, FailOnTimeout: false}) + if err != nil { + t.Fatalf("fiber test request failed: %v", err) } + body, _ := io.ReadAll(resp.Body) + _ = resp.Body.Close() + return resp, string(body) } func TestGeneralAuthMware_NoAuthorization(t *testing.T) { - mockJWT := &MockJWTHandler{} srv := setupServer() - mware := srv.GeneralAuthMware(mockJWT, "read", "*") - - req := httptest.NewRequest(http.MethodGet, "/", nil) - rec := httptest.NewRecorder() - app := iris.New() - ctx := app.ContextPool.Acquire(rec, req) - ctx.Params().Set("projectId", "ohsu-test") - - mware(ctx) - assert.Equal(t, http.StatusUnauthorized, rec.Code) - assert.Contains(t, rec.Body.String(), "Authorization token not provided") + app := fiber.New() + app.Get("/:projectId", servermw.GeneralAuth(srv.Logger, &MockJWTHandler{}, "read", "*"), func(c fiber.Ctx) error { return c.SendStatus(http.StatusOK) }) + resp, body := runFiber(app, httptest.NewRequest(http.MethodGet, "/ohsu-test", nil), t) + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + assert.Contains(t, body, "Authorization token not provided") } func TestGeneralAuthMware_BadProjectID(t *testing.T) { - mockJWT := &MockJWTHandler{AllowedResources: []string{"/programs/ohsu/projects/test"}} srv := setupServer() - mware := srv.GeneralAuthMware(mockJWT, "read", "*") - - req := httptest.NewRequest(http.MethodGet, "/", nil) + app := fiber.New() + app.Get("/:projectId", servermw.GeneralAuth(srv.Logger, &MockJWTHandler{AllowedResources: []string{"/programs/ohsu/projects/test"}}, "read", "*"), func(c fiber.Ctx) error { return c.SendStatus(http.StatusOK) }) + req := httptest.NewRequest(http.MethodGet, "/ohsu", nil) req.Header.Set("Authorization", "Bearer dummy") - rec := httptest.NewRecorder() - app := iris.New() - ctx := app.ContextPool.Acquire(rec, req) - ctx.Params().Set("projectId", "ohsu") // missing '-' - - mware(ctx) - assert.Equal(t, http.StatusNotFound, rec.Code) - assert.Contains(t, rec.Body.String(), "Failed to parse request body") + resp, body := runFiber(app, req, t) + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + assert.Contains(t, body, "Failed to parse request body") } func TestGeneralAuthMware_GetAllowedResourcesNonServerError(t *testing.T) { - mockJWT := &MockJWTHandler{ - Err: fmt.Errorf("generic error"), - } srv := setupServer() - mware := srv.GeneralAuthMware(mockJWT, "read", "*") - - req := httptest.NewRequest(http.MethodGet, "/", nil) + app := fiber.New() + app.Get("/:projectId", servermw.GeneralAuth(srv.Logger, &MockJWTHandler{Err: fmt.Errorf("generic error")}, "read", "*"), func(c fiber.Ctx) error { return c.SendStatus(http.StatusOK) }) + req := httptest.NewRequest(http.MethodGet, "/ohsu-test", nil) req.Header.Set("Authorization", "Bearer dummy") - rec := httptest.NewRecorder() - app := iris.New() - ctx := app.ContextPool.Acquire(rec, req) - ctx.Params().Set("projectId", "ohsu-test") - - mware(ctx) - assert.Equal(t, http.StatusNotFound, rec.Code) - assert.Contains(t, rec.Body.String(), "expecting error to be serverError type") + resp, body := runFiber(app, req, t) + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + assert.Contains(t, body, "expecting error to be serverError type") } func TestGeneralAuthMware_GetAllowedResourcesServerError(t *testing.T) { - mockJWT := &MockJWTHandler{ - Err: &middleware.ServerError{ - Message: "token expired", - StatusCode: http.StatusUnauthorized, - }, - } srv := setupServer() - mware := srv.GeneralAuthMware(mockJWT, "read", "*") - - req := httptest.NewRequest(http.MethodGet, "/", nil) + app := fiber.New() + app.Get("/:projectId", servermw.GeneralAuth(srv.Logger, &MockJWTHandler{Err: &middleware.ServerError{Message: "token expired", StatusCode: http.StatusUnauthorized}}, "read", "*"), func(c fiber.Ctx) error { return c.SendStatus(http.StatusOK) }) + req := httptest.NewRequest(http.MethodGet, "/ohsu-test", nil) req.Header.Set("Authorization", "Bearer dummy") - rec := httptest.NewRecorder() - app := iris.New() - ctx := app.ContextPool.Acquire(rec, req) - ctx.Params().Set("projectId", "ohsu-test") - - mware(ctx) - assert.Equal(t, http.StatusUnauthorized, rec.Code) - assert.Contains(t, rec.Body.String(), "token expired") + resp, body := runFiber(app, req, t) + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + assert.Contains(t, body, "token expired") } type MockJWTHandlerBadAny struct{} func (m *MockJWTHandlerBadAny) GetAllowedResources(token string, method, service string) ([]any, error) { - return []any{123}, nil // triggers convertAnyToStringSlice error + return []any{123}, nil } - func (m *MockJWTHandlerBadAny) CheckResourceServiceAccess(token, resource, service, method string) (bool, error) { return true, nil } -// Then in your test: func TestGeneralAuthMware_ConvertAnyToStringSliceError(t *testing.T) { - mockJWT := &MockJWTHandlerBadAny{} srv := setupServer() - mware := srv.GeneralAuthMware(mockJWT, "read", "*") - - req := httptest.NewRequest(http.MethodGet, "/", nil) + app := fiber.New() + app.Get("/:projectId", servermw.GeneralAuth(srv.Logger, &MockJWTHandlerBadAny{}, "read", "*"), func(c fiber.Ctx) error { return c.SendStatus(http.StatusOK) }) + req := httptest.NewRequest(http.MethodGet, "/ohsu-test", nil) req.Header.Set("Authorization", "Bearer dummy") - rec := httptest.NewRecorder() - app := iris.New() - ctx := app.ContextPool.Acquire(rec, req) - ctx.Params().Set("projectId", "ohsu-test") - - mware(ctx) - assert.Equal(t, http.StatusInternalServerError, rec.Code) - assert.Contains(t, rec.Body.String(), "Element 123 is not a string") + resp, body := runFiber(app, req, t) + assert.Equal(t, http.StatusInternalServerError, resp.StatusCode) + assert.Contains(t, body, "Element 123 is not a string") } func TestGeneralAuthMware_ParseAccessDenied(t *testing.T) { - mockJWT := &MockJWTHandler{ - AllowedResources: []string{"/programs/other/projects/test"}, - } srv := setupServer() - mware := srv.GeneralAuthMware(mockJWT, "read", "*") - - req := httptest.NewRequest(http.MethodGet, "/", nil) + app := fiber.New() + app.Get("/:projectId", servermw.GeneralAuth(srv.Logger, &MockJWTHandler{AllowedResources: []string{"/programs/other/projects/test"}}, "read", "*"), func(c fiber.Ctx) error { return c.SendStatus(http.StatusOK) }) + req := httptest.NewRequest(http.MethodGet, "/ohsu-test", nil) req.Header.Set("Authorization", "Bearer dummy") - rec := httptest.NewRecorder() - app := iris.New() - ctx := app.ContextPool.Acquire(rec, req) - ctx.Params().Set("projectId", "ohsu-test") - - mware(ctx) - assert.Equal(t, http.StatusForbidden, rec.Code) - assert.Contains(t, rec.Body.String(), "User is not allowed to read on resource path") + resp, body := runFiber(app, req, t) + assert.Equal(t, http.StatusForbidden, resp.StatusCode) + assert.Contains(t, body, "User is not allowed to read on resource path") } func TestConfigAuthMiddleware_MethodNotAllowed(t *testing.T) { - mockJWT := &MockJWTHandler{} srv := setupServer() - cfgMware := srv.ConfigAuthMiddleware(mockJWT) - - req := httptest.NewRequest(http.MethodPatch, "/configs/cbds-XYZ?configType=explorer", nil) + app := fiber.New() + app.Use("/config/explorer/:configId", func(c fiber.Ctx) error { c.Locals("configType", "explorer"); return c.Next() }) + app.Patch("/config/explorer/:configId", servermw.ConfigAuth(srv.Logger, &MockJWTHandler{}), func(c fiber.Ctx) error { return c.SendStatus(http.StatusOK) }) + req := httptest.NewRequest(http.MethodPatch, "/config/explorer/ohsu-test", nil) req.Header.Set("Authorization", "Bearer dummy") - rec := httptest.NewRecorder() - app := iris.New() - ctx := app.ContextPool.Acquire(rec, req) - ctx.Params().Set("configId", "ohsu-test") - ctx.Params().Set("configType", "explorer") - - cfgMware(ctx) - assert.Equal(t, http.StatusMethodNotAllowed, rec.Code) - assert.Contains(t, rec.Body.String(), "Unsupported HTTP method") -} - -func TestConfigAuthMiddleware_AppsPage_PublicGET(t *testing.T) { - mockJWT := &MockJWTHandler{} - srv := setupServer() - cfgMware := srv.ConfigAuthMiddleware(mockJWT) - - req := httptest.NewRequest(http.MethodGet, "/config/apps_page/default", nil) - rec := httptest.NewRecorder() - app := iris.New() - ctx := app.ContextPool.Acquire(rec, req) - ctx.Params().Set("configType", "apps_page") - ctx.Params().Set("configId", "default") - - cfgMware(ctx) - assert.False(t, ctx.IsStopped(), "GET for apps_page should be public") + resp, body := runFiber(app, req, t) + assert.Equal(t, http.StatusMethodNotAllowed, resp.StatusCode) + assert.Contains(t, body, "Unsupported HTTP method") } func TestConfigAuthMiddleware_Nav_PublicGET(t *testing.T) { - mockJWT := &MockJWTHandler{} srv := setupServer() - cfgMware := srv.ConfigAuthMiddleware(mockJWT) - - req := httptest.NewRequest(http.MethodGet, "/config/nav/default", nil) - rec := httptest.NewRecorder() - app := iris.New() - ctx := app.ContextPool.Acquire(rec, req) - ctx.Params().Set("configType", "nav") - ctx.Params().Set("configId", "default") - - cfgMware(ctx) - assert.False(t, ctx.IsStopped(), "GET for nav should be public") + app := fiber.New() + app.Use("/config/nav/:configId", func(c fiber.Ctx) error { c.Locals("configType", "nav"); return c.Next() }) + app.Get("/config/nav/:configId", servermw.ConfigAuth(srv.Logger, &MockJWTHandler{}), func(c fiber.Ctx) error { return c.SendStatus(http.StatusOK) }) + resp, _ := runFiber(app, httptest.NewRequest(http.MethodGet, "/config/nav/default", nil), t) + assert.Equal(t, http.StatusOK, resp.StatusCode) } func TestBaseConfigsAuthMiddleware_NoAuthorization(t *testing.T) { - mockJWT := &MockJWTHandler{} srv := setupServer() - mware := srv.BaseConfigsAuthMiddleware(mockJWT, "read", "*", "/programs") - - req := httptest.NewRequest(http.MethodGet, "/", nil) - rec := httptest.NewRecorder() - app := iris.New() - ctx := app.ContextPool.Acquire(rec, req) - - mware(ctx) - assert.Equal(t, http.StatusUnauthorized, rec.Code) - assert.Contains(t, rec.Body.String(), "Authorization token not provided") + app := fiber.New() + app.Get("/", servermw.BaseConfigsAuth(srv.Logger, &MockJWTHandler{}, "read", "*", "/programs"), func(c fiber.Ctx) error { return c.SendStatus(http.StatusOK) }) + resp, body := runFiber(app, httptest.NewRequest(http.MethodGet, "/", nil), t) + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + assert.Contains(t, body, "Authorization token not provided") } func TestBaseConfigsAuthMiddleware_InvalidJWTHandler(t *testing.T) { - mockJWT := &MockJWTHandler{} srv := setupServer() - mware := srv.BaseConfigsAuthMiddleware(mockJWT, "read", "*", "/programs") - + app := fiber.New() + app.Get("/", servermw.BaseConfigsAuth(srv.Logger, &MockJWTHandler{}, "read", "*", "/programs"), func(c fiber.Ctx) error { return c.SendStatus(http.StatusOK) }) req := httptest.NewRequest(http.MethodGet, "/", nil) req.Header.Set("Authorization", "Bearer dummy") - rec := httptest.NewRecorder() - app := iris.New() - ctx := app.ContextPool.Acquire(rec, req) - - mware(ctx) - assert.Equal(t, http.StatusInternalServerError, rec.Code) - assert.Contains(t, rec.Body.String(), "Invalid JWT handler configuration") -} - -// TestAppCardAuthMiddleware_NoAuthorization -func TestAppCardAuthMiddleware_NoAuthorization(t *testing.T) { - mockJWT := &MockJWTHandler{} - srv := setupServer() - mware := srv.AppCardAuthMiddleware(mockJWT) - - req := httptest.NewRequest(http.MethodGet, "/config/apps_page/appcard/TEST-PROJECT", nil) - rec := httptest.NewRecorder() - app := iris.New() - ctx := app.ContextPool.Acquire(rec, req) - ctx.Params().Set("projectId", "TEST-PROJECT") // would be set by path in real route - - mware(ctx) - - assert.Equal(t, http.StatusUnauthorized, rec.Code) - assert.Contains(t, rec.Body.String(), "Authorization token not provided") + resp, body := runFiber(app, req, t) + assert.Equal(t, http.StatusInternalServerError, resp.StatusCode) + assert.Contains(t, body, "Invalid JWT handler configuration") } -// TestAppCardAuthMiddleware_GET_Success -func TestAppCardAuthMiddleware_GET_Success(t *testing.T) { - mockJWT := &MockJWTHandler{ - AllowedResources: []string{"/programs/TEST/projects/PROJECT"}, - } +func TestConfigAuthMiddleware_Project_PublicGET(t *testing.T) { srv := setupServer() - mware := srv.AppCardAuthMiddleware(mockJWT) - - req := httptest.NewRequest(http.MethodGet, "/config/apps_page/appcard/TEST-PROJECT", nil) - req.Header.Set("Authorization", "Bearer dummy") - rec := httptest.NewRecorder() - app := iris.New() - ctx := app.ContextPool.Acquire(rec, req) - ctx.Params().Set("projectId", "TEST-PROJECT") - - mware(ctx) - - assert.False(t, ctx.IsStopped(), "Middleware should allow request to continue") - assert.Equal(t, http.StatusOK, rec.Code) // Middleware let it through, handler (default) returns 200 -} - -// TestAppCardAuthMiddleware_GET_Denied -func TestAppCardAuthMiddleware_GET_Denied(t *testing.T) { - mockJWT := &MockJWTHandler{ - AllowedResources: []string{"/programs/other/projects/wrong"}, - } - srv := setupServer() - mware := srv.AppCardAuthMiddleware(mockJWT) - - req := httptest.NewRequest(http.MethodGet, "/config/apps_page/appcard/TEST-PROJECT", nil) - req.Header.Set("Authorization", "Bearer dummy") - rec := httptest.NewRecorder() - app := iris.New() - ctx := app.ContextPool.Acquire(rec, req) - ctx.Params().Set("projectId", "TEST-PROJECT") - - mware(ctx) - - assert.Equal(t, http.StatusForbidden, rec.Code) - assert.Contains(t, rec.Body.String(), "User is not allowed to read on resource path") -} - -// TestAppCardAuthMiddleware_POST_MissingPermsInBody -func TestAppCardAuthMiddleware_POST_MissingPermsInBody(t *testing.T) { - mockJWT := &MockJWTHandler{} - srv := setupServer() - mware := srv.AppCardAuthMiddleware(mockJWT) - - body := `{"title": "Test", "description": "desc", "icon": "/icon.svg", "href": "/link"}` // missing perms - req := httptest.NewRequest(http.MethodPost, "/config/apps_page/appcard", bytes.NewBufferString(body)) - req.Header.Set("Authorization", "Bearer dummy") - req.Header.Set("Content-Type", "application/json") - rec := httptest.NewRecorder() - app := iris.New() - ctx := app.ContextPool.Acquire(rec, req) - - mware(ctx) - - assert.Equal(t, http.StatusBadRequest, rec.Code) - assert.Contains(t, rec.Body.String(), "Missing or empty projectId") -} - -// TestAppCardAuthMiddleware_POST_Success -func TestAppCardAuthMiddleware_POST_Success(t *testing.T) { - mockJWT := &MockJWTHandler{ - AllowedResources: []string{"/programs/TEST/projects/PROJECT"}, - } - srv := setupServer() - mware := srv.AppCardAuthMiddleware(mockJWT) - - body := `{ - "title": "Explore TEST", - "description": "Explore data", - "icon": "/icons/binoculars.svg", - "href": "/Explorer/TEST-PROJECT", - "perms": "TEST-PROJECT" - }` - req := httptest.NewRequest(http.MethodPost, "/config/apps_page/appcard", bytes.NewBufferString(body)) - req.Header.Set("Authorization", "Bearer dummy") - req.Header.Set("Content-Type", "application/json") - rec := httptest.NewRecorder() - app := iris.New() - ctx := app.ContextPool.Acquire(rec, req) - ctx.Params().Set("projectId", "TEST-PROJECT") - - mware(ctx) - - assert.False(t, ctx.IsStopped()) - assert.Equal(t, http.StatusOK, rec.Code) // Middleware let it through, handler (default) returns 200 -} - -// TestAppCardAuthMiddleware_POST_Denied -func TestAppCardAuthMiddleware_POST_Denied(t *testing.T) { - mockJWT := &MockJWTHandler{ - AllowedResources: []string{"/programs/other/projects/wrong"}, - } - srv := setupServer() - mware := srv.AppCardAuthMiddleware(mockJWT) - - body := `{ - "title": "Explore TEST", - "perms": "TEST-PROJECT" - }` - req := httptest.NewRequest(http.MethodPost, "/config/apps_page/appcard", bytes.NewBufferString(body)) - req.Header.Set("Authorization", "Bearer dummy") - req.Header.Set("Content-Type", "application/json") - rec := httptest.NewRecorder() - app := iris.New() - ctx := app.ContextPool.Acquire(rec, req) - ctx.Params().Set("projectId", "TEST-PROJECT") - - mware(ctx) - - assert.Equal(t, http.StatusForbidden, rec.Code) - assert.Contains(t, rec.Body.String(), "User is not allowed to create on resource path") -} - -// TestAppCardAuthMiddleware_DELETE_Success -func TestAppCardAuthMiddleware_DELETE_Success(t *testing.T) { - mockJWT := &MockJWTHandler{ - AllowedResources: []string{"/programs/TEST/projects/PROJECT"}, - } - srv := setupServer() - mware := srv.AppCardAuthMiddleware(mockJWT) - - req := httptest.NewRequest(http.MethodDelete, "/config/apps_page/appcard/TEST-PROJECT", nil) - req.Header.Set("Authorization", "Bearer dummy") - rec := httptest.NewRecorder() - app := iris.New() - ctx := app.ContextPool.Acquire(rec, req) - ctx.Params().Set("projectId", "TEST-PROJECT") - - mware(ctx) - - assert.False(t, ctx.IsStopped()) -} - -// TestAppCardAuthMiddleware_UnsupportedMethod -func TestAppCardAuthMiddleware_UnsupportedMethod(t *testing.T) { - mockJWT := &MockJWTHandler{} - srv := setupServer() - mware := srv.AppCardAuthMiddleware(mockJWT) - - req := httptest.NewRequest(http.MethodPatch, "/config/apps_page/appcard/something", nil) - req.Header.Set("Authorization", "Bearer dummy") - rec := httptest.NewRecorder() - app := iris.New() - ctx := app.ContextPool.Acquire(rec, req) - - mware(ctx) - - assert.Equal(t, http.StatusMethodNotAllowed, rec.Code) - assert.Contains(t, rec.Body.String(), "Unsupported HTTP method") + app := fiber.New() + app.Use("/config/project/:configId", func(c fiber.Ctx) error { c.Locals("configType", "project"); return c.Next() }) + app.Get("/config/project/:configId", servermw.ConfigAuth(srv.Logger, &MockJWTHandler{}), func(c fiber.Ctx) error { return c.SendStatus(http.StatusOK) }) + resp, _ := runFiber(app, httptest.NewRequest(http.MethodGet, "/config/project/default", nil), t) + assert.Equal(t, http.StatusOK, resp.StatusCode) } diff --git a/tests/integration/vector_test.go b/tests/integration/vector_test.go index 1b5fef7..99f4373 100644 --- a/tests/integration/vector_test.go +++ b/tests/integration/vector_test.go @@ -10,7 +10,7 @@ import ( "testing" "time" - "github.com/calypr/gecko/gecko/adapter" + "github.com/calypr/gecko/internal/vectoradapter" "github.com/google/uuid" "github.com/stretchr/testify/assert" ) @@ -29,11 +29,10 @@ func generateRandomFloats(n int) []float32 { var testCollectionName = fmt.Sprintf("test_collection_%x", time.Now().UnixNano()) -const vectorEndpoint = "http://localhost:8080/vector/collections" -const queryEndpoint = "http://localhost:8080/vector/collections/%s/points/search" +const queryEndpoint = "%s/vector/collections/%s/points/search" const VECTOR_NAME = "test_vector" -func cleanupCollection(t *testing.T, name string) { +func cleanupCollection(t *testing.T, name string, vectorEndpoint string) { t.Helper() url := fmt.Sprintf("%s/%s", vectorEndpoint, name) _, err := http.DefaultClient.Do(makeRequest(http.MethodDelete, url, nil)) @@ -43,7 +42,9 @@ func cleanupCollection(t *testing.T, name string) { } func TestQdrantCollectionWorkflow(t *testing.T) { - cleanupCollection(t, testCollectionName) + baseURL := requireIntegrationServer(t) + vectorEndpoint := baseURL + "/vector/collections" + cleanupCollection(t, testCollectionName, vectorEndpoint) pointsEndpoint := fmt.Sprintf("%s/%s/points", vectorEndpoint, testCollectionName) // Test CreateCollection (PUT /vector/collections/{collection}) t.Run("CreateCollection_OK", func(t *testing.T) { @@ -186,8 +187,8 @@ func TestQdrantCollectionWorkflow(t *testing.T) { }) t.Run("QueryPoints_Success", func(t *testing.T) { - url := fmt.Sprintf(queryEndpoint, testCollectionName) - requestBody := adapter.QueryPointsRequest{ + url := fmt.Sprintf(queryEndpoint, baseURL, testCollectionName) + requestBody := vectoradapter.QueryPointsRequest{ LookupID: ptr("c3fb3d5c-e423-46ba-a47a-9ff97b94fc50"), Limit: 100, VectorName: VECTOR_NAME, @@ -218,8 +219,8 @@ func TestQdrantCollectionWorkflow(t *testing.T) { }) t.Run("QueryPoints_MissingVector_BadRequest", func(t *testing.T) { - url := fmt.Sprintf(queryEndpoint, testCollectionName) - requestBody := adapter.QueryPointsRequest{ + url := fmt.Sprintf(queryEndpoint, baseURL, testCollectionName) + requestBody := vectoradapter.QueryPointsRequest{ Query: []float32{}, Limit: 5, } @@ -274,18 +275,18 @@ func TestQdrantCollectionWorkflow(t *testing.T) { }) t.Run("QueryPoints_ByColorFilter_Success", func(t *testing.T) { - url := fmt.Sprintf(queryEndpoint, testCollectionName) - requestBody := adapter.QueryPointsRequest{ + url := fmt.Sprintf(queryEndpoint, baseURL, testCollectionName) + requestBody := vectoradapter.QueryPointsRequest{ LookupID: ptr(ids[0]), // Use first ID, which has color_0 Limit: 10, VectorName: VECTOR_NAME, WithVector: ptr(true), WithPayload: ptr(true), - Filter: &adapter.HeadFilter{ - Must: []adapter.IndFilter{ + Filter: &vectoradapter.HeadFilter{ + Must: []vectoradapter.IndFilter{ { Key: "color", - Match: adapter.MatchFilter{ + Match: vectoradapter.MatchFilter{ Value: "color_0", }, }, @@ -337,8 +338,8 @@ func TestQdrantCollectionWorkflow(t *testing.T) { assert.Equal(t, http.StatusOK, upsertResp.StatusCode) // Now query using the vector we just generated - url := fmt.Sprintf(queryEndpoint, testCollectionName) - requestBody := adapter.QueryPointsRequest{ + url := fmt.Sprintf(queryEndpoint, baseURL, testCollectionName) + requestBody := vectoradapter.QueryPointsRequest{ Query: testVec, Limit: 10, VectorName: VECTOR_NAME, @@ -364,8 +365,8 @@ func TestQdrantCollectionWorkflow(t *testing.T) { }) t.Run("QueryPoints_BySingleID_Success", func(t *testing.T) { - url := fmt.Sprintf(queryEndpoint, testCollectionName) - requestBody := adapter.QueryPointsRequest{ + url := fmt.Sprintf(queryEndpoint, baseURL, testCollectionName) + requestBody := vectoradapter.QueryPointsRequest{ LookupID: ptr(ids[0]), Limit: 10, VectorName: VECTOR_NAME, @@ -391,8 +392,8 @@ func TestQdrantCollectionWorkflow(t *testing.T) { }) t.Run("QueryPoints_ByMultipleIDs_Success", func(t *testing.T) { - url := fmt.Sprintf(queryEndpoint, testCollectionName) - requestBody := adapter.QueryPointsRequest{ + url := fmt.Sprintf(queryEndpoint, baseURL, testCollectionName) + requestBody := vectoradapter.QueryPointsRequest{ Positives: []string{ids[0], ids[1]}, Negatives: []string{ids[9]}, Limit: 7,