diff --git a/api/v2.0/legacy_swagger.yaml b/api/v2.0/legacy_swagger.yaml index 19edea0c1..ff811e47b 100644 --- a/api/v2.0/legacy_swagger.yaml +++ b/api/v2.0/legacy_swagger.yaml @@ -31,28 +31,6 @@ paths: description: The system health status. schema: $ref: '#/definitions/OverallHealthStatus' - /search: - get: - summary: 'Search for projects, repositories and helm charts' - description: | - The Search endpoint returns information about the projects ,repositories and helm charts offered at public status or related to the current logged in user. The response includes the project, repository list and charts in a proper display order. - parameters: - - name: q - in: query - description: Search parameter for project and repository name. - required: true - type: string - tags: - - Products - responses: - '200': - description: An array of search results - schema: - type: array - items: - $ref: '#/definitions/Search' - '500': - description: Unexpected internal errors. '/projects/{project_id}/metadatas': get: summary: Get project metadata. @@ -1981,99 +1959,6 @@ responses: InternalServerError: description: 'Internal Server Error' definitions: - Search: - type: object - properties: - project: - description: Search results of the projects that matched the filter keywords. - type: array - items: - $ref: '#/definitions/Project' - repository: - description: Search results of the repositories that matched the filter keywords. - type: array - items: - $ref: '#/definitions/SearchRepository' - chart: - description: Search results of the charts that macthed the filter keywords. - type: array - items: - $ref: '#/definitions/SearchResult' - SearchRepository: - type: object - properties: - project_id: - type: integer - description: The ID of the project that the repository belongs to - project_name: - type: string - description: The name of the project that the repository belongs to - project_public: - type: boolean - description: 'The flag to indicate the publicity of the project that the repository belongs to (1 is public, 0 is not)' - repository_name: - type: string - description: The name of the repository - pull_count: - type: integer - description: The count how many times the repository is pulled - artifact_count: - type: integer - description: The count of artifacts in the repository - Project: - type: object - properties: - project_id: - type: integer - format: int32 - description: Project ID - owner_id: - type: integer - format: int32 - description: The owner ID of the project always means the creator of the project. - name: - type: string - description: The name of the project. - registry_id: - type: integer - format: int64 - description: The ID of referenced registry when the project is a proxy cache project. - creation_time: - type: string - description: The creation time of the project. - update_time: - type: string - description: The update time of the project. - deleted: - type: boolean - description: A deletion mark of the project. - owner_name: - type: string - description: The owner name of the project. - togglable: - type: boolean - description: Correspond to the UI about whether the project's publicity is updatable (for UI) - current_user_role_id: - type: integer - description: The role ID with highest permission of the current user who triggered the API (for UI). This attribute is deprecated and will be removed in future versions. - current_user_role_ids: - type: array - items: - type: integer - format: int32 - description: The list of role ID of the current user who triggered the API (for UI) - repo_count: - type: integer - description: The number of the repositories under this project. - chart_count: - type: integer - description: The total number of charts under this project. - metadata: - description: The metadata of the project. - $ref: '#/definitions/ProjectMetadata' - cve_allowlist: - description: The CVE allowlist of this project. - $ref: '#/definitions/CVEAllowlist' ProjectMetadata: type: object properties: @@ -2941,18 +2826,6 @@ definitions: properties: labels: $ref: '#/definitions/Labels' - SearchResult: - type: object - description: The chart search result item - properties: - name: - type: string - description: The chart name with repo name - score: - type: integer - description: The matched level - chart: - $ref: '#/definitions/ChartVersion' Labels: type: array description: A list of label diff --git a/api/v2.0/swagger.yaml b/api/v2.0/swagger.yaml index b8449661b..f5b5d55ba 100644 --- a/api/v2.0/swagger.yaml +++ b/api/v2.0/swagger.yaml @@ -19,6 +19,27 @@ security: - basic: [] - {} paths: + /search: + get: + summary: 'Search for projects, repositories and helm charts' + description: |- + The Search endpoint returns information about the projects, repositories and helm charts offered at public status or related to the current logged in user. The response includes the project, repository list and charts in a proper display order. + parameters: + - name: q + in: query + description: Search parameter for project and repository name. + required: true + type: string + tags: + - search + operationId: search + responses: + '200': + description: An array of search results + schema: + $ref: '#/definitions/Search' + '500': + $ref: '#/responses/500' /ldap/ping: post: operationId: pingLdap @@ -3656,6 +3677,134 @@ definitions: message: type: string description: The error message + Search: + type: object + properties: + project: + description: Search results of the projects that matched the filter keywords. + type: array + items: + $ref: '#/definitions/Project' + repository: + description: Search results of the repositories that matched the filter keywords. + type: array + items: + $ref: '#/definitions/SearchRepository' + chart: + description: Search results of the charts that macthed the filter keywords. + type: array + items: + $ref: '#/definitions/SearchResult' + x-omitempty: true + x-isnullable: true + SearchRepository: + type: object + properties: + project_id: + type: integer + description: The ID of the project that the repository belongs to + project_name: + type: string + description: The name of the project that the repository belongs to + project_public: + type: boolean + description: 'The flag to indicate the publicity of the project that the repository belongs to (1 is public, 0 is not)' + repository_name: + type: string + description: The name of the repository + pull_count: + type: integer + description: The count how many times the repository is pulled + artifact_count: + type: integer + description: The count of artifacts in the repository + SearchResult: + type: object + description: The chart search result item + properties: + Name: + type: string + description: The chart name with repo name + Score: + type: integer + description: The matched level + Chart: + $ref: '#/definitions/ChartVersion' + ChartVersion: + type: object + description: A specified chart entry + allOf: + - $ref: '#/definitions/ChartMetadata' + - type: object + properties: + created: + type: string + description: The created time of the chart entry + removed: + type: boolean + description: A flag to indicate if the chart entry is removed + digest: + type: string + description: The digest value of the chart entry + urls: + type: array + description: The urls of the chart entry + items: + type: string + properties: + labels: + type: array + description: A list of label + items: + $ref: '#/definitions/Label' + ChartMetadata: + type: object + description: The metadata of chart version + required: + - name + - version + - engine + - icon + - apiVersion + - appVersion + properties: + name: + type: string + description: The name of the chart + home: + type: string + description: The URL to the relevant project page + sources: + type: array + description: The URL to the source code of chart + items: + type: string + version: + type: string + description: A SemVer 2 version of chart + description: + type: string + description: A one-sentence description of chart + keywords: + type: array + description: A list of string keywords + items: + type: string + engine: + type: string + description: The name of template engine + icon: + type: string + description: The URL to an icon file + apiVersion: + type: string + description: The API version of this chart + appVersion: + type: string + description: The version of the application enclosed in the chart + deprecated: + type: boolean + description: Whether or not this chart is deprecated Repository: type: object properties: diff --git a/src/common/dao/repository.go b/src/common/dao/repository.go index 0ab14ef3b..326e07f45 100644 --- a/src/common/dao/repository.go +++ b/src/common/dao/repository.go @@ -86,38 +86,6 @@ func GetTotalOfRepositories(query ...*models.RepositoryQuery) (int64, error) { return total, nil } -// GetRepositories ... -func GetRepositories(query ...*models.RepositoryQuery) ([]*models.RepoRecord, error) { - repositories := []*models.RepoRecord{} - order := "name asc" - if len(query) > 0 && query[0] != nil { - if s, ok := orderMap[query[0].Sort]; ok { - order = s - } - } - - condition, params := repositoryQueryConditions(query...) - sql := fmt.Sprintf(`select r.repository_id, r.name, r.project_id, r.description, r.pull_count, - r.star_count, r.creation_time, r.update_time %s order by r.%s `, condition, order) - if len(query) > 0 && query[0] != nil { - page, size := query[0].Page, query[0].Size - if size > 0 { - sql += `limit ? ` - params = append(params, size) - if page > 0 { - sql += `offset ? ` - params = append(params, size*(page-1)) - } - } - } - - if _, err := GetOrmer().Raw(sql, params).QueryRows(&repositories); err != nil { - return nil, err - } - - return repositories, nil -} - func repositoryQueryConditions(query ...*models.RepositoryQuery) (string, []interface{}) { params := []interface{}{} sql := `from repository r ` @@ -127,7 +95,7 @@ func repositoryQueryConditions(query ...*models.RepositoryQuery) (string, []inte q := query[0] if q.LabelID > 0 { - sql += `join harbor_resource_label rl on r.repository_id = rl.resource_id + sql += `join harbor_resource_label rl on r.repository_id = rl.resource_id and rl.resource_type = 'r' ` } sql += `where 1=1 ` diff --git a/src/common/dao/repository_test.go b/src/common/dao/repository_test.go index 4a4b5518d..2f419eb70 100644 --- a/src/common/dao/repository_test.go +++ b/src/common/dao/repository_test.go @@ -17,7 +17,6 @@ package dao import ( "testing" - "github.com/goharbor/harbor/src/common" "github.com/goharbor/harbor/src/common/models" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -45,82 +44,6 @@ func TestGetTotalOfRepositories(t *testing.T) { assert.Equal(t, total+1, n) } -func TestGetRepositories(t *testing.T) { - // no query - repositories, err := GetRepositories() - require.Nil(t, err) - n := len(repositories) - - err = addRepository(repository) - require.Nil(t, err) - defer deleteRepository(name) - - repositories, err = GetRepositories() - require.Nil(t, err) - assert.Equal(t, n+1, len(repositories)) - - // query by name - repositories, err = GetRepositories(&models.RepositoryQuery{ - Name: name, - }) - require.Nil(t, err) - require.Equal(t, 1, len(repositories)) - assert.Equal(t, name, repositories[0].Name) - - // query by project name - repositories, err = GetRepositories(&models.RepositoryQuery{ - ProjectName: project, - }) - require.Nil(t, err) - found := false - for _, repository := range repositories { - if repository.Name == name { - found = true - break - } - } - assert.True(t, found) - - // query by project ID - repositories, err = GetRepositories(&models.RepositoryQuery{ - ProjectIDs: []int64{1}, - }) - require.Nil(t, err) - found = false - for _, repository := range repositories { - if repository.Name == name { - found = true - break - } - } - assert.True(t, found) - - // query by label ID - labelID, err := AddLabel(&models.Label{ - Name: "label_for_test", - }) - require.Nil(t, err) - defer DeleteLabel(labelID) - - r, err := GetRepositoryByName(name) - require.Nil(t, err) - - rlID, err := AddResourceLabel(&models.ResourceLabel{ - LabelID: labelID, - ResourceID: r.RepositoryID, - ResourceType: common.ResourceTypeRepository, - }) - require.Nil(t, err) - defer DeleteResourceLabel(rlID) - - repositories, err = GetRepositories(&models.RepositoryQuery{ - LabelID: labelID, - }) - require.Nil(t, err) - require.Equal(t, 1, len(repositories)) - assert.Equal(t, name, repositories[0].Name) -} - func addRepository(repository *models.RepoRecord) error { return AddRepository(*repository) } diff --git a/src/core/api/harborapi_test.go b/src/core/api/harborapi_test.go index 61043b7ed..fae4bb17c 100644 --- a/src/core/api/harborapi_test.go +++ b/src/core/api/harborapi_test.go @@ -95,7 +95,6 @@ func init() { beego.TestBeegoInit(apppath) beego.Router("/api/health", &HealthAPI{}, "get:CheckHealth") - beego.Router("/api/search/", &SearchAPI{}) beego.Router("/api/users/:id", &UserAPI{}, "get:Get") beego.Router("/api/users", &UserAPI{}, "get:List;post:Post;delete:Delete;put:Put") beego.Router("/api/users/search", &UserAPI{}, "get:Search") diff --git a/src/core/api/search.go b/src/core/api/search.go deleted file mode 100644 index 555f14914..000000000 --- a/src/core/api/search.go +++ /dev/null @@ -1,215 +0,0 @@ -// Copyright 2018 Project Harbor Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package api - -import ( - "fmt" - - "github.com/goharbor/harbor/src/common" - "github.com/goharbor/harbor/src/common/dao" - "github.com/goharbor/harbor/src/common/models" - "github.com/goharbor/harbor/src/common/security/local" - "github.com/goharbor/harbor/src/common/utils" - "github.com/goharbor/harbor/src/controller/artifact" - "github.com/goharbor/harbor/src/controller/project" - "github.com/goharbor/harbor/src/core/config" - "github.com/goharbor/harbor/src/lib/log" - "github.com/goharbor/harbor/src/lib/orm" - "github.com/goharbor/harbor/src/lib/q" - "helm.sh/helm/v3/cmd/helm/search" -) - -type chartSearchHandler func(string, []string) ([]*search.Result, error) - -var searchHandler chartSearchHandler - -// SearchAPI handles request to /api/search -type SearchAPI struct { - BaseController -} - -type searchResult struct { - Project []*models.Project `json:"project"` - Repository []map[string]interface{} `json:"repository"` - Chart *[]*search.Result `json:"chart,omitempty"` -} - -// Get ... -func (s *SearchAPI) Get() { - keyword := s.GetString("q") - - query := q.New(q.KeyWords{}) - if keyword != "" { - query.Keywords["name"] = &q.FuzzyMatchValue{Value: keyword} - } - - if !s.SecurityCtx.IsSysAdmin() { - if sc, ok := s.SecurityCtx.(*local.SecurityContext); ok && sc.IsAuthenticated() { - user := sc.User() - member := &project.MemberQuery{ - UserID: user.UserID, - GroupIDs: user.GroupIDs, - WithPublic: true, - } - query.Keywords["member"] = member - } else { - query.Keywords["public"] = true - } - } - - projects, err := s.ProjectCtl.List(s.Context(), query) - if err != nil { - s.ParseAndHandleError("failed to get projects", err) - return - } - - projectResult := []*models.Project{} - proNames := []string{} - for _, p := range projects { - proNames = append(proNames, p.Name) - - if sc, ok := s.SecurityCtx.(*local.SecurityContext); ok && sc.IsAuthenticated() { - roles, err := s.ProjectCtl.ListRoles(s.Context(), p.ProjectID, sc.User()) - if err != nil { - s.SendInternalServerError(fmt.Errorf("failed to list roles: %v", err)) - return - } - p.RoleList = roles - p.Role = highestRole(roles) - } - - total, err := dao.GetTotalOfRepositories(&models.RepositoryQuery{ - ProjectIDs: []int64{p.ProjectID}, - }) - if err != nil { - log.Errorf("failed to get total of repositories of project %d: %v", p.ProjectID, err) - s.SendInternalServerError(fmt.Errorf("failed to get total of repositories of project %d: %v", p.ProjectID, err)) - return - } - - p.RepoCount = total - - projectResult = append(projectResult, p) - } - - repositoryResult, err := filterRepositories(projects, keyword) - if err != nil { - log.Errorf("failed to filter repositories: %v", err) - s.SendInternalServerError(fmt.Errorf("failed to filter repositories: %v", err)) - return - } - - result := &searchResult{ - Project: projectResult, - Repository: repositoryResult, - } - - // If enable chart repository - if config.WithChartMuseum() { - if searchHandler == nil { - searchHandler = chartController.SearchChart - } - - chartResults, err := searchHandler(keyword, proNames) - if err != nil { - log.Errorf("failed to filter charts: %v", err) - s.SendInternalServerError(err) - return - - } - result.Chart = &chartResults - - } - - s.Data["json"] = result - s.ServeJSON() -} - -func filterRepositories(projects []*models.Project, keyword string) ( - []map[string]interface{}, error) { - result := []map[string]interface{}{} - if len(projects) == 0 { - return result, nil - } - - repositories, err := dao.GetRepositories(&models.RepositoryQuery{ - Name: keyword, - }) - if err != nil { - return nil, err - } - if len(repositories) == 0 { - return result, nil - } - - projectMap := map[string]*models.Project{} - for _, project := range projects { - projectMap[project.Name] = project - } - - ctx := orm.NewContext(nil, dao.GetOrmer()) - for _, repository := range repositories { - projectName, _ := utils.ParseRepository(repository.Name) - project, exist := projectMap[projectName] - if !exist { - continue - } - entry := make(map[string]interface{}) - entry["repository_name"] = repository.Name - entry["project_name"] = project.Name - entry["project_id"] = project.ProjectID - entry["project_public"] = project.IsPublic() - entry["pull_count"] = repository.PullCount - - count, err := artifact.Ctl.Count(ctx, &q.Query{ - Keywords: map[string]interface{}{ - "RepositoryID": repository.RepositoryID, - }, - }) - if err != nil { - log.Errorf("failed to get the count of artifacts under the repository %s: %v", - repository.Name, err) - } else { - entry["artifact_count"] = count - } - - result = append(result, entry) - } - return result, nil -} - -// Returns the highest role in the role list. -// This func should be removed once we deprecate the "current_user_role_id" in project API -// A user can have multiple roles and they may not have a strict ranking relationship -func highestRole(roles []int) int { - if roles == nil { - return 0 - } - rolePower := map[int]int{ - common.RoleProjectAdmin: 50, - common.RoleMaintainer: 40, - common.RoleDeveloper: 30, - common.RoleGuest: 20, - common.RoleLimitedGuest: 10, - } - var highest, highestPower int - for _, role := range roles { - if p, ok := rolePower[role]; ok && p > highestPower { - highest = role - highestPower = p - } - } - return highest -} diff --git a/src/core/api/search_test.go b/src/core/api/search_test.go deleted file mode 100644 index a25d0af1a..000000000 --- a/src/core/api/search_test.go +++ /dev/null @@ -1,208 +0,0 @@ -// Copyright 2018 Project Harbor Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -package api - -import ( - "fmt" - "net/http" - "testing" - - "github.com/goharbor/harbor/src/common" - "github.com/goharbor/harbor/src/common/models" - "github.com/goharbor/harbor/src/core/config" - - "github.com/goharbor/harbor/src/common/dao" - member "github.com/goharbor/harbor/src/common/dao/project" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "helm.sh/helm/v3/cmd/helm/search" - helm_repo "helm.sh/helm/v3/pkg/repo" -) - -func TestSearch(t *testing.T) { - fmt.Println("Testing Search(SearchGet) API") - // Use mock chart search handler - searchHandler = func(string, []string) ([]*search.Result, error) { - results := []*search.Result{} - results = append(results, &search.Result{ - Name: "library/harbor", - Score: 0, - Chart: &helm_repo.ChartVersion{}, - }) - - return results, nil - } - // create a public project named "search" - projectID1, err := dao.AddProject(models.Project{ - Name: "search", - OwnerID: int(nonSysAdminID), - }) - require.Nil(t, err) - defer dao.DeleteProject(projectID1) - - err = dao.AddProjectMetadata(&models.ProjectMetadata{ - ProjectID: projectID1, - Name: "public", - Value: "true", - }) - require.Nil(t, err) - - memberID1, err := member.AddProjectMember(models.Member{ - ProjectID: projectID1, - EntityID: int(nonSysAdminID), - EntityType: common.UserMember, - Role: common.RoleGuest, - }) - require.Nil(t, err) - defer member.DeleteProjectMemberByID(memberID1) - - // create a private project named "search-2", the "-" is necessary - // in the project name to test some corner cases - projectID2, err := dao.AddProject(models.Project{ - Name: "search-2", - OwnerID: int(nonSysAdminID), - }) - require.Nil(t, err) - defer dao.DeleteProject(projectID2) - - memberID2, err := member.AddProjectMember(models.Member{ - ProjectID: projectID2, - EntityID: int(nonSysAdminID), - EntityType: common.UserMember, - Role: common.RoleGuest, - }) - require.Nil(t, err) - defer member.DeleteProjectMemberByID(memberID2) - - // add a repository in project "search" - err = dao.AddRepository(models.RepoRecord{ - ProjectID: projectID1, - Name: "search/hello-world", - }) - require.Nil(t, err) - - // add a repository in project "search-2" - err = dao.AddRepository(models.RepoRecord{ - ProjectID: projectID2, - Name: "search-2/hello-world", - }) - require.Nil(t, err) - - // search without login - result := &searchResult{} - err = handleAndParse(&testingRequest{ - method: http.MethodGet, - url: "/api/search", - queryStruct: struct { - Keyword string `url:"q"` - }{ - Keyword: "search", - }, - }, result) - require.Nil(t, err) - require.Equal(t, 1, len(result.Project)) - require.Equal(t, 1, len(result.Repository)) - assert.Equal(t, "search", result.Project[0].Name) - assert.Equal(t, "search/hello-world", result.Repository[0]["repository_name"].(string)) - - // search with user who is the member of the project - err = handleAndParse(&testingRequest{ - method: http.MethodGet, - url: "/api/search", - queryStruct: struct { - Keyword string `url:"q"` - }{ - Keyword: "search", - }, - credential: nonSysAdmin, - }, result) - require.Nil(t, err) - require.Equal(t, 2, len(result.Project)) - require.Equal(t, 2, len(result.Repository)) - projects := map[string]struct{}{} - repositories := map[string]struct{}{} - for _, project := range result.Project { - projects[project.Name] = struct{}{} - } - for _, repository := range result.Repository { - repositories[repository["repository_name"].(string)] = struct{}{} - } - - _, exist := projects["search"] - assert.True(t, exist) - _, exist = projects["search-2"] - assert.True(t, exist) - _, exist = repositories["search/hello-world"] - assert.True(t, exist) - _, exist = repositories["search-2/hello-world"] - assert.True(t, exist) - - // search with system admin - err = handleAndParse(&testingRequest{ - method: http.MethodGet, - url: "/api/search", - queryStruct: struct { - Keyword string `url:"q"` - }{ - Keyword: "search", - }, - credential: sysAdmin, - }, result) - require.Nil(t, err) - require.Equal(t, 2, len(result.Project)) - require.Equal(t, 2, len(result.Repository)) - projects = map[string]struct{}{} - repositories = map[string]struct{}{} - for _, project := range result.Project { - projects[project.Name] = struct{}{} - } - for _, repository := range result.Repository { - repositories[repository["repository_name"].(string)] = struct{}{} - } - _, exist = projects["search"] - assert.True(t, exist) - _, exist = projects["search-2"] - assert.True(t, exist) - _, exist = repositories["search/hello-world"] - assert.True(t, exist) - _, exist = repositories["search-2/hello-world"] - assert.True(t, exist) - - chartSettings := map[string]interface{}{ - common.WithChartMuseum: true, - } - config.InitWithSettings(chartSettings) - defer func() { - // reset config - config.Init() - }() - - // Search chart - err = handleAndParse(&testingRequest{ - method: http.MethodGet, - url: "/api/search", - queryStruct: struct { - Keyword string `url:"q"` - }{ - Keyword: "harbor", - }, - credential: sysAdmin, - }, result) - require.Nil(t, err) - require.Equal(t, 1, len(*(result.Chart))) - require.Equal(t, "library/harbor", (*result.Chart)[0].Name) - - // Restore chart search handler - searchHandler = nil -} diff --git a/src/server/v2.0/handler/handler.go b/src/server/v2.0/handler/handler.go index 8ce90e1cc..a5e9506da 100644 --- a/src/server/v2.0/handler/handler.go +++ b/src/server/v2.0/handler/handler.go @@ -37,6 +37,7 @@ func New() http.Handler { ScannerAPI: newScannerAPI(), ScanAPI: newScanAPI(), ScanAllAPI: newScanAllAPI(), + SearchAPI: newSearchAPI(), ProjectAPI: newProjectAPI(), PreheatAPI: newPreheatAPI(), IconAPI: newIconAPI(), diff --git a/src/server/v2.0/handler/search.go b/src/server/v2.0/handler/search.go new file mode 100644 index 000000000..9b12d72d2 --- /dev/null +++ b/src/server/v2.0/handler/search.go @@ -0,0 +1,255 @@ +// Copyright Project Harbor Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package handler + +import ( + "context" + "fmt" + "net/http" + "strings" + + "github.com/go-openapi/runtime" + "github.com/go-openapi/runtime/middleware" + "github.com/goharbor/harbor/src/common/security" + "github.com/goharbor/harbor/src/common/security/local" + "github.com/goharbor/harbor/src/common/utils" + "github.com/goharbor/harbor/src/controller/artifact" + "github.com/goharbor/harbor/src/controller/project" + "github.com/goharbor/harbor/src/controller/repository" + "github.com/goharbor/harbor/src/core/api" + "github.com/goharbor/harbor/src/core/config" + "github.com/goharbor/harbor/src/lib" + "github.com/goharbor/harbor/src/lib/errors" + "github.com/goharbor/harbor/src/lib/log" + "github.com/goharbor/harbor/src/lib/q" + "github.com/goharbor/harbor/src/server/v2.0/handler/model" + "github.com/goharbor/harbor/src/server/v2.0/models" + operation "github.com/goharbor/harbor/src/server/v2.0/restapi/operations/search" + "helm.sh/helm/v3/cmd/helm/search" +) + +func newSearchAPI() *searchAPI { + return &searchAPI{ + artifactCtl: artifact.Ctl, + projectCtl: project.Ctl, + repositoryCtl: repository.Ctl, + + chartMuseumEnabled: config.WithChartMuseum(), + searchCharts: func(q string, namespaces []string) ([]*search.Result, error) { + return api.GetChartController().SearchChart(q, namespaces) + }, + } +} + +type searchAPI struct { + BaseAPI + artifactCtl artifact.Controller + projectCtl project.Controller + repositoryCtl repository.Controller + + chartMuseumEnabled bool + searchCharts func(string, []string) ([]*search.Result, error) +} + +func (s *searchAPI) Search(ctx context.Context, params operation.SearchParams) middleware.Responder { + secCtx, ok := security.FromContext(ctx) + if !ok { + return s.SendError(ctx, fmt.Errorf("security not found in the context")) + } + + kw := q.KeyWords{} + + if !secCtx.IsSysAdmin() { + if sc, ok := secCtx.(*local.SecurityContext); ok && sc.IsAuthenticated() { + user := sc.User() + kw["member"] = &project.MemberQuery{ + UserID: user.UserID, + GroupIDs: user.GroupIDs, + WithPublic: true, + } + } else { + kw["public"] = true + } + } + + projects, err := s.projectCtl.List(ctx, q.New(kw)) + if err != nil { + return s.SendError(ctx, err) + } + + projectResult := []*models.Project{} + proNames := []string{} + for _, p := range projects { + proNames = append(proNames, p.Name) + + if params.Q != "" && !strings.Contains(p.Name, params.Q) { + continue + } + + if sc, ok := secCtx.(*local.SecurityContext); ok && sc.IsAuthenticated() { + roles, err := s.projectCtl.ListRoles(ctx, p.ProjectID, sc.User()) + if err != nil { + return s.SendError(ctx, errors.Wrap(err, "failed to list roles")) + } + p.RoleList = roles + p.Role = highestRole(roles) + } + + total, err := s.repositoryCtl.Count(ctx, q.New(q.KeyWords{"project_id": p.ProjectID})) + if err != nil { + log.Errorf("failed to get total of repositories of project %d: %v", p.ProjectID, err) + return s.SendError(ctx, errors.Wrapf(err, "failed to get total of repositories of project %d", p.ProjectID)) + } + + p.RepoCount = total + + projectResult = append(projectResult, model.NewProject(p).ToSwagger()) + } + + repositoryResult, err := s.filterRepositories(ctx, projects, params.Q) + if err != nil { + log.Errorf("failed to filter repositories: %v", err) + return s.SendError(ctx, errors.Wrap(err, "failed to filter repositories")) + } + + chartResult, err := s.filterCharts(ctx, params.Q, proNames) + if err != nil { + log.Errorf("failed to filter charts: %v", err) + return s.SendError(ctx, errors.Wrap(err, "failed to filter charts")) + } + + return newSearchOK().WithPayload(&models.Search{ + Project: projectResult, + Repository: repositoryResult, + Chart: chartResult, + }) +} + +func (s *searchAPI) filterRepositories(ctx context.Context, projects []*project.Project, keyword string) ([]*models.SearchRepository, error) { + result := []*models.SearchRepository{} + if len(projects) == 0 { + return result, nil + } + + repositories, err := s.repositoryCtl.List(ctx, q.New(q.KeyWords{"name": &q.FuzzyMatchValue{Value: keyword}})) + if err != nil { + return nil, err + } + + if len(repositories) == 0 { + return result, nil + } + + projectMap := map[string]*project.Project{} + for _, project := range projects { + projectMap[project.Name] = project + } + + for _, repository := range repositories { + projectName, _ := utils.ParseRepository(repository.Name) + project, exist := projectMap[projectName] + if !exist { + continue + } + + entry := models.SearchRepository{ + RepositoryName: repository.Name, + ProjectName: project.Name, + ProjectID: repository.ProjectID, + ProjectPublic: project.IsPublic(), + PullCount: repository.PullCount, + } + + count, err := s.artifactCtl.Count(ctx, q.New(q.KeyWords{"RepositoryID": repository.RepositoryID})) + if err != nil { + log.Errorf("failed to get the count of artifacts under the repository %s: %v", + repository.Name, err) + } else { + entry.ArtifactCount = count + } + + result = append(result, &entry) + } + + return result, nil +} + +func (s *searchAPI) filterCharts(ctx context.Context, q string, namespaces []string) ([]*models.SearchResult, error) { + if !s.chartMuseumEnabled { + return nil, nil + } + + result := []*models.SearchResult{} + if len(namespaces) == 0 { + return result, nil + } + + charts, err := s.searchCharts(q, namespaces) + if err != nil { + return nil, err + } + + for _, chart := range charts { + var entry models.SearchResult + if err := lib.JSONCopy(&entry, chart); err != nil { + return nil, err + } + + result = append(result, &entry) + } + + return result, nil +} + +// searchOK removing the chart from the response when the chartmuseum is disabled +type searchOK struct { + Payload interface{} +} + +func (o *searchOK) WithPayload(payload *models.Search) *searchOK { + if payload != nil { + p := &struct { + Chart *[]*models.SearchResult `json:"chart,omitempty"` + Project []*models.Project `json:"project"` + Repository []*models.SearchRepository `json:"repository"` + }{ + Project: payload.Project, + Repository: payload.Repository, + } + + if payload.Chart != nil { + p.Chart = &payload.Chart + } + + o.Payload = p + } + + return o +} + +func (o *searchOK) WriteResponse(rw http.ResponseWriter, producer runtime.Producer) { + rw.WriteHeader(200) + + if o.Payload != nil { + payload := o.Payload + if err := producer.Produce(rw, payload); err != nil { + panic(err) // let the recovery middleware deal with this + } + } +} + +func newSearchOK() *searchOK { + return &searchOK{} +} diff --git a/src/server/v2.0/route/legacy.go b/src/server/v2.0/route/legacy.go index 9208aa9ec..61e0cf536 100755 --- a/src/server/v2.0/route/legacy.go +++ b/src/server/v2.0/route/legacy.go @@ -34,7 +34,6 @@ func registerLegacyRoutes() { beego.Router("/api/"+version+"/usergroups/?:ugid([0-9]+)", &api.UserGroupAPI{}) beego.Router("/api/"+version+"/email/ping", &api.EmailAPI{}, "post:Ping") beego.Router("/api/"+version+"/health", &api.HealthAPI{}, "get:CheckHealth") - beego.Router("/api/"+version+"/search", &api.SearchAPI{}) beego.Router("/api/"+version+"/projects/:id([0-9]+)/metadatas/?:name", &api.MetadataAPI{}, "get:Get") beego.Router("/api/"+version+"/projects/:id([0-9]+)/metadatas/", &api.MetadataAPI{}, "post:Post")