diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 8d7669762b722..81da8a50cd120 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -1407,11 +1407,14 @@ 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.Post("/link", reqPackageAccess(perm.AccessModeWrite), packages.LinkPackage) + }, reqToken()) m.Get("/", reqToken(), packages.ListPackages) }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryPackage), context_service.UserAssignmentAPI(), context.PackageAssignmentAPI(), reqPackageAccess(perm.AccessModeRead)) diff --git a/routers/api/v1/packages/package.go b/routers/api/v1/packages/package.go index a79ba315be2fc..3952378691e3a 100644 --- a/routers/api/v1/packages/package.go +++ b/routers/api/v1/packages/package.go @@ -4,9 +4,14 @@ package packages import ( + "errors" + "fmt" "net/http" + "strconv" + "strings" "code.gitea.io/gitea/models/packages" + repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/modules/context" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/util" @@ -213,3 +218,96 @@ 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 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_id + // in: query + // description: ID of the repository to link. Pass `0` to unlink + // type: integer + // required: false + // - name: repo_name + // in: query + // description: name of the repository to link, format `{owner}/{name}` + // type: string + // required: false + // responses: + // "201": + // "$ref": "#/responses/empty" + // "404": + // "$ref": "#/responses/notFound" + + pkg, err := packages.GetPackageByName(ctx, ctx.ContextUser.ID, packages.Type(ctx.Params("type")), ctx.Params("name")) + if err != nil { + if errors.Is(err, util.ErrNotExist) { + ctx.Error(http.StatusNotFound, "GetPackageByName", err) + } else { + ctx.Error(http.StatusInternalServerError, "GetPackageByName", err) + } + return + } + + var repoID int64 + + formRepoID := ctx.FormString("repo_id") + repoName := ctx.FormString("repo_name") + + if len(formRepoID) == 0 && len(repoName) == 0 { + ctx.Error(http.StatusBadRequest, "Missing parameter", fmt.Errorf("Either `repo_id` or `repo_name` must be given")) + return + } + + if len(formRepoID) > 0 { + repoID, err = strconv.ParseInt(ctx.FormString("repo_id"), 10, 64) + if err != nil { + ctx.Error(http.StatusBadRequest, "Invalid parameter", fmt.Errorf("`repo_id` must be a valid integer, if given")) + return + } + } else { + nameParts := strings.Split(repoName, "/") + if len(nameParts) != 2 { + ctx.Error(http.StatusBadRequest, "Invalid parameter", fmt.Errorf("`repo_name` must be of format `{owner}/{name}`, if given")) + return + } + + repo, err := repo_model.GetRepositoryByOwnerAndName(ctx, nameParts[0], nameParts[1]) + if err != nil { + if errors.Is(err, util.ErrNotExist) { + ctx.Error(http.StatusNotFound, "GetRepositoryByOwnerAndName", err) + } else { + ctx.Error(http.StatusInternalServerError, "GetRepositoryByOwnerAndName", err) + } + + return + } + + repoID = repo.ID + } + + err = packages_service.LinkPackageToRepository(ctx, ctx.Doer, pkg, repoID) + if err != nil { + ctx.Error(http.StatusInternalServerError, "LinkPackageToRepository", err) + return + } + + ctx.Status(http.StatusNoContent) +} diff --git a/services/packages/packages.go b/services/packages/packages.go index 56d5cc04de2df..9cd662cd7c7b5 100644 --- a/services/packages/packages.go +++ b/services/packages/packages.go @@ -14,7 +14,9 @@ import ( "code.gitea.io/gitea/models/db" 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/json" "code.gitea.io/gitea/modules/log" @@ -657,3 +659,29 @@ func RemoveAllPackages(ctx context.Context, userID int64) (int, error) { } return count, nil } + +func LinkPackageToRepository(ctx context.Context, doer *user_model.User, p *packages_model.Package, repoID int64) error { + if repoID != 0 { + repo, err := repo_model.GetRepositoryByID(ctx, repoID) + if err != nil { + return fmt.Errorf("Error getting repository %d: %w", 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) + } + + canWrite := perms.CanWrite(unit.TypePackages) + + if !canWrite { + return fmt.Errorf("No permission to link this package and repository, or packages are disabled") + } + } + + if err := packages_model.SetRepositoryLink(ctx, p.ID, repoID); err != nil { + return fmt.Errorf("Error updating package: %w", err) + } + + return nil +} diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index 094a0e9aec9f9..6c39721f4196b 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -2813,6 +2813,58 @@ } } }, + "/packages/{owner}/{type}/{name}/link": { + "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": "integer", + "description": "ID of the repository to link. Pass `0` to unlink", + "name": "repo_id", + "in": "query" + }, + { + "type": "string", + "description": "name of the repository to link, format `{owner}/{name}`", + "name": "repo_name", + "in": "query" + } + ], + "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 8c981566b6028..94d84ddc748bb 100644 --- a/tests/integration/api_packages_test.go +++ b/tests/integration/api_packages_test.go @@ -15,6 +15,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" + repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unittest" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/setting" @@ -31,9 +32,17 @@ import ( func TestPackageAPI(t *testing.T) { defer tests.PrepareTestEnv(t)() - user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4}) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) session := loginUser(t, user.Name) tokenReadPackage := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadPackage) + tokenWritePackage := getTokenForLoggedInUser( + t, + session, + auth_model.AccessTokenScopeWritePackage, + auth_model.AccessTokenScopeReadPackage, + auth_model.AccessTokenScopeWriteRepository, + ) tokenDeletePackage := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWritePackage) packageName := "test-package" @@ -99,7 +108,8 @@ func TestPackageAPI(t *testing.T) { assert.Nil(t, ap1.Repository) // link to public repository - assert.NoError(t, packages_model.SetRepositoryLink(db.DefaultContext, p.ID, 1)) + req = NewRequestf(t, "POST", fmt.Sprintf("/api/v1/packages/%s/generic/%s/link?repo_name=%s/%s", user.Name, packageName, repo.OwnerName, repo.Name)).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) @@ -108,10 +118,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 - assert.NoError(t, packages_model.SetRepositoryLink(db.DefaultContext, p.ID, 2)) + // link to repository without write access, should fail + req = NewRequestf(t, "POST", fmt.Sprintf("/api/v1/packages/%s/generic/%s/link?repo_id=%d", user.Name, packageName, 3)).AddTokenAuth(tokenWritePackage) + MakeRequest(t, req, http.StatusInternalServerError) + + // remove link + req = NewRequestf(t, "POST", fmt.Sprintf("/api/v1/packages/%s/generic/%s/link?repo_id=%d", user.Name, packageName, 0)).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 +136,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)) }) })