diff --git a/modules/structs/repo.go b/modules/structs/repo.go index 194f595cbd..10f442ba5a 100644 --- a/modules/structs/repo.go +++ b/modules/structs/repo.go @@ -158,6 +158,8 @@ type CreateRepoOption struct { // ObjectFormatName of the underlying git repository, empty string for default (sha1) // enum: ["sha1","sha256"] ObjectFormatName string `json:"object_format_name" binding:"MaxSize(6)"` + // GroupID of the group which will contain this repository. ignored if the repo owner is not an organization. + GroupID int64 `json:"group_id"` } // EditRepoOption options when editing a repository's properties diff --git a/modules/structs/repo_group.go b/modules/structs/repo_group.go index c4d4904e9a..0bc64fd253 100644 --- a/modules/structs/repo_group.go +++ b/modules/structs/repo_group.go @@ -27,7 +27,7 @@ type NewGroupOption struct { Visibility VisibleType `json:"visibility"` } -// MoveGroupOption - options for changing a group's parent and sort order +// MoveGroupOption - options for changing a group or repo's parent and sort order // swagger:model type MoveGroupOption struct { // the new parent group. can be 0 to specify no parent diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 49f66929fc..9c89c31831 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -68,11 +68,15 @@ import ( "net/http" "strings" + actions_model "code.gitea.io/gitea/models/actions" auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/organization" "code.gitea.io/gitea/models/perm" access_model "code.gitea.io/gitea/models/perm/access" repo_model "code.gitea.io/gitea/models/repo" + group_model "code.gitea.io/gitea/models/group" + shared_group_model "code.gitea.io/gitea/models/shared/group" "code.gitea.io/gitea/models/unit" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/log" @@ -485,6 +489,60 @@ func reqOrgOwnership() func(ctx *context.APIContext) { } } +// reqGroupMembership user should be organization owner, +// a member of a team with access to the group, or site admin +func reqGroupMembership(mode perm.AccessMode, needsCreatePerm bool) func(ctx *context.APIContext) { + return func(ctx *context.APIContext) { + if ctx.IsUserSiteAdmin() { + return + } + gid := ctx.PathParamInt64("group_id") + g, err := group_model.GetGroupByID(ctx, gid) + if err != nil { + ctx.APIErrorInternal(err) + return + } + err = g.LoadOwner(ctx) + if err != nil { + ctx.APIErrorInternal(err) + return + } + var canAccess bool + if ctx.IsSigned { + canAccess, err = g.CanAccessAtLevel(ctx, ctx.Doer.ID, mode) + } else { + canAccess, err = g.CanAccessAtLevel(ctx, 0, mode) + } + if err != nil { + ctx.APIErrorInternal(err) + return + } + igm, err := shared_group_model.IsGroupMember(ctx, gid, ctx.Doer) + if err != nil { + ctx.APIErrorInternal(err) + return + } + if !igm && !canAccess { + ctx.APIErrorNotFound() + return + } + if needsCreatePerm { + canCreateIn := false + if ctx.IsSigned { + canCreateIn, err = g.CanCreateIn(ctx, ctx.Doer.ID) + if err != nil { + ctx.APIErrorInternal(err) + return + } + } + if !canCreateIn { + ctx.APIError(http.StatusForbidden, fmt.Sprintf("User[%d] does not have permission to create new items in group[%d]", ctx.Doer.ID, gid)) + return + } + } + } +} + // reqTeamMembership user should be an team member, or a site admin func reqTeamMembership() func(ctx *context.APIContext) { return func(ctx *context.APIContext) { @@ -1154,6 +1212,7 @@ func Routes() *web.Router { m.Combo("").Get(reqAnyRepoReader(), repo.Get). Delete(reqToken(), reqOwner(), repo.Delete). Patch(reqToken(), reqAdmin(), bind(api.EditRepoOption{}), repo.Edit) + m.Post("/groups/move", reqToken(), bind(api.EditGroupOption{}), reqOrgMembership(), reqGroupMembership(perm.AccessModeWrite, false), repo.MoveRepoToGroup) m.Post("/generate", reqToken(), reqRepoReader(unit.TypeCode), bind(api.GenerateRepoOption{}), repo.Generate) m.Group("/transfer", func() { m.Post("", reqOwner(), bind(api.TransferRepoOption{}), repo.Transfer) @@ -1661,6 +1720,10 @@ func Routes() *web.Router { m.Delete("", org.UnblockUser) }) }, reqToken(), reqOrgOwnership()) + m.Group("/groups", func() { + m.Post("/new", reqToken(), reqGroupMembership(perm.AccessModeWrite, true), group.NewGroup) + m.Post("/{group_id}/move", reqToken(), reqGroupMembership(perm.AccessModeWrite, false), group.MoveGroup) + }) }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization), orgAssignment(true), checkTokenPublicOnly()) m.Group("/teams/{teamid}", func() { m.Combo("").Get(reqToken(), org.GetTeam). @@ -1741,7 +1804,15 @@ func Routes() *web.Router { m.Get("/search", repo.TopicSearch) }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryRepository)) }, sudo()) - + m.Group("/groups", func() { + m.Group("/{group_id}", func() { + m.Combo(""). + Get(reqGroupMembership(perm.AccessModeRead, false), group.GetGroup). + Patch(reqToken(), reqGroupMembership(perm.AccessModeWrite, false), bind(api.EditGroupOption{}), group.EditGroup). + Delete(reqToken(), reqGroupMembership(perm.AccessModeAdmin, false), group.DeleteGroup) + m.Post("/new", reqToken(), reqGroupMembership(perm.AccessModeWrite, true), bind(api.NewGroupOption{}), group.NewSubGroup) + }) + }) return m } diff --git a/routers/api/v1/group/group.go b/routers/api/v1/group/group.go new file mode 100644 index 0000000000..6f6638eb8e --- /dev/null +++ b/routers/api/v1/group/group.go @@ -0,0 +1,329 @@ +package group + +import ( + "net/http" + "strings" + + group_model "code.gitea.io/gitea/models/group" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/convert" + group_service "code.gitea.io/gitea/services/group" +) + +func createCommonGroup(ctx *context.APIContext, parentGroupID int64) (*api.Group, error) { + form := web.GetForm(ctx).(*api.NewGroupOption) + group := &group_model.Group{ + Name: form.Name, + Description: form.Description, + OwnerID: ctx.Org.Organization.ID, + LowerName: strings.ToLower(form.Name), + Visibility: form.Visibility, + ParentGroupID: parentGroupID, + } + if err := group_service.NewGroup(ctx, group); err != nil { + return nil, err + } + return convert.ToAPIGroup(ctx, group, ctx.Doer) +} + +// NewGroup create a new root-level group in an organization +func NewGroup(ctx *context.APIContext) { + // swagger:operation POST /orgs/{org}/groups/new repository-group groupNew + // --- + // summary: create a root-level repository group for an organization + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: org + // in: path + // description: name of the organization + // type: string + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/CreateGroupOption" + // responses: + // "201": + // "$ref": "#/responses/Group" + // "404": + // "$ref": "#/responses/notFound" + // "422": + // "$ref": "#/responses/validationError" + ag, err := createCommonGroup(ctx, 0) + if err != nil { + ctx.APIErrorInternal(err) + return + } + ctx.JSON(http.StatusCreated, ag) +} + +// NewSubGroup create a new subgroup inside a group +func NewSubGroup(ctx *context.APIContext) { + // swagger:operation POST /groups/{group_id}/new repository-group groupNewSubGroup + // --- + // summary: create a subgroup inside a group + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: group_id + // in: path + // description: id of the group to create a subgroup in + // type: integer + // format: int64 + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/CreateGroupOption" + // responses: + // "201": + // "$ref": "#/responses/Group" + // "404": + // "$ref": "#/responses/notFound" + // "422": + // "$ref": "#/responses/validationError" + var ( + group *api.Group + err error + ) + gid := ctx.PathParamInt64("group_id") + group, err = createCommonGroup(ctx, gid) + if err != nil { + ctx.APIErrorInternal(err) + return + } + ctx.JSON(http.StatusCreated, group) +} + +// MoveGroup - move a group to a different group in the same organization, or to the root level if +func MoveGroup(ctx *context.APIContext) { + // swagger:operation POST /orgs/{org}/groups/{group_id}/move repository-group groupMove + // --- + // summary: move a group to a different parent group + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: org + // in: path + // description: name of the organization + // type: string + // required: true + // - name: group_id + // in: path + // description: id of the group to move + // type: integer + // format: int64 + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/MoveGroupOption" + // responses: + // "200": + // "$ref": "#/responses/Group" + // "404": + // "$ref": "#/responses/notFound" + // "422": + // "$ref": "#/responses/validationError" + form := web.GetForm(ctx).(*api.MoveGroupOption) + id := ctx.PathParamInt64("group_id") + var err error + npos := -1 + if form.NewPos != nil { + npos = *form.NewPos + } + err = group_service.MoveGroupItem(ctx, group_service.MoveGroupOptions{ + form.NewParent, id, true, npos, + }, 3) + if group_model.IsErrGroupNotExist(err) { + ctx.APIErrorNotFound() + return + } + if err != nil { + ctx.APIErrorInternal(err) + return + } + var ( + ng *group_model.Group + apiGroup *api.Group + ) + ng, err = group_model.GetGroupByID(ctx, id) + if group_model.IsErrGroupNotExist(err) { + ctx.APIErrorNotFound() + return + } + if err != nil { + ctx.APIErrorInternal(err) + return + } + apiGroup, err = convert.ToAPIGroup(ctx, ng, ctx.Doer) + if err != nil { + ctx.APIErrorInternal(err) + } + ctx.JSON(http.StatusOK, apiGroup) +} + +// EditGroup - update a group in an organization +func EditGroup(ctx *context.APIContext) { + // swagger:operation PATCH /orgs/{org}/groups/{group_id} repository-group groupEdit + // --- + // summary: edits a group in an organization. only fields that are set will be changed. + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: org + // in: path + // description: name of the organization + // type: string + // required: true + // - name: group_id + // in: path + // description: id of the group to edit + // type: integer + // format: int64 + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/EditGroupOption" + // responses: + // "200": + // "$ref": "#/responses/Group" + // "404": + // "$ref": "#/responses/notFound" + // "422": + // "$ref": "#/responses/validationError" + var ( + err error + group *group_model.Group + ) + form := web.GetForm(ctx).(*api.EditGroupOption) + gid := ctx.PathParamInt64("group_id") + group, err = group_model.GetGroupByID(ctx, gid) + if group_model.IsErrGroupNotExist(err) { + ctx.APIErrorNotFound() + return + } + if err != nil { + ctx.APIErrorInternal(err) + return + } + if form.Visibility != nil { + group.Visibility = *form.Visibility + } + if form.Description != nil { + group.Description = *form.Description + } + if form.Name != nil { + group.Name = *form.Name + } + err = group_model.UpdateGroup(ctx, group) + if err != nil { + ctx.APIErrorInternal(err) + return + } + var newAPIGroup *api.Group + newAPIGroup, err = convert.ToAPIGroup(ctx, group, ctx.Doer) + if err != nil { + ctx.APIErrorInternal(err) + return + } + ctx.JSON(http.StatusOK, newAPIGroup) +} + +func GetGroup(ctx *context.APIContext) { + // swagger:operation GET /orgs/{org}/groups/{group_id} repository-group groupGet + // --- + // summary: gets a group in an organization + // produces: + // - application/json + // parameters: + // - name: org + // in: path + // description: name of the organization + // type: string + // required: true + // - name: group_id + // in: path + // description: id of the group to retrieve + // type: integer + // format: int64 + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/EditGroupOption" + // responses: + // "200": + // "$ref": "#/responses/Group" + // "404": + // "$ref": "#/responses/notFound" + // "422": + // "$ref": "#/responses/validationError" + var ( + err error + group *group_model.Group + ) + group, err = group_model.GetGroupByID(ctx, ctx.PathParamInt64("group_id")) + if group_model.IsErrGroupNotExist(err) { + ctx.APIErrorNotFound() + return + } + if group.OwnerID != ctx.Org.Organization.ID { + ctx.APIErrorNotFound() + return + } + if err != nil { + ctx.APIErrorInternal(err) + return + } + apiGroup, err := convert.ToAPIGroup(ctx, group, ctx.Doer) + if err != nil { + ctx.APIErrorInternal(err) + return + } + ctx.JSON(http.StatusOK, apiGroup) +} + +func DeleteGroup(ctx *context.APIContext) { + // swagger:operation DELETE /orgs/{org}/groups/{group_id} repositoryGroup groupDelete + // --- + // summary: Delete a repository group + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the group to delete + // type: string + // required: true + // - name: group_id + // in: path + // description: id of the group to delete + // type: string + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + err := group_service.DeleteGroup(ctx, ctx.PathParamInt64("group_id")) + if err != nil { + ctx.APIErrorInternal(err) + return + } + ctx.Status(http.StatusNoContent) +} diff --git a/routers/api/v1/repo/repo.go b/routers/api/v1/repo/repo.go index 4a5091fded..61a139875a 100644 --- a/routers/api/v1/repo/repo.go +++ b/routers/api/v1/repo/repo.go @@ -36,6 +36,7 @@ import ( "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/convert" feed_service "code.gitea.io/gitea/services/feed" + group_service "code.gitea.io/gitea/services/group" "code.gitea.io/gitea/services/issue" repo_service "code.gitea.io/gitea/services/repository" ) @@ -263,6 +264,7 @@ func CreateUserRepo(ctx *context.APIContext, owner *user_model.User, opt api.Cre TrustModel: repo_model.ToTrustModel(opt.TrustModel), IsTemplate: opt.Template, ObjectFormatName: opt.ObjectFormatName, + GroupID: opt.GroupID, }) if err != nil { if repo_model.IsErrRepoAlreadyExist(err) { @@ -1264,3 +1266,51 @@ func ListRepoActivityFeeds(ctx *context.APIContext) { ctx.JSON(http.StatusOK, convert.ToActivities(ctx, feeds, ctx.Doer)) } + +func MoveRepoToGroup(ctx *context.APIContext) { + // swagger:operation POST /repo/{owner}/{repo}/move + // --- + // summary: move a repository to another group + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true // - name: body + // in: body + // schema: + // "$ref": "#/definitions/MoveGroupOption" + // responses: + // "204": + // "$ref": "#/responses/empty" + // "400": + // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" + // "422": + // "$ref": "#/responses/validationError" + form := web.GetForm(ctx).(*api.MoveGroupOption) + npos := -1 + if form.NewPos != nil { + npos = *form.NewPos + } + err := group_service.MoveGroupItem(ctx, group_service.MoveGroupOptions{ + IsGroup: false, NewPos: npos, + ItemID: ctx.Repo.Repository.ID, + NewParent: form.NewParent, + }, ctx.Doer.ID) + if err != nil { + ctx.APIErrorInternal(err) + return + } + ctx.Status(http.StatusNoContent) +}