From 5df9fd3e9c6ae7f848da65dbe9b9d321f29c003a Mon Sep 17 00:00:00 2001 From: Lunny Xiao Date: Sun, 16 Feb 2025 19:18:00 -0800 Subject: [PATCH] Add API to support link package to repository and unlink it (#33481) Fix #21062 --------- Co-authored-by: Zettat123 --- models/packages/package.go | 5 + routers/api/v1/api.go | 18 ++-- routers/api/v1/packages/package.go | 122 +++++++++++++++++++++++++ services/packages/package_update.go | 78 ++++++++++++++++ templates/swagger/v1_json.tmpl | 87 ++++++++++++++++++ tests/integration/api_packages_test.go | 42 +++++++-- 6 files changed, 337 insertions(+), 15 deletions(-) create mode 100644 services/packages/package_update.go diff --git a/models/packages/package.go b/models/packages/package.go index 31e1277a6e..8935dbaa1c 100644 --- a/models/packages/package.go +++ b/models/packages/package.go @@ -228,6 +228,11 @@ func SetRepositoryLink(ctx context.Context, packageID, repoID int64) error { return err } +func UnlinkRepository(ctx context.Context, packageID int64) error { + _, err := db.GetEngine(ctx).ID(packageID).Cols("repo_id").Update(&Package{RepoID: 0}) + return err +} + // UnlinkRepositoryFromAllPackages unlinks every package from the repository func UnlinkRepositoryFromAllPackages(ctx context.Context, repoID int64) error { _, err := db.GetEngine(ctx).Where("repo_id = ?", repoID).Cols("repo_id").Update(&Package{}) diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 8c39393246..342e1e66bc 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -1537,13 +1537,19 @@ func Routes() *web.Router { // NOTE: these are Gitea package management API - see packages.CommonRoutes and packages.DockerContainerRoutes for endpoints that implement package manager APIs m.Group("/packages/{username}", func() { - m.Group("/{type}/{name}/{version}", func() { - m.Get("", reqToken(), packages.GetPackage) - m.Delete("", reqToken(), reqPackageAccess(perm.AccessModeWrite), packages.DeletePackage) - m.Get("/files", reqToken(), packages.ListPackageFiles) + m.Group("/{type}/{name}", func() { + m.Group("/{version}", func() { + m.Get("", packages.GetPackage) + m.Delete("", reqPackageAccess(perm.AccessModeWrite), packages.DeletePackage) + m.Get("/files", packages.ListPackageFiles) + }) + + m.Post("/-/link/{repo_name}", reqPackageAccess(perm.AccessModeWrite), packages.LinkPackage) + m.Post("/-/unlink", reqPackageAccess(perm.AccessModeWrite), packages.UnlinkPackage) }) - m.Get("/", reqToken(), packages.ListPackages) - }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryPackage), context.UserAssignmentAPI(), context.PackageAssignmentAPI(), reqPackageAccess(perm.AccessModeRead), checkTokenPublicOnly()) + + m.Get("/", packages.ListPackages) + }, reqToken(), tokenRequiresScopes(auth_model.AccessTokenScopeCategoryPackage), context.UserAssignmentAPI(), context.PackageAssignmentAPI(), reqPackageAccess(perm.AccessModeRead), checkTokenPublicOnly()) // Organizations m.Get("/user/orgs", reqToken(), tokenRequiresScopes(auth_model.AccessTokenScopeCategoryUser, auth_model.AccessTokenScopeCategoryOrganization), org.ListMyOrgs) diff --git a/routers/api/v1/packages/package.go b/routers/api/v1/packages/package.go index b38aa13167..626a34af86 100644 --- a/routers/api/v1/packages/package.go +++ b/routers/api/v1/packages/package.go @@ -4,11 +4,14 @@ package packages import ( + "errors" "net/http" "code.gitea.io/gitea/models/packages" + repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/modules/optional" api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/routers/api/v1/utils" "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/convert" @@ -213,3 +216,122 @@ func ListPackageFiles(ctx *context.APIContext) { ctx.JSON(http.StatusOK, apiPackageFiles) } + +// LinkPackage sets a repository link for a package +func LinkPackage(ctx *context.APIContext) { + // swagger:operation POST /packages/{owner}/{type}/{name}/-/link/{repo_name} package linkPackage + // --- + // summary: Link a package to a repository + // parameters: + // - name: owner + // in: path + // description: owner of the package + // type: string + // required: true + // - name: type + // in: path + // description: type of the package + // type: string + // required: true + // - name: name + // in: path + // description: name of the package + // type: string + // required: true + // - name: repo_name + // in: path + // description: name of the repository to link. + // type: string + // required: true + // responses: + // "201": + // "$ref": "#/responses/empty" + // "404": + // "$ref": "#/responses/notFound" + + pkg, err := packages.GetPackageByName(ctx, ctx.ContextUser.ID, packages.Type(ctx.PathParam("type")), ctx.PathParam("name")) + if err != nil { + if errors.Is(err, util.ErrNotExist) { + ctx.Error(http.StatusNotFound, "GetPackageByName", err) + } else { + ctx.Error(http.StatusInternalServerError, "GetPackageByName", err) + } + return + } + + repo, err := repo_model.GetRepositoryByName(ctx, ctx.ContextUser.ID, ctx.PathParam("repo_name")) + if err != nil { + if errors.Is(err, util.ErrNotExist) { + ctx.Error(http.StatusNotFound, "GetRepositoryByName", err) + } else { + ctx.Error(http.StatusInternalServerError, "GetRepositoryByName", err) + } + return + } + + err = packages_service.LinkToRepository(ctx, pkg, repo, ctx.Doer) + if err != nil { + switch { + case errors.Is(err, util.ErrInvalidArgument): + ctx.Error(http.StatusBadRequest, "LinkToRepository", err) + case errors.Is(err, util.ErrPermissionDenied): + ctx.Error(http.StatusForbidden, "LinkToRepository", err) + default: + ctx.Error(http.StatusInternalServerError, "LinkToRepository", err) + } + return + } + ctx.Status(http.StatusCreated) +} + +// UnlinkPackage sets a repository link for a package +func UnlinkPackage(ctx *context.APIContext) { + // swagger:operation POST /packages/{owner}/{type}/{name}/-/unlink package unlinkPackage + // --- + // summary: Unlink a package from a repository + // parameters: + // - name: owner + // in: path + // description: owner of the package + // type: string + // required: true + // - name: type + // in: path + // description: type of the package + // type: string + // required: true + // - name: name + // in: path + // description: name of the package + // type: string + // required: true + // responses: + // "201": + // "$ref": "#/responses/empty" + // "404": + // "$ref": "#/responses/notFound" + + pkg, err := packages.GetPackageByName(ctx, ctx.ContextUser.ID, packages.Type(ctx.PathParam("type")), ctx.PathParam("name")) + if err != nil { + if errors.Is(err, util.ErrNotExist) { + ctx.Error(http.StatusNotFound, "GetPackageByName", err) + } else { + ctx.Error(http.StatusInternalServerError, "GetPackageByName", err) + } + return + } + + err = packages_service.UnlinkFromRepository(ctx, pkg, ctx.Doer) + if err != nil { + switch { + case errors.Is(err, util.ErrPermissionDenied): + ctx.Error(http.StatusForbidden, "UnlinkFromRepository", err) + case errors.Is(err, util.ErrInvalidArgument): + ctx.Error(http.StatusBadRequest, "UnlinkFromRepository", err) + default: + ctx.Error(http.StatusInternalServerError, "UnlinkFromRepository", err) + } + return + } + ctx.Status(http.StatusNoContent) +} diff --git a/services/packages/package_update.go b/services/packages/package_update.go new file mode 100644 index 0000000000..8d851fac53 --- /dev/null +++ b/services/packages/package_update.go @@ -0,0 +1,78 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package packages + +import ( + "context" + "fmt" + + org_model "code.gitea.io/gitea/models/organization" + packages_model "code.gitea.io/gitea/models/packages" + access_model "code.gitea.io/gitea/models/perm/access" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unit" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/util" +) + +func LinkToRepository(ctx context.Context, pkg *packages_model.Package, repo *repo_model.Repository, doer *user_model.User) error { + if pkg.OwnerID != repo.OwnerID { + return util.ErrPermissionDenied + } + if pkg.RepoID > 0 { + return util.ErrInvalidArgument + } + + perms, err := access_model.GetUserRepoPermission(ctx, repo, doer) + if err != nil { + return fmt.Errorf("error getting permissions for user %d on repository %d: %w", doer.ID, repo.ID, err) + } + if !perms.CanWrite(unit.TypePackages) { + return util.ErrPermissionDenied + } + + if err := packages_model.SetRepositoryLink(ctx, pkg.ID, repo.ID); err != nil { + return fmt.Errorf("error while linking package '%v' to repo '%v' : %w", pkg.Name, repo.FullName(), err) + } + return nil +} + +func UnlinkFromRepository(ctx context.Context, pkg *packages_model.Package, doer *user_model.User) error { + if pkg.RepoID == 0 { + return util.ErrInvalidArgument + } + + repo, err := repo_model.GetRepositoryByID(ctx, pkg.RepoID) + if err != nil { + return fmt.Errorf("error getting repository %d: %w", pkg.RepoID, err) + } + + perms, err := access_model.GetUserRepoPermission(ctx, repo, doer) + if err != nil { + return fmt.Errorf("error getting permissions for user %d on repository %d: %w", doer.ID, repo.ID, err) + } + if !perms.CanWrite(unit.TypePackages) { + return util.ErrPermissionDenied + } + + user, err := user_model.GetUserByID(ctx, pkg.OwnerID) + if err != nil { + return err + } + if !doer.IsAdmin { + if !user.IsOrganization() { + if doer.ID != pkg.OwnerID { + return fmt.Errorf("no permission to unlink package '%v' from its repository, or packages are disabled", pkg.Name) + } + } else { + isOrgAdmin, err := org_model.OrgFromUser(user).IsOrgAdmin(ctx, doer.ID) + if err != nil { + return err + } else if !isOrgAdmin { + return fmt.Errorf("no permission to unlink package '%v' from its repository, or packages are disabled", pkg.Name) + } + } + } + return packages_model.UnlinkRepository(ctx, pkg.ID) +} diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index 091ede2ff9..d173f3161b 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -3339,6 +3339,93 @@ } } }, + "/packages/{owner}/{type}/{name}/-/link/{repo_name}": { + "post": { + "tags": [ + "package" + ], + "summary": "Link a package to a repository", + "operationId": "linkPackage", + "parameters": [ + { + "type": "string", + "description": "owner of the package", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "type of the package", + "name": "type", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the package", + "name": "name", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repository to link.", + "name": "repo_name", + "in": "path", + "required": true + } + ], + "responses": { + "201": { + "$ref": "#/responses/empty" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + } + }, + "/packages/{owner}/{type}/{name}/-/unlink": { + "post": { + "tags": [ + "package" + ], + "summary": "Unlink a package from a repository", + "operationId": "unlinkPackage", + "parameters": [ + { + "type": "string", + "description": "owner of the package", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "type of the package", + "name": "type", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the package", + "name": "name", + "in": "path", + "required": true + } + ], + "responses": { + "201": { + "$ref": "#/responses/empty" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + } + }, "/packages/{owner}/{type}/{name}/{version}": { "get": { "produces": [ diff --git a/tests/integration/api_packages_test.go b/tests/integration/api_packages_test.go index daf32e82f9..978a690302 100644 --- a/tests/integration/api_packages_test.go +++ b/tests/integration/api_packages_test.go @@ -23,6 +23,7 @@ import ( "code.gitea.io/gitea/modules/util" packages_service "code.gitea.io/gitea/services/packages" packages_cleanup_service "code.gitea.io/gitea/services/packages/cleanup" + repo_service "code.gitea.io/gitea/services/repository" "code.gitea.io/gitea/tests" "github.com/stretchr/testify/assert" @@ -34,7 +35,7 @@ func TestPackageAPI(t *testing.T) { user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4}) session := loginUser(t, user.Name) tokenReadPackage := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadPackage) - tokenDeletePackage := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWritePackage) + tokenWritePackage := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWritePackage) packageName := "test-package" packageVersion := "1.0.3" @@ -86,7 +87,7 @@ func TestPackageAPI(t *testing.T) { t.Run("RepositoryLink", func(t *testing.T) { defer tests.PrintCurrentTest(t)() - p, err := packages_model.GetPackageByName(db.DefaultContext, user.ID, packages_model.TypeGeneric, packageName) + _, err := packages_model.GetPackageByName(db.DefaultContext, user.ID, packages_model.TypeGeneric, packageName) assert.NoError(t, err) // no repository link @@ -98,8 +99,15 @@ func TestPackageAPI(t *testing.T) { DecodeJSON(t, resp, &ap1) assert.Nil(t, ap1.Repository) + // create a repository + newRepo, err := repo_service.CreateRepository(db.DefaultContext, user, user, repo_service.CreateRepoOptions{ + Name: "repo4", + }) + assert.NoError(t, err) + // link to public repository - assert.NoError(t, packages_model.SetRepositoryLink(db.DefaultContext, p.ID, 1)) + req = NewRequest(t, "POST", fmt.Sprintf("/api/v1/packages/%s/generic/%s/-/link/%s", user.Name, packageName, newRepo.Name)).AddTokenAuth(tokenWritePackage) + MakeRequest(t, req, http.StatusCreated) req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/packages/%s/generic/%s/%s", user.Name, packageName, packageVersion)). AddTokenAuth(tokenReadPackage) @@ -108,10 +116,15 @@ func TestPackageAPI(t *testing.T) { var ap2 *api.Package DecodeJSON(t, resp, &ap2) assert.NotNil(t, ap2.Repository) - assert.EqualValues(t, 1, ap2.Repository.ID) + assert.EqualValues(t, newRepo.ID, ap2.Repository.ID) + + // link to repository without write access, should fail + req = NewRequest(t, "POST", fmt.Sprintf("/api/v1/packages/%s/generic/%s/-/link/%s", user.Name, packageName, "repo3")).AddTokenAuth(tokenWritePackage) + MakeRequest(t, req, http.StatusNotFound) - // link to private repository - assert.NoError(t, packages_model.SetRepositoryLink(db.DefaultContext, p.ID, 2)) + // remove link + req = NewRequest(t, "POST", fmt.Sprintf("/api/v1/packages/%s/generic/%s/-/unlink", user.Name, packageName)).AddTokenAuth(tokenWritePackage) + MakeRequest(t, req, http.StatusNoContent) req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/packages/%s/generic/%s/%s", user.Name, packageName, packageVersion)). AddTokenAuth(tokenReadPackage) @@ -121,7 +134,18 @@ func TestPackageAPI(t *testing.T) { DecodeJSON(t, resp, &ap3) assert.Nil(t, ap3.Repository) - assert.NoError(t, packages_model.UnlinkRepositoryFromAllPackages(db.DefaultContext, 2)) + // force link to a repository the currently logged-in user doesn't have access to + privateRepoID := int64(6) + assert.NoError(t, packages_model.SetRepositoryLink(db.DefaultContext, p.ID, privateRepoID)) + + req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/packages/%s/generic/%s/%s", user.Name, packageName, packageVersion)).AddTokenAuth(tokenReadPackage) + resp = MakeRequest(t, req, http.StatusOK) + + var ap4 *api.Package + DecodeJSON(t, resp, &ap4) + assert.Nil(t, ap4.Repository) + + assert.NoError(t, packages_model.UnlinkRepositoryFromAllPackages(db.DefaultContext, privateRepoID)) }) }) @@ -152,11 +176,11 @@ func TestPackageAPI(t *testing.T) { defer tests.PrintCurrentTest(t)() req := NewRequest(t, "DELETE", fmt.Sprintf("/api/v1/packages/%s/dummy/%s/%s", user.Name, packageName, packageVersion)). - AddTokenAuth(tokenDeletePackage) + AddTokenAuth(tokenWritePackage) MakeRequest(t, req, http.StatusNotFound) req = NewRequest(t, "DELETE", fmt.Sprintf("/api/v1/packages/%s/generic/%s/%s", user.Name, packageName, packageVersion)). - AddTokenAuth(tokenDeletePackage) + AddTokenAuth(tokenWritePackage) MakeRequest(t, req, http.StatusNoContent) }) }