diff --git a/registry/app/api/controller/pkg/npm/controller.go b/registry/app/api/controller/pkg/npm/controller.go index aaef909b8..b05fb36a9 100644 --- a/registry/app/api/controller/pkg/npm/controller.go +++ b/registry/app/api/controller/pkg/npm/controller.go @@ -80,6 +80,12 @@ type Controller interface { ctx context.Context, info npm.ArtifactInfo, ) *DeleteEntityResponse + + SearchPackage( + ctx context.Context, + info npm.ArtifactInfo, + limit int, offset int, + ) *SearchArtifactResponse } // NewController creates a new PyPI controller. diff --git a/registry/app/api/controller/pkg/npm/response.go b/registry/app/api/controller/pkg/npm/response.go index 87bcbbd8f..976e73829 100644 --- a/registry/app/api/controller/pkg/npm/response.go +++ b/registry/app/api/controller/pkg/npm/response.go @@ -81,3 +81,12 @@ type DeleteEntityResponse struct { func (r *DeleteEntityResponse) GetError() error { return r.Error } + +type SearchArtifactResponse struct { + BaseResponse + Artifacts *npm2.PackageSearch +} + +func (r *SearchArtifactResponse) GetError() error { + return r.Error +} diff --git a/registry/app/api/controller/pkg/npm/search_package.go b/registry/app/api/controller/pkg/npm/search_package.go new file mode 100644 index 000000000..00115c789 --- /dev/null +++ b/registry/app/api/controller/pkg/npm/search_package.go @@ -0,0 +1,66 @@ +// Copyright 2023 Harness, Inc. +// +// 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 npm + +import ( + "context" + "fmt" + + "github.com/harness/gitness/registry/app/pkg" + "github.com/harness/gitness/registry/app/pkg/base" + "github.com/harness/gitness/registry/app/pkg/commons" + npm2 "github.com/harness/gitness/registry/app/pkg/npm" + "github.com/harness/gitness/registry/app/pkg/response" + "github.com/harness/gitness/registry/app/pkg/types/npm" + "github.com/harness/gitness/registry/types" +) + +func (c *controller) SearchPackage( + ctx context.Context, + info npm.ArtifactInfo, + limit int, offset int, +) *SearchArtifactResponse { + f := func(registry types.Registry, a pkg.Artifact) response.Response { + info.RegIdentifier = registry.Name + info.RegistryID = registry.ID + info.ParentID = registry.ParentID + info.Registry = registry + npmRegistry, ok := a.(npm2.Registry) + if !ok { + return &GetMetadataResponse{ + BaseResponse: BaseResponse{Error: fmt.Errorf("invalid registry type: expected npm.Registry")}, + } + } + artifacts, err := npmRegistry.SearchPackage(ctx, info, limit, offset) + return &SearchArtifactResponse{ + BaseResponse: BaseResponse{Error: err}, + Artifacts: artifacts, + } + } + + result, err := base.NoProxyWrapper(ctx, c.registryDao, f, info) + if !commons.IsEmpty(err) { + return &SearchArtifactResponse{ + BaseResponse: BaseResponse{Error: err}, + } + } + metadataResponse, ok := result.(*SearchArtifactResponse) + if !ok { + return &SearchArtifactResponse{ + BaseResponse: BaseResponse{Error: fmt.Errorf("invalid response type: expected GetMetadataResponse, got %T", result)}, + } + } + return metadataResponse +} diff --git a/registry/app/api/handler/npm/handler.go b/registry/app/api/handler/npm/handler.go index 1c4bd9857..6a720bd2b 100644 --- a/registry/app/api/handler/npm/handler.go +++ b/registry/app/api/handler/npm/handler.go @@ -56,6 +56,7 @@ type Handler interface { DeletePackage(w http.ResponseWriter, r *http.Request) DeleteVersion(w http.ResponseWriter, r *http.Request) DeletePreview(w http.ResponseWriter, r *http.Request) + SearchPackage(w http.ResponseWriter, r *http.Request) } type handler struct { diff --git a/registry/app/api/handler/npm/search.go b/registry/app/api/handler/npm/search.go new file mode 100644 index 000000000..2f22e1874 --- /dev/null +++ b/registry/app/api/handler/npm/search.go @@ -0,0 +1,66 @@ +// Copyright 2023 Harness, Inc. +// +// 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 npm + +import ( + "encoding/json" + "fmt" + "net/http" + "strconv" + + errors2 "github.com/harness/gitness/errors" + "github.com/harness/gitness/registry/app/pkg/commons" + npm2 "github.com/harness/gitness/registry/app/pkg/types/npm" + "github.com/harness/gitness/registry/request" + + "github.com/rs/zerolog/log" +) + +func (h *handler) SearchPackage(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + info, ok := request.ArtifactInfoFrom(ctx).(npm2.ArtifactInfo) + if !ok { + log.Ctx(ctx).Error().Msg("Failed to get npm artifact info from context") + h.HandleErrors(r.Context(), []error{fmt.Errorf("failed to fetch npm artifact info from context")}, w) + return + } + + info.Image = r.FormValue("text") + limit, err := strconv.Atoi(r.FormValue("size")) + if err != nil { + limit = 20 + } + offset, err := strconv.Atoi(r.FormValue("from")) + if err != nil { + offset = 0 + } + response := h.controller.SearchPackage(ctx, info, limit, offset) + + if !commons.IsEmpty(response.GetError()) { + if errors2.IsNotFound(response.GetError()) { + http.Error(w, response.GetError().Error(), http.StatusNotFound) + return + } + http.Error(w, response.GetError().Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + err = json.NewEncoder(w).Encode(response.Artifacts) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } +} diff --git a/registry/app/api/router/packages/route.go b/registry/app/api/router/packages/route.go index 1931577f4..7c20ad90a 100644 --- a/registry/app/api/router/packages/route.go +++ b/registry/app/api/router/packages/route.go @@ -197,6 +197,12 @@ func NewRouter( r.Route("/{id}/-rev/{revision}", func(r chi.Router) { registerRevisionRoutes(r, npmHandler, packageHandler) }) + + r.Route("/-/v1/search", func(r chi.Router) { + r.With(middleware.StoreArtifactInfo(npmHandler)). + With(middleware.RequestPackageAccess(packageHandler, enum.PermissionArtifactsDownload)). + Get("/", npmHandler.SearchPackage) + }) }) r.Route("/rpm", func(r chi.Router) { r.Use(middlewareauthn.Attempt(packageHandler.GetAuthenticator())) diff --git a/registry/app/metadata/npm/metadata.go b/registry/app/metadata/npm/metadata.go index c1e9df891..8929924b9 100644 --- a/registry/app/metadata/npm/metadata.go +++ b/registry/app/metadata/npm/metadata.go @@ -110,12 +110,12 @@ type PackageSearchPackage struct { Scope string `json:"scope"` Name string `json:"name"` Version string `json:"version"` - Date time.Time `json:"date"` - Description string `json:"description"` - Author User `json:"author"` - Publisher User `json:"publisher"` - Maintainers []User `json:"maintainers"` - Keywords []string `json:"keywords,omitempty"` + Date interface{} `json:"date"` + Description interface{} `json:"description"` + Author interface{} `json:"author"` + Publisher interface{} `json:"publisher"` + Maintainers interface{} `json:"maintainers"` + Keywords interface{} `json:"keywords,omitempty"` Links *PackageSearchPackageLinks `json:"links"` } @@ -132,7 +132,7 @@ type User struct { URL string `json:"url,omitempty"` } -// PythonMetadata represents the metadata for a Python package. +// NpmMetadata represents the metadata for a Python package. // //nolint:revive type NpmMetadata struct { diff --git a/registry/app/pkg/npm/local.go b/registry/app/pkg/npm/local.go index b76c5e4b6..8e99a0664 100644 --- a/registry/app/pkg/npm/local.go +++ b/registry/app/pkg/npm/local.go @@ -19,6 +19,7 @@ import ( "encoding/json" "fmt" "io" + "strings" "github.com/harness/gitness/app/api/usererror" urlprovider "github.com/harness/gitness/app/url" @@ -165,6 +166,98 @@ func (c *localRegistry) GetPackageMetadata(ctx context.Context, info npm.Artifac return packageMetadata, nil } +func (c *localRegistry) SearchPackage(ctx context.Context, info npm.ArtifactInfo, + limit int, offset int) (*npm2.PackageSearch, error) { + metadataList, err := c.artifactDao.SearchLatestByName(ctx, info.RegistryID, info.Image, limit, offset) + + if err != nil { + log.Err(err).Msgf("Failed to search package for search term: [%s]", info.Image) + return &npm2.PackageSearch{}, err + } + count, err := c.artifactDao.CountLatestByName(ctx, info.RegistryID, info.Image) + + if err != nil { + log.Err(err).Msgf("Failed to search package for search term: [%s]", info.Image) + return &npm2.PackageSearch{}, err + } + psList := make([]*npm2.PackageSearchObject, 0) + registryURL := c.urlProvider.PackageURL(ctx, + info.BaseArtifactInfo().RootIdentifier+"/"+info.BaseArtifactInfo().RegIdentifier, "npm") + + for _, metadata := range *metadataList { + pso, err := mapToPackageSearch(metadata, registryURL) + if err != nil { + log.Err(err).Msgf("Failed to map search package results: [%s]", info.Image) + return &npm2.PackageSearch{}, err + } + psList = append(psList, pso) + } + return &npm2.PackageSearch{ + Objects: psList, + Total: count, + }, nil +} + +func mapToPackageSearch(metadata types.Artifact, registryURL string) (*npm2.PackageSearchObject, error) { + var art *npm2.NpmMetadata + if err := json.Unmarshal(metadata.Metadata, &art); err != nil { + return &npm2.PackageSearchObject{}, err + } + + for _, version := range art.Versions { + var author npm2.User + if version.Author != nil { + data, err := json.Marshal(version.Author) + if err != nil { + log.Err(err).Msgf("Failed to marshal search package results: [%s]", art.Name) + return &npm2.PackageSearchObject{}, err + } + err = json.Unmarshal(data, &author) + if err != nil { + log.Err(err).Msgf("Failed to unmarshal search package results: [%s]", art.Name) + return &npm2.PackageSearchObject{}, err + } + } + + return &npm2.PackageSearchObject{ + Package: &npm2.PackageSearchPackage{ + Name: version.Name, + Version: version.Version, + Description: version.Description, + Date: metadata.CreatedAt, + + Scope: getScope(art.Name), + Author: npm2.User{Username: author.Name}, + Publisher: npm2.User{Username: author.Name}, + Maintainers: getValueOrDefault(version.Maintainers, []npm2.User{}), // npm cli needs this field + Keywords: getValueOrDefault(version.Keywords, []string{}), + Links: &npm2.PackageSearchPackageLinks{ + Registry: registryURL, + Homepage: registryURL, + Repository: registryURL, + }, + }, + }, nil + } + return &npm2.PackageSearchObject{}, fmt.Errorf("no version found in the metadata for image:[%s]", art.Name) +} + +func getValueOrDefault(value interface{}, defaultValue interface{}) interface{} { + if value != nil { + return value + } + return defaultValue +} + +func getScope(name string) string { + if strings.HasPrefix(name, "@") { + if i := strings.Index(name, "/"); i != -1 { + return name[1:i] // Strip @ and return only the scope + } + } + return "unscoped" +} + func CreatePackageMetadataVersion(registryURL string, metadata *npm2.PackageMetadataVersion) *npm2.PackageMetadataVersion { return &npm2.PackageMetadataVersion{ diff --git a/registry/app/pkg/npm/proxy.go b/registry/app/pkg/npm/proxy.go index 994dc8a46..07e272985 100644 --- a/registry/app/pkg/npm/proxy.go +++ b/registry/app/pkg/npm/proxy.go @@ -53,6 +53,10 @@ type proxy struct { localRegistryHelper LocalRegistryHelper } +func (r *proxy) SearchPackage(_ context.Context, _ npm2.ArtifactInfo, _ int, _ int) (*npm.PackageSearch, error) { + return nil, commons.ErrNotSupported +} + func (r *proxy) UploadPackageFileReader(_ context.Context, _ npm2.ArtifactInfo) (*commons.ResponseHeaders, string, error) { return nil, " ", commons.ErrNotSupported diff --git a/registry/app/pkg/npm/registry.go b/registry/app/pkg/npm/registry.go index 0462467d3..52633389b 100644 --- a/registry/app/pkg/npm/registry.go +++ b/registry/app/pkg/npm/registry.go @@ -53,4 +53,6 @@ type Registry interface { DeletePackage(ctx context.Context, info npm.ArtifactInfo) error DeleteVersion(ctx context.Context, info npm.ArtifactInfo) error + + SearchPackage(ctx context.Context, info npm.ArtifactInfo, limit int, offset int) (*npm3.PackageSearch, error) } diff --git a/registry/app/store/database.go b/registry/app/store/database.go index 70d784d26..28ab064c3 100644 --- a/registry/app/store/database.go +++ b/registry/app/store/database.go @@ -499,6 +499,14 @@ type ArtifactRepository interface { GetAllArtifactsByRepo( ctx context.Context, registryID int64, batchSize int, artifactID int64, ) (*[]types.ArtifactMetadata, error) + + SearchLatestByName( + ctx context.Context, regID int64, name string, limit int, offset int, + ) (*[]types.Artifact, error) + + CountLatestByName( + ctx context.Context, regID int64, name string, + ) (int64, error) } type DownloadStatRepository interface { diff --git a/registry/app/store/database/artifact.go b/registry/app/store/database/artifact.go index e37313665..30a1d52eb 100644 --- a/registry/app/store/database/artifact.go +++ b/registry/app/store/database/artifact.go @@ -292,6 +292,80 @@ func (a ArtifactDao) mapToArtifact(_ context.Context, dst *artifactDB) (*types.A }, nil } +func (a ArtifactDao) SearchLatestByName( + ctx context.Context, regID int64, name string, limit int, offset int, +) (*[]types.Artifact, error) { + subQuery := ` + SELECT artifact_image_id, MAX(artifact_created_at) AS max_created_at + FROM artifacts + GROUP BY artifact_image_id` + + q := databaseg.Builder. + Select("a.artifact_metadata,"+ + "a.artifact_created_at"). + From("artifacts a"). + Join("images i ON a.artifact_image_id = i.image_id"). + Join(fmt.Sprintf(`(%s) latest + ON a.artifact_image_id = latest.artifact_image_id + AND a.artifact_created_at = latest.max_created_at +`, subQuery)). + Where("i.image_name LIKE ? AND i.image_registry_id = ?", "%"+name+"%", regID). + Limit(util.SafeIntToUInt64(limit)). + Offset(util.SafeIntToUInt64(offset)) + + sql, args, err := q.ToSql() + if err != nil { + return nil, errors.Wrap(err, "Failed to build SQL for latest artifact metadata with pagination") + } + db := dbtx.GetAccessor(ctx, a.db) + + var metadataList []*artifactDB + if err := db.SelectContext(ctx, &metadataList, sql, args...); err != nil { + return nil, databaseg.ProcessSQLErrorf(ctx, err, "Failed to get artifact metadata") + } + + artifactList, err := a.mapArtifactToArtifactMetadataList(ctx, metadataList) + if err != nil { + return nil, errors.Wrap(err, "Failed to map artifact metadata") + } + + return artifactList, nil +} + +func (a ArtifactDao) CountLatestByName( + ctx context.Context, regID int64, name string, +) (int64, error) { + subQuery := ` + SELECT artifact_image_id, MAX(artifact_created_at) AS max_created_at + FROM artifacts + GROUP BY artifact_image_id` + + // Main count query + q := databaseg.Builder. + Select("COUNT(*)"). + From("artifacts a"). + Join("images i ON a.artifact_image_id = i.image_id"). + Join(fmt.Sprintf(`(%s) latest + ON a.artifact_image_id = latest.artifact_image_id + AND a.artifact_created_at = latest.max_created_at +`, subQuery)). + Where("i.image_name LIKE ? AND i.image_registry_id = ?", "%"+name+"%", regID) + + sql, args, err := q.ToSql() + if err != nil { + return 0, errors.Wrap(err, "Failed to build count SQL") + } + + db := dbtx.GetAccessor(ctx, a.db) + + var count int64 + if err := db.GetContext(ctx, &count, sql, args...); err != nil { + return 0, databaseg.ProcessSQLErrorf(ctx, err, "Failed to count artifact metadata") + } + + return count, nil +} + func (a ArtifactDao) GetAllArtifactsByParentID( ctx context.Context, parentID int64, @@ -610,6 +684,20 @@ func (a ArtifactDao) GetLatestArtifactMetadata( return a.mapToArtifactMetadata(dst) } +func (a ArtifactDao) mapArtifactToArtifactMetadataList(ctx context.Context, + dst []*artifactDB, +) (*[]types.Artifact, error) { + artifacts := make([]types.Artifact, 0, len(dst)) + for _, d := range dst { + artifact, err := a.mapToArtifact(ctx, d) + if err != nil { + return nil, err + } + artifacts = append(artifacts, *artifact) + } + return &artifacts, nil +} + func (a ArtifactDao) mapToArtifactMetadataList( dst []*artifactMetadataDB, ) (*[]types.ArtifactMetadata, error) {