feat(pin): implemented base code for pins on the backend

- Created the pin model, and basic CRUD operations.
- Implemented checks for lost of visibility of owner, to automatically
  delete the repository.
- Implemented check for the lack of visibility of a viewer, when
  requesting the repos of another user, excluding those repositories
  from the list that is returned.
- Added the deletion of all the pins of a repository when it is deleted.
- Implemented the ability for a user to pin on the profile of an org
  user, checking if the user is admin of the org, and the org is owner
  of the repo.

Co-authored-by: Daniel Carvalho <daniel.m.carvalho@tecnico.ulisboa.pt>
This commit is contained in:
Carlos Felgueiras 2024-05-10 15:58:31 +00:00
parent 080486e47d
commit 5f890b55ca
4 changed files with 259 additions and 0 deletions

65
models/repo/pin.go Normal file
View File

@ -0,0 +1,65 @@
// 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 "repo_pin"
}
func init() {
db.RegisterModel(new(Pin))
}
func IsPinned(ctx context.Context, userID, repoID int64) bool {
exists, _ := db.GetEngine(ctx).Get(&Pin{UID: userID, RepoID: repoID})
return exists
}
func PinRepo(ctx context.Context, doer *user_model.User, repo *Repository, pin bool) error {
ctx, commiter, err := db.TxContext(ctx)
if err != nil {
return err
}
defer commiter.Close()
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 commiter.Commit()
}

View File

@ -54,6 +54,37 @@ 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{
"repo_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", "repo_pin", "`repository`.id=`repo_pin`.repo_id")
return nil
},
}
}
func GetPinnedRepos(ctx context.Context, opts *PinnedReposOptions) (RepositoryList, error) {
return db.Find[Repository](ctx, opts)
}
type WatchedReposOptions struct {
db.ListOptions
WatcherID int64

View File

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

162
services/user/pin.go Normal file
View File

@ -0,0 +1,162 @@
// 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"
)
// 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 {
repos, err := repo_model.GetPinnedRepos(*ctx, &repo_model.PinnedReposOptions{
ListOptions: db.ListOptions{
ListAll: true,
},
PinnerID: u.ID,
})
if err != nil {
ctx.ServerError("GetPinnedRepos", err)
return false
}
if len(repos) >= 6 {
return false
}
return HasPermsToPin(ctx, u, r)
}
// 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 {
if !CanPin(ctx, targetUser, repo) {
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
}