Compare commits

...

28 Commits

Author SHA1 Message Date
Lunny Xiao 369830bada Release notes for 1.25.4 (#36385)
---------

Signed-off-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: Thomas Beutlich <115483027+thbeu@users.noreply.github.com>
2026-01-22 00:57:25 +00:00
Tyrone Yeh d7d6533311 Fix markdown newline handling during IME composition (gitea#36421) (#36424)
Backport of #36421
2026-01-21 16:56:39 -08:00
Giteabot c326369f47 Allow foreachref parse max tokens from 4*64KB to 4MB (#36414) (#36429)
Backport #36414 by @lunny

Fix #36408

Signed-off-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: Lauris BH <lauris@nix.lv>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-01-21 16:23:00 -08:00
Lunny Xiao 4cdb8a7f96 Fix missing repository id when migrating release attachments (#36389) (#36413)
This PR fixes missed repo_id on the migration of attachments to Gitea.
It also provides a doctor check to fix the dirty data on the database.

Backport #36389
2026-01-20 23:36:45 +02:00
wxiaoguang 38125a8d1d Fix git http service handling (#36396)
Partially backport #36391
2026-01-18 01:42:35 +08:00
Giteabot 175a425825 Fix bug on notification read (#36339) (#36387)
Backport #36339 by @lunny

When a user has been revoked permission to access a repository, the
related notification could still be visited. But the repository's
information should not be leaked any more.

Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
2026-01-16 09:58:56 +02:00
Lunny Xiao 6132f639e7 Fix bug when compare in the pull request (#36363) (#36372)
The pull request comparison should not use `direct compare`.

Backport #36363
2026-01-15 01:58:42 +00:00
Giteabot dfe4055b92 Release attachments must belong to the intended repo (#36347) (#36375)
Backport #36347 by @lunny

Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
2026-01-15 02:30:18 +01:00
Giteabot 5fe9703586 Fix permission check on org project operations (#36318) (#36373)
Backport #36318 by @lunny

Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
2026-01-14 14:22:27 -08:00
Giteabot 53d67dae28 clean watches when make a repository private and check permission when send release emails (#36319) (#36370)
Backport #36319 by @lunny

Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
2026-01-14 19:28:02 +01:00
Giteabot ef6ab681f7 Fix incorrect text content detection (#36364) (#36369)
Backport #36364 by wxiaoguang

Fix #36325

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-01-15 00:45:38 +08:00
Giteabot 812a3cffb3 Add more check for stopwatch read or list (#36340) (#36368)
Backport #36340 by @lunny

Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
2026-01-14 16:04:02 +00:00
Giteabot 669b22100b Fix openid setting check (#36346) (#36361)
Backport #36346 by @lunny

Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
2026-01-13 16:47:00 +02:00
Giteabot a0c77673ff fill missing has_code in repository api (#36338) (#36359)
Backport #36338 by @TheFox0x7

fixes: https://github.com/go-gitea/gitea/issues/36332

Co-authored-by: TheFox0x7 <thefox0x7@gmail.com>
Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
2026-01-13 10:31:18 +00:00
Giteabot d96b68cbf5 Fix notifications pagination query parameters (#36351) (#36358)
Backport #36351 by @wxiaoguang

Fix #36350

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-01-13 04:37:20 +00:00
Giteabot f8ec5b3e43 Fix cancel auto merge bug (#36341) (#36356)
Backport #36341 by @lunny

Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
2026-01-12 18:55:41 -08:00
Lunny Xiao 11891c2dac Fix delete attachment check (#36320) (#36355)
Backport #36320
2026-01-12 13:48:07 -08:00
Giteabot 2c778ff067 LFS locks must belong to the intended repo (#36344) (#36349)
Backport #36344 by @lunny

Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
2026-01-12 09:09:16 +02:00
wxiaoguang 83ce45b186 Fix some trivial problems (#36336) (#36337)
Partially backport #36336

1. correctly parse git protocol's "OldCommit NewCommit RefName" line, it
should be explicitly split by space
2. trim space for the "commit status context name" to follow the same
behavior of git_model.NewCommitStatus
2026-01-09 20:59:03 +02:00
silverwind 39e83bd3fd Fix WebAuthn error checking (#36219) (#36235)
Backport of https://github.com/go-gitea/gitea/pull/36219
Fixes: https://github.com/go-gitea/gitea/issues/36216

Now `detectWebAuthnSupport` returns the error type and lets the caller
decide whether they call `webAuthnError` and show the error. It no
longer shows the error during page load when the user has not even
interacted with the feature.

The bug affects all users on HTTP, so I think a quick fix release for
this might be good.
2026-01-06 04:57:59 +00:00
Giteabot c2f9edd673 fix: prevent panic when GitLab release has more links than sources (#36295) (#36305)
Backport #36295 by argoyle

Fixes #36292

Co-authored-by: Joakim Olsson <joakim@unbound.se>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-01-05 17:29:28 +00:00
Giteabot aa575672ac Fix stats bug when syncing release (#36285) (#36294)
Backport #36285 by @lunny

Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
2026-01-04 12:14:00 +02:00
TheFox0x7 e9c14723b6 add more routes to the "expensive" list (#36290)
backport: https://github.com/go-gitea/gitea/pull/35547
closes https://github.com/go-gitea/gitea/issues/36229

Signed-off-by: Steven Noonan <steven@uplinklabs.net>
Co-authored-by: Steven Noonan <steven@uplinklabs.net>
2026-01-03 16:59:48 +02:00
Giteabot 76b6e94b5b Always honor user's choice for "delete branch after merge" (#36281) (#36286)
Backport #36281 by wxiaoguang

Fix #36280

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-01-03 03:06:10 +00:00
Giteabot 163113d173 Make "commit statuses" API accept slashes in "ref" (#36264) (#36275)
Backport #36264 by wxiaoguang

Fix #36253

Support slashes in `{ref}` (follow GitHub's behavior)

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-01-01 03:04:03 +00:00
Lunny Xiao 7d010c6932 Fix panic when get editor config file (#36241) (#36247)
Fix #36239
Backport #36241
2025-12-27 20:29:22 -08:00
Giteabot b71e688634 Use the requested host for LFS links (#36242) (#36258)
Backport #36242 by sollyu

Co-authored-by: sollyu <43105186+sollyu@users.noreply.github.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2025-12-27 07:23:18 +00:00
Giteabot e147a8223a Fix regression in writing authorized principals (#36213) (#36218)
Backport #36213 by peterverraedt

Fixes: #36212

Signed-off-by: Peter Verraedt <peter.verraedt@kuleuven.be>
Co-authored-by: Peter Verraedt <peter.verraedt@kuleuven.be>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2025-12-20 01:57:26 +00:00
76 changed files with 1424 additions and 435 deletions
+31
View File
@@ -4,6 +4,37 @@ This changelog goes through the changes that have been made in each release
without substantial changes to our git log; to see the highlights of what has without substantial changes to our git log; to see the highlights of what has
been added to each release, please refer to the [blog](https://blog.gitea.com). been added to each release, please refer to the [blog](https://blog.gitea.com).
## [1.25.4](https://github.com/go-gitea/gitea/releases/tag/1.25.4) - 2026-01-15
* SECURITY
* Release attachments must belong to the intended repo (#36347) (#36375)
* Fix permission check on org project operations (#36318) (#36373)
* Clean watches when make a repository private and check permission when send release emails (#36319) (#36370)
* Add more check for stopwatch read or list (#36340) (#36368)
* Fix openid setting check (#36346) (#36361)
* Fix cancel auto merge bug (#36341) (#36356)
* Fix delete attachment check (#36320) (#36355)
* LFS locks must belong to the intended repo (#36344) (#36349)
* Fix bug on notification read (#36339) #36387
* ENHANCEMENTS
* Add more routes to the "expensive" list (#36290)
* Make "commit statuses" API accept slashes in "ref" (#36264) (#36275)
* BUGFIXES
* Fix markdown newline handling during IME composition (#36421) #36424
* Fix missing repository id when migrating release attachments (#36389)
* Fix bug when compare in the pull request (#36363) (#36372)
* Fix incorrect text content detection (#36364) (#36369)
* Fill missing `has_code` in repository api (#36338) (#36359)
* Fix notifications pagination query parameters (#36351) (#36358)
* Fix some trivial problems (#36336) (#36337)
* Prevent panic when GitLab release has more links than sources (#36295) (#36305)
* Fix stats bug when syncing release (#36285) (#36294)
* Always honor user's choice for "delete branch after merge" (#36281) (#36286)
* Use the requested host for LFS links (#36242) (#36258)
* Fix panic when get editor config file (#36241) (#36247)
* Fix regression in writing authorized principals (#36213) (#36218)
* Fix WebAuthn error checking (#36219) (#36235)
## [1.25.3](https://github.com/go-gitea/gitea/releases/tag/1.25.3) - 2025-12-17 ## [1.25.3](https://github.com/go-gitea/gitea/releases/tag/1.25.3) - 2025-12-17
* SECURITY * SECURITY
+13 -11
View File
@@ -163,6 +163,14 @@ func (n *nilWriter) WriteString(s string) (int, error) {
return len(s), nil return len(s), nil
} }
func parseGitHookCommitRefLine(line string) (oldCommitID, newCommitID string, refFullName git.RefName, ok bool) {
fields := strings.Split(line, " ")
if len(fields) != 3 {
return "", "", "", false
}
return fields[0], fields[1], git.RefName(fields[2]), true
}
func runHookPreReceive(ctx context.Context, c *cli.Command) error { func runHookPreReceive(ctx context.Context, c *cli.Command) error {
if isInternal, _ := strconv.ParseBool(os.Getenv(repo_module.EnvIsInternal)); isInternal { if isInternal, _ := strconv.ParseBool(os.Getenv(repo_module.EnvIsInternal)); isInternal {
return nil return nil
@@ -228,14 +236,11 @@ Gitea or set your environment appropriately.`, "")
continue continue
} }
fields := bytes.Fields(scanner.Bytes()) oldCommitID, newCommitID, refFullName, ok := parseGitHookCommitRefLine(scanner.Text())
if len(fields) != 3 { if !ok {
continue continue
} }
oldCommitID := string(fields[0])
newCommitID := string(fields[1])
refFullName := git.RefName(fields[2])
total++ total++
lastline++ lastline++
@@ -378,16 +383,13 @@ Gitea or set your environment appropriately.`, "")
continue continue
} }
fields := bytes.Fields(scanner.Bytes()) var ok bool
if len(fields) != 3 { oldCommitIDs[count], newCommitIDs[count], refFullNames[count], ok = parseGitHookCommitRefLine(scanner.Text())
if !ok {
continue continue
} }
fmt.Fprintf(out, ".") fmt.Fprintf(out, ".")
oldCommitIDs[count] = string(fields[0])
newCommitIDs[count] = string(fields[1])
refFullNames[count] = git.RefName(fields[2])
commitID, _ := git.NewIDFromString(newCommitIDs[count]) commitID, _ := git.NewIDFromString(newCommitIDs[count])
if refFullNames[count] == git.BranchPrefix+"master" && !commitID.IsZero() && count == total { if refFullNames[count] == git.BranchPrefix+"master" && !commitID.IsZero() && count == total {
masterPushed = true masterPushed = true
+14
View File
@@ -39,3 +39,17 @@ func TestPktLine(t *testing.T) {
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, []byte("0007a\nb"), w.Bytes()) assert.Equal(t, []byte("0007a\nb"), w.Bytes())
} }
func TestParseGitHookCommitRefLine(t *testing.T) {
oldCommitID, newCommitID, refName, ok := parseGitHookCommitRefLine("a b c")
assert.True(t, ok)
assert.Equal(t, "a", oldCommitID)
assert.Equal(t, "b", newCommitID)
assert.Equal(t, "c", string(refName))
_, _, _, ok = parseGitHookCommitRefLine("a\tb\tc")
assert.False(t, ok)
_, _, _, ok = parseGitHookCommitRefLine("a b")
assert.False(t, ok)
}
+36 -7
View File
@@ -10,6 +10,7 @@ import (
"io" "io"
"os" "os"
"path/filepath" "path/filepath"
"regexp"
"strings" "strings"
"sync" "sync"
@@ -50,12 +51,42 @@ func WriteAuthorizedStringForValidKey(key *PublicKey, w io.Writer) error {
return err return err
} }
var globalVars = sync.OnceValue(func() (ret struct {
principalRegexp *regexp.Regexp
},
) {
// principalRegexp expresses whether a principal is considered valid.
// This reverse engineers how sshd parses the authorized keys file,
// see e.g. https://github.com/openssh/openssh-portable/blob/32deb00b38b4ee2b3302f261ea1e68c04e020a08/auth2-pubkeyfile.c#L221-L256
// Any newline or # comment will be stripped when parsing, so don't allow
// those. Also, if any space or tab is present in the principal, the part
// proceeding this would be parsed as an option, so just avoid any whitespace
// altogether.
ret.principalRegexp = regexp.MustCompile(`^[^\s#]+$`)
return ret
})
func writeAuthorizedStringForKey(key *PublicKey, w io.Writer) (keyValid bool, err error) { func writeAuthorizedStringForKey(key *PublicKey, w io.Writer) (keyValid bool, err error) {
const tpl = AuthorizedStringCommentPrefix + "\n" + `command=%s,no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty,no-user-rc,restrict %s %s` + "\n" const tpl = AuthorizedStringCommentPrefix + "\n" + `command=%s,no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty,no-user-rc,restrict %s` + "\n"
pubKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(key.Content))
if err != nil { var sshKey string
return false, err
if key.Type == KeyTypePrincipal {
// TODO: actually using PublicKey to store "principal" is an abuse
if !globalVars().principalRegexp.MatchString(key.Content) {
return false, fmt.Errorf("invalid principal key: %s", key.Content)
}
sshKey = fmt.Sprintf("%s # user-%d", key.Content, key.OwnerID)
} else {
pubKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(key.Content))
if err != nil {
return false, err
}
sshKeyMarshalled := strings.TrimSpace(string(ssh.MarshalAuthorizedKey(pubKey)))
sshKey = fmt.Sprintf("%s user-%d", sshKeyMarshalled, key.OwnerID)
} }
// now the key is valid, the code below could only return template/IO related errors // now the key is valid, the code below could only return template/IO related errors
sbCmd := &strings.Builder{} sbCmd := &strings.Builder{}
err = setting.SSH.AuthorizedKeysCommandTemplateTemplate.Execute(sbCmd, map[string]any{ err = setting.SSH.AuthorizedKeysCommandTemplateTemplate.Execute(sbCmd, map[string]any{
@@ -69,9 +100,7 @@ func writeAuthorizedStringForKey(key *PublicKey, w io.Writer) (keyValid bool, er
return true, err return true, err
} }
sshCommandEscaped := util.ShellEscape(sbCmd.String()) sshCommandEscaped := util.ShellEscape(sbCmd.String())
sshKeyMarshalled := strings.TrimSpace(string(ssh.MarshalAuthorizedKey(pubKey))) _, err = fmt.Fprintf(w, tpl, sshCommandEscaped, sshKey)
sshKeyComment := fmt.Sprintf("user-%d", key.OwnerID)
_, err = fmt.Fprintf(w, tpl, sshCommandEscaped, sshKeyMarshalled, sshKeyComment)
return true, err return true, err
} }
@@ -0,0 +1,90 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package asymkey
import (
"strings"
"testing"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/test"
"github.com/stretchr/testify/assert"
)
func TestWriteAuthorizedStringForKey(t *testing.T) {
defer test.MockVariableValue(&setting.AppPath, "/tmp/gitea")()
defer test.MockVariableValue(&setting.CustomConf, "/tmp/app.ini")()
writeKey := func(t *testing.T, key *PublicKey) (bool, string, error) {
sb := &strings.Builder{}
valid, err := writeAuthorizedStringForKey(key, sb)
return valid, sb.String(), err
}
const validKeyContent = `ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICV0MGX/W9IvLA4FXpIuUcdDcbj5KX4syHgsTy7soVgf`
testValid := func(t *testing.T, key *PublicKey, expected string) {
valid, content, err := writeKey(t, key)
assert.True(t, valid)
assert.Equal(t, expected, content)
assert.NoError(t, err)
}
testInvalid := func(t *testing.T, key *PublicKey) {
valid, content, err := writeKey(t, key)
assert.False(t, valid)
assert.Empty(t, content)
assert.Error(t, err)
}
t.Run("PublicKey", func(t *testing.T) {
testValid(t, &PublicKey{
OwnerID: 123,
Content: validKeyContent + " any-comment",
Type: KeyTypeUser,
}, `# gitea public key
command="/tmp/gitea --config=/tmp/app.ini serv key-0",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty,no-user-rc,restrict ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICV0MGX/W9IvLA4FXpIuUcdDcbj5KX4syHgsTy7soVgf user-123
`)
})
t.Run("PublicKeyWithNewLine", func(t *testing.T) {
testValid(t, &PublicKey{
OwnerID: 123,
Content: validKeyContent + "\nany-more", // the new line should be ignored
Type: KeyTypeUser,
}, `# gitea public key
command="/tmp/gitea --config=/tmp/app.ini serv key-0",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty,no-user-rc,restrict ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICV0MGX/W9IvLA4FXpIuUcdDcbj5KX4syHgsTy7soVgf user-123
`)
})
t.Run("PublicKeyInvalid", func(t *testing.T) {
testInvalid(t, &PublicKey{
OwnerID: 123,
Content: validKeyContent + "any-more",
Type: KeyTypeUser,
})
})
t.Run("Principal", func(t *testing.T) {
testValid(t, &PublicKey{
OwnerID: 123,
Content: "any-content",
Type: KeyTypePrincipal,
}, `# gitea public key
command="/tmp/gitea --config=/tmp/app.ini serv key-0",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty,no-user-rc,restrict any-content # user-123
`)
})
t.Run("PrincipalInvalid", func(t *testing.T) {
testInvalid(t, &PublicKey{
OwnerID: 123,
Content: "a b",
Type: KeyTypePrincipal,
})
testInvalid(t, &PublicKey{
OwnerID: 123,
Content: "a\nb",
Type: KeyTypePrincipal,
})
})
}
+4 -4
View File
@@ -108,10 +108,10 @@ func GetLFSLock(ctx context.Context, repo *repo_model.Repository, path string) (
return rel, nil return rel, nil
} }
// GetLFSLockByID returns release by given id. // GetLFSLockByIDAndRepo returns lfs lock by given id and repository id.
func GetLFSLockByID(ctx context.Context, id int64) (*LFSLock, error) { func GetLFSLockByIDAndRepo(ctx context.Context, id, repoID int64) (*LFSLock, error) {
lock := new(LFSLock) lock := new(LFSLock)
has, err := db.GetEngine(ctx).ID(id).Get(lock) has, err := db.GetEngine(ctx).ID(id).And("repo_id = ?", repoID).Get(lock)
if err != nil { if err != nil {
return nil, err return nil, err
} else if !has { } else if !has {
@@ -160,7 +160,7 @@ func CountLFSLockByRepoID(ctx context.Context, repoID int64) (int64, error) {
// DeleteLFSLockByID deletes a lock by given ID. // DeleteLFSLockByID deletes a lock by given ID.
func DeleteLFSLockByID(ctx context.Context, id int64, repo *repo_model.Repository, u *user_model.User, force bool) (*LFSLock, error) { func DeleteLFSLockByID(ctx context.Context, id int64, repo *repo_model.Repository, u *user_model.User, force bool) (*LFSLock, error) {
return db.WithTx2(ctx, func(ctx context.Context) (*LFSLock, error) { return db.WithTx2(ctx, func(ctx context.Context) (*LFSLock, error) {
lock, err := GetLFSLockByID(ctx, id) lock, err := GetLFSLockByIDAndRepo(ctx, id, repo.ID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
+82
View File
@@ -0,0 +1,82 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package git
import (
"fmt"
"testing"
"time"
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"
"github.com/stretchr/testify/require"
)
func createTestLock(t *testing.T, repo *repo_model.Repository, owner *user_model.User) *LFSLock {
t.Helper()
path := fmt.Sprintf("%s-%d-%d", t.Name(), repo.ID, time.Now().UnixNano())
lock, err := CreateLFSLock(t.Context(), repo, &LFSLock{
OwnerID: owner.ID,
Path: path,
})
require.NoError(t, err)
return lock
}
func TestGetLFSLockByIDAndRepo(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
repo3 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 3})
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
user4 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4})
lockRepo1 := createTestLock(t, repo1, user2)
lockRepo3 := createTestLock(t, repo3, user4)
fetched, err := GetLFSLockByIDAndRepo(t.Context(), lockRepo1.ID, repo1.ID)
require.NoError(t, err)
assert.Equal(t, lockRepo1.ID, fetched.ID)
assert.Equal(t, repo1.ID, fetched.RepoID)
_, err = GetLFSLockByIDAndRepo(t.Context(), lockRepo1.ID, repo3.ID)
assert.Error(t, err)
assert.True(t, IsErrLFSLockNotExist(err))
_, err = GetLFSLockByIDAndRepo(t.Context(), lockRepo3.ID, repo1.ID)
assert.Error(t, err)
assert.True(t, IsErrLFSLockNotExist(err))
}
func TestDeleteLFSLockByIDRequiresRepoMatch(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
repo3 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 3})
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
user4 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4})
lockRepo1 := createTestLock(t, repo1, user2)
lockRepo3 := createTestLock(t, repo3, user4)
_, err := DeleteLFSLockByID(t.Context(), lockRepo3.ID, repo1, user2, true)
assert.Error(t, err)
assert.True(t, IsErrLFSLockNotExist(err))
existing, err := GetLFSLockByIDAndRepo(t.Context(), lockRepo3.ID, repo3.ID)
require.NoError(t, err)
assert.Equal(t, lockRepo3.ID, existing.ID)
deleted, err := DeleteLFSLockByID(t.Context(), lockRepo3.ID, repo3, user4, true)
require.NoError(t, err)
assert.Equal(t, lockRepo3.ID, deleted.ID)
deleted, err = DeleteLFSLockByID(t.Context(), lockRepo1.ID, repo1, user2, false)
require.NoError(t, err)
assert.Equal(t, lockRepo1.ID, deleted.ID)
}
+13
View File
@@ -12,6 +12,8 @@ import (
user_model "code.gitea.io/gitea/models/user" user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/timeutil"
"code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/util"
"xorm.io/builder"
) )
// Stopwatch represents a stopwatch for time tracking. // Stopwatch represents a stopwatch for time tracking.
@@ -232,3 +234,14 @@ func CancelStopwatch(ctx context.Context, user *user_model.User, issue *Issue) (
}) })
return ok, err return ok, err
} }
// RemoveStopwatchesByRepoID removes all stopwatches for a user in a specific repository
// this function should be called before removing all the issues of the repository
func RemoveStopwatchesByRepoID(ctx context.Context, userID, repoID int64) error {
_, err := db.GetEngine(ctx).
Where("`stopwatch`.user_id = ?", userID).
And(builder.In("`stopwatch`.issue_id",
builder.Select("id").From("issue").Where(builder.Eq{"repo_id": repoID}))).
Delete(new(Stopwatch))
return err
}
+12
View File
@@ -213,6 +213,18 @@ func GetColumn(ctx context.Context, columnID int64) (*Column, error) {
return column, nil return column, nil
} }
func GetColumnByIDAndProjectID(ctx context.Context, columnID, projectID int64) (*Column, error) {
column := new(Column)
has, err := db.GetEngine(ctx).ID(columnID).And("project_id=?", projectID).Get(column)
if err != nil {
return nil, err
} else if !has {
return nil, ErrProjectColumnNotExist{ColumnID: columnID}
}
return column, nil
}
// UpdateColumn updates a project column // UpdateColumn updates a project column
func UpdateColumn(ctx context.Context, column *Column) error { func UpdateColumn(ctx context.Context, column *Column) error {
var fieldToUpdate []string var fieldToUpdate []string
+12
View File
@@ -302,6 +302,18 @@ func GetProjectByID(ctx context.Context, id int64) (*Project, error) {
return p, nil return p, nil
} }
func GetProjectByIDAndOwner(ctx context.Context, id, ownerID int64) (*Project, error) {
p := new(Project)
has, err := db.GetEngine(ctx).ID(id).And("owner_id = ?", ownerID).Get(p)
if err != nil {
return nil, err
} else if !has {
return nil, ErrProjectNotExist{ID: id}
}
return p, nil
}
// GetProjectForRepoByID returns the projects in a repository // GetProjectForRepoByID returns the projects in a repository
func GetProjectForRepoByID(ctx context.Context, repoID, id int64) (*Project, error) { func GetProjectForRepoByID(ctx context.Context, repoID, id int64) (*Project, error) {
p := new(Project) p := new(Project)
+5
View File
@@ -166,6 +166,11 @@ func GetAttachmentByReleaseIDFileName(ctx context.Context, releaseID int64, file
return attach, nil return attach, nil
} }
func GetUnlinkedAttachmentsByUserID(ctx context.Context, userID int64) ([]*Attachment, error) {
attachments := make([]*Attachment, 0, 10)
return attachments, db.GetEngine(ctx).Where("uploader_id = ? AND issue_id = 0 AND release_id = 0 AND comment_id = 0", userID).Find(&attachments)
}
// DeleteAttachment deletes the given attachment and optionally the associated file. // DeleteAttachment deletes the given attachment and optionally the associated file.
func DeleteAttachment(ctx context.Context, a *Attachment, remove bool) error { func DeleteAttachment(ctx context.Context, a *Attachment, remove bool) error {
_, err := DeleteAttachments(ctx, []*Attachment{a}, remove) _, err := DeleteAttachments(ctx, []*Attachment{a}, remove)
+16
View File
@@ -101,3 +101,19 @@ func TestGetAttachmentsByUUIDs(t *testing.T) {
assert.Equal(t, int64(1), attachList[0].IssueID) assert.Equal(t, int64(1), attachList[0].IssueID)
assert.Equal(t, int64(5), attachList[1].IssueID) assert.Equal(t, int64(5), attachList[1].IssueID)
} }
func TestGetUnlinkedAttachmentsByUserID(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
attachments, err := repo_model.GetUnlinkedAttachmentsByUserID(t.Context(), 8)
assert.NoError(t, err)
assert.Len(t, attachments, 1)
assert.Equal(t, int64(10), attachments[0].ID)
assert.Zero(t, attachments[0].IssueID)
assert.Zero(t, attachments[0].ReleaseID)
assert.Zero(t, attachments[0].CommentID)
attachments, err = repo_model.GetUnlinkedAttachmentsByUserID(t.Context(), 1)
assert.NoError(t, err)
assert.Empty(t, attachments)
}
+34 -8
View File
@@ -93,15 +93,25 @@ func init() {
db.RegisterModel(new(Release)) db.RegisterModel(new(Release))
} }
// LoadAttributes load repo and publisher attributes for a release // LegacyAttachmentMissingRepoIDCutoff marks the date when repo_id started to be written during uploads
func (r *Release) LoadAttributes(ctx context.Context) error { // (2026-01-16T00:00:00Z). Older rows might have repo_id=0 and should be tolerated once.
var err error const LegacyAttachmentMissingRepoIDCutoff timeutil.TimeStamp = 1768521600
if r.Repo == nil {
r.Repo, err = GetRepositoryByID(ctx, r.RepoID) func (r *Release) LoadRepo(ctx context.Context) (err error) {
if err != nil { if r.Repo != nil {
return err 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 { if r.Publisher == nil {
r.Publisher, err = user_model.GetUserByID(ctx, r.PublisherID) r.Publisher, err = user_model.GetUserByID(ctx, r.PublisherID)
if err != nil { if err != nil {
@@ -168,6 +178,11 @@ func UpdateReleaseNumCommits(ctx context.Context, rel *Release) error {
// AddReleaseAttachments adds a release attachments // AddReleaseAttachments adds a release attachments
func AddReleaseAttachments(ctx context.Context, releaseID int64, attachmentUUIDs []string) (err error) { func AddReleaseAttachments(ctx context.Context, releaseID int64, attachmentUUIDs []string) (err error) {
rel, err := GetReleaseByID(ctx, releaseID)
if err != nil {
return err
}
// Check attachments // Check attachments
attachments, err := GetAttachmentsByUUIDs(ctx, attachmentUUIDs) attachments, err := GetAttachmentsByUUIDs(ctx, attachmentUUIDs)
if err != nil { if err != nil {
@@ -175,6 +190,17 @@ func AddReleaseAttachments(ctx context.Context, releaseID int64, attachmentUUIDs
} }
for i := range attachments { for i := range attachments {
if attachments[i].RepoID == 0 && attachments[i].CreatedUnix < LegacyAttachmentMissingRepoIDCutoff {
attachments[i].RepoID = rel.RepoID
if _, err = db.GetEngine(ctx).ID(attachments[i].ID).Cols("repo_id").Update(attachments[i]); err != nil {
return fmt.Errorf("update attachment repo_id [%d]: %w", attachments[i].ID, err)
}
}
if attachments[i].RepoID != rel.RepoID {
return util.NewPermissionDeniedErrorf("attachment belongs to different repository")
}
if attachments[i].ReleaseID != 0 { if attachments[i].ReleaseID != 0 {
return util.NewPermissionDeniedErrorf("release permission denied") return util.NewPermissionDeniedErrorf("release permission denied")
} }
+53
View File
@@ -6,7 +6,9 @@ package repo
import ( import (
"testing" "testing"
"code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/models/unittest" "code.gitea.io/gitea/models/unittest"
"code.gitea.io/gitea/modules/util"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
@@ -37,3 +39,54 @@ func Test_FindTagsByCommitIDs(t *testing.T) {
assert.Equal(t, "delete-tag", rels[1].TagName) assert.Equal(t, "delete-tag", rels[1].TagName)
assert.Equal(t, "v1.0", rels[2].TagName) assert.Equal(t, "v1.0", rels[2].TagName)
} }
func TestAddReleaseAttachmentsRejectsDifferentRepo(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
uuid := "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a12" // attachment 2 belongs to repo 2
err := AddReleaseAttachments(t.Context(), 1, []string{uuid})
assert.Error(t, err)
assert.ErrorIs(t, err, util.ErrPermissionDenied)
attach, err := GetAttachmentByUUID(t.Context(), uuid)
assert.NoError(t, err)
assert.Zero(t, attach.ReleaseID, "attachment should not be linked to release on failure")
}
func TestAddReleaseAttachmentsAllowsLegacyMissingRepoID(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
legacyUUID := "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a20" // attachment 10 has repo_id 0
err := AddReleaseAttachments(t.Context(), 1, []string{legacyUUID})
assert.NoError(t, err)
attach, err := GetAttachmentByUUID(t.Context(), legacyUUID)
assert.NoError(t, err)
assert.EqualValues(t, 1, attach.RepoID)
assert.EqualValues(t, 1, attach.ReleaseID)
}
func TestAddReleaseAttachmentsRejectsRecentZeroRepoID(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
recentUUID := "a0eebc99-9c0b-4ef8-bb6d-6bb9bd3800aa"
attachment := &Attachment{
UUID: recentUUID,
RepoID: 0,
IssueID: 0,
ReleaseID: 0,
CommentID: 0,
Name: "recent-zero",
CreatedUnix: LegacyAttachmentMissingRepoIDCutoff + 1,
}
assert.NoError(t, db.Insert(t.Context(), attachment))
err := AddReleaseAttachments(t.Context(), 1, []string{recentUUID})
assert.Error(t, err)
assert.ErrorIs(t, err, util.ErrPermissionDenied)
attach, err := GetAttachmentByUUID(t.Context(), recentUUID)
assert.NoError(t, err)
assert.Zero(t, attach.ReleaseID)
assert.Zero(t, attach.RepoID)
}
+10
View File
@@ -176,3 +176,13 @@ func WatchIfAuto(ctx context.Context, userID, repoID int64, isWrite bool) error
} }
return watchRepoMode(ctx, watch, WatchModeAuto) 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})
}
+19
View File
@@ -13,6 +13,7 @@ import (
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
) )
func TestIsWatching(t *testing.T) { func TestIsWatching(t *testing.T) {
@@ -119,3 +120,21 @@ func TestWatchIfAuto(t *testing.T) {
assert.NoError(t, err) assert.NoError(t, err)
assert.Len(t, watchers, prevCount) 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)
}
+9 -3
View File
@@ -102,7 +102,13 @@ func DeleteUserOpenID(ctx context.Context, openid *UserOpenID) (err error) {
} }
// ToggleUserOpenIDVisibility toggles visibility of an openid address of given user. // ToggleUserOpenIDVisibility toggles visibility of an openid address of given user.
func ToggleUserOpenIDVisibility(ctx context.Context, id int64) (err error) { func ToggleUserOpenIDVisibility(ctx context.Context, id int64, user *User) error {
_, err = db.GetEngine(ctx).Exec("update `user_open_id` set `show` = not `show` where `id` = ?", id) affected, err := db.GetEngine(ctx).Exec("update `user_open_id` set `show` = not `show` where `id` = ? AND uid = ?", id, user.ID)
return err if err != nil {
return err
}
if n, _ := affected.RowsAffected(); n != 1 {
return util.NewNotExistErrorf("OpenID is unknown")
}
return nil
} }
+15 -2
View File
@@ -8,6 +8,7 @@ import (
"code.gitea.io/gitea/models/unittest" "code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user" user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/util"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@@ -33,12 +34,14 @@ func TestGetUserOpenIDs(t *testing.T) {
func TestToggleUserOpenIDVisibility(t *testing.T) { func TestToggleUserOpenIDVisibility(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase()) assert.NoError(t, unittest.PrepareTestDatabase())
user, err := user_model.GetUserByID(t.Context(), int64(2))
require.NoError(t, err)
oids, err := user_model.GetUserOpenIDs(t.Context(), int64(2)) oids, err := user_model.GetUserOpenIDs(t.Context(), int64(2))
require.NoError(t, err) require.NoError(t, err)
require.Len(t, oids, 1) require.Len(t, oids, 1)
assert.True(t, oids[0].Show) assert.True(t, oids[0].Show)
err = user_model.ToggleUserOpenIDVisibility(t.Context(), oids[0].ID) err = user_model.ToggleUserOpenIDVisibility(t.Context(), oids[0].ID, user)
require.NoError(t, err) require.NoError(t, err)
oids, err = user_model.GetUserOpenIDs(t.Context(), int64(2)) oids, err = user_model.GetUserOpenIDs(t.Context(), int64(2))
@@ -46,7 +49,7 @@ func TestToggleUserOpenIDVisibility(t *testing.T) {
require.Len(t, oids, 1) require.Len(t, oids, 1)
assert.False(t, oids[0].Show) assert.False(t, oids[0].Show)
err = user_model.ToggleUserOpenIDVisibility(t.Context(), oids[0].ID) err = user_model.ToggleUserOpenIDVisibility(t.Context(), oids[0].ID, user)
require.NoError(t, err) require.NoError(t, err)
oids, err = user_model.GetUserOpenIDs(t.Context(), int64(2)) oids, err = user_model.GetUserOpenIDs(t.Context(), int64(2))
@@ -55,3 +58,13 @@ func TestToggleUserOpenIDVisibility(t *testing.T) {
assert.True(t, oids[0].Show) assert.True(t, oids[0].Show)
} }
} }
func TestToggleUserOpenIDVisibilityRequiresOwnership(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
unauthorizedUser, err := user_model.GetUserByID(t.Context(), int64(2))
require.NoError(t, err)
err = user_model.ToggleUserOpenIDVisibility(t.Context(), int64(1), unauthorizedUser)
require.Error(t, err)
assert.ErrorIs(t, err, util.ErrNotExist)
}
+8 -1
View File
@@ -9,6 +9,7 @@ import (
activities_model "code.gitea.io/gitea/models/activities" activities_model "code.gitea.io/gitea/models/activities"
issues_model "code.gitea.io/gitea/models/issues" issues_model "code.gitea.io/gitea/models/issues"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/graceful" "code.gitea.io/gitea/modules/graceful"
"code.gitea.io/gitea/modules/json" "code.gitea.io/gitea/modules/json"
"code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/log"
@@ -91,7 +92,13 @@ loop:
} }
for _, userStopwatches := range usersStopwatches { for _, userStopwatches := range usersStopwatches {
apiSWs, err := convert.ToStopWatches(ctx, userStopwatches.StopWatches) u, err := user_model.GetUserByID(ctx, userStopwatches.UserID)
if err != nil {
log.Error("Unable to get user %d: %v", userStopwatches.UserID, err)
continue
}
apiSWs, err := convert.ToStopWatches(ctx, u, userStopwatches.StopWatches)
if err != nil { if err != nil {
if !issues_model.IsErrIssueNotExist(err) { if !issues_model.IsErrIssueNotExist(err) {
log.Error("Unable to APIFormat stopwatches: %v", err) log.Error("Unable to APIFormat stopwatches: %v", err)
+19 -5
View File
@@ -9,24 +9,38 @@ package git
import ( import (
"io" "io"
"code.gitea.io/gitea/modules/log"
"github.com/go-git/go-git/v5/plumbing" "github.com/go-git/go-git/v5/plumbing"
) )
// Blob represents a Git object. // Blob represents a Git object.
type Blob struct { type Blob struct {
ID ObjectID ID ObjectID
repo *Repository
name string
}
gogitEncodedObj plumbing.EncodedObject func (b *Blob) gogitEncodedObj() (plumbing.EncodedObject, error) {
name string return b.repo.gogitRepo.Storer.EncodedObject(plumbing.AnyObject, plumbing.Hash(b.ID.RawValue()))
} }
// DataAsync gets a ReadCloser for the contents of a blob without reading it all. // DataAsync gets a ReadCloser for the contents of a blob without reading it all.
// Calling the Close function on the result will discard all unread output. // Calling the Close function on the result will discard all unread output.
func (b *Blob) DataAsync() (io.ReadCloser, error) { func (b *Blob) DataAsync() (io.ReadCloser, error) {
return b.gogitEncodedObj.Reader() obj, err := b.gogitEncodedObj()
if err != nil {
return nil, err
}
return obj.Reader()
} }
// Size returns the uncompressed size of the blob // Size returns the uncompressed size of the blob
func (b *Blob) Size() int64 { func (b *Blob) Size() int64 {
return b.gogitEncodedObj.Size() obj, err := b.gogitEncodedObj()
if err != nil {
log.Error("Error getting gogit encoded object for blob %s(%s): %v", b.name, b.ID.String(), err)
return 0
}
return obj.Size()
} }
+5 -3
View File
@@ -30,9 +30,11 @@ type Parser struct {
func NewParser(r io.Reader, format Format) *Parser { func NewParser(r io.Reader, format Format) *Parser {
scanner := bufio.NewScanner(r) scanner := bufio.NewScanner(r)
// default MaxScanTokenSize = 64 kiB may be too small for some references, // default Scanner.MaxScanTokenSize = 64 kiB may be too small for some references,
// so allow the buffer to grow up to 4x if needed // so allow the buffer to be large enough in case the ref has long content (e.g.: a tag with long message)
scanner.Buffer(nil, 4*bufio.MaxScanTokenSize) // as long as it doesn't exceed some reasonable limit (4 MiB here, or MAX_DISPLAY_FILE_SIZE=8MiB), it is OK
// there are still some choices: 1. add a config option for the limit; 2. don't use scanner and write our own parser to fully handle large contents
scanner.Buffer(nil, 4*1024*1024)
// in addition to the reference delimiter we specified in the --format, // in addition to the reference delimiter we specified in the --format,
// `git for-each-ref` will always add a newline after every reference. // `git for-each-ref` will always add a newline after every reference.
+7 -1
View File
@@ -9,5 +9,11 @@ func (repo *Repository) GetBlob(idStr string) (*Blob, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
return repo.getBlob(id) if id.IsZero() {
return nil, ErrNotExist{id.String(), ""}
}
return &Blob{
ID: id,
repo: repo,
}, nil
} }
-22
View File
@@ -1,22 +0,0 @@
// Copyright 2018 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
//go:build gogit
package git
import (
"github.com/go-git/go-git/v5/plumbing"
)
func (repo *Repository) getBlob(id ObjectID) (*Blob, error) {
encodedObj, err := repo.gogitRepo.Storer.EncodedObject(plumbing.AnyObject, plumbing.Hash(id.RawValue()))
if err != nil {
return nil, ErrNotExist{id.String(), ""}
}
return &Blob{
ID: id,
gogitEncodedObj: encodedObj,
}, nil
}
-16
View File
@@ -1,16 +0,0 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
//go:build !gogit
package git
func (repo *Repository) getBlob(id ObjectID) (*Blob, error) {
if id.IsZero() {
return nil, ErrNotExist{id.String(), ""}
}
return &Blob{
ID: id,
repo: repo,
}, nil
}
+3 -9
View File
@@ -7,7 +7,6 @@
package git package git
import ( import (
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/filemode" "github.com/go-git/go-git/v5/plumbing/filemode"
"github.com/go-git/go-git/v5/plumbing/object" "github.com/go-git/go-git/v5/plumbing/object"
) )
@@ -83,14 +82,9 @@ func (te *TreeEntry) IsExecutable() bool {
// Blob returns the blob object the entry // Blob returns the blob object the entry
func (te *TreeEntry) Blob() *Blob { func (te *TreeEntry) Blob() *Blob {
encodedObj, err := te.ptree.repo.gogitRepo.Storer.EncodedObject(plumbing.AnyObject, te.gogitTreeEntry.Hash)
if err != nil {
return nil
}
return &Blob{ return &Blob{
ID: ParseGogitHash(te.gogitTreeEntry.Hash), ID: ParseGogitHash(te.gogitTreeEntry.Hash),
gogitEncodedObj: encodedObj, repo: te.ptree.repo,
name: te.Name(), name: te.Name(),
} }
} }
+6 -3
View File
@@ -10,9 +10,12 @@ import (
// ReleaseAsset represents a release asset // ReleaseAsset represents a release asset
type ReleaseAsset struct { type ReleaseAsset struct {
ID int64 ID int64
Name string Name string
ContentType *string `yaml:"content_type"`
// There was a field "ContentType (content_type)" because Some forges can provide that for assets,
// but we don't need it when migrating, so the field is omitted here.
Size *int Size *int
DownloadCount *int `yaml:"download_count"` DownloadCount *int `yaml:"download_count"`
Created time.Time Created time.Time
+1 -1
View File
@@ -233,7 +233,7 @@ func SyncReleasesWithTags(ctx context.Context, repo *repo_model.Repository, gitR
return fmt.Errorf("unable to update tag %s for pull-mirror Repo[%d:%s/%s]: %w", tag.Name, repo.ID, repo.OwnerName, repo.Name, err) return fmt.Errorf("unable to update tag %s for pull-mirror Repo[%d:%s/%s]: %w", tag.Name, repo.ID, repo.OwnerName, repo.Name, err)
} }
} }
added, deleted, updated = len(deletes), len(updates), len(inserts) added, deleted, updated = len(inserts), len(deletes), len(updates)
return nil return nil
}) })
if err != nil { if err != nil {
+23
View File
@@ -107,6 +107,17 @@ func detectFileTypeBox(data []byte) (brands []string, found bool) {
return brands, true return brands, true
} }
func isEmbeddedOpenType(data []byte) bool {
// https://www.w3.org/submissions/EOT
if len(data) < 80 {
return false
}
version := binary.LittleEndian.Uint32(data[8:]) // Actually this standard is abandoned (for IE6-IE11 only), there are only 3 versions defined
magic := binary.LittleEndian.Uint16(data[34:36]) // MagicNumber: 0x504C ("LP")
reserved := data[64:80] // Reserved 1-4 (each: unsigned long)
return (version == 0x00010000 || version == 0x00020001 || version == 0x00020002) && magic == 0x504C && bytes.Count(reserved, []byte{0}) == len(reserved)
}
// DetectContentType extends http.DetectContentType with more content types. Defaults to text/plain if input is empty. // DetectContentType extends http.DetectContentType with more content types. Defaults to text/plain if input is empty.
func DetectContentType(data []byte) SniffedType { func DetectContentType(data []byte) SniffedType {
if len(data) == 0 { if len(data) == 0 {
@@ -119,6 +130,18 @@ func DetectContentType(data []byte) SniffedType {
data = data[:SniffContentSize] data = data[:SniffContentSize]
} }
const typeMsFontObject = "application/vnd.ms-fontobject"
if ct == typeMsFontObject {
// Stupid Golang blindly detects any content with 34th-35th bytes being "LP" as font.
// If it is not really for ".eot" content, we try to detect it again by hiding the "LP", see the test for more details.
if isEmbeddedOpenType(data) {
return SniffedType{typeMsFontObject}
}
data = slices.Clone(data)
data[34] = 'l'
ct = http.DetectContentType(data)
}
vars := globalVars() vars := globalVars()
// SVG is unsupported by http.DetectContentType, https://github.com/golang/go/issues/15888 // SVG is unsupported by http.DetectContentType, https://github.com/golang/go/issues/15888
detectByHTML := strings.Contains(ct, "text/plain") || strings.Contains(ct, "text/html") detectByHTML := strings.Contains(ct, "text/plain") || strings.Contains(ct, "text/html")
+23
View File
@@ -6,6 +6,7 @@ package typesniffer
import ( import (
"encoding/base64" "encoding/base64"
"encoding/hex" "encoding/hex"
"net/http"
"strings" "strings"
"testing" "testing"
@@ -154,3 +155,25 @@ func TestDetectContentTypeAvif(t *testing.T) {
st := DetectContentType(buf) st := DetectContentType(buf)
assert.Equal(t, MimeTypeImageAvif, st.contentType) assert.Equal(t, MimeTypeImageAvif, st.contentType)
} }
func TestDetectContentTypeIncorrectFont(t *testing.T) {
s := "Stupid Golang keep detecting 34th LP as font"
// They don't want to have any improvement to it: https://github.com/golang/go/issues/77172
golangDetected := http.DetectContentType([]byte(s))
assert.Equal(t, "application/vnd.ms-fontobject", golangDetected)
// We have to make our patch to make it work correctly
ourDetected := DetectContentType([]byte(s))
assert.Equal(t, "text/plain; charset=utf-8", ourDetected.contentType)
// For binary content, ensure it still detects as font. The content is from "opensans-regular.eot"
b := []byte{
0x3d, 0x30, 0x00, 0x00, 0x6b, 0x2f, 0x00, 0x00, 0x02, 0x00, 0x02, 0x00, 0x04, 0x00, 0x00, 0x00,
0x02, 0x0b, 0x06, 0x06, 0x03, 0x05, 0x04, 0x02, 0x02, 0x04, 0x01, 0x00, 0x90, 0x01, 0x00, 0x00,
0x04, 0x00, 0x4c, 0x50, 0xef, 0x02, 0x00, 0xe0, 0x5b, 0x20, 0x00, 0x40, 0x28, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x9f, 0x01, 0x00, 0x20, 0x00, 0x00, 0x00, 0x00, 0x63, 0xf4, 0x17, 0x14,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x12, 0x00, 0x4f, 0x00, 0x70, 0x00, 0x65, 0x00, 0x6e, 0x00, 0x20, 0x00, 0x53, 0x00,
}
assert.Equal(t, "application/vnd.ms-fontobject", http.DetectContentType(b))
assert.Equal(t, "application/vnd.ms-fontobject", DetectContentType(b).contentType)
}
+8 -8
View File
@@ -1400,19 +1400,19 @@ func Routes() *web.Router {
}) })
m.Get("/{base}/*", repo.GetPullRequestByBaseHead) m.Get("/{base}/*", repo.GetPullRequestByBaseHead)
}, mustAllowPulls, reqRepoReader(unit.TypeCode), context.ReferencesGitRepo()) }, mustAllowPulls, reqRepoReader(unit.TypeCode), context.ReferencesGitRepo())
m.Group("/statuses", func() { m.Group("/statuses", func() { // "/statuses/{sha}" only accepts commit ID
m.Combo("/{sha}").Get(repo.GetCommitStatuses). m.Combo("/{sha}").Get(repo.GetCommitStatuses).
Post(reqToken(), reqRepoWriter(unit.TypeCode), bind(api.CreateStatusOption{}), repo.NewCommitStatus) Post(reqToken(), reqRepoWriter(unit.TypeCode), bind(api.CreateStatusOption{}), repo.NewCommitStatus)
}, reqRepoReader(unit.TypeCode)) }, reqRepoReader(unit.TypeCode))
m.Group("/commits", func() { m.Group("/commits", func() {
m.Get("", context.ReferencesGitRepo(), repo.GetAllCommits) m.Get("", context.ReferencesGitRepo(), repo.GetAllCommits)
m.Group("/{ref}", func() { m.PathGroup("/*", func(g *web.RouterPathGroup) {
m.Get("/status", repo.GetCombinedCommitStatusByRef) // Mis-configured reverse proxy might decode the `%2F` to slash ahead, so we need to support both formats (escaped, unescaped) here.
m.Get("/statuses", repo.GetCommitStatusesByRef) // It also matches GitHub's behavior
}, context.ReferencesGitRepo()) g.MatchPath("GET", "/<ref:*>/status", repo.GetCombinedCommitStatusByRef)
m.Group("/{sha}", func() { g.MatchPath("GET", "/<ref:*>/statuses", repo.GetCommitStatusesByRef)
m.Get("/pull", repo.GetCommitPullRequest) g.MatchPath("GET", "/<sha>/pull", repo.GetCommitPullRequest)
}, context.ReferencesGitRepo()) })
}, reqRepoReader(unit.TypeCode)) }, reqRepoReader(unit.TypeCode))
m.Group("/git", func() { m.Group("/git", func() {
m.Group("/commits", func() { m.Group("/commits", func() {
+1 -1
View File
@@ -224,7 +224,7 @@ func GetStopwatches(ctx *context.APIContext) {
return return
} }
apiSWs, err := convert.ToStopWatches(ctx, sws) apiSWs, err := convert.ToStopWatches(ctx, ctx.Doer, sws)
if err != nil { if err != nil {
ctx.APIErrorInternal(err) ctx.APIErrorInternal(err)
return return
+3 -3
View File
@@ -1337,7 +1337,7 @@ func CancelScheduledAutoMerge(ctx *context.APIContext) {
} }
if ctx.Doer.ID != autoMerge.DoerID { if ctx.Doer.ID != autoMerge.DoerID {
allowed, err := access_model.IsUserRepoAdmin(ctx, ctx.Repo.Repository, ctx.Doer) allowed, err := pull_service.IsUserAllowedToMerge(ctx, pull, ctx.Repo.Permission, ctx.Doer)
if err != nil { if err != nil {
ctx.APIErrorInternal(err) ctx.APIErrorInternal(err)
return return
@@ -1548,9 +1548,9 @@ func GetPullRequestFiles(ctx *context.APIContext) {
var prInfo *pull_service.CompareInfo var prInfo *pull_service.CompareInfo
if pr.HasMerged { if pr.HasMerged {
prInfo, err = pull_service.GetCompareInfo(ctx, pr.BaseRepo, pr.BaseRepo, baseGitRepo, pr.MergeBase, pr.GetGitHeadRefName(), true, false) prInfo, err = pull_service.GetCompareInfo(ctx, pr.BaseRepo, pr.BaseRepo, baseGitRepo, pr.MergeBase, pr.GetGitHeadRefName(), false, false)
} else { } else {
prInfo, err = pull_service.GetCompareInfo(ctx, pr.BaseRepo, pr.BaseRepo, baseGitRepo, pr.BaseBranch, pr.GetGitHeadRefName(), true, false) prInfo, err = pull_service.GetCompareInfo(ctx, pr.BaseRepo, pr.BaseRepo, baseGitRepo, pr.BaseBranch, pr.GetGitHeadRefName(), false, false)
} }
if err != nil { if err != nil {
ctx.APIErrorInternal(err) ctx.APIErrorInternal(err)
@@ -398,7 +398,6 @@ func DeleteReleaseAttachment(ctx *context.APIContext) {
ctx.APIErrorNotFound() ctx.APIErrorNotFound()
return return
} }
// FIXME Should prove the existence of the given repo, but results in unnecessary database requests
if err := repo_model.DeleteAttachment(ctx, attach, true); err != nil { if err := repo_model.DeleteAttachment(ctx, attach, true); err != nil {
ctx.APIErrorInternal(err) ctx.APIErrorInternal(err)
+2
View File
@@ -44,9 +44,11 @@ func isRoutePathExpensive(routePattern string) bool {
"/{username}/{reponame}/blame/", "/{username}/{reponame}/blame/",
"/{username}/{reponame}/commit/", "/{username}/{reponame}/commit/",
"/{username}/{reponame}/commits/", "/{username}/{reponame}/commits/",
"/{username}/{reponame}/compare/",
"/{username}/{reponame}/graph", "/{username}/{reponame}/graph",
"/{username}/{reponame}/media/", "/{username}/{reponame}/media/",
"/{username}/{reponame}/raw/", "/{username}/{reponame}/raw/",
"/{username}/{reponame}/rss/branch/",
"/{username}/{reponame}/src/", "/{username}/{reponame}/src/",
// issue & PR related (no trailing slash) // issue & PR related (no trailing slash)
+28 -71
View File
@@ -205,22 +205,24 @@ func ChangeProjectStatus(ctx *context.Context) {
} }
id := ctx.PathParamInt64("id") id := ctx.PathParamInt64("id")
if err := project_model.ChangeProjectStatusByRepoIDAndID(ctx, 0, id, toClose); err != nil { project, err := project_model.GetProjectByIDAndOwner(ctx, id, ctx.ContextUser.ID)
ctx.NotFoundOrServerError("ChangeProjectStatusByRepoIDAndID", project_model.IsErrProjectNotExist, err)
return
}
ctx.JSONRedirect(project_model.ProjectLinkForOrg(ctx.ContextUser, id))
}
// DeleteProject delete a project
func DeleteProject(ctx *context.Context) {
p, err := project_model.GetProjectByID(ctx, ctx.PathParamInt64("id"))
if err != nil { if err != nil {
ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err) ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err)
return return
} }
if p.OwnerID != ctx.ContextUser.ID {
ctx.NotFound(nil) if err := project_model.ChangeProjectStatusByRepoIDAndID(ctx, 0, project.ID, toClose); err != nil {
ctx.NotFoundOrServerError("ChangeProjectStatusByRepoIDAndID", project_model.IsErrProjectNotExist, err)
return
}
ctx.JSONRedirect(project_model.ProjectLinkForOrg(ctx.ContextUser, project.ID))
}
// DeleteProject delete a project
func DeleteProject(ctx *context.Context) {
p, err := project_model.GetProjectByIDAndOwner(ctx, ctx.PathParamInt64("id"), ctx.ContextUser.ID)
if err != nil {
ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err)
return return
} }
@@ -246,15 +248,11 @@ func RenderEditProject(ctx *context.Context) {
return return
} }
p, err := project_model.GetProjectByID(ctx, ctx.PathParamInt64("id")) p, err := project_model.GetProjectByIDAndOwner(ctx, ctx.PathParamInt64("id"), ctx.ContextUser.ID)
if err != nil { if err != nil {
ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err) ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err)
return return
} }
if p.OwnerID != ctx.ContextUser.ID {
ctx.NotFound(nil)
return
}
ctx.Data["projectID"] = p.ID ctx.Data["projectID"] = p.ID
ctx.Data["title"] = p.Title ctx.Data["title"] = p.Title
@@ -288,15 +286,11 @@ func EditProjectPost(ctx *context.Context) {
return return
} }
p, err := project_model.GetProjectByID(ctx, projectID) p, err := project_model.GetProjectByIDAndOwner(ctx, projectID, ctx.ContextUser.ID)
if err != nil { if err != nil {
ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err) ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err)
return return
} }
if p.OwnerID != ctx.ContextUser.ID {
ctx.NotFound(nil)
return
}
p.Title = form.Title p.Title = form.Title
p.Description = form.Content p.Description = form.Content
@@ -316,15 +310,12 @@ func EditProjectPost(ctx *context.Context) {
// ViewProject renders the project with board view for a project // ViewProject renders the project with board view for a project
func ViewProject(ctx *context.Context) { func ViewProject(ctx *context.Context) {
project, err := project_model.GetProjectByID(ctx, ctx.PathParamInt64("id")) project, err := project_model.GetProjectByIDAndOwner(ctx, ctx.PathParamInt64("id"), ctx.ContextUser.ID)
if err != nil { if err != nil {
ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err) ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err)
return return
} }
if project.OwnerID != ctx.ContextUser.ID {
ctx.NotFound(nil)
return
}
if err := project.LoadOwner(ctx); err != nil { if err := project.LoadOwner(ctx); err != nil {
ctx.ServerError("LoadOwner", err) ctx.ServerError("LoadOwner", err)
return return
@@ -455,28 +446,15 @@ func DeleteProjectColumn(ctx *context.Context) {
return return
} }
project, err := project_model.GetProjectByID(ctx, ctx.PathParamInt64("id")) project, err := project_model.GetProjectByIDAndOwner(ctx, ctx.PathParamInt64("id"), ctx.ContextUser.ID)
if err != nil { if err != nil {
ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err) ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err)
return return
} }
pb, err := project_model.GetColumn(ctx, ctx.PathParamInt64("columnID")) _, err = project_model.GetColumnByIDAndProjectID(ctx, ctx.PathParamInt64("columnID"), project.ID)
if err != nil { if err != nil {
ctx.ServerError("GetProjectColumn", err) ctx.NotFoundOrServerError("GetColumnByIDAndProjectID", project_model.IsErrProjectColumnNotExist, err)
return
}
if pb.ProjectID != ctx.PathParamInt64("id") {
ctx.JSON(http.StatusUnprocessableEntity, map[string]string{
"message": fmt.Sprintf("ProjectColumn[%d] is not in Project[%d] as expected", pb.ID, project.ID),
})
return
}
if project.OwnerID != ctx.ContextUser.ID {
ctx.JSON(http.StatusUnprocessableEntity, map[string]string{
"message": fmt.Sprintf("ProjectColumn[%d] is not in Owner[%d] as expected", pb.ID, ctx.ContextUser.ID),
})
return return
} }
@@ -492,7 +470,7 @@ func DeleteProjectColumn(ctx *context.Context) {
func AddColumnToProjectPost(ctx *context.Context) { func AddColumnToProjectPost(ctx *context.Context) {
form := web.GetForm(ctx).(*forms.EditProjectColumnForm) form := web.GetForm(ctx).(*forms.EditProjectColumnForm)
project, err := project_model.GetProjectByID(ctx, ctx.PathParamInt64("id")) project, err := project_model.GetProjectByIDAndOwner(ctx, ctx.PathParamInt64("id"), ctx.ContextUser.ID)
if err != nil { if err != nil {
ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err) ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err)
return return
@@ -520,30 +498,18 @@ func CheckProjectColumnChangePermissions(ctx *context.Context) (*project_model.P
return nil, nil return nil, nil
} }
project, err := project_model.GetProjectByID(ctx, ctx.PathParamInt64("id")) project, err := project_model.GetProjectByIDAndOwner(ctx, ctx.PathParamInt64("id"), ctx.ContextUser.ID)
if err != nil { if err != nil {
ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err) ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err)
return nil, nil return nil, nil
} }
column, err := project_model.GetColumn(ctx, ctx.PathParamInt64("columnID")) column, err := project_model.GetColumnByIDAndProjectID(ctx, ctx.PathParamInt64("columnID"), project.ID)
if err != nil { if err != nil {
ctx.ServerError("GetProjectColumn", err) ctx.NotFoundOrServerError("GetColumnByIDAndProjectID", project_model.IsErrProjectColumnNotExist, err)
return nil, nil
}
if column.ProjectID != ctx.PathParamInt64("id") {
ctx.JSON(http.StatusUnprocessableEntity, map[string]string{
"message": fmt.Sprintf("ProjectColumn[%d] is not in Project[%d] as expected", column.ID, project.ID),
})
return nil, nil return nil, nil
} }
if project.OwnerID != ctx.ContextUser.ID {
ctx.JSON(http.StatusUnprocessableEntity, map[string]string{
"message": fmt.Sprintf("ProjectColumn[%d] is not in Repository[%d] as expected", column.ID, project.ID),
})
return nil, nil
}
return project, column return project, column
} }
@@ -595,24 +561,15 @@ func MoveIssues(ctx *context.Context) {
return return
} }
project, err := project_model.GetProjectByID(ctx, ctx.PathParamInt64("id")) project, err := project_model.GetProjectByIDAndOwner(ctx, ctx.PathParamInt64("id"), ctx.ContextUser.ID)
if err != nil { if err != nil {
ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err) ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err)
return return
} }
if project.OwnerID != ctx.ContextUser.ID {
ctx.NotFound(nil)
return
}
column, err := project_model.GetColumn(ctx, ctx.PathParamInt64("columnID")) column, err := project_model.GetColumnByIDAndProjectID(ctx, ctx.PathParamInt64("columnID"), project.ID)
if err != nil { if err != nil {
ctx.NotFoundOrServerError("GetProjectColumn", project_model.IsErrProjectColumnNotExist, err) ctx.NotFoundOrServerError("GetColumnByIDAndProjectID", project_model.IsErrProjectColumnNotExist, err)
return
}
if column.ProjectID != project.ID {
ctx.NotFound(nil)
return return
} }
+30
View File
@@ -4,11 +4,14 @@
package org_test package org_test
import ( import (
"net/http"
"testing" "testing"
"code.gitea.io/gitea/models/unittest" "code.gitea.io/gitea/models/unittest"
"code.gitea.io/gitea/modules/web"
"code.gitea.io/gitea/routers/web/org" "code.gitea.io/gitea/routers/web/org"
"code.gitea.io/gitea/services/contexttest" "code.gitea.io/gitea/services/contexttest"
"code.gitea.io/gitea/services/forms"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
@@ -26,3 +29,30 @@ func TestCheckProjectColumnChangePermissions(t *testing.T) {
assert.NotNil(t, column) assert.NotNil(t, column)
assert.False(t, ctx.Written()) assert.False(t, ctx.Written())
} }
func TestChangeProjectStatusRejectsForeignProjects(t *testing.T) {
unittest.PrepareTestEnv(t)
// project 4 is owned by user2 not user1
ctx, _ := contexttest.MockContext(t, "user1/-/projects/4/close")
contexttest.LoadUser(t, ctx, 1)
ctx.ContextUser = ctx.Doer
ctx.SetPathParam("action", "close")
ctx.SetPathParam("id", "4")
org.ChangeProjectStatus(ctx)
assert.Equal(t, http.StatusNotFound, ctx.Resp.WrittenStatus())
}
func TestAddColumnToProjectPostRejectsForeignProjects(t *testing.T) {
unittest.PrepareTestEnv(t)
ctx, _ := contexttest.MockContext(t, "user1/-/projects/4/columns/new")
contexttest.LoadUser(t, ctx, 1)
ctx.ContextUser = ctx.Doer
ctx.SetPathParam("id", "4")
web.SetForm(ctx, &forms.EditProjectColumnForm{Title: "foreign"})
org.AddColumnToProjectPost(ctx)
assert.Equal(t, http.StatusNotFound, ctx.Resp.WrittenStatus())
}
+64 -14
View File
@@ -4,11 +4,12 @@
package repo package repo
import ( import (
"fmt"
"net/http" "net/http"
issues_model "code.gitea.io/gitea/models/issues"
access_model "code.gitea.io/gitea/models/perm/access" access_model "code.gitea.io/gitea/models/perm/access"
repo_model "code.gitea.io/gitea/models/repo" repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unit"
"code.gitea.io/gitea/modules/httpcache" "code.gitea.io/gitea/modules/httpcache"
"code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
@@ -40,7 +41,7 @@ func uploadAttachment(ctx *context.Context, repoID int64, allowedTypes string) {
file, header, err := ctx.Req.FormFile("file") file, header, err := ctx.Req.FormFile("file")
if err != nil { if err != nil {
ctx.HTTPError(http.StatusInternalServerError, fmt.Sprintf("FormFile: %v", err)) ctx.ServerError("FormFile", err)
return return
} }
defer file.Close() defer file.Close()
@@ -56,7 +57,7 @@ func uploadAttachment(ctx *context.Context, repoID int64, allowedTypes string) {
ctx.HTTPError(http.StatusBadRequest, err.Error()) ctx.HTTPError(http.StatusBadRequest, err.Error())
return return
} }
ctx.HTTPError(http.StatusInternalServerError, fmt.Sprintf("NewAttachment: %v", err)) ctx.ServerError("UploadAttachmentGeneralSizeLimit", err)
return return
} }
@@ -74,13 +75,44 @@ func DeleteAttachment(ctx *context.Context) {
ctx.HTTPError(http.StatusBadRequest, err.Error()) ctx.HTTPError(http.StatusBadRequest, err.Error())
return return
} }
if !ctx.IsSigned || (ctx.Doer.ID != attach.UploaderID) {
if !ctx.IsSigned {
ctx.HTTPError(http.StatusForbidden) ctx.HTTPError(http.StatusForbidden)
return return
} }
if attach.RepoID != ctx.Repo.Repository.ID {
ctx.HTTPError(http.StatusBadRequest, "attachment does not belong to this repository")
return
}
if ctx.Doer.ID != attach.UploaderID {
if attach.IssueID > 0 {
issue, err := issues_model.GetIssueByID(ctx, attach.IssueID)
if err != nil {
ctx.ServerError("GetIssueByID", err)
return
}
if !ctx.Repo.Permission.CanWriteIssuesOrPulls(issue.IsPull) {
ctx.HTTPError(http.StatusForbidden)
return
}
} else if attach.ReleaseID > 0 {
if !ctx.Repo.Permission.CanWrite(unit.TypeReleases) {
ctx.HTTPError(http.StatusForbidden)
return
}
} else {
if !ctx.Repo.Permission.IsAdmin() && !ctx.Repo.Permission.IsOwner() {
ctx.HTTPError(http.StatusForbidden)
return
}
}
}
err = repo_model.DeleteAttachment(ctx, attach, true) err = repo_model.DeleteAttachment(ctx, attach, true)
if err != nil { if err != nil {
ctx.HTTPError(http.StatusInternalServerError, fmt.Sprintf("DeleteAttachment: %v", err)) ctx.ServerError("DeleteAttachment", err)
return return
} }
ctx.JSON(http.StatusOK, map[string]string{ ctx.JSON(http.StatusOK, map[string]string{
@@ -100,23 +132,41 @@ func ServeAttachment(ctx *context.Context, uuid string) {
return return
} }
repository, unitType, err := repo_service.LinkedRepository(ctx, attach) // prevent visiting attachment from other repository directly
if err != nil { // The check will be ignored before this code merged.
ctx.ServerError("LinkedRepository", err) if attach.CreatedUnix > repo_model.LegacyAttachmentMissingRepoIDCutoff && ctx.Repo.Repository != nil && ctx.Repo.Repository.ID != attach.RepoID {
ctx.HTTPError(http.StatusNotFound)
return return
} }
if repository == nil { // If not linked unitType, repoID, err := repo_service.GetAttachmentLinkedTypeAndRepoID(ctx, attach)
if err != nil {
ctx.ServerError("GetAttachmentLinkedTypeAndRepoID", err)
return
}
if unitType == unit.TypeInvalid { // unlinked attachment can only be accessed by the uploader
if !(ctx.IsSigned && attach.UploaderID == ctx.Doer.ID) { // We block if not the uploader if !(ctx.IsSigned && attach.UploaderID == ctx.Doer.ID) { // We block if not the uploader
ctx.HTTPError(http.StatusNotFound) ctx.HTTPError(http.StatusNotFound)
return return
} }
} else { // If we have the repository we check access } else { // If we have the linked type, we need to check access
perm, err := access_model.GetUserRepoPermission(ctx, repository, ctx.Doer) var perm access_model.Permission
if err != nil { if ctx.Repo.Repository == nil {
ctx.HTTPError(http.StatusInternalServerError, "GetUserRepoPermission", err.Error()) repo, err := repo_model.GetRepositoryByID(ctx, repoID)
return if err != nil {
ctx.ServerError("GetRepositoryByID", err)
return
}
perm, err = access_model.GetUserRepoPermission(ctx, repo, ctx.Doer)
if err != nil {
ctx.ServerError("GetUserRepoPermission", err)
return
}
} else {
perm = ctx.Repo.Permission
} }
if !perm.CanRead(unitType) { if !perm.CanRead(unitType) {
ctx.HTTPError(http.StatusNotFound) ctx.HTTPError(http.StatusNotFound)
return return
+18 -29
View File
@@ -31,6 +31,7 @@ import (
repo_module "code.gitea.io/gitea/modules/repository" repo_module "code.gitea.io/gitea/modules/repository"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/context"
repo_service "code.gitea.io/gitea/services/repository" repo_service "code.gitea.io/gitea/services/repository"
@@ -57,7 +58,7 @@ func CorsHandler() func(next http.Handler) http.Handler {
} }
// httpBase implementation git smart HTTP protocol // httpBase implementation git smart HTTP protocol
func httpBase(ctx *context.Context) *serviceHandler { func httpBase(ctx *context.Context, optGitService ...string) *serviceHandler {
username := ctx.PathParam("username") username := ctx.PathParam("username")
reponame := strings.TrimSuffix(ctx.PathParam("reponame"), ".git") reponame := strings.TrimSuffix(ctx.PathParam("reponame"), ".git")
@@ -67,18 +68,14 @@ func httpBase(ctx *context.Context) *serviceHandler {
} }
var isPull, receivePack bool var isPull, receivePack bool
service := ctx.FormString("service") switch util.OptionalArg(optGitService) {
if service == "git-receive-pack" || case "git-receive-pack":
strings.HasSuffix(ctx.Req.URL.Path, "git-receive-pack") {
isPull = false
receivePack = true receivePack = true
} else if service == "git-upload-pack" || case "git-upload-pack":
strings.HasSuffix(ctx.Req.URL.Path, "git-upload-pack") {
isPull = true isPull = true
} else if service == "git-upload-archive" || case "git-upload-archive":
strings.HasSuffix(ctx.Req.URL.Path, "git-upload-archive") {
isPull = true isPull = true
} else { default:
isPull = ctx.Req.Method == http.MethodHead || ctx.Req.Method == http.MethodGet isPull = ctx.Req.Method == http.MethodHead || ctx.Req.Method == http.MethodGet
} }
@@ -411,13 +408,19 @@ func prepareGitCmdWithAllowedService(service string) (*gitcmd.Command, error) {
return nil, fmt.Errorf("service %q is not allowed", service) return nil, fmt.Errorf("service %q is not allowed", service)
} }
func serviceRPC(ctx *context.Context, h *serviceHandler, service string) { func serviceRPC(ctx *context.Context, service string) {
defer func() { defer func() {
if err := ctx.Req.Body.Close(); err != nil { if err := ctx.Req.Body.Close(); err != nil {
log.Error("serviceRPC: Close: %v", err) log.Error("serviceRPC: Close: %v", err)
} }
}() }()
h := httpBase(ctx, "git-"+service)
if h == nil {
ctx.Resp.WriteHeader(http.StatusBadRequest)
return
}
expectedContentType := fmt.Sprintf("application/x-git-%s-request", service) expectedContentType := fmt.Sprintf("application/x-git-%s-request", service)
if ctx.Req.Header.Get("Content-Type") != expectedContentType { if ctx.Req.Header.Get("Content-Type") != expectedContentType {
log.Error("Content-Type (%q) doesn't match expected: %q", ctx.Req.Header.Get("Content-Type"), expectedContentType) log.Error("Content-Type (%q) doesn't match expected: %q", ctx.Req.Header.Get("Content-Type"), expectedContentType)
@@ -472,26 +475,12 @@ func serviceRPC(ctx *context.Context, h *serviceHandler, service string) {
// ServiceUploadPack implements Git Smart HTTP protocol // ServiceUploadPack implements Git Smart HTTP protocol
func ServiceUploadPack(ctx *context.Context) { func ServiceUploadPack(ctx *context.Context) {
h := httpBase(ctx) serviceRPC(ctx, "upload-pack")
if h != nil {
serviceRPC(ctx, h, "upload-pack")
}
} }
// ServiceReceivePack implements Git Smart HTTP protocol // ServiceReceivePack implements Git Smart HTTP protocol
func ServiceReceivePack(ctx *context.Context) { func ServiceReceivePack(ctx *context.Context) {
h := httpBase(ctx) serviceRPC(ctx, "receive-pack")
if h != nil {
serviceRPC(ctx, h, "receive-pack")
}
}
func getServiceType(ctx *context.Context) string {
serviceType := ctx.Req.FormValue("service")
if !strings.HasPrefix(serviceType, "git-") {
return ""
}
return strings.TrimPrefix(serviceType, "git-")
} }
func updateServerInfo(ctx gocontext.Context, dir string) []byte { func updateServerInfo(ctx gocontext.Context, dir string) []byte {
@@ -512,12 +501,12 @@ func packetWrite(str string) []byte {
// GetInfoRefs implements Git dumb HTTP // GetInfoRefs implements Git dumb HTTP
func GetInfoRefs(ctx *context.Context) { func GetInfoRefs(ctx *context.Context) {
h := httpBase(ctx) service := strings.TrimPrefix(ctx.Req.FormValue("service"), "git-")
h := httpBase(ctx, "git-"+service)
if h == nil { if h == nil {
return return
} }
setHeaderNoCache(ctx) setHeaderNoCache(ctx)
service := getServiceType(ctx)
cmd, err := prepareGitCmdWithAllowedService(service) cmd, err := prepareGitCmdWithAllowedService(service)
if err == nil { if err == nil {
if protocol := ctx.Req.Header.Get("Git-Protocol"); protocol != "" && safeGitProtocolHeader.MatchString(protocol) { if protocol := ctx.Req.Header.Get("Git-Protocol"); protocol != "" && safeGitProtocolHeader.MatchString(protocol) {
+26 -5
View File
@@ -32,6 +32,7 @@ import (
"code.gitea.io/gitea/modules/graceful" "code.gitea.io/gitea/modules/graceful"
issue_template "code.gitea.io/gitea/modules/issue/template" issue_template "code.gitea.io/gitea/modules/issue/template"
"code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/optional"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/templates" "code.gitea.io/gitea/modules/templates"
"code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/util"
@@ -1100,11 +1101,9 @@ func MergePullRequest(ctx *context.Context) {
message += "\n\n" + form.MergeMessageField message += "\n\n" + form.MergeMessageField
} }
deleteBranchAfterMerge, err := pull_service.ShouldDeleteBranchAfterMerge(ctx, form.DeleteBranchAfterMerge, ctx.Repo.Repository, pr) // There is always a checkbox on the UI (the DeleteBranchAfterMerge is nil if the checkbox is not checked),
if err != nil { // just use the user's choice, don't use pull_service.ShouldDeleteBranchAfterMerge to decide
ctx.ServerError("ShouldDeleteBranchAfterMerge", err) deleteBranchAfterMerge := optional.FromPtr(form.DeleteBranchAfterMerge).Value()
return
}
if form.MergeWhenChecksSucceed { if form.MergeWhenChecksSucceed {
// delete all scheduled auto merges // delete all scheduled auto merges
@@ -1230,6 +1229,28 @@ func CancelAutoMergePullRequest(ctx *context.Context) {
return return
} }
exist, autoMerge, err := pull_model.GetScheduledMergeByPullID(ctx, issue.PullRequest.ID)
if err != nil {
ctx.ServerError("GetScheduledMergeByPullID", err)
return
}
if !exist {
ctx.NotFound(nil)
return
}
if ctx.Doer.ID != autoMerge.DoerID {
allowed, err := pull_service.IsUserAllowedToMerge(ctx, issue.PullRequest, ctx.Repo.Permission, ctx.Doer)
if err != nil {
ctx.ServerError("IsUserAllowedToMerge", err)
return
}
if !allowed {
ctx.HTTPError(http.StatusForbidden, "user has no permission to cancel the scheduled auto merge")
return
}
}
if err := automerge.RemoveScheduledAutoMerge(ctx, ctx.Doer, issue.PullRequest); err != nil { if err := automerge.RemoveScheduledAutoMerge(ctx, ctx.Doer, issue.PullRequest); err != nil {
if db.IsErrNotExist(err) { if db.IsErrNotExist(err) {
ctx.Flash.Error(ctx.Tr("repo.pulls.auto_merge_not_scheduled")) ctx.Flash.Error(ctx.Tr("repo.pulls.auto_merge_not_scheduled"))
+3
View File
@@ -15,6 +15,7 @@ import (
repo_model "code.gitea.io/gitea/models/repo" repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unit" "code.gitea.io/gitea/models/unit"
"code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/base"
"code.gitea.io/gitea/modules/container"
"code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/optional"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
@@ -128,7 +129,9 @@ func prepareUserNotificationsData(ctx *context.Context) {
ctx.Data["Notifications"] = notifications ctx.Data["Notifications"] = notifications
ctx.Data["Link"] = setting.AppSubURL + "/notifications" ctx.Data["Link"] = setting.AppSubURL + "/notifications"
ctx.Data["SequenceNumber"] = ctx.FormString("sequence-number") ctx.Data["SequenceNumber"] = ctx.FormString("sequence-number")
pager.AddParamFromRequest(ctx.Req) pager.AddParamFromRequest(ctx.Req)
pager.RemoveParam(container.SetOf("div-only", "sequence-number"))
ctx.Data["Page"] = pager ctx.Data["Page"] = pager
} }
@@ -0,0 +1,14 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package security
import (
"testing"
"code.gitea.io/gitea/models/unittest"
)
func TestMain(m *testing.M) {
unittest.MainTest(m)
}
+13 -3
View File
@@ -4,12 +4,14 @@
package security package security
import ( import (
"errors"
"net/http" "net/http"
user_model "code.gitea.io/gitea/models/user" user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/auth/openid" "code.gitea.io/gitea/modules/auth/openid"
"code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/modules/web"
"code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/context"
"code.gitea.io/gitea/services/forms" "code.gitea.io/gitea/services/forms"
@@ -116,7 +118,11 @@ func DeleteOpenID(ctx *context.Context) {
} }
if err := user_model.DeleteUserOpenID(ctx, &user_model.UserOpenID{ID: ctx.FormInt64("id"), UID: ctx.Doer.ID}); err != nil { if err := user_model.DeleteUserOpenID(ctx, &user_model.UserOpenID{ID: ctx.FormInt64("id"), UID: ctx.Doer.ID}); err != nil {
ctx.ServerError("DeleteUserOpenID", err) if errors.Is(err, util.ErrNotExist) {
ctx.HTTPError(http.StatusNotFound)
} else {
ctx.ServerError("DeleteUserOpenID", err)
}
return return
} }
log.Trace("OpenID address deleted: %s", ctx.Doer.Name) log.Trace("OpenID address deleted: %s", ctx.Doer.Name)
@@ -132,8 +138,12 @@ func ToggleOpenIDVisibility(ctx *context.Context) {
return return
} }
if err := user_model.ToggleUserOpenIDVisibility(ctx, ctx.FormInt64("id")); err != nil { if err := user_model.ToggleUserOpenIDVisibility(ctx, ctx.FormInt64("id"), ctx.Doer); err != nil {
ctx.ServerError("ToggleUserOpenIDVisibility", err) if errors.Is(err, util.ErrNotExist) {
ctx.HTTPError(http.StatusNotFound)
} else {
ctx.ServerError("ToggleUserOpenIDVisibility", err)
}
return return
} }
@@ -0,0 +1,36 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package security
import (
"net/http"
"testing"
"code.gitea.io/gitea/models/unittest"
"code.gitea.io/gitea/services/contexttest"
"github.com/stretchr/testify/assert"
)
func TestDeleteOpenIDReturnsNotFoundForOtherUsersAddress(t *testing.T) {
unittest.PrepareTestEnv(t)
ctx, _ := contexttest.MockContext(t, "POST /user/settings/security")
contexttest.LoadUser(t, ctx, 2)
ctx.SetFormString("id", "1")
DeleteOpenID(ctx)
assert.Equal(t, http.StatusNotFound, ctx.Resp.WrittenStatus())
}
func TestToggleOpenIDVisibilityReturnsNotFoundForOtherUsersAddress(t *testing.T) {
unittest.PrepareTestEnv(t)
ctx, _ := contexttest.MockContext(t, "POST /user/settings/security")
contexttest.LoadUser(t, ctx, 2)
ctx.SetFormString("id", "1")
ToggleOpenIDVisibility(ctx)
assert.Equal(t, http.StatusNotFound, ctx.Resp.WrittenStatus())
}
+1 -1
View File
@@ -29,7 +29,7 @@ func GetStopwatches(ctx *context.Context) {
return return
} }
apiSWs, err := convert.ToStopWatches(ctx, sws) apiSWs, err := convert.ToStopWatches(ctx, ctx.Doer, sws)
if err != nil { if err != nil {
ctx.HTTPError(http.StatusInternalServerError, err.Error()) ctx.HTTPError(http.StatusInternalServerError, err.Error())
return return
+2
View File
@@ -8,6 +8,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"path" "path"
"strings"
actions_model "code.gitea.io/gitea/models/actions" actions_model "code.gitea.io/gitea/models/actions"
"code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/db"
@@ -91,6 +92,7 @@ func createCommitStatus(ctx context.Context, job *actions_model.ActionRunJob) er
runName = wfs[0].Name runName = wfs[0].Name
} }
ctxname := fmt.Sprintf("%s / %s (%s)", runName, job.Name, event) ctxname := fmt.Sprintf("%s / %s (%s)", runName, job.Name, event)
ctxname = strings.TrimSpace(ctxname) // git_model.NewCommitStatus also trims spaces
state := toCommitStatus(job.Status) state := toCommitStatus(job.Status)
if statuses, err := git_model.GetLatestCommitStatus(ctx, repo.ID, sha, db.ListOptionsAll); err == nil { if statuses, err := git_model.GetLatestCommitStatus(ctx, repo.ID, sha, db.ListOptionsAll); err == nil {
for _, v := range statuses { for _, v := range statuses {
+10
View File
@@ -8,8 +8,10 @@ import (
"html/template" "html/template"
"net/http" "net/http"
"net/url" "net/url"
"slices"
"strings" "strings"
"code.gitea.io/gitea/modules/container"
"code.gitea.io/gitea/modules/paginator" "code.gitea.io/gitea/modules/paginator"
) )
@@ -49,6 +51,14 @@ func (p *Pagination) AddParamFromRequest(req *http.Request) {
p.AddParamFromQuery(req.URL.Query()) p.AddParamFromQuery(req.URL.Query())
} }
func (p *Pagination) RemoveParam(keys container.Set[string]) {
p.urlParams = slices.DeleteFunc(p.urlParams, func(s string) bool {
k, _, _ := strings.Cut(s, "=")
k, _ = url.QueryUnescape(k)
return keys.Contains(k)
})
}
// GetParams returns the configured URL params // GetParams returns the configured URL params
func (p *Pagination) GetParams() template.URL { func (p *Pagination) GetParams() template.URL {
return template.URL(strings.Join(p.urlParams, "&")) return template.URL(strings.Join(p.urlParams, "&"))
+35
View File
@@ -0,0 +1,35 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package context
import (
"net/url"
"testing"
"code.gitea.io/gitea/modules/container"
"github.com/stretchr/testify/assert"
)
func TestPagination(t *testing.T) {
p := NewPagination(1, 1, 1, 1)
params := url.Values{}
params.Add("k1", "11")
params.Add("k1", "12")
params.Add("k", "a")
params.Add("k", "b")
params.Add("k2", "21")
params.Add("k2", "22")
params.Add("foo", "bar")
p.AddParamFromQuery(params)
v, _ := url.ParseQuery(string(p.GetParams()))
assert.Equal(t, params, v)
p.RemoveParam(container.SetOf("k", "foo"))
params.Del("k")
params.Del("foo")
v, _ = url.ParseQuery(string(p.GetParams()))
assert.Equal(t, params, v)
}
+21 -2
View File
@@ -10,6 +10,7 @@ import (
"strings" "strings"
issues_model "code.gitea.io/gitea/models/issues" issues_model "code.gitea.io/gitea/models/issues"
access_model "code.gitea.io/gitea/models/perm/access"
repo_model "code.gitea.io/gitea/models/repo" repo_model "code.gitea.io/gitea/models/repo"
user_model "code.gitea.io/gitea/models/user" user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/label" "code.gitea.io/gitea/modules/label"
@@ -163,11 +164,12 @@ func ToTrackedTime(ctx context.Context, doer *user_model.User, t *issues_model.T
} }
// ToStopWatches convert Stopwatch list to api.StopWatches // ToStopWatches convert Stopwatch list to api.StopWatches
func ToStopWatches(ctx context.Context, sws []*issues_model.Stopwatch) (api.StopWatches, error) { func ToStopWatches(ctx context.Context, doer *user_model.User, sws []*issues_model.Stopwatch) (api.StopWatches, error) {
result := api.StopWatches(make([]api.StopWatch, 0, len(sws))) result := api.StopWatches(make([]api.StopWatch, 0, len(sws)))
issueCache := make(map[int64]*issues_model.Issue) issueCache := make(map[int64]*issues_model.Issue)
repoCache := make(map[int64]*repo_model.Repository) repoCache := make(map[int64]*repo_model.Repository)
permCache := make(map[int64]access_model.Permission)
var ( var (
issue *issues_model.Issue issue *issues_model.Issue
repo *repo_model.Repository repo *repo_model.Repository
@@ -182,13 +184,30 @@ func ToStopWatches(ctx context.Context, sws []*issues_model.Stopwatch) (api.Stop
if err != nil { if err != nil {
return nil, err return nil, err
} }
issueCache[sw.IssueID] = issue
} }
repo, ok = repoCache[issue.RepoID] repo, ok = repoCache[issue.RepoID]
if !ok { if !ok {
repo, err = repo_model.GetRepositoryByID(ctx, issue.RepoID) repo, err = repo_model.GetRepositoryByID(ctx, issue.RepoID)
if err != nil { if err != nil {
return nil, err log.Error("GetRepositoryByID(%d): %v", issue.RepoID, err)
continue
} }
repoCache[issue.RepoID] = repo
}
// ADD: Check user permissions
perm, ok := permCache[repo.ID]
if !ok {
perm, err = access_model.GetUserRepoPermission(ctx, repo, doer)
if err != nil {
continue
}
permCache[repo.ID] = perm
}
if !perm.CanReadIssuesOrPulls(issue.IsPull) {
continue
} }
result = append(result, api.StopWatch{ result = append(result, api.StopWatch{
+28
View File
@@ -8,9 +8,11 @@ import (
"testing" "testing"
"time" "time"
"code.gitea.io/gitea/models/db"
issues_model "code.gitea.io/gitea/models/issues" issues_model "code.gitea.io/gitea/models/issues"
repo_model "code.gitea.io/gitea/models/repo" repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest" "code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
api "code.gitea.io/gitea/modules/structs" api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/timeutil"
@@ -55,3 +57,29 @@ func TestMilestone_APIFormat(t *testing.T) {
Deadline: milestone.DeadlineUnix.AsTimePtr(), Deadline: milestone.DeadlineUnix.AsTimePtr(),
}, *ToAPIMilestone(milestone)) }, *ToAPIMilestone(milestone))
} }
func TestToStopWatchesRespectsPermissions(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
ctx := t.Context()
publicSW := unittest.AssertExistsAndLoadBean(t, &issues_model.Stopwatch{ID: 1})
privateIssue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{RepoID: 3})
privateSW := &issues_model.Stopwatch{IssueID: privateIssue.ID, UserID: 5}
assert.NoError(t, db.Insert(ctx, privateSW))
assert.NotZero(t, privateSW.ID)
regularUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5})
adminUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
sws := []*issues_model.Stopwatch{publicSW, privateSW}
visible, err := ToStopWatches(ctx, regularUser, sws)
assert.NoError(t, err)
assert.Len(t, visible, 1)
assert.Equal(t, "repo1", visible[0].RepoName)
visibleAdmin, err := ToStopWatches(ctx, adminUser, sws)
assert.NoError(t, err)
assert.Len(t, visibleAdmin, 2)
assert.ElementsMatch(t, []string{"repo1", "repo3"}, []string{visibleAdmin[0].RepoName, visibleAdmin[1].RepoName})
}
+12 -6
View File
@@ -8,8 +8,8 @@ import (
"net/url" "net/url"
activities_model "code.gitea.io/gitea/models/activities" activities_model "code.gitea.io/gitea/models/activities"
"code.gitea.io/gitea/models/perm"
access_model "code.gitea.io/gitea/models/perm/access" access_model "code.gitea.io/gitea/models/perm/access"
"code.gitea.io/gitea/modules/log"
api "code.gitea.io/gitea/modules/structs" api "code.gitea.io/gitea/modules/structs"
) )
@@ -25,11 +25,17 @@ func ToNotificationThread(ctx context.Context, n *activities_model.Notification)
// since user only get notifications when he has access to use minimal access mode // since user only get notifications when he has access to use minimal access mode
if n.Repository != nil { if n.Repository != nil {
result.Repository = ToRepo(ctx, n.Repository, access_model.Permission{AccessMode: perm.AccessModeRead}) perm, err := access_model.GetUserRepoPermission(ctx, n.Repository, n.User)
if err != nil {
// This permission is not correct and we should not be reporting it log.Error("GetUserRepoPermission failed: %v", err)
for repository := result.Repository; repository != nil; repository = repository.Parent { return result
repository.Permissions = nil }
if perm.HasAnyUnitAccessOrPublicAccess() { // if user has been revoked access to repo, do not show repo info
result.Repository = ToRepo(ctx, n.Repository, perm)
// This permission is not correct and we should not be reporting it
for repository := result.Repository; repository != nil; repository = repository.Parent {
repository.Permissions = nil
}
} }
} }
+57
View File
@@ -0,0 +1,57 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package convert
import (
"testing"
activities_model "code.gitea.io/gitea/models/activities"
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/timeutil"
"github.com/stretchr/testify/assert"
)
func TestToNotificationThreadIncludesRepoForAccessibleUser(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
n := newRepoNotification(t, 1, 4)
thread := ToNotificationThread(t.Context(), n)
if assert.NotNil(t, thread.Repository) {
assert.Equal(t, n.Repository.FullName(), thread.Repository.FullName)
assert.Nil(t, thread.Repository.Permissions)
}
}
func TestToNotificationThreadOmitsRepoWhenAccessRevoked(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
n := newRepoNotification(t, 2, 4)
thread := ToNotificationThread(t.Context(), n)
assert.Nil(t, thread.Repository)
}
func newRepoNotification(t *testing.T, repoID, userID int64) *activities_model.Notification {
t.Helper()
ctx := t.Context()
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: repoID})
assert.NoError(t, repo.LoadOwner(ctx))
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: userID})
return &activities_model.Notification{
ID: repoID*1000 + userID,
UserID: user.ID,
RepoID: repo.ID,
Status: activities_model.NotificationStatusUnread,
Source: activities_model.NotificationSourceRepository,
UpdatedUnix: timeutil.TimeStampNow(),
Repository: repo,
User: user,
}
}
+5 -14
View File
@@ -127,20 +127,10 @@ func innerToRepo(ctx context.Context, repo *repo_model.Repository, permissionInR
projectsMode = config.ProjectsMode projectsMode = config.ProjectsMode
} }
hasReleases := false hasCode := repo.UnitEnabled(ctx, unit_model.TypeCode)
if _, err := repo.GetUnit(ctx, unit_model.TypeReleases); err == nil { hasReleases := repo.UnitEnabled(ctx, unit_model.TypeReleases)
hasReleases = true hasPackages := repo.UnitEnabled(ctx, unit_model.TypePackages)
} hasActions := repo.UnitEnabled(ctx, unit_model.TypeActions)
hasPackages := false
if _, err := repo.GetUnit(ctx, unit_model.TypePackages); err == nil {
hasPackages = true
}
hasActions := false
if _, err := repo.GetUnit(ctx, unit_model.TypeActions); err == nil {
hasActions = true
}
if err := repo.LoadOwner(ctx); err != nil { if err := repo.LoadOwner(ctx); err != nil {
return nil return nil
@@ -221,6 +211,7 @@ func innerToRepo(ctx context.Context, repo *repo_model.Repository, permissionInR
Updated: repo.UpdatedUnix.AsTime(), Updated: repo.UpdatedUnix.AsTime(),
ArchivedAt: repo.ArchivedUnix.AsTime(), ArchivedAt: repo.ArchivedUnix.AsTime(),
Permissions: permission, Permissions: permission,
HasCode: hasCode,
HasIssues: hasIssues, HasIssues: hasIssues,
ExternalTracker: externalTracker, ExternalTracker: externalTracker,
InternalTracker: internalTracker, InternalTracker: internalTracker,
+1 -1
View File
@@ -90,7 +90,7 @@ func GetListLockHandler(ctx *context.Context) {
}) })
return return
} }
lock, err := git_model.GetLFSLockByID(ctx, v) lock, err := git_model.GetLFSLockByIDAndRepo(ctx, v, repository.ID)
if err != nil && !git_model.IsErrLFSLockNotExist(err) { if err != nil && !git_model.IsErrLFSLockNotExist(err) {
log.Error("Unable to get lock with ID[%s]: Error: %v", v, err) log.Error("Unable to get lock with ID[%s]: Error: %v", v, err)
} }
+10 -6
View File
@@ -14,7 +14,6 @@ import (
"maps" "maps"
"net/http" "net/http"
"net/url" "net/url"
"path"
"regexp" "regexp"
"strconv" "strconv"
"strings" "strings"
@@ -29,6 +28,7 @@ import (
"code.gitea.io/gitea/models/unit" "code.gitea.io/gitea/models/unit"
user_model "code.gitea.io/gitea/models/user" user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/auth/httpauth" "code.gitea.io/gitea/modules/auth/httpauth"
"code.gitea.io/gitea/modules/httplib"
"code.gitea.io/gitea/modules/json" "code.gitea.io/gitea/modules/json"
lfs_module "code.gitea.io/gitea/modules/lfs" lfs_module "code.gitea.io/gitea/modules/lfs"
"code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/log"
@@ -45,6 +45,7 @@ type requestContext struct {
Repo string Repo string
Authorization string Authorization string
Method string Method string
RepoGitURL string
} }
// Claims is a JWT Token Claims // Claims is a JWT Token Claims
@@ -84,17 +85,17 @@ func GetLFSAuthTokenWithBearer(opts AuthTokenOptions) (string, error) {
// DownloadLink builds a URL to download the object. // DownloadLink builds a URL to download the object.
func (rc *requestContext) DownloadLink(p lfs_module.Pointer) string { func (rc *requestContext) DownloadLink(p lfs_module.Pointer) string {
return setting.AppURL + path.Join(url.PathEscape(rc.User), url.PathEscape(rc.Repo+".git"), "info/lfs/objects", url.PathEscape(p.Oid)) return rc.RepoGitURL + "/info/lfs/objects/" + url.PathEscape(p.Oid)
} }
// UploadLink builds a URL to upload the object. // UploadLink builds a URL to upload the object.
func (rc *requestContext) UploadLink(p lfs_module.Pointer) string { func (rc *requestContext) UploadLink(p lfs_module.Pointer) string {
return setting.AppURL + path.Join(url.PathEscape(rc.User), url.PathEscape(rc.Repo+".git"), "info/lfs/objects", url.PathEscape(p.Oid), strconv.FormatInt(p.Size, 10)) return rc.RepoGitURL + "/info/lfs/objects/" + url.PathEscape(p.Oid) + "/" + strconv.FormatInt(p.Size, 10)
} }
// VerifyLink builds a URL for verifying the object. // VerifyLink builds a URL for verifying the object.
func (rc *requestContext) VerifyLink(p lfs_module.Pointer) string { func (rc *requestContext) VerifyLink(p lfs_module.Pointer) string {
return setting.AppURL + path.Join(url.PathEscape(rc.User), url.PathEscape(rc.Repo+".git"), "info/lfs/verify") return rc.RepoGitURL + "/info/lfs/verify"
} }
// CheckAcceptMediaType checks if the client accepts the LFS media type. // CheckAcceptMediaType checks if the client accepts the LFS media type.
@@ -422,11 +423,14 @@ func decodeJSON(req *http.Request, v any) error {
} }
func getRequestContext(ctx *context.Context) *requestContext { func getRequestContext(ctx *context.Context) *requestContext {
ownerName := ctx.PathParam("username")
repoName := strings.TrimSuffix(ctx.PathParam("reponame"), ".git")
return &requestContext{ return &requestContext{
User: ctx.PathParam("username"), User: ownerName,
Repo: strings.TrimSuffix(ctx.PathParam("reponame"), ".git"), Repo: repoName,
Authorization: ctx.Req.Header.Get("Authorization"), Authorization: ctx.Req.Header.Get("Authorization"),
Method: ctx.Req.Method, Method: ctx.Req.Method,
RepoGitURL: httplib.GuessCurrentAppURL(ctx) + url.PathEscape(ownerName) + "/" + url.PathEscape(repoName+".git"),
} }
} }
+13
View File
@@ -7,9 +7,12 @@ import (
"bytes" "bytes"
"context" "context"
"fmt" "fmt"
"slices"
access_model "code.gitea.io/gitea/models/perm/access"
"code.gitea.io/gitea/models/renderhelper" "code.gitea.io/gitea/models/renderhelper"
repo_model "code.gitea.io/gitea/models/repo" repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unit"
user_model "code.gitea.io/gitea/models/user" user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/markup/markdown" "code.gitea.io/gitea/modules/markup/markdown"
@@ -44,6 +47,16 @@ func MailNewRelease(ctx context.Context, rel *repo_model.Release) {
return 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) langMap := make(map[string][]*user_model.User)
for _, user := range recipients { for _, user := range recipients {
if user.ID != rel.PublisherID { if user.ID != rel.PublisherID {
+71
View File
@@ -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}}", "<p>{{.Release.TagName}}</p>")
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)
}
+1
View File
@@ -320,6 +320,7 @@ func (g *GiteaLocalUploader) CreateReleases(ctx context.Context, releases ...*ba
} }
attach := repo_model.Attachment{ attach := repo_model.Attachment{
UUID: uuid.New().String(), UUID: uuid.New().String(),
RepoID: g.repo.ID,
Name: asset.Name, Name: asset.Name,
DownloadCount: int64(*asset.DownloadCount), DownloadCount: int64(*asset.DownloadCount),
Size: int64(*asset.Size), Size: int64(*asset.Size),
-1
View File
@@ -329,7 +329,6 @@ func (g *GithubDownloaderV3) convertGithubRelease(ctx context.Context, rel *gith
r.Assets = append(r.Assets, &base.ReleaseAsset{ r.Assets = append(r.Assets, &base.ReleaseAsset{
ID: asset.GetID(), ID: asset.GetID(),
Name: asset.GetName(), Name: asset.GetName(),
ContentType: asset.ContentType,
Size: asset.Size, Size: asset.Size,
DownloadCount: asset.DownloadCount, DownloadCount: asset.DownloadCount,
Created: asset.CreatedAt.Time, Created: asset.CreatedAt.Time,
+1 -2
View File
@@ -316,12 +316,11 @@ func (g *GitlabDownloader) convertGitlabRelease(ctx context.Context, rel *gitlab
httpClient := NewMigrationHTTPClient() httpClient := NewMigrationHTTPClient()
for k, asset := range rel.Assets.Links { for _, asset := range rel.Assets.Links {
assetID := asset.ID // Don't optimize this, for closure we need a local variable assetID := asset.ID // Don't optimize this, for closure we need a local variable
r.Assets = append(r.Assets, &base.ReleaseAsset{ r.Assets = append(r.Assets, &base.ReleaseAsset{
ID: int64(asset.ID), ID: int64(asset.ID),
Name: asset.Name, Name: asset.Name,
ContentType: &rel.Assets.Sources[k].Format,
Size: &zero, Size: &zero,
DownloadCount: &zero, DownloadCount: &zero,
DownloadFunc: func() (io.ReadCloser, error) { DownloadFunc: func() (io.ReadCloser, error) {
-1
View File
@@ -171,7 +171,6 @@ func assertReactionsEqual(t *testing.T, expected, actual []*base.Reaction) {
func assertReleaseAssetEqual(t *testing.T, expected, actual *base.ReleaseAsset) { func assertReleaseAssetEqual(t *testing.T, expected, actual *base.ReleaseAsset) {
assert.Equal(t, expected.ID, actual.ID) assert.Equal(t, expected.ID, actual.ID)
assert.Equal(t, expected.Name, actual.Name) assert.Equal(t, expected.Name, actual.Name)
assert.Equal(t, expected.ContentType, actual.ContentType)
assert.Equal(t, expected.Size, actual.Size) assert.Equal(t, expected.Size, actual.Size)
assert.Equal(t, expected.DownloadCount, actual.DownloadCount) assert.Equal(t, expected.DownloadCount, actual.DownloadCount)
assertTimeEqual(t, expected.Created, actual.Created) assertTimeEqual(t, expected.Created, actual.Created)
+31
View File
@@ -8,6 +8,7 @@ import (
"strings" "strings"
"testing" "testing"
issues_model "code.gitea.io/gitea/models/issues"
"code.gitea.io/gitea/models/organization" "code.gitea.io/gitea/models/organization"
"code.gitea.io/gitea/models/perm" "code.gitea.io/gitea/models/perm"
access_model "code.gitea.io/gitea/models/perm/access" access_model "code.gitea.io/gitea/models/perm/access"
@@ -62,6 +63,36 @@ func TestTeam_RemoveMember(t *testing.T) {
assert.True(t, organization.IsErrLastOrgOwner(err)) assert.True(t, organization.IsErrLastOrgOwner(err))
} }
func TestRemoveTeamMemberRemovesSubscriptionsAndStopwatches(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
ctx := t.Context()
team := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: 2})
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4})
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 3})
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{RepoID: repo.ID})
assert.NoError(t, repo_model.WatchRepo(ctx, user, repo, true))
assert.NoError(t, issues_model.CreateOrUpdateIssueWatch(ctx, user.ID, issue.ID, true))
ok, err := issues_model.CreateIssueStopwatch(ctx, user, issue)
assert.NoError(t, err)
assert.True(t, ok)
assert.NoError(t, RemoveTeamMember(ctx, team, user))
watch, err := repo_model.GetWatch(ctx, user.ID, repo.ID)
assert.NoError(t, err)
assert.False(t, repo_model.IsWatchMode(watch.Mode))
_, exists, err := issues_model.GetIssueWatch(ctx, user.ID, issue.ID)
assert.NoError(t, err)
assert.False(t, exists)
hasStopwatch, _, _, err := issues_model.HasUserStopwatch(ctx, user.ID)
assert.NoError(t, err)
assert.False(t, hasStopwatch)
}
func TestNewTeam(t *testing.T) { func TestNewTeam(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase()) assert.NoError(t, unittest.PrepareTestDatabase())
+1 -1
View File
@@ -1078,7 +1078,7 @@ func GetPullCommits(ctx context.Context, baseGitRepo *git.Repository, doer *user
if pull.HasMerged { if pull.HasMerged {
baseBranch = pull.MergeBase baseBranch = pull.MergeBase
} }
prInfo, err := GetCompareInfo(ctx, pull.BaseRepo, pull.BaseRepo, baseGitRepo, baseBranch, pull.GetGitHeadRefName(), true, false) prInfo, err := GetCompareInfo(ctx, pull.BaseRepo, pull.BaseRepo, baseGitRepo, baseBranch, pull.GetGitHeadRefName(), false, false)
if err != nil { if err != nil {
return nil, "", err return nil, "", err
} }
+5
View File
@@ -120,6 +120,11 @@ func ReconsiderWatches(ctx context.Context, repo *repo_model.Repository, user *u
return err return err
} }
// Remove all stopwatches a user has running in the repository
if err := issues_model.RemoveStopwatchesByRepoID(ctx, user.ID, repo.ID); err != nil {
return err
}
// Remove all IssueWatches a user has subscribed to in the repository // Remove all IssueWatches a user has subscribed to in the repository
return issues_model.RemoveIssueWatchersByRepoID(ctx, user.ID, repo.ID) return issues_model.RemoveIssueWatchersByRepoID(ctx, user.ID, repo.ID)
} }
+52 -2
View File
@@ -6,7 +6,10 @@ package repository
import ( import (
"testing" "testing"
"code.gitea.io/gitea/models/db"
issues_model "code.gitea.io/gitea/models/issues"
"code.gitea.io/gitea/models/perm" "code.gitea.io/gitea/models/perm"
access_model "code.gitea.io/gitea/models/perm/access"
repo_model "code.gitea.io/gitea/models/repo" repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest" "code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user" user_model "code.gitea.io/gitea/models/user"
@@ -32,8 +35,8 @@ func TestRepository_AddCollaborator(t *testing.T) {
func TestRepository_DeleteCollaboration(t *testing.T) { func TestRepository_DeleteCollaboration(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase()) assert.NoError(t, unittest.PrepareTestDatabase())
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4}) user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 15})
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4}) repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 22})
assert.NoError(t, repo.LoadOwner(t.Context())) assert.NoError(t, repo.LoadOwner(t.Context()))
assert.NoError(t, DeleteCollaboration(t.Context(), repo, user)) assert.NoError(t, DeleteCollaboration(t.Context(), repo, user))
@@ -44,3 +47,50 @@ func TestRepository_DeleteCollaboration(t *testing.T) {
unittest.CheckConsistencyFor(t, &repo_model.Repository{ID: repo.ID}) unittest.CheckConsistencyFor(t, &repo_model.Repository{ID: repo.ID})
} }
func TestRepository_DeleteCollaborationRemovesSubscriptionsAndStopwatches(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
ctx := t.Context()
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 15})
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 22})
assert.NoError(t, repo.LoadOwner(ctx))
assert.NoError(t, repo_model.WatchRepo(ctx, user, repo, true))
hasAccess, err := access_model.HasAnyUnitAccess(ctx, user.ID, repo)
assert.NoError(t, err)
assert.True(t, hasAccess)
issueCount, err := db.GetEngine(ctx).Where("repo_id=?", repo.ID).Count(new(issues_model.Issue))
assert.NoError(t, err)
tempIssue := &issues_model.Issue{
RepoID: repo.ID,
Index: issueCount + 1,
PosterID: repo.OwnerID,
Title: "temp issue",
Content: "temp",
}
assert.NoError(t, db.Insert(ctx, tempIssue))
assert.NoError(t, issues_model.CreateOrUpdateIssueWatch(ctx, user.ID, tempIssue.ID, true))
ok, err := issues_model.CreateIssueStopwatch(ctx, user, tempIssue)
assert.NoError(t, err)
assert.True(t, ok)
assert.NoError(t, DeleteCollaboration(ctx, repo, user))
hasAccess, err = access_model.HasAnyUnitAccess(ctx, user.ID, repo)
assert.NoError(t, err)
assert.False(t, hasAccess)
watch, err := repo_model.GetWatch(ctx, user.ID, repo.ID)
assert.NoError(t, err)
assert.False(t, repo_model.IsWatchMode(watch.Mode))
_, exists, err := issues_model.GetIssueWatch(ctx, user.ID, tempIssue.ID)
assert.NoError(t, err)
assert.False(t, exists)
hasStopwatch, _, _, err := issues_model.HasUserStopwatch(ctx, user.ID)
assert.NoError(t, err)
assert.False(t, hasStopwatch)
}
+14 -10
View File
@@ -197,6 +197,10 @@ func MakeRepoPrivate(ctx context.Context, repo *repo_model.Repository) (err erro
return err return err
} }
if err = repo_model.ClearRepoWatches(ctx, repo.ID); err != nil {
return err
}
// Create/Remove git-daemon-export-ok for git-daemon... // Create/Remove git-daemon-export-ok for git-daemon...
if err := CheckDaemonExportOK(ctx, repo); err != nil { if err := CheckDaemonExportOK(ctx, repo); err != nil {
return err return err
@@ -220,28 +224,28 @@ func MakeRepoPrivate(ctx context.Context, repo *repo_model.Repository) (err erro
}) })
} }
// LinkedRepository returns the linked repo if any // GetAttachmentLinkedTypeAndRepoID returns the linked type and repository id of attachment if any
func LinkedRepository(ctx context.Context, a *repo_model.Attachment) (*repo_model.Repository, unit.Type, error) { func GetAttachmentLinkedTypeAndRepoID(ctx context.Context, a *repo_model.Attachment) (unit.Type, int64, error) {
if a.IssueID != 0 { if a.IssueID != 0 {
iss, err := issues_model.GetIssueByID(ctx, a.IssueID) iss, err := issues_model.GetIssueByID(ctx, a.IssueID)
if err != nil { if err != nil {
return nil, unit.TypeIssues, err return unit.TypeIssues, 0, err
} }
repo, err := repo_model.GetRepositoryByID(ctx, iss.RepoID)
unitType := unit.TypeIssues unitType := unit.TypeIssues
if iss.IsPull { if iss.IsPull {
unitType = unit.TypePullRequests unitType = unit.TypePullRequests
} }
return repo, unitType, err return unitType, iss.RepoID, nil
} else if a.ReleaseID != 0 { }
if a.ReleaseID != 0 {
rel, err := repo_model.GetReleaseByID(ctx, a.ReleaseID) rel, err := repo_model.GetReleaseByID(ctx, a.ReleaseID)
if err != nil { if err != nil {
return nil, unit.TypeReleases, err return unit.TypeReleases, 0, err
} }
repo, err := repo_model.GetRepositoryByID(ctx, rel.RepoID) return unit.TypeReleases, rel.RepoID, nil
return repo, unit.TypeReleases, err
} }
return nil, -1, nil return unit.TypeInvalid, 0, nil
} }
// CheckDaemonExportOK creates/removes git-daemon-export-ok for git-daemon... // CheckDaemonExportOK creates/removes git-daemon-export-ok for git-daemon...
+30 -10
View File
@@ -13,31 +13,30 @@ import (
"code.gitea.io/gitea/models/unittest" "code.gitea.io/gitea/models/unittest"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
) )
func TestLinkedRepository(t *testing.T) { func TestAttachLinkedTypeAndRepoID(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase()) assert.NoError(t, unittest.PrepareTestDatabase())
testCases := []struct { testCases := []struct {
name string name string
attachID int64 attachID int64
expectedRepo *repo_model.Repository
expectedUnitType unit.Type expectedUnitType unit.Type
expectedRepoID int64
}{ }{
{"LinkedIssue", 1, &repo_model.Repository{ID: 1}, unit.TypeIssues}, {"LinkedIssue", 1, unit.TypeIssues, 1},
{"LinkedComment", 3, &repo_model.Repository{ID: 1}, unit.TypePullRequests}, {"LinkedComment", 3, unit.TypePullRequests, 1},
{"LinkedRelease", 9, &repo_model.Repository{ID: 1}, unit.TypeReleases}, {"LinkedRelease", 9, unit.TypeReleases, 1},
{"Notlinked", 10, nil, -1}, {"Notlinked", 10, unit.TypeInvalid, 0},
} }
for _, tc := range testCases { for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
attach, err := repo_model.GetAttachmentByID(t.Context(), tc.attachID) attach, err := repo_model.GetAttachmentByID(t.Context(), tc.attachID)
assert.NoError(t, err) assert.NoError(t, err)
repo, unitType, err := LinkedRepository(t.Context(), attach) unitType, repoID, err := GetAttachmentLinkedTypeAndRepoID(t.Context(), attach)
assert.NoError(t, err) assert.NoError(t, err)
if tc.expectedRepo != nil {
assert.Equal(t, tc.expectedRepo.ID, repo.ID)
}
assert.Equal(t, tc.expectedUnitType, unitType) assert.Equal(t, tc.expectedUnitType, unitType)
assert.Equal(t, tc.expectedRepoID, repoID)
}) })
} }
} }
@@ -70,3 +69,24 @@ func TestRepository_HasWiki(t *testing.T) {
repo2 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2}) repo2 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2})
assert.False(t, HasWiki(t.Context(), repo2)) 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)
}
+18
View File
@@ -241,6 +241,11 @@ func DeleteUser(ctx context.Context, u *user_model.User, purge bool) error {
if err := deleteUser(ctx, u, purge); err != nil { if err := deleteUser(ctx, u, purge); err != nil {
return fmt.Errorf("DeleteUser: %w", err) return fmt.Errorf("DeleteUser: %w", err)
} }
// Finally delete any unlinked attachments, this will also delete the attached files
if err := deleteUserUnlinkedAttachments(ctx, u); err != nil {
return fmt.Errorf("deleteUserUnlinkedAttachments: %w", err)
}
return nil return nil
}); err != nil { }); err != nil {
return err return err
@@ -271,6 +276,19 @@ func DeleteUser(ctx context.Context, u *user_model.User, purge bool) error {
return nil return nil
} }
func deleteUserUnlinkedAttachments(ctx context.Context, u *user_model.User) error {
attachments, err := repo_model.GetUnlinkedAttachmentsByUserID(ctx, u.ID)
if err != nil {
return fmt.Errorf("GetUnlinkedAttachmentsByUserID: %w", err)
}
for _, attach := range attachments {
if err := repo_model.DeleteAttachment(ctx, attach, true); err != nil {
return fmt.Errorf("DeleteAttachment ID[%d]: %w", attach.ID, err)
}
}
return nil
}
// DeleteInactiveUsers deletes all inactive users and their email addresses. // DeleteInactiveUsers deletes all inactive users and their email addresses.
func DeleteInactiveUsers(ctx context.Context, olderThan time.Duration) error { func DeleteInactiveUsers(ctx context.Context, olderThan time.Duration) error {
inactiveUsers, err := user_model.GetInactiveUsers(ctx, olderThan) inactiveUsers, err := user_model.GetInactiveUsers(ctx, olderThan)
+18
View File
@@ -62,6 +62,24 @@ func TestDeleteUser(t *testing.T) {
assert.Error(t, DeleteUser(t.Context(), org, false)) assert.Error(t, DeleteUser(t.Context(), org, false))
} }
func TestDeleteUserUnlinkedAttachments(t *testing.T) {
t.Run("DeleteExisting", func(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 8})
unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: 10})
assert.NoError(t, deleteUserUnlinkedAttachments(t.Context(), user))
unittest.AssertNotExistsBean(t, &repo_model.Attachment{ID: 10})
})
t.Run("NoUnlinkedAttachments", func(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
assert.NoError(t, deleteUserUnlinkedAttachments(t.Context(), user))
})
}
func TestPurgeUser(t *testing.T) { func TestPurgeUser(t *testing.T) {
test := func(userID int64) { test := func(userID int64) {
assert.NoError(t, unittest.PrepareTestDatabase()) assert.NoError(t, unittest.PrepareTestDatabase())
+50 -1
View File
@@ -34,6 +34,14 @@ func testGeneratePngBytes() []byte {
} }
func testCreateIssueAttachment(t *testing.T, session *TestSession, csrf, repoURL, filename string, content []byte, expectedStatus int) string { func testCreateIssueAttachment(t *testing.T, session *TestSession, csrf, repoURL, filename string, content []byte, expectedStatus int) string {
return testCreateAttachment(t, session, csrf, repoURL, "issues", filename, content, expectedStatus)
}
func testCreateReleaseAttachment(t *testing.T, session *TestSession, csrf, repoURL, filename string, content []byte, expectedStatus int) string {
return testCreateAttachment(t, session, csrf, repoURL, "releases", filename, content, expectedStatus)
}
func testCreateAttachment(t *testing.T, session *TestSession, csrf, repoURL, issueOrRelease, filename string, content []byte, expectedStatus int) string {
body := &bytes.Buffer{} body := &bytes.Buffer{}
// Setup multi-part // Setup multi-part
@@ -45,7 +53,7 @@ func testCreateIssueAttachment(t *testing.T, session *TestSession, csrf, repoURL
err = writer.Close() err = writer.Close()
assert.NoError(t, err) assert.NoError(t, err)
req := NewRequestWithBody(t, "POST", repoURL+"/issues/attachments", body) req := NewRequestWithBody(t, "POST", repoURL+"/"+issueOrRelease+"/attachments", body)
req.Header.Add("X-Csrf-Token", csrf) req.Header.Add("X-Csrf-Token", csrf)
req.Header.Add("Content-Type", writer.FormDataContentType()) req.Header.Add("Content-Type", writer.FormDataContentType())
resp := session.MakeRequest(t, req, expectedStatus) resp := session.MakeRequest(t, req, expectedStatus)
@@ -58,12 +66,25 @@ func testCreateIssueAttachment(t *testing.T, session *TestSession, csrf, repoURL
return obj["uuid"] return obj["uuid"]
} }
func testDeleteIssueAttachment(t *testing.T, session *TestSession, csrf, repoURL, uuid string, expectedStatus int) {
req := NewRequestWithValues(t, "POST", repoURL+"/issues/attachments/remove", map[string]string{"file": uuid})
req.Header.Add("X-Csrf-Token", csrf)
session.MakeRequest(t, req, expectedStatus)
}
func testDeleteReleaseAttachment(t *testing.T, session *TestSession, csrf, repoURL, uuid string, expectedStatus int) {
req := NewRequestWithValues(t, "POST", repoURL+"/releases/attachments/remove", map[string]string{"file": uuid})
req.Header.Add("X-Csrf-Token", csrf)
session.MakeRequest(t, req, expectedStatus)
}
func TestAttachments(t *testing.T) { func TestAttachments(t *testing.T) {
defer tests.PrepareTestEnv(t)() defer tests.PrepareTestEnv(t)()
t.Run("CreateAnonymousAttachment", testCreateAnonymousAttachment) t.Run("CreateAnonymousAttachment", testCreateAnonymousAttachment)
t.Run("CreateUser2IssueAttachment", testCreateUser2IssueAttachment) t.Run("CreateUser2IssueAttachment", testCreateUser2IssueAttachment)
t.Run("UploadAttachmentDeleteTemp", testUploadAttachmentDeleteTemp) t.Run("UploadAttachmentDeleteTemp", testUploadAttachmentDeleteTemp)
t.Run("GetAttachment", testGetAttachment) t.Run("GetAttachment", testGetAttachment)
t.Run("DeleteAttachmentPermissions", testDeleteAttachmentPermissions)
} }
func testUploadAttachmentDeleteTemp(t *testing.T) { func testUploadAttachmentDeleteTemp(t *testing.T) {
@@ -159,3 +180,31 @@ func testGetAttachment(t *testing.T) {
}) })
} }
} }
func testDeleteAttachmentPermissions(t *testing.T) {
const repoURL = "user2/repo1"
ownerSession := loginUser(t, "user2")
readonlySession := loginUser(t, "user5")
ownerCSRF := GetUserCSRFToken(t, ownerSession)
readonlyCSRF := GetUserCSRFToken(t, readonlySession)
issueFromOwner := testCreateIssueAttachment(t, ownerSession, ownerCSRF, repoURL, "owner-issue.png", testGeneratePngBytes(), http.StatusOK)
testDeleteIssueAttachment(t, readonlySession, readonlyCSRF, repoURL, issueFromOwner, http.StatusForbidden)
issueFromReader := testCreateIssueAttachment(t, readonlySession, readonlyCSRF, repoURL, "reader-issue.png", testGeneratePngBytes(), http.StatusOK)
testDeleteIssueAttachment(t, ownerSession, ownerCSRF, repoURL, issueFromReader, http.StatusOK)
testCreateReleaseAttachment(t, readonlySession, readonlyCSRF, repoURL, "reader-release.png", testGeneratePngBytes(), http.StatusNotFound)
crossRepoUUID := testCreateIssueAttachment(t, ownerSession, ownerCSRF, repoURL, "cross-repo.png", testGeneratePngBytes(), http.StatusOK)
testDeleteIssueAttachment(t, ownerSession, ownerCSRF, "user2/repo2", crossRepoUUID, http.StatusBadRequest)
testDeleteIssueAttachment(t, ownerSession, ownerCSRF, repoURL, crossRepoUUID, http.StatusOK)
releaseUUID := testCreateReleaseAttachment(t, ownerSession, ownerCSRF, repoURL, "reader-release.png", testGeneratePngBytes(), http.StatusOK)
testDeleteReleaseAttachment(t, ownerSession, ownerCSRF, repoURL, releaseUUID, http.StatusOK)
// test deleting release attachment from another repo
testDeleteReleaseAttachment(t, ownerSession, ownerCSRF, "user2/repo2", crossRepoUUID, http.StatusBadRequest)
}
+30 -13
View File
@@ -707,6 +707,11 @@ func doAutoPRMerge(baseCtx *APITestContext, dstPath string) func(t *testing.T) {
defer tests.PrintCurrentTest(t)() defer tests.PrintCurrentTest(t)()
ctx := NewAPITestContext(t, baseCtx.Username, baseCtx.Reponame, auth_model.AccessTokenScopeWriteRepository) ctx := NewAPITestContext(t, baseCtx.Username, baseCtx.Reponame, auth_model.AccessTokenScopeWriteRepository)
collaboratorCtx := NewAPITestContext(t, "user5", baseCtx.Reponame, auth_model.AccessTokenScopeWriteRepository)
readOnlyCtx := NewAPITestContext(t, "user4", baseCtx.Reponame, auth_model.AccessTokenScopeWriteRepository)
t.Run("AddAutoMergeCollaborator", doAPIAddCollaborator(*baseCtx, collaboratorCtx.Username, perm.AccessModeWrite))
t.Run("AddReadOnlyAutoMergeCollaborator", doAPIAddCollaborator(*baseCtx, readOnlyCtx.Username, perm.AccessModeRead))
// automerge will merge immediately if the PR is mergeable and there is no "status check" because no status check also means "all checks passed" // automerge will merge immediately if the PR is mergeable and there is no "status check" because no status check also means "all checks passed"
// so we must set up a status check to test the auto merge feature // so we must set up a status check to test the auto merge feature
@@ -738,17 +743,8 @@ func doAutoPRMerge(baseCtx *APITestContext, dstPath string) func(t *testing.T) {
commitID := path.Base(commitURL) commitID := path.Base(commitURL)
addCommitStatus := func(status commitstatus.CommitStatusState) func(*testing.T) {
return doAPICreateCommitStatus(ctx, commitID, api.CreateStatusOption{
State: status,
TargetURL: "http://test.ci/",
Description: "",
Context: "testci",
})
}
// Call API to add Pending status for commit // Call API to add Pending status for commit
t.Run("CreateStatus", addCommitStatus(commitstatus.CommitStatusPending)) t.Run("CreateStatus", doAPICreateCommitStatusTest(ctx, commitID, commitstatus.CommitStatusPending, "testci"))
// Cancel not existing auto merge // Cancel not existing auto merge
ctx.ExpectedCode = http.StatusNotFound ctx.ExpectedCode = http.StatusNotFound
@@ -762,10 +758,32 @@ func doAutoPRMerge(baseCtx *APITestContext, dstPath string) func(t *testing.T) {
ctx.ExpectedCode = http.StatusConflict ctx.ExpectedCode = http.StatusConflict
t.Run("AutoMergePRTwice", doAPIAutoMergePullRequest(ctx, baseCtx.Username, baseCtx.Reponame, pr.Index)) t.Run("AutoMergePRTwice", doAPIAutoMergePullRequest(ctx, baseCtx.Username, baseCtx.Reponame, pr.Index))
// Read-only collaborator still cannot cancel
readOnlyCtx.ExpectedCode = http.StatusForbidden
t.Run("CancelAutoMergePRByReadOnlyCollaboratorForbidden", doAPICancelAutoMergePullRequest(readOnlyCtx, baseCtx.Username, baseCtx.Reponame, pr.Index))
readOnlyCtx.ExpectedCode = 0
// Collaborators with merge permissions can cancel a schedule made by someone else
collaboratorCtx.ExpectedCode = http.StatusNoContent
t.Run("CancelAutoMergePRByCollaborator", doAPICancelAutoMergePullRequest(collaboratorCtx, baseCtx.Username, baseCtx.Reponame, pr.Index))
collaboratorCtx.ExpectedCode = 0
// Re-add auto merge request so the repo owner can cancel it as well
ctx.ExpectedCode = http.StatusCreated
t.Run("AutoMergePRAfterCollaboratorCancel", doAPIAutoMergePullRequest(ctx, baseCtx.Username, baseCtx.Reponame, pr.Index))
// Cancel auto merge request // Cancel auto merge request
ctx.ExpectedCode = http.StatusNoContent ctx.ExpectedCode = http.StatusNoContent
t.Run("CancelAutoMergePR", doAPICancelAutoMergePullRequest(ctx, baseCtx.Username, baseCtx.Reponame, pr.Index)) t.Run("CancelAutoMergePR", doAPICancelAutoMergePullRequest(ctx, baseCtx.Username, baseCtx.Reponame, pr.Index))
// Collaborator can schedule but admins should still be able to cancel their schedule
collaboratorCtx.ExpectedCode = http.StatusCreated
t.Run("AutoMergePRByCollaborator", doAPIAutoMergePullRequest(collaboratorCtx, baseCtx.Username, baseCtx.Reponame, pr.Index))
collaboratorCtx.ExpectedCode = 0
ctx.ExpectedCode = http.StatusNoContent
t.Run("CancelAutoMergePRByAdmin", doAPICancelAutoMergePullRequest(ctx, baseCtx.Username, baseCtx.Reponame, pr.Index))
// Add auto merge request // Add auto merge request
ctx.ExpectedCode = http.StatusCreated ctx.ExpectedCode = http.StatusCreated
t.Run("AutoMergePR", doAPIAutoMergePullRequest(ctx, baseCtx.Username, baseCtx.Reponame, pr.Index)) t.Run("AutoMergePR", doAPIAutoMergePullRequest(ctx, baseCtx.Username, baseCtx.Reponame, pr.Index))
@@ -777,7 +795,7 @@ func doAutoPRMerge(baseCtx *APITestContext, dstPath string) func(t *testing.T) {
assert.False(t, pr.HasMerged) assert.False(t, pr.HasMerged)
// Call API to add Failure status for commit // Call API to add Failure status for commit
t.Run("CreateStatus", addCommitStatus(commitstatus.CommitStatusFailure)) t.Run("CreateStatus", doAPICreateCommitStatusTest(ctx, commitID, commitstatus.CommitStatusFailure, "testci"))
// Check pr status // Check pr status
pr, err = doAPIGetPullRequest(ctx, baseCtx.Username, baseCtx.Reponame, pr.Index)(t) pr, err = doAPIGetPullRequest(ctx, baseCtx.Username, baseCtx.Reponame, pr.Index)(t)
@@ -785,8 +803,7 @@ func doAutoPRMerge(baseCtx *APITestContext, dstPath string) func(t *testing.T) {
assert.False(t, pr.HasMerged) assert.False(t, pr.HasMerged)
// Call API to add Success status for commit // Call API to add Success status for commit
t.Run("CreateStatus", addCommitStatus(commitstatus.CommitStatusSuccess)) t.Run("CreateStatus", doAPICreateCommitStatusTest(ctx, commitID, commitstatus.CommitStatusSuccess, "testci"))
// wait to let gitea merge stuff // wait to let gitea merge stuff
time.Sleep(time.Second) time.Sleep(time.Second)
+1 -1
View File
@@ -338,7 +338,7 @@ func NewRequestWithBody(t testing.TB, method, urlStr string, body io.Reader) *Re
return &RequestWrapper{req} return &RequestWrapper{req}
} }
const NoExpectedStatus = -1 const NoExpectedStatus = 0
func MakeRequest(t testing.TB, rw *RequestWrapper, expectedStatus int) *httptest.ResponseRecorder { func MakeRequest(t testing.TB, rw *RequestWrapper, expectedStatus int) *httptest.ResponseRecorder {
t.Helper() t.Helper()
+9 -18
View File
@@ -77,12 +77,7 @@ func TestPullCreate_CommitStatus(t *testing.T) {
// Update commit status, and check if icon is updated as well // Update commit status, and check if icon is updated as well
for _, status := range statusList { for _, status := range statusList {
// Call API to add status for commit // Call API to add status for commit
t.Run("CreateStatus", doAPICreateCommitStatus(testCtx, commitID, api.CreateStatusOption{ t.Run("CreateStatus", doAPICreateCommitStatusTest(testCtx, commitID, status, "testci"))
State: status,
TargetURL: "http://test.ci/",
Description: "",
Context: "testci",
}))
req = NewRequest(t, "GET", "/user1/repo1/pulls/1/commits") req = NewRequest(t, "GET", "/user1/repo1/pulls/1/commits")
resp = session.MakeRequest(t, req, http.StatusOK) resp = session.MakeRequest(t, req, http.StatusOK)
@@ -104,19 +99,15 @@ func TestPullCreate_CommitStatus(t *testing.T) {
}) })
} }
func doAPICreateCommitStatus(ctx APITestContext, commitID string, data api.CreateStatusOption) func(*testing.T) { func doAPICreateCommitStatusTest(ctx APITestContext, ref string, state commitstatus.CommitStatusState, statusContext string) func(*testing.T) {
return func(t *testing.T) { return func(t *testing.T) {
req := NewRequestWithJSON( link := fmt.Sprintf("/api/v1/repos/%s/%s/statuses/%s", ctx.Username, ctx.Reponame, url.PathEscape(ref))
t, req := NewRequestWithJSON(t, http.MethodPost, link, api.CreateStatusOption{
http.MethodPost, State: state,
fmt.Sprintf("/api/v1/repos/%s/%s/statuses/%s", ctx.Username, ctx.Reponame, commitID), TargetURL: "http://test.ci/",
data, Context: statusContext,
).AddTokenAuth(ctx.Token) }).AddTokenAuth(ctx.Token)
if ctx.ExpectedCode != 0 { ctx.Session.MakeRequest(t, req, ctx.ExpectedCode)
ctx.Session.MakeRequest(t, req, ctx.ExpectedCode)
return
}
ctx.Session.MakeRequest(t, req, http.StatusCreated)
} }
} }
+72 -99
View File
@@ -6,12 +6,13 @@ package integration
import ( import (
"fmt" "fmt"
"net/http" "net/http"
"net/http/httptest"
"path" "path"
"sync" "sync"
"testing" "testing"
auth_model "code.gitea.io/gitea/models/auth" auth_model "code.gitea.io/gitea/models/auth"
"code.gitea.io/gitea/models/db"
git_model "code.gitea.io/gitea/models/git"
"code.gitea.io/gitea/modules/commitstatus" "code.gitea.io/gitea/modules/commitstatus"
"code.gitea.io/gitea/modules/json" "code.gitea.io/gitea/modules/json"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
@@ -20,6 +21,7 @@ import (
"github.com/PuerkitoBio/goquery" "github.com/PuerkitoBio/goquery"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
) )
func TestRepoCommits(t *testing.T) { func TestRepoCommits(t *testing.T) {
@@ -79,98 +81,87 @@ func TestRepoCommits(t *testing.T) {
}) })
} }
func doTestRepoCommitWithStatus(t *testing.T, state string, classes ...string) { func TestRepoCommitsWithStatus(t *testing.T) {
defer tests.PrepareTestEnv(t)() defer tests.PrepareTestEnv(t)()
session := loginUser(t, "user2") session := loginUser(t, "user2")
// Request repository commits page
req := NewRequest(t, "GET", "/user2/repo1/commits/branch/master")
resp := session.MakeRequest(t, req, http.StatusOK)
doc := NewHTMLParser(t, resp.Body)
// Get first commit URL
commitURL, exists := doc.doc.Find("#commits-table .commit-id-short").Attr("href")
assert.True(t, exists)
assert.NotEmpty(t, commitURL)
// Call API to add status for commit
ctx := NewAPITestContext(t, "user2", "repo1", auth_model.AccessTokenScopeWriteRepository) ctx := NewAPITestContext(t, "user2", "repo1", auth_model.AccessTokenScopeWriteRepository)
t.Run("CreateStatus", doAPICreateCommitStatus(ctx, path.Base(commitURL), api.CreateStatusOption{
State: commitstatus.CommitStatusState(state),
TargetURL: "http://test.ci/",
Description: "",
Context: "testci",
}))
req = NewRequest(t, "GET", "/user2/repo1/commits/branch/master") requestCommitStatuses := func(t *testing.T, linkList, linkCombined string) (statuses []*api.CommitStatus, status api.CombinedStatus) {
resp = session.MakeRequest(t, req, http.StatusOK) assert.NoError(t, json.Unmarshal(session.MakeRequest(t, NewRequest(t, "GET", linkList), http.StatusOK).Body.Bytes(), &statuses))
assert.NoError(t, json.Unmarshal(session.MakeRequest(t, NewRequest(t, "GET", linkCombined), http.StatusOK).Body.Bytes(), &status))
doc = NewHTMLParser(t, resp.Body) return statuses, status
// Check if commit status is displayed in message column (.tippy-target to ignore the tippy trigger)
sel := doc.doc.Find("#commits-table .message .tippy-target .commit-status")
assert.Equal(t, 1, sel.Length())
for _, class := range classes {
assert.True(t, sel.HasClass(class))
} }
// By SHA testRefMaster := func(t *testing.T, state commitstatus.CommitStatusState, classes ...string) {
req = NewRequest(t, "GET", "/api/v1/repos/user2/repo1/commits/"+path.Base(commitURL)+"/statuses") _ = db.TruncateBeans(t.Context(), &git_model.CommitStatus{})
reqOne := NewRequest(t, "GET", "/api/v1/repos/user2/repo1/commits/"+path.Base(commitURL)+"/status")
testRepoCommitsWithStatus(t, session.MakeRequest(t, req, http.StatusOK), session.MakeRequest(t, reqOne, http.StatusOK), state)
// By short SHA // Request repository commits page
req = NewRequest(t, "GET", "/api/v1/repos/user2/repo1/commits/"+path.Base(commitURL)[:10]+"/statuses") req := NewRequest(t, "GET", "/user2/repo1/commits/branch/master")
reqOne = NewRequest(t, "GET", "/api/v1/repos/user2/repo1/commits/"+path.Base(commitURL)[:10]+"/status") resp := session.MakeRequest(t, req, http.StatusOK)
testRepoCommitsWithStatus(t, session.MakeRequest(t, req, http.StatusOK), session.MakeRequest(t, reqOne, http.StatusOK), state)
// By Ref doc := NewHTMLParser(t, resp.Body)
req = NewRequest(t, "GET", "/api/v1/repos/user2/repo1/commits/master/statuses") // Get first commit URL
reqOne = NewRequest(t, "GET", "/api/v1/repos/user2/repo1/commits/master/status") commitURL, _ := doc.doc.Find("#commits-table .commit-id-short").Attr("href")
testRepoCommitsWithStatus(t, session.MakeRequest(t, req, http.StatusOK), session.MakeRequest(t, reqOne, http.StatusOK), state) require.NotEmpty(t, commitURL)
req = NewRequest(t, "GET", "/api/v1/repos/user2/repo1/commits/v1.1/statuses") commitID := path.Base(commitURL)
reqOne = NewRequest(t, "GET", "/api/v1/repos/user2/repo1/commits/v1.1/status")
testRepoCommitsWithStatus(t, session.MakeRequest(t, req, http.StatusOK), session.MakeRequest(t, reqOne, http.StatusOK), state)
}
func testRepoCommitsWithStatus(t *testing.T, resp, respOne *httptest.ResponseRecorder, state string) { // Call API to add status for commit
var statuses []*api.CommitStatus doAPICreateCommitStatusTest(ctx, path.Base(commitURL), state, "testci")(t)
assert.NoError(t, json.Unmarshal(resp.Body.Bytes(), &statuses))
var status api.CombinedStatus
assert.NoError(t, json.Unmarshal(respOne.Body.Bytes(), &status))
assert.NotNil(t, status)
if assert.Len(t, statuses, 1) { req = NewRequest(t, "GET", "/user2/repo1/commits/branch/master")
assert.Equal(t, commitstatus.CommitStatusState(state), statuses[0].State) resp = session.MakeRequest(t, req, http.StatusOK)
assert.Equal(t, setting.AppURL+"api/v1/repos/user2/repo1/statuses/65f1bf27bc3bf70f64657658635e66094edbcb4d", statuses[0].URL)
assert.Equal(t, "http://test.ci/", statuses[0].TargetURL)
assert.Empty(t, statuses[0].Description)
assert.Equal(t, "testci", statuses[0].Context)
assert.Len(t, status.Statuses, 1) doc = NewHTMLParser(t, resp.Body)
assert.Equal(t, statuses[0], status.Statuses[0]) // Check if commit status is displayed in message column (.tippy-target to ignore the tippy trigger)
assert.Equal(t, "65f1bf27bc3bf70f64657658635e66094edbcb4d", status.SHA) sel := doc.doc.Find("#commits-table .message .tippy-target .commit-status")
assert.Equal(t, 1, sel.Length())
for _, class := range classes {
assert.True(t, sel.HasClass(class))
}
testRepoCommitsWithStatus := func(t *testing.T, linkList, linkCombined string, state commitstatus.CommitStatusState) {
statuses, status := requestCommitStatuses(t, linkList, linkCombined)
require.Len(t, statuses, 1)
require.NotNil(t, status)
assert.Equal(t, state, statuses[0].State)
assert.Equal(t, setting.AppURL+"api/v1/repos/user2/repo1/statuses/"+commitID, statuses[0].URL)
assert.Equal(t, "http://test.ci/", statuses[0].TargetURL)
assert.Empty(t, statuses[0].Description)
assert.Equal(t, "testci", statuses[0].Context)
assert.Len(t, status.Statuses, 1)
assert.Equal(t, statuses[0], status.Statuses[0])
assert.Equal(t, commitID, status.SHA)
}
// By SHA
testRepoCommitsWithStatus(t, "/api/v1/repos/user2/repo1/commits/"+commitID+"/statuses", "/api/v1/repos/user2/repo1/commits/"+commitID+"/status", state)
// By short SHA
testRepoCommitsWithStatus(t, "/api/v1/repos/user2/repo1/commits/"+commitID[:7]+"/statuses", "/api/v1/repos/user2/repo1/commits/"+commitID[:7]+"/status", state)
// By Ref
testRepoCommitsWithStatus(t, "/api/v1/repos/user2/repo1/commits/master/statuses", "/api/v1/repos/user2/repo1/commits/master/status", state)
// Tag "v1.1" points to master
testRepoCommitsWithStatus(t, "/api/v1/repos/user2/repo1/commits/v1.1/statuses", "/api/v1/repos/user2/repo1/commits/v1.1/status", state)
} }
}
func TestRepoCommitsWithStatusPending(t *testing.T) { t.Run("pending", func(t *testing.T) { testRefMaster(t, "pending", "octicon-dot-fill", "yellow") })
doTestRepoCommitWithStatus(t, "pending", "octicon-dot-fill", "yellow") t.Run("success", func(t *testing.T) { testRefMaster(t, "success", "octicon-check", "green") })
} t.Run("error", func(t *testing.T) { testRefMaster(t, "error", "gitea-exclamation", "red") })
t.Run("failure", func(t *testing.T) { testRefMaster(t, "failure", "octicon-x", "red") })
t.Run("warning", func(t *testing.T) { testRefMaster(t, "warning", "gitea-exclamation", "yellow") })
t.Run("BranchWithSlash", func(t *testing.T) {
_ = db.TruncateBeans(t.Context(), &git_model.CommitStatus{})
func TestRepoCommitsWithStatusSuccess(t *testing.T) { linkList, linkCombined := "/api/v1/repos/user2/repo1/commits/feature%2F1/statuses", "/api/v1/repos/user2/repo1/commits/feature/1/status"
doTestRepoCommitWithStatus(t, "success", "octicon-check", "green") statuses, status := requestCommitStatuses(t, linkList, linkCombined)
} assert.Empty(t, statuses)
assert.Empty(t, status.Statuses)
func TestRepoCommitsWithStatusError(t *testing.T) { doAPICreateCommitStatusTest(ctx, "feature/1", commitstatus.CommitStatusSuccess, "testci")(t)
doTestRepoCommitWithStatus(t, "error", "gitea-exclamation", "red") statuses, status = requestCommitStatuses(t, linkList, linkCombined)
} assert.NotEmpty(t, statuses)
assert.NotEmpty(t, status.Statuses)
func TestRepoCommitsWithStatusFailure(t *testing.T) { })
doTestRepoCommitWithStatus(t, "failure", "octicon-x", "red")
}
func TestRepoCommitsWithStatusWarning(t *testing.T) {
doTestRepoCommitWithStatus(t, "warning", "gitea-exclamation", "yellow")
} }
func TestRepoCommitsStatusParallel(t *testing.T) { func TestRepoCommitsStatusParallel(t *testing.T) {
@@ -194,13 +185,7 @@ func TestRepoCommitsStatusParallel(t *testing.T) {
go func(parentT *testing.T, i int) { go func(parentT *testing.T, i int) {
parentT.Run(fmt.Sprintf("ParallelCreateStatus_%d", i), func(t *testing.T) { parentT.Run(fmt.Sprintf("ParallelCreateStatus_%d", i), func(t *testing.T) {
ctx := NewAPITestContext(t, "user2", "repo1", auth_model.AccessTokenScopeWriteRepository) ctx := NewAPITestContext(t, "user2", "repo1", auth_model.AccessTokenScopeWriteRepository)
runBody := doAPICreateCommitStatus(ctx, path.Base(commitURL), api.CreateStatusOption{ doAPICreateCommitStatusTest(ctx, path.Base(commitURL), commitstatus.CommitStatusPending, "testci")(t)
State: commitstatus.CommitStatusPending,
TargetURL: "http://test.ci/",
Description: "",
Context: "testci",
})
runBody(t)
wg.Done() wg.Done()
}) })
}(t, i) }(t, i)
@@ -225,20 +210,8 @@ func TestRepoCommitsStatusMultiple(t *testing.T) {
// Call API to add status for commit // Call API to add status for commit
ctx := NewAPITestContext(t, "user2", "repo1", auth_model.AccessTokenScopeWriteRepository) ctx := NewAPITestContext(t, "user2", "repo1", auth_model.AccessTokenScopeWriteRepository)
t.Run("CreateStatus", doAPICreateCommitStatus(ctx, path.Base(commitURL), api.CreateStatusOption{ t.Run("CreateStatus", doAPICreateCommitStatusTest(ctx, path.Base(commitURL), commitstatus.CommitStatusSuccess, "testci"))
State: commitstatus.CommitStatusSuccess, t.Run("CreateStatus", doAPICreateCommitStatusTest(ctx, path.Base(commitURL), commitstatus.CommitStatusSuccess, "other_context"))
TargetURL: "http://test.ci/",
Description: "",
Context: "testci",
}))
t.Run("CreateStatus", doAPICreateCommitStatus(ctx, path.Base(commitURL), api.CreateStatusOption{
State: commitstatus.CommitStatusSuccess,
TargetURL: "http://test.ci/",
Description: "",
Context: "other_context",
}))
req = NewRequest(t, "GET", "/user2/repo1/commits/branch/master") req = NewRequest(t, "GET", "/user2/repo1/commits/branch/master")
resp = session.MakeRequest(t, req, http.StatusOK) resp = session.MakeRequest(t, req, http.StatusOK)
+1 -6
View File
@@ -934,12 +934,7 @@ func Test_WebhookStatus(t *testing.T) {
testCtx := NewAPITestContext(t, "user2", "repo1", auth_model.AccessTokenScopeAll) testCtx := NewAPITestContext(t, "user2", "repo1", auth_model.AccessTokenScopeAll)
// update a status for a commit via API // update a status for a commit via API
doAPICreateCommitStatus(testCtx, commitID, api.CreateStatusOption{ doAPICreateCommitStatusTest(testCtx, commitID, commitstatus.CommitStatusSuccess, "testci")(t)
State: commitstatus.CommitStatusSuccess,
TargetURL: "http://test.ci/",
Description: "",
Context: "testci",
})(t)
// 3. validate the webhook is triggered // 3. validate the webhook is triggered
assert.Equal(t, "status", triggeredEvent) assert.Equal(t, "status", triggeredEvent)
@@ -176,6 +176,7 @@ export function markdownHandleIndention(tvs: TextareaValueSelection): MarkdownHa
} }
function handleNewline(textarea: HTMLTextAreaElement, e: Event) { function handleNewline(textarea: HTMLTextAreaElement, e: Event) {
if ((e as KeyboardEvent).isComposing) return;
const ret = markdownHandleIndention({value: textarea.value, selStart: textarea.selectionStart, selEnd: textarea.selectionEnd}); const ret = markdownHandleIndention({value: textarea.value, selStart: textarea.selectionStart, selEnd: textarea.selectionEnd});
if (!ret.handled) return; if (!ret.handled) return;
e.preventDefault(); e.preventDefault();
+14 -9
View File
@@ -4,6 +4,9 @@ import {GET, POST} from '../modules/fetch.ts';
const {appSubUrl} = window.config; const {appSubUrl} = window.config;
/** One of the possible values for the `data-webauthn-error-msg` attribute on the webauthn error message element */
type ErrorType = 'general' | 'insecure' | 'browser' | 'unable-to-process' | 'duplicated' | 'unknown';
export async function initUserAuthWebAuthn() { export async function initUserAuthWebAuthn() {
const elPrompt = document.querySelector('.user.signin.webauthn-prompt'); const elPrompt = document.querySelector('.user.signin.webauthn-prompt');
const elSignInPasskeyBtn = document.querySelector('.signin-passkey'); const elSignInPasskeyBtn = document.querySelector('.signin-passkey');
@@ -11,7 +14,8 @@ export async function initUserAuthWebAuthn() {
return; return;
} }
if (!detectWebAuthnSupport()) { const errorType = detectWebAuthnSupport();
if (errorType) {
if (elSignInPasskeyBtn) hideElem(elSignInPasskeyBtn); if (elSignInPasskeyBtn) hideElem(elSignInPasskeyBtn);
return; return;
} }
@@ -177,7 +181,7 @@ async function webauthnRegistered(newCredential: any) { // TODO: Credential type
window.location.reload(); window.location.reload();
} }
function webAuthnError(errorType: string, message:string = '') { function webAuthnError(errorType: ErrorType, message:string = '') {
const elErrorMsg = document.querySelector(`#webauthn-error-msg`); const elErrorMsg = document.querySelector(`#webauthn-error-msg`);
if (errorType === 'general') { if (errorType === 'general') {
@@ -194,25 +198,26 @@ function webAuthnError(errorType: string, message:string = '') {
showElem('#webauthn-error'); showElem('#webauthn-error');
} }
function detectWebAuthnSupport() { /** Returns the error type or `null` when there was no error. */
function detectWebAuthnSupport(): ErrorType | null {
if (!window.isSecureContext) { if (!window.isSecureContext) {
webAuthnError('insecure'); return 'insecure';
return false;
} }
if (typeof window.PublicKeyCredential !== 'function') { if (typeof window.PublicKeyCredential !== 'function') {
webAuthnError('browser'); return 'browser';
return false;
} }
return true; return null;
} }
export function initUserAuthWebAuthnRegister() { export function initUserAuthWebAuthnRegister() {
const elRegister = document.querySelector<HTMLInputElement>('#register-webauthn'); const elRegister = document.querySelector<HTMLInputElement>('#register-webauthn');
if (!elRegister) return; if (!elRegister) return;
if (!detectWebAuthnSupport()) { const errorType = detectWebAuthnSupport();
if (errorType) {
webAuthnError(errorType);
elRegister.disabled = true; elRegister.disabled = true;
return; return;
} }