{{$description}}
{{end}} + {{if .Topics}} + + {{end}} +diff --git a/models/repo/pin.go b/models/repo/pin.go new file mode 100644 index 0000000000000..fb892b1959771 --- /dev/null +++ b/models/repo/pin.go @@ -0,0 +1,62 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "context" + + "code.gitea.io/gitea/models/db" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/timeutil" +) + +type Pin struct { + ID int64 `xorm:"pk autoincr"` + UID int64 `xorm:"UNIQUE(s)"` + RepoID int64 `xorm:"UNIQUE(s)"` + CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"` +} + +// TableName sets the table name for the pin struct +func (s *Pin) TableName() string { + return "repository_pin" +} + +func init() { + db.RegisterModel(new(Pin)) +} + +func IsPinned(ctx context.Context, userID, repoID int64) bool { + exists, _ := db.GetEngine(ctx).Exist(&Pin{UID: userID, RepoID: repoID}) + + return exists +} + +func PinRepo(ctx context.Context, doer *user_model.User, repo *Repository, pin bool) error { + return db.WithTx(ctx, func(ctx context.Context) error { + pinned := IsPinned(ctx, doer.ID, repo.ID) + + if pin { + // Already pinned, nothing to do + if pinned { + return nil + } + + if err := db.Insert(ctx, &Pin{UID: doer.ID, RepoID: repo.ID}); err != nil { + return err + } + } else { + // Not pinned, nothing to do + if !pinned { + return nil + } + + if _, err := db.DeleteByBean(ctx, &Pin{UID: doer.ID, RepoID: repo.ID}); err != nil { + return err + } + } + + return nil + }) +} diff --git a/models/repo/pin_test.go b/models/repo/pin_test.go new file mode 100644 index 0000000000000..d6d9b2e765f85 --- /dev/null +++ b/models/repo/pin_test.go @@ -0,0 +1,48 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo_test + +import ( + "testing" + + "code.gitea.io/gitea/models/db" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + + "github.com/stretchr/testify/assert" +) + +func TestPinRepoFunctionality(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + + unittest.AssertNotExistsBean(t, &repo_model.Pin{UID: user.ID, RepoID: repo.ID}) + assert.NoError(t, repo_model.PinRepo(db.DefaultContext, user, repo, true)) + unittest.AssertExistsAndLoadBean(t, &repo_model.Pin{UID: user.ID, RepoID: repo.ID}) + assert.NoError(t, repo_model.PinRepo(db.DefaultContext, user, repo, true)) + unittest.AssertExistsAndLoadBean(t, &repo_model.Pin{UID: user.ID, RepoID: repo.ID}) + assert.NoError(t, repo_model.PinRepo(db.DefaultContext, user, repo, false)) + unittest.AssertNotExistsBean(t, &repo_model.Star{UID: user.ID, RepoID: repo.ID}) +} + +func TestIsPinned(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + + unittest.AssertNotExistsBean(t, &repo_model.Pin{UID: user.ID, RepoID: repo.ID}) + assert.NoError(t, repo_model.PinRepo(db.DefaultContext, user, repo, true)) + unittest.AssertExistsAndLoadBean(t, &repo_model.Pin{UID: user.ID, RepoID: repo.ID}) + + assert.True(t, repo_model.IsPinned(db.DefaultContext, user.ID, repo.ID)) + + assert.NoError(t, repo_model.PinRepo(db.DefaultContext, user, repo, false)) + unittest.AssertNotExistsBean(t, &repo_model.Star{UID: user.ID, RepoID: repo.ID}) + + assert.False(t, repo_model.IsPinned(db.DefaultContext, user.ID, repo.ID)) +} diff --git a/models/repo/user_repo.go b/models/repo/user_repo.go index c305603e02070..72149cdea875f 100644 --- a/models/repo/user_repo.go +++ b/models/repo/user_repo.go @@ -54,6 +54,41 @@ func GetStarredRepos(ctx context.Context, opts *StarredReposOptions) ([]*Reposit return db.Find[Repository](ctx, opts) } +type PinnedReposOptions struct { + db.ListOptions + PinnerID int64 + RepoOwnerID int64 +} + +func (opts *PinnedReposOptions) ToConds() builder.Cond { + var cond builder.Cond = builder.Eq{ + "repository_pin.uid": opts.PinnerID, + } + if opts.RepoOwnerID != 0 { + cond = cond.And(builder.Eq{ + "repository.owner_id": opts.RepoOwnerID, + }) + } + return cond +} + +func (opts *PinnedReposOptions) ToJoins() []db.JoinFunc { + return []db.JoinFunc{ + func(e db.Engine) error { + e.Join("INNER", "repository_pin", "`repository`.id=`repository_pin`.repo_id") + return nil + }, + } +} + +func GetPinnedRepos(ctx context.Context, opts *PinnedReposOptions) (RepositoryList, error) { + return db.Find[Repository](ctx, opts) +} + +func CountPinnedRepos(ctx context.Context, opts *PinnedReposOptions) (int64, error) { + return db.Count[Repository](ctx, opts) +} + type WatchedReposOptions struct { db.ListOptions WatcherID int64 diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index 539715b3f9c91..ce8a4efefa6b4 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -1102,6 +1102,7 @@ transfer.no_permission_to_reject = You do not have permission to reject this tra desc.private = Private desc.public = Public desc.template = Template +desc.private_template = Private Template desc.internal = Internal desc.archived = Archived desc.sha256 = SHA256 @@ -1187,10 +1188,15 @@ fork_from_self = You cannot fork a repository you own. fork_guest_user = Sign in to fork this repository. watch_guest_user = Sign in to watch this repository. star_guest_user = Sign in to star this repository. +pin_guest_user = Sign in to pin this repository. unwatch = Unwatch watch = Watch unstar = Unstar star = Star +pin = Pin +unpin = Unpin +pin-org = Pin to %s +unpin-org = Unpin from %s fork = Fork action.blocked_user = Cannot perform action because you are blocked by the repository owner. download_archive = Download Repository diff --git a/routers/web/org/home.go b/routers/web/org/home.go index 846b1de18ab13..b23bbeefa04f5 100644 --- a/routers/web/org/home.go +++ b/routers/web/org/home.go @@ -20,6 +20,7 @@ import ( "code.gitea.io/gitea/modules/util" shared_user "code.gitea.io/gitea/routers/web/shared/user" "code.gitea.io/gitea/services/context" + user_service "code.gitea.io/gitea/services/user" ) const ( @@ -101,9 +102,11 @@ func Home(ctx *context.Context) { ctx.Data["IsPrivate"] = private var ( - repos []*repo_model.Repository - count int64 - err error + repos []*repo_model.Repository + count int64 + pinnedRepos []*repo_model.Repository + pinnedCount int64 + err error ) repos, count, err = repo_model.SearchRepository(ctx, &repo_model.SearchRepoOptions{ ListOptions: db.ListOptions{ @@ -139,8 +142,19 @@ func Home(ctx *context.Context) { return } + // Get pinned repos + pinnedRepos, err = user_service.GetUserPinnedRepos(ctx, org.AsUser(), ctx.Doer) + if err != nil { + ctx.ServerError("GetUserPinnedRepos", err) + return + } + + pinnedCount = int64(len(pinnedRepos)) + ctx.Data["Repos"] = repos ctx.Data["Total"] = count + ctx.Data["PinnedRepos"] = pinnedRepos + ctx.Data["PinnedTotal"] = pinnedCount ctx.Data["Members"] = members ctx.Data["Teams"] = ctx.Org.Teams ctx.Data["DisableNewPullMirrors"] = setting.Mirror.DisableNewPull diff --git a/routers/web/repo/repo.go b/routers/web/repo/repo.go index 5a74971827901..d55ec8579b784 100644 --- a/routers/web/repo/repo.go +++ b/routers/web/repo/repo.go @@ -36,6 +36,7 @@ import ( repo_service "code.gitea.io/gitea/services/repository" archiver_service "code.gitea.io/gitea/services/repository/archiver" commitstatus_service "code.gitea.io/gitea/services/repository/commitstatus" + user_service "code.gitea.io/gitea/services/user" ) const ( @@ -321,6 +322,14 @@ func Action(ctx *context.Context) { err = repo_model.StarRepo(ctx, ctx.Doer, ctx.Repo.Repository, true) case "unstar": err = repo_model.StarRepo(ctx, ctx.Doer, ctx.Repo.Repository, false) + case "pin": + err = user_service.PinRepo(ctx, ctx.Doer, ctx.Repo.Repository, true, false) + case "unpin": + err = user_service.PinRepo(ctx, ctx.Doer, ctx.Repo.Repository, false, false) + case "pin-org": + err = user_service.PinRepo(ctx, ctx.Doer, ctx.Repo.Repository, true, true) + case "unpin-org": + err = user_service.PinRepo(ctx, ctx.Doer, ctx.Repo.Repository, false, true) 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 386ef7be5ce87..a491c7fe06e1d 100644 --- a/routers/web/repo/view.go +++ b/routers/web/repo/view.go @@ -29,6 +29,7 @@ import ( "code.gitea.io/gitea/models/db" git_model "code.gitea.io/gitea/models/git" issue_model "code.gitea.io/gitea/models/issues" + "code.gitea.io/gitea/models/organization" access_model "code.gitea.io/gitea/models/perm/access" repo_model "code.gitea.io/gitea/models/repo" unit_model "code.gitea.io/gitea/models/unit" @@ -51,6 +52,7 @@ import ( "code.gitea.io/gitea/services/context" issue_service "code.gitea.io/gitea/services/issue" files_service "code.gitea.io/gitea/services/repository/files" + user_service "code.gitea.io/gitea/services/user" "github.com/nektos/act/pkg/model" @@ -792,6 +794,14 @@ func Home(ctx *context.Context) { return } + if ctx.IsSigned { + err := loadPinData(ctx) + if err != nil { + ctx.ServerError("loadPinData", err) + return + } + } + renderHomeCode(ctx) } @@ -1179,3 +1189,37 @@ func Forks(ctx *context.Context) { ctx.HTML(http.StatusOK, tplForks) } + +func loadPinData(ctx *context.Context) error { + // First, cleanup any pins that are no longer valid + err := user_service.CleanupPins(ctx, ctx.Doer) + if err != nil { + return err + } + + ctx.Data["IsPinningRepo"] = repo_model.IsPinned(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID) + ctx.Data["CanPinRepo"], err = user_service.CanPin(ctx, ctx.Doer, ctx.Repo.Repository) + if err != nil { + return err + } + + if ctx.Repo.Repository.Owner.IsOrganization() { + org := organization.OrgFromUser(ctx.Repo.Repository.Owner) + + isAdmin, err := org.IsOrgAdmin(ctx, ctx.Doer.ID) + if err != nil { + return err + } + + if isAdmin { + ctx.Data["CanUserPinToOrg"] = true + ctx.Data["IsOrgPinningRepo"] = repo_model.IsPinned(ctx, ctx.Repo.Repository.OwnerID, ctx.Repo.Repository.ID) + ctx.Data["CanOrgPinRepo"], err = user_service.CanPin(ctx, ctx.Repo.Repository.Owner, ctx.Repo.Repository) + if err != nil { + return err + } + } + } + + return nil +} diff --git a/routers/web/user/profile.go b/routers/web/user/profile.go index f0749e10216ee..ef8072f8cf065 100644 --- a/routers/web/user/profile.go +++ b/routers/web/user/profile.go @@ -26,6 +26,7 @@ import ( "code.gitea.io/gitea/routers/web/org" shared_user "code.gitea.io/gitea/routers/web/shared/user" "code.gitea.io/gitea/services/context" + user_service "code.gitea.io/gitea/services/user" ) const ( @@ -104,10 +105,12 @@ func prepareUserProfileTabData(ctx *context.Context, showPrivate bool, profileDb pagingNum := setting.UI.User.RepoPagingNum topicOnly := ctx.FormBool("topic") var ( - repos []*repo_model.Repository - count int64 - total int - orderBy db.SearchOrderBy + repos []*repo_model.Repository + pinnedRepos []*repo_model.Repository + count int64 + pinnedCount int64 + total int + orderBy db.SearchOrderBy ) ctx.Data["SortType"] = ctx.FormString("sort") @@ -312,9 +315,19 @@ func prepareUserProfileTabData(ctx *context.Context, showPrivate bool, profileDb } total = int(count) + + pinnedRepos, err = user_service.GetUserPinnedRepos(ctx, ctx.ContextUser, ctx.Doer) + if err != nil { + ctx.ServerError("GetUserPinnedRepos", err) + return + } + + pinnedCount = int64(len(pinnedRepos)) } ctx.Data["Repos"] = repos ctx.Data["Total"] = total + ctx.Data["PinnedRepos"] = pinnedRepos + ctx.Data["PinnedCount"] = pinnedCount err = shared_user.LoadHeaderCount(ctx) if err != nil { diff --git a/services/repository/delete.go b/services/repository/delete.go index cd779b05c3501..2805eed3a53f0 100644 --- a/services/repository/delete.go +++ b/services/repository/delete.go @@ -151,6 +151,7 @@ func DeleteRepositoryDirectly(ctx context.Context, doer *user_model.User, repoID &repo_model.Redirect{RedirectRepoID: repoID}, &repo_model.RepoUnit{RepoID: repoID}, &repo_model.Star{RepoID: repoID}, + &repo_model.Pin{RepoID: repoID}, &admin_model.Task{RepoID: repoID}, &repo_model.Watch{RepoID: repoID}, &webhook.Webhook{RepoID: repoID}, diff --git a/services/user/delete.go b/services/user/delete.go index 39c6ef052dca7..3bc92c3d9aebf 100644 --- a/services/user/delete.go +++ b/services/user/delete.go @@ -95,6 +95,7 @@ func deleteUser(ctx context.Context, u *user_model.User, purge bool) (err error) &user_model.Blocking{BlockerID: u.ID}, &user_model.Blocking{BlockeeID: u.ID}, &actions_model.ActionRunnerToken{OwnerID: u.ID}, + &repo_model.Pin{UID: u.ID}, ); err != nil { return fmt.Errorf("deleteBeans: %w", err) } diff --git a/services/user/pin.go b/services/user/pin.go new file mode 100644 index 0000000000000..6610f3bb8960b --- /dev/null +++ b/services/user/pin.go @@ -0,0 +1,169 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package user + +import ( + "errors" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/organization" + 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/services/context" +) + +const maxPins = 6 + +// Check if a user have a new pinned repo in it's profile, meaning that it +// has permissions to pin said repo and also has enough space on the pinned list. +func CanPin(ctx *context.Context, u *user_model.User, r *repo_model.Repository) (bool, error) { + count, err := repo_model.CountPinnedRepos(*ctx, &repo_model.PinnedReposOptions{ + ListOptions: db.ListOptions{ + ListAll: true, + }, + PinnerID: u.ID, + }) + if err != nil { + ctx.ServerError("CountPinnedRepos", err) + return false, err + } + + if count >= maxPins { + return false, nil + } + + return HasPermsToPin(ctx, u, r), nil +} + +// Checks if the user has permission to have the repo pinned in it's profile. +func HasPermsToPin(ctx *context.Context, u *user_model.User, r *repo_model.Repository) bool { + // If user is an organization, it can only pin its own repos + if u.IsOrganization() { + return r.OwnerID == u.ID + } + + // For normal users, anyone that has read access to the repo can pin it + return canSeePin(ctx, u, r) +} + +// Check if a user can see a pin +// A user can see a pin if he has read access to the repo +func canSeePin(ctx *context.Context, u *user_model.User, r *repo_model.Repository) bool { + perm, err := access_model.GetUserRepoPermission(ctx, r, u) + if err != nil { + ctx.ServerError("GetUserRepoPermission", err) + return false + } + return perm.HasAnyUnitAccess() +} + +// CleanupPins iterates over the repos pinned by a user and removes +// the invalid pins. (Needs to be called everytime before we read/write a pin) +func CleanupPins(ctx *context.Context, u *user_model.User) error { + pinnedRepos, err := repo_model.GetPinnedRepos(*ctx, &repo_model.PinnedReposOptions{ + ListOptions: db.ListOptions{ + ListAll: true, + }, + PinnerID: u.ID, + }) + if err != nil { + return err + } + + for _, repo := range pinnedRepos { + if !HasPermsToPin(ctx, u, repo) { + if err := repo_model.PinRepo(*ctx, u, repo, false); err != nil { + return err + } + } + } + + return nil +} + +// Returns the pinned repos of a user that the viewer can see +func GetUserPinnedRepos(ctx *context.Context, user, viewer *user_model.User) ([]*repo_model.Repository, error) { + // Start by cleaning up the invalid pins + err := CleanupPins(ctx, user) + if err != nil { + return nil, err + } + + // Get all of the user's pinned repos + pinnedRepos, err := repo_model.GetPinnedRepos(*ctx, &repo_model.PinnedReposOptions{ + ListOptions: db.ListOptions{ + ListAll: true, + }, + PinnerID: user.ID, + }) + if err != nil { + return nil, err + } + + var repos []*repo_model.Repository + + // Only include the repos that the viewer can see + for _, repo := range pinnedRepos { + if canSeePin(ctx, viewer, repo) { + repos = append(repos, repo) + } + } + + return repos, nil +} + +func PinRepo(ctx *context.Context, doer *user_model.User, repo *repo_model.Repository, pin, toOrg bool) error { + // Determine the user which profile is the target for the pin + var targetUser *user_model.User + if toOrg { + targetUser = repo.Owner + } else { + targetUser = doer + } + + // Start by cleaning up the invalid pins + err := CleanupPins(ctx, targetUser) + if err != nil { + return err + } + + // If target is org profile, need to check if the doer can pin the repo + // on said org profile + if toOrg { + err = assertUserOrgPerms(ctx, doer, repo) + if err != nil { + return err + } + } + + if pin { + canPin, err := CanPin(ctx, targetUser, repo) + if err != nil { + return err + } + if !canPin { + return errors.New("user cannot pin this repository") + } + } + + return repo_model.PinRepo(*ctx, targetUser, repo, pin) +} + +func assertUserOrgPerms(ctx *context.Context, doer *user_model.User, repo *repo_model.Repository) error { + if !ctx.Repo.Owner.IsOrganization() { + return errors.New("owner is not an organization") + } + + isAdmin, err := organization.OrgFromUser(repo.Owner).IsOrgAdmin(ctx, doer.ID) + if err != nil { + return err + } + + if !isAdmin { + return errors.New("user is not an admin of this organization") + } + + return nil +} diff --git a/templates/org/home.tmpl b/templates/org/home.tmpl index 4851b6997967b..1f83dddda68c7 100644 --- a/templates/org/home.tmpl +++ b/templates/org/home.tmpl @@ -8,6 +8,9 @@ {{if .ProfileReadme}}
{{$description}}
{{end}} + {{if .Topics}} + + {{end}} +