Add API to support link package to repository and unlink it (#33481)

Fix #21062

---------

Co-authored-by: Zettat123 <zettat123@gmail.com>
(cherry picked from commit 5df9fd3e9c6ae7f848da65dbe9b9d321f29c003a)
This commit is contained in:
Lunny Xiao 2025-02-16 19:18:00 -08:00 committed by Gusted
parent b74a967c92
commit e09ea821bd
No known key found for this signature in database
GPG key ID: FD821B732837125F
6 changed files with 334 additions and 14 deletions

View file

@ -242,6 +242,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{})

View file

@ -1485,13 +1485,19 @@ func Routes() *web.Route {
// 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.Get("/", reqToken(), packages.ListPackages)
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryPackage), context.UserAssignmentAPI(), context.PackageAssignmentAPI(), reqPackageAccess(perm.AccessModeRead), checkTokenPublicOnly())
m.Post("/-/link/{repo_name}", reqPackageAccess(perm.AccessModeWrite), packages.LinkPackage)
m.Post("/-/unlink", reqPackageAccess(perm.AccessModeWrite), packages.UnlinkPackage)
})
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)

View file

@ -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.PathParamRaw("type")), ctx.PathParamRaw("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.PathParamRaw("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.PathParamRaw("type")), ctx.PathParamRaw("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)
}

View file

@ -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)
}

View file

@ -4159,6 +4159,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": [

View file

@ -16,6 +16,7 @@ import (
"code.gitea.io/gitea/models/db"
packages_model "code.gitea.io/gitea/models/packages"
container_model "code.gitea.io/gitea/models/packages/container"
unit_model "code.gitea.io/gitea/models/unit"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/setting"
@ -35,7 +36,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"
@ -99,8 +100,13 @@ func TestPackageAPI(t *testing.T) {
DecodeJSON(t, resp, &ap1)
assert.Nil(t, ap1.Repository)
// create a repository
repo, _, f := tests.CreateDeclarativeRepo(t, user, "", []unit_model.Type{unit_model.TypeCode}, nil, nil)
defer f()
// link to public repository
require.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, repo.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)
@ -109,10 +115,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, repo.ID, ap2.Repository.ID)
// link to private repository
require.NoError(t, packages_model.SetRepositoryLink(db.DefaultContext, p.ID, 2))
// 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)
// 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)
@ -122,7 +133,18 @@ func TestPackageAPI(t *testing.T) {
DecodeJSON(t, resp, &ap3)
assert.Nil(t, ap3.Repository)
require.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)
require.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)
require.NoError(t, packages_model.UnlinkRepositoryFromAllPackages(db.DefaultContext, privateRepoID))
})
})
@ -153,11 +175,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)
})
}