diff --git a/models/repo/release.go b/models/repo/release.go index 67aa390e6d..05475899b8 100644 --- a/models/repo/release.go +++ b/models/repo/release.go @@ -93,15 +93,21 @@ func init() { db.RegisterModel(new(Release)) } -// LoadAttributes load repo and publisher attributes for a release -func (r *Release) LoadAttributes(ctx context.Context) error { - var err error - if r.Repo == nil { - r.Repo, err = GetRepositoryByID(ctx, r.RepoID) - if err != nil { - return err - } +func (r *Release) LoadRepo(ctx context.Context) (err error) { + if r.Repo != nil { + return nil } + + r.Repo, err = GetRepositoryByID(ctx, r.RepoID) + return err +} + +// LoadAttributes load repo and publisher attributes for a release +func (r *Release) LoadAttributes(ctx context.Context) (err error) { + if err := r.LoadRepo(ctx); err != nil { + return err + } + if r.Publisher == nil { r.Publisher, err = user_model.GetUserByID(ctx, r.PublisherID) if err != nil { diff --git a/models/repo/watch.go b/models/repo/watch.go index a616544cae..1e63d5c3d2 100644 --- a/models/repo/watch.go +++ b/models/repo/watch.go @@ -176,3 +176,13 @@ func WatchIfAuto(ctx context.Context, userID, repoID int64, isWrite bool) error } return watchRepoMode(ctx, watch, WatchModeAuto) } + +// ClearRepoWatches clears all watches for a repository and from the user that watched it. +// Used when a repository is set to private. +func ClearRepoWatches(ctx context.Context, repoID int64) error { + if _, err := db.Exec(ctx, "UPDATE `repository` SET num_watches = 0 WHERE id = ?", repoID); err != nil { + return err + } + + return db.DeleteBeans(ctx, Watch{RepoID: repoID}) +} diff --git a/models/repo/watch_test.go b/models/repo/watch_test.go index 19e363f6b0..97576fb787 100644 --- a/models/repo/watch_test.go +++ b/models/repo/watch_test.go @@ -13,6 +13,7 @@ import ( "code.gitea.io/gitea/modules/setting" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestIsWatching(t *testing.T) { @@ -119,3 +120,21 @@ func TestWatchIfAuto(t *testing.T) { assert.NoError(t, err) assert.Len(t, watchers, prevCount) } + +func TestClearRepoWatches(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + const repoID int64 = 1 + watchers, err := repo_model.GetRepoWatchersIDs(t.Context(), repoID) + require.NoError(t, err) + require.NotEmpty(t, watchers) + + assert.NoError(t, repo_model.ClearRepoWatches(t.Context(), repoID)) + + watchers, err = repo_model.GetRepoWatchersIDs(t.Context(), repoID) + assert.NoError(t, err) + assert.Empty(t, watchers) + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: repoID}) + assert.Zero(t, repo.NumWatches) +} diff --git a/services/mailer/mail_release.go b/services/mailer/mail_release.go index 248cf0ab90..1f940f33df 100644 --- a/services/mailer/mail_release.go +++ b/services/mailer/mail_release.go @@ -7,9 +7,12 @@ import ( "bytes" "context" "fmt" + "slices" + access_model "code.gitea.io/gitea/models/perm/access" "code.gitea.io/gitea/models/renderhelper" 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/log" "code.gitea.io/gitea/modules/markup/markdown" @@ -44,6 +47,16 @@ func MailNewRelease(ctx context.Context, rel *repo_model.Release) { return } + if err := rel.LoadRepo(ctx); err != nil { + log.Error("rel.LoadRepo: %v", err) + return + } + + // delete publisher or any users with no permission + recipients = slices.DeleteFunc(recipients, func(u *user_model.User) bool { + return u.ID == rel.PublisherID || !access_model.CheckRepoUnitUser(ctx, rel.Repo, u, unit.TypeReleases) + }) + langMap := make(map[string][]*user_model.User) for _, user := range recipients { if user.ID != rel.PublisherID { diff --git a/services/mailer/mail_release_test.go b/services/mailer/mail_release_test.go new file mode 100644 index 0000000000..d078bdde74 --- /dev/null +++ b/services/mailer/mail_release_test.go @@ -0,0 +1,71 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package mailer + +import ( + "testing" + + 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/setting" + sender_service "code.gitea.io/gitea/services/mailer/sender" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMailNewReleaseFiltersUnauthorizedWatchers(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + origMailService := setting.MailService + origDomain := setting.Domain + origAppName := setting.AppName + origAppURL := setting.AppURL + origTemplates := LoadedTemplates() + defer func() { + setting.MailService = origMailService + setting.Domain = origDomain + setting.AppName = origAppName + setting.AppURL = origAppURL + loadedTemplates.Store(origTemplates) + }() + + setting.MailService = &setting.Mailer{ + From: "Gitea", + FromEmail: "noreply@example.com", + } + setting.Domain = "example.com" + setting.AppName = "Gitea" + setting.AppURL = "https://example.com/" + prepareMailTemplates(string(tplNewReleaseMail), "{{.Subject}}", "

