aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorLunny Xiao <xiaolunwen@gmail.com>2025-02-16 19:18:00 -0800
committerGitHub <noreply@github.com>2025-02-16 19:18:00 -0800
commit5df9fd3e9c6ae7f848da65dbe9b9d321f29c003a (patch)
tree11179814c0b4554566d259d2d99b24d65e8945ea
parent50a5d6bf5d57c0990896c8cc876fc96647608077 (diff)
downloadgitea-5df9fd3e9c6ae7f848da65dbe9b9d321f29c003a.tar.gz
gitea-5df9fd3e9c6ae7f848da65dbe9b9d321f29c003a.zip
Add API to support link package to repository and unlink it (#33481)
Fix #21062 --------- Co-authored-by: Zettat123 <zettat123@gmail.com>
-rw-r--r--models/packages/package.go5
-rw-r--r--routers/api/v1/api.go18
-rw-r--r--routers/api/v1/packages/package.go122
-rw-r--r--services/packages/package_update.go78
-rw-r--r--templates/swagger/v1_json.tmpl87
-rw-r--r--tests/integration/api_packages_test.go42
6 files changed, 337 insertions, 15 deletions
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)
})
}