mirror of
https://github.com/go-gitea/gitea
synced 2025-12-07 13:28:25 +00:00
Merge branch 'main' into lunny/automerge_support_delete_branch
This commit is contained in:
@@ -7,11 +7,11 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"code.gitea.io/gitea/models"
|
||||
"code.gitea.io/gitea/models/organization"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/container"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
org_service "code.gitea.io/gitea/services/org"
|
||||
)
|
||||
|
||||
type syncType int
|
||||
@@ -100,12 +100,12 @@ func syncGroupsToTeamsCached(ctx context.Context, user *user_model.User, orgTeam
|
||||
}
|
||||
|
||||
if action == syncAdd && !isMember {
|
||||
if err := models.AddTeamMember(ctx, team, user); err != nil {
|
||||
if err := org_service.AddTeamMember(ctx, team, user); err != nil {
|
||||
log.Error("group sync: Could not add user to team: %v", err)
|
||||
return err
|
||||
}
|
||||
} else if action == syncRemove && isMember {
|
||||
if err := models.RemoveTeamMember(ctx, team, user); err != nil {
|
||||
if err := org_service.RemoveTeamMember(ctx, team, user); err != nil {
|
||||
log.Error("group sync: Could not remove user from team: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -259,9 +259,7 @@ func HandleOrgAssignment(ctx *Context, args ...bool) {
|
||||
|
||||
ctx.Data["IsFollowing"] = ctx.Doer != nil && user_model.IsFollowing(ctx, ctx.Doer.ID, ctx.ContextUser.ID)
|
||||
if len(ctx.ContextUser.Description) != 0 {
|
||||
content, err := markdown.RenderString(&markup.RenderContext{
|
||||
Ctx: ctx,
|
||||
}, ctx.ContextUser.Description)
|
||||
content, err := markdown.RenderString(markup.NewRenderContext(ctx), ctx.ContextUser.Description)
|
||||
if err != nil {
|
||||
ctx.ServerError("RenderString", err)
|
||||
return
|
||||
|
||||
@@ -158,6 +158,7 @@ func ToBranchProtection(ctx context.Context, bp *git_model.ProtectedBranch, repo
|
||||
return &api.BranchProtection{
|
||||
BranchName: branchName,
|
||||
RuleName: bp.RuleName,
|
||||
Priority: bp.Priority,
|
||||
EnablePush: bp.CanPush,
|
||||
EnablePushWhitelist: bp.EnableWhitelist,
|
||||
PushWhitelistUsernames: pushWhitelistUsernames,
|
||||
|
||||
@@ -76,6 +76,11 @@ func ToTimelineComment(ctx context.Context, repo *repo_model.Repository, c *issu
|
||||
// so we check for the "|" delimiter and convert new to legacy format on demand
|
||||
c.Content = util.SecToTime(c.Content[1:])
|
||||
}
|
||||
|
||||
if c.Type == issues_model.CommentTypeChangeTimeEstimate {
|
||||
timeSec, _ := util.ToInt64(c.Content)
|
||||
c.Content = util.TimeEstimateString(timeSec)
|
||||
}
|
||||
}
|
||||
|
||||
comment := &api.TimelineComment{
|
||||
|
||||
@@ -6,11 +6,11 @@ package doctor
|
||||
import (
|
||||
"context"
|
||||
|
||||
"code.gitea.io/gitea/models"
|
||||
"code.gitea.io/gitea/models/db"
|
||||
org_model "code.gitea.io/gitea/models/organization"
|
||||
"code.gitea.io/gitea/models/perm"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
org_service "code.gitea.io/gitea/services/org"
|
||||
|
||||
"xorm.io/builder"
|
||||
)
|
||||
@@ -29,7 +29,7 @@ func fixOwnerTeamCreateOrgRepo(ctx context.Context, logger log.Logger, autofix b
|
||||
return nil
|
||||
}
|
||||
|
||||
return models.UpdateTeam(ctx, team, false, false)
|
||||
return org_service.UpdateTeam(ctx, team, false, false)
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package feed
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
activities_model "code.gitea.io/gitea/models/activities"
|
||||
)
|
||||
|
||||
// GetFeeds returns actions according to the provided options
|
||||
func GetFeeds(ctx context.Context, opts activities_model.GetFeedsOptions) (activities_model.ActionList, int64, error) {
|
||||
return activities_model.GetFeeds(ctx, opts)
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package feed
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
activities_model "code.gitea.io/gitea/models/activities"
|
||||
"code.gitea.io/gitea/models/db"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
"code.gitea.io/gitea/models/unittest"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestGetFeeds(t *testing.T) {
|
||||
// test with an individual user
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
|
||||
actions, count, err := GetFeeds(db.DefaultContext, activities_model.GetFeedsOptions{
|
||||
RequestedUser: user,
|
||||
Actor: user,
|
||||
IncludePrivate: true,
|
||||
OnlyPerformedBy: false,
|
||||
IncludeDeleted: true,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
if assert.Len(t, actions, 1) {
|
||||
assert.EqualValues(t, 1, actions[0].ID)
|
||||
assert.EqualValues(t, user.ID, actions[0].UserID)
|
||||
}
|
||||
assert.Equal(t, int64(1), count)
|
||||
|
||||
actions, count, err = GetFeeds(db.DefaultContext, activities_model.GetFeedsOptions{
|
||||
RequestedUser: user,
|
||||
Actor: user,
|
||||
IncludePrivate: false,
|
||||
OnlyPerformedBy: false,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, actions, 0)
|
||||
assert.Equal(t, int64(0), count)
|
||||
}
|
||||
|
||||
func TestGetFeedsForRepos(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
privRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2})
|
||||
pubRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 8})
|
||||
|
||||
// private repo & no login
|
||||
actions, count, err := GetFeeds(db.DefaultContext, activities_model.GetFeedsOptions{
|
||||
RequestedRepo: privRepo,
|
||||
IncludePrivate: true,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, actions, 0)
|
||||
assert.Equal(t, int64(0), count)
|
||||
|
||||
// public repo & no login
|
||||
actions, count, err = GetFeeds(db.DefaultContext, activities_model.GetFeedsOptions{
|
||||
RequestedRepo: pubRepo,
|
||||
IncludePrivate: true,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, actions, 1)
|
||||
assert.Equal(t, int64(1), count)
|
||||
|
||||
// private repo and login
|
||||
actions, count, err = GetFeeds(db.DefaultContext, activities_model.GetFeedsOptions{
|
||||
RequestedRepo: privRepo,
|
||||
IncludePrivate: true,
|
||||
Actor: user,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, actions, 1)
|
||||
assert.Equal(t, int64(1), count)
|
||||
|
||||
// public repo & login
|
||||
actions, count, err = GetFeeds(db.DefaultContext, activities_model.GetFeedsOptions{
|
||||
RequestedRepo: pubRepo,
|
||||
IncludePrivate: true,
|
||||
Actor: user,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, actions, 1)
|
||||
assert.Equal(t, int64(1), count)
|
||||
}
|
||||
|
||||
func TestGetFeeds2(t *testing.T) {
|
||||
// test with an organization user
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
org := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3})
|
||||
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
|
||||
actions, count, err := GetFeeds(db.DefaultContext, activities_model.GetFeedsOptions{
|
||||
RequestedUser: org,
|
||||
Actor: user,
|
||||
IncludePrivate: true,
|
||||
OnlyPerformedBy: false,
|
||||
IncludeDeleted: true,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, actions, 1)
|
||||
if assert.Len(t, actions, 1) {
|
||||
assert.EqualValues(t, 2, actions[0].ID)
|
||||
assert.EqualValues(t, org.ID, actions[0].UserID)
|
||||
}
|
||||
assert.Equal(t, int64(1), count)
|
||||
|
||||
actions, count, err = GetFeeds(db.DefaultContext, activities_model.GetFeedsOptions{
|
||||
RequestedUser: org,
|
||||
Actor: user,
|
||||
IncludePrivate: false,
|
||||
OnlyPerformedBy: false,
|
||||
IncludeDeleted: true,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, actions, 0)
|
||||
assert.Equal(t, int64(0), count)
|
||||
}
|
||||
|
||||
func TestGetFeedsCorrupted(t *testing.T) {
|
||||
// Now we will not check for corrupted data in the feeds
|
||||
// users should run doctor to fix their data
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
|
||||
unittest.AssertExistsAndLoadBean(t, &activities_model.Action{
|
||||
ID: 8,
|
||||
RepoID: 1700,
|
||||
})
|
||||
|
||||
actions, count, err := GetFeeds(db.DefaultContext, activities_model.GetFeedsOptions{
|
||||
RequestedUser: user,
|
||||
Actor: user,
|
||||
IncludePrivate: true,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, actions, 1)
|
||||
assert.Equal(t, int64(1), count)
|
||||
}
|
||||
|
||||
func TestRepoActions(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
||||
_ = db.TruncateBeans(db.DefaultContext, &activities_model.Action{})
|
||||
for i := 0; i < 3; i++ {
|
||||
_ = db.Insert(db.DefaultContext, &activities_model.Action{
|
||||
UserID: 2 + int64(i),
|
||||
ActUserID: 2,
|
||||
RepoID: repo.ID,
|
||||
OpType: activities_model.ActionCommentIssue,
|
||||
})
|
||||
}
|
||||
count, _ := db.Count[activities_model.Action](db.DefaultContext, &db.ListOptions{})
|
||||
assert.EqualValues(t, 3, count)
|
||||
actions, _, err := GetFeeds(db.DefaultContext, activities_model.GetFeedsOptions{
|
||||
RequestedRepo: repo,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, actions, 1)
|
||||
}
|
||||
@@ -390,6 +390,12 @@ func (a *actionNotifier) DeleteRef(ctx context.Context, doer *user_model.User, r
|
||||
}
|
||||
|
||||
func (a *actionNotifier) SyncPushCommits(ctx context.Context, pusher *user_model.User, repo *repo_model.Repository, opts *repository.PushUpdateOptions, commits *repository.PushCommits) {
|
||||
// ignore pull sync message for pull requests refs
|
||||
// TODO: it's better to have a UI to let users chose
|
||||
if opts.RefFullName.IsPull() {
|
||||
return
|
||||
}
|
||||
|
||||
data, err := json.Marshal(commits)
|
||||
if err != nil {
|
||||
log.Error("json.Marshal: %v", err)
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
type PackageCleanupRuleForm struct {
|
||||
ID int64
|
||||
Enabled bool
|
||||
Type string `binding:"Required;In(alpine,cargo,chef,composer,conan,conda,container,cran,debian,generic,go,helm,maven,npm,nuget,pub,pypi,rpm,rubygems,swift,vagrant)"`
|
||||
Type string `binding:"Required;In(alpine,arch,cargo,chef,composer,conan,conda,container,cran,debian,generic,go,helm,maven,npm,nuget,pub,pypi,rpm,rubygems,swift,vagrant)"`
|
||||
KeepCount int `binding:"In(0,1,5,10,25,50,100)"`
|
||||
KeepPattern string `binding:"RegexPattern"`
|
||||
RemoveDays int `binding:"In(0,7,14,30,60,90,180)"`
|
||||
|
||||
@@ -228,6 +228,10 @@ func (f *ProtectBranchForm) Validate(req *http.Request, errs binding.Errors) bin
|
||||
return middleware.Validate(errs, ctx.Data, f, ctx.Locale)
|
||||
}
|
||||
|
||||
type ProtectBranchPriorityForm struct {
|
||||
IDs []int64
|
||||
}
|
||||
|
||||
// __ __ ___. .__ __
|
||||
// / \ / \ ____\_ |__ | |__ ____ ____ | | __
|
||||
// \ \/\/ // __ \| __ \| | \ / _ \ / _ \| |/ /
|
||||
|
||||
@@ -366,7 +366,7 @@ func (f *NewAccessTokenForm) GetScope() (auth_model.AccessTokenScope, error) {
|
||||
// EditOAuth2ApplicationForm form for editing oauth2 applications
|
||||
type EditOAuth2ApplicationForm struct {
|
||||
Name string `binding:"Required;MaxSize(255)" form:"application_name"`
|
||||
RedirectURIs string `binding:"Required" form:"redirect_uris"`
|
||||
RedirectURIs string `binding:"Required;ValidUrlList" form:"redirect_uris"`
|
||||
ConfidentialClient bool `form:"confidential_client"`
|
||||
SkipSecondaryAuthorization bool `form:"skip_secondary_authorization"`
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ var hiddenCommentTypeGroups = hiddenCommentTypeGroupsType{
|
||||
/*14*/ issues_model.CommentTypeAddTimeManual,
|
||||
/*15*/ issues_model.CommentTypeCancelTracking,
|
||||
/*26*/ issues_model.CommentTypeDeleteTimeManual,
|
||||
/*38*/ issues_model.CommentTypeChangeTimeEstimate,
|
||||
},
|
||||
"deadline": {
|
||||
/*16*/ issues_model.CommentTypeAddedDeadline,
|
||||
|
||||
@@ -119,7 +119,7 @@ func isValidReviewRequest(ctx context.Context, reviewer, doer *user_model.User,
|
||||
return err
|
||||
}
|
||||
|
||||
canDoerChangeReviewRequests := CanDoerChangeReviewRequests(ctx, doer, issue.Repo, issue)
|
||||
canDoerChangeReviewRequests := CanDoerChangeReviewRequests(ctx, doer, issue.Repo, issue.PosterID)
|
||||
|
||||
if isAdd {
|
||||
if !permReviewer.CanAccessAny(perm.AccessModeRead, unit.TypePullRequests) {
|
||||
@@ -178,7 +178,7 @@ func isValidTeamReviewRequest(ctx context.Context, reviewer *organization.Team,
|
||||
}
|
||||
}
|
||||
|
||||
canDoerChangeReviewRequests := CanDoerChangeReviewRequests(ctx, doer, issue.Repo, issue)
|
||||
canDoerChangeReviewRequests := CanDoerChangeReviewRequests(ctx, doer, issue.Repo, issue.PosterID)
|
||||
|
||||
if isAdd {
|
||||
if issue.Repo.IsPrivate {
|
||||
@@ -276,12 +276,12 @@ func teamReviewRequestNotify(ctx context.Context, issue *issues_model.Issue, doe
|
||||
}
|
||||
|
||||
// CanDoerChangeReviewRequests returns if the doer can add/remove review requests of a PR
|
||||
func CanDoerChangeReviewRequests(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, issue *issues_model.Issue) bool {
|
||||
func CanDoerChangeReviewRequests(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, posterID int64) bool {
|
||||
if repo.IsArchived {
|
||||
return false
|
||||
}
|
||||
// The poster of the PR can change the reviewers
|
||||
if doer.ID == issue.PosterID {
|
||||
if doer.ID == posterID {
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -105,6 +105,13 @@ func ChangeTitle(ctx context.Context, issue *issues_model.Issue, doer *user_mode
|
||||
return nil
|
||||
}
|
||||
|
||||
// ChangeTimeEstimate changes the time estimate of this issue, as the given user.
|
||||
func ChangeTimeEstimate(ctx context.Context, issue *issues_model.Issue, doer *user_model.User, timeEstimate int64) (err error) {
|
||||
issue.TimeEstimate = timeEstimate
|
||||
|
||||
return issues_model.ChangeIssueTimeEstimate(ctx, issue, doer, timeEstimate)
|
||||
}
|
||||
|
||||
// ChangeIssueRef changes the branch of this issue, as the given user.
|
||||
func ChangeIssueRef(ctx context.Context, issue *issues_model.Issue, doer *user_model.User, ref string) error {
|
||||
oldRef := issue.Ref
|
||||
|
||||
+13
-20
@@ -18,20 +18,19 @@ import (
|
||||
|
||||
activities_model "code.gitea.io/gitea/models/activities"
|
||||
issues_model "code.gitea.io/gitea/models/issues"
|
||||
"code.gitea.io/gitea/models/renderhelper"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/base"
|
||||
"code.gitea.io/gitea/modules/emoji"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/markup"
|
||||
"code.gitea.io/gitea/modules/markup/markdown"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/timeutil"
|
||||
"code.gitea.io/gitea/modules/translation"
|
||||
incoming_payload "code.gitea.io/gitea/services/mailer/incoming/payload"
|
||||
sender_service "code.gitea.io/gitea/services/mailer/sender"
|
||||
"code.gitea.io/gitea/services/mailer/token"
|
||||
|
||||
"gopkg.in/gomail.v2"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -60,7 +59,7 @@ func SendTestMail(email string) error {
|
||||
// No mail service configured
|
||||
return nil
|
||||
}
|
||||
return gomail.Send(Sender, NewMessage(email, "Gitea Test Email!", "Gitea Test Email!").ToMessage())
|
||||
return sender_service.Send(sender, sender_service.NewMessage(email, "Gitea Test Email!", "Gitea Test Email!"))
|
||||
}
|
||||
|
||||
// sendUserMail sends a mail to the user
|
||||
@@ -82,7 +81,7 @@ func sendUserMail(language string, u *user_model.User, tpl base.TplName, code, s
|
||||
return
|
||||
}
|
||||
|
||||
msg := NewMessage(u.EmailTo(), subject, content.String())
|
||||
msg := sender_service.NewMessage(u.EmailTo(), subject, content.String())
|
||||
msg.Info = fmt.Sprintf("UID: %d, %s", u.ID, info)
|
||||
|
||||
SendAsync(msg)
|
||||
@@ -130,7 +129,7 @@ func SendActivateEmailMail(u *user_model.User, email string) {
|
||||
return
|
||||
}
|
||||
|
||||
msg := NewMessage(email, locale.TrString("mail.activate_email"), content.String())
|
||||
msg := sender_service.NewMessage(email, locale.TrString("mail.activate_email"), content.String())
|
||||
msg.Info = fmt.Sprintf("UID: %d, activate email", u.ID)
|
||||
|
||||
SendAsync(msg)
|
||||
@@ -158,7 +157,7 @@ func SendRegisterNotifyMail(u *user_model.User) {
|
||||
return
|
||||
}
|
||||
|
||||
msg := NewMessage(u.EmailTo(), locale.TrString("mail.register_notify", setting.AppName), content.String())
|
||||
msg := sender_service.NewMessage(u.EmailTo(), locale.TrString("mail.register_notify", setting.AppName), content.String())
|
||||
msg.Info = fmt.Sprintf("UID: %d, registration notify", u.ID)
|
||||
|
||||
SendAsync(msg)
|
||||
@@ -189,13 +188,13 @@ func SendCollaboratorMail(u, doer *user_model.User, repo *repo_model.Repository)
|
||||
return
|
||||
}
|
||||
|
||||
msg := NewMessage(u.EmailTo(), subject, content.String())
|
||||
msg := sender_service.NewMessage(u.EmailTo(), subject, content.String())
|
||||
msg.Info = fmt.Sprintf("UID: %d, add collaborator", u.ID)
|
||||
|
||||
SendAsync(msg)
|
||||
}
|
||||
|
||||
func composeIssueCommentMessages(ctx *mailCommentContext, lang string, recipients []*user_model.User, fromMention bool, info string) ([]*Message, error) {
|
||||
func composeIssueCommentMessages(ctx *mailCommentContext, lang string, recipients []*user_model.User, fromMention bool, info string) ([]*sender_service.Message, error) {
|
||||
var (
|
||||
subject string
|
||||
link string
|
||||
@@ -219,15 +218,9 @@ func composeIssueCommentMessages(ctx *mailCommentContext, lang string, recipient
|
||||
}
|
||||
|
||||
// This is the body of the new issue or comment, not the mail body
|
||||
body, err := markdown.RenderString(&markup.RenderContext{
|
||||
Ctx: ctx,
|
||||
Repo: ctx.Issue.Repo,
|
||||
Links: markup.Links{
|
||||
AbsolutePrefix: true,
|
||||
Base: ctx.Issue.Repo.HTMLURL(),
|
||||
},
|
||||
Metas: ctx.Issue.Repo.ComposeMetas(ctx),
|
||||
}, ctx.Content)
|
||||
rctx := renderhelper.NewRenderContextRepoComment(ctx.Context, ctx.Issue.Repo).WithUseAbsoluteLink(true)
|
||||
body, err := markdown.RenderString(rctx,
|
||||
ctx.Content)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -310,9 +303,9 @@ func composeIssueCommentMessages(ctx *mailCommentContext, lang string, recipient
|
||||
return nil, err
|
||||
}
|
||||
|
||||
msgs := make([]*Message, 0, len(recipients))
|
||||
msgs := make([]*sender_service.Message, 0, len(recipients))
|
||||
for _, recipient := range recipients {
|
||||
msg := NewMessageFrom(
|
||||
msg := sender_service.NewMessageFrom(
|
||||
recipient.Email,
|
||||
fromDisplayName(ctx.Doer),
|
||||
setting.MailService.FromEmail,
|
||||
|
||||
@@ -7,14 +7,15 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
|
||||
"code.gitea.io/gitea/models/renderhelper"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/base"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/markup"
|
||||
"code.gitea.io/gitea/modules/markup/markdown"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/translation"
|
||||
sender_service "code.gitea.io/gitea/services/mailer/sender"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -56,14 +57,9 @@ func mailNewRelease(ctx context.Context, lang string, tos []*user_model.User, re
|
||||
locale := translation.NewLocale(lang)
|
||||
|
||||
var err error
|
||||
rel.RenderedNote, err = markdown.RenderString(&markup.RenderContext{
|
||||
Ctx: ctx,
|
||||
Repo: rel.Repo,
|
||||
Links: markup.Links{
|
||||
Base: rel.Repo.HTMLURL(),
|
||||
},
|
||||
Metas: rel.Repo.ComposeMetas(ctx),
|
||||
}, rel.Note)
|
||||
rctx := renderhelper.NewRenderContextRepoComment(ctx, rel.Repo).WithUseAbsoluteLink(true)
|
||||
rel.RenderedNote, err = markdown.RenderString(rctx,
|
||||
rel.Note)
|
||||
if err != nil {
|
||||
log.Error("markdown.RenderString(%d): %v", rel.RepoID, err)
|
||||
return
|
||||
@@ -85,11 +81,11 @@ func mailNewRelease(ctx context.Context, lang string, tos []*user_model.User, re
|
||||
return
|
||||
}
|
||||
|
||||
msgs := make([]*Message, 0, len(tos))
|
||||
msgs := make([]*sender_service.Message, 0, len(tos))
|
||||
publisherName := fromDisplayName(rel.Publisher)
|
||||
msgID := generateMessageIDForRelease(rel)
|
||||
for _, to := range tos {
|
||||
msg := NewMessageFrom(to.EmailTo(), publisherName, setting.MailService.FromEmail, subject, mailBody.String())
|
||||
msg := sender_service.NewMessageFrom(to.EmailTo(), publisherName, setting.MailService.FromEmail, subject, mailBody.String())
|
||||
msg.Info = subject
|
||||
msg.SetHeader("Message-ID", msgID)
|
||||
msgs = append(msgs, msg)
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/translation"
|
||||
sender_service "code.gitea.io/gitea/services/mailer/sender"
|
||||
)
|
||||
|
||||
// SendRepoTransferNotifyMail triggers a notification e-mail when a pending repository transfer was created
|
||||
@@ -79,7 +80,7 @@ func sendRepoTransferNotifyMailPerLang(lang string, newOwner, doer *user_model.U
|
||||
}
|
||||
|
||||
for _, to := range emailTos {
|
||||
msg := NewMessageFrom(to.EmailTo(), fromDisplayName(doer), setting.MailService.FromEmail, subject, content.String())
|
||||
msg := sender_service.NewMessageFrom(to.EmailTo(), fromDisplayName(doer), setting.MailService.FromEmail, subject, content.String())
|
||||
msg.Info = fmt.Sprintf("UID: %d, repository pending transfer notification", newOwner.ID)
|
||||
|
||||
SendAsync(msg)
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/translation"
|
||||
sender_service "code.gitea.io/gitea/services/mailer/sender"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -67,7 +68,7 @@ func MailTeamInvite(ctx context.Context, inviter *user_model.User, team *org_mod
|
||||
return err
|
||||
}
|
||||
|
||||
msg := NewMessage(invite.Email, subject, mailBody.String())
|
||||
msg := sender_service.NewMessage(invite.Email, subject, mailBody.String())
|
||||
msg.Info = subject
|
||||
|
||||
SendAsync(msg)
|
||||
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/markup"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
sender_service "code.gitea.io/gitea/services/mailer/sender"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
@@ -70,7 +71,7 @@ func prepareMailerTest(t *testing.T) (doer *user_model.User, repo *repo_model.Re
|
||||
func TestComposeIssueCommentMessage(t *testing.T) {
|
||||
doer, _, issue, comment := prepareMailerTest(t)
|
||||
|
||||
markup.Init(&markup.ProcessorHelper{
|
||||
markup.Init(&markup.RenderHelperFuncs{
|
||||
IsUsernameMentionable: func(ctx context.Context, username string) bool {
|
||||
return username == doer.Name
|
||||
},
|
||||
@@ -92,20 +93,20 @@ func TestComposeIssueCommentMessage(t *testing.T) {
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, msgs, 2)
|
||||
gomailMsg := msgs[0].ToMessage()
|
||||
replyTo := gomailMsg.GetHeader("Reply-To")[0]
|
||||
subject := gomailMsg.GetHeader("Subject")[0]
|
||||
replyTo := gomailMsg.GetGenHeader("Reply-To")[0]
|
||||
subject := gomailMsg.GetGenHeader("Subject")[0]
|
||||
|
||||
assert.Len(t, gomailMsg.GetHeader("To"), 1, "exactly one recipient is expected in the To field")
|
||||
assert.Len(t, gomailMsg.GetAddrHeader("To"), 1, "exactly one recipient is expected in the To field")
|
||||
tokenRegex := regexp.MustCompile(`\Aincoming\+(.+)@localhost\z`)
|
||||
assert.Regexp(t, tokenRegex, replyTo)
|
||||
token := tokenRegex.FindAllStringSubmatch(replyTo, 1)[0][1]
|
||||
assert.Equal(t, "Re: ", subject[:4], "Comment reply subject should contain Re:")
|
||||
assert.Equal(t, "Re: [user2/repo1] @user2 #1 - issue1", subject)
|
||||
assert.Equal(t, "<user2/repo1/issues/1@localhost>", gomailMsg.GetHeader("In-Reply-To")[0], "In-Reply-To header doesn't match")
|
||||
assert.ElementsMatch(t, []string{"<user2/repo1/issues/1@localhost>", "<reply-" + token + "@localhost>"}, gomailMsg.GetHeader("References"), "References header doesn't match")
|
||||
assert.Equal(t, "<user2/repo1/issues/1/comment/2@localhost>", gomailMsg.GetHeader("Message-ID")[0], "Message-ID header doesn't match")
|
||||
assert.Equal(t, "<mailto:"+replyTo+">", gomailMsg.GetHeader("List-Post")[0])
|
||||
assert.Len(t, gomailMsg.GetHeader("List-Unsubscribe"), 2) // url + mailto
|
||||
assert.Equal(t, "<user2/repo1/issues/1@localhost>", gomailMsg.GetGenHeader("In-Reply-To")[0], "In-Reply-To header doesn't match")
|
||||
assert.ElementsMatch(t, []string{"<user2/repo1/issues/1@localhost>", "<reply-" + token + "@localhost>"}, gomailMsg.GetGenHeader("References"), "References header doesn't match")
|
||||
assert.Equal(t, "<user2/repo1/issues/1/comment/2@localhost>", gomailMsg.GetGenHeader("Message-ID")[0], "Message-ID header doesn't match")
|
||||
assert.Equal(t, "<mailto:"+replyTo+">", gomailMsg.GetGenHeader("List-Post")[0])
|
||||
assert.Len(t, gomailMsg.GetGenHeader("List-Unsubscribe"), 2) // url + mailto
|
||||
|
||||
var buf bytes.Buffer
|
||||
gomailMsg.WriteTo(&buf)
|
||||
@@ -138,19 +139,19 @@ func TestComposeIssueMessage(t *testing.T) {
|
||||
assert.Len(t, msgs, 2)
|
||||
|
||||
gomailMsg := msgs[0].ToMessage()
|
||||
mailto := gomailMsg.GetHeader("To")
|
||||
subject := gomailMsg.GetHeader("Subject")
|
||||
messageID := gomailMsg.GetHeader("Message-ID")
|
||||
inReplyTo := gomailMsg.GetHeader("In-Reply-To")
|
||||
references := gomailMsg.GetHeader("References")
|
||||
mailto := gomailMsg.GetAddrHeader("To")
|
||||
subject := gomailMsg.GetGenHeader("Subject")
|
||||
messageID := gomailMsg.GetGenHeader("Message-ID")
|
||||
inReplyTo := gomailMsg.GetGenHeader("In-Reply-To")
|
||||
references := gomailMsg.GetGenHeader("References")
|
||||
|
||||
assert.Len(t, mailto, 1, "exactly one recipient is expected in the To field")
|
||||
assert.Equal(t, "[user2/repo1] @user2 #1 - issue1", subject[0])
|
||||
assert.Equal(t, "<user2/repo1/issues/1@localhost>", inReplyTo[0], "In-Reply-To header doesn't match")
|
||||
assert.Equal(t, "<user2/repo1/issues/1@localhost>", references[0], "References header doesn't match")
|
||||
assert.Equal(t, "<user2/repo1/issues/1@localhost>", messageID[0], "Message-ID header doesn't match")
|
||||
assert.Empty(t, gomailMsg.GetHeader("List-Post")) // incoming mail feature disabled
|
||||
assert.Len(t, gomailMsg.GetHeader("List-Unsubscribe"), 1) // url without mailto
|
||||
assert.Empty(t, gomailMsg.GetGenHeader("List-Post")) // incoming mail feature disabled
|
||||
assert.Len(t, gomailMsg.GetGenHeader("List-Unsubscribe"), 1) // url without mailto
|
||||
}
|
||||
|
||||
func TestTemplateSelection(t *testing.T) {
|
||||
@@ -167,8 +168,8 @@ func TestTemplateSelection(t *testing.T) {
|
||||
template.Must(bodyTemplates.New("pull/comment").Parse("pull/comment/body"))
|
||||
template.Must(bodyTemplates.New("issue/close").Parse("issue/close/body"))
|
||||
|
||||
expect := func(t *testing.T, msg *Message, expSubject, expBody string) {
|
||||
subject := msg.ToMessage().GetHeader("Subject")
|
||||
expect := func(t *testing.T, msg *sender_service.Message, expSubject, expBody string) {
|
||||
subject := msg.ToMessage().GetGenHeader("Subject")
|
||||
msgbuf := new(bytes.Buffer)
|
||||
_, _ = msg.ToMessage().WriteTo(msgbuf)
|
||||
wholemsg := msgbuf.String()
|
||||
@@ -224,7 +225,7 @@ func TestTemplateServices(t *testing.T) {
|
||||
Content: "test body", Comment: comment,
|
||||
}, recipients, fromMention, "TestTemplateServices")
|
||||
|
||||
subject := msg.ToMessage().GetHeader("Subject")
|
||||
subject := msg.ToMessage().GetGenHeader("Subject")
|
||||
msgbuf := new(bytes.Buffer)
|
||||
_, _ = msg.ToMessage().WriteTo(msgbuf)
|
||||
wholemsg := msgbuf.String()
|
||||
@@ -252,7 +253,7 @@ func TestTemplateServices(t *testing.T) {
|
||||
"//Re: //")
|
||||
}
|
||||
|
||||
func testComposeIssueCommentMessage(t *testing.T, ctx *mailCommentContext, recipients []*user_model.User, fromMention bool, info string) *Message {
|
||||
func testComposeIssueCommentMessage(t *testing.T, ctx *mailCommentContext, recipients []*user_model.User, fromMention bool, info string) *sender_service.Message {
|
||||
msgs, err := composeIssueCommentMessages(ctx, "en-US", recipients, fromMention, info)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, msgs, 1)
|
||||
|
||||
+13
-383
@@ -5,391 +5,21 @@
|
||||
package mailer
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"io"
|
||||
"net"
|
||||
"net/smtp"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"code.gitea.io/gitea/modules/base"
|
||||
"code.gitea.io/gitea/modules/graceful"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/process"
|
||||
"code.gitea.io/gitea/modules/queue"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/templates"
|
||||
sender_service "code.gitea.io/gitea/services/mailer/sender"
|
||||
notify_service "code.gitea.io/gitea/services/notify"
|
||||
|
||||
ntlmssp "github.com/Azure/go-ntlmssp"
|
||||
"github.com/jaytaylor/html2text"
|
||||
"gopkg.in/gomail.v2"
|
||||
)
|
||||
|
||||
// Message mail body and log info
|
||||
type Message struct {
|
||||
Info string // Message information for log purpose.
|
||||
FromAddress string
|
||||
FromDisplayName string
|
||||
To string // Use only one recipient to prevent leaking of addresses
|
||||
ReplyTo string
|
||||
Subject string
|
||||
Date time.Time
|
||||
Body string
|
||||
Headers map[string][]string
|
||||
}
|
||||
var mailQueue *queue.WorkerPoolQueue[*sender_service.Message]
|
||||
|
||||
// ToMessage converts a Message to gomail.Message
|
||||
func (m *Message) ToMessage() *gomail.Message {
|
||||
msg := gomail.NewMessage()
|
||||
msg.SetAddressHeader("From", m.FromAddress, m.FromDisplayName)
|
||||
msg.SetHeader("To", m.To)
|
||||
if m.ReplyTo != "" {
|
||||
msg.SetHeader("Reply-To", m.ReplyTo)
|
||||
}
|
||||
for header := range m.Headers {
|
||||
msg.SetHeader(header, m.Headers[header]...)
|
||||
}
|
||||
|
||||
if setting.MailService.SubjectPrefix != "" {
|
||||
msg.SetHeader("Subject", setting.MailService.SubjectPrefix+" "+m.Subject)
|
||||
} else {
|
||||
msg.SetHeader("Subject", m.Subject)
|
||||
}
|
||||
msg.SetDateHeader("Date", m.Date)
|
||||
msg.SetHeader("X-Auto-Response-Suppress", "All")
|
||||
|
||||
plainBody, err := html2text.FromString(m.Body)
|
||||
if err != nil || setting.MailService.SendAsPlainText {
|
||||
if strings.Contains(base.TruncateString(m.Body, 100), "<html>") {
|
||||
log.Warn("Mail contains HTML but configured to send as plain text.")
|
||||
}
|
||||
msg.SetBody("text/plain", plainBody)
|
||||
} else {
|
||||
msg.SetBody("text/plain", plainBody)
|
||||
msg.AddAlternative("text/html", m.Body)
|
||||
}
|
||||
|
||||
if len(msg.GetHeader("Message-ID")) == 0 {
|
||||
msg.SetHeader("Message-ID", m.generateAutoMessageID())
|
||||
}
|
||||
|
||||
for k, v := range setting.MailService.OverrideHeader {
|
||||
if len(msg.GetHeader(k)) != 0 {
|
||||
log.Debug("Mailer override header '%s' as per config", k)
|
||||
}
|
||||
msg.SetHeader(k, v...)
|
||||
}
|
||||
|
||||
return msg
|
||||
}
|
||||
|
||||
// SetHeader adds additional headers to a message
|
||||
func (m *Message) SetHeader(field string, value ...string) {
|
||||
m.Headers[field] = value
|
||||
}
|
||||
|
||||
func (m *Message) generateAutoMessageID() string {
|
||||
dateMs := m.Date.UnixNano() / 1e6
|
||||
h := fnv.New64()
|
||||
if len(m.To) > 0 {
|
||||
_, _ = h.Write([]byte(m.To))
|
||||
}
|
||||
_, _ = h.Write([]byte(m.Subject))
|
||||
_, _ = h.Write([]byte(m.Body))
|
||||
return fmt.Sprintf("<autogen-%d-%016x@%s>", dateMs, h.Sum64(), setting.Domain)
|
||||
}
|
||||
|
||||
// NewMessageFrom creates new mail message object with custom From header.
|
||||
func NewMessageFrom(to, fromDisplayName, fromAddress, subject, body string) *Message {
|
||||
log.Trace("NewMessageFrom (body):\n%s", body)
|
||||
|
||||
return &Message{
|
||||
FromAddress: fromAddress,
|
||||
FromDisplayName: fromDisplayName,
|
||||
To: to,
|
||||
Subject: subject,
|
||||
Date: time.Now(),
|
||||
Body: body,
|
||||
Headers: map[string][]string{},
|
||||
}
|
||||
}
|
||||
|
||||
// NewMessage creates new mail message object with default From header.
|
||||
func NewMessage(to, subject, body string) *Message {
|
||||
return NewMessageFrom(to, setting.MailService.FromName, setting.MailService.FromEmail, subject, body)
|
||||
}
|
||||
|
||||
type loginAuth struct {
|
||||
username, password string
|
||||
}
|
||||
|
||||
// LoginAuth SMTP AUTH LOGIN Auth Handler
|
||||
func LoginAuth(username, password string) smtp.Auth {
|
||||
return &loginAuth{username, password}
|
||||
}
|
||||
|
||||
// Start start SMTP login auth
|
||||
func (a *loginAuth) Start(server *smtp.ServerInfo) (string, []byte, error) {
|
||||
return "LOGIN", []byte{}, nil
|
||||
}
|
||||
|
||||
// Next next step of SMTP login auth
|
||||
func (a *loginAuth) Next(fromServer []byte, more bool) ([]byte, error) {
|
||||
if more {
|
||||
switch string(fromServer) {
|
||||
case "Username:":
|
||||
return []byte(a.username), nil
|
||||
case "Password:":
|
||||
return []byte(a.password), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown fromServer: %s", string(fromServer))
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
type ntlmAuth struct {
|
||||
username, password, domain string
|
||||
domainNeeded bool
|
||||
}
|
||||
|
||||
// NtlmAuth SMTP AUTH NTLM Auth Handler
|
||||
func NtlmAuth(username, password string) smtp.Auth {
|
||||
user, domain, domainNeeded := ntlmssp.GetDomain(username)
|
||||
return &ntlmAuth{user, password, domain, domainNeeded}
|
||||
}
|
||||
|
||||
// Start starts SMTP NTLM Auth
|
||||
func (a *ntlmAuth) Start(server *smtp.ServerInfo) (string, []byte, error) {
|
||||
negotiateMessage, err := ntlmssp.NewNegotiateMessage(a.domain, "")
|
||||
return "NTLM", negotiateMessage, err
|
||||
}
|
||||
|
||||
// Next next step of SMTP ntlm auth
|
||||
func (a *ntlmAuth) Next(fromServer []byte, more bool) ([]byte, error) {
|
||||
if more {
|
||||
if len(fromServer) == 0 {
|
||||
return nil, fmt.Errorf("ntlm ChallengeMessage is empty")
|
||||
}
|
||||
authenticateMessage, err := ntlmssp.ProcessChallenge(fromServer, a.username, a.password, a.domainNeeded)
|
||||
return authenticateMessage, err
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Sender SMTP mail sender
|
||||
type smtpSender struct{}
|
||||
|
||||
// Send send email
|
||||
func (s *smtpSender) Send(from string, to []string, msg io.WriterTo) error {
|
||||
opts := setting.MailService
|
||||
|
||||
var network string
|
||||
var address string
|
||||
if opts.Protocol == "smtp+unix" {
|
||||
network = "unix"
|
||||
address = opts.SMTPAddr
|
||||
} else {
|
||||
network = "tcp"
|
||||
address = net.JoinHostPort(opts.SMTPAddr, opts.SMTPPort)
|
||||
}
|
||||
|
||||
conn, err := net.Dial(network, address)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to establish network connection to SMTP server: %w", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
var tlsconfig *tls.Config
|
||||
if opts.Protocol == "smtps" || opts.Protocol == "smtp+starttls" {
|
||||
tlsconfig = &tls.Config{
|
||||
InsecureSkipVerify: opts.ForceTrustServerCert,
|
||||
ServerName: opts.SMTPAddr,
|
||||
}
|
||||
|
||||
if opts.UseClientCert {
|
||||
cert, err := tls.LoadX509KeyPair(opts.ClientCertFile, opts.ClientKeyFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not load SMTP client certificate: %w", err)
|
||||
}
|
||||
tlsconfig.Certificates = []tls.Certificate{cert}
|
||||
}
|
||||
}
|
||||
|
||||
if opts.Protocol == "smtps" {
|
||||
conn = tls.Client(conn, tlsconfig)
|
||||
}
|
||||
|
||||
host := "localhost"
|
||||
if opts.Protocol == "smtp+unix" {
|
||||
host = opts.SMTPAddr
|
||||
}
|
||||
client, err := smtp.NewClient(conn, host)
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not initiate SMTP session: %w", err)
|
||||
}
|
||||
|
||||
if opts.EnableHelo {
|
||||
hostname := opts.HeloHostname
|
||||
if len(hostname) == 0 {
|
||||
hostname, err = os.Hostname()
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not retrieve system hostname: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err = client.Hello(hostname); err != nil {
|
||||
return fmt.Errorf("failed to issue HELO command: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if opts.Protocol == "smtp+starttls" {
|
||||
hasStartTLS, _ := client.Extension("STARTTLS")
|
||||
if hasStartTLS {
|
||||
if err = client.StartTLS(tlsconfig); err != nil {
|
||||
return fmt.Errorf("failed to start TLS connection: %w", err)
|
||||
}
|
||||
} else {
|
||||
log.Warn("StartTLS requested, but SMTP server does not support it; falling back to regular SMTP")
|
||||
}
|
||||
}
|
||||
|
||||
canAuth, options := client.Extension("AUTH")
|
||||
if len(opts.User) > 0 {
|
||||
if !canAuth {
|
||||
return fmt.Errorf("SMTP server does not support AUTH, but credentials provided")
|
||||
}
|
||||
|
||||
var auth smtp.Auth
|
||||
|
||||
if strings.Contains(options, "CRAM-MD5") {
|
||||
auth = smtp.CRAMMD5Auth(opts.User, opts.Passwd)
|
||||
} else if strings.Contains(options, "PLAIN") {
|
||||
auth = smtp.PlainAuth("", opts.User, opts.Passwd, host)
|
||||
} else if strings.Contains(options, "LOGIN") {
|
||||
// Patch for AUTH LOGIN
|
||||
auth = LoginAuth(opts.User, opts.Passwd)
|
||||
} else if strings.Contains(options, "NTLM") {
|
||||
auth = NtlmAuth(opts.User, opts.Passwd)
|
||||
}
|
||||
|
||||
if auth != nil {
|
||||
if err = client.Auth(auth); err != nil {
|
||||
return fmt.Errorf("failed to authenticate SMTP: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if opts.OverrideEnvelopeFrom {
|
||||
if err = client.Mail(opts.EnvelopeFrom); err != nil {
|
||||
return fmt.Errorf("failed to issue MAIL command: %w", err)
|
||||
}
|
||||
} else {
|
||||
if err = client.Mail(from); err != nil {
|
||||
return fmt.Errorf("failed to issue MAIL command: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
for _, rec := range to {
|
||||
if err = client.Rcpt(rec); err != nil {
|
||||
return fmt.Errorf("failed to issue RCPT command: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
w, err := client.Data()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to issue DATA command: %w", err)
|
||||
} else if _, err = msg.WriteTo(w); err != nil {
|
||||
return fmt.Errorf("SMTP write failed: %w", err)
|
||||
} else if err = w.Close(); err != nil {
|
||||
return fmt.Errorf("SMTP close failed: %w", err)
|
||||
}
|
||||
|
||||
return client.Quit()
|
||||
}
|
||||
|
||||
// Sender sendmail mail sender
|
||||
type sendmailSender struct{}
|
||||
|
||||
// Send send email
|
||||
func (s *sendmailSender) Send(from string, to []string, msg io.WriterTo) error {
|
||||
var err error
|
||||
var closeError error
|
||||
var waitError error
|
||||
|
||||
envelopeFrom := from
|
||||
if setting.MailService.OverrideEnvelopeFrom {
|
||||
envelopeFrom = setting.MailService.EnvelopeFrom
|
||||
}
|
||||
|
||||
args := []string{"-f", envelopeFrom, "-i"}
|
||||
args = append(args, setting.MailService.SendmailArgs...)
|
||||
args = append(args, to...)
|
||||
log.Trace("Sending with: %s %v", setting.MailService.SendmailPath, args)
|
||||
|
||||
desc := fmt.Sprintf("SendMail: %s %v", setting.MailService.SendmailPath, args)
|
||||
|
||||
ctx, _, finished := process.GetManager().AddContextTimeout(graceful.GetManager().HammerContext(), setting.MailService.SendmailTimeout, desc)
|
||||
defer finished()
|
||||
|
||||
cmd := exec.CommandContext(ctx, setting.MailService.SendmailPath, args...)
|
||||
pipe, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
process.SetSysProcAttribute(cmd)
|
||||
|
||||
if err = cmd.Start(); err != nil {
|
||||
_ = pipe.Close()
|
||||
return err
|
||||
}
|
||||
|
||||
if setting.MailService.SendmailConvertCRLF {
|
||||
buf := &strings.Builder{}
|
||||
_, err = msg.WriteTo(buf)
|
||||
if err == nil {
|
||||
_, err = strings.NewReplacer("\r\n", "\n").WriteString(pipe, buf.String())
|
||||
}
|
||||
} else {
|
||||
_, err = msg.WriteTo(pipe)
|
||||
}
|
||||
|
||||
// we MUST close the pipe or sendmail will hang waiting for more of the message
|
||||
// Also we should wait on our sendmail command even if something fails
|
||||
closeError = pipe.Close()
|
||||
waitError = cmd.Wait()
|
||||
if err != nil {
|
||||
return err
|
||||
} else if closeError != nil {
|
||||
return closeError
|
||||
}
|
||||
return waitError
|
||||
}
|
||||
|
||||
// Sender sendmail mail sender
|
||||
type dummySender struct{}
|
||||
|
||||
// Send send email
|
||||
func (s *dummySender) Send(from string, to []string, msg io.WriterTo) error {
|
||||
buf := bytes.Buffer{}
|
||||
if _, err := msg.WriteTo(&buf); err != nil {
|
||||
return err
|
||||
}
|
||||
log.Debug("Mail From: %s To: %v Body: %s", from, to, buf.String())
|
||||
return nil
|
||||
}
|
||||
|
||||
var mailQueue *queue.WorkerPoolQueue[*Message]
|
||||
|
||||
// Sender sender for sending mail synchronously
|
||||
var Sender gomail.Sender
|
||||
// sender sender for sending mail synchronously
|
||||
var sender sender_service.Sender
|
||||
|
||||
// NewContext start mail queue service
|
||||
func NewContext(ctx context.Context) {
|
||||
@@ -406,23 +36,23 @@ func NewContext(ctx context.Context) {
|
||||
|
||||
switch setting.MailService.Protocol {
|
||||
case "sendmail":
|
||||
Sender = &sendmailSender{}
|
||||
sender = &sender_service.SendmailSender{}
|
||||
case "dummy":
|
||||
Sender = &dummySender{}
|
||||
sender = &sender_service.DummySender{}
|
||||
default:
|
||||
Sender = &smtpSender{}
|
||||
sender = &sender_service.SMTPSender{}
|
||||
}
|
||||
|
||||
subjectTemplates, bodyTemplates = templates.Mailer(ctx)
|
||||
|
||||
mailQueue = queue.CreateSimpleQueue(graceful.GetManager().ShutdownContext(), "mail", func(items ...*Message) []*Message {
|
||||
mailQueue = queue.CreateSimpleQueue(graceful.GetManager().ShutdownContext(), "mail", func(items ...*sender_service.Message) []*sender_service.Message {
|
||||
for _, msg := range items {
|
||||
gomailMsg := msg.ToMessage()
|
||||
log.Trace("New e-mail sending request %s: %s", gomailMsg.GetHeader("To"), msg.Info)
|
||||
if err := gomail.Send(Sender, gomailMsg); err != nil {
|
||||
log.Error("Failed to send emails %s: %s - %v", gomailMsg.GetHeader("To"), msg.Info, err)
|
||||
log.Trace("New e-mail sending request %s: %s", gomailMsg.GetGenHeader("To"), msg.Info)
|
||||
if err := sender_service.Send(sender, msg); err != nil {
|
||||
log.Error("Failed to send emails %s: %s - %v", gomailMsg.GetGenHeader("To"), msg.Info, err)
|
||||
} else {
|
||||
log.Trace("E-mails sent %s: %s", gomailMsg.GetHeader("To"), msg.Info)
|
||||
log.Trace("E-mails sent %s: %s", gomailMsg.GetGenHeader("To"), msg.Info)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -436,7 +66,7 @@ func NewContext(ctx context.Context) {
|
||||
// SendAsync send emails asynchronously (make it mockable)
|
||||
var SendAsync = sendAsync
|
||||
|
||||
func sendAsync(msgs ...*Message) {
|
||||
func sendAsync(msgs ...*sender_service.Message) {
|
||||
if setting.MailService == nil {
|
||||
log.Error("Mailer: SendAsync is being invoked but mail service hasn't been initialized")
|
||||
return
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sender
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
)
|
||||
|
||||
// DummySender Sender sendmail mail sender
|
||||
type DummySender struct{}
|
||||
|
||||
var _ Sender = &DummySender{}
|
||||
|
||||
// Send send email
|
||||
func (s *DummySender) Send(from string, to []string, msg io.WriterTo) error {
|
||||
buf := bytes.Buffer{}
|
||||
if _, err := msg.WriteTo(&buf); err != nil {
|
||||
return err
|
||||
}
|
||||
log.Debug("Mail From: %s To: %v Body: %s", from, to, buf.String())
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// Copyright 2019 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sender
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"code.gitea.io/gitea/models/unittest"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
unittest.MainTest(m)
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sender
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"net/mail"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"code.gitea.io/gitea/modules/base"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
|
||||
"github.com/jaytaylor/html2text"
|
||||
gomail "github.com/wneessen/go-mail"
|
||||
)
|
||||
|
||||
// Message mail body and log info
|
||||
type Message struct {
|
||||
Info string // Message information for log purpose.
|
||||
FromAddress string
|
||||
FromDisplayName string
|
||||
To string // Use only one recipient to prevent leaking of addresses
|
||||
ReplyTo string
|
||||
Subject string
|
||||
Date time.Time
|
||||
Body string
|
||||
Headers map[string][]string
|
||||
}
|
||||
|
||||
// ToMessage converts a Message to gomail.Message
|
||||
func (m *Message) ToMessage() *gomail.Msg {
|
||||
msg := gomail.NewMsg()
|
||||
addr := mail.Address{Name: m.FromDisplayName, Address: m.FromAddress}
|
||||
_ = msg.SetAddrHeader("From", addr.String())
|
||||
_ = msg.SetAddrHeader("To", m.To)
|
||||
if m.ReplyTo != "" {
|
||||
msg.SetGenHeader("Reply-To", m.ReplyTo)
|
||||
}
|
||||
for header := range m.Headers {
|
||||
msg.SetGenHeader(gomail.Header(header), m.Headers[header]...)
|
||||
}
|
||||
|
||||
if setting.MailService.SubjectPrefix != "" {
|
||||
msg.SetGenHeader("Subject", setting.MailService.SubjectPrefix+" "+m.Subject)
|
||||
} else {
|
||||
msg.SetGenHeader("Subject", m.Subject)
|
||||
}
|
||||
msg.SetDateWithValue(m.Date)
|
||||
msg.SetGenHeader("X-Auto-Response-Suppress", "All")
|
||||
|
||||
plainBody, err := html2text.FromString(m.Body)
|
||||
if err != nil || setting.MailService.SendAsPlainText {
|
||||
if strings.Contains(base.TruncateString(m.Body, 100), "<html>") {
|
||||
log.Warn("Mail contains HTML but configured to send as plain text.")
|
||||
}
|
||||
msg.SetBodyString("text/plain", plainBody)
|
||||
} else {
|
||||
msg.SetBodyString("text/plain", plainBody)
|
||||
msg.AddAlternativeString("text/html", m.Body)
|
||||
}
|
||||
|
||||
if len(msg.GetGenHeader("Message-ID")) == 0 {
|
||||
msg.SetGenHeader("Message-ID", m.generateAutoMessageID())
|
||||
}
|
||||
|
||||
for k, v := range setting.MailService.OverrideHeader {
|
||||
if len(msg.GetGenHeader(gomail.Header(k))) != 0 {
|
||||
log.Debug("Mailer override header '%s' as per config", k)
|
||||
}
|
||||
msg.SetGenHeader(gomail.Header(k), v...)
|
||||
}
|
||||
|
||||
return msg
|
||||
}
|
||||
|
||||
// SetHeader adds additional headers to a message
|
||||
func (m *Message) SetHeader(field string, value ...string) {
|
||||
m.Headers[field] = value
|
||||
}
|
||||
|
||||
func (m *Message) generateAutoMessageID() string {
|
||||
dateMs := m.Date.UnixNano() / 1e6
|
||||
h := fnv.New64()
|
||||
if len(m.To) > 0 {
|
||||
_, _ = h.Write([]byte(m.To))
|
||||
}
|
||||
_, _ = h.Write([]byte(m.Subject))
|
||||
_, _ = h.Write([]byte(m.Body))
|
||||
return fmt.Sprintf("<autogen-%d-%016x@%s>", dateMs, h.Sum64(), setting.Domain)
|
||||
}
|
||||
|
||||
// NewMessageFrom creates new mail message object with custom From header.
|
||||
func NewMessageFrom(to, fromDisplayName, fromAddress, subject, body string) *Message {
|
||||
log.Trace("NewMessageFrom (body):\n%s", body)
|
||||
|
||||
return &Message{
|
||||
FromAddress: fromAddress,
|
||||
FromDisplayName: fromDisplayName,
|
||||
To: to,
|
||||
Subject: subject,
|
||||
Date: time.Now(),
|
||||
Body: body,
|
||||
Headers: map[string][]string{},
|
||||
}
|
||||
}
|
||||
|
||||
// NewMessage creates new mail message object with default From header.
|
||||
func NewMessage(to, subject, body string) *Message {
|
||||
return NewMessageFrom(to, setting.MailService.FromName, setting.MailService.FromEmail, subject, body)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
// Copyright 2021 The Gogs Authors. All rights reserved.
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package mailer
|
||||
package sender
|
||||
|
||||
import (
|
||||
"strings"
|
||||
@@ -25,25 +25,27 @@ func TestGenerateMessageID(t *testing.T) {
|
||||
m := NewMessageFrom("", "display-name", "from-address", "subject", "body")
|
||||
m.Date = date
|
||||
gm := m.ToMessage()
|
||||
assert.Equal(t, "<autogen-946782245000-41e8fc54a8ad3a3f@localhost>", gm.GetHeader("Message-ID")[0])
|
||||
assert.Equal(t, "<autogen-946782245000-41e8fc54a8ad3a3f@localhost>", gm.GetGenHeader("Message-ID")[0])
|
||||
|
||||
m = NewMessageFrom("a@b.com", "display-name", "from-address", "subject", "body")
|
||||
m.Date = date
|
||||
gm = m.ToMessage()
|
||||
assert.Equal(t, "<autogen-946782245000-cc88ce3cfe9bd04f@localhost>", gm.GetHeader("Message-ID")[0])
|
||||
assert.Equal(t, "<autogen-946782245000-cc88ce3cfe9bd04f@localhost>", gm.GetGenHeader("Message-ID")[0])
|
||||
|
||||
m = NewMessageFrom("a@b.com", "display-name", "from-address", "subject", "body")
|
||||
m.SetHeader("Message-ID", "<msg-d@domain.com>")
|
||||
gm = m.ToMessage()
|
||||
assert.Equal(t, "<msg-d@domain.com>", gm.GetHeader("Message-ID")[0])
|
||||
assert.Equal(t, "<msg-d@domain.com>", gm.GetGenHeader("Message-ID")[0])
|
||||
}
|
||||
|
||||
func TestToMessage(t *testing.T) {
|
||||
oldConf := *setting.MailService
|
||||
oldConf := setting.MailService
|
||||
defer func() {
|
||||
setting.MailService = &oldConf
|
||||
setting.MailService = oldConf
|
||||
}()
|
||||
setting.MailService.From = "test@gitea.com"
|
||||
setting.MailService = &setting.Mailer{
|
||||
From: "test@gitea.com",
|
||||
}
|
||||
|
||||
m1 := Message{
|
||||
Info: "info",
|
||||
@@ -54,18 +56,24 @@ func TestToMessage(t *testing.T) {
|
||||
Body: "Some Issue got closed by Y-Man",
|
||||
}
|
||||
|
||||
assertHeaders := func(t *testing.T, expected, header map[string]string) {
|
||||
for k, v := range expected {
|
||||
assert.Equal(t, v, header[k], "Header %s should be %s but got %s", k, v, header[k])
|
||||
}
|
||||
}
|
||||
|
||||
buf := &strings.Builder{}
|
||||
_, err := m1.ToMessage().WriteTo(buf)
|
||||
assert.NoError(t, err)
|
||||
header, _ := extractMailHeaderAndContent(t, buf.String())
|
||||
assert.EqualValues(t, map[string]string{
|
||||
assertHeaders(t, map[string]string{
|
||||
"Content-Type": "multipart/alternative;",
|
||||
"Date": "Mon, 01 Jan 0001 00:00:00 +0000",
|
||||
"From": "\"Test Gitea\" <test@gitea.com>",
|
||||
"Message-ID": "<autogen--6795364578871-69c000786adc60dc@localhost>",
|
||||
"Mime-Version": "1.0",
|
||||
"MIME-Version": "1.0",
|
||||
"Subject": "Issue X Closed",
|
||||
"To": "a@b.com",
|
||||
"To": "<a@b.com>",
|
||||
"X-Auto-Response-Suppress": "All",
|
||||
}, header)
|
||||
|
||||
@@ -78,14 +86,14 @@ func TestToMessage(t *testing.T) {
|
||||
_, err = m1.ToMessage().WriteTo(buf)
|
||||
assert.NoError(t, err)
|
||||
header, _ = extractMailHeaderAndContent(t, buf.String())
|
||||
assert.EqualValues(t, map[string]string{
|
||||
assertHeaders(t, map[string]string{
|
||||
"Content-Type": "multipart/alternative;",
|
||||
"Date": "Mon, 01 Jan 0001 00:00:00 +0000",
|
||||
"From": "\"Test Gitea\" <test@gitea.com>",
|
||||
"Message-ID": "",
|
||||
"Mime-Version": "1.0",
|
||||
"MIME-Version": "1.0",
|
||||
"Subject": "Issue X Closed",
|
||||
"To": "a@b.com",
|
||||
"To": "<a@b.com>",
|
||||
"X-Auto-Response-Suppress": "All",
|
||||
"Auto-Submitted": "auto-generated",
|
||||
}, header)
|
||||
@@ -0,0 +1,38 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sender
|
||||
|
||||
import (
|
||||
"io"
|
||||
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
)
|
||||
|
||||
type Sender interface {
|
||||
Send(from string, to []string, msg io.WriterTo) error
|
||||
}
|
||||
|
||||
var Send = send
|
||||
|
||||
func send(sender Sender, msgs ...*Message) error {
|
||||
if setting.MailService == nil {
|
||||
log.Error("Mailer: Send is being invoked but mail service hasn't been initialized")
|
||||
return nil
|
||||
}
|
||||
for _, msg := range msgs {
|
||||
m := msg.ToMessage()
|
||||
froms := m.GetFrom()
|
||||
to, err := m.GetRecipients()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// TODO: implement sending from multiple addresses
|
||||
if err := sender.Send(froms[0].Address, to, m); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sender
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"code.gitea.io/gitea/modules/graceful"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/process"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
)
|
||||
|
||||
// SendmailSender Sender sendmail mail sender
|
||||
type SendmailSender struct{}
|
||||
|
||||
var _ Sender = &SendmailSender{}
|
||||
|
||||
// Send send email
|
||||
func (s *SendmailSender) Send(from string, to []string, msg io.WriterTo) error {
|
||||
var err error
|
||||
var closeError error
|
||||
var waitError error
|
||||
|
||||
envelopeFrom := from
|
||||
if setting.MailService.OverrideEnvelopeFrom {
|
||||
envelopeFrom = setting.MailService.EnvelopeFrom
|
||||
}
|
||||
|
||||
args := []string{"-f", envelopeFrom, "-i"}
|
||||
args = append(args, setting.MailService.SendmailArgs...)
|
||||
args = append(args, to...)
|
||||
log.Trace("Sending with: %s %v", setting.MailService.SendmailPath, args)
|
||||
|
||||
desc := fmt.Sprintf("SendMail: %s %v", setting.MailService.SendmailPath, args)
|
||||
|
||||
ctx, _, finished := process.GetManager().AddContextTimeout(graceful.GetManager().HammerContext(), setting.MailService.SendmailTimeout, desc)
|
||||
defer finished()
|
||||
|
||||
cmd := exec.CommandContext(ctx, setting.MailService.SendmailPath, args...)
|
||||
pipe, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
process.SetSysProcAttribute(cmd)
|
||||
|
||||
if err = cmd.Start(); err != nil {
|
||||
_ = pipe.Close()
|
||||
return err
|
||||
}
|
||||
|
||||
if setting.MailService.SendmailConvertCRLF {
|
||||
buf := &strings.Builder{}
|
||||
_, err = msg.WriteTo(buf)
|
||||
if err == nil {
|
||||
_, err = strings.NewReplacer("\r\n", "\n").WriteString(pipe, buf.String())
|
||||
}
|
||||
} else {
|
||||
_, err = msg.WriteTo(pipe)
|
||||
}
|
||||
|
||||
// we MUST close the pipe or sendmail will hang waiting for more of the message
|
||||
// Also we should wait on our sendmail command even if something fails
|
||||
closeError = pipe.Close()
|
||||
waitError = cmd.Wait()
|
||||
if err != nil {
|
||||
return err
|
||||
} else if closeError != nil {
|
||||
return closeError
|
||||
}
|
||||
return waitError
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sender
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
|
||||
"github.com/wneessen/go-mail/smtp"
|
||||
)
|
||||
|
||||
// SMTPSender Sender SMTP mail sender
|
||||
type SMTPSender struct{}
|
||||
|
||||
var _ Sender = &SMTPSender{}
|
||||
|
||||
// Send send email
|
||||
func (s *SMTPSender) Send(from string, to []string, msg io.WriterTo) error {
|
||||
opts := setting.MailService
|
||||
|
||||
var network string
|
||||
var address string
|
||||
if opts.Protocol == "smtp+unix" {
|
||||
network = "unix"
|
||||
address = opts.SMTPAddr
|
||||
} else {
|
||||
network = "tcp"
|
||||
address = net.JoinHostPort(opts.SMTPAddr, opts.SMTPPort)
|
||||
}
|
||||
|
||||
conn, err := net.Dial(network, address)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to establish network connection to SMTP server: %w", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
var tlsconfig *tls.Config
|
||||
if opts.Protocol == "smtps" || opts.Protocol == "smtp+starttls" {
|
||||
tlsconfig = &tls.Config{
|
||||
InsecureSkipVerify: opts.ForceTrustServerCert,
|
||||
ServerName: opts.SMTPAddr,
|
||||
}
|
||||
|
||||
if opts.UseClientCert {
|
||||
cert, err := tls.LoadX509KeyPair(opts.ClientCertFile, opts.ClientKeyFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not load SMTP client certificate: %w", err)
|
||||
}
|
||||
tlsconfig.Certificates = []tls.Certificate{cert}
|
||||
}
|
||||
}
|
||||
|
||||
if opts.Protocol == "smtps" {
|
||||
conn = tls.Client(conn, tlsconfig)
|
||||
}
|
||||
|
||||
host := "localhost"
|
||||
if opts.Protocol == "smtp+unix" {
|
||||
host = opts.SMTPAddr
|
||||
}
|
||||
client, err := smtp.NewClient(conn, host)
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not initiate SMTP session: %w", err)
|
||||
}
|
||||
|
||||
if opts.EnableHelo {
|
||||
hostname := opts.HeloHostname
|
||||
if len(hostname) == 0 {
|
||||
hostname, err = os.Hostname()
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not retrieve system hostname: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err = client.Hello(hostname); err != nil {
|
||||
return fmt.Errorf("failed to issue HELO command: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if opts.Protocol == "smtp+starttls" {
|
||||
hasStartTLS, _ := client.Extension("STARTTLS")
|
||||
if hasStartTLS {
|
||||
if err = client.StartTLS(tlsconfig); err != nil {
|
||||
return fmt.Errorf("failed to start TLS connection: %w", err)
|
||||
}
|
||||
} else {
|
||||
log.Warn("StartTLS requested, but SMTP server does not support it; falling back to regular SMTP")
|
||||
}
|
||||
}
|
||||
|
||||
canAuth, options := client.Extension("AUTH")
|
||||
if len(opts.User) > 0 {
|
||||
if !canAuth {
|
||||
return fmt.Errorf("SMTP server does not support AUTH, but credentials provided")
|
||||
}
|
||||
|
||||
var auth smtp.Auth
|
||||
|
||||
if strings.Contains(options, "CRAM-MD5") {
|
||||
auth = smtp.CRAMMD5Auth(opts.User, opts.Passwd)
|
||||
} else if strings.Contains(options, "PLAIN") {
|
||||
auth = smtp.PlainAuth("", opts.User, opts.Passwd, host, false)
|
||||
} else if strings.Contains(options, "LOGIN") {
|
||||
// Patch for AUTH LOGIN
|
||||
auth = LoginAuth(opts.User, opts.Passwd)
|
||||
} else if strings.Contains(options, "NTLM") {
|
||||
auth = NtlmAuth(opts.User, opts.Passwd)
|
||||
}
|
||||
|
||||
if auth != nil {
|
||||
if err = client.Auth(auth); err != nil {
|
||||
return fmt.Errorf("failed to authenticate SMTP: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if opts.OverrideEnvelopeFrom {
|
||||
if err = client.Mail(opts.EnvelopeFrom); err != nil {
|
||||
return fmt.Errorf("failed to issue MAIL command: %w", err)
|
||||
}
|
||||
} else {
|
||||
if err = client.Mail(from); err != nil {
|
||||
return fmt.Errorf("failed to issue MAIL command: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
for _, rec := range to {
|
||||
if err = client.Rcpt(rec); err != nil {
|
||||
return fmt.Errorf("failed to issue RCPT command: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
w, err := client.Data()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to issue DATA command: %w", err)
|
||||
} else if _, err = msg.WriteTo(w); err != nil {
|
||||
return fmt.Errorf("SMTP write failed: %w", err)
|
||||
} else if err = w.Close(); err != nil {
|
||||
return fmt.Errorf("SMTP close failed: %w", err)
|
||||
}
|
||||
|
||||
err = client.Quit()
|
||||
if err != nil {
|
||||
log.Error("Quit client failed: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sender
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/Azure/go-ntlmssp"
|
||||
"github.com/wneessen/go-mail/smtp"
|
||||
)
|
||||
|
||||
type loginAuth struct {
|
||||
username, password string
|
||||
}
|
||||
|
||||
// LoginAuth SMTP AUTH LOGIN Auth Handler
|
||||
func LoginAuth(username, password string) smtp.Auth {
|
||||
return &loginAuth{username, password}
|
||||
}
|
||||
|
||||
// Start start SMTP login auth
|
||||
func (a *loginAuth) Start(server *smtp.ServerInfo) (string, []byte, error) {
|
||||
return "LOGIN", []byte{}, nil
|
||||
}
|
||||
|
||||
// Next next step of SMTP login auth
|
||||
func (a *loginAuth) Next(fromServer []byte, more bool) ([]byte, error) {
|
||||
if more {
|
||||
switch string(fromServer) {
|
||||
case "Username:":
|
||||
return []byte(a.username), nil
|
||||
case "Password:":
|
||||
return []byte(a.password), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown fromServer: %s", string(fromServer))
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
type ntlmAuth struct {
|
||||
username, password, domain string
|
||||
domainNeeded bool
|
||||
}
|
||||
|
||||
// NtlmAuth SMTP AUTH NTLM Auth Handler
|
||||
func NtlmAuth(username, password string) smtp.Auth {
|
||||
user, domain, domainNeeded := ntlmssp.GetDomain(username)
|
||||
return &ntlmAuth{user, password, domain, domainNeeded}
|
||||
}
|
||||
|
||||
// Start starts SMTP NTLM Auth
|
||||
func (a *ntlmAuth) Start(server *smtp.ServerInfo) (string, []byte, error) {
|
||||
negotiateMessage, err := ntlmssp.NewNegotiateMessage(a.domain, "")
|
||||
return "NTLM", negotiateMessage, err
|
||||
}
|
||||
|
||||
// Next next step of SMTP ntlm auth
|
||||
func (a *ntlmAuth) Next(fromServer []byte, more bool) ([]byte, error) {
|
||||
if more {
|
||||
if len(fromServer) == 0 {
|
||||
return nil, fmt.Errorf("ntlm ChallengeMessage is empty")
|
||||
}
|
||||
authenticateMessage, err := ntlmssp.ProcessChallenge(fromServer, a.username, a.password, a.domainNeeded)
|
||||
return authenticateMessage, err
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
@@ -11,10 +11,8 @@ import (
|
||||
gitea_context "code.gitea.io/gitea/services/context"
|
||||
)
|
||||
|
||||
func ProcessorHelper() *markup.ProcessorHelper {
|
||||
return &markup.ProcessorHelper{
|
||||
ElementDir: "auto", // set dir="auto" for necessary (eg: <p>, <h?>, etc) tags
|
||||
|
||||
func ProcessorHelper() *markup.RenderHelperFuncs {
|
||||
return &markup.RenderHelperFuncs{
|
||||
RenderRepoFileCodePreview: renderRepoFileCodePreview,
|
||||
IsUsernameMentionable: func(ctx context.Context, username string) bool {
|
||||
mentionedUser, err := user.GetUserByName(ctx, username)
|
||||
|
||||
@@ -74,26 +74,32 @@ type AccessTokenResponse struct {
|
||||
// GrantAdditionalScopes returns valid scopes coming from grant
|
||||
func GrantAdditionalScopes(grantScopes string) auth.AccessTokenScope {
|
||||
// scopes_supported from templates/user/auth/oidc_wellknown.tmpl
|
||||
scopesSupported := []string{
|
||||
generalScopesSupported := []string{
|
||||
"openid",
|
||||
"profile",
|
||||
"email",
|
||||
"groups",
|
||||
}
|
||||
|
||||
var tokenScopes []string
|
||||
for _, tokenScope := range strings.Split(grantScopes, " ") {
|
||||
if slices.Index(scopesSupported, tokenScope) == -1 {
|
||||
tokenScopes = append(tokenScopes, tokenScope)
|
||||
var accessScopes []string // the scopes for access control, but not for general information
|
||||
for _, scope := range strings.Split(grantScopes, " ") {
|
||||
if scope != "" && !slices.Contains(generalScopesSupported, scope) {
|
||||
accessScopes = append(accessScopes, scope)
|
||||
}
|
||||
}
|
||||
|
||||
// since version 1.22, access tokens grant full access to the API
|
||||
// with this access is reduced only if additional scopes are provided
|
||||
accessTokenScope := auth.AccessTokenScope(strings.Join(tokenScopes, ","))
|
||||
if accessTokenWithAdditionalScopes, err := accessTokenScope.Normalize(); err == nil && len(tokenScopes) > 0 {
|
||||
return accessTokenWithAdditionalScopes
|
||||
if len(accessScopes) > 0 {
|
||||
accessTokenScope := auth.AccessTokenScope(strings.Join(accessScopes, ","))
|
||||
if normalizedAccessTokenScope, err := accessTokenScope.Normalize(); err == nil {
|
||||
return normalizedAccessTokenScope
|
||||
}
|
||||
// TODO: if there are invalid access scopes (err != nil),
|
||||
// then it is treated as "all", maybe in the future we should make it stricter to return an error
|
||||
// at the moment, to avoid breaking 1.22 behavior, invalid tokens are also treated as "all"
|
||||
}
|
||||
// fallback, empty access scope is treated as "all" access
|
||||
return auth.AccessTokenScopeAll
|
||||
}
|
||||
|
||||
@@ -235,14 +241,15 @@ func GetOAuthGroupsForUser(ctx context.Context, user *user_model.User, onlyPubli
|
||||
return nil, fmt.Errorf("GetUserOrgList: %w", err)
|
||||
}
|
||||
|
||||
orgTeams, err := org_model.OrgList(orgs).LoadTeams(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("LoadTeams: %w", err)
|
||||
}
|
||||
|
||||
var groups []string
|
||||
for _, org := range orgs {
|
||||
groups = append(groups, org.Name)
|
||||
teams, err := org.LoadTeams(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("LoadTeams: %w", err)
|
||||
}
|
||||
for _, team := range teams {
|
||||
for _, team := range orgTeams[org.ID] {
|
||||
if team.IsMember(ctx, user.ID) {
|
||||
groups = append(groups, org.Name+":"+team.LowerName)
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ func TestGrantAdditionalScopes(t *testing.T) {
|
||||
grantScopes string
|
||||
expectedScopes string
|
||||
}{
|
||||
{"", "all"}, // for old tokens without scope, treat it as "all"
|
||||
{"openid profile email", "all"},
|
||||
{"openid profile email groups", "all"},
|
||||
{"openid profile email all", "all"},
|
||||
@@ -22,12 +23,14 @@ func TestGrantAdditionalScopes(t *testing.T) {
|
||||
{"read:user read:repository", "read:repository,read:user"},
|
||||
{"read:user write:issue public-only", "public-only,write:issue,read:user"},
|
||||
{"openid profile email read:user", "read:user"},
|
||||
|
||||
// TODO: at the moment invalid tokens are treated as "all" to avoid breaking 1.22 behavior (more details are in GrantAdditionalScopes)
|
||||
{"read:invalid_scope", "all"},
|
||||
{"read:invalid_scope,write:scope_invalid,just-plain-wrong", "all"},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.grantScopes, func(t *testing.T) {
|
||||
t.Run("scope:"+test.grantScopes, func(t *testing.T) {
|
||||
result := GrantAdditionalScopes(test.grantScopes)
|
||||
assert.Equal(t, test.expectedScopes, string(result))
|
||||
})
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package org
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"code.gitea.io/gitea/models"
|
||||
"code.gitea.io/gitea/models/db"
|
||||
"code.gitea.io/gitea/models/organization"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
)
|
||||
|
||||
// TeamAddRepository adds new repository to team of organization.
|
||||
func TeamAddRepository(ctx context.Context, t *organization.Team, repo *repo_model.Repository) (err error) {
|
||||
if repo.OwnerID != t.OrgID {
|
||||
return errors.New("repository does not belong to organization")
|
||||
} else if organization.HasTeamRepo(ctx, t.OrgID, t.ID, repo.ID) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return db.WithTx(ctx, func(ctx context.Context) error {
|
||||
return models.AddRepository(ctx, t, repo)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package org
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
git_model "code.gitea.io/gitea/models/git"
|
||||
issues_model "code.gitea.io/gitea/models/issues"
|
||||
"code.gitea.io/gitea/models/organization"
|
||||
access_model "code.gitea.io/gitea/models/perm/access"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
repo_service "code.gitea.io/gitea/services/repository"
|
||||
|
||||
"xorm.io/builder"
|
||||
)
|
||||
|
||||
// NewTeam creates a record of new team.
|
||||
// It's caller's responsibility to assign organization ID.
|
||||
func NewTeam(ctx context.Context, t *organization.Team) (err error) {
|
||||
if len(t.Name) == 0 {
|
||||
return util.NewInvalidArgumentErrorf("empty team name")
|
||||
}
|
||||
|
||||
if err = organization.IsUsableTeamName(t.Name); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
has, err := db.ExistByID[user_model.User](ctx, t.OrgID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !has {
|
||||
return organization.ErrOrgNotExist{ID: t.OrgID}
|
||||
}
|
||||
|
||||
t.LowerName = strings.ToLower(t.Name)
|
||||
has, err = db.Exist[organization.Team](ctx, builder.Eq{
|
||||
"org_id": t.OrgID,
|
||||
"lower_name": t.LowerName,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if has {
|
||||
return organization.ErrTeamAlreadyExist{OrgID: t.OrgID, Name: t.LowerName}
|
||||
}
|
||||
|
||||
ctx, committer, err := db.TxContext(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer committer.Close()
|
||||
|
||||
if err = db.Insert(ctx, t); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// insert units for team
|
||||
if len(t.Units) > 0 {
|
||||
for _, unit := range t.Units {
|
||||
unit.TeamID = t.ID
|
||||
}
|
||||
if err = db.Insert(ctx, &t.Units); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Add all repositories to the team if it has access to all of them.
|
||||
if t.IncludesAllRepositories {
|
||||
err = repo_service.AddAllRepositoriesToTeam(ctx, t)
|
||||
if err != nil {
|
||||
return fmt.Errorf("addAllRepositories: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Update organization number of teams.
|
||||
if _, err = db.Exec(ctx, "UPDATE `user` SET num_teams=num_teams+1 WHERE id = ?", t.OrgID); err != nil {
|
||||
return err
|
||||
}
|
||||
return committer.Commit()
|
||||
}
|
||||
|
||||
// UpdateTeam updates information of team.
|
||||
func UpdateTeam(ctx context.Context, t *organization.Team, authChanged, includeAllChanged bool) (err error) {
|
||||
if len(t.Name) == 0 {
|
||||
return util.NewInvalidArgumentErrorf("empty team name")
|
||||
}
|
||||
|
||||
if len(t.Description) > 255 {
|
||||
t.Description = t.Description[:255]
|
||||
}
|
||||
|
||||
ctx, committer, err := db.TxContext(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer committer.Close()
|
||||
|
||||
t.LowerName = strings.ToLower(t.Name)
|
||||
has, err := db.Exist[organization.Team](ctx, builder.Eq{
|
||||
"org_id": t.OrgID,
|
||||
"lower_name": t.LowerName,
|
||||
}.And(builder.Neq{"id": t.ID}),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
} else if has {
|
||||
return organization.ErrTeamAlreadyExist{OrgID: t.OrgID, Name: t.LowerName}
|
||||
}
|
||||
|
||||
sess := db.GetEngine(ctx)
|
||||
if _, err = sess.ID(t.ID).Cols("name", "lower_name", "description",
|
||||
"can_create_org_repo", "authorize", "includes_all_repositories").Update(t); err != nil {
|
||||
return fmt.Errorf("update: %w", err)
|
||||
}
|
||||
|
||||
// update units for team
|
||||
if len(t.Units) > 0 {
|
||||
for _, unit := range t.Units {
|
||||
unit.TeamID = t.ID
|
||||
}
|
||||
// Delete team-unit.
|
||||
if _, err := sess.
|
||||
Where("team_id=?", t.ID).
|
||||
Delete(new(organization.TeamUnit)); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = sess.Cols("org_id", "team_id", "type", "access_mode").Insert(&t.Units); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Update access for team members if needed.
|
||||
if authChanged {
|
||||
if err = t.LoadRepositories(ctx); err != nil {
|
||||
return fmt.Errorf("LoadRepositories: %w", err)
|
||||
}
|
||||
|
||||
for _, repo := range t.Repos {
|
||||
if err = access_model.RecalculateTeamAccesses(ctx, repo, 0); err != nil {
|
||||
return fmt.Errorf("recalculateTeamAccesses: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add all repositories to the team if it has access to all of them.
|
||||
if includeAllChanged && t.IncludesAllRepositories {
|
||||
err = repo_service.AddAllRepositoriesToTeam(ctx, t)
|
||||
if err != nil {
|
||||
return fmt.Errorf("addAllRepositories: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return committer.Commit()
|
||||
}
|
||||
|
||||
// DeleteTeam deletes given team.
|
||||
// It's caller's responsibility to assign organization ID.
|
||||
func DeleteTeam(ctx context.Context, t *organization.Team) error {
|
||||
ctx, committer, err := db.TxContext(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer committer.Close()
|
||||
|
||||
if err := t.LoadRepositories(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := t.LoadMembers(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// update branch protections
|
||||
{
|
||||
protections := make([]*git_model.ProtectedBranch, 0, 10)
|
||||
err := db.GetEngine(ctx).In("repo_id",
|
||||
builder.Select("id").From("repository").Where(builder.Eq{"owner_id": t.OrgID})).
|
||||
Find(&protections)
|
||||
if err != nil {
|
||||
return fmt.Errorf("findProtectedBranches: %w", err)
|
||||
}
|
||||
for _, p := range protections {
|
||||
if err := git_model.RemoveTeamIDFromProtectedBranch(ctx, p, t.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := repo_service.RemoveAllRepositoriesFromTeam(ctx, t); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := db.DeleteBeans(ctx,
|
||||
&organization.Team{ID: t.ID},
|
||||
&organization.TeamUser{OrgID: t.OrgID, TeamID: t.ID},
|
||||
&organization.TeamUnit{TeamID: t.ID},
|
||||
&organization.TeamInvite{TeamID: t.ID},
|
||||
&issues_model.Review{Type: issues_model.ReviewTypeRequest, ReviewerTeamID: t.ID}, // batch delete the binding relationship between team and PR (request review from team)
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, tm := range t.Members {
|
||||
if err := removeInvalidOrgUser(ctx, t.OrgID, tm); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Update organization number of teams.
|
||||
if _, err := db.Exec(ctx, "UPDATE `user` SET num_teams=num_teams-1 WHERE id=?", t.OrgID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return committer.Commit()
|
||||
}
|
||||
|
||||
// AddTeamMember adds new membership of given team to given organization,
|
||||
// the user will have membership to given organization automatically when needed.
|
||||
func AddTeamMember(ctx context.Context, team *organization.Team, user *user_model.User) error {
|
||||
if user_model.IsUserBlockedBy(ctx, user, team.OrgID) {
|
||||
return user_model.ErrBlockedUser
|
||||
}
|
||||
|
||||
isAlreadyMember, err := organization.IsTeamMember(ctx, team.OrgID, team.ID, user.ID)
|
||||
if err != nil || isAlreadyMember {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := organization.AddOrgUser(ctx, team.OrgID, user.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = db.WithTx(ctx, func(ctx context.Context) error {
|
||||
// check in transaction
|
||||
isAlreadyMember, err = organization.IsTeamMember(ctx, team.OrgID, team.ID, user.ID)
|
||||
if err != nil || isAlreadyMember {
|
||||
return err
|
||||
}
|
||||
|
||||
sess := db.GetEngine(ctx)
|
||||
|
||||
if err := db.Insert(ctx, &organization.TeamUser{
|
||||
UID: user.ID,
|
||||
OrgID: team.OrgID,
|
||||
TeamID: team.ID,
|
||||
}); err != nil {
|
||||
return err
|
||||
} else if _, err := sess.Incr("num_members").ID(team.ID).Update(new(organization.Team)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
team.NumMembers++
|
||||
|
||||
// Give access to team repositories.
|
||||
// update exist access if mode become bigger
|
||||
subQuery := builder.Select("repo_id").From("team_repo").
|
||||
Where(builder.Eq{"team_id": team.ID})
|
||||
|
||||
if _, err := sess.Where("user_id=?", user.ID).
|
||||
In("repo_id", subQuery).
|
||||
And("mode < ?", team.AccessMode).
|
||||
SetExpr("mode", team.AccessMode).
|
||||
Update(new(access_model.Access)); err != nil {
|
||||
return fmt.Errorf("update user accesses: %w", err)
|
||||
}
|
||||
|
||||
// for not exist access
|
||||
var repoIDs []int64
|
||||
accessSubQuery := builder.Select("repo_id").From("access").Where(builder.Eq{"user_id": user.ID})
|
||||
if err := sess.SQL(subQuery.And(builder.NotIn("repo_id", accessSubQuery))).Find(&repoIDs); err != nil {
|
||||
return fmt.Errorf("select id accesses: %w", err)
|
||||
}
|
||||
|
||||
accesses := make([]*access_model.Access, 0, 100)
|
||||
for i, repoID := range repoIDs {
|
||||
accesses = append(accesses, &access_model.Access{RepoID: repoID, UserID: user.ID, Mode: team.AccessMode})
|
||||
if (i%100 == 0 || i == len(repoIDs)-1) && len(accesses) > 0 {
|
||||
if err = db.Insert(ctx, accesses); err != nil {
|
||||
return fmt.Errorf("insert new user accesses: %w", err)
|
||||
}
|
||||
accesses = accesses[:0]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// this behaviour may spend much time so run it in a goroutine
|
||||
// FIXME: Update watch repos batchly
|
||||
if setting.Service.AutoWatchNewRepos {
|
||||
// Get team and its repositories.
|
||||
if err := team.LoadRepositories(ctx); err != nil {
|
||||
log.Error("team.LoadRepositories failed: %v", err)
|
||||
}
|
||||
|
||||
// FIXME: in the goroutine, it can't access the "ctx", it could only use db.DefaultContext at the moment
|
||||
go func(repos []*repo_model.Repository) {
|
||||
for _, repo := range repos {
|
||||
if err = repo_model.WatchRepo(db.DefaultContext, user, repo, true); err != nil {
|
||||
log.Error("watch repo failed: %v", err)
|
||||
}
|
||||
}
|
||||
}(team.Repos)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func removeTeamMember(ctx context.Context, team *organization.Team, user *user_model.User) error {
|
||||
e := db.GetEngine(ctx)
|
||||
isMember, err := organization.IsTeamMember(ctx, team.OrgID, team.ID, user.ID)
|
||||
if err != nil || !isMember {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check if the user to delete is the last member in owner team.
|
||||
if team.IsOwnerTeam() && team.NumMembers == 1 {
|
||||
return organization.ErrLastOrgOwner{UID: user.ID}
|
||||
}
|
||||
|
||||
team.NumMembers--
|
||||
|
||||
if err := team.LoadRepositories(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := e.Delete(&organization.TeamUser{
|
||||
UID: user.ID,
|
||||
OrgID: team.OrgID,
|
||||
TeamID: team.ID,
|
||||
}); err != nil {
|
||||
return err
|
||||
} else if _, err = e.
|
||||
ID(team.ID).
|
||||
Cols("num_members").
|
||||
Update(team); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Delete access to team repositories.
|
||||
for _, repo := range team.Repos {
|
||||
if err := access_model.RecalculateUserAccess(ctx, repo, user.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Remove watches from now unaccessible
|
||||
if err := repo_service.ReconsiderWatches(ctx, repo, user); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Remove issue assignments from now unaccessible
|
||||
if err := repo_service.ReconsiderRepoIssuesAssignee(ctx, repo, user); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return removeInvalidOrgUser(ctx, team.OrgID, user)
|
||||
}
|
||||
|
||||
func removeInvalidOrgUser(ctx context.Context, orgID int64, user *user_model.User) error {
|
||||
// Check if the user is a member of any team in the organization.
|
||||
if count, err := db.GetEngine(ctx).Count(&organization.TeamUser{
|
||||
UID: user.ID,
|
||||
OrgID: orgID,
|
||||
}); err != nil {
|
||||
return err
|
||||
} else if count == 0 {
|
||||
org, err := organization.GetOrgByID(ctx, orgID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return RemoveOrgUser(ctx, org, user)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveTeamMember removes member from given team of given organization.
|
||||
func RemoveTeamMember(ctx context.Context, team *organization.Team, user *user_model.User) error {
|
||||
ctx, committer, err := db.TxContext(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer committer.Close()
|
||||
if err := removeTeamMember(ctx, team, user); err != nil {
|
||||
return err
|
||||
}
|
||||
return committer.Commit()
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
// Copyright 2017 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package org
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
"code.gitea.io/gitea/models/organization"
|
||||
"code.gitea.io/gitea/models/perm"
|
||||
access_model "code.gitea.io/gitea/models/perm/access"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
"code.gitea.io/gitea/models/unittest"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/structs"
|
||||
repo_service "code.gitea.io/gitea/services/repository"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestTeam_AddMember(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
test := func(team *organization.Team, user *user_model.User) {
|
||||
assert.NoError(t, AddTeamMember(db.DefaultContext, team, user))
|
||||
unittest.AssertExistsAndLoadBean(t, &organization.TeamUser{UID: user.ID, TeamID: team.ID})
|
||||
unittest.CheckConsistencyFor(t, &organization.Team{ID: team.ID}, &user_model.User{ID: team.OrgID})
|
||||
}
|
||||
|
||||
team1 := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: 1})
|
||||
team3 := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: 3})
|
||||
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
user4 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4})
|
||||
|
||||
test(team1, user2)
|
||||
test(team1, user4)
|
||||
test(team3, user2)
|
||||
}
|
||||
|
||||
func TestTeam_RemoveMember(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
testSuccess := func(team *organization.Team, user *user_model.User) {
|
||||
assert.NoError(t, RemoveTeamMember(db.DefaultContext, team, user))
|
||||
unittest.AssertNotExistsBean(t, &organization.TeamUser{UID: user.ID, TeamID: team.ID})
|
||||
unittest.CheckConsistencyFor(t, &organization.Team{ID: team.ID})
|
||||
}
|
||||
|
||||
team1 := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: 1})
|
||||
team2 := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: 2})
|
||||
team3 := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: 3})
|
||||
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
user4 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4})
|
||||
|
||||
testSuccess(team1, user4)
|
||||
testSuccess(team2, user2)
|
||||
testSuccess(team3, user2)
|
||||
|
||||
err := RemoveTeamMember(db.DefaultContext, team1, user2)
|
||||
assert.True(t, organization.IsErrLastOrgOwner(err))
|
||||
}
|
||||
|
||||
func TestNewTeam(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
const teamName = "newTeamName"
|
||||
team := &organization.Team{Name: teamName, OrgID: 3}
|
||||
assert.NoError(t, NewTeam(db.DefaultContext, team))
|
||||
unittest.AssertExistsAndLoadBean(t, &organization.Team{Name: teamName})
|
||||
unittest.CheckConsistencyFor(t, &organization.Team{}, &user_model.User{ID: team.OrgID})
|
||||
}
|
||||
|
||||
func TestUpdateTeam(t *testing.T) {
|
||||
// successful update
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
team := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: 2})
|
||||
team.LowerName = "newname"
|
||||
team.Name = "newName"
|
||||
team.Description = strings.Repeat("A long description!", 100)
|
||||
team.AccessMode = perm.AccessModeAdmin
|
||||
assert.NoError(t, UpdateTeam(db.DefaultContext, team, true, false))
|
||||
|
||||
team = unittest.AssertExistsAndLoadBean(t, &organization.Team{Name: "newName"})
|
||||
assert.True(t, strings.HasPrefix(team.Description, "A long description!"))
|
||||
|
||||
access := unittest.AssertExistsAndLoadBean(t, &access_model.Access{UserID: 4, RepoID: 3})
|
||||
assert.EqualValues(t, perm.AccessModeAdmin, access.Mode)
|
||||
|
||||
unittest.CheckConsistencyFor(t, &organization.Team{ID: team.ID})
|
||||
}
|
||||
|
||||
func TestUpdateTeam2(t *testing.T) {
|
||||
// update to already-existing team
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
team := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: 2})
|
||||
team.LowerName = "owners"
|
||||
team.Name = "Owners"
|
||||
team.Description = strings.Repeat("A long description!", 100)
|
||||
err := UpdateTeam(db.DefaultContext, team, true, false)
|
||||
assert.True(t, organization.IsErrTeamAlreadyExist(err))
|
||||
|
||||
unittest.CheckConsistencyFor(t, &organization.Team{ID: team.ID})
|
||||
}
|
||||
|
||||
func TestDeleteTeam(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
team := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: 2})
|
||||
assert.NoError(t, DeleteTeam(db.DefaultContext, team))
|
||||
unittest.AssertNotExistsBean(t, &organization.Team{ID: team.ID})
|
||||
unittest.AssertNotExistsBean(t, &organization.TeamRepo{TeamID: team.ID})
|
||||
unittest.AssertNotExistsBean(t, &organization.TeamUser{TeamID: team.ID})
|
||||
|
||||
// check that team members don't have "leftover" access to repos
|
||||
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4})
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 3})
|
||||
accessMode, err := access_model.AccessLevel(db.DefaultContext, user, repo)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, accessMode < perm.AccessModeWrite)
|
||||
}
|
||||
|
||||
func TestAddTeamMember(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
test := func(team *organization.Team, user *user_model.User) {
|
||||
assert.NoError(t, AddTeamMember(db.DefaultContext, team, user))
|
||||
unittest.AssertExistsAndLoadBean(t, &organization.TeamUser{UID: user.ID, TeamID: team.ID})
|
||||
unittest.CheckConsistencyFor(t, &organization.Team{ID: team.ID}, &user_model.User{ID: team.OrgID})
|
||||
}
|
||||
|
||||
team1 := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: 1})
|
||||
team3 := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: 3})
|
||||
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
user4 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4})
|
||||
|
||||
test(team1, user2)
|
||||
test(team1, user4)
|
||||
test(team3, user2)
|
||||
}
|
||||
|
||||
func TestRemoveTeamMember(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
testSuccess := func(team *organization.Team, user *user_model.User) {
|
||||
assert.NoError(t, RemoveTeamMember(db.DefaultContext, team, user))
|
||||
unittest.AssertNotExistsBean(t, &organization.TeamUser{UID: user.ID, TeamID: team.ID})
|
||||
unittest.CheckConsistencyFor(t, &organization.Team{ID: team.ID})
|
||||
}
|
||||
|
||||
team1 := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: 1})
|
||||
team2 := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: 2})
|
||||
team3 := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: 3})
|
||||
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
user4 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4})
|
||||
|
||||
testSuccess(team1, user4)
|
||||
testSuccess(team2, user2)
|
||||
testSuccess(team3, user2)
|
||||
|
||||
err := RemoveTeamMember(db.DefaultContext, team1, user2)
|
||||
assert.True(t, organization.IsErrLastOrgOwner(err))
|
||||
}
|
||||
|
||||
func TestRepository_RecalculateAccesses3(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
team5 := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: 5})
|
||||
user29 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 29})
|
||||
|
||||
has, err := db.GetEngine(db.DefaultContext).Get(&access_model.Access{UserID: user29.ID, RepoID: 23})
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, has)
|
||||
|
||||
// adding user29 to team5 should add an explicit access row for repo 23
|
||||
// even though repo 23 is public
|
||||
assert.NoError(t, AddTeamMember(db.DefaultContext, team5, user29))
|
||||
|
||||
has, err = db.GetEngine(db.DefaultContext).Get(&access_model.Access{UserID: user29.ID, RepoID: 23})
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, has)
|
||||
}
|
||||
|
||||
func TestIncludesAllRepositoriesTeams(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
testTeamRepositories := func(teamID int64, repoIDs []int64) {
|
||||
team := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: teamID})
|
||||
assert.NoError(t, team.LoadRepositories(db.DefaultContext), "%s: GetRepositories", team.Name)
|
||||
assert.Len(t, team.Repos, team.NumRepos, "%s: len repo", team.Name)
|
||||
assert.Len(t, team.Repos, len(repoIDs), "%s: repo count", team.Name)
|
||||
for i, rid := range repoIDs {
|
||||
if rid > 0 {
|
||||
assert.True(t, repo_service.HasRepository(db.DefaultContext, team, rid), "%s: HasRepository(%d) %d", rid, i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get an admin user.
|
||||
user, err := user_model.GetUserByID(db.DefaultContext, 1)
|
||||
assert.NoError(t, err, "GetUserByID")
|
||||
|
||||
// Create org.
|
||||
org := &organization.Organization{
|
||||
Name: "All_repo",
|
||||
IsActive: true,
|
||||
Type: user_model.UserTypeOrganization,
|
||||
Visibility: structs.VisibleTypePublic,
|
||||
}
|
||||
assert.NoError(t, organization.CreateOrganization(db.DefaultContext, org, user), "CreateOrganization")
|
||||
|
||||
// Check Owner team.
|
||||
ownerTeam, err := org.GetOwnerTeam(db.DefaultContext)
|
||||
assert.NoError(t, err, "GetOwnerTeam")
|
||||
assert.True(t, ownerTeam.IncludesAllRepositories, "Owner team includes all repositories")
|
||||
|
||||
// Create repos.
|
||||
repoIDs := make([]int64, 0)
|
||||
for i := 0; i < 3; i++ {
|
||||
r, err := repo_service.CreateRepositoryDirectly(db.DefaultContext, user, org.AsUser(), repo_service.CreateRepoOptions{Name: fmt.Sprintf("repo-%d", i)})
|
||||
assert.NoError(t, err, "CreateRepository %d", i)
|
||||
if r != nil {
|
||||
repoIDs = append(repoIDs, r.ID)
|
||||
}
|
||||
}
|
||||
// Get fresh copy of Owner team after creating repos.
|
||||
ownerTeam, err = org.GetOwnerTeam(db.DefaultContext)
|
||||
assert.NoError(t, err, "GetOwnerTeam")
|
||||
|
||||
// Create teams and check repositories.
|
||||
teams := []*organization.Team{
|
||||
ownerTeam,
|
||||
{
|
||||
OrgID: org.ID,
|
||||
Name: "team one",
|
||||
AccessMode: perm.AccessModeRead,
|
||||
IncludesAllRepositories: true,
|
||||
},
|
||||
{
|
||||
OrgID: org.ID,
|
||||
Name: "team 2",
|
||||
AccessMode: perm.AccessModeRead,
|
||||
IncludesAllRepositories: false,
|
||||
},
|
||||
{
|
||||
OrgID: org.ID,
|
||||
Name: "team three",
|
||||
AccessMode: perm.AccessModeWrite,
|
||||
IncludesAllRepositories: true,
|
||||
},
|
||||
{
|
||||
OrgID: org.ID,
|
||||
Name: "team 4",
|
||||
AccessMode: perm.AccessModeWrite,
|
||||
IncludesAllRepositories: false,
|
||||
},
|
||||
}
|
||||
teamRepos := [][]int64{
|
||||
repoIDs,
|
||||
repoIDs,
|
||||
{},
|
||||
repoIDs,
|
||||
{},
|
||||
}
|
||||
for i, team := range teams {
|
||||
if i > 0 { // first team is Owner.
|
||||
assert.NoError(t, NewTeam(db.DefaultContext, team), "%s: NewTeam", team.Name)
|
||||
}
|
||||
testTeamRepositories(team.ID, teamRepos[i])
|
||||
}
|
||||
|
||||
// Update teams and check repositories.
|
||||
teams[3].IncludesAllRepositories = false
|
||||
teams[4].IncludesAllRepositories = true
|
||||
teamRepos[4] = repoIDs
|
||||
for i, team := range teams {
|
||||
assert.NoError(t, UpdateTeam(db.DefaultContext, team, false, true), "%s: UpdateTeam", team.Name)
|
||||
testTeamRepositories(team.ID, teamRepos[i])
|
||||
}
|
||||
|
||||
// Create repo and check teams repositories.
|
||||
r, err := repo_service.CreateRepositoryDirectly(db.DefaultContext, user, org.AsUser(), repo_service.CreateRepoOptions{Name: "repo-last"})
|
||||
assert.NoError(t, err, "CreateRepository last")
|
||||
if r != nil {
|
||||
repoIDs = append(repoIDs, r.ID)
|
||||
}
|
||||
teamRepos[0] = repoIDs
|
||||
teamRepos[1] = repoIDs
|
||||
teamRepos[4] = repoIDs
|
||||
for i, team := range teams {
|
||||
testTeamRepositories(team.ID, teamRepos[i])
|
||||
}
|
||||
|
||||
// Remove repo and check teams repositories.
|
||||
assert.NoError(t, repo_service.DeleteRepositoryDirectly(db.DefaultContext, user, repoIDs[0]), "DeleteRepository")
|
||||
teamRepos[0] = repoIDs[1:]
|
||||
teamRepos[1] = repoIDs[1:]
|
||||
teamRepos[3] = repoIDs[1:3]
|
||||
teamRepos[4] = repoIDs[1:]
|
||||
for i, team := range teams {
|
||||
testTeamRepositories(team.ID, teamRepos[i])
|
||||
}
|
||||
|
||||
// Wipe created items.
|
||||
for i, rid := range repoIDs {
|
||||
if i > 0 { // first repo already deleted.
|
||||
assert.NoError(t, repo_service.DeleteRepositoryDirectly(db.DefaultContext, user, rid), "DeleteRepository %d", i)
|
||||
}
|
||||
}
|
||||
assert.NoError(t, organization.DeleteOrganization(db.DefaultContext, org), "DeleteOrganization")
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
// Copyright 2014 The Gogs Authors. All rights reserved.
|
||||
// Copyright 2019 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package org
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
// RemoveOrgUser removes user from given organization.
|
||||
func RemoveOrgUser(ctx context.Context, org *organization.Organization, user *user_model.User) error {
|
||||
ou := new(organization.OrgUser)
|
||||
|
||||
has, err := db.GetEngine(ctx).
|
||||
Where("uid=?", user.ID).
|
||||
And("org_id=?", org.ID).
|
||||
Get(ou)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get org-user: %w", err)
|
||||
} else if !has {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if the user to delete is the last member in owner team.
|
||||
if isOwner, err := organization.IsOrganizationOwner(ctx, org.ID, user.ID); err != nil {
|
||||
return err
|
||||
} else if isOwner {
|
||||
t, err := organization.GetOwnerTeam(ctx, org.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if t.NumMembers == 1 {
|
||||
if err := t.LoadMembers(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if t.Members[0].ID == user.ID {
|
||||
return organization.ErrLastOrgOwner{UID: user.ID}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ctx, committer, err := db.TxContext(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer committer.Close()
|
||||
|
||||
if _, err := db.DeleteByID[organization.OrgUser](ctx, ou.ID); err != nil {
|
||||
return err
|
||||
} else if _, err = db.Exec(ctx, "UPDATE `user` SET num_members=num_members-1 WHERE id=?", org.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Delete all repository accesses and unwatch them.
|
||||
env, err := organization.AccessibleReposEnv(ctx, org, user.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("AccessibleReposEnv: %w", err)
|
||||
}
|
||||
repoIDs, err := env.RepoIDs(1, org.NumRepos)
|
||||
if err != nil {
|
||||
return fmt.Errorf("GetUserRepositories [%d]: %w", user.ID, err)
|
||||
}
|
||||
for _, repoID := range repoIDs {
|
||||
repo, err := repo_model.GetRepositoryByID(ctx, repoID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = repo_model.WatchRepo(ctx, user, repo, false); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if len(repoIDs) > 0 {
|
||||
if _, err = db.GetEngine(ctx).
|
||||
Where("user_id = ?", user.ID).
|
||||
In("repo_id", repoIDs).
|
||||
Delete(new(access_model.Access)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Delete member in their teams.
|
||||
teams, err := organization.GetUserOrgTeams(ctx, org.ID, user.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, t := range teams {
|
||||
if err = removeTeamMember(ctx, t, user); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return committer.Commit()
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
// Copyright 2017 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package org
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
"code.gitea.io/gitea/models/organization"
|
||||
"code.gitea.io/gitea/models/unittest"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestUser_RemoveMember(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
org := unittest.AssertExistsAndLoadBean(t, &organization.Organization{ID: 3})
|
||||
user4 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4})
|
||||
user5 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5})
|
||||
|
||||
// remove a user that is a member
|
||||
unittest.AssertExistsAndLoadBean(t, &organization.OrgUser{UID: user4.ID, OrgID: org.ID})
|
||||
prevNumMembers := org.NumMembers
|
||||
assert.NoError(t, RemoveOrgUser(db.DefaultContext, org, user4))
|
||||
unittest.AssertNotExistsBean(t, &organization.OrgUser{UID: user4.ID, OrgID: org.ID})
|
||||
|
||||
org = unittest.AssertExistsAndLoadBean(t, &organization.Organization{ID: org.ID})
|
||||
assert.Equal(t, prevNumMembers-1, org.NumMembers)
|
||||
|
||||
// remove a user that is not a member
|
||||
unittest.AssertNotExistsBean(t, &organization.OrgUser{UID: user5.ID, OrgID: org.ID})
|
||||
prevNumMembers = org.NumMembers
|
||||
assert.NoError(t, RemoveOrgUser(db.DefaultContext, org, user5))
|
||||
unittest.AssertNotExistsBean(t, &organization.OrgUser{UID: user5.ID, OrgID: org.ID})
|
||||
|
||||
org = unittest.AssertExistsAndLoadBean(t, &organization.Organization{ID: org.ID})
|
||||
assert.Equal(t, prevNumMembers, org.NumMembers)
|
||||
|
||||
unittest.CheckConsistencyFor(t, &user_model.User{}, &organization.Team{})
|
||||
}
|
||||
|
||||
func TestRemoveOrgUser(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
testSuccess := func(org *organization.Organization, user *user_model.User) {
|
||||
expectedNumMembers := org.NumMembers
|
||||
if unittest.BeanExists(t, &organization.OrgUser{OrgID: org.ID, UID: user.ID}) {
|
||||
expectedNumMembers--
|
||||
}
|
||||
assert.NoError(t, RemoveOrgUser(db.DefaultContext, org, user))
|
||||
unittest.AssertNotExistsBean(t, &organization.OrgUser{OrgID: org.ID, UID: user.ID})
|
||||
org = unittest.AssertExistsAndLoadBean(t, &organization.Organization{ID: org.ID})
|
||||
assert.EqualValues(t, expectedNumMembers, org.NumMembers)
|
||||
}
|
||||
|
||||
org3 := unittest.AssertExistsAndLoadBean(t, &organization.Organization{ID: 3})
|
||||
org7 := unittest.AssertExistsAndLoadBean(t, &organization.Organization{ID: 7})
|
||||
user4 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4})
|
||||
user5 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5})
|
||||
|
||||
testSuccess(org3, user4)
|
||||
|
||||
org3 = unittest.AssertExistsAndLoadBean(t, &organization.Organization{ID: 3})
|
||||
testSuccess(org3, user4)
|
||||
|
||||
err := RemoveOrgUser(db.DefaultContext, org7, user5)
|
||||
assert.Error(t, err)
|
||||
assert.True(t, organization.IsErrLastOrgOwner(err))
|
||||
unittest.AssertExistsAndLoadBean(t, &organization.OrgUser{OrgID: org7.ID, UID: user5.ID})
|
||||
unittest.CheckConsistencyFor(t, &user_model.User{}, &organization.Team{})
|
||||
}
|
||||
@@ -72,7 +72,7 @@ func GetOrCreateKeyPair(ctx context.Context, ownerID int64) (string, string, err
|
||||
return priv, pub, nil
|
||||
}
|
||||
|
||||
// BuildAllRepositoryFiles (re)builds all repository files for every available distributions, components and architectures
|
||||
// BuildAllRepositoryFiles (re)builds all repository files for every available branches, repositories and architectures
|
||||
func BuildAllRepositoryFiles(ctx context.Context, ownerID int64) error {
|
||||
pv, err := GetOrCreateRepositoryVersion(ctx, ownerID)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,401 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package arch
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
packages_model "code.gitea.io/gitea/models/packages"
|
||||
arch_model "code.gitea.io/gitea/models/packages/arch"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/container"
|
||||
"code.gitea.io/gitea/modules/globallock"
|
||||
"code.gitea.io/gitea/modules/json"
|
||||
packages_module "code.gitea.io/gitea/modules/packages"
|
||||
arch_module "code.gitea.io/gitea/modules/packages/arch"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
packages_service "code.gitea.io/gitea/services/packages"
|
||||
|
||||
"github.com/keybase/go-crypto/openpgp"
|
||||
"github.com/keybase/go-crypto/openpgp/armor"
|
||||
"github.com/keybase/go-crypto/openpgp/packet"
|
||||
)
|
||||
|
||||
const (
|
||||
IndexArchiveFilename = "packages.db"
|
||||
)
|
||||
|
||||
func AquireRegistryLock(ctx context.Context, ownerID int64) (globallock.ReleaseFunc, error) {
|
||||
return globallock.Lock(ctx, fmt.Sprintf("packages_arch_%d", ownerID))
|
||||
}
|
||||
|
||||
// GetOrCreateRepositoryVersion gets or creates the internal repository package
|
||||
// The Arch registry needs multiple index files which are stored in this package.
|
||||
func GetOrCreateRepositoryVersion(ctx context.Context, ownerID int64) (*packages_model.PackageVersion, error) {
|
||||
return packages_service.GetOrCreateInternalPackageVersion(ctx, ownerID, packages_model.TypeArch, arch_module.RepositoryPackage, arch_module.RepositoryVersion)
|
||||
}
|
||||
|
||||
// GetOrCreateKeyPair gets or creates the PGP keys used to sign repository files
|
||||
func GetOrCreateKeyPair(ctx context.Context, ownerID int64) (string, string, error) {
|
||||
priv, err := user_model.GetSetting(ctx, ownerID, arch_module.SettingKeyPrivate)
|
||||
if err != nil && !errors.Is(err, util.ErrNotExist) {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
pub, err := user_model.GetSetting(ctx, ownerID, arch_module.SettingKeyPublic)
|
||||
if err != nil && !errors.Is(err, util.ErrNotExist) {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
if priv == "" || pub == "" {
|
||||
priv, pub, err = generateKeypair()
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
if err := user_model.SetUserSetting(ctx, ownerID, arch_module.SettingKeyPrivate, priv); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
if err := user_model.SetUserSetting(ctx, ownerID, arch_module.SettingKeyPublic, pub); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
}
|
||||
|
||||
return priv, pub, nil
|
||||
}
|
||||
|
||||
func generateKeypair() (string, string, error) {
|
||||
e, err := openpgp.NewEntity("", "Arch Registry", "", nil)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
var priv strings.Builder
|
||||
var pub strings.Builder
|
||||
|
||||
w, err := armor.Encode(&priv, openpgp.PrivateKeyType, nil)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if err := e.SerializePrivate(w, nil); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
w.Close()
|
||||
|
||||
w, err = armor.Encode(&pub, openpgp.PublicKeyType, nil)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if err := e.Serialize(w); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
w.Close()
|
||||
|
||||
return priv.String(), pub.String(), nil
|
||||
}
|
||||
|
||||
func SignData(ctx context.Context, ownerID int64, r io.Reader) ([]byte, error) {
|
||||
priv, _, err := GetOrCreateKeyPair(ctx, ownerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
block, err := armor.Decode(strings.NewReader(priv))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
e, err := openpgp.ReadEntity(packet.NewReader(block.Body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
buf := &bytes.Buffer{}
|
||||
if err := openpgp.DetachSign(buf, e, r, nil); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// BuildAllRepositoryFiles (re)builds all repository files for every available repositories and architectures
|
||||
func BuildAllRepositoryFiles(ctx context.Context, ownerID int64) error {
|
||||
pv, err := GetOrCreateRepositoryVersion(ctx, ownerID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 1. Delete all existing repository files
|
||||
pfs, err := packages_model.GetFilesByVersionID(ctx, pv.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, pf := range pfs {
|
||||
if err := packages_service.DeletePackageFile(ctx, pf); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// 2. (Re)Build repository files for existing packages
|
||||
repositories, err := arch_model.GetRepositories(ctx, ownerID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, repository := range repositories {
|
||||
architectures, err := arch_model.GetArchitectures(ctx, ownerID, repository)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, architecture := range architectures {
|
||||
if err := buildPackagesIndex(ctx, ownerID, pv, repository, architecture); err != nil {
|
||||
return fmt.Errorf("failed to build repository files [%s/%s]: %w", repository, architecture, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// BuildSpecificRepositoryFiles builds index files for the repository
|
||||
func BuildSpecificRepositoryFiles(ctx context.Context, ownerID int64, repository, architecture string) error {
|
||||
pv, err := GetOrCreateRepositoryVersion(ctx, ownerID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
architectures := container.SetOf(architecture)
|
||||
if architecture == arch_module.AnyArch {
|
||||
// Update all other architectures too when updating the any index
|
||||
additionalArchitectures, err := arch_model.GetArchitectures(ctx, ownerID, repository)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
architectures.AddMultiple(additionalArchitectures...)
|
||||
}
|
||||
|
||||
for architecture := range architectures {
|
||||
if err := buildPackagesIndex(ctx, ownerID, pv, repository, architecture); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func searchPackageFiles(ctx context.Context, ownerID int64, repository, architecture string) ([]*packages_model.PackageFile, error) {
|
||||
pfs, _, err := packages_model.SearchFiles(ctx, &packages_model.PackageFileSearchOptions{
|
||||
OwnerID: ownerID,
|
||||
PackageType: packages_model.TypeArch,
|
||||
Query: "%.pkg.tar.%",
|
||||
Properties: map[string]string{
|
||||
arch_module.PropertyRepository: repository,
|
||||
arch_module.PropertyArchitecture: architecture,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return pfs, nil
|
||||
}
|
||||
|
||||
func buildPackagesIndex(ctx context.Context, ownerID int64, repoVersion *packages_model.PackageVersion, repository, architecture string) error {
|
||||
pfs, err := searchPackageFiles(ctx, ownerID, repository, architecture)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if architecture != arch_module.AnyArch {
|
||||
// Add all any packages too
|
||||
anyarchFiles, err := searchPackageFiles(ctx, ownerID, repository, arch_module.AnyArch)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pfs = append(pfs, anyarchFiles...)
|
||||
}
|
||||
|
||||
// Delete the package indices if there are no packages
|
||||
if len(pfs) == 0 {
|
||||
pf, err := packages_model.GetFileForVersionByName(ctx, repoVersion.ID, IndexArchiveFilename, fmt.Sprintf("%s|%s", repository, architecture))
|
||||
if err != nil && !errors.Is(err, util.ErrNotExist) {
|
||||
return err
|
||||
} else if pf == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return packages_service.DeletePackageFile(ctx, pf)
|
||||
}
|
||||
|
||||
indexContent, _ := packages_module.NewHashedBuffer()
|
||||
defer indexContent.Close()
|
||||
|
||||
gw := gzip.NewWriter(indexContent)
|
||||
tw := tar.NewWriter(gw)
|
||||
|
||||
cache := make(map[int64]*packages_model.Package)
|
||||
|
||||
for _, pf := range pfs {
|
||||
opts := &entryOptions{
|
||||
File: pf,
|
||||
}
|
||||
|
||||
opts.Version, err = packages_model.GetVersionByID(ctx, pf.VersionID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := json.Unmarshal([]byte(opts.Version.MetadataJSON), &opts.VersionMetadata); err != nil {
|
||||
return err
|
||||
}
|
||||
opts.Package = cache[opts.Version.PackageID]
|
||||
if opts.Package == nil {
|
||||
opts.Package, err = packages_model.GetPackageByID(ctx, opts.Version.PackageID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cache[opts.Package.ID] = opts.Package
|
||||
}
|
||||
opts.Blob, err = packages_model.GetBlobByID(ctx, pf.BlobID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sig, err := packages_model.GetPropertiesByName(ctx, packages_model.PropertyTypeFile, pf.ID, arch_module.PropertySignature)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(sig) == 0 {
|
||||
return util.ErrNotExist
|
||||
}
|
||||
opts.Signature = sig[0].Value
|
||||
|
||||
meta, err := packages_model.GetPropertiesByName(ctx, packages_model.PropertyTypeFile, pf.ID, arch_module.PropertyMetadata)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(meta) == 0 {
|
||||
return util.ErrNotExist
|
||||
}
|
||||
if err := json.Unmarshal([]byte(meta[0].Value), &opts.FileMetadata); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := writeFiles(tw, opts); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeDescription(tw, opts); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
tw.Close()
|
||||
gw.Close()
|
||||
|
||||
signature, err := SignData(ctx, ownerID, indexContent)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := indexContent.Seek(0, io.SeekStart); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = packages_service.AddFileToPackageVersionInternal(
|
||||
ctx,
|
||||
repoVersion,
|
||||
&packages_service.PackageFileCreationInfo{
|
||||
PackageFileInfo: packages_service.PackageFileInfo{
|
||||
Filename: IndexArchiveFilename,
|
||||
CompositeKey: fmt.Sprintf("%s|%s", repository, architecture),
|
||||
},
|
||||
Creator: user_model.NewGhostUser(),
|
||||
Data: indexContent,
|
||||
IsLead: false,
|
||||
OverwriteExisting: true,
|
||||
Properties: map[string]string{
|
||||
arch_module.PropertyRepository: repository,
|
||||
arch_module.PropertyArchitecture: architecture,
|
||||
arch_module.PropertySignature: base64.StdEncoding.EncodeToString(signature),
|
||||
},
|
||||
},
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
type entryOptions struct {
|
||||
Package *packages_model.Package
|
||||
Version *packages_model.PackageVersion
|
||||
VersionMetadata *arch_module.VersionMetadata
|
||||
File *packages_model.PackageFile
|
||||
FileMetadata *arch_module.FileMetadata
|
||||
Blob *packages_model.PackageBlob
|
||||
Signature string
|
||||
}
|
||||
|
||||
type keyValue struct {
|
||||
Key string
|
||||
Value string
|
||||
}
|
||||
|
||||
func writeFiles(tw *tar.Writer, opts *entryOptions) error {
|
||||
return writeFields(tw, fmt.Sprintf("%s-%s/files", opts.Package.Name, opts.Version.Version), []keyValue{
|
||||
{"FILES", strings.Join(opts.FileMetadata.Files, "\n")},
|
||||
})
|
||||
}
|
||||
|
||||
// https://gitlab.archlinux.org/pacman/pacman/-/blob/master/lib/libalpm/be_sync.c#L562
|
||||
func writeDescription(tw *tar.Writer, opts *entryOptions) error {
|
||||
return writeFields(tw, fmt.Sprintf("%s-%s/desc", opts.Package.Name, opts.Version.Version), []keyValue{
|
||||
{"FILENAME", opts.File.Name},
|
||||
{"MD5SUM", opts.Blob.HashMD5},
|
||||
{"SHA256SUM", opts.Blob.HashSHA256},
|
||||
{"PGPSIG", opts.Signature},
|
||||
{"CSIZE", fmt.Sprintf("%d", opts.Blob.Size)},
|
||||
{"ISIZE", fmt.Sprintf("%d", opts.FileMetadata.InstalledSize)},
|
||||
{"NAME", opts.Package.Name},
|
||||
{"BASE", opts.FileMetadata.Base},
|
||||
{"ARCH", opts.FileMetadata.Architecture},
|
||||
{"VERSION", opts.Version.Version},
|
||||
{"DESC", opts.VersionMetadata.Description},
|
||||
{"URL", opts.VersionMetadata.ProjectURL},
|
||||
{"LICENSE", strings.Join(opts.VersionMetadata.Licenses, "\n")},
|
||||
{"GROUPS", strings.Join(opts.FileMetadata.Groups, "\n")},
|
||||
{"BUILDDATE", fmt.Sprintf("%d", opts.FileMetadata.BuildDate)},
|
||||
{"PACKAGER", opts.FileMetadata.Packager},
|
||||
{"PROVIDES", strings.Join(opts.FileMetadata.Provides, "\n")},
|
||||
{"DEPENDS", strings.Join(opts.FileMetadata.Depends, "\n")},
|
||||
{"OPTDEPENDS", strings.Join(opts.FileMetadata.OptDepends, "\n")},
|
||||
{"MAKEDEPENDS", strings.Join(opts.FileMetadata.MakeDepends, "\n")},
|
||||
{"CHECKDEPENDS", strings.Join(opts.FileMetadata.CheckDepends, "\n")},
|
||||
{"XDATA", strings.Join(opts.FileMetadata.XData, "\n")},
|
||||
})
|
||||
}
|
||||
|
||||
func writeFields(tw *tar.Writer, filename string, fields []keyValue) error {
|
||||
buf := &bytes.Buffer{}
|
||||
for _, kv := range fields {
|
||||
if kv.Value == "" {
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(buf, "%%%s%%\n%s\n\n", kv.Key, kv.Value)
|
||||
}
|
||||
|
||||
if err := tw.WriteHeader(&tar.Header{
|
||||
Name: filename,
|
||||
Size: int64(buf.Len()),
|
||||
Mode: int64(os.ModePerm),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err := io.Copy(tw, buf)
|
||||
return err
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
packages_module "code.gitea.io/gitea/modules/packages"
|
||||
packages_service "code.gitea.io/gitea/services/packages"
|
||||
alpine_service "code.gitea.io/gitea/services/packages/alpine"
|
||||
arch_service "code.gitea.io/gitea/services/packages/arch"
|
||||
cargo_service "code.gitea.io/gitea/services/packages/cargo"
|
||||
container_service "code.gitea.io/gitea/services/packages/container"
|
||||
debian_service "code.gitea.io/gitea/services/packages/debian"
|
||||
@@ -120,18 +121,29 @@ func ExecuteCleanupRules(outerCtx context.Context) error {
|
||||
}
|
||||
|
||||
if anyVersionDeleted {
|
||||
if pcr.Type == packages_model.TypeDebian {
|
||||
switch pcr.Type {
|
||||
case packages_model.TypeDebian:
|
||||
if err := debian_service.BuildAllRepositoryFiles(ctx, pcr.OwnerID); err != nil {
|
||||
return fmt.Errorf("CleanupRule [%d]: debian.BuildAllRepositoryFiles failed: %w", pcr.ID, err)
|
||||
}
|
||||
} else if pcr.Type == packages_model.TypeAlpine {
|
||||
case packages_model.TypeAlpine:
|
||||
if err := alpine_service.BuildAllRepositoryFiles(ctx, pcr.OwnerID); err != nil {
|
||||
return fmt.Errorf("CleanupRule [%d]: alpine.BuildAllRepositoryFiles failed: %w", pcr.ID, err)
|
||||
}
|
||||
} else if pcr.Type == packages_model.TypeRpm {
|
||||
case packages_model.TypeRpm:
|
||||
if err := rpm_service.BuildAllRepositoryFiles(ctx, pcr.OwnerID); err != nil {
|
||||
return fmt.Errorf("CleanupRule [%d]: rpm.BuildAllRepositoryFiles failed: %w", pcr.ID, err)
|
||||
}
|
||||
case packages_model.TypeArch:
|
||||
release, err := arch_service.AquireRegistryLock(ctx, pcr.OwnerID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer release()
|
||||
|
||||
if err := arch_service.BuildAllRepositoryFiles(ctx, pcr.OwnerID); err != nil {
|
||||
return fmt.Errorf("CleanupRule [%d]: arch.BuildAllRepositoryFiles failed: %w", pcr.ID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -355,6 +355,8 @@ func CheckSizeQuotaExceeded(ctx context.Context, doer, owner *user_model.User, p
|
||||
switch packageType {
|
||||
case packages_model.TypeAlpine:
|
||||
typeSpecificSize = setting.Packages.LimitSizeAlpine
|
||||
case packages_model.TypeArch:
|
||||
typeSpecificSize = setting.Packages.LimitSizeArch
|
||||
case packages_model.TypeCargo:
|
||||
typeSpecificSize = setting.Packages.LimitSizeCargo
|
||||
case packages_model.TypeChef:
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package pull
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
"code.gitea.io/gitea/models/organization"
|
||||
"code.gitea.io/gitea/models/perm"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
"code.gitea.io/gitea/models/unit"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/container"
|
||||
|
||||
"xorm.io/builder"
|
||||
)
|
||||
|
||||
// GetReviewers get all users can be requested to review:
|
||||
// - Poster should not be listed
|
||||
// - For collaborator, all users that have read access or higher to the repository.
|
||||
// - For repository under organization, users under the teams which have read permission or higher of pull request unit
|
||||
// - Owner will be listed if it's not an organization, not the poster and not in the list of reviewers
|
||||
func GetReviewers(ctx context.Context, repo *repo_model.Repository, doerID, posterID int64) ([]*user_model.User, error) {
|
||||
if err := repo.LoadOwner(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
e := db.GetEngine(ctx)
|
||||
uniqueUserIDs := make(container.Set[int64])
|
||||
|
||||
collaboratorIDs := make([]int64, 0, 10)
|
||||
if err := e.Table("collaboration").Where("repo_id=?", repo.ID).
|
||||
And("mode >= ?", perm.AccessModeRead).
|
||||
Select("user_id").
|
||||
Find(&collaboratorIDs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
uniqueUserIDs.AddMultiple(collaboratorIDs...)
|
||||
|
||||
if repo.Owner.IsOrganization() {
|
||||
additionalUserIDs := make([]int64, 0, 10)
|
||||
if err := e.Table("team_user").
|
||||
Join("INNER", "team_repo", "`team_repo`.team_id = `team_user`.team_id").
|
||||
Join("INNER", "team_unit", "`team_unit`.team_id = `team_user`.team_id").
|
||||
Where("`team_repo`.repo_id = ? AND (`team_unit`.access_mode >= ? AND `team_unit`.`type` = ?)",
|
||||
repo.ID, perm.AccessModeRead, unit.TypePullRequests).
|
||||
Distinct("`team_user`.uid").
|
||||
Select("`team_user`.uid").
|
||||
Find(&additionalUserIDs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
uniqueUserIDs.AddMultiple(additionalUserIDs...)
|
||||
}
|
||||
|
||||
uniqueUserIDs.Remove(posterID) // posterID should not be in the list of reviewers
|
||||
|
||||
// Leave a seat for owner itself to append later, but if owner is an organization
|
||||
// and just waste 1 unit is cheaper than re-allocate memory once.
|
||||
users := make([]*user_model.User, 0, len(uniqueUserIDs)+1)
|
||||
if len(uniqueUserIDs) > 0 {
|
||||
if err := e.In("id", uniqueUserIDs.Values()).
|
||||
Where(builder.Eq{"`user`.is_active": true}).
|
||||
OrderBy(user_model.GetOrderByName()).
|
||||
Find(&users); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// add owner after all users are loaded because we can avoid load owner twice
|
||||
if repo.OwnerID != posterID && !repo.Owner.IsOrganization() && !uniqueUserIDs.Contains(repo.OwnerID) {
|
||||
users = append(users, repo.Owner)
|
||||
}
|
||||
|
||||
return users, nil
|
||||
}
|
||||
|
||||
// GetReviewerTeams get all teams can be requested to review
|
||||
func GetReviewerTeams(ctx context.Context, repo *repo_model.Repository) ([]*organization.Team, error) {
|
||||
if err := repo.LoadOwner(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !repo.Owner.IsOrganization() {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return organization.GetTeamsWithAccessToRepoUnit(ctx, repo.OwnerID, repo.ID, perm.AccessModeRead, unit.TypePullRequests)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package pull_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
"code.gitea.io/gitea/models/unittest"
|
||||
pull_service "code.gitea.io/gitea/services/pull"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestRepoGetReviewers(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
// test public repo
|
||||
repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
||||
|
||||
ctx := db.DefaultContext
|
||||
reviewers, err := pull_service.GetReviewers(ctx, repo1, 2, 0)
|
||||
assert.NoError(t, err)
|
||||
if assert.Len(t, reviewers, 1) {
|
||||
assert.ElementsMatch(t, []int64{2}, []int64{reviewers[0].ID})
|
||||
}
|
||||
|
||||
// should not include doer and remove the poster
|
||||
reviewers, err = pull_service.GetReviewers(ctx, repo1, 11, 2)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, reviewers, 0)
|
||||
|
||||
// should not include PR poster, if PR poster would be otherwise eligible
|
||||
reviewers, err = pull_service.GetReviewers(ctx, repo1, 11, 4)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, reviewers, 1)
|
||||
|
||||
// test private user repo
|
||||
repo2 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2})
|
||||
|
||||
reviewers, err = pull_service.GetReviewers(ctx, repo2, 2, 4)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, reviewers, 1)
|
||||
assert.EqualValues(t, reviewers[0].ID, 2)
|
||||
|
||||
// test private org repo
|
||||
repo3 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 3})
|
||||
|
||||
reviewers, err = pull_service.GetReviewers(ctx, repo3, 2, 1)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, reviewers, 2)
|
||||
|
||||
reviewers, err = pull_service.GetReviewers(ctx, repo3, 2, 2)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, reviewers, 1)
|
||||
}
|
||||
|
||||
func TestRepoGetReviewerTeams(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
repo2 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2})
|
||||
teams, err := pull_service.GetReviewerTeams(db.DefaultContext, repo2)
|
||||
assert.NoError(t, err)
|
||||
assert.Empty(t, teams)
|
||||
|
||||
repo3 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 3})
|
||||
teams, err = pull_service.GetReviewerTeams(db.DefaultContext, repo3)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, teams, 2)
|
||||
}
|
||||
@@ -67,30 +67,36 @@ func (e RepoRefNotFoundError) Is(err error) bool {
|
||||
return ok
|
||||
}
|
||||
|
||||
// NewRequest creates an archival request, based on the URI. The
|
||||
// resulting ArchiveRequest is suitable for being passed to Await()
|
||||
// if it's determined that the request still needs to be satisfied.
|
||||
func NewRequest(repoID int64, repo *git.Repository, uri string) (*ArchiveRequest, error) {
|
||||
r := &ArchiveRequest{
|
||||
RepoID: repoID,
|
||||
}
|
||||
|
||||
var ext string
|
||||
func ParseFileName(uri string) (ext string, tp git.ArchiveType, err error) {
|
||||
switch {
|
||||
case strings.HasSuffix(uri, ".zip"):
|
||||
ext = ".zip"
|
||||
r.Type = git.ZIP
|
||||
tp = git.ZIP
|
||||
case strings.HasSuffix(uri, ".tar.gz"):
|
||||
ext = ".tar.gz"
|
||||
r.Type = git.TARGZ
|
||||
tp = git.TARGZ
|
||||
case strings.HasSuffix(uri, ".bundle"):
|
||||
ext = ".bundle"
|
||||
r.Type = git.BUNDLE
|
||||
tp = git.BUNDLE
|
||||
default:
|
||||
return nil, ErrUnknownArchiveFormat{RequestFormat: uri}
|
||||
return "", 0, ErrUnknownArchiveFormat{RequestFormat: uri}
|
||||
}
|
||||
return ext, tp, nil
|
||||
}
|
||||
|
||||
// NewRequest creates an archival request, based on the URI. The
|
||||
// resulting ArchiveRequest is suitable for being passed to Await()
|
||||
// if it's determined that the request still needs to be satisfied.
|
||||
func NewRequest(repoID int64, repo *git.Repository, refName string, fileType git.ArchiveType) (*ArchiveRequest, error) {
|
||||
if fileType < git.ZIP || fileType > git.BUNDLE {
|
||||
return nil, ErrUnknownArchiveFormat{RequestFormat: fileType.String()}
|
||||
}
|
||||
|
||||
r.refName = strings.TrimSuffix(uri, ext)
|
||||
r := &ArchiveRequest{
|
||||
RepoID: repoID,
|
||||
refName: refName,
|
||||
Type: fileType,
|
||||
}
|
||||
|
||||
// Get corresponding commit.
|
||||
commitID, err := repo.ConvertToGitID(r.refName)
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
"code.gitea.io/gitea/models/unittest"
|
||||
"code.gitea.io/gitea/modules/git"
|
||||
"code.gitea.io/gitea/services/contexttest"
|
||||
|
||||
_ "code.gitea.io/gitea/models/actions"
|
||||
@@ -31,47 +32,47 @@ func TestArchive_Basic(t *testing.T) {
|
||||
contexttest.LoadGitRepo(t, ctx)
|
||||
defer ctx.Repo.GitRepo.Close()
|
||||
|
||||
bogusReq, err := NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, firstCommit+".zip")
|
||||
bogusReq, err := NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, firstCommit, git.ZIP)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, bogusReq)
|
||||
assert.EqualValues(t, firstCommit+".zip", bogusReq.GetArchiveName())
|
||||
|
||||
// Check a series of bogus requests.
|
||||
// Step 1, valid commit with a bad extension.
|
||||
bogusReq, err = NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, firstCommit+".dilbert")
|
||||
bogusReq, err = NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, firstCommit, 100)
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, bogusReq)
|
||||
|
||||
// Step 2, missing commit.
|
||||
bogusReq, err = NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, "dbffff.zip")
|
||||
bogusReq, err = NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, "dbffff", git.ZIP)
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, bogusReq)
|
||||
|
||||
// Step 3, doesn't look like branch/tag/commit.
|
||||
bogusReq, err = NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, "db.zip")
|
||||
bogusReq, err = NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, "db", git.ZIP)
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, bogusReq)
|
||||
|
||||
bogusReq, err = NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, "master.zip")
|
||||
bogusReq, err = NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, "master", git.ZIP)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, bogusReq)
|
||||
assert.EqualValues(t, "master.zip", bogusReq.GetArchiveName())
|
||||
|
||||
bogusReq, err = NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, "test/archive.zip")
|
||||
bogusReq, err = NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, "test/archive", git.ZIP)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, bogusReq)
|
||||
assert.EqualValues(t, "test-archive.zip", bogusReq.GetArchiveName())
|
||||
|
||||
// Now two valid requests, firstCommit with valid extensions.
|
||||
zipReq, err := NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, firstCommit+".zip")
|
||||
zipReq, err := NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, firstCommit, git.ZIP)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, zipReq)
|
||||
|
||||
tgzReq, err := NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, firstCommit+".tar.gz")
|
||||
tgzReq, err := NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, firstCommit, git.TARGZ)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, tgzReq)
|
||||
|
||||
secondReq, err := NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, secondCommit+".zip")
|
||||
secondReq, err := NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, secondCommit, git.ZIP)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, secondReq)
|
||||
|
||||
@@ -91,7 +92,7 @@ func TestArchive_Basic(t *testing.T) {
|
||||
// Sleep two seconds to make sure the queue doesn't change.
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
zipReq2, err := NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, firstCommit+".zip")
|
||||
zipReq2, err := NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, firstCommit, git.ZIP)
|
||||
assert.NoError(t, err)
|
||||
// This zipReq should match what's sitting in the queue, as we haven't
|
||||
// let it release yet. From the consumer's point of view, this looks like
|
||||
@@ -106,12 +107,12 @@ func TestArchive_Basic(t *testing.T) {
|
||||
// Now we'll submit a request and TimedWaitForCompletion twice, before and
|
||||
// after we release it. We should trigger both the timeout and non-timeout
|
||||
// cases.
|
||||
timedReq, err := NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, secondCommit+".tar.gz")
|
||||
timedReq, err := NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, secondCommit, git.TARGZ)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, timedReq)
|
||||
doArchive(db.DefaultContext, timedReq)
|
||||
|
||||
zipReq2, err = NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, firstCommit+".zip")
|
||||
zipReq2, err = NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, firstCommit, git.ZIP)
|
||||
assert.NoError(t, err)
|
||||
// Now, we're guaranteed to have released the original zipReq from the queue.
|
||||
// Ensure that we don't get handed back the released entry somehow, but they
|
||||
|
||||
@@ -466,24 +466,17 @@ var (
|
||||
ErrInsufficientAccess = errors.New("insufficient access")
|
||||
)
|
||||
|
||||
// DeleteBranch delete branch
|
||||
func DeleteBranch(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, gitRepo *git.Repository, branchName string, pr *issues_model.PullRequest) error {
|
||||
func CanDeleteBranch(ctx context.Context, repo *repo_model.Repository, branchName string, doer *user_model.User) error {
|
||||
if branchName == repo.DefaultBranch {
|
||||
return ErrBranchIsDefault
|
||||
}
|
||||
|
||||
perm, err := access_model.GetUserRepoPermission(ctx, repo, doer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !perm.CanWrite(unit.TypeCode) {
|
||||
return ErrInsufficientAccess
|
||||
}
|
||||
|
||||
err = repo.MustNotBeArchived()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if branchName == repo.DefaultBranch {
|
||||
return ErrBranchIsDefault
|
||||
return util.NewPermissionDeniedErrorf("permission denied to access repo %d unit %s", repo.ID, unit.TypeCode.LogString())
|
||||
}
|
||||
|
||||
isProtected, err := git_model.IsBranchProtected(ctx, repo.ID, branchName)
|
||||
@@ -493,6 +486,19 @@ func DeleteBranch(ctx context.Context, doer *user_model.User, repo *repo_model.R
|
||||
if isProtected {
|
||||
return git_model.ErrBranchIsProtected
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteBranch delete branch
|
||||
func DeleteBranch(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, gitRepo *git.Repository, branchName string, pr *issues_model.PullRequest) error {
|
||||
err := repo.MustNotBeArchived()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := CanDeleteBranch(ctx, repo, branchName, doer); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rawBranch, err := git_model.GetBranch(ctx, repo.ID, branchName)
|
||||
if err != nil && !git_model.IsErrBranchNotExist(err) {
|
||||
|
||||
@@ -6,9 +6,10 @@ package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"code.gitea.io/gitea/models"
|
||||
"code.gitea.io/gitea/models/db"
|
||||
issues_model "code.gitea.io/gitea/models/issues"
|
||||
"code.gitea.io/gitea/models/perm"
|
||||
access_model "code.gitea.io/gitea/models/perm/access"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
@@ -94,14 +95,39 @@ func DeleteCollaboration(ctx context.Context, repo *repo_model.Repository, colla
|
||||
return err
|
||||
}
|
||||
|
||||
if err = models.ReconsiderWatches(ctx, repo, collaborator); err != nil {
|
||||
if err = ReconsiderWatches(ctx, repo, collaborator); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Unassign a user from any issue (s)he has been assigned to in the repository
|
||||
if err := models.ReconsiderRepoIssuesAssignee(ctx, repo, collaborator); err != nil {
|
||||
if err := ReconsiderRepoIssuesAssignee(ctx, repo, collaborator); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return committer.Commit()
|
||||
}
|
||||
|
||||
func ReconsiderRepoIssuesAssignee(ctx context.Context, repo *repo_model.Repository, user *user_model.User) error {
|
||||
if canAssigned, err := access_model.CanBeAssigned(ctx, user, repo, true); err != nil || canAssigned {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := db.GetEngine(ctx).Where(builder.Eq{"assignee_id": user.ID}).
|
||||
In("issue_id", builder.Select("id").From("issue").Where(builder.Eq{"repo_id": repo.ID})).
|
||||
Delete(&issues_model.IssueAssignees{}); err != nil {
|
||||
return fmt.Errorf("Could not delete assignee[%d] %w", user.ID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ReconsiderWatches(ctx context.Context, repo *repo_model.Repository, user *user_model.User) error {
|
||||
if has, err := access_model.HasAnyUnitAccess(ctx, user.ID, repo); err != nil || has {
|
||||
return err
|
||||
}
|
||||
if err := repo_model.WatchRepo(ctx, user, repo, false); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Remove all IssueWatches a user has subscribed to in the repository
|
||||
return issues_model.RemoveIssueWatchersByRepoID(ctx, user.ID, repo.ID)
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"code.gitea.io/gitea/models"
|
||||
"code.gitea.io/gitea/models/db"
|
||||
"code.gitea.io/gitea/models/organization"
|
||||
"code.gitea.io/gitea/models/perm"
|
||||
@@ -448,7 +447,7 @@ func CreateRepositoryByExample(ctx context.Context, doer, u *user_model.User, re
|
||||
}
|
||||
for _, t := range teams {
|
||||
if t.IncludesAllRepositories {
|
||||
if err := models.AddRepository(ctx, t, repo); err != nil {
|
||||
if err := addRepositoryToTeam(ctx, t, repo); err != nil {
|
||||
return fmt.Errorf("AddRepository: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repository
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"code.gitea.io/gitea/models"
|
||||
"code.gitea.io/gitea/models/db"
|
||||
"code.gitea.io/gitea/models/organization"
|
||||
"code.gitea.io/gitea/models/perm"
|
||||
"code.gitea.io/gitea/models/unittest"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/structs"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestIncludesAllRepositoriesTeams(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
testTeamRepositories := func(teamID int64, repoIDs []int64) {
|
||||
team := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: teamID})
|
||||
assert.NoError(t, team.LoadRepositories(db.DefaultContext), "%s: GetRepositories", team.Name)
|
||||
assert.Len(t, team.Repos, team.NumRepos, "%s: len repo", team.Name)
|
||||
assert.Len(t, team.Repos, len(repoIDs), "%s: repo count", team.Name)
|
||||
for i, rid := range repoIDs {
|
||||
if rid > 0 {
|
||||
assert.True(t, HasRepository(db.DefaultContext, team, rid), "%s: HasRepository(%d) %d", rid, i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get an admin user.
|
||||
user, err := user_model.GetUserByID(db.DefaultContext, 1)
|
||||
assert.NoError(t, err, "GetUserByID")
|
||||
|
||||
// Create org.
|
||||
org := &organization.Organization{
|
||||
Name: "All_repo",
|
||||
IsActive: true,
|
||||
Type: user_model.UserTypeOrganization,
|
||||
Visibility: structs.VisibleTypePublic,
|
||||
}
|
||||
assert.NoError(t, organization.CreateOrganization(db.DefaultContext, org, user), "CreateOrganization")
|
||||
|
||||
// Check Owner team.
|
||||
ownerTeam, err := org.GetOwnerTeam(db.DefaultContext)
|
||||
assert.NoError(t, err, "GetOwnerTeam")
|
||||
assert.True(t, ownerTeam.IncludesAllRepositories, "Owner team includes all repositories")
|
||||
|
||||
// Create repos.
|
||||
repoIDs := make([]int64, 0)
|
||||
for i := 0; i < 3; i++ {
|
||||
r, err := CreateRepositoryDirectly(db.DefaultContext, user, org.AsUser(), CreateRepoOptions{Name: fmt.Sprintf("repo-%d", i)})
|
||||
assert.NoError(t, err, "CreateRepository %d", i)
|
||||
if r != nil {
|
||||
repoIDs = append(repoIDs, r.ID)
|
||||
}
|
||||
}
|
||||
// Get fresh copy of Owner team after creating repos.
|
||||
ownerTeam, err = org.GetOwnerTeam(db.DefaultContext)
|
||||
assert.NoError(t, err, "GetOwnerTeam")
|
||||
|
||||
// Create teams and check repositories.
|
||||
teams := []*organization.Team{
|
||||
ownerTeam,
|
||||
{
|
||||
OrgID: org.ID,
|
||||
Name: "team one",
|
||||
AccessMode: perm.AccessModeRead,
|
||||
IncludesAllRepositories: true,
|
||||
},
|
||||
{
|
||||
OrgID: org.ID,
|
||||
Name: "team 2",
|
||||
AccessMode: perm.AccessModeRead,
|
||||
IncludesAllRepositories: false,
|
||||
},
|
||||
{
|
||||
OrgID: org.ID,
|
||||
Name: "team three",
|
||||
AccessMode: perm.AccessModeWrite,
|
||||
IncludesAllRepositories: true,
|
||||
},
|
||||
{
|
||||
OrgID: org.ID,
|
||||
Name: "team 4",
|
||||
AccessMode: perm.AccessModeWrite,
|
||||
IncludesAllRepositories: false,
|
||||
},
|
||||
}
|
||||
teamRepos := [][]int64{
|
||||
repoIDs,
|
||||
repoIDs,
|
||||
{},
|
||||
repoIDs,
|
||||
{},
|
||||
}
|
||||
for i, team := range teams {
|
||||
if i > 0 { // first team is Owner.
|
||||
assert.NoError(t, models.NewTeam(db.DefaultContext, team), "%s: NewTeam", team.Name)
|
||||
}
|
||||
testTeamRepositories(team.ID, teamRepos[i])
|
||||
}
|
||||
|
||||
// Update teams and check repositories.
|
||||
teams[3].IncludesAllRepositories = false
|
||||
teams[4].IncludesAllRepositories = true
|
||||
teamRepos[4] = repoIDs
|
||||
for i, team := range teams {
|
||||
assert.NoError(t, models.UpdateTeam(db.DefaultContext, team, false, true), "%s: UpdateTeam", team.Name)
|
||||
testTeamRepositories(team.ID, teamRepos[i])
|
||||
}
|
||||
|
||||
// Create repo and check teams repositories.
|
||||
r, err := CreateRepositoryDirectly(db.DefaultContext, user, org.AsUser(), CreateRepoOptions{Name: "repo-last"})
|
||||
assert.NoError(t, err, "CreateRepository last")
|
||||
if r != nil {
|
||||
repoIDs = append(repoIDs, r.ID)
|
||||
}
|
||||
teamRepos[0] = repoIDs
|
||||
teamRepos[1] = repoIDs
|
||||
teamRepos[4] = repoIDs
|
||||
for i, team := range teams {
|
||||
testTeamRepositories(team.ID, teamRepos[i])
|
||||
}
|
||||
|
||||
// Remove repo and check teams repositories.
|
||||
assert.NoError(t, DeleteRepositoryDirectly(db.DefaultContext, user, repoIDs[0]), "DeleteRepository")
|
||||
teamRepos[0] = repoIDs[1:]
|
||||
teamRepos[1] = repoIDs[1:]
|
||||
teamRepos[3] = repoIDs[1:3]
|
||||
teamRepos[4] = repoIDs[1:]
|
||||
for i, team := range teams {
|
||||
testTeamRepositories(team.ID, teamRepos[i])
|
||||
}
|
||||
|
||||
// Wipe created items.
|
||||
for i, rid := range repoIDs {
|
||||
if i > 0 { // first repo already deleted.
|
||||
assert.NoError(t, DeleteRepositoryDirectly(db.DefaultContext, user, rid), "DeleteRepository %d", i)
|
||||
}
|
||||
}
|
||||
assert.NoError(t, organization.DeleteOrganization(db.DefaultContext, org), "DeleteOrganization")
|
||||
}
|
||||
@@ -348,87 +348,6 @@ func DeleteRepositoryDirectly(ctx context.Context, doer *user_model.User, repoID
|
||||
return nil
|
||||
}
|
||||
|
||||
// removeRepositoryFromTeam removes a repository from a team and recalculates access
|
||||
// Note: Repository shall not be removed from team if it includes all repositories (unless the repository is deleted)
|
||||
func removeRepositoryFromTeam(ctx context.Context, t *organization.Team, repo *repo_model.Repository, recalculate bool) (err error) {
|
||||
e := db.GetEngine(ctx)
|
||||
if err = organization.RemoveTeamRepo(ctx, t.ID, repo.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
t.NumRepos--
|
||||
if _, err = e.ID(t.ID).Cols("num_repos").Update(t); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Don't need to recalculate when delete a repository from organization.
|
||||
if recalculate {
|
||||
if err = access_model.RecalculateTeamAccesses(ctx, repo, t.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
teamMembers, err := organization.GetTeamMembers(ctx, &organization.SearchMembersOptions{
|
||||
TeamID: t.ID,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("GetTeamMembers: %w", err)
|
||||
}
|
||||
for _, member := range teamMembers {
|
||||
has, err := access_model.HasAnyUnitAccess(ctx, member.ID, repo)
|
||||
if err != nil {
|
||||
return err
|
||||
} else if has {
|
||||
continue
|
||||
}
|
||||
|
||||
if err = repo_model.WatchRepo(ctx, member, repo, false); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Remove all IssueWatches a user has subscribed to in the repositories
|
||||
if err := issues_model.RemoveIssueWatchersByRepoID(ctx, member.ID, repo.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// HasRepository returns true if given repository belong to team.
|
||||
func HasRepository(ctx context.Context, t *organization.Team, repoID int64) bool {
|
||||
return organization.HasTeamRepo(ctx, t.OrgID, t.ID, repoID)
|
||||
}
|
||||
|
||||
// RemoveRepositoryFromTeam removes repository from team of organization.
|
||||
// If the team shall include all repositories the request is ignored.
|
||||
func RemoveRepositoryFromTeam(ctx context.Context, t *organization.Team, repoID int64) error {
|
||||
if !HasRepository(ctx, t, repoID) {
|
||||
return nil
|
||||
}
|
||||
|
||||
if t.IncludesAllRepositories {
|
||||
return nil
|
||||
}
|
||||
|
||||
repo, err := repo_model.GetRepositoryByID(ctx, repoID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, committer, err := db.TxContext(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer committer.Close()
|
||||
|
||||
if err = removeRepositoryFromTeam(ctx, t, repo, true); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return committer.Commit()
|
||||
}
|
||||
|
||||
// DeleteOwnerRepositoriesDirectly calls DeleteRepositoryDirectly for all repos of the given owner
|
||||
func DeleteOwnerRepositoriesDirectly(ctx context.Context, owner *user_model.User) error {
|
||||
for {
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
issues_model "code.gitea.io/gitea/models/issues"
|
||||
"code.gitea.io/gitea/models/organization"
|
||||
access_model "code.gitea.io/gitea/models/perm/access"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
)
|
||||
|
||||
// TeamAddRepository adds new repository to team of organization.
|
||||
func TeamAddRepository(ctx context.Context, t *organization.Team, repo *repo_model.Repository) (err error) {
|
||||
if repo.OwnerID != t.OrgID {
|
||||
return errors.New("repository does not belong to organization")
|
||||
} else if organization.HasTeamRepo(ctx, t.OrgID, t.ID, repo.ID) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return db.WithTx(ctx, func(ctx context.Context) error {
|
||||
return addRepositoryToTeam(ctx, t, repo)
|
||||
})
|
||||
}
|
||||
|
||||
func addRepositoryToTeam(ctx context.Context, t *organization.Team, repo *repo_model.Repository) (err error) {
|
||||
if err = organization.AddTeamRepo(ctx, t.OrgID, t.ID, repo.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = organization.IncrTeamRepoNum(ctx, t.ID); err != nil {
|
||||
return fmt.Errorf("update team: %w", err)
|
||||
}
|
||||
|
||||
t.NumRepos++
|
||||
|
||||
if err = access_model.RecalculateTeamAccesses(ctx, repo, 0); err != nil {
|
||||
return fmt.Errorf("recalculateAccesses: %w", err)
|
||||
}
|
||||
|
||||
// Make all team members watch this repo if enabled in global settings
|
||||
if setting.Service.AutoWatchNewRepos {
|
||||
if err = t.LoadMembers(ctx); err != nil {
|
||||
return fmt.Errorf("getMembers: %w", err)
|
||||
}
|
||||
for _, u := range t.Members {
|
||||
if err = repo_model.WatchRepo(ctx, u, repo, true); err != nil {
|
||||
return fmt.Errorf("watchRepo: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddAllRepositoriesToTeam adds all repositories to the team.
|
||||
// If the team already has some repositories they will be left unchanged.
|
||||
func AddAllRepositoriesToTeam(ctx context.Context, t *organization.Team) error {
|
||||
return db.WithTx(ctx, func(ctx context.Context) error {
|
||||
orgRepos, err := organization.GetOrgRepositories(ctx, t.OrgID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get org repos: %w", err)
|
||||
}
|
||||
|
||||
for _, repo := range orgRepos {
|
||||
if !organization.HasTeamRepo(ctx, t.OrgID, t.ID, repo.ID) {
|
||||
if err := addRepositoryToTeam(ctx, t, repo); err != nil {
|
||||
return fmt.Errorf("AddRepository: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// RemoveAllRepositoriesFromTeam removes all repositories from team and recalculates access
|
||||
func RemoveAllRepositoriesFromTeam(ctx context.Context, t *organization.Team) (err error) {
|
||||
if t.IncludesAllRepositories {
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx, committer, err := db.TxContext(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer committer.Close()
|
||||
|
||||
if err = removeAllRepositoriesFromTeam(ctx, t); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return committer.Commit()
|
||||
}
|
||||
|
||||
// removeAllRepositoriesFromTeam removes all repositories from team and recalculates access
|
||||
// Note: Shall not be called if team includes all repositories
|
||||
func removeAllRepositoriesFromTeam(ctx context.Context, t *organization.Team) (err error) {
|
||||
e := db.GetEngine(ctx)
|
||||
// Delete all accesses.
|
||||
for _, repo := range t.Repos {
|
||||
if err := access_model.RecalculateTeamAccesses(ctx, repo, t.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Remove watches from all users and now unaccessible repos
|
||||
for _, user := range t.Members {
|
||||
has, err := access_model.HasAnyUnitAccess(ctx, user.ID, repo)
|
||||
if err != nil {
|
||||
return err
|
||||
} else if has {
|
||||
continue
|
||||
}
|
||||
|
||||
if err = repo_model.WatchRepo(ctx, user, repo, false); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Remove all IssueWatches a user has subscribed to in the repositories
|
||||
if err = issues_model.RemoveIssueWatchersByRepoID(ctx, user.ID, repo.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Delete team-repo
|
||||
if _, err := e.
|
||||
Where("team_id=?", t.ID).
|
||||
Delete(new(organization.TeamRepo)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
t.NumRepos = 0
|
||||
if _, err = e.ID(t.ID).Cols("num_repos").Update(t); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveRepositoryFromTeam removes repository from team of organization.
|
||||
// If the team shall include all repositories the request is ignored.
|
||||
func RemoveRepositoryFromTeam(ctx context.Context, t *organization.Team, repoID int64) error {
|
||||
if !HasRepository(ctx, t, repoID) {
|
||||
return nil
|
||||
}
|
||||
|
||||
if t.IncludesAllRepositories {
|
||||
return nil
|
||||
}
|
||||
|
||||
repo, err := repo_model.GetRepositoryByID(ctx, repoID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, committer, err := db.TxContext(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer committer.Close()
|
||||
|
||||
if err = removeRepositoryFromTeam(ctx, t, repo, true); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return committer.Commit()
|
||||
}
|
||||
|
||||
// removeRepositoryFromTeam removes a repository from a team and recalculates access
|
||||
// Note: Repository shall not be removed from team if it includes all repositories (unless the repository is deleted)
|
||||
func removeRepositoryFromTeam(ctx context.Context, t *organization.Team, repo *repo_model.Repository, recalculate bool) (err error) {
|
||||
e := db.GetEngine(ctx)
|
||||
if err = organization.RemoveTeamRepo(ctx, t.ID, repo.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
t.NumRepos--
|
||||
if _, err = e.ID(t.ID).Cols("num_repos").Update(t); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Don't need to recalculate when delete a repository from organization.
|
||||
if recalculate {
|
||||
if err = access_model.RecalculateTeamAccesses(ctx, repo, t.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
teamMembers, err := organization.GetTeamMembers(ctx, &organization.SearchMembersOptions{
|
||||
TeamID: t.ID,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("GetTeamMembers: %w", err)
|
||||
}
|
||||
for _, member := range teamMembers {
|
||||
has, err := access_model.HasAnyUnitAccess(ctx, member.ID, repo)
|
||||
if err != nil {
|
||||
return err
|
||||
} else if has {
|
||||
continue
|
||||
}
|
||||
|
||||
if err = repo_model.WatchRepo(ctx, member, repo, false); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Remove all IssueWatches a user has subscribed to in the repositories
|
||||
if err := issues_model.RemoveIssueWatchersByRepoID(ctx, member.ID, repo.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// HasRepository returns true if given repository belong to team.
|
||||
func HasRepository(ctx context.Context, t *organization.Team, repoID int64) bool {
|
||||
return organization.HasTeamRepo(ctx, t.OrgID, t.ID, repoID)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package org
|
||||
package repository
|
||||
|
||||
import (
|
||||
"testing"
|
||||
@@ -1,24 +0,0 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"code.gitea.io/gitea/models/organization"
|
||||
"code.gitea.io/gitea/models/perm"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
)
|
||||
|
||||
// GetReviewerTeams get all teams can be requested to review
|
||||
func GetReviewerTeams(ctx context.Context, repo *repo_model.Repository) ([]*organization.Team, error) {
|
||||
if err := repo.LoadOwner(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !repo.Owner.IsOrganization() {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return organization.GetTeamsWithAccessToRepo(ctx, repo.OwnerID, repo.ID, perm.AccessModeRead)
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repository
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
"code.gitea.io/gitea/models/unittest"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestRepoGetReviewerTeams(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
repo2 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2})
|
||||
teams, err := GetReviewerTeams(db.DefaultContext, repo2)
|
||||
assert.NoError(t, err)
|
||||
assert.Empty(t, teams)
|
||||
|
||||
repo3 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 3})
|
||||
teams, err = GetReviewerTeams(db.DefaultContext, repo3)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, teams, 2)
|
||||
}
|
||||
@@ -59,7 +59,7 @@ func TransferOwnership(ctx context.Context, doer, newOwner *user_model.User, rep
|
||||
}
|
||||
|
||||
for _, team := range teams {
|
||||
if err := models.AddRepository(ctx, team, newRepo); err != nil {
|
||||
if err := addRepositoryToTeam(ctx, team, newRepo); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -205,7 +205,7 @@ func transferOwnership(ctx context.Context, doer *user_model.User, newOwnerName
|
||||
}
|
||||
for _, t := range teams {
|
||||
if t.IncludesAllRepositories {
|
||||
if err := models.AddRepository(ctx, t, repo); err != nil {
|
||||
if err := addRepositoryToTeam(ctx, t, repo); err != nil {
|
||||
return fmt.Errorf("AddRepository: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,7 +188,7 @@ func DeleteUser(ctx context.Context, u *user_model.User, purge bool) error {
|
||||
break
|
||||
}
|
||||
for _, org := range orgs {
|
||||
if err := models.RemoveOrgUser(ctx, org, u); err != nil {
|
||||
if err := org_service.RemoveOrgUser(ctx, org, u); err != nil {
|
||||
if organization.IsErrLastOrgOwner(err) {
|
||||
err = org_service.DeleteOrganization(ctx, org, true)
|
||||
if err != nil {
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/timeutil"
|
||||
org_service "code.gitea.io/gitea/services/org"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
@@ -44,7 +45,7 @@ func TestDeleteUser(t *testing.T) {
|
||||
assert.NoError(t, db.GetEngine(db.DefaultContext).Find(&orgUsers, &organization.OrgUser{UID: userID}))
|
||||
for _, orgUser := range orgUsers {
|
||||
org := unittest.AssertExistsAndLoadBean(t, &organization.Organization{ID: orgUser.OrgID})
|
||||
if err := models.RemoveOrgUser(db.DefaultContext, org, user); err != nil {
|
||||
if err := org_service.RemoveOrgUser(db.DefaultContext, org, user); err != nil {
|
||||
assert.True(t, organization.IsErrLastOrgOwner(err))
|
||||
return
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user