{{.Release.TagName}}

") + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2}) + require.True(t, repo.IsPrivate) + + admin := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) + unauthorized := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5}) + + assert.NoError(t, repo_model.WatchRepo(t.Context(), admin, repo, true)) + assert.NoError(t, repo_model.WatchRepo(t.Context(), unauthorized, repo, true)) + + rel := unittest.AssertExistsAndLoadBean(t, &repo_model.Release{ID: 11}) + rel.Repo = nil + rel.Publisher = unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: rel.PublisherID}) + + var sent []*sender_service.Message + origSend := SendAsync + SendAsync = func(msgs ...*sender_service.Message) { + sent = append(sent, msgs...) + } + defer func() { + SendAsync = origSend + }() + + MailNewRelease(t.Context(), rel) + + require.Len(t, sent, 1) + assert.Equal(t, admin.EmailTo(), sent[0].To) + assert.NotEqual(t, unauthorized.EmailTo(), sent[0].To) +} diff --git a/services/repository/repository.go b/services/repository/repository.go index 4d07cb0e38..21a75a559f 100644 --- a/services/repository/repository.go +++ b/services/repository/repository.go @@ -194,6 +194,10 @@ func MakeRepoPrivate(ctx context.Context, repo *repo_model.Repository) (err erro return err } + if err = repo_model.ClearRepoWatches(ctx, repo.ID); err != nil { + return err + } + // Create/Remove git-daemon-export-ok for git-daemon... if err := CheckDaemonExportOK(ctx, repo); err != nil { return err diff --git a/services/repository/repository_test.go b/services/repository/repository_test.go index 5673a4a161..cf7dd6b7ed 100644 --- a/services/repository/repository_test.go +++ b/services/repository/repository_test.go @@ -13,6 +13,7 @@ import ( "code.gitea.io/gitea/models/unittest" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestLinkedRepository(t *testing.T) { @@ -70,3 +71,24 @@ func TestRepository_HasWiki(t *testing.T) { repo2 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2}) assert.False(t, HasWiki(t.Context(), repo2)) } + +func TestMakeRepoPrivateClearsWatches(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + repo.IsPrivate = false + + watchers, err := repo_model.GetRepoWatchersIDs(t.Context(), repo.ID) + require.NoError(t, err) + require.NotEmpty(t, watchers) + + assert.NoError(t, MakeRepoPrivate(t.Context(), repo)) + + watchers, err = repo_model.GetRepoWatchersIDs(t.Context(), repo.ID) + assert.NoError(t, err) + assert.Empty(t, watchers) + + updatedRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: repo.ID}) + assert.True(t, updatedRepo.IsPrivate) + assert.Zero(t, updatedRepo.NumWatches) +}