diff --git a/models/repo/repo.go b/models/repo/repo.go index f6097d2d6a428..30e442979bbff 100644 --- a/models/repo/repo.go +++ b/models/repo/repo.go @@ -217,6 +217,20 @@ func (repo *Repository) IsBroken() bool { return repo.Status == RepositoryBroken } +// IsPinned indicates that repository is pinned +func (repo *Repository) IsPinned() bool { + pinned, err := user_model.GetPinnedRepositoryIDs(repo.OwnerID) + if err != nil { + return false + } + for _, r := range pinned { + if r == repo.ID { + return true + } + } + return false +} + // AfterLoad is invoked from XORM after setting the values of all fields of this object. func (repo *Repository) AfterLoad() { // FIXME: use models migration to solve all at once. diff --git a/models/user/pin.go b/models/user/pin.go new file mode 100644 index 0000000000000..778b4787733a0 --- /dev/null +++ b/models/user/pin.go @@ -0,0 +1,105 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package user + +import ( + "fmt" + + "code.gitea.io/gitea/modules/json" +) + +const maxPinnedRepos = 3 + +// GetPinnedRepositoryIDs returns all the repository IDs pinned by the given user or org. +// If they've never pinned a repository, an empty array is returned. +func GetPinnedRepositoryIDs(ownerID int64) ([]int64, error) { + pinnedstring, err := GetUserSetting(ownerID, PinnedRepositories) + if err != nil { + return nil, err + } + + var parsedValues []int64 + if pinnedstring == "" { + return parsedValues, nil + } + + err = json.Unmarshal([]byte(pinnedstring), &parsedValues) + + if err != nil { + return nil, err + } + + return parsedValues, nil +} + +func setPinnedRepositories(ownerID int64, repos []int64) error { + stringed, err := json.Marshal(repos) + if err != nil { + return err + } + + return SetUserSetting(ownerID, PinnedRepositories, string(stringed)) +} + +type TooManyPinnedReposError struct { + count int +} + +func (e *TooManyPinnedReposError) Error() string { + return fmt.Sprintf("can pin at most %d repositories, %d pinned repositories is too much", maxPinnedRepos, e.count) +} + +// PinRepos pin the specified repos for the given user or organization. +// The caller must ensure all repos belong to the owner. +func PinRepos(ownerID int64, repoIDs ...int64) error { + repos, err := GetPinnedRepositoryIDs(ownerID) + if err != nil { + return err + } + newrepos := make([]int64, 0, len(repoIDs)+len(repos)) + + repos = append(repos, repoIDs...) + + for _, toadd := range repos { + alreadypresent := false + for _, present := range newrepos { + if toadd == present { + alreadypresent = true + break + } + } + if !alreadypresent { + newrepos = append(newrepos, toadd) + } + } + if len(newrepos) > maxPinnedRepos { + return &TooManyPinnedReposError{count: len(newrepos)} + } + return setPinnedRepositories(ownerID, newrepos) +} + +// UnpinRepos unpin the given repositories for the given user or organization +func UnpinRepos(ownerID int64, repoIDs ...int64) error { + prevRepos, err := GetPinnedRepositoryIDs(ownerID) + if err != nil { + return err + } + var nextRepos []int64 + + for _, r := range prevRepos { + keep := true + for _, unp := range repoIDs { + if r == unp { + keep = false + break + } + } + if keep { + nextRepos = append(nextRepos, r) + } + } + + return setPinnedRepositories(ownerID, nextRepos) +} diff --git a/models/user/pin_test.go b/models/user/pin_test.go new file mode 100644 index 0000000000000..331f4138faa18 --- /dev/null +++ b/models/user/pin_test.go @@ -0,0 +1,37 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package user + +import ( + "testing" + + "code.gitea.io/gitea/models/unittest" + + "github.com/stretchr/testify/assert" +) + +func TestPinAndUnpinRepos(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + // User:2 pins repositories 1 and 2 + userID, repoID1, repoID2 := 2, 1, 2 + { + assert.NoError(t, PinRepos(userID, repoID1, repoID2)) + pinned, err := GetPinnedRepositoryIDs(userID) + + assert.NoError(t, err) + expected := []int64{repoID1, repoID2} + assert.Equal(t, pinned, expected) + } + // User 2 unpins repository 2, leaving just 1 + { + assert.NoError(t, UnpinRepos(userID, repoID2)) + + pinned, err := GetPinnedRepositoryIDs(userID) + + assert.NoError(t, err) + expected := []int64{repoID1} + assert.Equal(t, pinned, expected) + } +} diff --git a/models/user/setting_keys.go b/models/user/setting_keys.go index 109b5dd916365..f759082909d6a 100644 --- a/models/user/setting_keys.go +++ b/models/user/setting_keys.go @@ -9,4 +9,5 @@ const ( SettingsKeyHiddenCommentTypes = "issue.hidden_comment_types" // SettingsKeyDiffWhitespaceBehavior is the setting key for whitespace behavior of diff SettingsKeyDiffWhitespaceBehavior = "diff.whitespace_behaviour" + PinnedRepositories = "pinned_repos" ) diff --git a/public/img/svg/octicon-custom-pin-off.svg b/public/img/svg/octicon-custom-pin-off.svg new file mode 100644 index 0000000000000..a6e96e7bd8510 --- /dev/null +++ b/public/img/svg/octicon-custom-pin-off.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/routers/web/org/home.go b/routers/web/org/home.go index d565a0c242404..3807b6168db06 100644 --- a/routers/web/org/home.go +++ b/routers/web/org/home.go @@ -10,9 +10,12 @@ import ( "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/organization" + access "code.gitea.io/gitea/models/perm/access" repo_model "code.gitea.io/gitea/models/repo" + user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/markup" "code.gitea.io/gitea/modules/markup/markdown" "code.gitea.io/gitea/modules/setting" @@ -149,8 +152,41 @@ func Home(ctx *context.Context) { return } + pinnedRepoIDs, err := user_model.GetPinnedRepositoryIDs(org.ID) + if err != nil { + ctx.ServerError("GetPinnedRepositoryIDs", err) + return + } + pinnedRepos := make([]*repo_model.Repository, 0, len(pinnedRepoIDs)) + for _, id := range pinnedRepoIDs { + repo, err := repo_model.GetRepositoryByID(id) + if err != nil { + ctx.ServerError("GetRepositoryByID", err) + return + } + if err = repo.LoadAttributes(ctx); err != nil { + ctx.ServerError("LoadAttributes", err) + return + } + if repo.OwnerID != org.ID { + log.Warn("Ignoring pinned repo %v because it's not owned by %v", repo.FullName(), org.Name) + } else { + perm, err := access.GetUserRepoPermission(ctx, repo, ctx.Doer) + if err != nil { + ctx.ServerError("GetUserRepoPermission", err) + return + } + if !perm.HasAccess() { + log.Info("Ignoring pinned repo %v because user %v has no access to it.", repo.FullName(), ctx.Doer) + } else { + pinnedRepos = append(pinnedRepos, repo) + } + } + } + ctx.Data["Owner"] = org ctx.Data["Repos"] = repos + ctx.Data["PinnedRepos"] = pinnedRepos ctx.Data["Total"] = count ctx.Data["MembersTotal"] = membersCount ctx.Data["Members"] = members diff --git a/routers/web/repo/pin_test.go b/routers/web/repo/pin_test.go new file mode 100644 index 0000000000000..57c774f0e82ed --- /dev/null +++ b/routers/web/repo/pin_test.go @@ -0,0 +1,121 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package repo + +import ( + "testing" + + "code.gitea.io/gitea/models/unittest" + "code.gitea.io/gitea/modules/test" + + "github.com/stretchr/testify/assert" +) + +const ( + pin = true + unpin = false +) + +func TestUserPinUnpin(t *testing.T) { + unittest.PrepareTestEnv(t) + // These test cases run sequentially since they modify state + testcases := []struct { + uid int64 + rid int64 + action bool + endstate bool + failmesssage string + }{ + { + uid: 2, + rid: 2, + action: pin, + endstate: pin, + failmesssage: "user cannot pin repos they own", + }, + { + uid: 2, + rid: 2, + action: unpin, + endstate: unpin, + failmesssage: "user cannot unpin repos they own", + }, + + { + uid: 2, + rid: 5, + action: pin, + endstate: pin, + failmesssage: "user cannot pin repos they have admin access to", + }, + { + uid: 2, + rid: 5, + action: unpin, + endstate: unpin, + failmesssage: "user cannot unpin repos they have admin access to", + }, + + { + uid: 2, + rid: 4, + action: pin, + endstate: unpin, + failmesssage: "user can pin repos they don't have access to", + }, + + { + uid: 5, + rid: 4, + action: pin, + endstate: pin, + failmesssage: "user cannot pin repos they own (this should never fail)", + }, + { + uid: 2, + rid: 4, + action: unpin, + endstate: pin, + failmesssage: "user can unpin repos they don't have access to", + }, + { + uid: 1, + rid: 4, + action: unpin, + endstate: unpin, + failmesssage: "admin can't unpin repos", + }, + { + uid: 1, + rid: 4, + action: pin, + endstate: pin, + failmesssage: "admin can't pin repos", + }, + } + + for _, c := range testcases { + ctx := test.MockContext(t, "") + test.LoadUser(t, ctx, c.uid) + test.LoadRepo(t, ctx, c.rid) + + switch c.action { + case pin: + ctx.SetParams(":action", "pin") + case unpin: + ctx.SetParams(":action", "unpin") + } + + Action(ctx) + ispinned := getRepository(ctx, c.rid).IsPinned() + + assert.Equal(t, ispinned, c.endstate, c.failmesssage) + + if c.endstate != ispinned { + // We have to stop at first failure, state won't be coherent afterwards. + return + } + } +} diff --git a/routers/web/repo/repo.go b/routers/web/repo/repo.go index c2c79e4a0df1c..dccc74976f4e2 100644 --- a/routers/web/repo/repo.go +++ b/routers/web/repo/repo.go @@ -32,6 +32,7 @@ import ( "code.gitea.io/gitea/services/forms" repo_service "code.gitea.io/gitea/services/repository" archiver_service "code.gitea.io/gitea/services/repository/archiver" + user_service "code.gitea.io/gitea/services/user" ) const ( @@ -292,6 +293,22 @@ func Action(ctx *context.Context) { err = repo_model.StarRepo(ctx.Doer.ID, ctx.Repo.Repository.ID, true) case "unstar": err = repo_model.StarRepo(ctx.Doer.ID, ctx.Repo.Repository.ID, false) + case "pin": + if user_service.CanPin(ctx, ctx.Doer, ctx.Repo.Repository) { + err = user_model.PinRepos(ctx.Repo.Owner.ID, ctx.Repo.Repository.ID) + if _, ok := err.(*user_model.TooManyPinnedReposError); ok { + ctx.Error(http.StatusBadRequest, err.Error()) + return + } + } else { + err = errors.New("user does not have permission to pin") + } + case "unpin": + if user_service.CanPin(ctx, ctx.Doer, ctx.Repo.Repository) { + err = user_model.UnpinRepos(ctx.Repo.Owner.ID, ctx.Repo.Repository.ID) + } else { + err = errors.New("user does not have permission to unpin") + } case "accept_transfer": err = acceptOrRejectRepoTransfer(ctx, true) case "reject_transfer": diff --git a/routers/web/repo/view.go b/routers/web/repo/view.go index 01bd2d89234f5..d195d7295288a 100644 --- a/routers/web/repo/view.go +++ b/routers/web/repo/view.go @@ -41,6 +41,7 @@ import ( "code.gitea.io/gitea/modules/typesniffer" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/routers/web/feed" + user_service "code.gitea.io/gitea/services/user" ) const ( @@ -733,6 +734,7 @@ func Home(ctx *context.Context) { } ctx.Data["FeedURL"] = ctx.Repo.Repository.HTMLURL() + ctx.Data["CanPinRepos"] = ctx.IsSigned && user_service.CanPin(ctx, ctx.Doer, ctx.Repo.Repository) checkHomeCodeViewable(ctx) if ctx.Written() { diff --git a/routers/web/user/profile.go b/routers/web/user/profile.go index 44501fc24549c..3d51f671ec43a 100644 --- a/routers/web/user/profile.go +++ b/routers/web/user/profile.go @@ -13,10 +13,12 @@ import ( "code.gitea.io/gitea/models" "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/organization" + access "code.gitea.io/gitea/models/perm/access" project_model "code.gitea.io/gitea/models/project" repo_model "code.gitea.io/gitea/models/repo" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/markup" "code.gitea.io/gitea/modules/markup/markdown" "code.gitea.io/gitea/modules/setting" @@ -271,9 +273,42 @@ func Profile(ctx *context.Context) { total = int(count) } + + pinnedRepoIDs, err := user_model.GetPinnedRepositoryIDs(ctx.ContextUser.ID) + if err != nil { + ctx.ServerError("GetPinnedRepositoryIDs", err) + return + } + pinnedRepos := make([]*repo_model.Repository, 0, len(pinnedRepoIDs)) + for _, id := range pinnedRepoIDs { + repo, err := repo_model.GetRepositoryByID(id) + if err != nil { + ctx.ServerError("GetRepositoryByID", err) + return + } + if err = repo.LoadAttributes(ctx); err != nil { + ctx.ServerError("LoadAttributes", err) + return + } + if repo.OwnerID != ctx.ContextUser.ID { + log.Warn("Ignoring pinned repo %v because it's not owned by %v", repo.FullName(), ctx.ContextUser.Name) + } else { + perm, err := access.GetUserRepoPermission(ctx, repo, ctx.Doer) + if err != nil { + ctx.ServerError("GetUserRepoPermission", err) + return + } + if !perm.HasAccess() { + log.Info("Ignoring pinned repo %v because user %v has no access to it.", repo.FullName(), ctx.Doer) + } else { + pinnedRepos = append(pinnedRepos, repo) + } + } + } + ctx.Data["Repos"] = repos ctx.Data["Total"] = total - + ctx.Data["PinnedRepos"] = pinnedRepos pager := context.NewPagination(total, setting.UI.User.RepoPagingNum, page, 5) pager.SetDefaultParams(ctx) if tab != "followers" && tab != "following" && tab != "activity" && tab != "projects" { diff --git a/services/user/user.go b/services/user/user.go index 4db4d7ca17f12..963c802825c69 100644 --- a/services/user/user.go +++ b/services/user/user.go @@ -18,6 +18,8 @@ import ( "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/organization" packages_model "code.gitea.io/gitea/models/packages" + "code.gitea.io/gitea/models/perm" + access_model "code.gitea.io/gitea/models/perm/access" repo_model "code.gitea.io/gitea/models/repo" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/avatar" @@ -181,3 +183,25 @@ func DeleteAvatar(u *user_model.User) error { } return nil } + +// Verify that a user has permission to pin/unpin a repository +func CanPin(ctx context.Context, u *user_model.User, r *repo_model.Repository) bool { + if u.IsAdmin { + return true + } + if r.Owner.IsOrganization() { + org := organization.OrgFromUser(r.Owner) + teams, err := org.GetUserTeams(u.ID) + if err != nil { + return false + } + for _, team := range teams { + if team.AccessMode >= perm.AccessModeAdmin { + return true + } + } + return false + } + perm, err := access_model.GetUserRepoPermission(ctx, r, u) + return err == nil && perm.IsAdmin() +} diff --git a/templates/org/home.tmpl b/templates/org/home.tmpl index a49029d4d5082..1cf7b3f46434c 100644 --- a/templates/org/home.tmpl +++ b/templates/org/home.tmpl @@ -18,7 +18,14 @@ - + {{if .PinnedRepos}} +