diff --git a/models/action.go b/models/action.go index 5177616497514..f08c59e1a7f3b 100644 --- a/models/action.go +++ b/models/action.go @@ -67,20 +67,21 @@ const ( // repository. It implemented interface base.Actioner so that can be // used in template render. type Action struct { - ID int64 `xorm:"pk autoincr"` - UserID int64 `xorm:"INDEX"` // Receiver user id. - OpType ActionType - ActUserID int64 `xorm:"INDEX"` // Action user id. - ActUser *user_model.User `xorm:"-"` - RepoID int64 `xorm:"INDEX"` - Repo *repo_model.Repository `xorm:"-"` - CommentID int64 `xorm:"INDEX"` - Comment *Comment `xorm:"-"` - IsDeleted bool `xorm:"INDEX NOT NULL DEFAULT false"` - RefName string - IsPrivate bool `xorm:"INDEX NOT NULL DEFAULT false"` - Content string `xorm:"TEXT"` - CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"` + ID int64 `xorm:"pk autoincr"` + UserID int64 `xorm:"INDEX"` // Receiver user id. + OpType ActionType + ActUserID int64 `xorm:"INDEX"` // Action user id. + ActUser *user_model.User `xorm:"-"` + RepoID int64 `xorm:"INDEX"` + Repo *repo_model.Repository `xorm:"-"` + CommentID int64 `xorm:"INDEX"` + Comment *Comment `xorm:"-"` + IsDeleted bool `xorm:"INDEX NOT NULL DEFAULT false"` + RefName string + IsPrivate bool `xorm:"INDEX NOT NULL DEFAULT false"` + Content string `xorm:"TEXT"` + CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"` + IsIssuePrivate bool `xorm:"INDEX NOT NULL DEFAULT false"` } func init() { @@ -455,6 +456,7 @@ func notifyWatchers(ctx context.Context, actions ...*Action) error { var permCode []bool var permIssue []bool var permPR []bool + var permPrivateIssue []bool e := db.GetEngine(ctx) @@ -500,6 +502,7 @@ func notifyWatchers(ctx context.Context, actions ...*Action) error { permCode = make([]bool, len(watchers)) permIssue = make([]bool, len(watchers)) permPR = make([]bool, len(watchers)) + permPrivateIssue = make([]bool, len(watchers)) for i, watcher := range watchers { user, err := user_model.GetUserByIDEngine(e, watcher.UserID) if err != nil { @@ -518,6 +521,7 @@ func notifyWatchers(ctx context.Context, actions ...*Action) error { permCode[i] = perm.CanRead(unit.TypeCode) permIssue[i] = perm.CanRead(unit.TypeIssues) permPR[i] = perm.CanRead(unit.TypePullRequests) + permPrivateIssue[i] = perm.CanReadPrivateIssues() } } @@ -529,6 +533,10 @@ func notifyWatchers(ctx context.Context, actions ...*Action) error { act.UserID = watcher.UserID act.Repo.Units = nil + if act.IsIssuePrivate && !permPrivateIssue[i] { + continue + } + switch act.OpType { case ActionCommitRepo, ActionPushTag, ActionDeleteTag, ActionPublishRelease, ActionDeleteBranch: if !permCode[i] { diff --git a/models/error.go b/models/error.go index c29c818589fb4..1a12bca507260 100644 --- a/models/error.go +++ b/models/error.go @@ -761,6 +761,25 @@ func (err ErrPullWasClosed) Error() string { return fmt.Sprintf("Pull request [%d] %d was already closed", err.ID, err.Index) } +// ErrCannotSeePrivateIssue is used when a user tries to view a issue +// which they don't have permission for. +type ErrCannotSeePrivateIssue struct { + UserID int64 + ID int64 + RepoID int64 + Index int64 +} + +// IsErrCannotSeePrivateIssue checks if an error is a ErrCannotSeePrivateIssue. +func IsErrCannotSeePrivateIssue(err error) bool { + _, ok := err.(ErrCannotSeePrivateIssue) + return ok +} + +func (err ErrCannotSeePrivateIssue) Error() string { + return fmt.Sprintf("issue is private but user has no permission to view it [id: %d, repo_id: %d, index: %d, user_id: %d]", err.ID, err.RepoID, err.Index, err.UserID) +} + // __________ .__ .__ __________ __ // \______ \__ __| | | |\______ \ ____ ________ __ ____ _______/ |_ // | ___/ | \ | | | | _// __ \/ ____/ | \_/ __ \ / ___/\ __\ diff --git a/models/fixtures/attachment.yml b/models/fixtures/attachment.yml index 8612f6ece7088..7c756112d90e5 100644 --- a/models/fixtures/attachment.yml +++ b/models/fixtures/attachment.yml @@ -91,6 +91,7 @@ id: 10 uuid: a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a20 repo_id: 0 # TestGetAttachment/NotLinked + issue_id: 0 uploader_id: 8 name: attach1 download_count: 0 @@ -100,6 +101,7 @@ id: 11 uuid: a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a21 repo_id: 40 + issue_id: 16 release_id: 2 name: attach1 download_count: 0 diff --git a/models/fixtures/issue.yml b/models/fixtures/issue.yml index d5738d5db43f9..9417f7786c796 100644 --- a/models/fixtures/issue.yml +++ b/models/fixtures/issue.yml @@ -184,3 +184,15 @@ is_pull: false created_unix: 1602935696 updated_unix: 1602935696 + +- + id: 16 + repo_id: 40 + index: 1 + poster_id: 2 + name: issue in active repo + content: we are a no-op issue for some attachment, pretty special + is_closed: false + is_pull: false + created_unix: 1602935696 + updated_unix: 1602935696 diff --git a/models/issue.go b/models/issue.go index 2a41cbc28efd9..2ac5bbc4f5d3b 100644 --- a/models/issue.go +++ b/models/issue.go @@ -80,6 +80,8 @@ type Issue struct { // IsLocked limits commenting abilities to users on an issue // with write access IsLocked bool `xorm:"NOT NULL DEFAULT false"` + // IsPrivate limits who can see the issue. + IsPrivate bool `xorm:"NOT NULL DEFAULT false"` // For view issue page. ShowRole RoleDescriptor `xorm:"-"` @@ -362,6 +364,25 @@ func (issue *Issue) LoadAttributes() error { return issue.loadAttributes(db.DefaultContext) } +// LoadCommentsAsUser loads the comment of the issue, as the user. +func (issue *Issue) LoadCommentsAsUser(user *user_model.User, canSeePrivateIssues bool) error { + return issue.loadCommentsAsUser(db.GetEngine(db.DefaultContext), user, canSeePrivateIssues) +} + +func (issue *Issue) loadCommentsAsUser(e db.Engine, user *user_model.User, canSeePrivateIssues bool) (err error) { + var userID int64 + if user != nil { + userID = user.ID + } + issue.Comments, err = findComments(e, &FindCommentsOptions{ + IssueID: issue.ID, + Type: CommentTypeUnknown, + UserID: userID, + CanSeePrivate: canSeePrivateIssues, + }) + return err +} + // LoadMilestone load milestone of this issue. func (issue *Issue) LoadMilestone() error { return issue.loadMilestone(db.DefaultContext) @@ -448,6 +469,14 @@ func (issue *Issue) IsPoster(uid int64) bool { return issue.OriginalAuthorID == 0 && issue.PosterID == uid } +// CanSeeIssue returns true when a given user can view the issue. +func (issue *Issue) CanSeeIssue(userID int64, repoPermission *Permission) bool { + if issue.IsPrivate { + return repoPermission.CanReadPrivateIssues() || issue.IsPoster(userID) + } + return true +} + func (issue *Issue) getLabels(e db.Engine) (err error) { if len(issue.Labels) > 0 { return nil @@ -753,6 +782,49 @@ func ChangeIssueTitle(issue *Issue, doer *user_model.User, oldTitle string) (err return committer.Commit() } +// ChangePrivate changes the private status of the issue, as the given user. +func (issue *Issue) ChangePrivate(doer *user_model.User, isConfidential bool) (err error) { + ctx, committer, err := db.TxContext() + if err != nil { + return err + } + defer committer.Close() + + if err = UpdateIssueCols(ctx, issue, "is_private"); err != nil { + return fmt.Errorf("updateIssueCols: %v", err) + } + + if err = issue.LoadRepo(ctx); err != nil { + return fmt.Errorf("loadRepo: %v", err) + } + + opts := &CreateCommentOptions{ + Type: CommentTypeConfidentialChanged, + Doer: doer, + Repo: issue.Repo, + Issue: issue, + OldConfidential: !issue.IsPrivate, + NewConfidential: issue.IsPrivate, + } + if _, err = CreateCommentCtx(ctx, opts); err != nil { + return fmt.Errorf("createComment: %v", err) + } + if err = issue.addCrossReferences(ctx, doer, true); err != nil { + return err + } + + engine := db.GetEngine(ctx) + if isConfidential { + _, err = engine.Exec("UPDATE `repository` SET num_private_issues = num_private_issues + 1 WHERE id = ?", opts.Issue.RepoID) + _, err = engine.Exec("UPDATE `repository` SET num_issues = num_issues - 1 WHERE id = ?", opts.Issue.RepoID) + } else { + _, err = engine.Exec("UPDATE `repository` SET num_private_issues = num_private_issues - 1 WHERE id = ?", opts.Issue.RepoID) + _, err = engine.Exec("UPDATE `repository` SET num_issues = num_issues + 1 WHERE id = ?", opts.Issue.RepoID) + } + + return committer.Commit() +} + // ChangeIssueRef changes the branch of this issue, as the given user. func ChangeIssueRef(issue *Issue, doer *user_model.User, oldRef string) (err error) { ctx, committer, err := db.TxContext() @@ -981,7 +1053,11 @@ func newIssue(ctx context.Context, doer *user_model.User, opts NewIssueOptions) if opts.IsPull { _, err = e.Exec("UPDATE `repository` SET num_pulls = num_pulls + 1 WHERE id = ?", opts.Issue.RepoID) } else { - _, err = e.Exec("UPDATE `repository` SET num_issues = num_issues + 1 WHERE id = ?", opts.Issue.RepoID) + if opts.Issue.IsPrivate { + _, err = e.Exec("UPDATE `repository` SET num_private_issues = num_private_issues + 1 WHERE id = ?", opts.Issue.RepoID) + } else { + _, err = e.Exec("UPDATE `repository` SET num_issues = num_issues + 1 WHERE id = ?", opts.Issue.RepoID) + } } if err != nil { return err @@ -1199,6 +1275,7 @@ type IssuesOptions struct { MilestoneIDs []int64 ProjectID int64 ProjectBoardID int64 + UserID int64 IsClosed util.OptionalBool IsPull util.OptionalBool LabelIDs []int64 @@ -1212,6 +1289,7 @@ type IssuesOptions struct { // prioritize issues from this repo PriorityRepoID int64 IsArchived util.OptionalBool + CanSeePrivate bool Org *organization.Organization // issues permission scope Team *organization.Team // issues permission scope User *user_model.User // issues permission scope @@ -1333,6 +1411,10 @@ func (opts *IssuesOptions) setupSessionNoLimit(sess *xorm.Session) { } } + if !opts.CanSeePrivate { + applyPosterPrivateIssues(sess, opts.UserID) + } + switch opts.IsPull { case util.OptionalBoolTrue: sess.And("issue.is_pull=?", true) @@ -1433,6 +1515,27 @@ func applyReviewRequestedCondition(sess *xorm.Session, reviewRequestedID int64) reviewRequestedID, ReviewTypeApprove, ReviewTypeReject, ReviewTypeRequest, reviewRequestedID) } +func applyPosterPrivateIssues(sess *xorm.Session, userID int64) *xorm.Session { + if userID == 0 { + return sess.And("issue.is_private=?", false) + } + // OR: + // All non-private issues by default + // All issues that satisfy the condtion + // AND: + // All private issues + // That are in the group of where the user is the poster. + return sess.And( + builder.Or( + builder.Eq{"`issue`.is_private": false}, + builder.And( + builder.Eq{"`issue`.is_private": true}, + builder.In("`issue`.poster_id", userID), + ), + ), + ) +} + // CountIssuesByRepo map from repoID to number of issues matching the options func CountIssuesByRepo(opts *IssuesOptions) (map[int64]int64, error) { e := db.GetEngine(db.DefaultContext) @@ -1487,7 +1590,7 @@ func Issues(opts *IssuesOptions) ([]*Issue, error) { opts.setupSessionWithLimit(sess) sortIssuesSession(sess, opts.SortType, opts.PriorityRepoID) - issues := make([]*Issue, 0, opts.ListOptions.PageSize) + issues := make([]*Issue, 0, opts.PageSize) if err := sess.Find(&issues); err != nil { return nil, fmt.Errorf("unable to query Issues: %w", err) } @@ -1587,8 +1690,10 @@ type IssueStatsOptions struct { MentionedID int64 PosterID int64 ReviewRequestedID int64 + UserID int64 IsPull util.OptionalBool IssueIDs []int64 + CanSeePrivate bool } const ( @@ -1597,7 +1702,7 @@ const ( maxQueryParameters = 300 ) -// GetIssueStats returns issue statistic information by given conditions. +// GetIssueStats returns issue statistic information by given conditions, as User func GetIssueStats(opts *IssueStatsOptions) (*IssueStats, error) { if len(opts.IssueIDs) <= maxQueryParameters { return getIssueStatsChunk(opts, opts.IssueIDs) @@ -1676,6 +1781,10 @@ func getIssueStatsChunk(opts *IssueStatsOptions, issueIDs []int64) (*IssueStats, applyReviewRequestedCondition(sess, opts.ReviewRequestedID) } + if !opts.CanSeePrivate { + applyPosterPrivateIssues(sess, opts.UserID) + } + switch opts.IsPull { case util.OptionalBoolTrue: sess.And("issue.is_pull=?", true) @@ -1747,6 +1856,16 @@ func GetUserIssueStats(opts UserIssueStatsOptions) (*IssueStats, error) { s.And(builder.Eq{"repository.is_archived": opts.IsArchived.IsTrue()}) } } + // Allow to see comments on private issues + s.And( + builder.Or( + builder.Eq{"`issue`.is_private": false}, + builder.And( + builder.Eq{"`issue`.is_private": true}, + builder.In("`issue`.poster_id", opts.UserID), + ), + ), + ) return s } @@ -2196,9 +2315,13 @@ func (issue *Issue) BlockingDependencies() ([]*DependencyInfo, error) { func updateIssueClosedNum(ctx context.Context, issue *Issue) (err error) { if issue.IsPull { - err = repoStatsCorrectNumClosed(ctx, issue.RepoID, true, "num_closed_pulls") + err = repoStatsCorrectNumClosed(ctx, issue.RepoID, true, false, "num_closed_pulls") } else { - err = repoStatsCorrectNumClosed(ctx, issue.RepoID, false, "num_closed_issues") + if issue.IsPrivate { + err = repoStatsCorrectNumClosed(ctx, issue.RepoID, false, true, "num_private_issues") + } else { + err = repoStatsCorrectNumClosed(ctx, issue.RepoID, false, false, "num_closed_issues") + } } return } @@ -2341,7 +2464,7 @@ func ResolveIssueMentionsByVisibility(ctx context.Context, issue *Issue, doer *u if err != nil { return nil, fmt.Errorf("GetUserRepoPermission [%d]: %v", user.ID, err) } - if !perm.CanReadIssuesOrPulls(issue.IsPull) { + if !perm.CanReadIssuesOrPulls(issue.IsPull) || !issue.CanSeeIssue(user.ID, &perm) { continue } users = append(users, user) diff --git a/models/issue_comment.go b/models/issue_comment.go index 39c2818eed049..6c2cda3e31b97 100644 --- a/models/issue_comment.go +++ b/models/issue_comment.go @@ -110,6 +110,8 @@ const ( CommentTypeDismissReview // 33 Change issue ref CommentTypeChangeIssueRef + // 34 Change confidential + CommentTypeConfidentialChanged ) var commentStrings = []string{ @@ -957,6 +959,8 @@ type CreateCommentOptions struct { NewTitle string OldRef string NewRef string + OldConfidential bool + NewConfidential bool CommitID int64 CommitSHA string Patch string @@ -1042,14 +1046,17 @@ func getCommentByID(e db.Engine, id int64) (*Comment, error) { // FindCommentsOptions describes the conditions to Find comments type FindCommentsOptions struct { db.ListOptions - RepoID int64 - IssueID int64 - ReviewID int64 - Since int64 - Before int64 - Line int64 - TreePath string - Type CommentType + RepoID int64 + IssueID int64 + ReviewID int64 + UserID int64 + Since int64 + Before int64 + Line int64 + TreePath string + Type CommentType + CanSeePrivate bool + DisablePrivateIssues bool } func (opts *FindCommentsOptions) toConds() builder.Cond { @@ -1078,15 +1085,30 @@ func (opts *FindCommentsOptions) toConds() builder.Cond { if len(opts.TreePath) > 0 { cond = cond.And(builder.Eq{"comment.tree_path": opts.TreePath}) } + if !opts.CanSeePrivate && !opts.DisablePrivateIssues { + if opts.UserID != 0 { + // Allow to see comments on private issues + cond = cond.And( + builder.Or( + builder.Eq{"`issue`.is_private": false}, + builder.And( + builder.Eq{"`issue`.is_private": true}, + builder.In("`issue`.poster_id", opts.UserID), + ), + ), + ) + } else { + cond = cond.And(builder.Eq{"`issue`.is_private": false}) + } + } + return cond } func findComments(e db.Engine, opts *FindCommentsOptions) ([]*Comment, error) { comments := make([]*Comment, 0, 10) sess := e.Where(opts.toConds()) - if opts.RepoID > 0 { - sess.Join("INNER", "issue", "issue.id = comment.issue_id") - } + sess.Join("INNER", "issue", "issue.id = comment.issue_id") if opts.Page != 0 { sess = db.SetSessionPagination(sess, opts) @@ -1108,9 +1130,8 @@ func FindComments(opts *FindCommentsOptions) ([]*Comment, error) { // CountComments count all comments according options by ignoring pagination func CountComments(opts *FindCommentsOptions) (int64, error) { sess := db.GetEngine(db.DefaultContext).Where(opts.toConds()) - if opts.RepoID > 0 { - sess.Join("INNER", "issue", "issue.id = comment.issue_id") - } + sess.Join("INNER", "issue", "issue.id = comment.issue_id") + return sess.Count(&Comment{}) } @@ -1225,6 +1246,7 @@ func findCodeComments(ctx context.Context, opts FindCommentsOptions, issue *Issu } e := db.GetEngine(ctx) if err := e.Where(conds). + Join("INNER", "issue", "issue.id = comment.issue_id"). Asc("comment.created_unix"). Asc("comment.id"). Find(&comments); err != nil { diff --git a/models/issue_project.go b/models/issue_project.go index 0e993b39c5962..badedc795355a 100644 --- a/models/issue_project.go +++ b/models/issue_project.go @@ -60,14 +60,22 @@ func (i *Issue) projectBoardID(e db.Engine) int64 { return ip.ProjectBoardID } +// LoadIssuesOpts list the options that can be given to load the issues. +type LoadIssuesOpts struct { + UserID int64 + CanSeePrivateIssues bool +} + // LoadIssuesFromBoard load issues assigned to this board -func LoadIssuesFromBoard(b *project_model.Board) (IssueList, error) { +func LoadIssuesFromBoard(b *project_model.Board, opts *LoadIssuesOpts) (IssueList, error) { issueList := make([]*Issue, 0, 10) if b.ID != 0 { issues, err := Issues(&IssuesOptions{ ProjectBoardID: b.ID, ProjectID: b.ProjectID, + UserID: opts.UserID, + CanSeePrivate: opts.CanSeePrivateIssues, }) if err != nil { return nil, err @@ -79,6 +87,8 @@ func LoadIssuesFromBoard(b *project_model.Board) (IssueList, error) { issues, err := Issues(&IssuesOptions{ ProjectBoardID: -1, // Issues without ProjectBoardID ProjectID: b.ProjectID, + UserID: opts.UserID, + CanSeePrivate: opts.CanSeePrivateIssues, }) if err != nil { return nil, err @@ -94,10 +104,10 @@ func LoadIssuesFromBoard(b *project_model.Board) (IssueList, error) { } // LoadIssuesFromBoardList load issues assigned to the boards -func LoadIssuesFromBoardList(bs project_model.BoardList) (map[int64]IssueList, error) { +func LoadIssuesFromBoardList(bs project_model.BoardList, opts *LoadIssuesOpts) (map[int64]IssueList, error) { issuesMap := make(map[int64]IssueList, len(bs)) for i := range bs { - il, err := LoadIssuesFromBoard(bs[i]) + il, err := LoadIssuesFromBoard(bs[i], opts) if err != nil { return nil, err } diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index 9e46791ec607b..1e535394b065a 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -383,6 +383,8 @@ var migrations = []Migration{ NewMigration("Add package tables", addPackageTables), // v213 -> v214 NewMigration("Add allow edits from maintainers to PullRequest table", addAllowMaintainerEdit), + // v214 -> v215 + NewMigration("Add private issues", addPrivateIssues), } // GetCurrentDBVersion returns the current db version diff --git a/models/migrations/v214.go b/models/migrations/v214.go new file mode 100644 index 0000000000000..7835f89aa2712 --- /dev/null +++ b/models/migrations/v214.go @@ -0,0 +1,42 @@ +// 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 migrations + +import ( + "fmt" + + "xorm.io/xorm" +) + +func addPrivateIssues(x *xorm.Engine) error { + type Repository struct { + NumPrivateIssues int `xorm:"NOT NULL DEFAULT 0"` + NumClosedPrivateIssues int `xorm:"NOT NULL DEFAULT 0"` + } + + if err := x.Sync2(new(Repository)); err != nil { + return fmt.Errorf("Sync2: %v", err) + } + + type Issue struct { + IsPrivate bool `xorm:"NOT NULL DEFAULT false"` + } + + if err := x.Sync2(new(Issue)); err != nil { + return err + } + + type Team struct { + ID int64 `xorm:"pk autoincr"` + CanSeePrivateIssues bool `xorm:"NOT NULL DEFAULT false"` + } + + if err := x.Sync2(new(Team)); err != nil { + return err + } + + _, err := x.Exec("UPDATE `team` SET `can_see_private_issues` = ? WHERE `name`=?", + true, "Owners") + return err +} diff --git a/models/notification.go b/models/notification.go index a1248c240b948..d0dccf6484d47 100644 --- a/models/notification.go +++ b/models/notification.go @@ -271,6 +271,16 @@ func createOrUpdateIssueNotifications(ctx context.Context, issueID, commentID, n return err } + perm, err := GetUserRepoPermission(ctx, issue.Repo, user) + if err != nil { + log.Error("getUserRepoPermission(): %v", err) + return err + } + + if !issue.CanSeeIssue(user.ID, &perm) { + continue + } + if issue.IsPull && !checkRepoUnitUser(ctx, issue.Repo, user, unit.TypePullRequests) { continue } diff --git a/models/org_team.go b/models/org_team.go index 695f803dbfdbe..1011185008c42 100644 --- a/models/org_team.go +++ b/models/org_team.go @@ -351,7 +351,7 @@ func UpdateTeam(t *organization.Team, authChanged, includeAllChanged bool) (err } if _, err = sess.ID(t.ID).Cols("name", "lower_name", "description", - "can_create_org_repo", "authorize", "includes_all_repositories").Update(t); err != nil { + "can_create_org_repo", "authorize", "includes_all_repositories", "can_see_private_issues").Update(t); err != nil { return fmt.Errorf("update: %v", err) } diff --git a/models/organization/org.go b/models/organization/org.go index 3761335922a4a..fdebc12d93fbb 100644 --- a/models/organization/org.go +++ b/models/organization/org.go @@ -302,6 +302,7 @@ func CreateOrganization(org *Organization, owner *user_model.User) (err error) { NumMembers: 1, IncludesAllRepositories: true, CanCreateOrgRepo: true, + CanSeePrivateIssues: true, } if err = db.Insert(ctx, t); err != nil { return fmt.Errorf("insert owner team: %v", err) diff --git a/models/organization/team.go b/models/organization/team.go index 077fba6a60cf0..20fabbc4e8beb 100644 --- a/models/organization/team.go +++ b/models/organization/team.go @@ -78,6 +78,7 @@ type Team struct { Units []*TeamUnit `xorm:"-"` IncludesAllRepositories bool `xorm:"NOT NULL DEFAULT false"` CanCreateOrgRepo bool `xorm:"NOT NULL DEFAULT false"` + CanSeePrivateIssues bool `xorm:"NOT NULL DEFAULT false"` } func init() { diff --git a/models/project/board.go b/models/project/board.go index f770a18f59e01..5bee3b112d93a 100644 --- a/models/project/board.go +++ b/models/project/board.go @@ -58,12 +58,37 @@ func (Board) TableName() string { return "project_board" } -// NumIssues return counter of all issues assigned to the board +// NumIssues return counter of all non-private issues assigned to the board. func (b *Board) NumIssues() int { c, err := db.GetEngine(db.DefaultContext).Table("project_issue"). - Where("project_id=?", b.ProjectID). - And("project_board_id=?", b.ID). - GroupBy("issue_id"). + Join("INNER", "issue", "project_issue.issue_id=issue.id"). + Where("project_issue.project_board_id=? AND project_issue.project_id=? AND issue.is_private=?", b.ID, b.ProjectID, false). + Cols("issue_id"). + Count() + if err != nil { + return 0 + } + return int(c) +} + +// NumPrivateIssues return counter of all private issues assigned to the board. +func (b *Board) NumPrivateIssues() int { + c, err := db.GetEngine(db.DefaultContext).Table("project_issue"). + Join("INNER", "issue", "project_issue.issue_id=issue.id"). + Where("project_issue.project_board_id=? AND project_issue.project_id=? AND issue.is_private=?", b.ID, b.ProjectID, true). + Cols("issue_id"). + Count() + if err != nil { + return 0 + } + return int(c) +} + +// NumPrivateOwnIssues return counter of user created private issues assigned to the board. +func (b *Board) NumPrivateOwnIssues(userID int64) int { + c, err := db.GetEngine(db.DefaultContext).Table("project_issue"). + Join("INNER", "issue", "project_issue.issue_id=issue.id"). + Where("project_issue.project_board_id=? AND project_issue.project_ID=? AND issue.is_private=? AND issue.poster_id=?", b.ID, b.ProjectID, true, userID). Cols("issue_id"). Count() if err != nil { diff --git a/models/project/issue.go b/models/project/issue.go index 0976185c495ad..a6881443dbca3 100644 --- a/models/project/issue.go +++ b/models/project/issue.go @@ -30,11 +30,37 @@ func deleteProjectIssuesByProjectID(e db.Engine, projectID int64) error { return err } -// NumIssues return counter of all issues assigned to a project +// NumIssues return counter of all non-private issues assigned to a project func (p *Project) NumIssues() int { c, err := db.GetEngine(db.DefaultContext).Table("project_issue"). - Where("project_id=?", p.ID). - GroupBy("issue_id"). + Join("INNER", "issue", "project_issue.issue_id=issue.id"). + Where("project_issue.project_id=? AND issue.is_private=?", p.ID, false). + Cols("issue_id"). + Count() + if err != nil { + return 0 + } + return int(c) +} + +// NumPrivateIssues return counter of all private issues assigned to a project +func (p *Project) NumPrivateIssues() int { + c, err := db.GetEngine(db.DefaultContext).Table("project_issue"). + Join("INNER", "issue", "project_issue.issue_id=issue.id"). + Where("project_issue.project_id=? AND issue.is_private=?", p.ID, true). + Cols("issue_id"). + Count() + if err != nil { + return 0 + } + return int(c) +} + +// NumPrivateOwnIssues return counter of all user created private issues assigned to a project +func (p *Project) NumPrivateOwnIssues(userID int64) int { + c, err := db.GetEngine(db.DefaultContext).Table("project_issue"). + Join("INNER", "issue", "project_issue.issue_id=issue.id"). + Where("project_issue.project_id=? AND issue.is_private=? AND issue.poster_id=?", p.ID, true, userID). Cols("issue_id"). Count() if err != nil { @@ -47,7 +73,7 @@ func (p *Project) NumIssues() int { func (p *Project) NumClosedIssues() int { c, err := db.GetEngine(db.DefaultContext).Table("project_issue"). Join("INNER", "issue", "project_issue.issue_id=issue.id"). - Where("project_issue.project_id=? AND issue.is_closed=?", p.ID, true). + Where("project_issue.project_id=? AND issue.is_closed=? AND issue.is_private=?", p.ID, true, false). Cols("issue_id"). Count() if err != nil { @@ -60,7 +86,59 @@ func (p *Project) NumClosedIssues() int { func (p *Project) NumOpenIssues() int { c, err := db.GetEngine(db.DefaultContext).Table("project_issue"). Join("INNER", "issue", "project_issue.issue_id=issue.id"). - Where("project_issue.project_id=? AND issue.is_closed=?", p.ID, false).Count("issue.id") + Where("project_issue.project_id=? AND issue.is_closed=? AND issue.is_private=?", p.ID, false, false).Count("issue.id") + if err != nil { + return 0 + } + return int(c) +} + +// NumClosedPrivateOwnIssues return counter of closed private issues of the user, assigned to a project +func (p *Project) NumClosedPrivateOwnIssues(userID int64) int { + c, err := db.GetEngine(db.DefaultContext).Table("project_issue"). + Join("INNER", "issue", "project_issue.issue_id=issue.id"). + Where("project_issue.project_id=? AND issue.is_closed=? AND issue.is_private=? AND issue.poster_id=?", p.ID, true, true, userID). + Cols("issue_id"). + Count() + if err != nil { + return 0 + } + return int(c) +} + +// NumClosedPrivateIssues return counter of closed private issues assigned to a project +func (p *Project) NumClosedPrivateIssues() int { + c, err := db.GetEngine(db.DefaultContext).Table("project_issue"). + Join("INNER", "issue", "project_issue.issue_id=issue.id"). + Where("project_issue.project_id=? AND issue.is_closed=? AND issue.is_private=?", p.ID, true, true). + Cols("issue_id"). + Count() + if err != nil { + return 0 + } + return int(c) +} + +// NumOpenPrivateIssues return counter of open private issues assigned to a project +func (p *Project) NumOpenPrivateIssues() int { + c, err := db.GetEngine(db.DefaultContext).Table("project_issue"). + Join("INNER", "issue", "project_issue.issue_id=issue.id"). + Where("project_issue.project_id=? AND issue.is_closed=? AND issue.is_private=?", p.ID, false, true). + Cols("issue_id"). + Count() + if err != nil { + return 0 + } + return int(c) +} + +// NumOpenPrivateOwnIssues return counter of open private issues of the user, assigned to a project +func (p *Project) NumOpenPrivateOwnIssues(userID int64) int { + c, err := db.GetEngine(db.DefaultContext).Table("project_issue"). + Join("INNER", "issue", "project_issue.issue_id=issue.id"). + Where("project_issue.project_id=? AND issue.is_closed=? AND issue.is_private=? AND issue.poster_id=?", p.ID, false, true, userID). + Cols("issue_id"). + Count() if err != nil { return 0 } diff --git a/models/repo.go b/models/repo.go index 9c2fce8d3cd3e..973b960cc7813 100644 --- a/models/repo.go +++ b/models/repo.go @@ -974,6 +974,10 @@ func repoStatsCorrectNumIssues(ctx context.Context, id int64) error { return repoStatsCorrectNum(ctx, id, false, "num_issues") } +func repoStatsCorrectNumPrivateIssues(ctx context.Context, id int64) error { + return repoStatsCorrectNumClosed(ctx, id, true, true, "num_private_issues") +} + func repoStatsCorrectNumPulls(ctx context.Context, id int64) error { return repoStatsCorrectNum(ctx, id, true, "num_pulls") } @@ -984,15 +988,15 @@ func repoStatsCorrectNum(ctx context.Context, id int64, isPull bool, field strin } func repoStatsCorrectNumClosedIssues(ctx context.Context, id int64) error { - return repoStatsCorrectNumClosed(ctx, id, false, "num_closed_issues") + return repoStatsCorrectNumClosed(ctx, id, false, false, "num_closed_issues") } func repoStatsCorrectNumClosedPulls(ctx context.Context, id int64) error { - return repoStatsCorrectNumClosed(ctx, id, true, "num_closed_pulls") + return repoStatsCorrectNumClosed(ctx, id, true, false, "num_closed_pulls") } -func repoStatsCorrectNumClosed(ctx context.Context, id int64, isPull bool, field string) error { - _, err := db.GetEngine(ctx).Exec("UPDATE `repository` SET "+field+"=(SELECT COUNT(*) FROM `issue` WHERE repo_id=? AND is_closed=? AND is_pull=?) WHERE id=?", id, true, isPull, id) +func repoStatsCorrectNumClosed(ctx context.Context, id int64, isPull, isPrivate bool, field string) error { + _, err := db.GetEngine(ctx).Exec("UPDATE `repository` SET "+field+"=(SELECT COUNT(*) FROM `issue` WHERE repo_id=? AND is_closed=? AND is_pull=? AND is_private=?) WHERE id=?", id, true, isPull, isPrivate, id) return err } @@ -1021,7 +1025,7 @@ func CheckRepoStats(ctx context.Context) error { }, // Repository.NumClosedIssues { - statsQuery("SELECT repo.id FROM `repository` repo WHERE repo.num_closed_issues!=(SELECT COUNT(*) FROM `issue` WHERE repo_id=repo.id AND is_closed=? AND is_pull=?)", true, false), + statsQuery("SELECT repo.id FROM `repository` repo WHERE repo.num_closed_issues!=(SELECT COUNT(*) FROM `issue` WHERE repo_id=repo.id AND is_closed=? AND is_pull=? and is_private=?)", true, false, false), repoStatsCorrectNumClosedIssues, "repository count 'num_closed_issues'", }, @@ -1119,6 +1123,7 @@ func UpdateRepoStats(ctx context.Context, id int64) error { repoStatsCorrectNumWatches, repoStatsCorrectNumStars, repoStatsCorrectNumIssues, + repoStatsCorrectNumPrivateIssues, repoStatsCorrectNumPulls, repoStatsCorrectNumClosedIssues, repoStatsCorrectNumClosedPulls, diff --git a/models/repo/repo.go b/models/repo/repo.go index fc72d36dac8e3..5dd8bc7a8d3dc 100644 --- a/models/repo/repo.go +++ b/models/repo/repo.go @@ -105,21 +105,24 @@ type Repository struct { OriginalURL string `xorm:"VARCHAR(2048)"` DefaultBranch string - NumWatches int - NumStars int - NumForks int - NumIssues int - NumClosedIssues int - NumOpenIssues int `xorm:"-"` - NumPulls int - NumClosedPulls int - NumOpenPulls int `xorm:"-"` - NumMilestones int `xorm:"NOT NULL DEFAULT 0"` - NumClosedMilestones int `xorm:"NOT NULL DEFAULT 0"` - NumOpenMilestones int `xorm:"-"` - NumProjects int `xorm:"NOT NULL DEFAULT 0"` - NumClosedProjects int `xorm:"NOT NULL DEFAULT 0"` - NumOpenProjects int `xorm:"-"` + NumWatches int + NumStars int + NumForks int + NumIssues int + NumClosedIssues int + NumOpenIssues int `xorm:"-"` + NumPrivateIssues int `xorm:"NOT NULL DEFAULT 0"` + NumClosedPrivateIssues int `xorm:"NOT NULL DEFAULT 0"` + NumOpenPrivateIssues int `xorm:"-"` + NumPulls int + NumClosedPulls int + NumOpenPulls int `xorm:"-"` + NumMilestones int `xorm:"NOT NULL DEFAULT 0"` + NumClosedMilestones int `xorm:"NOT NULL DEFAULT 0"` + NumOpenMilestones int `xorm:"-"` + NumProjects int `xorm:"NOT NULL DEFAULT 0"` + NumClosedProjects int `xorm:"NOT NULL DEFAULT 0"` + NumOpenProjects int `xorm:"-"` IsPrivate bool `xorm:"INDEX"` IsEmpty bool `xorm:"INDEX"` @@ -209,6 +212,7 @@ func (repo *Repository) AfterLoad() { } repo.NumOpenIssues = repo.NumIssues - repo.NumClosedIssues + repo.NumOpenPrivateIssues = repo.NumPrivateIssues - repo.NumClosedPrivateIssues repo.NumOpenPulls = repo.NumPulls - repo.NumClosedPulls repo.NumOpenMilestones = repo.NumMilestones - repo.NumClosedMilestones repo.NumOpenProjects = repo.NumProjects - repo.NumClosedProjects diff --git a/models/repo_activity.go b/models/repo_activity.go index 7475be2b116d3..f28465c228611 100644 --- a/models/repo_activity.go +++ b/models/repo_activity.go @@ -15,6 +15,7 @@ import ( user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/git" + "xorm.io/builder" "xorm.io/xorm" ) @@ -43,35 +44,47 @@ type ActivityStats struct { Code *git.CodeActivityStats } -// GetActivityStats return stats for repository at given time range -func GetActivityStats(ctx context.Context, repo *repo_model.Repository, timeFrom time.Time, releases, issues, prs, code bool) (*ActivityStats, error) { +// GetActivityStatsOpts represents the possible options to GetActivityStats +type GetActivityStatsOpts struct { + TimeFrom time.Time + UserID int64 + ShowReleases bool + ShowIssues bool + ShowPRs bool + ShowCode bool + CanReadPrivateIssues bool +} + +// GetActivityStats return stats for repository at given time range, as user +func GetActivityStats(repo *repo_model.Repository, opts *GetActivityStatsOpts) (*ActivityStats, error) { stats := &ActivityStats{Code: &git.CodeActivityStats{}} - if releases { - if err := stats.FillReleases(repo.ID, timeFrom); err != nil { + if opts.ShowReleases { + if err := stats.FillReleases(repo.ID, opts.TimeFrom); err != nil { return nil, fmt.Errorf("FillReleases: %v", err) } } - if prs { - if err := stats.FillPullRequests(repo.ID, timeFrom); err != nil { + if opts.ShowPRs { + if err := stats.FillPullRequests(repo.ID, opts.TimeFrom); err != nil { return nil, fmt.Errorf("FillPullRequests: %v", err) } } - if issues { - if err := stats.FillIssues(repo.ID, timeFrom); err != nil { + if opts.ShowIssues { + if err := stats.FillIssues(repo.ID, opts.TimeFrom, opts.CanReadPrivateIssues, opts.UserID); err != nil { return nil, fmt.Errorf("FillIssues: %v", err) } } - if err := stats.FillUnresolvedIssues(repo.ID, timeFrom, issues, prs); err != nil { + if err := stats.FillUnresolvedIssues(repo.ID, opts.TimeFrom, opts.ShowIssues, opts.ShowPRs, opts.CanReadPrivateIssues, opts.UserID); err != nil { return nil, fmt.Errorf("FillUnresolvedIssues: %v", err) } - if code { - gitRepo, closer, err := git.RepositoryFromContextOrOpen(ctx, repo.RepoPath()) + + if opts.ShowCode { + gitRepo, closer, err := git.RepositoryFromContextOrOpen(git.DefaultContext, repo.RepoPath()) if err != nil { return nil, fmt.Errorf("OpenRepository: %v", err) } defer closer.Close() - code, err := gitRepo.GetCodeActivityStats(timeFrom, repo.DefaultBranch) + code, err := gitRepo.GetCodeActivityStats(opts.TimeFrom, repo.DefaultBranch) if err != nil { return nil, fmt.Errorf("FillFromGit: %v", err) } @@ -264,12 +277,21 @@ func pullRequestsForActivityStatement(repoID int64, fromTime time.Time, merged b } // FillIssues returns issue information for activity page -func (stats *ActivityStats) FillIssues(repoID int64, fromTime time.Time) error { +func (stats *ActivityStats) FillIssues(repoID int64, fromTime time.Time, canReadPrivateIssues bool, userID int64) error { var err error var count int64 + activityStreamOptions := &activityStreamOpts{ + fromTime: fromTime, + userID: userID, + repoID: repoID, + closed: true, + unresolved: false, + canReadPrivateIssues: canReadPrivateIssues, + } + // Closed issues - sess := issuesForActivityStatement(repoID, fromTime, true, false) + sess := issuesForActivityStatement(activityStreamOptions) sess.OrderBy("issue.closed_unix DESC") stats.ClosedIssues = make(IssueList, 0) if err = sess.Find(&stats.ClosedIssues); err != nil { @@ -277,14 +299,16 @@ func (stats *ActivityStats) FillIssues(repoID int64, fromTime time.Time) error { } // Closed issue authors - sess = issuesForActivityStatement(repoID, fromTime, true, false) + sess = issuesForActivityStatement(activityStreamOptions) if _, err = sess.Select("count(distinct issue.poster_id) as `count`").Table("issue").Get(&count); err != nil { return err } stats.ClosedIssueAuthorCount = count + activityStreamOptions.closed = false + // New issues - sess = issuesForActivityStatement(repoID, fromTime, false, false) + sess = issuesForActivityStatement(activityStreamOptions) sess.OrderBy("issue.created_unix ASC") stats.OpenedIssues = make(IssueList, 0) if err = sess.Find(&stats.OpenedIssues); err != nil { @@ -292,7 +316,7 @@ func (stats *ActivityStats) FillIssues(repoID int64, fromTime time.Time) error { } // Opened issue authors - sess = issuesForActivityStatement(repoID, fromTime, false, false) + sess = issuesForActivityStatement(activityStreamOptions) if _, err = sess.Select("count(distinct issue.poster_id) as `count`").Table("issue").Get(&count); err != nil { return err } @@ -302,12 +326,19 @@ func (stats *ActivityStats) FillIssues(repoID int64, fromTime time.Time) error { } // FillUnresolvedIssues returns unresolved issue and pull request information for activity page -func (stats *ActivityStats) FillUnresolvedIssues(repoID int64, fromTime time.Time, issues, prs bool) error { +func (stats *ActivityStats) FillUnresolvedIssues(repoID int64, fromTime time.Time, issues, prs, canReadPrivateIssues bool, userID int64) error { // Check if we need to select anything if !issues && !prs { return nil } - sess := issuesForActivityStatement(repoID, fromTime, false, true) + sess := issuesForActivityStatement(&activityStreamOpts{ + fromTime: fromTime, + userID: userID, + repoID: repoID, + closed: false, + unresolved: true, + canReadPrivateIssues: canReadPrivateIssues, + }) if !issues || !prs { sess.And("issue.is_pull = ?", prs) } @@ -316,20 +347,46 @@ func (stats *ActivityStats) FillUnresolvedIssues(repoID int64, fromTime time.Tim return sess.Find(&stats.UnresolvedIssues) } -func issuesForActivityStatement(repoID int64, fromTime time.Time, closed, unresolved bool) *xorm.Session { - sess := db.GetEngine(db.DefaultContext).Where("issue.repo_id = ?", repoID). - And("issue.is_closed = ?", closed) +type activityStreamOpts struct { + fromTime time.Time + userID int64 + repoID int64 + closed bool + unresolved bool + canReadPrivateIssues bool +} + +func issuesForActivityStatement(opts *activityStreamOpts) *xorm.Session { + sess := db.GetEngine(db.DefaultContext).Where("issue.repo_id = ?", opts.repoID). + And("issue.is_closed = ?", opts.closed) - if !unresolved { + if !opts.unresolved { sess.And("issue.is_pull = ?", false) - if closed { - sess.And("issue.closed_unix >= ?", fromTime.Unix()) + if opts.closed { + sess.And("issue.closed_unix >= ?", opts.fromTime.Unix()) } else { - sess.And("issue.created_unix >= ?", fromTime.Unix()) + sess.And("issue.created_unix >= ?", opts.fromTime.Unix()) } } else { - sess.And("issue.created_unix < ?", fromTime.Unix()) - sess.And("issue.updated_unix >= ?", fromTime.Unix()) + sess.And("issue.created_unix < ?", opts.fromTime.Unix()) + sess.And("issue.updated_unix >= ?", opts.fromTime.Unix()) + } + + if !opts.canReadPrivateIssues { + if opts.userID == 0 { + sess.And("issue.is_private = ?", false) + } else { + // Allow to see private issues if the user is the poster of it. + sess.And( + builder.Or( + builder.Eq{"`issue`.is_private": false}, + builder.And( + builder.Eq{"`issue`.is_private": true}, + builder.In("`issue`.poster_id", opts.userID), + ), + ), + ) + } } return sess diff --git a/models/repo_permission.go b/models/repo_permission.go index bc31b873f2e97..6436f19d1faa6 100644 --- a/models/repo_permission.go +++ b/models/repo_permission.go @@ -19,9 +19,10 @@ import ( // Permission contains all the permissions related variables to a repository for a user type Permission struct { - AccessMode perm_model.AccessMode - Units []*repo_model.RepoUnit - UnitsMode map[unit.Type]perm_model.AccessMode + AccessMode perm_model.AccessMode + Units []*repo_model.RepoUnit + UnitsMode map[unit.Type]perm_model.AccessMode + SeePrivateIssue bool } // IsOwner returns true if current user is the owner of repository. @@ -136,6 +137,11 @@ func (p *Permission) CanWriteToBranch(user *user_model.User, branch string) bool return false } +// CanReadPrivateIssues returns true if the user is allowed to see private issues on the repo. +func (p *Permission) CanReadPrivateIssues() bool { + return p.SeePrivateIssue || p.IsAdmin() +} + // ColorFormat writes a colored string for these Permissions func (p *Permission) ColorFormat(s fmt.State) { noColor := log.ColorBytes(log.Reset) @@ -275,6 +281,10 @@ func GetUserRepoPermission(ctx context.Context, repo *repo_model.Repository, use perm.UnitsMode = nil return } + // Check if the team has access to private issues. + if team.CanSeePrivateIssues { + perm.SeePrivateIssue = true + } } for _, u := range repo.Units { diff --git a/models/review.go b/models/review.go index a9e29a10e05ca..fec660ee52cbf 100644 --- a/models/review.go +++ b/models/review.go @@ -15,6 +15,7 @@ import ( "code.gitea.io/gitea/models/unit" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/timeutil" "xorm.io/builder" @@ -929,9 +930,10 @@ func DeleteReview(r *Review) error { } opts := FindCommentsOptions{ - Type: CommentTypeCode, - IssueID: r.IssueID, - ReviewID: r.ID, + Type: CommentTypeCode, + IssueID: r.IssueID, + ReviewID: r.ID, + DisablePrivateIssues: true, } if _, err := sess.Where(opts.toConds()).Delete(new(Comment)); err != nil { @@ -939,9 +941,10 @@ func DeleteReview(r *Review) error { } opts = FindCommentsOptions{ - Type: CommentTypeReview, - IssueID: r.IssueID, - ReviewID: r.ID, + Type: CommentTypeReview, + IssueID: r.IssueID, + ReviewID: r.ID, + DisablePrivateIssues: true, } if _, err := sess.Where(opts.toConds()).Delete(new(Comment)); err != nil { @@ -967,8 +970,12 @@ func (r *Review) GetCodeCommentsCount() int { conds = conds.And(builder.Eq{"invalidated": false}) } - count, err := db.GetEngine(db.DefaultContext).Where(conds).Count(new(Comment)) + count, err := db.GetEngine(db.DefaultContext). + Where(conds). + Join("INNER", "issue", "issue.id = comment.issue_id"). + Count(new(Comment)) if err != nil { + log.Error("GetCodeCommentsCount: %v", err) return 0 } return int(count) @@ -982,7 +989,10 @@ func (r *Review) HTMLURL() string { ReviewID: r.ID, } comment := new(Comment) - has, err := db.GetEngine(db.DefaultContext).Where(opts.toConds()).Get(comment) + has, err := db.GetEngine(db.DefaultContext). + Where(opts.toConds()). + Join("INNER", "issue", "issue.id = comment.issue_id"). + Get(comment) if err != nil || !has { return "" } diff --git a/modules/context/repo.go b/modules/context/repo.go index b2c9a21f8e54f..01b47a89eeeae 100644 --- a/modules/context/repo.go +++ b/modules/context/repo.go @@ -153,6 +153,10 @@ func (r *Repository) CanUseTimetracker(issue *models.Issue, user *user_model.Use // Checking for following: // 1. Is timetracker enabled // 2. Is the user a contributor, admin, poster or assignee and do the repository policies require this? + // 3. Is on a private issue, check if they can access that private issue. + if !issue.CanSeeIssue(user.ID, &r.Permission) { + return false + } isAssigned, _ := models.IsUserAssignedToIssue(issue, user) return r.Repository.IsTimetrackerEnabled() && (!r.Repository.AllowOnlyContributorsToTrackTime() || r.Permission.CanWriteIssuesOrPulls(issue.IsPull) || issue.IsPoster(user.ID) || isAssigned) diff --git a/modules/notification/action/action.go b/modules/notification/action/action.go index 547498a9dcca7..67a0a4364789c 100644 --- a/modules/notification/action/action.go +++ b/modules/notification/action/action.go @@ -45,13 +45,14 @@ func (a *actionNotifier) NotifyNewIssue(issue *models.Issue, mentions []*user_mo repo := issue.Repo if err := models.NotifyWatchers(&models.Action{ - ActUserID: issue.Poster.ID, - ActUser: issue.Poster, - OpType: models.ActionCreateIssue, - Content: fmt.Sprintf("%d|%s", issue.Index, issue.Title), - RepoID: repo.ID, - Repo: repo, - IsPrivate: repo.IsPrivate, + ActUserID: issue.Poster.ID, + ActUser: issue.Poster, + OpType: models.ActionCreateIssue, + Content: fmt.Sprintf("%d|%s", issue.Index, issue.Title), + RepoID: repo.ID, + Repo: repo, + IsPrivate: repo.IsPrivate, + IsIssuePrivate: issue.IsPrivate, }); err != nil { log.Error("NotifyWatchers: %v", err) } @@ -95,13 +96,14 @@ func (a *actionNotifier) NotifyCreateIssueComment(doer *user_model.User, repo *r issue *models.Issue, comment *models.Comment, mentions []*user_model.User, ) { act := &models.Action{ - ActUserID: doer.ID, - ActUser: doer, - RepoID: issue.Repo.ID, - Repo: issue.Repo, - Comment: comment, - CommentID: comment.ID, - IsPrivate: issue.Repo.IsPrivate, + ActUserID: doer.ID, + ActUser: doer, + RepoID: issue.Repo.ID, + Repo: issue.Repo, + Comment: comment, + CommentID: comment.ID, + IsPrivate: issue.Repo.IsPrivate, + IsIssuePrivate: issue.IsPrivate, } truncatedContent, truncatedRight := util.SplitStringAtByteN(comment.Content, 200) diff --git a/modules/notification/webhook/webhook.go b/modules/notification/webhook/webhook.go index d24440d585c79..11a676f0a178d 100644 --- a/modules/notification/webhook/webhook.go +++ b/modules/notification/webhook/webhook.go @@ -52,6 +52,10 @@ func (m *webhookNotifier) NotifyIssueClearLabels(doer *user_model.User, issue *m return } + if issue.IsPrivate { + return + } + mode, _ := models.AccessLevel(issue.Poster, issue.Repo) var err error if issue.IsPull { @@ -147,6 +151,10 @@ func (m *webhookNotifier) NotifyMigrateRepository(doer, u *user_model.User, repo } func (m *webhookNotifier) NotifyIssueChangeAssignee(doer *user_model.User, issue *models.Issue, assignee *user_model.User, removed bool, comment *models.Comment) { + if issue.IsPrivate { + return + } + ctx, _, finished := process.GetManager().AddContext(graceful.GetManager().HammerContext(), fmt.Sprintf("webhook.NotifyIssueChangeAssignee User: %s[%d] Issue[%d] #%d in [%d] Assignee %s[%d] removed: %t", doer.Name, doer.ID, issue.ID, issue.Index, issue.RepoID, assignee.Name, assignee.ID, removed)) defer finished() @@ -196,6 +204,10 @@ func (m *webhookNotifier) NotifyIssueChangeAssignee(doer *user_model.User, issue } func (m *webhookNotifier) NotifyIssueChangeTitle(doer *user_model.User, issue *models.Issue, oldTitle string) { + if issue.IsPrivate { + return + } + ctx, _, finished := process.GetManager().AddContext(graceful.GetManager().HammerContext(), fmt.Sprintf("webhook.NotifyIssueChangeTitle User: %s[%d] Issue[%d] #%d in [%d]", doer.Name, doer.ID, issue.ID, issue.Index, issue.RepoID)) defer finished() @@ -240,9 +252,12 @@ func (m *webhookNotifier) NotifyIssueChangeTitle(doer *user_model.User, issue *m } func (m *webhookNotifier) NotifyIssueChangeStatus(doer *user_model.User, issue *models.Issue, actionComment *models.Comment, isClosed bool) { + if issue.IsPrivate { + return + } + ctx, _, finished := process.GetManager().AddContext(graceful.GetManager().HammerContext(), fmt.Sprintf("webhook.NotifyIssueChangeStatus User: %s[%d] Issue[%d] #%d in [%d]", doer.Name, doer.ID, issue.ID, issue.Index, issue.RepoID)) defer finished() - mode, _ := models.AccessLevel(issue.Poster, issue.Repo) var err error if issue.IsPull { @@ -291,6 +306,9 @@ func (m *webhookNotifier) NotifyNewIssue(issue *models.Issue, mentions []*user_m log.Error("issue.LoadPoster: %v", err) return } + if issue.IsPrivate { + return + } mode, _ := models.AccessLevel(issue.Poster, issue.Repo) if err := webhook_services.PrepareWebhooks(issue.Repo, webhook.HookEventIssues, &api.IssuePayload{ @@ -339,6 +357,9 @@ func (m *webhookNotifier) NotifyIssueChangeContent(doer *user_model.User, issue mode, _ := models.AccessLevel(issue.Poster, issue.Repo) var err error + if issue.IsPrivate { + return + } if issue.IsPull { issue.PullRequest.Issue = issue err = webhook_services.PrepareWebhooks(issue.Repo, webhook.HookEventPullRequest, &api.PullRequestPayload{ @@ -388,6 +409,9 @@ func (m *webhookNotifier) NotifyUpdateComment(doer *user_model.User, c *models.C log.Error("LoadAttributes: %v", err) return } + if c.Issue.IsPrivate { + return + } mode, _ := models.AccessLevel(doer, c.Issue.Repo) if c.Issue.IsPull { @@ -430,6 +454,10 @@ func (m *webhookNotifier) NotifyCreateIssueComment(doer *user_model.User, repo * ) { mode, _ := models.AccessLevel(doer, repo) + if issue.IsPrivate { + return + } + var err error if issue.IsPull { err = webhook_services.PrepareWebhooks(issue.Repo, webhook.HookEventPullRequestComment, &api.IssueCommentPayload{ @@ -473,6 +501,10 @@ func (m *webhookNotifier) NotifyDeleteComment(doer *user_model.User, comment *mo return } + if comment.Issue.IsPrivate { + return + } + mode, _ := models.AccessLevel(doer, comment.Issue.Repo) if comment.Issue.IsPull { @@ -518,6 +550,10 @@ func (m *webhookNotifier) NotifyIssueChangeLabels(doer *user_model.User, issue * return } + if issue.IsPrivate { + return + } + mode, _ := models.AccessLevel(issue.Poster, issue.Repo) if issue.IsPull { if err = issue.LoadPullRequest(); err != nil { @@ -561,6 +597,9 @@ func (m *webhookNotifier) NotifyIssueChangeMilestone(doer *user_model.User, issu hookAction = api.HookIssueDemilestoned } + if issue.IsPrivate { + return + } if err = issue.LoadAttributes(); err != nil { log.Error("issue.LoadAttributes failed: %v", err) return diff --git a/modules/structs/issue.go b/modules/structs/issue.go index c72487fe4dcad..0ed1a7432d09c 100644 --- a/modules/structs/issue.go +++ b/modules/structs/issue.go @@ -89,6 +89,8 @@ type CreateIssueOption struct { // list of label ids Labels []int64 `json:"labels"` Closed bool `json:"closed"` + // mark if the issue is confidential + IsConfidential bool `json:"is_confidential"` } // EditIssueOption options for editing an issue diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index 0175c8bfc8b00..d58ef7ba4db01 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -1480,6 +1480,13 @@ issues.content_history.created = created issues.content_history.delete_from_history = Delete from history issues.content_history.delete_from_history_confirm = Delete from history? issues.content_history.options = Options +issues.confidential = Confidential +issues.confidential.off = Make issue public +issues.confidential.off_popup = By clicking this button, this issue will be visible to everyone with read access to this repositry. +issues.confidential.on = Make issue confidential +issues.confidential.on_popup = By turning on the confidential mode, only the poster, repository administrators and team members who have "Access private issues" permission will be able to access this issue. +issues.confidential.title_bar = This issue is confidential +issues.confidential.warning = This issue is marked as confidential, your comment won't be visible to the public. compare.compare_base = base compare.compare_head = compare @@ -2382,6 +2389,8 @@ teams.all_repositories_helper = Team has access to all repositories. Selecting t teams.all_repositories_read_permission_desc = This team grants Read access to all repositories: members can view and clone repositories. teams.all_repositories_write_permission_desc = This team grants Write access to all repositories: members can read from and push to repositories. teams.all_repositories_admin_permission_desc = This team grants Admin access to all repositories: members can read from, push to and add collaborators to repositories. +teams.can_see_private_issues = Access private issues +teams.can_see_private_issues_helper = Members can access the private issues in team repositories. [admin] dashboard = Dashboard diff --git a/routers/api/v1/repo/issue.go b/routers/api/v1/repo/issue.go index 9654b270c019e..76a4c1f7527f6 100644 --- a/routers/api/v1/repo/issue.go +++ b/routers/api/v1/repo/issue.go @@ -227,10 +227,13 @@ func SearchIssues(ctx *context.APIContext) { } else if limit > setting.API.MaxResponseItems { limit = setting.API.MaxResponseItems } - // Only fetch the issues if we either don't have a keyword or the search returned issues // This would otherwise return all issues if no issues were found by the search. if len(keyword) == 0 || len(issueIDs) > 0 || len(includedLabelNames) > 0 || len(includedMilestones) > 0 { + var userID int64 + if ctx.IsSigned { + userID = ctx.Doer.ID + } issuesOpt := &models.IssuesOptions{ ListOptions: db.ListOptions{ Page: ctx.FormInt("page"), @@ -246,6 +249,7 @@ func SearchIssues(ctx *context.APIContext) { IsPull: isPull, UpdatedBeforeUnix: before, UpdatedAfterUnix: since, + UserID: userID, } ctxUserID := int64(0) @@ -461,6 +465,10 @@ func ListIssues(ctx *context.APIContext) { // Only fetch the issues if we either don't have a keyword or the search returned issues // This would otherwise return all issues if no issues were found by the search. if len(keyword) == 0 || len(issueIDs) > 0 || len(labelIDs) > 0 { + var userID int64 + if ctx.IsSigned { + userID = ctx.Doer.ID + } issuesOpt := &models.IssuesOptions{ ListOptions: listOptions, RepoID: ctx.Repo.Repository.ID, @@ -474,6 +482,7 @@ func ListIssues(ctx *context.APIContext) { PosterID: createdByID, AssigneeID: assignedByID, MentionedID: mentionedByID, + UserID: userID, } if issues, err = models.Issues(issuesOpt); err != nil { @@ -554,6 +563,16 @@ func GetIssue(ctx *context.APIContext) { } return } + + var userID int64 + if ctx.IsSigned { + userID = ctx.Doer.ID + } + if !issue.CanSeeIssue(userID, &ctx.Repo.Permission) { + ctx.NotFound() + return + } + ctx.JSON(http.StatusOK, convert.ToAPIIssue(issue)) } @@ -605,6 +624,7 @@ func CreateIssue(ctx *context.APIContext) { Content: form.Body, Ref: form.Ref, DeadlineUnix: deadlineUnix, + IsPrivate: form.IsConfidential, } assigneeIDs := make([]int64, 0) @@ -723,6 +743,12 @@ func EditIssue(ctx *context.APIContext) { } return } + + if !issue.CanSeeIssue(ctx.Doer.ID, &ctx.Repo.Permission) { + ctx.NotFound() + return + } + issue.Repo = ctx.Repo.Repository canWrite := ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull) @@ -936,6 +962,11 @@ func UpdateIssueDeadline(ctx *context.APIContext) { return } + if !issue.CanSeeIssue(ctx.Doer.ID, &ctx.Repo.Permission) { + ctx.NotFound() + return + } + if !ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull) { ctx.Error(http.StatusForbidden, "", "Not repo writer") return diff --git a/routers/api/v1/repo/issue_comment.go b/routers/api/v1/repo/issue_comment.go index bc68cb396b97d..53e2a7a2c8fc8 100644 --- a/routers/api/v1/repo/issue_comment.go +++ b/routers/api/v1/repo/issue_comment.go @@ -66,11 +66,24 @@ func ListIssueComments(ctx *context.APIContext) { } issue, err := models.GetIssueByIndex(ctx.Repo.Repository.ID, ctx.ParamsInt64(":index")) if err != nil { - ctx.Error(http.StatusInternalServerError, "GetRawIssueByIndex", err) + if models.IsErrIssueNotExist(err) { + ctx.NotFound() + } else { + ctx.Error(http.StatusInternalServerError, "GetIssueByIndex", err) + } + return + } + + var userID int64 + if ctx.IsSigned { + userID = ctx.Doer.ID + } + if !issue.CanSeeIssue(userID, &ctx.Repo.Permission) { + ctx.NotFound() return } - issue.Repo = ctx.Repo.Repository + issue.Repo = ctx.Repo.Repository opts := &models.FindCommentsOptions{ IssueID: issue.ID, Since: since, @@ -260,12 +273,19 @@ func ListRepoIssueComments(ctx *context.APIContext) { return } + var userID int64 + if ctx.IsSigned { + userID = ctx.Doer.ID + } + opts := &models.FindCommentsOptions{ - ListOptions: utils.GetListOptions(ctx), - RepoID: ctx.Repo.Repository.ID, - Type: models.CommentTypeComment, - Since: since, - Before: before, + ListOptions: utils.GetListOptions(ctx), + RepoID: ctx.Repo.Repository.ID, + Type: models.CommentTypeComment, + Since: since, + Before: before, + CanSeePrivate: ctx.Repo.CanReadPrivateIssues(), + UserID: userID, } comments, err := models.FindComments(opts) @@ -344,7 +364,20 @@ func CreateIssueComment(ctx *context.APIContext) { form := web.GetForm(ctx).(*api.CreateIssueCommentOption) issue, err := models.GetIssueByIndex(ctx.Repo.Repository.ID, ctx.ParamsInt64(":index")) if err != nil { - ctx.Error(http.StatusInternalServerError, "GetIssueByIndex", err) + if models.IsErrIssueNotExist(err) { + ctx.NotFound() + } else { + ctx.Error(http.StatusInternalServerError, "GetIssueByIndex", err) + } + return + } + + var userID int64 + if ctx.IsSigned { + userID = ctx.Doer.ID + } + if !issue.CanSeeIssue(userID, &ctx.Repo.Permission) { + ctx.NotFound() return } @@ -409,9 +442,19 @@ func GetIssueComment(ctx *context.APIContext) { } if err = comment.LoadIssue(); err != nil { - ctx.InternalServerError(err) + ctx.Error(http.StatusInternalServerError, "LoadIssue", err) return } + + var userID int64 + if ctx.IsSigned { + userID = ctx.Doer.ID + } + if !comment.Issue.CanSeeIssue(userID, &ctx.Repo.Permission) { + ctx.Status(http.StatusNotFound) + return + } + if comment.Issue.RepoID != ctx.Repo.Repository.ID { ctx.Status(http.StatusNotFound) return @@ -535,6 +578,23 @@ func editIssueComment(ctx *context.APIContext, form api.EditIssueCommentOption) return } + if err = comment.LoadIssue(); err != nil { + ctx.Error(http.StatusInternalServerError, "LoadIssue", err) + return + } + + if comment.Issue.IsPrivate { + var userID int64 + if ctx.IsSigned { + userID = ctx.Doer.ID + } + + if !(comment.Issue.PosterID == userID || ctx.Repo.CanReadPrivateIssues()) { + ctx.Status(http.StatusNotFound) + return + } + } + if !ctx.IsSigned || (ctx.Doer.ID != comment.PosterID && !ctx.Repo.IsAdmin()) { ctx.Status(http.StatusForbidden) return @@ -638,6 +698,20 @@ func deleteIssueComment(ctx *context.APIContext) { return } + if err = comment.LoadIssue(); err != nil { + ctx.Error(http.StatusInternalServerError, "LoadIssue", err) + return + } + + var userID int64 + if ctx.IsSigned { + userID = ctx.Doer.ID + } + if !comment.Issue.CanSeeIssue(userID, &ctx.Repo.Permission) { + ctx.Status(http.StatusNotFound) + return + } + if !ctx.IsSigned || (ctx.Doer.ID != comment.PosterID && !ctx.Repo.IsAdmin()) { ctx.Status(http.StatusForbidden) return diff --git a/routers/api/v1/repo/issue_label.go b/routers/api/v1/repo/issue_label.go index e314e756dda6b..d1c1f5be11861 100644 --- a/routers/api/v1/repo/issue_label.go +++ b/routers/api/v1/repo/issue_label.go @@ -61,6 +61,15 @@ func ListIssueLabels(ctx *context.APIContext) { return } + var userID int64 + if ctx.IsSigned { + userID = ctx.Doer.ID + } + if !issue.CanSeeIssue(userID, &ctx.Repo.Permission) { + ctx.NotFound() + return + } + ctx.JSON(http.StatusOK, convert.ToLabelList(issue.Labels, ctx.Repo.Repository, ctx.Repo.Owner)) } @@ -168,6 +177,15 @@ func DeleteIssueLabel(ctx *context.APIContext) { return } + var userID int64 + if ctx.IsSigned { + userID = ctx.Doer.ID + } + if !issue.CanSeeIssue(userID, &ctx.Repo.Permission) { + ctx.NotFound() + return + } + if !ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull) { ctx.Status(http.StatusForbidden) return @@ -286,6 +304,15 @@ func ClearIssueLabels(ctx *context.APIContext) { return } + var userID int64 + if ctx.IsSigned { + userID = ctx.Doer.ID + } + if !issue.CanSeeIssue(userID, &ctx.Repo.Permission) { + ctx.NotFound() + return + } + if !ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull) { ctx.Status(http.StatusForbidden) return @@ -310,6 +337,15 @@ func prepareForReplaceOrAdd(ctx *context.APIContext, form api.IssueLabelsOption) return } + var userID int64 + if ctx.IsSigned { + userID = ctx.Doer.ID + } + if !issue.CanSeeIssue(userID, &ctx.Repo.Permission) { + ctx.NotFound() + return + } + labels, err = models.GetLabelsByIDs(form.Labels) if err != nil { ctx.Error(http.StatusInternalServerError, "GetLabelsByIDs", err) diff --git a/routers/api/v1/repo/issue_reaction.go b/routers/api/v1/repo/issue_reaction.go index 5aa73667968ef..dc09574495030 100644 --- a/routers/api/v1/repo/issue_reaction.go +++ b/routers/api/v1/repo/issue_reaction.go @@ -62,6 +62,14 @@ func GetIssueCommentReactions(ctx *context.APIContext) { if err := comment.LoadIssue(); err != nil { ctx.Error(http.StatusInternalServerError, "comment.LoadIssue", err) } + var userID int64 + if ctx.IsSigned { + userID = ctx.Doer.ID + } + if !comment.Issue.CanSeeIssue(userID, &ctx.Repo.Permission) { + ctx.Status(http.StatusNotFound) + return + } if !ctx.Repo.CanReadIssuesOrPulls(comment.Issue.IsPull) { ctx.Error(http.StatusForbidden, "GetIssueCommentReactions", errors.New("no permission to get reactions")) @@ -191,6 +199,15 @@ func changeIssueCommentReaction(ctx *context.APIContext, form api.EditReactionOp ctx.Error(http.StatusInternalServerError, "comment.LoadIssue() failed", err) } + var userID int64 + if ctx.IsSigned { + userID = ctx.Doer.ID + } + if !comment.Issue.CanSeeIssue(userID, &ctx.Repo.Permission) { + ctx.Status(http.StatusNotFound) + return + } + if comment.Issue.IsLocked && !ctx.Repo.CanWriteIssuesOrPulls(comment.Issue.IsPull) { ctx.Error(http.StatusForbidden, "ChangeIssueCommentReaction", errors.New("no permission to change reaction")) return @@ -281,6 +298,15 @@ func GetIssueReactions(ctx *context.APIContext) { return } + var userID int64 + if ctx.IsSigned { + userID = ctx.Doer.ID + } + if !issue.CanSeeIssue(userID, &ctx.Repo.Permission) { + ctx.NotFound() + return + } + if !ctx.Repo.CanReadIssuesOrPulls(issue.IsPull) { ctx.Error(http.StatusForbidden, "GetIssueReactions", errors.New("no permission to get reactions")) return @@ -401,6 +427,15 @@ func changeIssueReaction(ctx *context.APIContext, form api.EditReactionOption, i return } + var userID int64 + if ctx.IsSigned { + userID = ctx.Doer.ID + } + if !issue.CanSeeIssue(userID, &ctx.Repo.Permission) { + ctx.NotFound() + return + } + if issue.IsLocked && !ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull) { ctx.Error(http.StatusForbidden, "ChangeIssueCommentReaction", errors.New("no permission to change reaction")) return diff --git a/routers/api/v1/repo/issue_stopwatch.go b/routers/api/v1/repo/issue_stopwatch.go index 382f294346bb7..02ab53608e45b 100644 --- a/routers/api/v1/repo/issue_stopwatch.go +++ b/routers/api/v1/repo/issue_stopwatch.go @@ -173,6 +173,10 @@ func prepareIssueStopwatch(ctx *context.APIContext, shouldExist bool) (*models.I return nil, err } + if !issue.CanSeeIssue(ctx.Doer.ID, &ctx.Repo.Permission) { + ctx.Status(http.StatusNotFound) + return nil, errors.New("Not enough permission to see issue") + } if !ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull) { ctx.Status(http.StatusForbidden) return nil, errors.New("Unable to write to PRs") diff --git a/routers/api/v1/repo/issue_subscription.go b/routers/api/v1/repo/issue_subscription.go index f00c85b12622c..a23db703c9ae2 100644 --- a/routers/api/v1/repo/issue_subscription.go +++ b/routers/api/v1/repo/issue_subscription.go @@ -116,6 +116,11 @@ func setIssueSubscription(ctx *context.APIContext, watch bool) { return } + if !issue.CanSeeIssue(ctx.Doer.ID, &ctx.Repo.Permission) { + ctx.NotFound() + return + } + user, err := user_model.GetUserByName(ctx.Params(":user")) if err != nil { if user_model.IsErrUserNotExist(err) { @@ -197,6 +202,11 @@ func CheckIssueSubscription(ctx *context.APIContext) { return } + if !issue.CanSeeIssue(ctx.Doer.ID, &ctx.Repo.Permission) { + ctx.NotFound() + return + } + watching, err := models.CheckIssueWatch(ctx.Doer, issue) if err != nil { ctx.InternalServerError(err) @@ -263,6 +273,15 @@ func GetIssueSubscribers(ctx *context.APIContext) { return } + var userID int64 + if ctx.IsSigned { + userID = ctx.Doer.ID + } + if !issue.CanSeeIssue(userID, &ctx.Repo.Permission) { + ctx.NotFound() + return + } + iwl, err := models.GetIssueWatchers(issue.ID, utils.GetListOptions(ctx)) if err != nil { ctx.Error(http.StatusInternalServerError, "GetIssueWatchers", err) diff --git a/routers/api/v1/repo/issue_tracked_time.go b/routers/api/v1/repo/issue_tracked_time.go index e42dc60a94c81..47a0885bb1d98 100644 --- a/routers/api/v1/repo/issue_tracked_time.go +++ b/routers/api/v1/repo/issue_tracked_time.go @@ -78,13 +78,18 @@ func ListTrackedTimes(ctx *context.APIContext) { issue, err := models.GetIssueByIndex(ctx.Repo.Repository.ID, ctx.ParamsInt64(":index")) if err != nil { if models.IsErrIssueNotExist(err) { - ctx.NotFound(err) + ctx.NotFound() } else { ctx.Error(http.StatusInternalServerError, "GetIssueByIndex", err) } return } + if !issue.CanSeeIssue(ctx.Doer.ID, &ctx.Repo.Permission) { + ctx.NotFound() + return + } + opts := &models.FindTrackedTimesOptions{ ListOptions: utils.GetListOptions(ctx), RepositoryID: ctx.Repo.Repository.ID, @@ -182,13 +187,18 @@ func AddTime(ctx *context.APIContext) { issue, err := models.GetIssueByIndex(ctx.Repo.Repository.ID, ctx.ParamsInt64(":index")) if err != nil { if models.IsErrIssueNotExist(err) { - ctx.NotFound(err) + ctx.NotFound() } else { ctx.Error(http.StatusInternalServerError, "GetIssueByIndex", err) } return } + if !issue.CanSeeIssue(ctx.Doer.ID, &ctx.Repo.Permission) { + ctx.NotFound() + return + } + if !ctx.Repo.CanUseTimetracker(issue, ctx.Doer) { if !ctx.Repo.Repository.IsTimetrackerEnabled() { ctx.Error(http.StatusBadRequest, "", "time tracking disabled") @@ -263,13 +273,18 @@ func ResetIssueTime(ctx *context.APIContext) { issue, err := models.GetIssueByIndex(ctx.Repo.Repository.ID, ctx.ParamsInt64(":index")) if err != nil { if models.IsErrIssueNotExist(err) { - ctx.NotFound(err) + ctx.NotFound() } else { ctx.Error(http.StatusInternalServerError, "GetIssueByIndex", err) } return } + if !issue.CanSeeIssue(ctx.Doer.ID, &ctx.Repo.Permission) { + ctx.NotFound() + return + } + if !ctx.Repo.CanUseTimetracker(issue, ctx.Doer) { if !ctx.Repo.Repository.IsTimetrackerEnabled() { ctx.JSON(http.StatusBadRequest, struct{ Message string }{Message: "time tracking disabled"}) @@ -334,13 +349,18 @@ func DeleteTime(ctx *context.APIContext) { issue, err := models.GetIssueByIndex(ctx.Repo.Repository.ID, ctx.ParamsInt64(":index")) if err != nil { if models.IsErrIssueNotExist(err) { - ctx.NotFound(err) + ctx.NotFound() } else { ctx.Error(http.StatusInternalServerError, "GetIssueByIndex", err) } return } + if !issue.CanSeeIssue(ctx.Doer.ID, &ctx.Repo.Permission) { + ctx.NotFound() + return + } + if !ctx.Repo.CanUseTimetracker(issue, ctx.Doer) { if !ctx.Repo.Repository.IsTimetrackerEnabled() { ctx.JSON(http.StatusBadRequest, struct{ Message string }{Message: "time tracking disabled"}) diff --git a/routers/api/v1/repo/pull_review.go b/routers/api/v1/repo/pull_review.go index b3ebe49bf512e..19538915fb170 100644 --- a/routers/api/v1/repo/pull_review.go +++ b/routers/api/v1/repo/pull_review.go @@ -261,7 +261,7 @@ func DeletePullReview(ctx *context.APIContext) { } if err := models.DeleteReview(review); err != nil { - ctx.Error(http.StatusInternalServerError, "DeleteReview", fmt.Errorf("can not delete ReviewID: %d", review.ID)) + ctx.Error(http.StatusInternalServerError, "DeleteReview", fmt.Errorf("can not delete ReviewID: %d, %v", review.ID, err)) return } diff --git a/routers/web/org/teams.go b/routers/web/org/teams.go index 31bfaea92f310..ca889684ef9d4 100644 --- a/routers/web/org/teams.go +++ b/routers/web/org/teams.go @@ -262,6 +262,7 @@ func NewTeamPost(ctx *context.Context) { AccessMode: p, IncludesAllRepositories: includesAllRepositories, CanCreateOrgRepo: form.CanCreateOrgRepo, + CanSeePrivateIssues: form.CanSeePrivateIssues, } if t.AccessMode < perm.AccessModeAdmin { @@ -437,7 +438,9 @@ func EditTeamPost(ctx *context.Context) { return } } + t.CanCreateOrgRepo = form.CanCreateOrgRepo + t.CanSeePrivateIssues = form.CanSeePrivateIssues if ctx.HasError() { ctx.HTML(http.StatusOK, tplTeamNew) diff --git a/routers/web/repo/activity.go b/routers/web/repo/activity.go index b2f25ebe724e4..1b15b2150082f 100644 --- a/routers/web/repo/activity.go +++ b/routers/web/repo/activity.go @@ -51,12 +51,21 @@ func Activity(ctx *context.Context) { ctx.Data["DateUntil"] = timeUntil.Format("January 2, 2006") ctx.Data["PeriodText"] = ctx.Tr("repo.activity.period." + ctx.Data["Period"].(string)) + var userID int64 + if ctx.IsSigned { + userID = ctx.Doer.ID + } + var err error - if ctx.Data["Activity"], err = models.GetActivityStats(ctx, ctx.Repo.Repository, timeFrom, - ctx.Repo.CanRead(unit.TypeReleases), - ctx.Repo.CanRead(unit.TypeIssues), - ctx.Repo.CanRead(unit.TypePullRequests), - ctx.Repo.CanRead(unit.TypeCode)); err != nil { + if ctx.Data["Activity"], err = models.GetActivityStats(ctx.Repo.Repository, &models.GetActivityStatsOpts{ + TimeFrom: timeFrom, + UserID: userID, + ShowReleases: ctx.Repo.CanRead(unit.TypeReleases), + ShowIssues: ctx.Repo.CanRead(unit.TypeIssues), + ShowPRs: ctx.Repo.CanRead(unit.TypePullRequests), + ShowCode: ctx.Repo.CanRead(unit.TypeCode), + CanReadPrivateIssues: ctx.Repo.CanReadPrivateIssues(), + }); err != nil { ctx.ServerError("GetActivityStats", err) return } diff --git a/routers/web/repo/attachment.go b/routers/web/repo/attachment.go index c930311f70f24..f636cd1cd5395 100644 --- a/routers/web/repo/attachment.go +++ b/routers/web/repo/attachment.go @@ -115,6 +115,21 @@ func GetAttachment(ctx *context.Context) { ctx.Error(http.StatusNotFound) return } + if attach.IssueID != 0 { + issue, err := models.GetIssueByID(attach.IssueID) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetIssueByID", err.Error()) + return + } + var userID int64 + if ctx.IsSigned { + userID = ctx.Doer.ID + } + if !issue.CanSeeIssue(userID, &perm) { + ctx.Error(http.StatusNotFound) + return + } + } } if err := attach.IncreaseDownloadCount(); err != nil { diff --git a/routers/web/repo/issue.go b/routers/web/repo/issue.go index a54ad3306bb3b..06176b2995916 100644 --- a/routers/web/repo/issue.go +++ b/routers/web/repo/issue.go @@ -179,6 +179,11 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption uti } } + var userID int64 + if ctx.IsSigned { + userID = ctx.Doer.ID + } + var issueStats *models.IssueStats if forceEmpty { issueStats = &models.IssueStats{} @@ -193,6 +198,8 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption uti ReviewRequestedID: reviewRequestedID, IsPull: isPullOption, IssueIDs: issueIDs, + UserID: userID, + CanSeePrivate: ctx.Repo.CanReadPrivateIssues(), }) if err != nil { ctx.ServerError("GetIssueStats", err) @@ -245,6 +252,8 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption uti LabelIDs: labelIDs, SortType: sortType, IssueIDs: issueIDs, + UserID: userID, + CanSeePrivate: ctx.Repo.CanReadPrivateIssues(), }) if err != nil { ctx.ServerError("Issues", err) @@ -1026,6 +1035,7 @@ func NewIssuePost(ctx *context.Context) { MilestoneID: milestoneID, Content: form.Content, Ref: form.Ref, + IsPrivate: form.IsConfidential, } if err := issue_service.NewIssue(repo, issue, labelIDs, attachments, assigneeIDs); err != nil { @@ -1144,6 +1154,22 @@ func ViewIssue(ctx *context.Context) { issue.Repo = ctx.Repo.Repository } + // Check if the issue is private, if so check if the user has enough + // permission to view the issue. + var userID int64 + if ctx.IsSigned { + userID = ctx.Doer.ID + } + if !issue.CanSeeIssue(userID, &ctx.Repo.Permission) { + ctx.NotFound("CanSeePrivateIssues", models.ErrCannotSeePrivateIssue{ + UserID: userID, + ID: issue.ID, + RepoID: ctx.Repo.Repository.ID, + Index: ctx.ParamsInt64(":index"), + }) + return + } + // Make sure type and URL matches. if ctx.Params(":type") == "issues" && issue.IsPull { ctx.Redirect(issue.Link()) @@ -1188,6 +1214,13 @@ func ViewIssue(ctx *context.Context) { return } + if issue.IsPrivate { + if err = issue.LoadCommentsAsUser(ctx.Doer, ctx.Repo.CanReadPrivateIssues()); err != nil { + ctx.ServerError("LoadCommentsAsUser", err) + return + } + } + if err = filterXRefComments(ctx, issue); err != nil { ctx.ServerError("filterXRefComments", err) return @@ -1728,8 +1761,13 @@ func GetActionIssue(ctx *context.Context) *models.Issue { } func checkIssueRights(ctx *context.Context, issue *models.Issue) { + var userID int64 + if ctx.IsSigned { + userID = ctx.Doer.ID + } if issue.IsPull && !ctx.Repo.CanRead(unit.TypePullRequests) || - !issue.IsPull && !ctx.Repo.CanRead(unit.TypeIssues) { + (!issue.IsPull && !ctx.Repo.CanRead(unit.TypeIssues)) || + !(issue.CanSeeIssue(userID, &ctx.Repo.Permission)) { ctx.NotFound("IssueOrPullRequestUnitNotAllowed", nil) } } @@ -1761,6 +1799,14 @@ func getActionIssues(ctx *context.Context) []*models.Issue { ctx.NotFound("IssueOrPullRequestUnitNotAllowed", nil) return nil } + var userID int64 + if ctx.IsSigned { + userID = ctx.Doer.ID + } + if !issue.CanSeeIssue(userID, &ctx.Repo.Permission) { + ctx.NotFound("IsPrivate", nil) + return nil + } if err = issue.LoadAttributes(); err != nil { ctx.ServerError("LoadAttributes", err) return nil @@ -1811,6 +1857,28 @@ func UpdateIssueTitle(ctx *context.Context) { }) } +// IssuePrivate sets the issue confidential +func IssuePrivate(ctx *context.Context) { + issue := GetActionIssue(ctx) + if ctx.Written() { + return + } + + if !ctx.IsSigned || (!issue.IsPoster(ctx.Doer.ID) && !ctx.Repo.IsAdmin()) { + ctx.Error(http.StatusForbidden) + return + } + + isConfidential := ctx.FormBool("is_confidential") + + if err := issue_service.ChangePrivate(issue, ctx.Doer, isConfidential); err != nil { + ctx.ServerError("ChangePrivate", err) + return + } + + ctx.Redirect(issue.HTMLURL(), http.StatusSeeOther) +} + // UpdateIssueRef change issue's ref (branch) func UpdateIssueRef(ctx *context.Context) { issue := GetActionIssue(ctx) @@ -2488,7 +2556,7 @@ func NewComment(ctx *context.Context) { return } - if !ctx.IsSigned || (ctx.Doer.ID != issue.PosterID && !ctx.Repo.CanReadIssuesOrPulls(issue.IsPull)) { + if !ctx.IsSigned || (ctx.Doer.ID != issue.PosterID && !ctx.Repo.CanReadIssuesOrPulls(issue.IsPull)) || !issue.CanSeeIssue(ctx.Doer.ID, &ctx.Repo.Permission) { if log.IsTrace() { if ctx.IsSigned { issueType := "issues" diff --git a/routers/web/repo/issue_dependency.go b/routers/web/repo/issue_dependency.go index ec713238c67b4..621ba89defe4f 100644 --- a/routers/web/repo/issue_dependency.go +++ b/routers/web/repo/issue_dependency.go @@ -17,7 +17,25 @@ func AddDependency(ctx *context.Context) { issueIndex := ctx.ParamsInt64("index") issue, err := models.GetIssueByIndex(ctx.Repo.Repository.ID, issueIndex) if err != nil { - ctx.ServerError("GetIssueByIndex", err) + if models.IsErrIssueNotExist(err) { + ctx.NotFound("GetIssueByIndex", err) + } else { + ctx.ServerError("GetIssueByIndex", err) + } + return + } + + var userID int64 + if ctx.IsSigned { + userID = ctx.Doer.ID + } + if !issue.CanSeeIssue(userID, &ctx.Repo.Permission) { + ctx.NotFound("CanSeePrivateIssues", models.ErrCannotSeePrivateIssue{ + UserID: ctx.Doer.ID, + ID: issue.ID, + RepoID: ctx.Repo.Repository.ID, + Index: ctx.ParamsInt64("index"), + }) return } @@ -44,6 +62,16 @@ func AddDependency(ctx *context.Context) { return } + if !canDepBeLoaded(issue, dep, ctx) { + ctx.NotFound("CanSeePrivateIssues", models.ErrCannotSeePrivateIssue{ + UserID: ctx.Doer.ID, + ID: dep.ID, + RepoID: dep.Repo.ID, + Index: ctx.ParamsInt64("index"), + }) + return + } + // Check if both issues are in the same repo if cross repository dependencies is not enabled if issue.RepoID != dep.RepoID && !setting.Service.AllowCrossRepositoryDependencies { ctx.Flash.Error(ctx.Tr("repo.issues.dependency.add_error_dep_not_same_repo")) @@ -76,7 +104,25 @@ func RemoveDependency(ctx *context.Context) { issueIndex := ctx.ParamsInt64("index") issue, err := models.GetIssueByIndex(ctx.Repo.Repository.ID, issueIndex) if err != nil { - ctx.ServerError("GetIssueByIndex", err) + if models.IsErrIssueNotExist(err) { + ctx.NotFound("GetIssueByIndex", err) + } else { + ctx.ServerError("GetIssueByIndex", err) + } + return + } + + var userID int64 + if ctx.IsSigned { + userID = ctx.Doer.ID + } + if !issue.CanSeeIssue(userID, &ctx.Repo.Permission) { + ctx.NotFound("CanSeePrivateIssues", models.ErrCannotSeePrivateIssue{ + UserID: userID, + ID: issue.ID, + RepoID: ctx.Repo.Repository.ID, + Index: ctx.ParamsInt64(":index"), + }) return } @@ -115,6 +161,16 @@ func RemoveDependency(ctx *context.Context) { return } + if !canDepBeLoaded(issue, dep, ctx) { + ctx.NotFound("CanSeePrivateIssues", models.ErrCannotSeePrivateIssue{ + UserID: ctx.Doer.ID, + ID: dep.ID, + RepoID: dep.Repo.ID, + Index: ctx.ParamsInt64("index"), + }) + return + } + if err = models.RemoveIssueDependency(ctx.Doer, issue, dep, depType); err != nil { if models.IsErrDependencyNotExists(err) { ctx.Flash.Error(ctx.Tr("repo.issues.dependency.add_error_dep_not_exist")) @@ -127,3 +183,33 @@ func RemoveDependency(ctx *context.Context) { // Redirect ctx.Redirect(issue.HTMLURL()) } + +func canDepBeLoaded(issue, dep *models.Issue, ctx *context.Context) bool { + if issue.RepoID == dep.RepoID { + var userID int64 + if ctx.IsSigned { + userID = ctx.Doer.ID + } + if !dep.CanSeeIssue(userID, &ctx.Repo.Permission) { + return false + } + } else { + if err := dep.LoadRepo(ctx); err != nil { + ctx.ServerError("LoadRepo", err) + } + + perm, err := models.GetUserRepoPermission(ctx, dep.Repo, ctx.Doer) + if err != nil { + ctx.ServerError("GetUserRepoPermission", err) + } + + var userID int64 + if ctx.IsSigned { + userID = ctx.Doer.ID + } + if !dep.CanSeeIssue(userID, &perm) { + return false + } + } + return true +} diff --git a/routers/web/repo/issue_stopwatch.go b/routers/web/repo/issue_stopwatch.go index 83e4ecedbf2e2..0396eda498821 100644 --- a/routers/web/repo/issue_stopwatch.go +++ b/routers/web/repo/issue_stopwatch.go @@ -107,6 +107,16 @@ func GetActiveStopwatch(ctx *context.Context) { return } + perm, err := models.GetUserRepoPermission(ctx, issue.Repo, ctx.Doer) + if err != nil { + ctx.ServerError("GetUserRepoPermission", err) + return + } + + if !issue.CanSeeIssue(ctx.Doer.ID, &perm) { + return + } + ctx.Data["ActiveStopwatch"] = StopwatchTmplInfo{ issue.Link(), issue.Repo.FullName(), diff --git a/routers/web/repo/projects.go b/routers/web/repo/projects.go index a6f843d848fab..018e42ca8bf5d 100644 --- a/routers/web/repo/projects.go +++ b/routers/web/repo/projects.go @@ -112,11 +112,18 @@ func Projects(ctx *context.Context) { pager.AddParam(ctx, "state", "State") ctx.Data["Page"] = pager - ctx.Data["CanWriteProjects"] = ctx.Repo.Permission.CanWrite(unit.TypeProjects) + ctx.Data["CanWriteProjects"] = ctx.Repo.CanWrite(unit.TypeProjects) + ctx.Data["CanSeePrivateIssues"] = ctx.Repo.CanReadPrivateIssues() ctx.Data["IsShowClosed"] = isShowClosed ctx.Data["IsProjectsPage"] = true ctx.Data["SortType"] = sortType + var userID int64 + if ctx.IsSigned { + userID = ctx.Doer.ID + } + ctx.Data["UserID"] = userID + ctx.HTML(http.StatusOK, tplProjects) } @@ -124,7 +131,7 @@ func Projects(ctx *context.Context) { func NewProject(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("repo.projects.new") ctx.Data["ProjectTypes"] = project_model.GetProjectsConfig() - ctx.Data["CanWriteProjects"] = ctx.Repo.Permission.CanWrite(unit.TypeProjects) + ctx.Data["CanWriteProjects"] = ctx.Repo.CanWrite(unit.TypeProjects) ctx.HTML(http.StatusOK, tplProjectsNew) } @@ -296,7 +303,20 @@ func ViewProject(ctx *context.Context) { boards[0].Title = ctx.Tr("repo.projects.type.uncategorized") } - issuesMap, err := models.LoadIssuesFromBoardList(boards) + var userID int64 + if ctx.IsSigned { + userID = ctx.Doer.ID + } + + ctx.Data["UserID"] = userID + canSeePrivateIssues := ctx.Repo.CanReadPrivateIssues() + + loadIssueOpt := &models.LoadIssuesOpts{ + UserID: userID, + CanSeePrivateIssues: canSeePrivateIssues, + } + + issuesMap, err := models.LoadIssuesFromBoardList(boards, loadIssueOpt) if err != nil { ctx.ServerError("LoadIssuesOfBoards", err) return @@ -336,7 +356,8 @@ func ViewProject(ctx *context.Context) { } ctx.Data["IsProjectsPage"] = true - ctx.Data["CanWriteProjects"] = ctx.Repo.Permission.CanWrite(unit.TypeProjects) + ctx.Data["CanWriteProjects"] = ctx.Repo.CanWrite(unit.TypeProjects) + ctx.Data["CanSeePrivateIssues"] = canSeePrivateIssues ctx.Data["Project"] = project ctx.Data["IssuesMap"] = issuesMap ctx.Data["Boards"] = boards diff --git a/routers/web/repo/pull.go b/routers/web/repo/pull.go index 99faa5413828a..487594edb4c5b 100644 --- a/routers/web/repo/pull.go +++ b/routers/web/repo/pull.go @@ -262,6 +262,21 @@ func checkPullInfo(ctx *context.Context) *models.Issue { } return nil } + + var userID int64 + if ctx.IsSigned { + userID = ctx.Doer.ID + } + if !issue.CanSeeIssue(userID, &ctx.Repo.Permission) { + ctx.NotFound("CanSeePrivateIssues", models.ErrCannotSeePrivateIssue{ + UserID: userID, + ID: issue.ID, + RepoID: ctx.Repo.Repository.ID, + Index: ctx.ParamsInt64(":index"), + }) + return nil + } + if err = issue.LoadPoster(); err != nil { ctx.ServerError("LoadPoster", err) return nil diff --git a/routers/web/web.go b/routers/web/web.go index 22b8e7cdf3abc..c525d3b4103a1 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -845,6 +845,7 @@ func RegisterRoutes(m *web.Route) { m.Post("/content", repo.UpdateIssueContent) m.Post("/deadline", bindIgnErr(structs.EditDeadlineOption{}), repo.UpdateIssueDeadline) m.Post("/watch", repo.IssueWatch) + m.Post("/private", repo.IssuePrivate) m.Post("/ref", repo.UpdateIssueRef) m.Group("/dependency", func() { m.Post("/add", repo.AddDependency) diff --git a/services/forms/org.go b/services/forms/org.go index dec2dbfa6555a..63125d50823be 100644 --- a/services/forms/org.go +++ b/services/forms/org.go @@ -62,11 +62,12 @@ func (f *UpdateOrgSettingForm) Validate(req *http.Request, errs binding.Errors) // CreateTeamForm form for creating team type CreateTeamForm struct { - TeamName string `binding:"Required;AlphaDashDot;MaxSize(30)"` - Description string `binding:"MaxSize(255)"` - Permission string - RepoAccess string - CanCreateOrgRepo bool + TeamName string `binding:"Required;AlphaDashDot;MaxSize(30)"` + Description string `binding:"MaxSize(255)"` + Permission string + RepoAccess string + CanCreateOrgRepo bool + CanSeePrivateIssues bool } // Validate validates the fields diff --git a/services/forms/repo_form.go b/services/forms/repo_form.go index f40e99a0440a5..ba89e933c15d9 100644 --- a/services/forms/repo_form.go +++ b/services/forms/repo_form.go @@ -432,6 +432,7 @@ type CreateIssueForm struct { Content string Files []string AllowMaintainerEdit bool + IsConfidential bool `form:"is_confidential"` } // Validate validates the fields diff --git a/services/issue/commit.go b/services/issue/commit.go index b5d97e12a80f3..e79e4fc4d733d 100644 --- a/services/issue/commit.go +++ b/services/issue/commit.go @@ -127,6 +127,7 @@ func UpdateIssuesCommit(doer *user_model.User, repo *repo_model.Repository, comm if refIssue, err = getIssueFromRef(refRepo, ref.Index); err != nil { return err } + if refIssue == nil { continue } @@ -136,6 +137,10 @@ func UpdateIssuesCommit(doer *user_model.User, repo *repo_model.Repository, comm return err } + if !refIssue.CanSeeIssue(doer.ID, &perm) { + continue + } + key := markKey{ID: refIssue.ID, Action: ref.Action} if refMarked[key] { continue diff --git a/services/issue/issue.go b/services/issue/issue.go index 6bc39599793d8..f928d00381f73 100644 --- a/services/issue/issue.go +++ b/services/issue/issue.go @@ -58,6 +58,41 @@ func ChangeTitle(issue *models.Issue, doer *user_model.User, title string) (err return nil } +// ChangePrivate changes the private flag of the issue, as the given user. +func ChangePrivate(issue *models.Issue, doer *user_model.User, isConfidential bool) (err error) { + if doer == nil { + return models.ErrCannotSeePrivateIssue{ + UserID: -1, + ID: issue.ID, + RepoID: issue.RepoID, + Index: issue.Index, + } + } + + isUserAdmin, err := models.IsUserRealRepoAdmin(issue.Repo, doer) + if err != nil { + return + } + if !issue.IsPoster(doer.ID) && !isUserAdmin { + return models.ErrCannotSeePrivateIssue{ + UserID: -1, + ID: issue.ID, + RepoID: issue.RepoID, + Index: issue.Index, + } + } + + issue.IsPrivate = isConfidential + + if err = issue.ChangePrivate(doer, isConfidential); err != nil { + return + } + + // TODO: add a notification (possible)? + + return nil +} + // ChangeIssueRef changes the branch of this issue, as the given user. func ChangeIssueRef(issue *models.Issue, doer *user_model.User, ref string) error { oldRef := issue.Ref diff --git a/services/mailer/mail_issue.go b/services/mailer/mail_issue.go index c24edf50c93a7..91980b53bc7fe 100644 --- a/services/mailer/mail_issue.go +++ b/services/mailer/mail_issue.go @@ -118,12 +118,11 @@ func mailIssueCommentToParticipants(ctx *mailCommentContext, mentions []*user_mo } func mailIssueCommentBatch(ctx *mailCommentContext, users []*user_model.User, visited map[int64]bool, fromMention bool) error { + langMap := make(map[string][]*user_model.User) checkUnit := unit.TypeIssues if ctx.Issue.IsPull { checkUnit = unit.TypePullRequests } - - langMap := make(map[string][]*user_model.User) for _, user := range users { if !user.IsActive { // Exclude deactivated users @@ -145,7 +144,13 @@ func mailIssueCommentBatch(ctx *mailCommentContext, users []*user_model.User, vi visited[user.ID] = true // test if this user is allowed to see the issue/pull - if !models.CheckRepoUnitUser(ctx.Issue.Repo, user, checkUnit) { + perm, err := models.GetUserRepoPermission(ctx, ctx.Issue.Repo, user) + if err != nil { + log.Error("getUserRepoPermission(): %v", err) + return err + } + + if !models.CheckRepoUnitUser(ctx.Issue.Repo, user, checkUnit) || !ctx.Issue.CanSeeIssue(user.ID, &perm) { continue } diff --git a/templates/org/team/new.tmpl b/templates/org/team/new.tmpl index 2531868ec8ca5..e57561479b9d6 100644 --- a/templates/org/team/new.tmpl +++ b/templates/org/team/new.tmpl @@ -68,6 +68,13 @@ {{.i18n.Tr "org.teams.admin_access_helper"}} +
+
+ + + {{.i18n.Tr "org.teams.can_see_private_issues_helper"}} +
+
diff --git a/templates/repo/header.tmpl b/templates/repo/header.tmpl index 624eb17a5e1d2..e32a521902091 100644 --- a/templates/repo/header.tmpl +++ b/templates/repo/header.tmpl @@ -157,8 +157,12 @@ {{if .Permission.CanRead $.UnitTypeIssues}} {{svg "octicon-issue-opened"}} {{.i18n.Tr "repo.issues"}} - {{if .Repository.NumOpenIssues}} - {{CountFmt .Repository.NumOpenIssues}} + {{if or .Repository.NumOpenIssues .Repository.NumOpenPrivateIssues}} + {{if and .Permission.CanReadPrivateIssues .Repository.NumOpenPrivateIssues}} + {{CountFmt (Add .Repository.NumOpenPrivateIssues .Repository.NumOpenIssues)}} + {{else if .Repository.NumOpenIssues}} + {{CountFmt .Repository.NumOpenIssues}} + {{end}} {{end}} {{end}} diff --git a/templates/repo/issue/new_form.tmpl b/templates/repo/issue/new_form.tmpl index 9a4548643fb99..7389629b55d34 100644 --- a/templates/repo/issue/new_form.tmpl +++ b/templates/repo/issue/new_form.tmpl @@ -235,6 +235,16 @@ {{end}} + {{if not .PageIsComparePull}} +
+
+ + +
+ {{end}} {{if and .PageIsComparePull (not (eq .HeadRepo.FullName .BaseCompareRepo.FullName)) .CanWriteToHeadRepo}}
diff --git a/templates/repo/issue/view_content.tmpl b/templates/repo/issue/view_content.tmpl index b5f838e9d0aa4..26f211805dc3f 100644 --- a/templates/repo/issue/view_content.tmpl +++ b/templates/repo/issue/view_content.tmpl @@ -105,6 +105,11 @@
+ {{if .Issue.IsPrivate}} +
+ {{.i18n.Tr "repo.issues.confidential.warning"}} +
+ {{end}} {{template "repo/issue/comment_tab" .}} {{.CsrfTokenHtml}} @@ -156,6 +161,11 @@
+ {{if .Issue.IsPrivate}} +
+ {{.i18n.Tr "repo.issues.confidential.warning"}} +
+ {{end}} {{template "repo/issue/comment_tab" .}} {{.CsrfTokenHtml}} diff --git a/templates/repo/issue/view_content/sidebar.tmpl b/templates/repo/issue/view_content/sidebar.tmpl index 9e9bc670a0d7a..6d118345e7f5b 100644 --- a/templates/repo/issue/view_content/sidebar.tmpl +++ b/templates/repo/issue/view_content/sidebar.tmpl @@ -302,6 +302,28 @@
+ {{if and (or (.IsRepoAdmin) (.IsIssuePoster)) (not .Issue.IsPull)}} +
+
+ {{.i18n.Tr "repo.issues.confidential"}} +
+ + + {{$.CsrfTokenHtml}} + + +
+
+ {{end}}
{{if .Participants}} diff --git a/templates/repo/issue/view_title.tmpl b/templates/repo/issue/view_title.tmpl index 707b8252f4652..c349fd3ebb884 100644 --- a/templates/repo/issue/view_title.tmpl +++ b/templates/repo/issue/view_title.tmpl @@ -101,5 +101,12 @@ {{$.i18n.Tr "repo.issues.num_comments" .Issue.NumComments}} {{end}} + {{if .Issue.IsPrivate }} + ยท +
+ {{svg "octicon-eye-closed" 16 "mr-2"}} + {{$.i18n.Tr "repo.issues.confidential.title_bar"}} +
+ {{end}}
diff --git a/templates/repo/projects/list.tmpl b/templates/repo/projects/list.tmpl index a8c51d668e247..4a1d5823bd091 100644 --- a/templates/repo/projects/list.tmpl +++ b/templates/repo/projects/list.tmpl @@ -47,8 +47,16 @@ {{svg "octicon-clock"}} {{$.i18n.Tr "repo.milestones.closed" $closedDate|Str2html}} {{end}} - {{svg "octicon-issue-opened"}} {{$.i18n.Tr "repo.issues.open_tab" .NumOpenIssues}} - {{svg "octicon-issue-closed"}} {{$.i18n.Tr "repo.issues.close_tab" .NumClosedIssues}} + {{if $.CanSeePrivateIssues}} + {{svg "octicon-issue-opened"}} {{$.i18n.Tr "repo.issues.open_tab" (Add .NumOpenIssues .NumOpenPrivateIssues)}} + {{svg "octicon-issue-closed"}} {{$.i18n.Tr "repo.issues.close_tab" (Add .NumClosedIssues .NumClosedPrivateIssues)}} + {{else if (gt $.UserID 0)}} + {{svg "octicon-issue-opened"}} {{$.i18n.Tr "repo.issues.open_tab" (Add .NumOpenIssues (.NumOpenPrivateOwnIssues $.UserID))}} + {{svg "octicon-issue-closed"}} {{$.i18n.Tr "repo.issues.close_tab" (Add .NumClosedIssues (.NumClosedPrivateOwnIssues $.UserID))}} + {{else}} + {{svg "octicon-issue-opened"}} {{$.i18n.Tr "repo.issues.open_tab" .NumOpenIssues}} + {{svg "octicon-issue-closed"}} {{$.i18n.Tr "repo.issues.close_tab" .NumClosedIssues}} + {{end}} {{if and (or $.CanWriteIssues $.CanWritePulls) (not $.Repository.IsArchived)}} diff --git a/templates/repo/projects/view.tmpl b/templates/repo/projects/view.tmpl index 7d4d21f8fea45..33f99305e8c33 100644 --- a/templates/repo/projects/view.tmpl +++ b/templates/repo/projects/view.tmpl @@ -84,7 +84,13 @@
- {{.NumIssues}} + {{if $.CanSeePrivateIssues}} + {{Add .NumIssues .NumPrivateIssues}} + {{else if (gt $.UserID 0)}} + {{Add .NumIssues (.NumPrivateOwnIssues $.UserID)}} + {{else}} + {{.NumIssues}} + {{end}}
{{.Title}}
diff --git a/templates/shared/issuelist.tmpl b/templates/shared/issuelist.tmpl index e81855851bf06..82426e629cc61 100644 --- a/templates/shared/issuelist.tmpl +++ b/templates/shared/issuelist.tmpl @@ -21,7 +21,13 @@ {{end}} {{end}} {{else}} - {{if .IsClosed}} + {{if .IsPrivate}} + {{if .IsClosed}} + {{svg "octicon-eye-closed" 16 "text red"}} + {{else}} + {{svg "octicon-eye-closed" 16 "text green"}} + {{end}} + {{else if .IsClosed}} {{svg "octicon-issue-closed" 16 "text red"}} {{else}} {{svg "octicon-issue-opened" 16 "text green"}} diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index 0b7d1d74c2216..87f698f4b2334 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -14000,6 +14000,11 @@ "format": "date-time", "x-go-name": "Deadline" }, + "is_confidential": { + "description": "mark if the issue is confidential", + "type": "boolean", + "x-go-name": "IsConfidential" + }, "labels": { "description": "list of label ids", "type": "array", diff --git a/web_src/less/_repository.less b/web_src/less/_repository.less index 13f9384ba0635..cb18a6dd62f2e 100644 --- a/web_src/less/_repository.less +++ b/web_src/less/_repository.less @@ -739,6 +739,10 @@ } } + .confidential_box { + max-width: 125px; + } + .pull { &.tabular.menu { margin-bottom: 1rem;