mirror of
https://github.com/go-gitea/gitea.git
synced 2026-09-22 02:55:03 +09:00
Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 95ba37d9af | |||
| db23633874 | |||
| 2bde4fa5d2 | |||
| 4aa0d704f2 | |||
| b0eaf6c75b | |||
| 42eb7945d1 | |||
| 0b9623e188 | |||
| 6fb96fcfdf | |||
| 99f8b3d9a1 | |||
| f6e2ca4388 | |||
| 61c58b4b65 | |||
| 5facdcc7fd | |||
| bb4cccc6e9 | |||
| 98cc15b307 | |||
| 9b8bfdceb1 | |||
| e156ac8063 | |||
| 4751adf42d | |||
| 3bebddedc0 | |||
| 617b6948b1 | |||
| 1c7b7ea72d | |||
| e107498f3b | |||
| 3b705738ab | |||
| 8f4b7ebbf6 | |||
| 603c8ece00 | |||
| 4a19964921 | |||
| 38711f2696 | |||
| 64ad4bb0ff | |||
| 8bf445e86a | |||
| 094eeee365 | |||
| cc3ee01fd8 | |||
| c044b0f48c | |||
| 10fc85e263 | |||
| bb6ca9da4d | |||
| 53877583f0 |
@@ -4,6 +4,56 @@ 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.26.4](https://github.com/go-gitea/gitea/releases/tag/1.26.4) - 2026-06-21
|
||||||
|
|
||||||
|
* SECURITY
|
||||||
|
* fix(auth): do not auto-reactivate disabled users on OAuth2 callback (#38009) (#38183)
|
||||||
|
|
||||||
|
* BUGFIXES
|
||||||
|
* fix: walk git log context error handling (#38182) (#38185)
|
||||||
|
|
||||||
|
## [1.26.3](https://github.com/go-gitea/gitea/releases/tag/1.26.3) - 2026-06-18
|
||||||
|
|
||||||
|
* BREAKING
|
||||||
|
* fix(actions)!: require merged PR to bypass fork PR approval gate (#38010) (#38041)
|
||||||
|
|
||||||
|
* SECURITY
|
||||||
|
* fix(hostmatcher): patch incorrect private list (#38170) (#38173)
|
||||||
|
* fix: Various security fixes (#38103) (#38151)
|
||||||
|
* fix: Various sec fixes (#38108) (#38147)
|
||||||
|
* fix: allow git clone of private repos with anonymous code access (#38074) (#38146)
|
||||||
|
* fix(auth): ignore stale OIDC external login links to organizations (#37875) (#38141)
|
||||||
|
* fix(hostmatcher): block reserved IP ranges from external/private filters (#38039) (#38059)
|
||||||
|
* fix(lfs): require Code-unit access for cross-repo LFS object reuse (#38006) (#38050)
|
||||||
|
* fix(lfs): reject unknown SSH LFS sub-verbs to prevent auth bypass (#38008) (#38015)
|
||||||
|
* fix: bound CODEOWNERS regex match time (#38011) (#38025)
|
||||||
|
* fix: bound debian ParseControlFile to a single control stanza (#38044) (#38055)
|
||||||
|
* fix(deps): update module golang.org/x/net to v0.55.0 [security] (#37813) (#37829)
|
||||||
|
|
||||||
|
* API
|
||||||
|
* feat(api): add Link header in ListForks (#38052) (#38063)
|
||||||
|
|
||||||
|
* BUGFIXES
|
||||||
|
* fix: Fix the panic when ssh remote lfs endpoint parsing failure (#38026) (#38158)
|
||||||
|
* fix(api): nil pointer panic when filtering tracked times by a non-existent user (#38112) (#38115)
|
||||||
|
* fix: keep literal "false" value displayed in workflow_dispatch choice dropdowns (#38080) (#38096)
|
||||||
|
* fix: parse HEAD ref (#38119)
|
||||||
|
* fix: git cmd (#38084) (#38087)
|
||||||
|
* fix(releases): generate notes for initial tag (#37697) (#37986)
|
||||||
|
* fix(actions): return 404 when job log blob is missing (#38003) (#38004)
|
||||||
|
* fix(actions): exclude `workflow_call` from workflow trigger detection (#37894) (#37899)
|
||||||
|
* fix(actions): keep action run title clickable when commit subject is a URL (#37867) (#37898)
|
||||||
|
* fix(actions): reject workflow_dispatch for workflows without that trigger (#37660) (#37895)
|
||||||
|
* fix(actions): ack re-sent `UpdateLog` finalize idempotently (#37885) (#37892)
|
||||||
|
* fix: http content file render (#37850) (#37856)
|
||||||
|
* fix(issues): clear stale ReviewTypeRequest when submitting pending review (#37809) (#37815)
|
||||||
|
* fix: Fix issue target branch selection for non-collaborators (#36916) (#38164)
|
||||||
|
|
||||||
|
* BUILD
|
||||||
|
* fix(deps): update `@playwright/test` to 1.60.0 (#38144)
|
||||||
|
* ci: add `tools/ci-tools.ts` for the PR labeler workflow (#37831)
|
||||||
|
* fix(build): swagger css import (#37801) (#37803)
|
||||||
|
|
||||||
## [1.26.2](https://github.com/go-gitea/gitea/releases/tag/1.26.2) - 2026-05-20
|
## [1.26.2](https://github.com/go-gitea/gitea/releases/tag/1.26.2) - 2026-05-20
|
||||||
|
|
||||||
* SECURITY
|
* SECURITY
|
||||||
|
|||||||
+14
-9
@@ -113,23 +113,25 @@ func handleCliResponseExtra(extra private.ResponseExtra) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func getAccessMode(verb, lfsVerb string) perm.AccessMode {
|
// getAccessMode maps an SSH git/LFS verb to the access mode it requires, with
|
||||||
|
// ok=false for an unrecognised verb. Callers MUST reject the request when ok is
|
||||||
|
// false: AccessModeNone would otherwise pass the `userMode < mode` permission
|
||||||
|
// check in routers/private/serv.go and grant access.
|
||||||
|
func getAccessMode(verb, lfsVerb string) (mode perm.AccessMode, ok bool) {
|
||||||
switch verb {
|
switch verb {
|
||||||
case git.CmdVerbUploadPack, git.CmdVerbUploadArchive:
|
case git.CmdVerbUploadPack, git.CmdVerbUploadArchive:
|
||||||
return perm.AccessModeRead
|
return perm.AccessModeRead, true
|
||||||
case git.CmdVerbReceivePack:
|
case git.CmdVerbReceivePack:
|
||||||
return perm.AccessModeWrite
|
return perm.AccessModeWrite, true
|
||||||
case git.CmdVerbLfsAuthenticate, git.CmdVerbLfsTransfer:
|
case git.CmdVerbLfsAuthenticate, git.CmdVerbLfsTransfer:
|
||||||
switch lfsVerb {
|
switch lfsVerb {
|
||||||
case git.CmdSubVerbLfsUpload:
|
case git.CmdSubVerbLfsUpload:
|
||||||
return perm.AccessModeWrite
|
return perm.AccessModeWrite, true
|
||||||
case git.CmdSubVerbLfsDownload:
|
case git.CmdSubVerbLfsDownload:
|
||||||
return perm.AccessModeRead
|
return perm.AccessModeRead, true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// should be unreachable
|
return perm.AccessModeNone, false
|
||||||
setting.PanicInDevOrTesting("unknown verb: %s %s", verb, lfsVerb)
|
|
||||||
return perm.AccessModeNone
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func runServ(ctx context.Context, c *cli.Command) error {
|
func runServ(ctx context.Context, c *cli.Command) error {
|
||||||
@@ -247,7 +249,10 @@ func runServ(ctx context.Context, c *cli.Command) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
requestedMode := getAccessMode(verb, lfsVerb)
|
requestedMode, ok := getAccessMode(verb, lfsVerb)
|
||||||
|
if !ok {
|
||||||
|
return fail(ctx, "Unknown git command", "Unknown git command %s %s", verb, lfsVerb)
|
||||||
|
}
|
||||||
|
|
||||||
results, extra := private.ServCommand(ctx, keyID, username, reponame, requestedMode, verb, lfsVerb)
|
results, extra := private.ServCommand(ctx, keyID, username, reponame, requestedMode, verb, lfsVerb)
|
||||||
if extra.HasError() {
|
if extra.HasError() {
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"code.gitea.io/gitea/models/perm"
|
||||||
|
"code.gitea.io/gitea/modules/git"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGetAccessMode(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
verb, lfsVerb string
|
||||||
|
expected perm.AccessMode
|
||||||
|
}{
|
||||||
|
{git.CmdVerbUploadPack, "", perm.AccessModeRead},
|
||||||
|
{git.CmdVerbUploadArchive, "", perm.AccessModeRead},
|
||||||
|
{git.CmdVerbReceivePack, "", perm.AccessModeWrite},
|
||||||
|
{git.CmdVerbLfsAuthenticate, git.CmdSubVerbLfsUpload, perm.AccessModeWrite},
|
||||||
|
{git.CmdVerbLfsAuthenticate, git.CmdSubVerbLfsDownload, perm.AccessModeRead},
|
||||||
|
{git.CmdVerbLfsTransfer, git.CmdSubVerbLfsUpload, perm.AccessModeWrite},
|
||||||
|
{git.CmdVerbLfsTransfer, git.CmdSubVerbLfsDownload, perm.AccessModeRead},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.verb+"/"+tc.lfsVerb, func(t *testing.T) {
|
||||||
|
mode, ok := getAccessMode(tc.verb, tc.lfsVerb)
|
||||||
|
assert.True(t, ok)
|
||||||
|
assert.Equal(t, tc.expected, mode)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGetAccessModeUnknownVerb locks in the invariant that getAccessMode reports
|
||||||
|
// ok=false for unrecognised verbs and LFS sub-verbs, so runServ rejects them. An
|
||||||
|
// unknown verb has no valid access mode; if it were treated as AccessModeNone (0)
|
||||||
|
// it would pass the `userMode < mode` permission check in routers/private/serv.go
|
||||||
|
// and hand out valid LFS JWTs for any private repository.
|
||||||
|
func TestGetAccessModeUnknownVerb(t *testing.T) {
|
||||||
|
cases := []struct{ verb, lfsVerb string }{
|
||||||
|
{git.CmdVerbLfsAuthenticate, ""},
|
||||||
|
{git.CmdVerbLfsAuthenticate, "badverb"},
|
||||||
|
{git.CmdVerbLfsTransfer, "badverb"},
|
||||||
|
{"git-unknown-verb", ""},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.verb+"/"+tc.lfsVerb, func(t *testing.T) {
|
||||||
|
mode, ok := getAccessMode(tc.verb, tc.lfsVerb)
|
||||||
|
assert.False(t, ok)
|
||||||
|
assert.Equal(t, perm.AccessModeNone, mode)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,8 +51,6 @@ ROOT_PATH = /data/gitea/log
|
|||||||
[security]
|
[security]
|
||||||
INSTALL_LOCK = $INSTALL_LOCK
|
INSTALL_LOCK = $INSTALL_LOCK
|
||||||
SECRET_KEY = $SECRET_KEY
|
SECRET_KEY = $SECRET_KEY
|
||||||
REVERSE_PROXY_LIMIT = 1
|
|
||||||
REVERSE_PROXY_TRUSTED_PROXIES = *
|
|
||||||
|
|
||||||
[service]
|
[service]
|
||||||
DISABLE_REGISTRATION = $DISABLE_REGISTRATION
|
DISABLE_REGISTRATION = $DISABLE_REGISTRATION
|
||||||
|
|||||||
@@ -48,8 +48,6 @@ ROOT_PATH = $GITEA_WORK_DIR/data/log
|
|||||||
[security]
|
[security]
|
||||||
INSTALL_LOCK = $INSTALL_LOCK
|
INSTALL_LOCK = $INSTALL_LOCK
|
||||||
SECRET_KEY = $SECRET_KEY
|
SECRET_KEY = $SECRET_KEY
|
||||||
REVERSE_PROXY_LIMIT = 1
|
|
||||||
REVERSE_PROXY_TRUSTED_PROXIES = *
|
|
||||||
|
|
||||||
[service]
|
[service]
|
||||||
DISABLE_REGISTRATION = $DISABLE_REGISTRATION
|
DISABLE_REGISTRATION = $DISABLE_REGISTRATION
|
||||||
|
|||||||
+4
-2
@@ -570,8 +570,6 @@ export default defineConfig([
|
|||||||
'no-redeclare': [0], // must be disabled for typescript overloads
|
'no-redeclare': [0], // must be disabled for typescript overloads
|
||||||
'no-regex-spaces': [2],
|
'no-regex-spaces': [2],
|
||||||
'no-restricted-exports': [0],
|
'no-restricted-exports': [0],
|
||||||
'no-restricted-globals': [2, ...restrictedGlobals],
|
|
||||||
'no-restricted-properties': [2, ...restrictedProperties],
|
|
||||||
'no-restricted-imports': [2, {paths: [
|
'no-restricted-imports': [2, {paths: [
|
||||||
{name: 'jquery', message: 'Use the global $ instead', allowTypeImports: true},
|
{name: 'jquery', message: 'Use the global $ instead', allowTypeImports: true},
|
||||||
{name: 'htmx.org', message: 'Use the global htmx instead', allowTypeImports: true},
|
{name: 'htmx.org', message: 'Use the global htmx instead', allowTypeImports: true},
|
||||||
@@ -1024,5 +1022,9 @@ export default defineConfig([
|
|||||||
{
|
{
|
||||||
files: ['web_src/**/*'],
|
files: ['web_src/**/*'],
|
||||||
languageOptions: {globals: {...globals.browser, ...globals.jquery, htmx: false}},
|
languageOptions: {globals: {...globals.browser, ...globals.jquery, htmx: false}},
|
||||||
|
rules: {
|
||||||
|
'no-restricted-globals': [2, ...restrictedGlobals],
|
||||||
|
'no-restricted-properties': [2, ...restrictedProperties],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -110,13 +110,13 @@ require (
|
|||||||
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
|
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
|
||||||
gitlab.com/gitlab-org/api/client-go v1.46.0
|
gitlab.com/gitlab-org/api/client-go v1.46.0
|
||||||
go.yaml.in/yaml/v4 v4.0.0-rc.3
|
go.yaml.in/yaml/v4 v4.0.0-rc.3
|
||||||
golang.org/x/crypto v0.50.0
|
golang.org/x/crypto v0.52.0
|
||||||
golang.org/x/image v0.38.0
|
golang.org/x/image v0.40.0
|
||||||
golang.org/x/net v0.53.0
|
golang.org/x/net v0.55.0
|
||||||
golang.org/x/oauth2 v0.36.0
|
golang.org/x/oauth2 v0.36.0
|
||||||
golang.org/x/sync v0.20.0
|
golang.org/x/sync v0.20.0
|
||||||
golang.org/x/sys v0.44.0
|
golang.org/x/sys v0.45.0
|
||||||
golang.org/x/text v0.36.0
|
golang.org/x/text v0.37.0
|
||||||
google.golang.org/grpc v1.79.3
|
google.golang.org/grpc v1.79.3
|
||||||
google.golang.org/protobuf v1.36.11
|
google.golang.org/protobuf v1.36.11
|
||||||
gopkg.in/ini.v1 v1.67.1
|
gopkg.in/ini.v1 v1.67.1
|
||||||
|
|||||||
@@ -785,12 +785,12 @@ golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDf
|
|||||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||||
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
|
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
|
||||||
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
|
||||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
|
||||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
|
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
|
||||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
|
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
|
||||||
golang.org/x/image v0.38.0 h1:5l+q+Y9JDC7mBOMjo4/aPhMDcxEptsX+Tt3GgRQRPuE=
|
golang.org/x/image v0.40.0 h1:Tw4GyDXMo+daZN1znreBRC3VayR1aLFUyUEOLUdW1a8=
|
||||||
golang.org/x/image v0.38.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY=
|
golang.org/x/image v0.40.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
|
||||||
golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
|
golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
|
||||||
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
|
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
|
||||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
@@ -819,8 +819,8 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
|||||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||||
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
||||||
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
||||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
@@ -868,8 +868,8 @@ golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
|||||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
|
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||||
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
@@ -880,8 +880,8 @@ golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
|||||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||||
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||||
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
|
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
|
||||||
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
|
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
|
||||||
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
|
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
@@ -892,8 +892,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
|||||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
|||||||
@@ -70,7 +70,6 @@ type FindRunOptions struct {
|
|||||||
Ref string // the commit/tag/… that caused this workflow
|
Ref string // the commit/tag/… that caused this workflow
|
||||||
TriggerUserID int64
|
TriggerUserID int64
|
||||||
TriggerEvent webhook_module.HookEventType
|
TriggerEvent webhook_module.HookEventType
|
||||||
Approved bool // not util.OptionalBool, it works only when it's true
|
|
||||||
Status []Status
|
Status []Status
|
||||||
ConcurrencyGroup string
|
ConcurrencyGroup string
|
||||||
CommitSHA string
|
CommitSHA string
|
||||||
@@ -87,9 +86,6 @@ func (opts FindRunOptions) ToConds() builder.Cond {
|
|||||||
if opts.TriggerUserID > 0 {
|
if opts.TriggerUserID > 0 {
|
||||||
cond = cond.And(builder.Eq{"`action_run`.trigger_user_id": opts.TriggerUserID})
|
cond = cond.And(builder.Eq{"`action_run`.trigger_user_id": opts.TriggerUserID})
|
||||||
}
|
}
|
||||||
if opts.Approved {
|
|
||||||
cond = cond.And(builder.Gt{"`action_run`.approved_by": 0})
|
|
||||||
}
|
|
||||||
if len(opts.Status) > 0 {
|
if len(opts.Status) > 0 {
|
||||||
cond = cond.And(builder.In("`action_run`.status", opts.Status))
|
cond = cond.And(builder.In("`action_run`.status", opts.Status))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import (
|
|||||||
|
|
||||||
"github.com/pquerna/otp/totp"
|
"github.com/pquerna/otp/totp"
|
||||||
"golang.org/x/crypto/pbkdf2"
|
"golang.org/x/crypto/pbkdf2"
|
||||||
|
"xorm.io/builder"
|
||||||
)
|
)
|
||||||
|
|
||||||
//
|
//
|
||||||
@@ -107,20 +108,43 @@ func (t *TwoFactor) SetSecret(secretString string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ValidateTOTP validates the provided passcode.
|
// validateTOTP validates the provided passcode. It does not consume the passcode; all login
|
||||||
func (t *TwoFactor) ValidateTOTP(passcode string) (bool, error) {
|
// surfaces must go through ValidateAndConsumeTOTP so that a passcode cannot be redeemed twice.
|
||||||
|
func (t *TwoFactor) validateTOTP(passcode string) (bool, error) {
|
||||||
decodedStoredSecret, err := base64.StdEncoding.DecodeString(t.Secret)
|
decodedStoredSecret, err := base64.StdEncoding.DecodeString(t.Secret)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, fmt.Errorf("ValidateTOTP invalid base64: %w", err)
|
return false, fmt.Errorf("validateTOTP invalid base64: %w", err)
|
||||||
}
|
}
|
||||||
secretBytes, err := secret.AesDecrypt(t.getEncryptionKey(), decodedStoredSecret)
|
secretBytes, err := secret.AesDecrypt(t.getEncryptionKey(), decodedStoredSecret)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, fmt.Errorf("ValidateTOTP unable to decrypt (maybe SECRET_KEY is wrong): %w", err)
|
return false, fmt.Errorf("validateTOTP unable to decrypt (maybe SECRET_KEY is wrong): %w", err)
|
||||||
}
|
}
|
||||||
secretStr := string(secretBytes)
|
secretStr := string(secretBytes)
|
||||||
return totp.Validate(passcode, secretStr), nil
|
return totp.Validate(passcode, secretStr), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ValidateAndConsumeTOTP validates the passcode and atomically records it as used so that the
|
||||||
|
// same passcode cannot be redeemed more than once (RFC 6238 §5.2). It returns false for an
|
||||||
|
// invalid passcode as well as for a replay, including the case where a concurrent request with
|
||||||
|
// the same passcode won the race first. All TOTP login surfaces must go through this helper.
|
||||||
|
func (t *TwoFactor) ValidateAndConsumeTOTP(ctx context.Context, passcode string) (bool, error) {
|
||||||
|
ok, err := t.validateTOTP(passcode)
|
||||||
|
if err != nil || !ok {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
// Conditional update: only a row whose stored passcode differs from this one is updated, so a
|
||||||
|
// replay (or a concurrent duplicate) matches zero rows and is rejected. The row lock taken by
|
||||||
|
// the UPDATE serializes racing requests, closing the read-validate-write TOCTOU window.
|
||||||
|
t.LastUsedPasscode = passcode
|
||||||
|
n, err := db.GetEngine(ctx).ID(t.ID).
|
||||||
|
Where(builder.Or(builder.IsNull{"last_used_passcode"}, builder.Neq{"last_used_passcode": passcode})).
|
||||||
|
Cols("last_used_passcode").Update(t)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return n == 1, nil
|
||||||
|
}
|
||||||
|
|
||||||
// NewTwoFactor creates a new two-factor authentication token.
|
// NewTwoFactor creates a new two-factor authentication token.
|
||||||
func NewTwoFactor(ctx context.Context, t *TwoFactor) error {
|
func NewTwoFactor(ctx context.Context, t *TwoFactor) error {
|
||||||
_, err := db.GetEngine(ctx).Insert(t)
|
_, err := db.GetEngine(ctx).Insert(t)
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package auth_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
auth_model "code.gitea.io/gitea/models/auth"
|
||||||
|
"code.gitea.io/gitea/models/unittest"
|
||||||
|
|
||||||
|
"github.com/pquerna/otp/totp"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTwoFactorValidateAndConsumeTOTP(t *testing.T) {
|
||||||
|
require.NoError(t, unittest.PrepareTestDatabase())
|
||||||
|
|
||||||
|
key, err := totp.Generate(totp.GenerateOpts{SecretSize: 40, Issuer: "gitea-test", AccountName: "consume"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
tfa := &auth_model.TwoFactor{UID: 1}
|
||||||
|
require.NoError(t, tfa.SetSecret(key.Secret()))
|
||||||
|
require.NoError(t, auth_model.NewTwoFactor(t.Context(), tfa))
|
||||||
|
|
||||||
|
passcode, err := totp.GenerateCode(key.Secret(), time.Now())
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// first use of a valid passcode succeeds
|
||||||
|
ok, err := tfa.ValidateAndConsumeTOTP(t.Context(), passcode)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, ok)
|
||||||
|
|
||||||
|
// replaying the same passcode is refused, even when still inside the TOTP validity window
|
||||||
|
reloaded, err := auth_model.GetTwoFactorByUID(t.Context(), tfa.UID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
ok, err = reloaded.ValidateAndConsumeTOTP(t.Context(), passcode)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.False(t, ok)
|
||||||
|
|
||||||
|
// an invalid passcode is rejected without consuming anything
|
||||||
|
ok, err = reloaded.ValidateAndConsumeTOTP(t.Context(), "000000")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.False(t, ok)
|
||||||
|
}
|
||||||
+5
-2
@@ -196,7 +196,10 @@ func LFSObjectAccessible(ctx context.Context, user *user_model.User, oid string)
|
|||||||
count, err := db.GetEngine(ctx).Count(&LFSMetaObject{Pointer: lfs.Pointer{Oid: oid}})
|
count, err := db.GetEngine(ctx).Count(&LFSMetaObject{Pointer: lfs.Pointer{Oid: oid}})
|
||||||
return count > 0, err
|
return count > 0, err
|
||||||
}
|
}
|
||||||
cond := repo_model.AccessibleRepositoryCondition(user, unit.TypeInvalid)
|
// LFS objects are repository code content, so authorization must require
|
||||||
|
// Code-unit access; other unit accesses (e.g. Issues) must not authorize
|
||||||
|
// reuse of an existing LFS object across repositories.
|
||||||
|
cond := repo_model.AccessibleRepositoryCondition(user, unit.TypeCode)
|
||||||
count, err := db.GetEngine(ctx).Where(cond).Join("INNER", "repository", "`lfs_meta_object`.repository_id = `repository`.id").Count(&LFSMetaObject{Pointer: lfs.Pointer{Oid: oid}})
|
count, err := db.GetEngine(ctx).Where(cond).Join("INNER", "repository", "`lfs_meta_object`.repository_id = `repository`.id").Count(&LFSMetaObject{Pointer: lfs.Pointer{Oid: oid}})
|
||||||
return count > 0, err
|
return count > 0, err
|
||||||
}
|
}
|
||||||
@@ -220,7 +223,7 @@ func LFSAutoAssociate(ctx context.Context, metas []*LFSMetaObject, user *user_mo
|
|||||||
newMetas := make([]*LFSMetaObject, 0, len(metas))
|
newMetas := make([]*LFSMetaObject, 0, len(metas))
|
||||||
cond := builder.In(
|
cond := builder.In(
|
||||||
"`lfs_meta_object`.repository_id",
|
"`lfs_meta_object`.repository_id",
|
||||||
builder.Select("`repository`.id").From("repository").Where(repo_model.AccessibleRepositoryCondition(user, unit.TypeInvalid)),
|
builder.Select("`repository`.id").From("repository").Where(repo_model.AccessibleRepositoryCondition(user, unit.TypeCode)),
|
||||||
)
|
)
|
||||||
if err := db.GetEngine(ctx).Cols("oid").Where(cond).In("oid", oids...).GroupBy("oid").Find(&newMetas); err != nil {
|
if err := db.GetEngine(ctx).Cols("oid").Where(cond).In("oid", oids...).GroupBy("oid").Find(&newMetas); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"code.gitea.io/gitea/models/db"
|
"code.gitea.io/gitea/models/db"
|
||||||
git_model "code.gitea.io/gitea/models/git"
|
git_model "code.gitea.io/gitea/models/git"
|
||||||
@@ -860,6 +861,11 @@ func GetCodeOwnersFromContent(ctx context.Context, data string) ([]*CodeOwnerRul
|
|||||||
return rules, warnings
|
return rules, warnings
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// codeOwnerMatchTimeout bounds a single pattern match so a crafted pattern
|
||||||
|
// cannot stall via catastrophic backtracking. See also the aggregate budget
|
||||||
|
// enforced by the caller across the whole rules×files match loop.
|
||||||
|
const codeOwnerMatchTimeout = 150 * time.Millisecond
|
||||||
|
|
||||||
type CodeOwnerRule struct {
|
type CodeOwnerRule struct {
|
||||||
Rule *regexp2.Regexp // it supports negative lookahead, does better for end users
|
Rule *regexp2.Regexp // it supports negative lookahead, does better for end users
|
||||||
Negative bool
|
Negative bool
|
||||||
@@ -888,6 +894,8 @@ func ParseCodeOwnersLine(ctx context.Context, tokens []string) (*CodeOwnerRule,
|
|||||||
warnings = append(warnings, fmt.Sprintf("incorrect codeowner regexp: %s", err))
|
warnings = append(warnings, fmt.Sprintf("incorrect codeowner regexp: %s", err))
|
||||||
return nil, warnings
|
return nil, warnings
|
||||||
}
|
}
|
||||||
|
// Bound matching time so user-supplied patterns cannot stall PR creation via catastrophic backtracking.
|
||||||
|
rule.Rule.MatchTimeout = codeOwnerMatchTimeout
|
||||||
|
|
||||||
for _, user := range tokens[1:] {
|
for _, user := range tokens[1:] {
|
||||||
user = strings.TrimPrefix(user, "@")
|
user = strings.TrimPrefix(user, "@")
|
||||||
|
|||||||
@@ -4,7 +4,9 @@
|
|||||||
package issues_test
|
package issues_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"code.gitea.io/gitea/models/db"
|
"code.gitea.io/gitea/models/db"
|
||||||
issues_model "code.gitea.io/gitea/models/issues"
|
issues_model "code.gitea.io/gitea/models/issues"
|
||||||
@@ -39,6 +41,7 @@ func TestPullRequest(t *testing.T) {
|
|||||||
t.Run("DeleteOrphanedObjects", testDeleteOrphanedObjects)
|
t.Run("DeleteOrphanedObjects", testDeleteOrphanedObjects)
|
||||||
t.Run("ParseCodeOwnersLine", testParseCodeOwnersLine)
|
t.Run("ParseCodeOwnersLine", testParseCodeOwnersLine)
|
||||||
t.Run("CodeOwnerAbsolutePathPatterns", testCodeOwnerAbsolutePathPatterns)
|
t.Run("CodeOwnerAbsolutePathPatterns", testCodeOwnerAbsolutePathPatterns)
|
||||||
|
t.Run("CodeOwnerPatternMatchTimeout", testCodeOwnerPatternMatchTimeout)
|
||||||
t.Run("GetApprovers", testGetApprovers)
|
t.Run("GetApprovers", testGetApprovers)
|
||||||
t.Run("GetPullRequestByMergedCommit", testGetPullRequestByMergedCommit)
|
t.Run("GetPullRequestByMergedCommit", testGetPullRequestByMergedCommit)
|
||||||
t.Run("Migrate_InsertPullRequests", testMigrateInsertPullRequests)
|
t.Run("Migrate_InsertPullRequests", testMigrateInsertPullRequests)
|
||||||
@@ -376,6 +379,22 @@ func testCodeOwnerAbsolutePathPatterns(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// testCodeOwnerPatternMatchTimeout ensures user-supplied CODEOWNERS patterns
|
||||||
|
// cannot stall pull request processing through catastrophic regex backtracking:
|
||||||
|
// each compiled rule must enforce a bounded match time.
|
||||||
|
func testCodeOwnerPatternMatchTimeout(t *testing.T) {
|
||||||
|
rules, _ := issues_model.GetCodeOwnersFromContent(t.Context(), "(a+)+ @user5\n")
|
||||||
|
require.Len(t, rules, 1)
|
||||||
|
|
||||||
|
maliciousInput := strings.Repeat("a", 30) + "X"
|
||||||
|
start := time.Now()
|
||||||
|
_, err := rules[0].Rule.MatchString(maliciousInput)
|
||||||
|
elapsed := time.Since(start)
|
||||||
|
|
||||||
|
require.Error(t, err, "expected MatchTimeout error on pathological input")
|
||||||
|
assert.Less(t, elapsed, time.Second, "match timeout did not bound regex evaluation; took %s", elapsed)
|
||||||
|
}
|
||||||
|
|
||||||
func testGetApprovers(t *testing.T) {
|
func testGetApprovers(t *testing.T) {
|
||||||
pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 5})
|
pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 5})
|
||||||
// Official reviews are already deduplicated. Allow unofficial reviews
|
// Official reviews are already deduplicated. Allow unofficial reviews
|
||||||
|
|||||||
@@ -483,6 +483,14 @@ func SubmitReview(ctx context.Context, doer *user_model.User, issue *Issue, revi
|
|||||||
if _, err := sess.ID(review.ID).Cols("content, type, official, commit_id, stale").Update(review); err != nil {
|
if _, err := sess.ID(review.ID).Cols("content, type, official, commit_id, stale").Update(review); err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// make sure the leftover review request is cleared, consistent with CreateReview
|
||||||
|
if reviewType != ReviewTypePending {
|
||||||
|
if _, err := sess.Where(builder.Eq{"reviewer_id": doer.ID, "issue_id": issue.ID, "type": ReviewTypeRequest}).
|
||||||
|
Delete(new(Review)); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
comm, err := CreateComment(ctx, &CreateCommentOptions{
|
comm, err := CreateComment(ctx, &CreateCommentOptions{
|
||||||
|
|||||||
@@ -303,6 +303,46 @@ func TestDeleteDismissedReview(t *testing.T) {
|
|||||||
unittest.AssertNotExistsBean(t, &issues_model.Comment{ID: comment.ID})
|
unittest.AssertNotExistsBean(t, &issues_model.Comment{ID: comment.ID})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSubmitReviewClearsStaleReviewRequest(t *testing.T) {
|
||||||
|
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||||
|
|
||||||
|
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 3})
|
||||||
|
assert.NoError(t, issue.LoadRepo(t.Context()))
|
||||||
|
assert.NoError(t, issue.Repo.LoadOwner(t.Context()))
|
||||||
|
reviewer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
|
||||||
|
doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||||
|
|
||||||
|
// the reviewer is requested to review the pull request
|
||||||
|
requestReview, err := issues_model.CreateReview(t.Context(), issues_model.CreateReviewOptions{
|
||||||
|
Type: issues_model.ReviewTypeRequest,
|
||||||
|
Issue: issue,
|
||||||
|
Reviewer: reviewer,
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// the reviewer starts a pending review (e.g. by adding code comments)
|
||||||
|
pendingReview, err := issues_model.CreateReview(t.Context(), issues_model.CreateReviewOptions{
|
||||||
|
Type: issues_model.ReviewTypePending,
|
||||||
|
Issue: issue,
|
||||||
|
Reviewer: reviewer,
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// submitting the pending review must clear the leftover review request,
|
||||||
|
// otherwise the reviewer can no longer be re-requested afterwards
|
||||||
|
review, _, err := issues_model.SubmitReview(t.Context(), reviewer, issue, issues_model.ReviewTypeComment, "looks good", "", false, nil)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, pendingReview.ID, review.ID)
|
||||||
|
assert.Equal(t, issues_model.ReviewTypeComment, review.Type)
|
||||||
|
|
||||||
|
unittest.AssertNotExistsBean(t, &issues_model.Review{ID: requestReview.ID})
|
||||||
|
|
||||||
|
// the reviewer can be re-requested afterwards (no-op before the fix)
|
||||||
|
comment, err := issues_model.AddReviewRequest(t.Context(), issue, reviewer, doer, false)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotNil(t, comment)
|
||||||
|
}
|
||||||
|
|
||||||
func TestAddReviewRequest(t *testing.T) {
|
func TestAddReviewRequest(t *testing.T) {
|
||||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||||
|
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ func OrderBy(orderBy string) any {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func whereOrderConditions(e db.Engine, conditions []any) db.Engine {
|
func whereOrderConditions(e db.Engine, conditions []any) db.Engine {
|
||||||
orderBy := "id" // query must have the "ORDER BY", otherwise the result is not deterministic
|
orderBy := "id" // query must have the "ORDER BY", otherwise the result is not deterministic. FIXME: some tables do not have "id" column
|
||||||
for _, condition := range conditions {
|
for _, condition := range conditions {
|
||||||
switch cond := condition.(type) {
|
switch cond := condition.(type) {
|
||||||
case *testCond:
|
case *testCond:
|
||||||
|
|||||||
@@ -92,8 +92,11 @@ func init() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetExternalLogin checks if a externalID in loginSourceID scope already exists
|
// GetExternalLogin checks if a externalID in loginSourceID scope already exists
|
||||||
func GetExternalLogin(ctx context.Context, externalLoginUser *ExternalLoginUser) (bool, error) {
|
func GetExternalLogin(ctx context.Context, loginSourceID int64, externalID string) (*ExternalLoginUser, bool, error) {
|
||||||
return db.GetEngine(ctx).Get(externalLoginUser)
|
return db.Get[ExternalLoginUser](ctx, builder.Eq{
|
||||||
|
"external_id": externalID,
|
||||||
|
"login_source_id": loginSourceID,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// LinkExternalToUser link the external user to the user
|
// LinkExternalToUser link the external user to the user
|
||||||
@@ -130,6 +133,12 @@ func RemoveAllAccountLinks(ctx context.Context, user *User) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RemoveExternalLoginByExternalID removes a specific external login link by its provider-side identifier.
|
||||||
|
func RemoveExternalLoginByExternalID(ctx context.Context, loginSourceID int64, externalID string) error {
|
||||||
|
_, err := db.GetEngine(ctx).Where("external_id=? AND login_source_id=?", externalID, loginSourceID).Delete(new(ExternalLoginUser))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// GetUserIDByExternalUserID get user id according to provider and userID
|
// GetUserIDByExternalUserID get user id according to provider and userID
|
||||||
func GetUserIDByExternalUserID(ctx context.Context, provider, userID string) (int64, error) {
|
func GetUserIDByExternalUserID(ctx context.Context, provider, userID string) (int64, error) {
|
||||||
var id int64
|
var id int64
|
||||||
|
|||||||
@@ -298,6 +298,9 @@ func toGitContext(input map[string]any) *model.GithubContext {
|
|||||||
return gitContext
|
return gitContext
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// workflowCallEvent is only fired by another workflow's `uses:`, so it is excluded from trigger detection.
|
||||||
|
const workflowCallEvent = "workflow_call"
|
||||||
|
|
||||||
func ParseRawOn(rawOn *yaml.Node) ([]*Event, error) {
|
func ParseRawOn(rawOn *yaml.Node) ([]*Event, error) {
|
||||||
switch rawOn.Kind {
|
switch rawOn.Kind {
|
||||||
case yaml.ScalarNode:
|
case yaml.ScalarNode:
|
||||||
@@ -306,6 +309,9 @@ func ParseRawOn(rawOn *yaml.Node) ([]*Event, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if val == workflowCallEvent {
|
||||||
|
return []*Event{}, nil
|
||||||
|
}
|
||||||
return []*Event{
|
return []*Event{
|
||||||
{Name: val},
|
{Name: val},
|
||||||
}, nil
|
}, nil
|
||||||
@@ -319,6 +325,9 @@ func ParseRawOn(rawOn *yaml.Node) ([]*Event, error) {
|
|||||||
for _, v := range val {
|
for _, v := range val {
|
||||||
switch t := v.(type) {
|
switch t := v.(type) {
|
||||||
case string:
|
case string:
|
||||||
|
if t == workflowCallEvent {
|
||||||
|
continue
|
||||||
|
}
|
||||||
res = append(res, &Event{Name: t})
|
res = append(res, &Event{Name: t})
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("invalid type %T", t)
|
return nil, fmt.Errorf("invalid type %T", t)
|
||||||
@@ -332,6 +341,9 @@ func ParseRawOn(rawOn *yaml.Node) ([]*Event, error) {
|
|||||||
}
|
}
|
||||||
res := make([]*Event, 0, len(events))
|
res := make([]*Event, 0, len(events))
|
||||||
for i, k := range events {
|
for i, k := range events {
|
||||||
|
if k == workflowCallEvent {
|
||||||
|
continue
|
||||||
|
}
|
||||||
v := triggers[i]
|
v := triggers[i]
|
||||||
switch v.Kind {
|
switch v.Kind {
|
||||||
case yaml.ScalarNode:
|
case yaml.ScalarNode:
|
||||||
|
|||||||
@@ -254,6 +254,53 @@ func TestParseRawOn(t *testing.T) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// `workflow_call` is only fired by another workflow's `uses:`, so ParseRawOn intentionally excludes it from trigger detection.
|
||||||
|
input: `on:
|
||||||
|
workflow_call:
|
||||||
|
inputs:
|
||||||
|
env:
|
||||||
|
type: string
|
||||||
|
required: true
|
||||||
|
outputs:
|
||||||
|
sha:
|
||||||
|
value: ${{ jobs.build.outputs.commit }}
|
||||||
|
secrets:
|
||||||
|
DEPLOY_KEY:
|
||||||
|
required: true
|
||||||
|
`,
|
||||||
|
result: []*Event{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Mixed: a workflow that is both callable AND triggered by push. Only the "push" event surfaces.
|
||||||
|
input: `on:
|
||||||
|
workflow_call:
|
||||||
|
inputs:
|
||||||
|
env:
|
||||||
|
type: string
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
`,
|
||||||
|
result: []*Event{
|
||||||
|
{
|
||||||
|
Name: "push",
|
||||||
|
acts: map[string][]string{"branches": {"main"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Scalar form: a purely reusable workflow has no event triggers.
|
||||||
|
input: "on: workflow_call",
|
||||||
|
result: []*Event{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Sequence form: `workflow_call` is excluded while sibling events are kept.
|
||||||
|
input: "on:\n - push\n - workflow_call\n - pull_request",
|
||||||
|
result: []*Event{
|
||||||
|
{Name: "push"},
|
||||||
|
{Name: "pull_request"},
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
for _, kase := range kases {
|
for _, kase := range kases {
|
||||||
t.Run(kase.input, func(t *testing.T) {
|
t.Run(kase.input, func(t *testing.T) {
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ func getLastCommitForPathsByCache(commitID, treePath string, paths []string, cac
|
|||||||
// GetLastCommitForPaths returns last commit information
|
// GetLastCommitForPaths returns last commit information
|
||||||
func GetLastCommitForPaths(ctx context.Context, commit *Commit, treePath string, paths []string) (map[string]*Commit, error) {
|
func GetLastCommitForPaths(ctx context.Context, commit *Commit, treePath string, paths []string) (map[string]*Commit, error) {
|
||||||
// We read backwards from the commit to obtain all of the commits
|
// We read backwards from the commit to obtain all of the commits
|
||||||
revs, err := WalkGitLog(ctx, commit.repo, commit, treePath, paths...)
|
revs, err := walkGitLog(ctx, commit.repo, commit, treePath, paths...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
//go:build !gogit
|
||||||
|
|
||||||
|
package git
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"code.gitea.io/gitea/modules/test"
|
||||||
|
"code.gitea.io/gitea/modules/util"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestEntries_GetCommitsInfo_ContextErr(t *testing.T) {
|
||||||
|
repo, err := OpenRepository(t.Context(), filepath.Join(testReposDir, "repo1_bare"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer repo.Close()
|
||||||
|
|
||||||
|
commit, err := repo.GetCommit("feaf4ba6bc635fec442f46ddd4512416ec43c2c2")
|
||||||
|
require.NoError(t, err)
|
||||||
|
entries, err := commit.Tree.ListEntries()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
countCommitInfosCommit := func(infos []CommitInfo) (nilCommits, nonNilCommits int) {
|
||||||
|
for _, info := range infos {
|
||||||
|
nilCommits += util.Iif(info.Commit == nil, 1, 0)
|
||||||
|
nonNilCommits += util.Iif(info.Commit != nil, 1, 0)
|
||||||
|
}
|
||||||
|
return nilCommits, nonNilCommits
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(t.Context())
|
||||||
|
defer test.MockVariableValue(&walkGitLogDebugBeforeNext)()
|
||||||
|
|
||||||
|
walkGitLogDebugBeforeNext = cancel
|
||||||
|
commitInfos, _, err := entries.GetCommitsInfo(ctx, "/any/repo-link", commit, "")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
nilCommits, nonNilCommits := countCommitInfosCommit(commitInfos)
|
||||||
|
assert.Equal(t, 0, nonNilCommits) // no commit info due to canceled (or deadline-exceeded) context
|
||||||
|
assert.Equal(t, 3, nilCommits)
|
||||||
|
|
||||||
|
walkGitLogDebugBeforeNext = nil
|
||||||
|
commitInfos, _, err = entries.GetCommitsInfo(t.Context(), "/any/repo-link", commit, "")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
nilCommits, nonNilCommits = countCommitInfosCommit(commitInfos)
|
||||||
|
assert.Equal(t, 3, nonNilCommits)
|
||||||
|
assert.Equal(t, 0, nilCommits)
|
||||||
|
}
|
||||||
@@ -445,6 +445,17 @@ func (c *Command) Start(ctx context.Context) (retErr error) {
|
|||||||
c.cmd.Stdout = c.cmdStdout
|
c.cmd.Stdout = c.cmdStdout
|
||||||
c.cmd.Stdin = c.cmdStdin
|
c.cmd.Stdin = c.cmdStdin
|
||||||
c.cmd.Stderr = c.cmdStderr
|
c.cmd.Stderr = c.cmdStderr
|
||||||
|
c.cmd.Cancel = func() error {
|
||||||
|
// Golang's default cmd.Cancel only calls Process.Kill(), but here we need to close the parent pipes together:
|
||||||
|
// * for some commands like "git --batch-xxx", Windows git might have 2 processes (a wrapper and a real git process)
|
||||||
|
// * on Windows, if parent process is killed (context canceled), the children process won't be killed, and the pipe handles are still open.
|
||||||
|
// * if we don't close the parent pipes here, the children process won't exit.
|
||||||
|
//
|
||||||
|
// There is no such problem on POSIX, while it won't make things worse by closing the parent pipes also on POSIX.
|
||||||
|
err := c.cmd.Process.Kill()
|
||||||
|
c.closePipeFiles(c.parentPipeFiles)
|
||||||
|
return err
|
||||||
|
}
|
||||||
return c.cmd.Start()
|
return c.cmd.Start()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ func (c *Commit) recursiveCache(ctx context.Context, tree *Tree, treePath string
|
|||||||
entryPaths[i] = entry.Name()
|
entryPaths[i] = entry.Name()
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = WalkGitLog(ctx, c.repo, c, treePath, entryPaths...)
|
_, err = walkGitLog(ctx, c.repo, c, treePath, entryPaths...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||||
// SPDX-License-Identifier: MIT
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
//go:build !gogit
|
||||||
|
|
||||||
package git
|
package git
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -18,10 +20,8 @@ import (
|
|||||||
"code.gitea.io/gitea/modules/log"
|
"code.gitea.io/gitea/modules/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
// LogNameStatusRepo opens git log --raw in the provided repo and returns a stdin pipe, a stdout reader and cancel function
|
// logNameStatusRepo opens git log --raw in the provided repo and returns a parser
|
||||||
func LogNameStatusRepo(ctx context.Context, repository, head, treepath string, paths ...string) (*bufio.Reader, func()) {
|
func logNameStatusRepo(ctx context.Context, repository, head, treepath string, paths ...string) *logNameStatusRepoParser {
|
||||||
// Lets also create a context so that we can absolutely ensure that the command should die when we're done
|
|
||||||
|
|
||||||
cmd := gitcmd.NewCommand()
|
cmd := gitcmd.NewCommand()
|
||||||
cmd.AddArguments("log", "--name-status", "-c", "--format=commit%x00%H %P%x00", "--parents", "--no-renames", "-t", "-z").AddDynamicArguments(head)
|
cmd.AddArguments("log", "--name-status", "-c", "--format=commit%x00%H %P%x00", "--parents", "--no-renames", "-t", "-z").AddDynamicArguments(head)
|
||||||
|
|
||||||
@@ -54,77 +54,62 @@ func LogNameStatusRepo(ctx context.Context, repository, head, treepath string, p
|
|||||||
ctx, ctxCancel := context.WithCancel(ctx)
|
ctx, ctxCancel := context.WithCancel(ctx)
|
||||||
go func() {
|
go func() {
|
||||||
err := cmd.WithDir(repository).RunWithStderr(ctx)
|
err := cmd.WithDir(repository).RunWithStderr(ctx)
|
||||||
if err != nil && !errors.Is(err, context.Canceled) {
|
if err != nil && !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) {
|
||||||
log.Error("Unable to run git command %v: %v", cmd.LogString(), err)
|
log.Error("Unable to run git command %v: %v", cmd.LogString(), err)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
bufReader := bufio.NewReaderSize(stdoutReader, 32*1024)
|
bufReader := bufio.NewReaderSize(stdoutReader, 32*1024)
|
||||||
|
return &logNameStatusRepoParser{
|
||||||
return bufReader, func() {
|
treepath: treepath,
|
||||||
ctxCancel()
|
paths: paths,
|
||||||
stdoutReaderClose()
|
rd: bufReader,
|
||||||
|
close: func() {
|
||||||
|
ctxCancel()
|
||||||
|
stdoutReaderClose()
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// LogNameStatusRepoParser parses a git log raw output from LogRawRepo
|
// logNameStatusRepoParser parses a git log raw output from LogRawRepo
|
||||||
type LogNameStatusRepoParser struct {
|
type logNameStatusRepoParser struct {
|
||||||
treepath string
|
treepath string
|
||||||
paths []string
|
paths []string
|
||||||
next []byte
|
next []byte
|
||||||
buffull bool
|
buffull bool
|
||||||
rd *bufio.Reader
|
rd *bufio.Reader
|
||||||
cancel func()
|
close func()
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewLogNameStatusRepoParser returns a new parser for a git log raw output
|
// logNameStatusCommitData represents a commit artifact from git log raw
|
||||||
func NewLogNameStatusRepoParser(ctx context.Context, repository, head, treepath string, paths ...string) *LogNameStatusRepoParser {
|
type logNameStatusCommitData struct {
|
||||||
rd, cancel := LogNameStatusRepo(ctx, repository, head, treepath, paths...)
|
|
||||||
return &LogNameStatusRepoParser{
|
|
||||||
treepath: treepath,
|
|
||||||
paths: paths,
|
|
||||||
rd: rd,
|
|
||||||
cancel: cancel,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// LogNameStatusCommitData represents a commit artefact from git log raw
|
|
||||||
type LogNameStatusCommitData struct {
|
|
||||||
CommitID string
|
CommitID string
|
||||||
ParentIDs []string
|
ParentIDs []string
|
||||||
Paths []bool
|
Paths []bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// Next returns the next LogStatusCommitData
|
// walkNext returns the next LogStatusCommitData
|
||||||
func (g *LogNameStatusRepoParser) Next(treepath string, paths2ids map[string]int, changed []bool, maxpathlen int) (*LogNameStatusCommitData, error) {
|
func (g *logNameStatusRepoParser) walkNext(treepath string, paths2ids map[string]int, changed []bool, maxpathlen int) (*logNameStatusCommitData, error) {
|
||||||
var err error
|
var err error
|
||||||
if len(g.next) == 0 {
|
if len(g.next) == 0 {
|
||||||
g.buffull = false
|
g.buffull = false
|
||||||
g.next, err = g.rd.ReadSlice('\x00')
|
g.next, err = g.rd.ReadSlice('\x00')
|
||||||
if err != nil {
|
switch {
|
||||||
switch err {
|
case errors.Is(err, bufio.ErrBufferFull):
|
||||||
case bufio.ErrBufferFull:
|
g.buffull = true
|
||||||
g.buffull = true
|
case err != nil:
|
||||||
case io.EOF:
|
return nil, err
|
||||||
return nil, nil //nolint:nilnil // return nil to signal EOF
|
|
||||||
default:
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ret := LogNameStatusCommitData{}
|
ret := logNameStatusCommitData{}
|
||||||
if bytes.Equal(g.next, []byte("commit\000")) {
|
if bytes.Equal(g.next, []byte("commit\000")) {
|
||||||
g.next, err = g.rd.ReadSlice('\x00')
|
g.next, err = g.rd.ReadSlice('\x00')
|
||||||
if err != nil {
|
switch {
|
||||||
switch err {
|
case errors.Is(err, bufio.ErrBufferFull):
|
||||||
case bufio.ErrBufferFull:
|
g.buffull = true
|
||||||
g.buffull = true
|
case err != nil:
|
||||||
case io.EOF:
|
return nil, err
|
||||||
return nil, nil //nolint:nilnil // return nil to signal EOF
|
|
||||||
default:
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -273,13 +258,10 @@ diffloop:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close closes the parser
|
var walkGitLogDebugBeforeNext func() // is used to simulate various edge git process cases
|
||||||
func (g *LogNameStatusRepoParser) Close() {
|
|
||||||
g.cancel()
|
|
||||||
}
|
|
||||||
|
|
||||||
// WalkGitLog walks the git log --name-status for the head commit in the provided treepath and files
|
// walkGitLog walks the git log --name-status for the head commit in the provided treepath and files
|
||||||
func WalkGitLog(ctx context.Context, repo *Repository, head *Commit, treepath string, paths ...string) (map[string]string, error) {
|
func walkGitLog(ctx context.Context, repo *Repository, head *Commit, treepath string, paths ...string) (map[string]string, error) {
|
||||||
headRef := head.ID.String()
|
headRef := head.ID.String()
|
||||||
|
|
||||||
tree, err := head.SubTree(treepath)
|
tree, err := head.SubTree(treepath)
|
||||||
@@ -322,11 +304,9 @@ func WalkGitLog(ctx context.Context, repo *Repository, head *Commit, treepath st
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
g := NewLogNameStatusRepoParser(ctx, repo.Path, head.ID.String(), treepath, paths...)
|
g := logNameStatusRepo(ctx, repo.Path, head.ID.String(), treepath, paths...)
|
||||||
// don't use defer g.Close() here as g may change its value - instead wrap in a func
|
// don't use defer g.cancel() here as g may change its value - instead wrap in a func
|
||||||
defer func() {
|
defer func() { g.close() }()
|
||||||
g.Close()
|
|
||||||
}()
|
|
||||||
|
|
||||||
results := make([]string, len(paths))
|
results := make([]string, len(paths))
|
||||||
remaining := len(paths)
|
remaining := len(paths)
|
||||||
@@ -340,25 +320,16 @@ func WalkGitLog(ctx context.Context, repo *Repository, head *Commit, treepath st
|
|||||||
|
|
||||||
heaploop:
|
heaploop:
|
||||||
for {
|
for {
|
||||||
select {
|
if walkGitLogDebugBeforeNext != nil {
|
||||||
case <-ctx.Done():
|
walkGitLogDebugBeforeNext()
|
||||||
if ctx.Err() == context.DeadlineExceeded {
|
|
||||||
break heaploop
|
|
||||||
}
|
|
||||||
g.Close()
|
|
||||||
return nil, ctx.Err()
|
|
||||||
default:
|
|
||||||
}
|
}
|
||||||
current, err := g.Next(treepath, path2idx, changed, maxpathlen)
|
current, err := g.walkNext(treepath, path2idx, changed, maxpathlen)
|
||||||
if err != nil {
|
if ctx.Err() != nil {
|
||||||
if errors.Is(err, context.DeadlineExceeded) {
|
break heaploop // context is either canceled or deadline exceeded - break the loop and return what we have so far
|
||||||
break heaploop
|
} else if errors.Is(err, io.EOF) {
|
||||||
}
|
break heaploop // reached to the end of log output
|
||||||
g.Close()
|
} else if err != nil {
|
||||||
return nil, err
|
return nil, err // other unknown errors
|
||||||
}
|
|
||||||
if current == nil {
|
|
||||||
break heaploop
|
|
||||||
}
|
}
|
||||||
parentRemaining.Remove(current.CommitID)
|
parentRemaining.Remove(current.CommitID)
|
||||||
for i, found := range current.Paths {
|
for i, found := range current.Paths {
|
||||||
@@ -395,14 +366,14 @@ heaploop:
|
|||||||
if remaining <= nextRestart {
|
if remaining <= nextRestart {
|
||||||
commitSinceNextRestart++
|
commitSinceNextRestart++
|
||||||
if 4*commitSinceNextRestart > 3*commitSinceLastEmptyParent {
|
if 4*commitSinceNextRestart > 3*commitSinceLastEmptyParent {
|
||||||
g.Close()
|
|
||||||
remainingPaths := make([]string, 0, len(paths))
|
remainingPaths := make([]string, 0, len(paths))
|
||||||
for i, pth := range paths {
|
for i, pth := range paths {
|
||||||
if results[i] == "" {
|
if results[i] == "" {
|
||||||
remainingPaths = append(remainingPaths, pth)
|
remainingPaths = append(remainingPaths, pth)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
g = NewLogNameStatusRepoParser(ctx, repo.Path, lastEmptyParent, treepath, remainingPaths...)
|
g.close()
|
||||||
|
g = logNameStatusRepo(ctx, repo.Path, lastEmptyParent, treepath, remainingPaths...)
|
||||||
parentRemaining = make(container.Set[string])
|
parentRemaining = make(container.Set[string])
|
||||||
nextRestart = (remaining * 3) / 4
|
nextRestart = (remaining * 3) / 4
|
||||||
continue heaploop
|
continue heaploop
|
||||||
@@ -410,7 +381,6 @@ heaploop:
|
|||||||
}
|
}
|
||||||
parentRemaining.AddMultiple(current.ParentIDs...)
|
parentRemaining.AddMultiple(current.ParentIDs...)
|
||||||
}
|
}
|
||||||
g.Close()
|
|
||||||
|
|
||||||
resultsMap := map[string]string{}
|
resultsMap := map[string]string{}
|
||||||
for i, pth := range paths {
|
for i, pth := range paths {
|
||||||
+1
-1
@@ -161,7 +161,7 @@ func (ref RefName) ShortName() string {
|
|||||||
if ref.IsFor() {
|
if ref.IsFor() {
|
||||||
return ref.ForBranchName()
|
return ref.ForBranchName()
|
||||||
}
|
}
|
||||||
return string(ref) // usually it is a commit ID
|
return string(ref) // usually it is a commit ID, or "HEAD"
|
||||||
}
|
}
|
||||||
|
|
||||||
// RefGroup returns the group type of the reference
|
// RefGroup returns the group type of the reference
|
||||||
|
|||||||
@@ -121,6 +121,9 @@ func Clone(ctx context.Context, from, to string, opts CloneRepoOptions) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
cmd := gitcmd.NewCommand().AddArguments("clone")
|
cmd := gitcmd.NewCommand().AddArguments("clone")
|
||||||
|
// Never follow HTTP redirects: no clone caller needs them, and a remote redirecting to an
|
||||||
|
// otherwise-blocked address would be an SSRF vector (e.g. migrating from an attacker URL).
|
||||||
|
cmd.AddArguments("-c", "http.followRedirects=false")
|
||||||
if opts.SkipTLSVerify {
|
if opts.SkipTLSVerify {
|
||||||
cmd.AddArguments("-c", "http.sslVerify=false")
|
cmd.AddArguments("-c", "http.sslVerify=false")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"code.gitea.io/gitea/modules/git/gitcmd"
|
"code.gitea.io/gitea/modules/git/gitcmd"
|
||||||
|
"code.gitea.io/gitea/modules/setting"
|
||||||
"code.gitea.io/gitea/modules/util"
|
"code.gitea.io/gitea/modules/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -86,8 +87,11 @@ func (repo *Repository) UnstableGuessRefByShortName(shortName string) RefName {
|
|||||||
commit, err := repo.GetCommit(shortName)
|
commit, err := repo.GetCommit(shortName)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
commitIDString := commit.ID.String()
|
commitIDString := commit.ID.String()
|
||||||
if strings.HasPrefix(commitIDString, shortName) {
|
// Make sure the short name is either a partial commit ID, or the symbolic HEAD ref.
|
||||||
|
if strings.HasPrefix(commitIDString, shortName) || shortName == "HEAD" {
|
||||||
return RefName(commitIDString)
|
return RefName(commitIDString)
|
||||||
|
} else {
|
||||||
|
setting.PanicInDevOrTesting("abuse of UnstableGuessRefByShortName, queried %s, got %s", shortName, commitIDString)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
@@ -53,3 +53,18 @@ func TestRepository_GetRefsFiltered(t *testing.T) {
|
|||||||
assert.Equal(t, "3ad28a9149a2864384548f3d17ed7f38014c9e8a", refs[1].Object.String())
|
assert.Equal(t, "3ad28a9149a2864384548f3d17ed7f38014c9e8a", refs[1].Object.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRepository_UnstableGuessRefByShortName(t *testing.T) {
|
||||||
|
bareRepo1Path := filepath.Join(testReposDir, "repo1_bare")
|
||||||
|
bareRepo1, err := OpenRepository(t.Context(), bareRepo1Path)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
defer bareRepo1.Close()
|
||||||
|
|
||||||
|
headCommit, err := bareRepo1.GetCommit("HEAD")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, RefName(headCommit.ID.String()), bareRepo1.UnstableGuessRefByShortName("HEAD"))
|
||||||
|
assert.Equal(t, RefName(headCommit.ID.String()), bareRepo1.UnstableGuessRefByShortName(headCommit.ID.String()[:8]))
|
||||||
|
assert.Equal(t, RefNameFromBranch("master"), bareRepo1.UnstableGuessRefByShortName("master"))
|
||||||
|
assert.Empty(t, bareRepo1.UnstableGuessRefByShortName("NotExisting"))
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,7 +4,10 @@
|
|||||||
package git
|
package git
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -19,3 +22,23 @@ func TestRepoIsEmpty(t *testing.T) {
|
|||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.True(t, isEmpty)
|
assert.True(t, isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestCloneRefusesRedirects ensures Clone never follows HTTP redirects, so a remote
|
||||||
|
// cannot redirect to an otherwise-blocked address (SSRF, e.g. during migration).
|
||||||
|
func TestCloneRefusesRedirects(t *testing.T) {
|
||||||
|
var targetHit atomic.Bool
|
||||||
|
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
targetHit.Store(true)
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
}))
|
||||||
|
defer target.Close()
|
||||||
|
|
||||||
|
redirect := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Redirect(w, r, target.URL+r.URL.Path, http.StatusFound)
|
||||||
|
}))
|
||||||
|
defer redirect.Close()
|
||||||
|
|
||||||
|
err := Clone(t.Context(), redirect.URL, filepath.Join(t.TempDir(), "dst"), CloneRepoOptions{})
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.False(t, targetHit.Load(), "git must not follow the redirect to the target")
|
||||||
|
}
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ type contextKey struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// RepositoryFromContextOrOpen attempts to get the repository from the context or just opens it
|
// RepositoryFromContextOrOpen attempts to get the repository from the context or just opens it
|
||||||
// The caller must call "defer gitRepo.Close()"
|
// The caller must call Closer.Close()
|
||||||
func RepositoryFromContextOrOpen(ctx context.Context, repo Repository) (*git.Repository, io.Closer, error) {
|
func RepositoryFromContextOrOpen(ctx context.Context, repo Repository) (*git.Repository, io.Closer, error) {
|
||||||
reqCtx := reqctx.FromContext(ctx)
|
reqCtx := reqctx.FromContext(ctx)
|
||||||
if reqCtx != nil {
|
if reqCtx != nil {
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ func TestFile(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "tags.py",
|
name: "tags.py",
|
||||||
code: "<>",
|
code: "<>",
|
||||||
want: lines(`<span class="o"><</span><span class="o">></span>`),
|
want: lines(`<span class="o"><></span>`),
|
||||||
lexerName: "Python",
|
lexerName: "Python",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -102,7 +102,7 @@ c=2
|
|||||||
<span class="n">def</span><span class="p">:</span>\n
|
<span class="n">def</span><span class="p">:</span>\n
|
||||||
<span class="n">a</span><span class="o">=</span><span class="mi">1</span>\n
|
<span class="n">a</span><span class="o">=</span><span class="mi">1</span>\n
|
||||||
\n
|
\n
|
||||||
<span class="n">b</span><span class="o">=</span><span class="sa"></span><span class="s1">'</span><span class="s1">'</span>\n
|
<span class="n">b</span><span class="o">=</span><span class="s1">''</span>\n
|
||||||
\n
|
\n
|
||||||
<span class="n">c</span><span class="o">=</span><span class="mi">2</span>`,
|
<span class="n">c</span><span class="o">=</span><span class="mi">2</span>`,
|
||||||
),
|
),
|
||||||
@@ -114,6 +114,18 @@ c=2
|
|||||||
want: []template.HTML{"<span class=\"c1\">--\n</span>", `<span class="k">SELECT</span>`},
|
want: []template.HTML{"<span class=\"c1\">--\n</span>", `<span class="k">SELECT</span>`},
|
||||||
lexerName: "SQL",
|
lexerName: "SQL",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "test.http",
|
||||||
|
code: `HTTP/1.0 400 Bad request
|
||||||
|
Content-Type: text/html
|
||||||
|
|
||||||
|
<html></html>`,
|
||||||
|
want: lines(`<span class="kr">HTTP</span><span class="o">/</span><span class="m">1.0</span> <span class="m">400</span> <span class="ne">Bad request</span>\n
|
||||||
|
<span class="n">Content-Type</span><span class="o">:</span> <span class="l">text/html</span>\n
|
||||||
|
\n
|
||||||
|
<span class="p"><</span><span class="nt">html</span><span class="p">></</span><span class="nt">html</span><span class="p">></span>`),
|
||||||
|
lexerName: "HTTP",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
|
|||||||
@@ -288,24 +288,24 @@ func detectChromaLexerWithAnalyze(fileName, lang string, code []byte) chroma.Lex
|
|||||||
|
|
||||||
// if lang is provided, and it matches a lexer, use it directly
|
// if lang is provided, and it matches a lexer, use it directly
|
||||||
if byLang {
|
if byLang {
|
||||||
return lexer
|
return chroma.Coalesce(lexer)
|
||||||
}
|
}
|
||||||
|
|
||||||
// if a lexer is detected and there is no conflict for the file extension, use it directly
|
// if a lexer is detected and there is no conflict for the file extension, use it directly
|
||||||
fileExt := path.Ext(fileName)
|
fileExt := path.Ext(fileName)
|
||||||
_, hasConflicts := chromaLexers().conflictingExtLangMap[fileExt]
|
_, hasConflicts := chromaLexers().conflictingExtLangMap[fileExt]
|
||||||
if !hasConflicts && lexer != lexers.Fallback {
|
if !hasConflicts && lexer != lexers.Fallback {
|
||||||
return lexer
|
return chroma.Coalesce(lexer)
|
||||||
}
|
}
|
||||||
|
|
||||||
// try to detect language by content, for best guessing for the language
|
// try to detect language by content, for best guessing for the language
|
||||||
// when using "code" to detect, analyze.GetCodeLanguage is slow, it iterates many rules to detect language from content
|
// when using "code" to detect, analyze.GetCodeLanguage is slow, it iterates many rules to detect language from content
|
||||||
analyzedLanguage := analyze.GetCodeLanguage(fileName, code)
|
analyzedLanguage := analyze.GetCodeLanguage(fileName, code)
|
||||||
lexer = DetectChromaLexerByFileName(fileName, analyzedLanguage)
|
lexer, _ = detectChromaLexerByFileName(fileName, analyzedLanguage)
|
||||||
if lexer == lexers.Fallback {
|
if lexer == lexers.Fallback {
|
||||||
if analyzedLanguage != enry.OtherLanguage {
|
if analyzedLanguage != enry.OtherLanguage {
|
||||||
log.Warn("No chroma lexer found for enry detected language: %s (file: %s), need to fix the language mapping between enry and chroma.", analyzedLanguage, fileName)
|
log.Warn("No chroma lexer found for enry detected language: %s (file: %s), need to fix the language mapping between enry and chroma.", analyzedLanguage, fileName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return lexer
|
return chroma.Coalesce(lexer)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
)
|
)
|
||||||
|
|
||||||
// HostMatchList is used to check if a host or IP is in a list.
|
// HostMatchList is used to check if a host or IP is in a list.
|
||||||
@@ -23,10 +24,61 @@ type HostMatchList struct {
|
|||||||
ipNets []*net.IPNet
|
ipNets []*net.IPNet
|
||||||
}
|
}
|
||||||
|
|
||||||
// MatchBuiltinExternal A valid non-private unicast IP, all hosts on public internet are matched
|
// MatchBuiltinExternal A valid global-unicast IP that is neither private (see MatchBuiltinPrivate)
|
||||||
|
// nor a reserved special-purpose range (see reservedIPNets); i.e. a routable host on the public internet.
|
||||||
const MatchBuiltinExternal = "external"
|
const MatchBuiltinExternal = "external"
|
||||||
|
|
||||||
// MatchBuiltinPrivate RFC 1918 (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16) and RFC 4193 (FC00::/7). Also called LAN/Intranet.
|
// reservedIPNets are special-purpose ranges that net.IP.IsPrivate omits but that must not be
|
||||||
|
// treated as public/external destinations (CGNAT, cloud metadata, IPv6 transition, etc.). We layer
|
||||||
|
// these on top of net.IP.IsPrivate (RFC 1918 / RFC 4193) so future additions to Go's IsPrivate are
|
||||||
|
// picked up automatically, while still covering the ranges it leaves out; otherwise the default
|
||||||
|
// allow-list would let authenticated users reach cloud metadata, internal, and IPv6 transition
|
||||||
|
// endpoints (SSRF), and a "private" block-list would fail to catch them.
|
||||||
|
var reservedIPNets = sync.OnceValue(func() []*net.IPNet {
|
||||||
|
var nets []*net.IPNet
|
||||||
|
for _, cidr := range []string{
|
||||||
|
// IPv4
|
||||||
|
"100.64.0.0/10", // RFC 6598 Carrier-Grade NAT
|
||||||
|
"168.63.129.16/32", // Azure WireServer metadata endpoint
|
||||||
|
"192.0.0.0/24", // RFC 6890 IETF protocol assignments
|
||||||
|
"192.0.2.0/24", // RFC 5737 TEST-NET-1
|
||||||
|
"192.88.99.0/24", // RFC 7526 6to4 relay anycast (deprecated)
|
||||||
|
"198.18.0.0/15", // RFC 2544 benchmarking
|
||||||
|
"198.51.100.0/24", // RFC 5737 TEST-NET-2
|
||||||
|
"203.0.113.0/24", // RFC 5737 TEST-NET-3
|
||||||
|
// IPv6
|
||||||
|
"100::/64", // RFC 6666 discard-only
|
||||||
|
"64:ff9b::/96", // RFC 6052 NAT64 (can embed IPv4 such as 169.254.169.254)
|
||||||
|
"64:ff9b:1::/48", // RFC 8215 local-use NAT64
|
||||||
|
"2001::/32", // RFC 4380 Teredo tunneling (embeds IPv4)
|
||||||
|
"2001:10::/28", // RFC 4843 ORCHID (deprecated)
|
||||||
|
"2001:20::/28", // RFC 7343 ORCHIDv2
|
||||||
|
"2001:db8::/32", // RFC 3849 documentation
|
||||||
|
"2002::/16", // RFC 3056 6to4 (embeds IPv4)
|
||||||
|
} {
|
||||||
|
_, ipNet, err := net.ParseCIDR(cidr)
|
||||||
|
if err != nil {
|
||||||
|
panic("hostmatcher: invalid reserved CIDR " + cidr + ": " + err.Error())
|
||||||
|
}
|
||||||
|
nets = append(nets, ipNet)
|
||||||
|
}
|
||||||
|
return nets
|
||||||
|
})
|
||||||
|
|
||||||
|
// isReservedIP reports whether ip falls in reserved special-purpose
|
||||||
|
// range (see reservedIPNets) that must not be considered a public/external destination.
|
||||||
|
func isReservedIP(ip net.IP) bool {
|
||||||
|
for _, ipNet := range reservedIPNets() {
|
||||||
|
if ipNet.Contains(ip) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// MatchBuiltinPrivate RFC 1918 (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16) and RFC 4193 (FC00::/7),
|
||||||
|
// plus the reserved special-purpose ranges in reservedIPNets (CGNAT, NAT64, cloud metadata, etc.).
|
||||||
|
// Also called LAN/Intranet.
|
||||||
const MatchBuiltinPrivate = "private"
|
const MatchBuiltinPrivate = "private"
|
||||||
|
|
||||||
// MatchBuiltinLoopback 127.0.0.0/8 for IPv4 and ::1/128 for IPv6, localhost is included.
|
// MatchBuiltinLoopback 127.0.0.0/8 for IPv4 and ::1/128 for IPv6, localhost is included.
|
||||||
@@ -98,18 +150,22 @@ func (hl *HostMatchList) checkPattern(host string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func (hl *HostMatchList) checkIP(ip net.IP) bool {
|
// matchesIP determines if the given IP matches any of the configured rules
|
||||||
|
func (hl *HostMatchList) matchesIP(ip net.IP) bool {
|
||||||
if slices.Contains(hl.patterns, "*") {
|
if slices.Contains(hl.patterns, "*") {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
for _, builtin := range hl.builtins {
|
for _, builtin := range hl.builtins {
|
||||||
switch builtin {
|
switch builtin {
|
||||||
case MatchBuiltinExternal:
|
case MatchBuiltinExternal:
|
||||||
if ip.IsGlobalUnicast() && !ip.IsPrivate() {
|
// External address must be a global unicast, must not be in reserved range and must not be in private range
|
||||||
|
if ip.IsGlobalUnicast() && !isReservedIP(ip) && !ip.IsPrivate() {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
case MatchBuiltinPrivate:
|
case MatchBuiltinPrivate:
|
||||||
if ip.IsPrivate() {
|
// Private address must be global unicast, must not be in range we explicitly exclude for security reasons
|
||||||
|
// and must be in private range
|
||||||
|
if ip.IsGlobalUnicast() && !isReservedIP(ip) && ip.IsPrivate() {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
case MatchBuiltinLoopback:
|
case MatchBuiltinLoopback:
|
||||||
@@ -140,7 +196,7 @@ func (hl *HostMatchList) MatchHostName(host string) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if ip := net.ParseIP(hostname); ip != nil {
|
if ip := net.ParseIP(hostname); ip != nil {
|
||||||
return hl.checkIP(ip)
|
return hl.matchesIP(ip)
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -151,7 +207,7 @@ func (hl *HostMatchList) MatchIPAddr(ip net.IP) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
host := ip.String() // nil-safe, we will get "<nil>" if ip is nil
|
host := ip.String() // nil-safe, we will get "<nil>" if ip is nil
|
||||||
return hl.checkPattern(host) || hl.checkIP(ip)
|
return hl.checkPattern(host) || hl.matchesIP(ip)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MatchHostOrIP checks if the host or IP matches an allow/deny(block) list
|
// MatchHostOrIP checks if the host or IP matches an allow/deny(block) list
|
||||||
|
|||||||
@@ -159,3 +159,60 @@ func TestHostOrIPMatchesList(t *testing.T) {
|
|||||||
}
|
}
|
||||||
test(cases)
|
test(cases)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestReservedRanges ensures special-purpose ranges that net.IP.IsPrivate misses are kept out of the
|
||||||
|
// "external" allow-list (the default for webhook delivery and repository migrations) and folded into
|
||||||
|
// the "private" block-list, so they cannot be used for SSRF to metadata/internal endpoints.
|
||||||
|
func TestReservedRanges(t *testing.T) {
|
||||||
|
external := ParseHostMatchList("", "external")
|
||||||
|
private := ParseHostMatchList("", "private")
|
||||||
|
|
||||||
|
// legitimate public destinations: external, not private
|
||||||
|
for _, ip := range []string{"8.8.8.8", "1.1.1.1", "2001:4860:4860::8888", "1000::1"} {
|
||||||
|
addr := net.ParseIP(ip)
|
||||||
|
assert.Truef(t, external.MatchIPAddr(addr), "public ip %s should be external", ip)
|
||||||
|
assert.Falsef(t, private.MatchIPAddr(addr), "public ip %s should not be private", ip)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RFC 1918 / RFC 4193 private ranges (now folded into privateIPNets instead of net.IP.IsPrivate):
|
||||||
|
// not external, blockable as private. Includes range edges to guard the CIDR boundaries.
|
||||||
|
for _, ip := range []string{
|
||||||
|
"10.0.0.0", "10.255.255.255", // 10.0.0.0/8
|
||||||
|
"172.16.0.0", "172.31.255.255", // 172.16.0.0/12
|
||||||
|
"192.168.0.0", "192.168.255.255", // 192.168.0.0/16
|
||||||
|
"fc00::", "fdff:ffff:ffff:ffff:ffff:ffff:ffff:ffff", // fc00::/7
|
||||||
|
} {
|
||||||
|
addr := net.ParseIP(ip)
|
||||||
|
assert.Falsef(t, external.MatchIPAddr(addr), "private ip %s must not be external", ip)
|
||||||
|
assert.Truef(t, private.MatchIPAddr(addr), "private ip %s should match private block-list", ip)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 172.32.0.0 is just outside 172.16.0.0/12: a public destination, not private
|
||||||
|
if addr := net.ParseIP("172.32.0.0"); assert.NotNil(t, addr) {
|
||||||
|
assert.True(t, external.MatchIPAddr(addr), "172.32.0.0 should be external")
|
||||||
|
assert.False(t, private.MatchIPAddr(addr), "172.32.0.0 should not be private")
|
||||||
|
}
|
||||||
|
|
||||||
|
// reserved ranges that IsPrivate does not cover: not external, but blockable as private
|
||||||
|
for _, ip := range []string{
|
||||||
|
"100.64.0.1", // CGNAT
|
||||||
|
"100.127.255.254", // CGNAT
|
||||||
|
"168.63.129.16", // Azure WireServer
|
||||||
|
"192.0.2.1", // TEST-NET-1
|
||||||
|
"198.18.0.1", // benchmarking
|
||||||
|
"198.51.100.1", // TEST-NET-2
|
||||||
|
"203.0.113.1", // TEST-NET-3
|
||||||
|
"169.254.169.254", // Cloud metadata
|
||||||
|
"192.88.99.1", // 6to4 relay anycast
|
||||||
|
"64:ff9b::1", // NAT64
|
||||||
|
"64:ff9b::a9fe:a9fe", // NAT64 embedding 169.254.169.254
|
||||||
|
"2001::1", // Teredo
|
||||||
|
"2002::1", // 6to4
|
||||||
|
"2001:db8::1", // documentation
|
||||||
|
"fe80::1", // link local address
|
||||||
|
} {
|
||||||
|
addr := net.ParseIP(ip)
|
||||||
|
assert.Falsef(t, external.MatchIPAddr(addr), "reserved ip %s must not be external", ip)
|
||||||
|
assert.Falsef(t, private.MatchIPAddr(addr), "reserved ip %s should match private block-list", ip)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+18
-2
@@ -5,9 +5,12 @@ package lfs
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
|
||||||
|
"code.gitea.io/gitea/modules/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DownloadCallback gets called for every requested LFS object to process its content
|
// DownloadCallback gets called for every requested LFS object to process its content
|
||||||
@@ -23,10 +26,23 @@ type Client interface {
|
|||||||
Upload(ctx context.Context, objects []Pointer, callback UploadCallback) error
|
Upload(ctx context.Context, objects []Pointer, callback UploadCallback) error
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewClient creates a LFS client
|
// newClient creates a LFS client
|
||||||
func NewClient(endpoint *url.URL, httpTransport *http.Transport) Client {
|
func newClient(endpoint *url.URL, httpTransport *http.Transport) Client {
|
||||||
if endpoint.Scheme == "file" {
|
if endpoint.Scheme == "file" {
|
||||||
return newFilesystemClient(endpoint)
|
return newFilesystemClient(endpoint)
|
||||||
}
|
}
|
||||||
return newHTTPClient(endpoint, httpTransport)
|
return newHTTPClient(endpoint, httpTransport)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewClientFromEndpoint creates a LFS client after resolving its endpoint.
|
||||||
|
func NewClientFromEndpoint(cloneurl, lfsurl string, httpTransport *http.Transport) (Client, error) {
|
||||||
|
endpoint := DetermineEndpoint(cloneurl, lfsurl)
|
||||||
|
if endpoint == nil {
|
||||||
|
source := cloneurl
|
||||||
|
if lfsurl != "" {
|
||||||
|
source = lfsurl
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("unable to determine LFS endpoint from %q", util.SanitizeCredentialURLs(source))
|
||||||
|
}
|
||||||
|
return newClient(endpoint, httpTransport), nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,10 +12,21 @@ import (
|
|||||||
|
|
||||||
func TestNewClient(t *testing.T) {
|
func TestNewClient(t *testing.T) {
|
||||||
u, _ := url.Parse("file:///test")
|
u, _ := url.Parse("file:///test")
|
||||||
c := NewClient(u, nil)
|
c := newClient(u, nil)
|
||||||
assert.IsType(t, &FilesystemClient{}, c)
|
assert.IsType(t, &FilesystemClient{}, c)
|
||||||
|
|
||||||
u, _ = url.Parse("https://test.com/lfs")
|
u, _ = url.Parse("https://test.com/lfs")
|
||||||
c = NewClient(u, nil)
|
c = newClient(u, nil)
|
||||||
assert.IsType(t, &HTTPClient{}, c)
|
assert.IsType(t, &HTTPClient{}, c)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNewClientFromEndpoint(t *testing.T) {
|
||||||
|
client, err := NewClientFromEndpoint("ssh://git@example.com/owner/repo.git", "", nil)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotNil(t, client)
|
||||||
|
|
||||||
|
client, err = NewClientFromEndpoint("ftp://example.com/owner/repo.git", "", nil)
|
||||||
|
assert.Nil(t, client)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "unable to determine LFS endpoint")
|
||||||
|
}
|
||||||
|
|||||||
+13
-1
@@ -10,6 +10,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
giturl "code.gitea.io/gitea/modules/git/url"
|
||||||
"code.gitea.io/gitea/modules/log"
|
"code.gitea.io/gitea/modules/log"
|
||||||
"code.gitea.io/gitea/modules/util"
|
"code.gitea.io/gitea/modules/util"
|
||||||
)
|
)
|
||||||
@@ -44,15 +45,20 @@ func endpointFromCloneURL(rawurl string) *url.URL {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func endpointFromURL(rawurl string) *url.URL {
|
func endpointFromURL(rawurl string) *url.URL {
|
||||||
|
if rawurl == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
if strings.HasPrefix(rawurl, "/") {
|
if strings.HasPrefix(rawurl, "/") {
|
||||||
return endpointFromLocalPath(rawurl)
|
return endpointFromLocalPath(rawurl)
|
||||||
}
|
}
|
||||||
|
|
||||||
u, err := url.Parse(rawurl)
|
gitURL, err := giturl.ParseGitURL(rawurl)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("lfs.endpointFromUrl: %v", err)
|
log.Error("lfs.endpointFromUrl: %v", err)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
u := gitURL.URL
|
||||||
|
|
||||||
switch u.Scheme {
|
switch u.Scheme {
|
||||||
case "http", "https":
|
case "http", "https":
|
||||||
@@ -60,6 +66,12 @@ func endpointFromURL(rawurl string) *url.URL {
|
|||||||
case "git":
|
case "git":
|
||||||
u.Scheme = "https"
|
u.Scheme = "https"
|
||||||
return u
|
return u
|
||||||
|
case "ssh", "git+ssh":
|
||||||
|
u.Scheme = "https" // is it possible http?
|
||||||
|
u.Host = u.Hostname() // remove ssh port if any
|
||||||
|
u.Path = "/" + strings.TrimPrefix(u.Path, "/")
|
||||||
|
u.User = nil
|
||||||
|
return u
|
||||||
case "file":
|
case "file":
|
||||||
return u
|
return u
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -64,6 +64,24 @@ func TestDetermineEndpoint(t *testing.T) {
|
|||||||
lfsurl: "git://gitlfs.com/repo",
|
lfsurl: "git://gitlfs.com/repo",
|
||||||
expected: str2url("https://gitlfs.com/repo"),
|
expected: str2url("https://gitlfs.com/repo"),
|
||||||
},
|
},
|
||||||
|
// case 7
|
||||||
|
{
|
||||||
|
cloneurl: "ssh://git@git.com/owner/repo.git",
|
||||||
|
lfsurl: "",
|
||||||
|
expected: str2url("https://git.com/owner/repo.git/info/lfs"),
|
||||||
|
},
|
||||||
|
// case 8
|
||||||
|
{
|
||||||
|
cloneurl: "git@git.com:owner/repo.git",
|
||||||
|
lfsurl: "",
|
||||||
|
expected: str2url("https://git.com/owner/repo.git/info/lfs"),
|
||||||
|
},
|
||||||
|
// case 9
|
||||||
|
{
|
||||||
|
cloneurl: "",
|
||||||
|
lfsurl: "ssh://git@gitlfs.com/owner/repo.git/info/lfs",
|
||||||
|
expected: str2url("https://gitlfs.com/owner/repo.git/info/lfs"),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for n, c := range cases {
|
for n, c := range cases {
|
||||||
|
|||||||
+21
-3
@@ -173,16 +173,25 @@ var emojiProcessors = []processor{
|
|||||||
emojiProcessor,
|
emojiProcessor,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isBareURLSubject reports whether the (HTML-escaped) commit subject content
|
||||||
|
// is entirely a single URL, ignoring leading/trailing whitespace.
|
||||||
|
func isBareURLSubject(content string) bool {
|
||||||
|
s := strings.TrimSpace(html.UnescapeString(content))
|
||||||
|
if s == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
m := common.GlobalVars().LinkRegex.FindStringIndex(s)
|
||||||
|
return m != nil && m[0] == 0 && m[1] == len(s)
|
||||||
|
}
|
||||||
|
|
||||||
// PostProcessCommitMessageSubject will use the same logic as PostProcess and
|
// PostProcessCommitMessageSubject will use the same logic as PostProcess and
|
||||||
// PostProcessCommitMessage, but will disable the shortLinkProcessor and
|
// PostProcessCommitMessage, but will disable the shortLinkProcessor and
|
||||||
// emailAddressProcessor, will add a defaultLinkProcessor if defaultLink is set,
|
// emailAddressProcessor, and wraps the whole subject in defaultLink.
|
||||||
// which changes every text node into a link to the passed default link.
|
|
||||||
func PostProcessCommitMessageSubject(ctx *RenderContext, defaultLink, content string) (string, error) {
|
func PostProcessCommitMessageSubject(ctx *RenderContext, defaultLink, content string) (string, error) {
|
||||||
procs := []processor{
|
procs := []processor{
|
||||||
fullIssuePatternProcessor,
|
fullIssuePatternProcessor,
|
||||||
comparePatternProcessor,
|
comparePatternProcessor,
|
||||||
fullHashPatternProcessor,
|
fullHashPatternProcessor,
|
||||||
linkProcessor,
|
|
||||||
mentionProcessor,
|
mentionProcessor,
|
||||||
issueIndexPatternProcessor,
|
issueIndexPatternProcessor,
|
||||||
commitCrossReferencePatternProcessor,
|
commitCrossReferencePatternProcessor,
|
||||||
@@ -190,6 +199,15 @@ func PostProcessCommitMessageSubject(ctx *RenderContext, defaultLink, content st
|
|||||||
emojiShortCodeProcessor,
|
emojiShortCodeProcessor,
|
||||||
emojiProcessor,
|
emojiProcessor,
|
||||||
}
|
}
|
||||||
|
// When the whole subject is a bare URL, linkProcessor would turn it into
|
||||||
|
// a competing anchor and hijack the surrounding defaultLink wrapper, leaving
|
||||||
|
// the subject visually unclickable. Match GitHub: render such subjects as
|
||||||
|
// plain text inside defaultLink. Partial URLs inside larger text still become
|
||||||
|
// their own links (nested anchors aren't legal HTML, so the outer defaultLink
|
||||||
|
// naturally breaks on that span, same as on GitHub).
|
||||||
|
if !isBareURLSubject(content) {
|
||||||
|
procs = append(procs, linkProcessor)
|
||||||
|
}
|
||||||
procs = append(procs, func(ctx *RenderContext, node *html.Node) {
|
procs = append(procs, func(ctx *RenderContext, node *html.Node) {
|
||||||
ch := &html.Node{Parent: node, Type: html.TextNode, Data: node.Data}
|
ch := &html.Node{Parent: node, Type: html.TextNode, Data: node.Data}
|
||||||
node.Type = html.ElementNode
|
node.Type = html.ElementNode
|
||||||
|
|||||||
@@ -146,15 +146,26 @@ func ParseControlFile(r io.Reader) (*Package, error) {
|
|||||||
var depends strings.Builder
|
var depends strings.Builder
|
||||||
var control strings.Builder
|
var control strings.Builder
|
||||||
|
|
||||||
s := bufio.NewScanner(io.TeeReader(r, &control))
|
// https://www.debian.org/doc/debian-policy/ch-controlfields.html#syntax-of-control-files
|
||||||
|
s := bufio.NewScanner(r)
|
||||||
for s.Scan() {
|
for s.Scan() {
|
||||||
line := s.Text()
|
line := s.Text()
|
||||||
|
|
||||||
trimmed := strings.TrimSpace(line)
|
trimmed := strings.TrimSpace(line)
|
||||||
if trimmed == "" {
|
if trimmed == "" {
|
||||||
continue
|
// A binary package control file holds exactly one stanza. Stop at the
|
||||||
|
// blank line that terminates it, otherwise a crafted control file could
|
||||||
|
// smuggle additional stanzas (with attacker-chosen Filename/Package
|
||||||
|
// fields) into the generated repository "Packages" index.
|
||||||
|
if control.Len() == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
|
control.WriteString(line)
|
||||||
|
control.WriteByte('\n')
|
||||||
|
|
||||||
if line[0] == ' ' || line[0] == '\t' {
|
if line[0] == ' ' || line[0] == '\t' {
|
||||||
switch key {
|
switch key {
|
||||||
case "Description":
|
case "Description":
|
||||||
|
|||||||
@@ -184,4 +184,19 @@ func TestParseControlFile(t *testing.T) {
|
|||||||
assert.NotNil(t, p)
|
assert.NotNil(t, p)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("SingleStanzaOnly", func(t *testing.T) {
|
||||||
|
// A control file with a trailing stanza must not leak the extra fields into
|
||||||
|
// p.Control, otherwise buildPackagesIndices would emit a second package entry
|
||||||
|
// with an attacker-chosen Filename into the repository "Packages" index.
|
||||||
|
content := bytes.NewBufferString("Package: realpkg\nVersion: 1.0.0\nArchitecture: amd64\nMaintainer: a <a@b.c>\nDescription: real\n\nPackage: openssl\nVersion: 99.0\nArchitecture: amd64\nFilename: pool/main/o/openssl/evil.deb\nDescription: spoofed\n")
|
||||||
|
|
||||||
|
p, err := ParseControlFile(content)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotNil(t, p)
|
||||||
|
assert.Equal(t, "realpkg", p.Name)
|
||||||
|
assert.Equal(t, "1.0.0", p.Version)
|
||||||
|
assert.NotContains(t, p.Control, "openssl")
|
||||||
|
assert.NotContains(t, p.Control, "evil.deb")
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -140,6 +140,18 @@ com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit
|
|||||||
assert.EqualValues(t, expected, newTestRenderUtils(t).RenderCommitMessageLinkSubject(testInput(), "https://example.com/link", mockRepo))
|
assert.EqualValues(t, expected, newTestRenderUtils(t).RenderCommitMessageLinkSubject(testInput(), "https://example.com/link", mockRepo))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("RenderCommitMessageLinkSubjectURLOnly", func(t *testing.T) {
|
||||||
|
// a bare URL in the subject must not hijack the default link
|
||||||
|
expected := `<a href="https://example.com/link" class="muted">https://example.com/file.bin</a>`
|
||||||
|
assert.EqualValues(t, expected, newTestRenderUtils(t).RenderCommitMessageLinkSubject("https://example.com/file.bin", "https://example.com/link", mockRepo))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("RenderCommitMessageLinkSubjectPartialURL", func(t *testing.T) {
|
||||||
|
// a URL embedded in larger subject text still becomes its own link
|
||||||
|
expected := `<a href="https://example.com/link" class="muted">see </a><a href="https://example.com/x" data-markdown-generated-content="">https://example.com/x</a><a href="https://example.com/link" class="muted"> here</a>`
|
||||||
|
assert.EqualValues(t, expected, newTestRenderUtils(t).RenderCommitMessageLinkSubject("see https://example.com/x here", "https://example.com/link", mockRepo))
|
||||||
|
})
|
||||||
|
|
||||||
t.Run("RenderIssueTitle", func(t *testing.T) {
|
t.Run("RenderIssueTitle", func(t *testing.T) {
|
||||||
defer test.MockVariableValue(&markup.RenderBehaviorForTesting.DisableAdditionalAttributes, true)()
|
defer test.MockVariableValue(&markup.RenderBehaviorForTesting.DisableAdditionalAttributes, true)()
|
||||||
expected := ` space @mention-user<SPACE><SPACE>
|
expected := ` space @mention-user<SPACE><SPACE>
|
||||||
|
|||||||
+1
-1
@@ -78,7 +78,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint-community/eslint-plugin-eslint-comments": "4.7.1",
|
"@eslint-community/eslint-plugin-eslint-comments": "4.7.1",
|
||||||
"@eslint/json": "1.2.0",
|
"@eslint/json": "1.2.0",
|
||||||
"@playwright/test": "1.59.0",
|
"@playwright/test": "1.60.0",
|
||||||
"@stylistic/eslint-plugin": "5.10.0",
|
"@stylistic/eslint-plugin": "5.10.0",
|
||||||
"@stylistic/stylelint-plugin": "5.1.0",
|
"@stylistic/stylelint-plugin": "5.1.0",
|
||||||
"@types/codemirror": "5.60.17",
|
"@types/codemirror": "5.60.17",
|
||||||
|
|||||||
Generated
+13
-13
@@ -238,8 +238,8 @@ importers:
|
|||||||
specifier: 1.2.0
|
specifier: 1.2.0
|
||||||
version: 1.2.0
|
version: 1.2.0
|
||||||
'@playwright/test':
|
'@playwright/test':
|
||||||
specifier: 1.59.0
|
specifier: 1.60.0
|
||||||
version: 1.59.0
|
version: 1.60.0
|
||||||
'@stylistic/eslint-plugin':
|
'@stylistic/eslint-plugin':
|
||||||
specifier: 5.10.0
|
specifier: 5.10.0
|
||||||
version: 5.10.0(eslint@10.1.0(jiti@2.6.1))
|
version: 5.10.0(eslint@10.1.0(jiti@2.6.1))
|
||||||
@@ -1076,8 +1076,8 @@ packages:
|
|||||||
resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==}
|
resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==}
|
||||||
engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
|
engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
|
||||||
|
|
||||||
'@playwright/test@1.59.0':
|
'@playwright/test@1.60.0':
|
||||||
resolution: {integrity: sha512-TOA5sTLd49rTDaZpYpvCQ9hGefHQq/OYOyCVnGqS2mjMfX+lGZv2iddIJd0I48cfxqSPttS9S3OuLKyylHcO1w==}
|
resolution: {integrity: sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
@@ -3610,13 +3610,13 @@ packages:
|
|||||||
pkg-types@1.3.1:
|
pkg-types@1.3.1:
|
||||||
resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==}
|
resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==}
|
||||||
|
|
||||||
playwright-core@1.59.0:
|
playwright-core@1.60.0:
|
||||||
resolution: {integrity: sha512-PW/X/IoZ6BMUUy8rpwHEZ8Kc0IiLIkgKYGNFaMs5KmQhcfLILNx9yCQD0rnWeWfz1PNeqcFP1BsihQhDOBCwZw==}
|
resolution: {integrity: sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
playwright@1.59.0:
|
playwright@1.60.0:
|
||||||
resolution: {integrity: sha512-wihGScriusvATUxmhfENxg0tj1vHEFeIwxlnPFKQTOQVd7aG08mUfvvniRP/PtQOC+2Bs52kBOC/Up1jTXeIbw==}
|
resolution: {integrity: sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
@@ -5216,9 +5216,9 @@ snapshots:
|
|||||||
|
|
||||||
'@pkgr/core@0.2.9': {}
|
'@pkgr/core@0.2.9': {}
|
||||||
|
|
||||||
'@playwright/test@1.59.0':
|
'@playwright/test@1.60.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
playwright: 1.59.0
|
playwright: 1.60.0
|
||||||
|
|
||||||
'@popperjs/core@2.11.8': {}
|
'@popperjs/core@2.11.8': {}
|
||||||
|
|
||||||
@@ -7974,11 +7974,11 @@ snapshots:
|
|||||||
mlly: 1.8.2
|
mlly: 1.8.2
|
||||||
pathe: 2.0.3
|
pathe: 2.0.3
|
||||||
|
|
||||||
playwright-core@1.59.0: {}
|
playwright-core@1.60.0: {}
|
||||||
|
|
||||||
playwright@1.59.0:
|
playwright@1.60.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
playwright-core: 1.59.0
|
playwright-core: 1.60.0
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
fsevents: 2.3.2
|
fsevents: 2.3.2
|
||||||
|
|
||||||
|
|||||||
@@ -270,6 +270,15 @@ func (s *Service) UpdateLog(
|
|||||||
rows = req.Msg.Rows[ack-req.Msg.Index:]
|
rows = req.Msg.Rows[ack-req.Msg.Index:]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ack a re-sent finalize idempotently. Appending new rows past the seal errors.
|
||||||
|
if task.LogInStorage {
|
||||||
|
if len(rows) > 0 {
|
||||||
|
return nil, status.Errorf(codes.AlreadyExists, "log file has been archived")
|
||||||
|
}
|
||||||
|
res.Msg.AckIndex = ack
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
// Bail unless we have new rows or a NoMore to finalize. Even with
|
// Bail unless we have new rows or a NoMore to finalize. Even with
|
||||||
// NoMore, bail when the runner has outrun the server — archiving a
|
// NoMore, bail when the runner has outrun the server — archiving a
|
||||||
// log with a gap is worse than asking it to retry.
|
// log with a gap is worse than asking it to retry.
|
||||||
@@ -278,10 +287,6 @@ func (s *Service) UpdateLog(
|
|||||||
return res, nil
|
return res, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if task.LogInStorage {
|
|
||||||
return nil, status.Errorf(codes.AlreadyExists, "log file has been archived")
|
|
||||||
}
|
|
||||||
|
|
||||||
// WriteLogs is called even with no rows: with offset==0 it bootstraps
|
// WriteLogs is called even with no rows: with offset==0 it bootstraps
|
||||||
// an empty DBFS file so TransferLogs below has something to read when
|
// an empty DBFS file so TransferLogs below has something to read when
|
||||||
// the runner finalizes a task that produced no log output.
|
// the runner finalizes a task that produced no log output.
|
||||||
|
|||||||
+73
-35
@@ -504,41 +504,79 @@ func reqOrgOwnership() func(ctx *context.APIContext) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// reqTeamMembership user should be an team member, or a site admin
|
// reqOrgVisible requires the organization to be visible to the doer, or a site admin
|
||||||
|
func reqOrgVisible() func(ctx *context.APIContext) {
|
||||||
|
return func(ctx *context.APIContext) {
|
||||||
|
if ctx.Org.Organization == nil {
|
||||||
|
setting.PanicInDevOrTesting("reqOrgVisible: unprepared context")
|
||||||
|
ctx.APIErrorInternal(errors.New("reqOrgVisible: unprepared context"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !organization.HasOrgOrUserVisible(ctx, ctx.Org.Organization.AsUser(), ctx.Doer) {
|
||||||
|
ctx.APIErrorNotFound()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func teamAccessPrivileged(ctx *context.APIContext) (orgID int64, privileged, ok bool) {
|
||||||
|
if ctx.IsUserSiteAdmin() {
|
||||||
|
return 0, true, true
|
||||||
|
}
|
||||||
|
if ctx.Org.Team == nil {
|
||||||
|
setting.PanicInDevOrTesting("teamAccess: unprepared context")
|
||||||
|
ctx.APIErrorInternal(errors.New("teamAccess: unprepared context"))
|
||||||
|
return 0, false, false
|
||||||
|
}
|
||||||
|
|
||||||
|
orgID = ctx.Org.Team.OrgID
|
||||||
|
isOwner, err := organization.IsOrganizationOwner(ctx, orgID, ctx.Doer.ID)
|
||||||
|
if err != nil {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return 0, false, false
|
||||||
|
} else if isOwner {
|
||||||
|
return orgID, true, true
|
||||||
|
}
|
||||||
|
|
||||||
|
isTeamMember, err := organization.IsTeamMember(ctx, orgID, ctx.Org.Team.ID, ctx.Doer.ID)
|
||||||
|
if err != nil {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return 0, false, false
|
||||||
|
}
|
||||||
|
return orgID, isTeamMember, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func denyNonTeamMember(ctx *context.APIContext, orgID int64) {
|
||||||
|
isOrgMember, err := organization.IsOrganizationMember(ctx, orgID, ctx.Doer.ID)
|
||||||
|
if err != nil {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
} else if isOrgMember {
|
||||||
|
ctx.APIError(http.StatusForbidden, "Must be a team member")
|
||||||
|
} else {
|
||||||
|
ctx.APIErrorNotFound()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// reqTeamReadAccess allows callers who can list the team to read its metadata.
|
||||||
|
// Not sufficient for mutations — use reqOrgOwnership() or reqTeamMembership() for those.
|
||||||
|
func reqTeamReadAccess() func(ctx *context.APIContext) {
|
||||||
|
return func(ctx *context.APIContext) {
|
||||||
|
orgID, privileged, ok := teamAccessPrivileged(ctx)
|
||||||
|
if !ok || privileged {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
denyNonTeamMember(ctx, orgID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// reqTeamMembership user should be a team member, or a site admin
|
||||||
func reqTeamMembership() func(ctx *context.APIContext) {
|
func reqTeamMembership() func(ctx *context.APIContext) {
|
||||||
return func(ctx *context.APIContext) {
|
return func(ctx *context.APIContext) {
|
||||||
if ctx.IsUserSiteAdmin() {
|
orgID, privileged, ok := teamAccessPrivileged(ctx)
|
||||||
return
|
if !ok || privileged {
|
||||||
}
|
|
||||||
if ctx.Org.Team == nil {
|
|
||||||
setting.PanicInDevOrTesting("reqTeamMembership: unprepared context")
|
|
||||||
ctx.APIErrorInternal(errors.New("reqTeamMembership: unprepared context"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
orgID := ctx.Org.Team.OrgID
|
|
||||||
isOwner, err := organization.IsOrganizationOwner(ctx, orgID, ctx.Doer.ID)
|
|
||||||
if err != nil {
|
|
||||||
ctx.APIErrorInternal(err)
|
|
||||||
return
|
|
||||||
} else if isOwner {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if isTeamMember, err := organization.IsTeamMember(ctx, orgID, ctx.Org.Team.ID, ctx.Doer.ID); err != nil {
|
|
||||||
ctx.APIErrorInternal(err)
|
|
||||||
return
|
|
||||||
} else if !isTeamMember {
|
|
||||||
isOrgMember, err := organization.IsOrganizationMember(ctx, orgID, ctx.Doer.ID)
|
|
||||||
if err != nil {
|
|
||||||
ctx.APIErrorInternal(err)
|
|
||||||
} else if isOrgMember {
|
|
||||||
ctx.APIError(http.StatusForbidden, "Must be a team member")
|
|
||||||
} else {
|
|
||||||
ctx.APIErrorNotFound()
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
denyNonTeamMember(ctx, orgID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1662,7 +1700,7 @@ func Routes() *web.Router {
|
|||||||
m.Combo("/{id}").Get(reqToken(), org.GetLabel).
|
m.Combo("/{id}").Get(reqToken(), org.GetLabel).
|
||||||
Patch(reqToken(), reqOrgOwnership(), bind(api.EditLabelOption{}), org.EditLabel).
|
Patch(reqToken(), reqOrgOwnership(), bind(api.EditLabelOption{}), org.EditLabel).
|
||||||
Delete(reqToken(), reqOrgOwnership(), org.DeleteLabel)
|
Delete(reqToken(), reqOrgOwnership(), org.DeleteLabel)
|
||||||
})
|
}, reqOrgVisible())
|
||||||
m.Group("/hooks", func() {
|
m.Group("/hooks", func() {
|
||||||
m.Combo("").Get(org.ListHooks).
|
m.Combo("").Get(org.ListHooks).
|
||||||
Post(bind(api.CreateHookOption{}), org.CreateHook)
|
Post(bind(api.CreateHookOption{}), org.CreateHook)
|
||||||
@@ -1699,12 +1737,12 @@ func Routes() *web.Router {
|
|||||||
m.Group("/repos", func() {
|
m.Group("/repos", func() {
|
||||||
m.Get("", reqToken(), org.GetTeamRepos)
|
m.Get("", reqToken(), org.GetTeamRepos)
|
||||||
m.Combo("/{org}/{reponame}").
|
m.Combo("/{org}/{reponame}").
|
||||||
Put(reqToken(), org.AddTeamRepository).
|
Put(reqToken(), reqTeamMembership(), org.AddTeamRepository).
|
||||||
Delete(reqToken(), org.RemoveTeamRepository).
|
Delete(reqToken(), reqTeamMembership(), org.RemoveTeamRepository).
|
||||||
Get(reqToken(), org.GetTeamRepo)
|
Get(reqToken(), org.GetTeamRepo)
|
||||||
})
|
})
|
||||||
m.Get("/activities/feeds", org.ListTeamActivityFeeds)
|
m.Get("/activities/feeds", org.ListTeamActivityFeeds)
|
||||||
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization), orgAssignment(false, true), reqToken(), reqTeamMembership(), checkTokenPublicOnly())
|
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization), orgAssignment(false, true), reqToken(), reqTeamReadAccess(), checkTokenPublicOnly())
|
||||||
|
|
||||||
m.Group("/admin", func() {
|
m.Group("/admin", func() {
|
||||||
m.Group("/cron", func() {
|
m.Group("/cron", func() {
|
||||||
|
|||||||
@@ -1074,6 +1074,8 @@ func ActionsDispatchWorkflow(ctx *context.APIContext) {
|
|||||||
ctx.APIError(http.StatusNotFound, err)
|
ctx.APIError(http.StatusNotFound, err)
|
||||||
} else if errors.Is(err, util.ErrPermissionDenied) {
|
} else if errors.Is(err, util.ErrPermissionDenied) {
|
||||||
ctx.APIError(http.StatusForbidden, err)
|
ctx.APIError(http.StatusForbidden, err)
|
||||||
|
} else if errors.Is(err, util.ErrInvalidArgument) {
|
||||||
|
ctx.APIError(http.StatusUnprocessableEntity, err)
|
||||||
} else {
|
} else {
|
||||||
ctx.APIErrorInternal(err)
|
ctx.APIErrorInternal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1268,6 +1268,9 @@ func MergeUpstream(ctx *context.APIContext) {
|
|||||||
} else if errors.Is(err, util.ErrNotExist) {
|
} else if errors.Is(err, util.ErrNotExist) {
|
||||||
ctx.APIError(http.StatusNotFound, err)
|
ctx.APIError(http.StatusNotFound, err)
|
||||||
return
|
return
|
||||||
|
} else if errors.Is(err, util.ErrPermissionDenied) {
|
||||||
|
ctx.APIError(http.StatusForbidden, err.Error())
|
||||||
|
return
|
||||||
}
|
}
|
||||||
ctx.APIErrorInternal(err)
|
ctx.APIErrorInternal(err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -55,7 +55,8 @@ func ListForks(ctx *context.APIContext) {
|
|||||||
// "404":
|
// "404":
|
||||||
// "$ref": "#/responses/notFound"
|
// "$ref": "#/responses/notFound"
|
||||||
|
|
||||||
forks, total, err := repo_service.FindForks(ctx, ctx.Repo.Repository, ctx.Doer, utils.GetListOptions(ctx))
|
listOptions := utils.GetListOptions(ctx)
|
||||||
|
forks, total, err := repo_service.FindForks(ctx, ctx.Repo.Repository, ctx.Doer, listOptions)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.APIErrorInternal(err)
|
ctx.APIErrorInternal(err)
|
||||||
return
|
return
|
||||||
@@ -79,6 +80,7 @@ func ListForks(ctx *context.APIContext) {
|
|||||||
apiForks[i] = convert.ToRepo(ctx, fork, permission)
|
apiForks[i] = convert.ToRepo(ctx, fork, permission)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ctx.SetLinkHeader(total, listOptions.PageSize)
|
||||||
ctx.SetTotalCountHeader(total)
|
ctx.SetTotalCountHeader(total)
|
||||||
ctx.JSON(http.StatusOK, apiForks)
|
ctx.JSON(http.StatusOK, apiForks)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,7 +95,8 @@ func ListTrackedTimes(ctx *context.APIContext) {
|
|||||||
if qUser != "" {
|
if qUser != "" {
|
||||||
user, err := user_model.GetUserByName(ctx, qUser)
|
user, err := user_model.GetUserByName(ctx, qUser)
|
||||||
if user_model.IsErrUserNotExist(err) {
|
if user_model.IsErrUserNotExist(err) {
|
||||||
ctx.APIError(http.StatusNotFound, err)
|
ctx.APIError(http.StatusNotFound, err.Error())
|
||||||
|
return
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
ctx.APIErrorInternal(err)
|
ctx.APIErrorInternal(err)
|
||||||
return
|
return
|
||||||
@@ -523,7 +524,8 @@ func ListTrackedTimesByRepository(ctx *context.APIContext) {
|
|||||||
if qUser != "" {
|
if qUser != "" {
|
||||||
user, err := user_model.GetUserByName(ctx, qUser)
|
user, err := user_model.GetUserByName(ctx, qUser)
|
||||||
if user_model.IsErrUserNotExist(err) {
|
if user_model.IsErrUserNotExist(err) {
|
||||||
ctx.APIError(http.StatusNotFound, err)
|
ctx.APIError(http.StatusNotFound, err.Error())
|
||||||
|
return
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
ctx.APIErrorInternal(err)
|
ctx.APIErrorInternal(err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -4,7 +4,9 @@
|
|||||||
package common
|
package common
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
actions_model "code.gitea.io/gitea/models/actions"
|
actions_model "code.gitea.io/gitea/models/actions"
|
||||||
@@ -50,6 +52,9 @@ func DownloadActionsRunJobLogs(ctx *context.Base, ctxRepo *repo_model.Repository
|
|||||||
|
|
||||||
reader, err := actions.OpenLogs(ctx, task.LogInStorage, task.LogFilename)
|
reader, err := actions.OpenLogs(ctx, task.LogInStorage, task.LogFilename)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if errors.Is(err, fs.ErrNotExist) {
|
||||||
|
return util.NewNotExistErrorf("logs not found")
|
||||||
|
}
|
||||||
return fmt.Errorf("OpenLogs: %w", err)
|
return fmt.Errorf("OpenLogs: %w", err)
|
||||||
}
|
}
|
||||||
defer reader.Close()
|
defer reader.Close()
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ func HookPostReceive(ctx *gitea_context.PrivateContext) {
|
|||||||
repo *repo_model.Repository
|
repo *repo_model.Repository
|
||||||
gitRepo *git.Repository
|
gitRepo *git.Repository
|
||||||
)
|
)
|
||||||
defer gitRepo.Close() // it's safe to call Close on a nil pointer
|
defer func() { _ = gitRepo.Close() }() // it's safe to call Close on a nil pointer, but it needs to use the latest value
|
||||||
|
|
||||||
updates := make([]*repo_module.PushUpdateOptions, 0, len(opts.OldCommitIDs))
|
updates := make([]*repo_module.PushUpdateOptions, 0, len(opts.OldCommitIDs))
|
||||||
wasEmpty := false
|
wasEmpty := false
|
||||||
|
|||||||
@@ -40,9 +40,6 @@ type preReceiveContext struct {
|
|||||||
canCreatePullRequest bool
|
canCreatePullRequest bool
|
||||||
checkedCanCreatePullRequest bool
|
checkedCanCreatePullRequest bool
|
||||||
|
|
||||||
canWriteCode bool
|
|
||||||
checkedCanWriteCode bool
|
|
||||||
|
|
||||||
protectedTags []*git_model.ProtectedTag
|
protectedTags []*git_model.ProtectedTag
|
||||||
gotProtectedTags bool
|
gotProtectedTags bool
|
||||||
|
|
||||||
@@ -50,24 +47,36 @@ type preReceiveContext struct {
|
|||||||
|
|
||||||
opts *private.HookOptions
|
opts *private.HookOptions
|
||||||
|
|
||||||
branchName string
|
// this context should only contain shared variables, mutable variables like "current branch name" shouldn't be put here
|
||||||
|
canWriteCodeUnitCached *bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// CanWriteCode returns true if pusher can write code
|
func (ctx *preReceiveContext) canWriteCodeUnit() bool {
|
||||||
func (ctx *preReceiveContext) CanWriteCode() bool {
|
if ctx.canWriteCodeUnitCached == nil {
|
||||||
if !ctx.checkedCanWriteCode {
|
var canWrite bool
|
||||||
if !ctx.loadPusherAndPermission() {
|
if ctx.loadPusherAndPermission() {
|
||||||
return false
|
canWrite = ctx.userPerm.CanWrite(unit.TypeCode) || ctx.deployKeyAccessMode >= perm_model.AccessModeWrite
|
||||||
}
|
}
|
||||||
ctx.canWriteCode = issues_model.CanMaintainerWriteToBranch(ctx, ctx.userPerm, ctx.branchName, ctx.user) || ctx.deployKeyAccessMode >= perm_model.AccessModeWrite
|
ctx.canWriteCodeUnitCached = &canWrite
|
||||||
ctx.checkedCanWriteCode = true
|
|
||||||
}
|
}
|
||||||
return ctx.canWriteCode
|
return *ctx.canWriteCodeUnitCached
|
||||||
}
|
}
|
||||||
|
|
||||||
// AssertCanWriteCode returns true if pusher can write code
|
// canWriteCodeRef returns true if pusher can write to the code ref (branch/tag/commit)
|
||||||
func (ctx *preReceiveContext) AssertCanWriteCode() bool {
|
func (ctx *preReceiveContext) canWriteCodeRef(refFullName git.RefName) bool {
|
||||||
if !ctx.CanWriteCode() {
|
if ctx.canWriteCodeUnit() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// then check whether if the pusher is a maintainer who can write the PR author's head repo branch
|
||||||
|
if !refFullName.IsBranch() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return issues_model.CanMaintainerWriteToBranch(ctx, ctx.userPerm, refFullName.BranchName(), ctx.user)
|
||||||
|
}
|
||||||
|
|
||||||
|
// assertCanWriteRef returns true if pusher can write to the code ref, otherwise it responds with 403 Forbidden and returns false
|
||||||
|
func (ctx *preReceiveContext) assertCanWriteRef(refFullName git.RefName) bool {
|
||||||
|
if !ctx.canWriteCodeRef(refFullName) {
|
||||||
if ctx.Written() {
|
if ctx.Written() {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -129,7 +138,7 @@ func HookPreReceive(ctx *gitea_context.PrivateContext) {
|
|||||||
case git.DefaultFeatures().SupportProcReceive && refFullName.IsFor():
|
case git.DefaultFeatures().SupportProcReceive && refFullName.IsFor():
|
||||||
preReceiveFor(ourCtx, refFullName)
|
preReceiveFor(ourCtx, refFullName)
|
||||||
default:
|
default:
|
||||||
ourCtx.AssertCanWriteCode()
|
ourCtx.assertCanWriteRef(refFullName)
|
||||||
}
|
}
|
||||||
if ctx.Written() {
|
if ctx.Written() {
|
||||||
return
|
return
|
||||||
@@ -141,9 +150,8 @@ func HookPreReceive(ctx *gitea_context.PrivateContext) {
|
|||||||
|
|
||||||
func preReceiveBranch(ctx *preReceiveContext, oldCommitID, newCommitID string, refFullName git.RefName) {
|
func preReceiveBranch(ctx *preReceiveContext, oldCommitID, newCommitID string, refFullName git.RefName) {
|
||||||
branchName := refFullName.BranchName()
|
branchName := refFullName.BranchName()
|
||||||
ctx.branchName = branchName
|
|
||||||
|
|
||||||
if !ctx.AssertCanWriteCode() {
|
if !ctx.assertCanWriteRef(refFullName) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -404,7 +412,7 @@ func preReceiveBranch(ctx *preReceiveContext, oldCommitID, newCommitID string, r
|
|||||||
}
|
}
|
||||||
|
|
||||||
func preReceiveTag(ctx *preReceiveContext, refFullName git.RefName) {
|
func preReceiveTag(ctx *preReceiveContext, refFullName git.RefName) {
|
||||||
if !ctx.AssertCanWriteCode() {
|
if !ctx.assertCanWriteRef(refFullName) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package private
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
issues_model "code.gitea.io/gitea/models/issues"
|
||||||
|
"code.gitea.io/gitea/models/perm/access"
|
||||||
|
repo_model "code.gitea.io/gitea/models/repo"
|
||||||
|
"code.gitea.io/gitea/models/unittest"
|
||||||
|
"code.gitea.io/gitea/modules/git"
|
||||||
|
"code.gitea.io/gitea/services/contexttest"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestPreReceiveCanWriteCodePerBranch ensures the maintainer-edit write grant is evaluated against
|
||||||
|
// the exact ref being pushed on every call, derived from that ref rather than shared mutable state.
|
||||||
|
// Otherwise a per-branch grant (an open PR with "allow edits from maintainers") could be batched
|
||||||
|
// together with a protected branch or a tag to escalate into full repository write.
|
||||||
|
func TestPreReceiveCanWriteCodePerBranch(t *testing.T) {
|
||||||
|
require.NoError(t, unittest.PrepareTestDatabase())
|
||||||
|
|
||||||
|
baseRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 10})
|
||||||
|
headRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 11})
|
||||||
|
require.NoError(t, baseRepo.LoadOwner(t.Context()))
|
||||||
|
require.NoError(t, headRepo.LoadOwner(t.Context()))
|
||||||
|
|
||||||
|
// An open PR from the head repo owner, with maintainer edits allowed: this grants the base
|
||||||
|
// repo owner write access to exactly this head branch and nothing else.
|
||||||
|
pr := &issues_model.PullRequest{
|
||||||
|
Issue: &issues_model.Issue{
|
||||||
|
RepoID: baseRepo.ID,
|
||||||
|
PosterID: headRepo.OwnerID,
|
||||||
|
},
|
||||||
|
HeadRepoID: headRepo.ID,
|
||||||
|
BaseRepoID: baseRepo.ID,
|
||||||
|
HeadBranch: "granted-branch",
|
||||||
|
BaseBranch: "master",
|
||||||
|
AllowMaintainerEdit: true,
|
||||||
|
}
|
||||||
|
require.NoError(t, issues_model.NewPullRequest(t.Context(), baseRepo, pr.Issue, nil, nil, pr))
|
||||||
|
|
||||||
|
// The pusher is the base repo owner (the maintainer) with only read access on the head repo.
|
||||||
|
maintainer := baseRepo.Owner
|
||||||
|
headPerm, err := access.GetIndividualUserRepoPermission(t.Context(), headRepo, maintainer)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
mockCtx, _ := contexttest.MockPrivateContext(t, "/")
|
||||||
|
ctx := &preReceiveContext{
|
||||||
|
PrivateContext: mockCtx,
|
||||||
|
loadedPusher: true,
|
||||||
|
user: maintainer,
|
||||||
|
userPerm: headPerm,
|
||||||
|
}
|
||||||
|
|
||||||
|
// The granted branch must be writable...
|
||||||
|
assert.True(t, ctx.canWriteCodeRef(git.RefNameFromBranch("granted-branch")))
|
||||||
|
|
||||||
|
// ...but another branch in the same push must NOT inherit that grant.
|
||||||
|
assert.False(t, ctx.canWriteCodeRef(git.RefNameFromBranch("master")))
|
||||||
|
|
||||||
|
// ...and a tag sharing the granted branch's name must NOT inherit it either: the grant is
|
||||||
|
// scoped to PR head branches, so a non-branch ref can never match it. (A tag ref already
|
||||||
|
// yields an empty branch name, so this guards the per-ref evaluation, not the IsBranch check.)
|
||||||
|
assert.False(t, ctx.canWriteCodeRef(git.RefNameFromTag("granted-branch")))
|
||||||
|
}
|
||||||
@@ -58,14 +58,14 @@ func TwoFactorPost(ctx *context.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate the passcode with the stored TOTP secret.
|
// Validate the passcode and atomically consume it to prevent reuse/replay.
|
||||||
ok, err := twofa.ValidateTOTP(form.Passcode)
|
ok, err := twofa.ValidateAndConsumeTOTP(ctx, form.Passcode)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.ServerError("UserSignIn", err)
|
ctx.ServerError("UserSignIn", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if ok && twofa.LastUsedPasscode != form.Passcode {
|
if ok {
|
||||||
remember := ctx.Session.Get("twofaRemember").(bool)
|
remember := ctx.Session.Get("twofaRemember").(bool)
|
||||||
u, err := user_model.GetUserByID(ctx, id)
|
u, err := user_model.GetUserByID(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -81,12 +81,6 @@ func TwoFactorPost(ctx *context.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
twofa.LastUsedPasscode = form.Passcode
|
|
||||||
if err = auth.UpdateTwoFactor(ctx, twofa); err != nil {
|
|
||||||
ctx.ServerError("UserSignIn", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = ctx.Session.Set(session.KeyUserHasTwoFactorAuth, true)
|
_ = ctx.Session.Set(session.KeyUserHasTwoFactorAuth, true)
|
||||||
handleSignIn(ctx, u, remember)
|
handleSignIn(ctx, u, remember)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -368,9 +368,21 @@ func handleOAuth2SignIn(ctx *context.Context, authSource *auth.Source, u *user_m
|
|||||||
|
|
||||||
opts := &user_service.UpdateOptions{}
|
opts := &user_service.UpdateOptions{}
|
||||||
|
|
||||||
// Reactivate user if they are deactivated
|
// HINT: OAUTH-AUTO-SYNC-USER-ACTIVATION: see services/auth/source/oauth2/source_sync.go
|
||||||
|
// Reactivate user only if they were disabled by the OAuth2 auto sync cron (invalid_grant),
|
||||||
|
// which clears AccessToken/RefreshToken/ExpiresAt on the ExternalLoginUser row
|
||||||
|
// An admin-disabled user has no such signature, so we leave IsActive alone
|
||||||
|
// and let verifyAuthWithOptions route them through the prohibit-login / activate page.
|
||||||
if !u.IsActive {
|
if !u.IsActive {
|
||||||
opts.IsActive = optional.Some(true)
|
extLogin, hasExt, err := user_model.GetExternalLogin(ctx, authSource.ID, gothUser.UserID)
|
||||||
|
if err != nil {
|
||||||
|
ctx.ServerError("GetExternalLogin", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
isDisabledByAutoSync := hasExt && extLogin.RefreshToken == ""
|
||||||
|
if isDisabledByAutoSync {
|
||||||
|
opts.IsActive = optional.Some(true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update GroupClaims
|
// Update GroupClaims
|
||||||
@@ -514,17 +526,33 @@ func oAuth2UserLoginCallback(ctx *context.Context, authSource *auth.Source, requ
|
|||||||
}
|
}
|
||||||
|
|
||||||
// search in external linked users
|
// search in external linked users
|
||||||
externalLoginUser := &user_model.ExternalLoginUser{
|
externalLoginUser, hasUser, err := user_model.GetExternalLogin(ctx, authSource.ID, gothUser.UserID)
|
||||||
ExternalID: gothUser.UserID,
|
|
||||||
LoginSourceID: authSource.ID,
|
|
||||||
}
|
|
||||||
hasUser, err = user_model.GetExternalLogin(request.Context(), externalLoginUser)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, goth.User{}, err
|
return nil, goth.User{}, err
|
||||||
}
|
}
|
||||||
if hasUser {
|
if hasUser {
|
||||||
user, err = user_model.GetUserByID(request.Context(), externalLoginUser.UserID)
|
user, err = user_model.GetUserByID(ctx, externalLoginUser.UserID)
|
||||||
return user, gothUser, err
|
if err != nil && !user_model.IsErrUserNotExist(err) {
|
||||||
|
return nil, goth.User{}, err
|
||||||
|
}
|
||||||
|
if err == nil && user.IsIndividual() {
|
||||||
|
return user, gothUser, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// The external login record is stale: the linked user no longer exists, or it exists but is
|
||||||
|
// not an individual user (only individual users can sign in, so a link pointing at an
|
||||||
|
// organization, bot or remote user can never resolve). Remove it so the next sign-in can
|
||||||
|
// relink the external account to the correct user. Nothing is lost, because the link is
|
||||||
|
// recreated automatically on the next sign-in.
|
||||||
|
reason := "linked user does not exist"
|
||||||
|
if err == nil {
|
||||||
|
reason = fmt.Sprintf("linked user type is %d", user.Type)
|
||||||
|
}
|
||||||
|
log.Warn("Ignoring stale external login link [external-id=%s login-source-id=%d user-id=%d]: %s", externalLoginUser.ExternalID, externalLoginUser.LoginSourceID, externalLoginUser.UserID, reason)
|
||||||
|
|
||||||
|
if err := user_model.RemoveExternalLoginByExternalID(ctx, externalLoginUser.LoginSourceID, externalLoginUser.ExternalID); err != nil {
|
||||||
|
return nil, goth.User{}, err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// no user found to login
|
// no user found to login
|
||||||
|
|||||||
@@ -177,23 +177,17 @@ func ResetPasswdPost(ctx *context.Context) {
|
|||||||
regenerateScratchToken = true
|
regenerateScratchToken = true
|
||||||
} else {
|
} else {
|
||||||
passcode := ctx.FormString("passcode")
|
passcode := ctx.FormString("passcode")
|
||||||
ok, err := twofa.ValidateTOTP(passcode)
|
ok, err := twofa.ValidateAndConsumeTOTP(ctx, passcode)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.HTTPError(http.StatusInternalServerError, "ValidateTOTP", err.Error())
|
ctx.HTTPError(http.StatusInternalServerError, "ValidateAndConsumeTOTP", err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !ok || twofa.LastUsedPasscode == passcode {
|
if !ok {
|
||||||
ctx.Data["IsResetForm"] = true
|
ctx.Data["IsResetForm"] = true
|
||||||
ctx.Data["Err_Passcode"] = true
|
ctx.Data["Err_Passcode"] = true
|
||||||
ctx.RenderWithErrDeprecated(ctx.Tr("auth.twofa_passcode_incorrect"), tplResetPassword, nil)
|
ctx.RenderWithErrDeprecated(ctx.Tr("auth.twofa_passcode_incorrect"), tplResetPassword, nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
twofa.LastUsedPasscode = passcode
|
|
||||||
if err = auth.UpdateTwoFactor(ctx, twofa); err != nil {
|
|
||||||
ctx.ServerError("ResetPasswdPost: UpdateTwoFactor", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ import (
|
|||||||
|
|
||||||
// ShowBranchFeed shows tags and/or releases on the repo as RSS / Atom feed
|
// ShowBranchFeed shows tags and/or releases on the repo as RSS / Atom feed
|
||||||
func ShowBranchFeed(ctx *context.Context, repo *repo.Repository, formatType string) {
|
func ShowBranchFeed(ctx *context.Context, repo *repo.Repository, formatType string) {
|
||||||
|
if !checkRepoFeedTokenScope(ctx) {
|
||||||
|
return
|
||||||
|
}
|
||||||
var commits []*git.Commit
|
var commits []*git.Commit
|
||||||
var err error
|
var err error
|
||||||
if ctx.Repo.Commit != nil {
|
if ctx.Repo.Commit != nil {
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ import (
|
|||||||
|
|
||||||
// ShowFileFeed shows tags and/or releases on the repo as RSS / Atom feed
|
// ShowFileFeed shows tags and/or releases on the repo as RSS / Atom feed
|
||||||
func ShowFileFeed(ctx *context.Context, repo *repo.Repository, formatType string) {
|
func ShowFileFeed(ctx *context.Context, repo *repo.Repository, formatType string) {
|
||||||
|
if !checkRepoFeedTokenScope(ctx) {
|
||||||
|
return
|
||||||
|
}
|
||||||
fileName := ctx.Repo.TreePath
|
fileName := ctx.Repo.TreePath
|
||||||
if len(fileName) == 0 {
|
if len(fileName) == 0 {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ import (
|
|||||||
|
|
||||||
// shows tags and/or releases on the repo as RSS / Atom feed
|
// shows tags and/or releases on the repo as RSS / Atom feed
|
||||||
func ShowReleaseFeed(ctx *context.Context, repo *repo_model.Repository, isReleasesOnly bool, formatType string) {
|
func ShowReleaseFeed(ctx *context.Context, repo *repo_model.Repository, isReleasesOnly bool, formatType string) {
|
||||||
|
if !checkRepoFeedTokenScope(ctx) {
|
||||||
|
return
|
||||||
|
}
|
||||||
releases, err := db.Find[repo_model.Release](ctx, repo_model.FindReleasesOptions{
|
releases, err := db.Find[repo_model.Release](ctx, repo_model.FindReleasesOptions{
|
||||||
IncludeTags: !isReleasesOnly,
|
IncludeTags: !isReleasesOnly,
|
||||||
RepoID: ctx.Repo.Repository.ID,
|
RepoID: ctx.Repo.Repository.ID,
|
||||||
|
|||||||
@@ -4,9 +4,18 @@
|
|||||||
package feed
|
package feed
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
auth_model "code.gitea.io/gitea/models/auth"
|
||||||
"code.gitea.io/gitea/services/context"
|
"code.gitea.io/gitea/services/context"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// checkRepoFeedTokenScope ensures an API token has repository read scope before a
|
||||||
|
// feed serves private repository content, mirroring checkDownloadTokenScope for
|
||||||
|
// downloads. Returns false (and writes the response) when the token is denied.
|
||||||
|
func checkRepoFeedTokenScope(ctx *context.Context) bool {
|
||||||
|
context.CheckRepoScopedToken(ctx, ctx.Repo.Repository, auth_model.Read)
|
||||||
|
return !ctx.Written()
|
||||||
|
}
|
||||||
|
|
||||||
// RenderBranchFeed render format for branch or file
|
// RenderBranchFeed render format for branch or file
|
||||||
func RenderBranchFeed(ctx *context.Context, feedType string) {
|
func RenderBranchFeed(ctx *context.Context, feedType string) {
|
||||||
if ctx.Repo.TreePath == "" {
|
if ctx.Repo.TreePath == "" {
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ import (
|
|||||||
|
|
||||||
// ShowRepoFeed shows user activity on the repo as RSS / Atom feed
|
// ShowRepoFeed shows user activity on the repo as RSS / Atom feed
|
||||||
func ShowRepoFeed(ctx *context.Context, repo *repo_model.Repository, formatType string) {
|
func ShowRepoFeed(ctx *context.Context, repo *repo_model.Repository, formatType string) {
|
||||||
|
if !checkRepoFeedTokenScope(ctx) {
|
||||||
|
return
|
||||||
|
}
|
||||||
actions, _, err := feed_service.GetFeeds(ctx, activities_model.GetFeedsOptions{
|
actions, _, err := feed_service.GetFeeds(ctx, activities_model.GetFeedsOptions{
|
||||||
RequestedRepo: repo,
|
RequestedRepo: repo,
|
||||||
Actor: ctx.Doer,
|
Actor: ctx.Doer,
|
||||||
|
|||||||
@@ -261,7 +261,7 @@ func MergeUpstream(ctx *context.Context) {
|
|||||||
branchName := ctx.FormString("branch")
|
branchName := ctx.FormString("branch")
|
||||||
_, err := repo_service.MergeUpstream(ctx, ctx.Doer, ctx.Repo.Repository, branchName, false)
|
_, err := repo_service.MergeUpstream(ctx, ctx.Doer, ctx.Repo.Repository, branchName, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, util.ErrNotExist) {
|
if errors.Is(err, util.ErrNotExist) || errors.Is(err, util.ErrPermissionDenied) {
|
||||||
ctx.JSONErrorNotFound()
|
ctx.JSONErrorNotFound()
|
||||||
return
|
return
|
||||||
} else if pull_service.IsErrMergeConflicts(err) {
|
} else if pull_service.IsErrMergeConflicts(err) {
|
||||||
|
|||||||
+22
-21
@@ -58,8 +58,6 @@ func CorsHandler() func(next http.Handler) http.Handler {
|
|||||||
// httpBase does the common work for git http services,
|
// httpBase does the common work for git http services,
|
||||||
// including early response, authentication, repository lookup and permission check.
|
// including early response, authentication, repository lookup and permission check.
|
||||||
func httpBase(ctx *context.Context, optGitService ...string) *serviceHandler {
|
func httpBase(ctx *context.Context, optGitService ...string) *serviceHandler {
|
||||||
reponame := strings.TrimSuffix(ctx.PathParam("reponame"), ".git")
|
|
||||||
|
|
||||||
if ctx.FormString("go-get") == "1" {
|
if ctx.FormString("go-get") == "1" {
|
||||||
context.EarlyResponseForGoGetMeta(ctx)
|
context.EarlyResponseForGoGetMeta(ctx)
|
||||||
return nil
|
return nil
|
||||||
@@ -93,11 +91,11 @@ func httpBase(ctx *context.Context, optGitService ...string) *serviceHandler {
|
|||||||
|
|
||||||
isWiki := false
|
isWiki := false
|
||||||
unitType := unit.TypeCode
|
unitType := unit.TypeCode
|
||||||
|
repoName := strings.TrimSuffix(ctx.PathParam("reponame"), ".git")
|
||||||
if strings.HasSuffix(reponame, ".wiki") {
|
if strings.HasSuffix(repoName, ".wiki") {
|
||||||
isWiki = true
|
isWiki = true
|
||||||
unitType = unit.TypeWiki
|
unitType = unit.TypeWiki
|
||||||
reponame = reponame[:len(reponame)-5]
|
repoName = repoName[:len(repoName)-5]
|
||||||
}
|
}
|
||||||
|
|
||||||
owner := ctx.ContextUser
|
owner := ctx.ContextUser
|
||||||
@@ -107,14 +105,14 @@ func httpBase(ctx *context.Context, optGitService ...string) *serviceHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
repoExist := true
|
repoExist := true
|
||||||
repo, err := repo_model.GetRepositoryByName(ctx, owner.ID, reponame)
|
repo, err := repo_model.GetRepositoryByName(ctx, owner.ID, repoName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if !repo_model.IsErrRepoNotExist(err) {
|
if !repo_model.IsErrRepoNotExist(err) {
|
||||||
ctx.ServerError("GetRepositoryByName", err)
|
ctx.ServerError("GetRepositoryByName", err)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if redirectRepoID, err := repo_model.LookupRedirect(ctx, owner.ID, reponame); err == nil {
|
if redirectRepoID, err := repo_model.LookupRedirect(ctx, owner.ID, repoName); err == nil {
|
||||||
context.RedirectToRepo(ctx.Base, redirectRepoID)
|
context.RedirectToRepo(ctx.Base, redirectRepoID)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -127,23 +125,26 @@ func httpBase(ctx *context.Context, optGitService ...string) *serviceHandler {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only public pull don't need auth.
|
// Only public pulls don't need auth: repo must exist, not require-sign-in
|
||||||
isPublicPull := repoExist && !repo.IsPrivate && isPull
|
canAnonymousPull := false
|
||||||
askAuth := !isPublicPull || setting.Service.RequireSignInViewStrict
|
if isPull && repoExist && !setting.Service.RequireSignInViewStrict {
|
||||||
|
// allow anonymous pulls if owner is public and repo is public (not private)
|
||||||
// don't allow anonymous pulls if organization is not public
|
if owner.Visibility == structs.VisibleTypePublic && !repo.IsPrivate {
|
||||||
if isPublicPull {
|
canAnonymousPull = true
|
||||||
if err := repo.LoadOwner(ctx); err != nil {
|
}
|
||||||
ctx.ServerError("LoadOwner", err)
|
// then check "public anonymous access" permission
|
||||||
return nil
|
if !canAnonymousPull && ctx.Doer == nil {
|
||||||
|
anonPerm, err := access_model.GetDoerRepoPermission(ctx, repo, nil)
|
||||||
|
if err != nil {
|
||||||
|
ctx.ServerError("GetDoerRepoPermission", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
canAnonymousPull = anonPerm.CanAccess(accessMode, unitType)
|
||||||
}
|
}
|
||||||
|
|
||||||
askAuth = askAuth || (repo.Owner.Visibility != structs.VisibleTypePublic)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// check access
|
// check access
|
||||||
if askAuth {
|
if !canAnonymousPull { // not public pull, then either the pull needs auth, or the push needs "write" permission, so ask auth
|
||||||
// rely on the results of Contexter
|
|
||||||
if !ctx.IsSigned {
|
if !ctx.IsSigned {
|
||||||
// TODO: support digit auth - which would be Authorization header with digit
|
// TODO: support digit auth - which would be Authorization header with digit
|
||||||
if setting.OAuth2.Enabled {
|
if setting.OAuth2.Enabled {
|
||||||
@@ -229,7 +230,7 @@ func httpBase(ctx *context.Context, optGitService ...string) *serviceHandler {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
repo, err = repo_service.PushCreateRepo(ctx, ctx.Doer, owner, reponame)
|
repo, err = repo_service.PushCreateRepo(ctx, ctx.Doer, owner, repoName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("pushCreateRepo: %v", err)
|
log.Error("pushCreateRepo: %v", err)
|
||||||
ctx.Status(http.StatusNotFound)
|
ctx.Status(http.StatusNotFound)
|
||||||
|
|||||||
@@ -145,6 +145,7 @@ func NewIssue(ctx *context.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ctx.Data["HasIssuesOrPullsWritePermission"] = ctx.Repo.CanWrite(unit.TypeIssues)
|
ctx.Data["HasIssuesOrPullsWritePermission"] = ctx.Repo.CanWrite(unit.TypeIssues)
|
||||||
|
ctx.Data["IsIssuePoster"] = true // the current user will be the poster of the new issue
|
||||||
|
|
||||||
if !issueConfig.BlankIssuesEnabled && hasTemplates && !templateLoaded {
|
if !issueConfig.BlankIssuesEnabled && hasTemplates && !templateLoaded {
|
||||||
// The "issues/new" and "issues/new/choose" share the same query parameters "project" and "milestone", if blank issues are disabled, just redirect to the "issues/choose" page with these parameters.
|
// The "issues/new" and "issues/new/choose" share the same query parameters "project" and "milestone", if blank issues are disabled, just redirect to the "issues/choose" page with these parameters.
|
||||||
|
|||||||
@@ -399,6 +399,24 @@ func notifyPackage(ctx context.Context, sender *user_model.User, pd *packages_mo
|
|||||||
}
|
}
|
||||||
|
|
||||||
func ifNeedApproval(ctx context.Context, run *actions_model.ActionRun, repo *repo_model.Repository, user *user_model.User) (bool, error) {
|
func ifNeedApproval(ctx context.Context, run *actions_model.ActionRun, repo *repo_model.Repository, user *user_model.User) (bool, error) {
|
||||||
|
canWrite := func(ctx context.Context, repo *repo_model.Repository, user *user_model.User) (bool, error) {
|
||||||
|
perm, err := access_model.GetDoerRepoPermission(ctx, repo, user)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return perm.CanWrite(unit_model.TypeActions), nil
|
||||||
|
}
|
||||||
|
return ifNeedApprovalWith(ctx, run, repo, user, canWrite, issues_model.HasMergedPullRequestInRepo)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ifNeedApprovalWith(
|
||||||
|
ctx context.Context,
|
||||||
|
run *actions_model.ActionRun,
|
||||||
|
repo *repo_model.Repository,
|
||||||
|
user *user_model.User,
|
||||||
|
canWriteActions func(context.Context, *repo_model.Repository, *user_model.User) (bool, error),
|
||||||
|
hasMergedPR func(context.Context, int64, int64) (bool, error),
|
||||||
|
) (bool, error) {
|
||||||
// 1. don't need approval if it's not a fork PR
|
// 1. don't need approval if it's not a fork PR
|
||||||
// 2. don't need approval if the event is `pull_request_target` since the workflow will run in the context of base branch
|
// 2. don't need approval if the event is `pull_request_target` since the workflow will run in the context of base branch
|
||||||
// see https://docs.github.com/en/actions/managing-workflow-runs/approving-workflow-runs-from-public-forks#about-workflow-runs-from-public-forks
|
// see https://docs.github.com/en/actions/managing-workflow-runs/approving-workflow-runs-from-public-forks#about-workflow-runs-from-public-forks
|
||||||
@@ -413,27 +431,24 @@ func ifNeedApproval(ctx context.Context, run *actions_model.ActionRun, repo *rep
|
|||||||
}
|
}
|
||||||
|
|
||||||
// don't need approval if the user can write
|
// don't need approval if the user can write
|
||||||
if perm, err := access_model.GetDoerRepoPermission(ctx, repo, user); err != nil {
|
if ok, err := canWriteActions(ctx, repo, user); err != nil {
|
||||||
return false, fmt.Errorf("GetDoerRepoPermission: %w", err)
|
return false, fmt.Errorf("GetDoerRepoPermission: %w", err)
|
||||||
} else if perm.CanWrite(unit_model.TypeActions) {
|
} else if ok {
|
||||||
log.Trace("do not need approval because user %d can write", user.ID)
|
log.Trace("do not need approval because user %d can write", user.ID)
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// don't need approval if the user has been approved before
|
// trust the user only after a merged PR — matching GitHub Actions. Approving one
|
||||||
if count, err := db.Count[actions_model.ActionRun](ctx, actions_model.FindRunOptions{
|
// fork PR's run must not implicitly trust later fork PRs that replace the workflow.
|
||||||
RepoID: repo.ID,
|
if merged, err := hasMergedPR(ctx, repo.ID, user.ID); err != nil {
|
||||||
TriggerUserID: user.ID,
|
return false, fmt.Errorf("HasMergedPullRequestInRepo: %w", err)
|
||||||
Approved: true,
|
} else if merged {
|
||||||
}); err != nil {
|
log.Trace("do not need approval because user %d has a merged pull request in repo %d", user.ID, repo.ID)
|
||||||
return false, fmt.Errorf("CountRuns: %w", err)
|
|
||||||
} else if count > 0 {
|
|
||||||
log.Trace("do not need approval because user %d has been approved before", user.ID)
|
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// otherwise, need approval
|
// otherwise, need approval
|
||||||
log.Trace("need approval because it's the first time user %d triggered actions", user.ID)
|
log.Trace("need approval because user %d has no merged pull request in repo %d", user.ID, repo.ID)
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package actions
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
actions_model "code.gitea.io/gitea/models/actions"
|
||||||
|
repo_model "code.gitea.io/gitea/models/repo"
|
||||||
|
user_model "code.gitea.io/gitea/models/user"
|
||||||
|
actions_module "code.gitea.io/gitea/modules/actions"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIfNeedApproval(t *testing.T) {
|
||||||
|
alwaysWrite := func(_ context.Context, _ *repo_model.Repository, _ *user_model.User) (bool, error) {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
neverWrite := func(_ context.Context, _ *repo_model.Repository, _ *user_model.User) (bool, error) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
hasMerged := func(_ context.Context, _, _ int64) (bool, error) { return true, nil }
|
||||||
|
noMerged := func(_ context.Context, _, _ int64) (bool, error) { return false, nil }
|
||||||
|
errPerm := errors.New("perm error")
|
||||||
|
errMerge := errors.New("merge error")
|
||||||
|
|
||||||
|
forkRun := &actions_model.ActionRun{IsForkPullRequest: true, TriggerEvent: actions_module.GithubEventPullRequest}
|
||||||
|
nonForkRun := &actions_model.ActionRun{IsForkPullRequest: false, TriggerEvent: actions_module.GithubEventPullRequest}
|
||||||
|
prTargetRun := &actions_model.ActionRun{IsForkPullRequest: true, TriggerEvent: actions_module.GithubEventPullRequestTarget}
|
||||||
|
|
||||||
|
repo := &repo_model.Repository{ID: 1}
|
||||||
|
normalUser := &user_model.User{ID: 10}
|
||||||
|
restrictedUser := &user_model.User{ID: 11, IsRestricted: true}
|
||||||
|
|
||||||
|
t.Run("not a fork PR never needs approval", func(t *testing.T) {
|
||||||
|
need, err := ifNeedApprovalWith(t.Context(), nonForkRun, repo, normalUser, alwaysWrite, hasMerged)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.False(t, need)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("pull_request_target never needs approval even when fork", func(t *testing.T) {
|
||||||
|
need, err := ifNeedApprovalWith(t.Context(), prTargetRun, repo, normalUser, alwaysWrite, hasMerged)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.False(t, need)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("restricted user always needs approval", func(t *testing.T) {
|
||||||
|
need, err := ifNeedApprovalWith(t.Context(), forkRun, repo, restrictedUser, alwaysWrite, hasMerged)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, need)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("fork PR with write permission does not need approval", func(t *testing.T) {
|
||||||
|
need, err := ifNeedApprovalWith(t.Context(), forkRun, repo, normalUser, alwaysWrite, noMerged)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.False(t, need)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("fork PR with merged PR but no write permission does not need approval", func(t *testing.T) {
|
||||||
|
need, err := ifNeedApprovalWith(t.Context(), forkRun, repo, normalUser, neverWrite, hasMerged)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.False(t, need)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("fork PR with no write and no merged PR needs approval", func(t *testing.T) {
|
||||||
|
need, err := ifNeedApprovalWith(t.Context(), forkRun, repo, normalUser, neverWrite, noMerged)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, need)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("canWriteActions error is propagated", func(t *testing.T) {
|
||||||
|
failWrite := func(_ context.Context, _ *repo_model.Repository, _ *user_model.User) (bool, error) {
|
||||||
|
return false, errPerm
|
||||||
|
}
|
||||||
|
_, err := ifNeedApprovalWith(t.Context(), forkRun, repo, normalUser, failWrite, noMerged)
|
||||||
|
require.ErrorIs(t, err, errPerm)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("hasMergedPR error is propagated", func(t *testing.T) {
|
||||||
|
failMerge := func(_ context.Context, _, _ int64) (bool, error) { return false, errMerge }
|
||||||
|
_, err := ifNeedApprovalWith(t.Context(), forkRun, repo, normalUser, neverWrite, failMerge)
|
||||||
|
require.ErrorIs(t, err, errMerge)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("restricted user skips permission check entirely", func(t *testing.T) {
|
||||||
|
// The perm and merge functions must not be called for a restricted user.
|
||||||
|
called := false
|
||||||
|
trackWrite := func(_ context.Context, _ *repo_model.Repository, _ *user_model.User) (bool, error) {
|
||||||
|
called = true
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
need, err := ifNeedApprovalWith(t.Context(), forkRun, repo, restrictedUser, trackWrite, noMerged)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, need)
|
||||||
|
assert.False(t, called, "permission check must not run for restricted user")
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -140,11 +140,17 @@ func DispatchActionWorkflow(ctx reqctx.RequestContext, doer *user_model.User, re
|
|||||||
workflow := &model.Workflow{
|
workflow := &model.Workflow{
|
||||||
RawOn: singleWorkflow.RawOn,
|
RawOn: singleWorkflow.RawOn,
|
||||||
}
|
}
|
||||||
|
workflowDispatch := workflow.WorkflowDispatchConfig()
|
||||||
|
if workflowDispatch == nil {
|
||||||
|
return 0, util.ErrorWrapTranslatable(
|
||||||
|
util.NewInvalidArgumentErrorf("workflow %q has no workflow_dispatch event trigger", workflowID),
|
||||||
|
"actions.workflow.has_no_workflow_dispatch", workflowID,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
inputsWithDefaults := make(map[string]any)
|
inputsWithDefaults := make(map[string]any)
|
||||||
if workflowDispatch := workflow.WorkflowDispatchConfig(); workflowDispatch != nil {
|
if err = processInputs(workflowDispatch, inputsWithDefaults); err != nil {
|
||||||
if err = processInputs(workflowDispatch, inputsWithDefaults); err != nil {
|
return 0, err
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ctx.Req.PostForm -> WorkflowDispatchPayload.Inputs -> ActionRun.EventPayload -> runner: ghc.Event
|
// ctx.Req.PostForm -> WorkflowDispatchPayload.Inputs -> ActionRun.EventPayload -> runner: ghc.Event
|
||||||
|
|||||||
@@ -176,7 +176,8 @@ func validateTOTP(req *http.Request, u *user_model.User) error {
|
|||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if ok, err := twofa.ValidateTOTP(req.Header.Get("X-Gitea-OTP")); err != nil {
|
// Consume the passcode atomically so a captured OTP cannot be replayed within its validity window.
|
||||||
|
if ok, err := twofa.ValidateAndConsumeTOTP(req.Context(), req.Header.Get("X-Gitea-OTP")); err != nil {
|
||||||
return err
|
return err
|
||||||
} else if !ok {
|
} else if !ok {
|
||||||
return util.NewInvalidArgumentErrorf("invalid provided OTP")
|
return util.NewInvalidArgumentErrorf("invalid provided OTP")
|
||||||
|
|||||||
@@ -88,8 +88,8 @@ func (source *Source) refresh(ctx context.Context, provider goth.Provider, u *us
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete stored tokens, since they are invalid. This
|
// HINT: OAUTH-AUTO-SYNC-USER-ACTIVATION
|
||||||
// also provents us from checking this in subsequent runs.
|
// Delete stored tokens, since they are invalid. This also prevents us from checking this in subsequent runs.
|
||||||
u.AccessToken = ""
|
u.AccessToken = ""
|
||||||
u.RefreshToken = ""
|
u.RefreshToken = ""
|
||||||
u.ExpiresAt = time.Time{}
|
u.ExpiresAt = time.Time{}
|
||||||
|
|||||||
@@ -57,12 +57,7 @@ func TestSource(t *testing.T) {
|
|||||||
err := source.refresh(t.Context(), provider, e)
|
err := source.refresh(t.Context(), provider, e)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
e := &user_model.ExternalLoginUser{
|
e, ok, err := user_model.GetExternalLogin(t.Context(), e.LoginSourceID, e.ExternalID)
|
||||||
ExternalID: e.ExternalID,
|
|
||||||
LoginSourceID: e.LoginSourceID,
|
|
||||||
}
|
|
||||||
|
|
||||||
ok, err := user_model.GetExternalLogin(t.Context(), e)
|
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.True(t, ok)
|
assert.True(t, ok)
|
||||||
assert.Equal(t, "refresh", e.RefreshToken)
|
assert.Equal(t, "refresh", e.RefreshToken)
|
||||||
@@ -82,12 +77,7 @@ func TestSource(t *testing.T) {
|
|||||||
})
|
})
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
e := &user_model.ExternalLoginUser{
|
e, ok, err := user_model.GetExternalLogin(t.Context(), e.LoginSourceID, e.ExternalID)
|
||||||
ExternalID: e.ExternalID,
|
|
||||||
LoginSourceID: e.LoginSourceID,
|
|
||||||
}
|
|
||||||
|
|
||||||
ok, err := user_model.GetExternalLogin(t.Context(), e)
|
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.True(t, ok)
|
assert.True(t, ok)
|
||||||
assert.Empty(t, e.RefreshToken)
|
assert.Empty(t, e.RefreshToken)
|
||||||
|
|||||||
@@ -24,19 +24,22 @@ 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 {
|
||||||
perm, err := access_model.GetIndividualUserRepoPermission(ctx, n.Repository, n.User)
|
return result
|
||||||
if err != nil {
|
}
|
||||||
log.Error("GetIndividualUserRepoPermission failed: %v", err)
|
perm, err := access_model.GetIndividualUserRepoPermission(ctx, n.Repository, n.User)
|
||||||
return result
|
if err != nil {
|
||||||
}
|
log.Error("GetIndividualUserRepoPermission failed: %v", err)
|
||||||
if perm.HasAnyUnitAccessOrPublicAccess() { // if user has been revoked access to repo, do not show repo info
|
return result
|
||||||
result.Repository = ToRepo(ctx, n.Repository, perm)
|
}
|
||||||
// This permission is not correct and we should not be reporting it
|
// if the user has been revoked access to the repo, do not leak repo or subject info
|
||||||
for repository := result.Repository; repository != nil; repository = repository.Parent {
|
if !perm.HasAnyUnitAccessOrPublicAccess() {
|
||||||
repository.Permissions = nil
|
return result
|
||||||
}
|
}
|
||||||
}
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
// handle Subject
|
// handle Subject
|
||||||
|
|||||||
@@ -39,6 +39,36 @@ func TestToNotificationThreadOmitsRepoWhenAccessRevoked(t *testing.T) {
|
|||||||
assert.Nil(t, thread.Repository)
|
assert.Nil(t, thread.Repository)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestToNotificationThreadOmitsSubjectWhenAccessRevoked(t *testing.T) {
|
||||||
|
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||||
|
|
||||||
|
ctx := t.Context()
|
||||||
|
// repo 2 is private; user 4 has no access to it
|
||||||
|
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2})
|
||||||
|
assert.NoError(t, repo.LoadOwner(ctx))
|
||||||
|
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 4, RepoID: repo.ID})
|
||||||
|
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4})
|
||||||
|
|
||||||
|
n := &activities_model.Notification{
|
||||||
|
ID: 12345,
|
||||||
|
UserID: user.ID,
|
||||||
|
RepoID: repo.ID,
|
||||||
|
Status: activities_model.NotificationStatusUnread,
|
||||||
|
Source: activities_model.NotificationSourceIssue,
|
||||||
|
IssueID: issue.ID,
|
||||||
|
UpdatedUnix: timeutil.TimeStampNow(),
|
||||||
|
Issue: issue,
|
||||||
|
Repository: repo,
|
||||||
|
User: user,
|
||||||
|
}
|
||||||
|
|
||||||
|
thread := ToNotificationThread(ctx, n)
|
||||||
|
|
||||||
|
// must not leak private issue metadata once access is revoked
|
||||||
|
assert.Nil(t, thread.Repository)
|
||||||
|
assert.Nil(t, thread.Subject)
|
||||||
|
}
|
||||||
|
|
||||||
func TestToNotificationThread(t *testing.T) {
|
func TestToNotificationThread(t *testing.T) {
|
||||||
require.NoError(t, unittest.PrepareTestDatabase())
|
require.NoError(t, unittest.PrepareTestDatabase())
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"slices"
|
"slices"
|
||||||
|
"time"
|
||||||
|
|
||||||
issues_model "code.gitea.io/gitea/models/issues"
|
issues_model "code.gitea.io/gitea/models/issues"
|
||||||
org_model "code.gitea.io/gitea/models/organization"
|
org_model "code.gitea.io/gitea/models/organization"
|
||||||
@@ -26,6 +27,10 @@ type ReviewRequestNotifier struct {
|
|||||||
|
|
||||||
var codeOwnerFiles = []string{"CODEOWNERS", "docs/CODEOWNERS", ".gitea/CODEOWNERS"}
|
var codeOwnerFiles = []string{"CODEOWNERS", "docs/CODEOWNERS", ".gitea/CODEOWNERS"}
|
||||||
|
|
||||||
|
// codeOwnerMatchBudget caps the total wall-clock time spent evaluating all
|
||||||
|
// CODEOWNERS rules against all changed files for a single PR.
|
||||||
|
const codeOwnerMatchBudget = 2 * time.Second
|
||||||
|
|
||||||
func IsCodeOwnerFile(f string) bool {
|
func IsCodeOwnerFile(f string) bool {
|
||||||
return slices.Contains(codeOwnerFiles, f)
|
return slices.Contains(codeOwnerFiles, f)
|
||||||
}
|
}
|
||||||
@@ -93,8 +98,17 @@ func PullRequestCodeOwnersReview(ctx context.Context, pr *issues_model.PullReque
|
|||||||
|
|
||||||
uniqUsers := make(map[int64]*user_model.User)
|
uniqUsers := make(map[int64]*user_model.User)
|
||||||
uniqTeams := make(map[string]*org_model.Team)
|
uniqTeams := make(map[string]*org_model.Team)
|
||||||
|
// Bound the total time spent matching rules×files. The per-rule MatchTimeout
|
||||||
|
// only caps a single match; without an aggregate budget a crafted CODEOWNERS
|
||||||
|
// plus a PR touching many files could still exhaust CPU inside this loop.
|
||||||
|
matchDeadline := time.Now().Add(codeOwnerMatchBudget)
|
||||||
|
ruleLoop:
|
||||||
for _, rule := range rules {
|
for _, rule := range rules {
|
||||||
for _, f := range changedFiles {
|
for _, f := range changedFiles {
|
||||||
|
if time.Now().After(matchDeadline) {
|
||||||
|
log.Warn("CODEOWNERS matching for PR %s#%d exceeded its time budget; some rules were not evaluated", pr.BaseRepo.FullName(), pr.ID)
|
||||||
|
break ruleLoop
|
||||||
|
}
|
||||||
shouldMatch := !rule.Negative
|
shouldMatch := !rule.Negative
|
||||||
matched, _ := rule.Rule.MatchString(f) // err only happens when timeouts, any error can be considered as not matched
|
matched, _ := rule.Rule.MatchString(f) // err only happens when timeouts, any error can be considered as not matched
|
||||||
if matched == shouldMatch {
|
if matched == shouldMatch {
|
||||||
|
|||||||
@@ -172,9 +172,10 @@ func runSync(ctx context.Context, m *repo_model.Mirror) ([]*repo_module.SyncResu
|
|||||||
|
|
||||||
if m.LFS && setting.LFS.StartServer {
|
if m.LFS && setting.LFS.StartServer {
|
||||||
log.Trace("SyncMirrors [repo: %-v]: syncing LFS objects...", m.Repo)
|
log.Trace("SyncMirrors [repo: %-v]: syncing LFS objects...", m.Repo)
|
||||||
endpoint := lfs.DetermineEndpoint(remoteURL.String(), m.LFSEndpoint)
|
lfsClient, err := lfs.NewClientFromEndpoint(remoteURL.String(), m.LFSEndpoint, migrations.NewMigrationHTTPTransport())
|
||||||
lfsClient := lfs.NewClient(endpoint, migrations.NewMigrationHTTPTransport())
|
if err != nil {
|
||||||
if err = repo_module.StoreMissingLfsObjectsInRepository(ctx, m.Repo, gitRepo, lfsClient); err != nil {
|
log.Error("SyncMirrors [repo: %-v]: failed to initialize LFS client: %v", m.Repo.FullName(), err)
|
||||||
|
} else if err = repo_module.StoreMissingLfsObjectsInRepository(ctx, m.Repo, gitRepo, lfsClient); err != nil {
|
||||||
log.Error("SyncMirrors [repo: %-v]: failed to synchronize LFS objects for repository: %v", m.Repo.FullName(), err)
|
log.Error("SyncMirrors [repo: %-v]: failed to synchronize LFS objects for repository: %v", m.Repo.FullName(), err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -144,8 +144,10 @@ func runPushSync(ctx context.Context, m *repo_model.PushMirror) error {
|
|||||||
}
|
}
|
||||||
defer gitRepo.Close()
|
defer gitRepo.Close()
|
||||||
|
|
||||||
endpoint := lfs.DetermineEndpoint(remoteURL.String(), "")
|
lfsClient, err := lfs.NewClientFromEndpoint(remoteURL.String(), "", migrations.NewMigrationHTTPTransport())
|
||||||
lfsClient := lfs.NewClient(endpoint, migrations.NewMigrationHTTPTransport())
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if err := pushAllLFSObjects(ctx, gitRepo, lfsClient); err != nil {
|
if err := pushAllLFSObjects(ctx, gitRepo, lfsClient); err != nil {
|
||||||
return util.SanitizeErrorCredentialURLs(err)
|
return util.SanitizeErrorCredentialURLs(err)
|
||||||
}
|
}
|
||||||
|
|||||||
+38
-11
@@ -10,6 +10,7 @@ import (
|
|||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"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"
|
||||||
user_model "code.gitea.io/gitea/models/user"
|
user_model "code.gitea.io/gitea/models/user"
|
||||||
@@ -32,18 +33,23 @@ func GenerateReleaseNotes(ctx context.Context, repo *repo_model.Repository, gitR
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
if opts.PreviousTag == "" {
|
isFirstRelease, err := isFirstRelease(ctx, repo.ID)
|
||||||
// no previous tag, usually due to there is no tag in the repo, use the same content as GitHub
|
if err != nil {
|
||||||
content := fmt.Sprintf("**Full Changelog**: %s/commits/tag/%s\n", repo.HTMLURL(ctx), util.PathEscapeSegments(opts.TagName))
|
return "", fmt.Errorf("isFirstRelease: %w", err)
|
||||||
return content, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
baseCommit, err := gitRepo.GetCommit(opts.PreviousTag)
|
baseCommitID := ""
|
||||||
if err != nil {
|
if opts.PreviousTag != "" {
|
||||||
|
baseCommit, err := gitRepo.GetCommit(opts.PreviousTag)
|
||||||
|
if err != nil {
|
||||||
|
return "", util.ErrorWrapTranslatable(util.ErrNotExist, "repo.release.generate_notes_tag_not_found", opts.TagName)
|
||||||
|
}
|
||||||
|
baseCommitID = baseCommit.ID.String()
|
||||||
|
} else if !isFirstRelease {
|
||||||
return "", util.ErrorWrapTranslatable(util.ErrNotExist, "repo.release.generate_notes_tag_not_found", opts.TagName)
|
return "", util.ErrorWrapTranslatable(util.ErrNotExist, "repo.release.generate_notes_tag_not_found", opts.TagName)
|
||||||
}
|
}
|
||||||
|
|
||||||
commits, err := gitRepo.CommitsBetweenIDs(headCommit.ID.String(), baseCommit.ID.String())
|
commits, err := gitRepo.CommitsBetweenIDs(headCommit.ID.String(), baseCommitID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("CommitsBetweenIDs: %w", err)
|
return "", fmt.Errorf("CommitsBetweenIDs: %w", err)
|
||||||
}
|
}
|
||||||
@@ -58,10 +64,27 @@ func GenerateReleaseNotes(ctx context.Context, repo *repo_model.Repository, gitR
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
content := buildReleaseNotesContent(ctx, repo, opts.TagName, opts.PreviousTag, prs, contributors, newContributors)
|
fullChangelogURL := ""
|
||||||
|
if isFirstRelease {
|
||||||
|
// Keep the first-release changelog link aligned with GitHub, while collecting PRs from full history.
|
||||||
|
fullChangelogURL = fmt.Sprintf("%s/commits/tag/%s", repo.HTMLURL(ctx), util.PathEscapeSegments(opts.TagName))
|
||||||
|
}
|
||||||
|
|
||||||
|
content := buildReleaseNotesContent(ctx, repo, opts.TagName, opts.PreviousTag, prs, contributors, newContributors, fullChangelogURL)
|
||||||
return content, nil
|
return content, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isFirstRelease(ctx context.Context, repoID int64) (bool, error) {
|
||||||
|
count, err := db.Count[repo_model.Release](ctx, repo_model.FindReleasesOptions{
|
||||||
|
RepoID: repoID,
|
||||||
|
IncludeDrafts: false,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return count == 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
func resolveHeadCommit(gitRepo *git.Repository, tagName, tagTarget string) (*git.Commit, error) {
|
func resolveHeadCommit(gitRepo *git.Repository, tagName, tagTarget string) (*git.Commit, error) {
|
||||||
ref := tagName
|
ref := tagName
|
||||||
if !gitRepo.IsTagExist(tagName) {
|
if !gitRepo.IsTagExist(tagName) {
|
||||||
@@ -107,7 +130,7 @@ func collectPullRequestsFromCommits(ctx context.Context, repoID int64, commits [
|
|||||||
return prs, nil
|
return prs, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildReleaseNotesContent(ctx context.Context, repo *repo_model.Repository, tagName, baseRef string, prs []*issues_model.PullRequest, contributors []*user_model.User, newContributors []*issues_model.PullRequest) string {
|
func buildReleaseNotesContent(ctx context.Context, repo *repo_model.Repository, tagName, baseRef string, prs []*issues_model.PullRequest, contributors []*user_model.User, newContributors []*issues_model.PullRequest, fullChangelogURL string) string {
|
||||||
var builder strings.Builder
|
var builder strings.Builder
|
||||||
builder.WriteString("## What's Changed\n")
|
builder.WriteString("## What's Changed\n")
|
||||||
|
|
||||||
@@ -136,8 +159,12 @@ func buildReleaseNotesContent(ctx context.Context, repo *repo_model.Repository,
|
|||||||
}
|
}
|
||||||
|
|
||||||
builder.WriteString("**Full Changelog**: ")
|
builder.WriteString("**Full Changelog**: ")
|
||||||
compareURL := fmt.Sprintf("%s/compare/%s...%s", repo.HTMLURL(ctx), util.PathEscapeSegments(baseRef), util.PathEscapeSegments(tagName))
|
if fullChangelogURL != "" {
|
||||||
fmt.Fprintf(&builder, "[%s...%s](%s)", baseRef, tagName, compareURL)
|
builder.WriteString(fullChangelogURL)
|
||||||
|
} else {
|
||||||
|
compareURL := fmt.Sprintf("%s/compare/%s...%s", repo.HTMLURL(ctx), util.PathEscapeSegments(baseRef), util.PathEscapeSegments(tagName))
|
||||||
|
fmt.Fprintf(&builder, "[%s...%s](%s)", baseRef, tagName, compareURL)
|
||||||
|
}
|
||||||
builder.WriteByte('\n')
|
builder.WriteByte('\n')
|
||||||
return builder.String()
|
return builder.String()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,13 +21,14 @@ import (
|
|||||||
func TestGenerateReleaseNotes(t *testing.T) {
|
func TestGenerateReleaseNotes(t *testing.T) {
|
||||||
unittest.PrepareTestEnv(t)
|
unittest.PrepareTestEnv(t)
|
||||||
|
|
||||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
|
||||||
gitRepo, err := gitrepo.OpenRepository(t.Context(), repo)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
t.Run("ChangeLogsWithPRs", func(t *testing.T) {
|
t.Run("ChangeLogsWithPRs", func(t *testing.T) {
|
||||||
|
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
||||||
|
gitRepo, err := gitrepo.OpenRepository(t.Context(), repo)
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { gitRepo.Close() })
|
||||||
|
|
||||||
mergedCommit := "90c1019714259b24fb81711d4416ac0f18667dfa"
|
mergedCommit := "90c1019714259b24fb81711d4416ac0f18667dfa"
|
||||||
createMergedPullRequest(t, repo, mergedCommit, 5)
|
createMergedPullRequest(t, repo, mergedCommit, 5, "Release notes test pull request")
|
||||||
|
|
||||||
content, err := GenerateReleaseNotes(t.Context(), repo, gitRepo, GenerateReleaseNotesOptions{
|
content, err := GenerateReleaseNotes(t.Context(), repo, gitRepo, GenerateReleaseNotesOptions{
|
||||||
TagName: "v1.2.0",
|
TagName: "v1.2.0",
|
||||||
@@ -50,16 +51,51 @@ func TestGenerateReleaseNotes(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("NoPreviousTag", func(t *testing.T) {
|
t.Run("NoPreviousTag", func(t *testing.T) {
|
||||||
|
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 16})
|
||||||
|
gitRepo, err := gitrepo.OpenRepository(t.Context(), repo)
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { gitRepo.Close() })
|
||||||
|
|
||||||
|
createMergedPullRequest(t, repo, "69554a64c1e6030f051e5c3f94bfbd773cd6a324", 5, "Initial tag PR 1")
|
||||||
|
createMergedPullRequest(t, repo, "27566bd5738fc8b4e3fef3c5e72cce608537bd95", 4, "Initial tag PR 2")
|
||||||
|
createMergedPullRequest(t, repo, "5099b81332712fe655e34e8dd63574f503f61811", 8, "Initial tag PR 3")
|
||||||
|
|
||||||
content, err := GenerateReleaseNotes(t.Context(), repo, gitRepo, GenerateReleaseNotesOptions{
|
content, err := GenerateReleaseNotes(t.Context(), repo, gitRepo, GenerateReleaseNotesOptions{
|
||||||
TagName: "v1.2.0",
|
TagName: "v0.1.0",
|
||||||
TagTarget: "DefaultBranch",
|
TagTarget: repo.DefaultBranch,
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, "**Full Changelog**: https://try.gitea.io/user2/repo1/commits/tag/v1.2.0\n", content)
|
|
||||||
|
assert.Contains(t, content, "## What's Changed\n")
|
||||||
|
assert.Contains(t, content, "* Initial tag PR 1 in [#")
|
||||||
|
assert.Contains(t, content, "* Initial tag PR 2 in [#")
|
||||||
|
assert.Contains(t, content, "* Initial tag PR 3 in [#")
|
||||||
|
assert.Contains(t, content, "\n## Contributors\n")
|
||||||
|
assert.Contains(t, content, "* @user5\n")
|
||||||
|
assert.Contains(t, content, "* @user4\n")
|
||||||
|
assert.Contains(t, content, "* @user8\n")
|
||||||
|
assert.Contains(t, content, "\n## New Contributors\n")
|
||||||
|
assert.Contains(t, content, "* @user5 made their first contribution in [#")
|
||||||
|
assert.Contains(t, content, "* @user4 made their first contribution in [#")
|
||||||
|
assert.Contains(t, content, "* @user8 made their first contribution in [#")
|
||||||
|
assert.Contains(t, content, "**Full Changelog**: https://try.gitea.io/user2/repo16/commits/tag/v0.1.0\n")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("EmptyPreviousTagWithExistingTags", func(t *testing.T) {
|
||||||
|
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
||||||
|
gitRepo, err := gitrepo.OpenRepository(t.Context(), repo)
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { gitRepo.Close() })
|
||||||
|
|
||||||
|
_, err = GenerateReleaseNotes(t.Context(), repo, gitRepo, GenerateReleaseNotesOptions{
|
||||||
|
TagName: "v1.2.0",
|
||||||
|
TagTarget: repo.DefaultBranch,
|
||||||
|
})
|
||||||
|
require.Error(t, err)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func createMergedPullRequest(t *testing.T, repo *repo_model.Repository, mergeCommit string, posterID int64) *issues_model.PullRequest {
|
func createMergedPullRequest(t *testing.T, repo *repo_model.Repository, mergeCommit string, posterID int64, title string) *issues_model.PullRequest {
|
||||||
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: posterID})
|
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: posterID})
|
||||||
|
|
||||||
issue := &issues_model.Issue{
|
issue := &issues_model.Issue{
|
||||||
@@ -67,7 +103,7 @@ func createMergedPullRequest(t *testing.T, repo *repo_model.Repository, mergeCom
|
|||||||
Repo: repo,
|
Repo: repo,
|
||||||
Poster: user,
|
Poster: user,
|
||||||
PosterID: user.ID,
|
PosterID: user.ID,
|
||||||
Title: "Release notes test pull request",
|
Title: title,
|
||||||
Content: "content",
|
Content: "content",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -47,7 +47,8 @@ func NewTemporaryUploadRepository(repo *repo_model.Repository) (*TemporaryUpload
|
|||||||
|
|
||||||
// Close the repository cleaning up all files
|
// Close the repository cleaning up all files
|
||||||
func (t *TemporaryUploadRepository) Close() {
|
func (t *TemporaryUploadRepository) Close() {
|
||||||
defer t.gitRepo.Close()
|
// must stop the repo access before removal, otherwise Windows can't remove the directory occupied by other processes
|
||||||
|
t.gitRepo.Close()
|
||||||
if t.cleanup != nil {
|
if t.cleanup != nil {
|
||||||
t.cleanup()
|
t.cleanup()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
issue_model "code.gitea.io/gitea/models/issues"
|
issue_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"
|
||||||
|
"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/git"
|
"code.gitea.io/gitea/modules/git"
|
||||||
"code.gitea.io/gitea/modules/gitrepo"
|
"code.gitea.io/gitea/modules/gitrepo"
|
||||||
@@ -26,6 +28,17 @@ func MergeUpstream(ctx reqctx.RequestContext, doer *user_model.User, repo *repo_
|
|||||||
if err = repo.GetBaseRepo(ctx); err != nil {
|
if err = repo.GetBaseRepo(ctx); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The doer must still be able to read the base repository's code. Otherwise a fork created
|
||||||
|
// while the base repo was public could keep pulling commits after it turned private.
|
||||||
|
basePerm, err := access_model.GetDoerRepoPermission(ctx, repo.BaseRepo, doer)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if !basePerm.CanRead(unit.TypeCode) {
|
||||||
|
return "", util.NewPermissionDeniedErrorf("permission denied to read base repo %d", repo.BaseRepo.ID)
|
||||||
|
}
|
||||||
|
|
||||||
divergingInfo, err := GetUpstreamDivergingInfo(ctx, repo, branch)
|
divergingInfo, err := GetUpstreamDivergingInfo(ctx, repo, branch)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
|
|||||||
@@ -159,8 +159,10 @@ func MigrateRepositoryGitData(ctx context.Context, u *user_model.User,
|
|||||||
}
|
}
|
||||||
|
|
||||||
if opts.LFS {
|
if opts.LFS {
|
||||||
endpoint := lfs.DetermineEndpoint(opts.CloneAddr, opts.LFSEndpoint)
|
lfsClient, err := lfs.NewClientFromEndpoint(opts.CloneAddr, opts.LFSEndpoint, httpTransport)
|
||||||
lfsClient := lfs.NewClient(endpoint, httpTransport)
|
if err != nil {
|
||||||
|
return repo, fmt.Errorf("NewClientFromEndpoint: %w", err)
|
||||||
|
}
|
||||||
if err = repo_module.StoreMissingLfsObjectsInRepository(ctx, repo, gitRepo, lfsClient); err != nil {
|
if err = repo_module.StoreMissingLfsObjectsInRepository(ctx, repo, gitRepo, lfsClient); err != nil {
|
||||||
log.Error("Failed to store missing LFS objects for repository: %v", err)
|
log.Error("Failed to store missing LFS objects for repository: %v", err)
|
||||||
return repo, fmt.Errorf("StoreMissingLfsObjectsInRepository: %w", err)
|
return repo, fmt.Errorf("StoreMissingLfsObjectsInRepository: %w", err)
|
||||||
|
|||||||
@@ -14,14 +14,15 @@ Still needs to figure out:
|
|||||||
* Is "GitHub-like development sidebar (`#31899`)" good enough (or better) for your usage?
|
* Is "GitHub-like development sidebar (`#31899`)" good enough (or better) for your usage?
|
||||||
*/}}
|
*/}}
|
||||||
{{if and (not .Issue.IsPull) (not .PageIsComparePull)}}
|
{{if and (not .Issue.IsPull) (not .PageIsComparePull)}}
|
||||||
|
{{$canChangeRef := or .IsIssuePoster .HasIssuesOrPullsWritePermission}}
|
||||||
<input id="ref_selector" name="ref" type="hidden" value="{{.Reference}}">
|
<input id="ref_selector" name="ref" type="hidden" value="{{.Reference}}">
|
||||||
<div class="ui dropdown select-branch branch-selector-dropdown ellipsis-text-items {{if not .HasIssuesOrPullsWritePermission}}disabled{{end}}"
|
<div class="ui dropdown select-branch branch-selector-dropdown ellipsis-text-items {{if not $canChangeRef}}disabled{{end}}"
|
||||||
data-no-results="{{ctx.Locale.Tr "no_results_found"}}"
|
data-no-results="{{ctx.Locale.Tr "no_results_found"}}"
|
||||||
{{if and .Issue (or .IsIssueWriter .HasIssuesOrPullsWritePermission)}}data-url-update-issueref="{{$.RepoLink}}/issues/{{.Issue.Index}}/ref"{{end}}
|
{{if and .Issue $canChangeRef}}data-url-update-issueref="{{$.RepoLink}}/issues/{{.Issue.Index}}/ref"{{end}}
|
||||||
>
|
>
|
||||||
<div class="ui button branch-dropdown-button">
|
<div class="ui button branch-dropdown-button">
|
||||||
<span class="text-branch-name gt-ellipsis">{{if .Reference}}{{$.RefEndName}}{{else}}{{ctx.Locale.Tr "repo.issues.no_ref"}}{{end}}</span>
|
<span class="text-branch-name gt-ellipsis">{{if .Reference}}{{$.RefEndName}}{{else}}{{ctx.Locale.Tr "repo.issues.no_ref"}}{{end}}</span>
|
||||||
{{if .HasIssuesOrPullsWritePermission}}{{svg "octicon-triangle-down" 14 "dropdown icon"}}{{end}}
|
{{if $canChangeRef}}{{svg "octicon-triangle-down" 14 "dropdown icon"}}{{end}}
|
||||||
</div>
|
</div>
|
||||||
<div class="menu">
|
<div class="menu">
|
||||||
<div class="ui icon search input">
|
<div class="ui icon search input">
|
||||||
|
|||||||
@@ -140,3 +140,107 @@ jobs:
|
|||||||
assert.Equal(t, actions_model.StatusWaiting, run2.Status)
|
assert.Equal(t, actions_model.StatusWaiting, run2.Status)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestForkPullRequestApprovalNotBypassedByPriorApproval verifies that a single
|
||||||
|
// approval on a fork PR does not permanently trust the contributor: a subsequent
|
||||||
|
// fork PR from the same user must still be gated (Blocked / NeedApproval=true)
|
||||||
|
// until that user has had a pull request merged in the repo.
|
||||||
|
func TestForkPullRequestApprovalNotBypassedByPriorApproval(t *testing.T) {
|
||||||
|
onGiteaRun(t, func(t *testing.T, u *url.URL) {
|
||||||
|
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||||
|
user2Session := loginUser(t, user2.Name)
|
||||||
|
user2Token := getTokenForLoggedInUser(t, user2Session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
|
||||||
|
user4 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4})
|
||||||
|
user4Session := loginUser(t, user4.Name)
|
||||||
|
user4Token := getTokenForLoggedInUser(t, user4Session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
|
||||||
|
|
||||||
|
apiBaseRepo := createActionsTestRepo(t, user2Token, "fork-approval-regression", false)
|
||||||
|
baseRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiBaseRepo.ID})
|
||||||
|
user2APICtx := NewAPITestContext(t, baseRepo.OwnerName, baseRepo.Name, auth_model.AccessTokenScopeWriteRepository)
|
||||||
|
defer doAPIDeleteRepository(user2APICtx)(t)
|
||||||
|
|
||||||
|
wfTreePath := ".gitea/workflows/ci.yml"
|
||||||
|
wfContent := `name: CI
|
||||||
|
on: pull_request
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- run: echo ok
|
||||||
|
`
|
||||||
|
createWorkflowFile(t, user2Token, baseRepo.OwnerName, baseRepo.Name, wfTreePath,
|
||||||
|
getWorkflowCreateFileOptions(user2, baseRepo.DefaultBranch, "add ci", wfContent))
|
||||||
|
|
||||||
|
// user4 forks the repo
|
||||||
|
req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/forks", baseRepo.OwnerName, baseRepo.Name),
|
||||||
|
&api.CreateForkOption{Name: new("fork-approval-regression-fork")}).AddTokenAuth(user4Token)
|
||||||
|
resp := MakeRequest(t, req, http.StatusAccepted)
|
||||||
|
apiForkRepo := DecodeJSON(t, resp, &api.Repository{})
|
||||||
|
forkRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiForkRepo.ID})
|
||||||
|
user4APICtx := NewAPITestContext(t, user4.Name, forkRepo.Name, auth_model.AccessTokenScopeWriteRepository)
|
||||||
|
defer doAPIDeleteRepository(user4APICtx)(t)
|
||||||
|
|
||||||
|
// PR #1: a benign change from user4's fork — first-time contributor, gate engages.
|
||||||
|
doAPICreateFile(user4APICtx, "first.txt", &api.CreateFileOptions{
|
||||||
|
FileOptions: api.FileOptions{
|
||||||
|
NewBranchName: "first",
|
||||||
|
Message: "first",
|
||||||
|
Author: api.Identity{Name: user4.Name, Email: user4.Email},
|
||||||
|
Committer: api.Identity{Name: user4.Name, Email: user4.Email},
|
||||||
|
Dates: api.CommitDateOptions{Author: time.Now(), Committer: time.Now()},
|
||||||
|
},
|
||||||
|
ContentBase64: base64.StdEncoding.EncodeToString([]byte("first")),
|
||||||
|
})(t)
|
||||||
|
pr1, err := doAPICreatePullRequest(user4APICtx, baseRepo.OwnerName, baseRepo.Name, baseRepo.DefaultBranch, user4.Name+":first")(t)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
run1 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: baseRepo.ID, TriggerUserID: user4.ID, Ref: fmt.Sprintf("refs/pull/%d/head", pr1.Index)})
|
||||||
|
assert.True(t, run1.NeedApproval, "first fork PR must require approval")
|
||||||
|
assert.Equal(t, actions_model.StatusBlocked, run1.Status)
|
||||||
|
|
||||||
|
// user2 approves run1.
|
||||||
|
req = NewRequest(t, "POST", fmt.Sprintf("%s/actions/approve-all-checks?commit_id=%s", baseRepo.Link(), pr1.Head.Sha))
|
||||||
|
user2Session.MakeRequest(t, req, http.StatusOK)
|
||||||
|
run1 = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run1.ID})
|
||||||
|
assert.False(t, run1.NeedApproval)
|
||||||
|
assert.Equal(t, user2.ID, run1.ApprovedBy)
|
||||||
|
|
||||||
|
// PR #2: same user, fresh branch. Pre-fix, this run was created with
|
||||||
|
// NeedApproval=false and dispatched immediately — the bypass path.
|
||||||
|
doAPICreateFile(user4APICtx, "second.txt", &api.CreateFileOptions{
|
||||||
|
FileOptions: api.FileOptions{
|
||||||
|
NewBranchName: "second",
|
||||||
|
Message: "second",
|
||||||
|
Author: api.Identity{Name: user4.Name, Email: user4.Email},
|
||||||
|
Committer: api.Identity{Name: user4.Name, Email: user4.Email},
|
||||||
|
Dates: api.CommitDateOptions{Author: time.Now(), Committer: time.Now()},
|
||||||
|
},
|
||||||
|
ContentBase64: base64.StdEncoding.EncodeToString([]byte("second")),
|
||||||
|
})(t)
|
||||||
|
pr2, err := doAPICreatePullRequest(user4APICtx, baseRepo.OwnerName, baseRepo.Name, baseRepo.DefaultBranch, user4.Name+":second")(t)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
run2 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: baseRepo.ID, TriggerUserID: user4.ID, Ref: fmt.Sprintf("refs/pull/%d/head", pr2.Index)})
|
||||||
|
assert.True(t, run2.NeedApproval, "second fork PR must still require approval — prior approval-to-run does not grant trust")
|
||||||
|
assert.Equal(t, actions_model.StatusBlocked, run2.Status)
|
||||||
|
assert.EqualValues(t, 0, run2.ApprovedBy)
|
||||||
|
|
||||||
|
// After merging PR #1, user4 becomes a known contributor and the gate lifts for a new PR.
|
||||||
|
doAPIMergePullRequest(user2APICtx, baseRepo.OwnerName, baseRepo.Name, pr1.Index)(t)
|
||||||
|
doAPICreateFile(user4APICtx, "third.txt", &api.CreateFileOptions{
|
||||||
|
FileOptions: api.FileOptions{
|
||||||
|
NewBranchName: "third",
|
||||||
|
Message: "third",
|
||||||
|
Author: api.Identity{Name: user4.Name, Email: user4.Email},
|
||||||
|
Committer: api.Identity{Name: user4.Name, Email: user4.Email},
|
||||||
|
Dates: api.CommitDateOptions{Author: time.Now(), Committer: time.Now()},
|
||||||
|
},
|
||||||
|
ContentBase64: base64.StdEncoding.EncodeToString([]byte("third")),
|
||||||
|
})(t)
|
||||||
|
pr3, err := doAPICreatePullRequest(user4APICtx, baseRepo.OwnerName, baseRepo.Name, baseRepo.DefaultBranch, user4.Name+":third")(t)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
run3 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: baseRepo.ID, TriggerUserID: user4.ID, Ref: fmt.Sprintf("refs/pull/%d/head", pr3.Index)})
|
||||||
|
assert.False(t, run3.NeedApproval, "fork PR from a user with a prior merged PR should not require approval")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -484,14 +484,18 @@ jobs:
|
|||||||
},
|
},
|
||||||
ContentBase64: base64.StdEncoding.EncodeToString([]byte("user4-fix2")),
|
ContentBase64: base64.StdEncoding.EncodeToString([]byte("user4-fix2")),
|
||||||
})(t)
|
})(t)
|
||||||
doAPICreatePullRequest(user4APICtx, baseRepo.OwnerName, baseRepo.Name, baseRepo.DefaultBranch, user4.Name+":do-not-cancel/ccc")(t)
|
pr3, _ := doAPICreatePullRequest(user4APICtx, baseRepo.OwnerName, baseRepo.Name, baseRepo.DefaultBranch, user4.Name+":do-not-cancel/ccc")(t)
|
||||||
// cannot fetch the task because cancel-in-progress is false
|
// cannot fetch the task: approval still required (user4 has no merged PR) and cancel-in-progress is false
|
||||||
runner.fetchNoTask(t)
|
runner.fetchNoTask(t)
|
||||||
runner.execTask(t, pr2Task1, &mockTaskOutcome{
|
runner.execTask(t, pr2Task1, &mockTaskOutcome{
|
||||||
result: runnerv1.Result_RESULT_SUCCESS,
|
result: runnerv1.Result_RESULT_SUCCESS,
|
||||||
})
|
})
|
||||||
pr2Run1 = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: pr2Run1.ID})
|
pr2Run1 = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: pr2Run1.ID})
|
||||||
assert.Equal(t, actions_model.StatusSuccess, pr2Run1.Status)
|
assert.Equal(t, actions_model.StatusSuccess, pr2Run1.Status)
|
||||||
|
// user2 approves the third PR's run (user4 still has no merged PR, approval still required)
|
||||||
|
pr3Run1Pending := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: baseRepo.ID, TriggerUserID: user4.ID, Ref: fmt.Sprintf("refs/pull/%d/head", pr3.Index)})
|
||||||
|
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/approve", baseRepo.OwnerName, baseRepo.Name, pr3Run1Pending.ID))
|
||||||
|
user2Session.MakeRequest(t, req, http.StatusOK)
|
||||||
// fetch the task
|
// fetch the task
|
||||||
pr3Task1 := runner.fetchTask(t)
|
pr3Task1 := runner.fetchTask(t)
|
||||||
_, _, pr3Run1 := getTaskAndJobAndRunByTaskID(t, pr3Task1.Id)
|
_, _, pr3Run1 := getTaskAndJobAndRunByTaskID(t, pr3Task1.Id)
|
||||||
|
|||||||
@@ -73,5 +73,19 @@ jobs:
|
|||||||
|
|
||||||
_, err = dbfs.Open(t.Context(), actions_module.DBFSPrefix+freshTask.LogFilename)
|
_, err = dbfs.Open(t.Context(), actions_module.DBFSPrefix+freshTask.LogFilename)
|
||||||
assert.ErrorIs(t, err, os.ErrNotExist, "DBFS row must be cleaned up after TransferLogs")
|
assert.ErrorIs(t, err, os.ErrNotExist, "DBFS row must be cleaned up after TransferLogs")
|
||||||
|
|
||||||
|
// The runner re-sends its final UpdateLog when the response was lost.
|
||||||
|
// A sealed log must ack the re-send and still reject new appended rows.
|
||||||
|
t.Run("re-sent finalize is idempotent", func(t *testing.T) {
|
||||||
|
finalize := &runnerv1.UpdateLogRequest{TaskId: task.Id, Index: 0, Rows: nil, NoMore: true}
|
||||||
|
resp, err := runner.client.runnerServiceClient.UpdateLog(t.Context(), connect.NewRequest(finalize))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.EqualValues(t, 0, resp.Msg.AckIndex)
|
||||||
|
|
||||||
|
_, err = runner.client.runnerServiceClient.UpdateLog(t.Context(), connect.NewRequest(&runnerv1.UpdateLogRequest{
|
||||||
|
TaskId: task.Id, Index: 0, Rows: []*runnerv1.LogRow{{Content: "late"}}, NoMore: true,
|
||||||
|
}))
|
||||||
|
require.Error(t, err, "appending rows past the seal must be rejected")
|
||||||
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -931,6 +931,76 @@ jobs:
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestWorkflowDispatchPublicApiRequiresWorkflowDispatchTrigger(t *testing.T) {
|
||||||
|
onGiteaRun(t, func(t *testing.T, u *url.URL) {
|
||||||
|
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||||
|
session := loginUser(t, user2.Name)
|
||||||
|
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
|
||||||
|
|
||||||
|
repo, err := repo_service.CreateRepository(t.Context(), user2, user2, repo_service.CreateRepoOptions{
|
||||||
|
Name: "workflow-dispatch-requires-trigger",
|
||||||
|
Description: "test workflow dispatch requires workflow_dispatch",
|
||||||
|
AutoInit: true,
|
||||||
|
Gitignores: "Go",
|
||||||
|
License: "MIT",
|
||||||
|
Readme: "Default",
|
||||||
|
DefaultBranch: "main",
|
||||||
|
IsPrivate: false,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, repo)
|
||||||
|
|
||||||
|
addWorkflowToBaseResp, err := files_service.ChangeRepoFiles(t.Context(), repo, user2, &files_service.ChangeRepoFilesOptions{
|
||||||
|
Files: []*files_service.ChangeRepoFile{
|
||||||
|
{
|
||||||
|
Operation: "create",
|
||||||
|
TreePath: ".gitea/workflows/push-only.yml",
|
||||||
|
ContentReader: strings.NewReader(`
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- run: echo helloworld
|
||||||
|
`),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Message: "add workflow",
|
||||||
|
OldBranch: "main",
|
||||||
|
NewBranch: "main",
|
||||||
|
Author: &files_service.IdentityOptions{
|
||||||
|
GitUserName: user2.Name,
|
||||||
|
GitUserEmail: user2.Email,
|
||||||
|
},
|
||||||
|
Committer: &files_service.IdentityOptions{
|
||||||
|
GitUserName: user2.Name,
|
||||||
|
GitUserEmail: user2.Email,
|
||||||
|
},
|
||||||
|
Dates: &files_service.CommitDateOptions{
|
||||||
|
Author: time.Now(),
|
||||||
|
Committer: time.Now(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, addWorkflowToBaseResp)
|
||||||
|
|
||||||
|
values := url.Values{}
|
||||||
|
values.Set("ref", "main")
|
||||||
|
req := NewRequestWithURLValues(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/workflows/push-only.yml/dispatches", repo.FullName()), values).
|
||||||
|
AddTokenAuth(token)
|
||||||
|
resp := MakeRequest(t, req, http.StatusUnprocessableEntity)
|
||||||
|
apiError := DecodeJSON(t, resp, &api.APIError{})
|
||||||
|
assert.Contains(t, apiError.Message, "has no workflow_dispatch event trigger")
|
||||||
|
|
||||||
|
unittest.AssertNotExistsBean(t, &actions_model.ActionRun{
|
||||||
|
RepoID: repo.ID,
|
||||||
|
Event: "workflow_dispatch",
|
||||||
|
WorkflowID: "push-only.yml",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func TestWorkflowDispatchPublicApiWithInputs(t *testing.T) {
|
func TestWorkflowDispatchPublicApiWithInputs(t *testing.T) {
|
||||||
onGiteaRun(t, func(t *testing.T, u *url.URL) {
|
onGiteaRun(t, func(t *testing.T, u *url.URL) {
|
||||||
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ func testAPIForkListLimitedAndPrivateRepos(t *testing.T) {
|
|||||||
assert.Equal(t, "0", resp.Header().Get("X-Total-Count"))
|
assert.Equal(t, "0", resp.Header().Get("X-Total-Count"))
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("Logged in", func(t *testing.T) {
|
t.Run("LoggedIn", func(t *testing.T) {
|
||||||
defer tests.PrintCurrentTest(t)()
|
defer tests.PrintCurrentTest(t)()
|
||||||
|
|
||||||
req := NewRequest(t, "GET", "/api/v1/repos/user2/repo1/forks").AddTokenAuth(user1Token)
|
req := NewRequest(t, "GET", "/api/v1/repos/user2/repo1/forks").AddTokenAuth(user1Token)
|
||||||
@@ -107,6 +107,36 @@ func testAPIForkListLimitedAndPrivateRepos(t *testing.T) {
|
|||||||
assert.Len(t, forks, 2)
|
assert.Len(t, forks, 2)
|
||||||
assert.Equal(t, "2", resp.Header().Get("X-Total-Count"))
|
assert.Equal(t, "2", resp.Header().Get("X-Total-Count"))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("RespHeaderLinks", func(t *testing.T) {
|
||||||
|
t.Run("Page1", func(t *testing.T) {
|
||||||
|
defer tests.PrintCurrentTest(t)()
|
||||||
|
|
||||||
|
req := NewRequest(t, "GET", "/api/v1/repos/user2/repo1/forks?page=1&limit=1").AddTokenAuth(user1Token)
|
||||||
|
resp := MakeRequest(t, req, http.StatusOK)
|
||||||
|
assert.Equal(t, "2", resp.Header().Get("X-Total-Count"))
|
||||||
|
|
||||||
|
linkHeader := resp.Header().Get("Link")
|
||||||
|
assert.NotEmpty(t, linkHeader, "Link header should not be empty")
|
||||||
|
assert.Contains(t, linkHeader, `rel="next"`)
|
||||||
|
assert.Contains(t, linkHeader, `rel="last"`)
|
||||||
|
assert.Contains(t, linkHeader, `/api/v1/repos/user2/repo1/forks?limit=1&page=2>`)
|
||||||
|
|
||||||
|
forks := DecodeJSON(t, resp, []*api.Repository{})
|
||||||
|
assert.Len(t, forks, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Page2", func(t *testing.T) {
|
||||||
|
defer tests.PrintCurrentTest(t)()
|
||||||
|
|
||||||
|
req := NewRequest(t, "GET", "/api/v1/repos/user2/repo1/forks?page=2&limit=1").AddTokenAuth(user1Token)
|
||||||
|
resp := MakeRequest(t, req, http.StatusOK)
|
||||||
|
assert.Equal(t, "2", resp.Header().Get("X-Total-Count"))
|
||||||
|
|
||||||
|
forks := DecodeJSON(t, resp, []*api.Repository{})
|
||||||
|
assert.Len(t, forks, 1)
|
||||||
|
})
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func testGetPrivateReposForks(t *testing.T) {
|
func testGetPrivateReposForks(t *testing.T) {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
issues_model "code.gitea.io/gitea/models/issues"
|
issues_model "code.gitea.io/gitea/models/issues"
|
||||||
"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/json"
|
||||||
api "code.gitea.io/gitea/modules/structs"
|
api "code.gitea.io/gitea/modules/structs"
|
||||||
"code.gitea.io/gitea/tests"
|
"code.gitea.io/gitea/tests"
|
||||||
|
|
||||||
@@ -63,6 +64,44 @@ func TestAPIGetTrackedTimes(t *testing.T) {
|
|||||||
assert.Equal(t, int64(6), filterAPITimes[1].ID)
|
assert.Equal(t, int64(6), filterAPITimes[1].ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestAPIGetTrackedTimesNonExistentUserFilter ensures filtering by a user that
|
||||||
|
// does not exist returns a clean 404 instead of panicking (nil pointer dereference).
|
||||||
|
func TestAPIGetTrackedTimesNonExistentUserFilter(t *testing.T) {
|
||||||
|
defer tests.PrepareTestEnv(t)()
|
||||||
|
|
||||||
|
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||||
|
issue2 := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 2})
|
||||||
|
assert.NoError(t, issue2.LoadRepo(t.Context()))
|
||||||
|
|
||||||
|
session := loginUser(t, user2.Name)
|
||||||
|
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadIssue, auth_model.AccessTokenScopeReadRepository)
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
url string
|
||||||
|
}{
|
||||||
|
{"repository level", fmt.Sprintf("/api/v1/repos/%s/%s/times?user=nonexistentuser", user2.Name, issue2.Repo.Name)},
|
||||||
|
{"issue level", fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/times?user=nonexistentuser", user2.Name, issue2.Repo.Name, issue2.Index)},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
req := NewRequest(t, "GET", tc.url).AddTokenAuth(token)
|
||||||
|
resp := MakeRequest(t, req, http.StatusNotFound)
|
||||||
|
|
||||||
|
assert.True(t, json.Valid(resp.Body.Bytes()), "response body must be a single JSON value, got: %s", resp.Body.Bytes())
|
||||||
|
|
||||||
|
var apiError api.APIError
|
||||||
|
DecodeJSON(t, resp, &apiError)
|
||||||
|
assert.Contains(t, apiError.Message, "user does not exist")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("existing user", func(t *testing.T) {
|
||||||
|
req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/times?user=%s", user2.Name, issue2.Repo.Name, user2.Name).AddTokenAuth(token)
|
||||||
|
resp := MakeRequest(t, req, http.StatusOK)
|
||||||
|
DecodeJSON(t, resp, api.TrackedTimeList{})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func TestAPIDeleteTrackedTime(t *testing.T) {
|
func TestAPIDeleteTrackedTime(t *testing.T) {
|
||||||
defer tests.PrepareTestEnv(t)()
|
defer tests.PrepareTestEnv(t)()
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
auth_model "code.gitea.io/gitea/models/auth"
|
auth_model "code.gitea.io/gitea/models/auth"
|
||||||
|
issues_model "code.gitea.io/gitea/models/issues"
|
||||||
org_model "code.gitea.io/gitea/models/organization"
|
org_model "code.gitea.io/gitea/models/organization"
|
||||||
"code.gitea.io/gitea/models/perm"
|
"code.gitea.io/gitea/models/perm"
|
||||||
unit_model "code.gitea.io/gitea/models/unit"
|
unit_model "code.gitea.io/gitea/models/unit"
|
||||||
@@ -260,3 +261,50 @@ func TestAPIOrgGeneral(t *testing.T) {
|
|||||||
MakeRequest(t, req, http.StatusForbidden)
|
MakeRequest(t, req, http.StatusForbidden)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestAPIOrgLabelsVisibility ensures the organization label read endpoints honor
|
||||||
|
// the organization visibility: labels of a private org must not be disclosed to
|
||||||
|
// users who cannot see the org.
|
||||||
|
func TestAPIOrgLabelsVisibility(t *testing.T) {
|
||||||
|
defer tests.PrepareTestEnv(t)()
|
||||||
|
|
||||||
|
// privated_org (id 23) is a private organization; user5 is its only member.
|
||||||
|
privateOrg := unittest.AssertExistsAndLoadBean(t, &org_model.Organization{ID: 23})
|
||||||
|
label := &issues_model.Label{OrgID: privateOrg.ID, Name: "internal-label", Color: "#aabbcc", Description: "private organization label"}
|
||||||
|
require.NoError(t, issues_model.NewLabel(t.Context(), label))
|
||||||
|
|
||||||
|
listURL := fmt.Sprintf("/api/v1/orgs/%s/labels", privateOrg.Name)
|
||||||
|
getURL := fmt.Sprintf("/api/v1/orgs/%s/labels/%d", privateOrg.Name, label.ID)
|
||||||
|
|
||||||
|
t.Run("NonMemberDenied", func(t *testing.T) {
|
||||||
|
// user2 is not a member of the private org and must not see its labels.
|
||||||
|
token := getUserToken(t, "user2", auth_model.AccessTokenScopeReadOrganization)
|
||||||
|
MakeRequest(t, NewRequest(t, "GET", listURL).AddTokenAuth(token), http.StatusNotFound)
|
||||||
|
MakeRequest(t, NewRequest(t, "GET", getURL).AddTokenAuth(token), http.StatusNotFound)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("AnonymousDenied", func(t *testing.T) {
|
||||||
|
MakeRequest(t, NewRequest(t, "GET", listURL), http.StatusNotFound)
|
||||||
|
MakeRequest(t, NewRequest(t, "GET", getURL), http.StatusNotFound)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("MemberAllowed", func(t *testing.T) {
|
||||||
|
token := getUserToken(t, "user5", auth_model.AccessTokenScopeReadOrganization)
|
||||||
|
resp := MakeRequest(t, NewRequest(t, "GET", listURL).AddTokenAuth(token), http.StatusOK)
|
||||||
|
labels := DecodeJSON(t, resp, &[]*api.Label{})
|
||||||
|
assert.Len(t, *labels, 1)
|
||||||
|
MakeRequest(t, NewRequest(t, "GET", getURL).AddTokenAuth(token), http.StatusOK)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("SiteAdminAllowed", func(t *testing.T) {
|
||||||
|
token := getUserToken(t, "user1", auth_model.AccessTokenScopeReadOrganization)
|
||||||
|
MakeRequest(t, NewRequest(t, "GET", listURL).AddTokenAuth(token), http.StatusOK)
|
||||||
|
MakeRequest(t, NewRequest(t, "GET", getURL).AddTokenAuth(token), http.StatusOK)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("PublicOrgStillReadable", func(t *testing.T) {
|
||||||
|
// org3 (id 3) is a public org with labels; non-members may read them.
|
||||||
|
token := getUserToken(t, "user2", auth_model.AccessTokenScopeReadOrganization)
|
||||||
|
MakeRequest(t, NewRequest(t, "GET", "/api/v1/orgs/org3/labels").AddTokenAuth(token), http.StatusOK)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -51,6 +51,12 @@ func TestAPITwoFactor(t *testing.T) {
|
|||||||
AddBasicAuth(user.Name)
|
AddBasicAuth(user.Name)
|
||||||
req.Header.Set("X-Gitea-OTP", passcode)
|
req.Header.Set("X-Gitea-OTP", passcode)
|
||||||
MakeRequest(t, req, http.StatusOK)
|
MakeRequest(t, req, http.StatusOK)
|
||||||
|
|
||||||
|
// the same passcode must not be replayable on the basic-auth surface (RFC 6238 single-use)
|
||||||
|
req = NewRequest(t, "GET", "/api/v1/user").
|
||||||
|
AddBasicAuth(user.Name)
|
||||||
|
req.Header.Set("X-Gitea-OTP", passcode)
|
||||||
|
MakeRequest(t, req, http.StatusUnauthorized)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBasicAuthWithWebAuthn(t *testing.T) {
|
func TestBasicAuthWithWebAuthn(t *testing.T) {
|
||||||
|
|||||||
@@ -0,0 +1,269 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package integration
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
auth_model "code.gitea.io/gitea/models/auth"
|
||||||
|
"code.gitea.io/gitea/models/db"
|
||||||
|
"code.gitea.io/gitea/models/unittest"
|
||||||
|
user_model "code.gitea.io/gitea/models/user"
|
||||||
|
"code.gitea.io/gitea/modules/json"
|
||||||
|
"code.gitea.io/gitea/modules/setting"
|
||||||
|
"code.gitea.io/gitea/modules/test"
|
||||||
|
"code.gitea.io/gitea/services/auth/source/oauth2"
|
||||||
|
"code.gitea.io/gitea/tests"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"xorm.io/builder"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestOIDCIgnoresStaleExternalLoginLinks(t *testing.T) {
|
||||||
|
defer tests.PrepareTestEnv(t)()
|
||||||
|
defer test.MockVariableValue(&setting.OAuth2Client.EnableAutoRegistration, true)()
|
||||||
|
defer test.MockVariableValue(&setting.OAuth2Client.AccountLinking, setting.OAuth2AccountLinkingAuto)()
|
||||||
|
defer test.MockVariableValue(&setting.OAuth2Client.Username, setting.OAuth2UsernameEmail)()
|
||||||
|
|
||||||
|
setup := func(t *testing.T, sourceName, sub, userName, email string) (*auth_model.Source, *user_model.User) {
|
||||||
|
t.Helper()
|
||||||
|
srv := newFakeOIDCServerWithProfile(t, sub, sub+"-oid", email, "OIDC Test User")
|
||||||
|
addOAuth2Source(t, sourceName, oauth2.Source{
|
||||||
|
Provider: "openidConnect",
|
||||||
|
ClientID: "test-client-id",
|
||||||
|
ClientSecret: "test-client-secret",
|
||||||
|
OpenIDConnectAutoDiscoveryURL: srv.URL + "/.well-known/openid-configuration",
|
||||||
|
})
|
||||||
|
authSource, err := auth_model.GetActiveOAuth2SourceByAuthName(t.Context(), sourceName)
|
||||||
|
require.NoError(t, err)
|
||||||
|
correctUser := &user_model.User{Name: userName, Email: email}
|
||||||
|
require.NoError(t, user_model.CreateUser(t.Context(), correctUser, &user_model.Meta{}))
|
||||||
|
return authSource, correctUser
|
||||||
|
}
|
||||||
|
|
||||||
|
// assertRelinked signs in via OIDC and asserts the stale link now points at the correct individual user.
|
||||||
|
assertRelinked := func(t *testing.T, authSource *auth_model.Source, sub string, correctUser *user_model.User) {
|
||||||
|
t.Helper()
|
||||||
|
doOIDCSignIn(t, authSource.Name)
|
||||||
|
// external_login_user has no "id" column, so order by the primary key instead
|
||||||
|
externalLink := unittest.AssertExistsAndLoadBean(t, &user_model.ExternalLoginUser{ExternalID: sub, LoginSourceID: authSource.ID}, unittest.OrderBy("external_id ASC"))
|
||||||
|
assert.Equal(t, correctUser.ID, externalLink.UserID)
|
||||||
|
assert.Equal(t, correctUser.Email, externalLink.Email)
|
||||||
|
assert.Equal(t, "OIDC Test User", externalLink.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("organization", func(t *testing.T) {
|
||||||
|
const sub, userName, email = "oidc-stale-org-link-sub", "guizar_m", "guizar_m@example.com"
|
||||||
|
authSource, correctUser := setup(t, "test-oidc-stale-org-link", sub, userName, email)
|
||||||
|
org := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3, Type: user_model.UserTypeOrganization})
|
||||||
|
require.NoError(t, user_model.LinkExternalToUser(t.Context(), org, &user_model.ExternalLoginUser{
|
||||||
|
ExternalID: sub,
|
||||||
|
UserID: org.ID,
|
||||||
|
LoginSourceID: authSource.ID,
|
||||||
|
Provider: authSource.Name,
|
||||||
|
}))
|
||||||
|
assertRelinked(t, authSource, sub, correctUser)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("deleted user", func(t *testing.T) {
|
||||||
|
const sub, userName, email = "oidc-stale-deleted-link-sub", "guizar_d", "guizar_d@example.com"
|
||||||
|
const deletedUserID = 999999
|
||||||
|
authSource, correctUser := setup(t, "test-oidc-stale-deleted", sub, userName, email)
|
||||||
|
// link the external account to a user id that does not exist, simulating a deleted user
|
||||||
|
require.NoError(t, user_model.LinkExternalToUser(t.Context(), &user_model.User{ID: deletedUserID}, &user_model.ExternalLoginUser{
|
||||||
|
ExternalID: sub,
|
||||||
|
UserID: deletedUserID,
|
||||||
|
LoginSourceID: authSource.ID,
|
||||||
|
Provider: authSource.Name,
|
||||||
|
}))
|
||||||
|
assertRelinked(t, authSource, sub, correctUser)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFakeOIDCServerWithProfile(t *testing.T, sub, oid, email, name string) *httptest.Server {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var srv *httptest.Server
|
||||||
|
srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/.well-known/openid-configuration": // discovery document
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||||
|
"issuer": srv.URL,
|
||||||
|
"authorization_endpoint": srv.URL + "/authorize",
|
||||||
|
"token_endpoint": srv.URL + "/token",
|
||||||
|
"userinfo_endpoint": srv.URL + "/userinfo",
|
||||||
|
})
|
||||||
|
case "/token": // returns an ID token with both "sub" and "oid" claims so tests can verify which one ends up as ExternalID
|
||||||
|
claims := map[string]any{
|
||||||
|
"iss": srv.URL,
|
||||||
|
"aud": "test-client-id",
|
||||||
|
"exp": time.Now().Add(time.Hour).Unix(),
|
||||||
|
"sub": sub,
|
||||||
|
"oid": oid,
|
||||||
|
}
|
||||||
|
payload, _ := json.Marshal(claims)
|
||||||
|
header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none"}`))
|
||||||
|
|
||||||
|
// build a JWT-shaped string whose payload encodes claims.
|
||||||
|
// goth's decodeJWT only base64-decodes the payload without verifying the signature, so no real signing infrastructure is needed.
|
||||||
|
idToken := header + "." + base64.RawURLEncoding.EncodeToString(payload) + ".fakesig"
|
||||||
|
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"access_token": "fake-access-token",
|
||||||
|
"token_type": "Bearer",
|
||||||
|
"id_token": idToken,
|
||||||
|
})
|
||||||
|
case "/userinfo":
|
||||||
|
// sub MUST match the id_token sub; goth rejects mismatches.
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"sub": sub,
|
||||||
|
"email": email,
|
||||||
|
"name": name,
|
||||||
|
})
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
return srv
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestOAuth2CallbackReactivationGating exercises the gate in handleOAuth2SignIn:
|
||||||
|
// an inactive user can only be reactivated when who was disabled by auto-sync
|
||||||
|
func TestOAuth2CallbackReactivationGating(t *testing.T) {
|
||||||
|
defer tests.PrepareTestEnv(t)()
|
||||||
|
defer test.MockVariableValue(&setting.OAuth2Client.EnableAutoRegistration, true)()
|
||||||
|
defer test.MockVariableValue(&setting.OAuth2Client.Username, setting.OAuth2UsernameUserid)()
|
||||||
|
|
||||||
|
srv := newFakeOIDCServer(t, FakeOIDCConfig{Sub: "test-sub", Email: "test@example.com", Name: "Test User"})
|
||||||
|
addOAuth2Source(t, "test-oauth-source", oauth2.Source{
|
||||||
|
Provider: "openidConnect",
|
||||||
|
ClientID: "test-client-id",
|
||||||
|
ClientSecret: "test-client-secret",
|
||||||
|
OpenIDConnectAutoDiscoveryURL: srv.URL + "/.well-known/openid-configuration",
|
||||||
|
})
|
||||||
|
authSource, err := auth_model.GetActiveOAuth2SourceByAuthName(t.Context(), "test-oauth-source")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
u := &user_model.User{Name: "test-user", Email: "test@example.com"}
|
||||||
|
require.NoError(t, user_model.CreateUser(t.Context(), u, &user_model.Meta{}))
|
||||||
|
|
||||||
|
extLink := &user_model.ExternalLoginUser{
|
||||||
|
UserID: u.ID,
|
||||||
|
LoginSourceID: authSource.ID,
|
||||||
|
Provider: authSource.Name,
|
||||||
|
ExternalID: "test-sub",
|
||||||
|
}
|
||||||
|
require.NoError(t, user_model.LinkExternalToUser(t.Context(), u, extLink))
|
||||||
|
|
||||||
|
prepareUserExternalLink := func(t *testing.T, refreshToken string) {
|
||||||
|
err := user_model.UpdateUserCols(t.Context(), &user_model.User{ID: u.ID, IsActive: false}, "is_active")
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, err = db.GetEngine(t.Context()).Where(builder.Eq{"user_id": u.ID}).Cols("refresh_token").
|
||||||
|
Update(&user_model.ExternalLoginUser{RefreshToken: refreshToken})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.False(t, unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: u.ID}).IsActive)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("admin-disabled user is not reactivated", func(t *testing.T) {
|
||||||
|
prepareUserExternalLink(t, "non-empty-refresh-token")
|
||||||
|
doOIDCSignIn(t, authSource.Name)
|
||||||
|
after := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: u.ID})
|
||||||
|
assert.False(t, after.IsActive, "OAuth callback must not re-enable an administrator-disabled account")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("auto-sync-disabled user is reactivated", func(t *testing.T) {
|
||||||
|
prepareUserExternalLink(t, "" /* empty refresh token */)
|
||||||
|
doOIDCSignIn(t, authSource.Name)
|
||||||
|
after := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: u.ID})
|
||||||
|
assert.True(t, after.IsActive, "OAuth callback must reactivate a sync-disabled account on successful login")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// FakeOIDCConfig holds configuration for the fake OIDC server used in tests.
|
||||||
|
type FakeOIDCConfig struct {
|
||||||
|
Sub string
|
||||||
|
OID string
|
||||||
|
Email string
|
||||||
|
Name string
|
||||||
|
Groups []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// newFakeOIDCServer starts a httptest.Server that implements the minimum OIDC endpoints needed to complete a sign-in flow
|
||||||
|
func newFakeOIDCServer(t *testing.T, cfg FakeOIDCConfig) *httptest.Server {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var srv *httptest.Server
|
||||||
|
srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/.well-known/openid-configuration": // discovery document
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||||
|
"issuer": srv.URL,
|
||||||
|
"authorization_endpoint": srv.URL + "/authorize",
|
||||||
|
"token_endpoint": srv.URL + "/token",
|
||||||
|
"userinfo_endpoint": srv.URL + "/userinfo",
|
||||||
|
})
|
||||||
|
case "/token": // returns an ID token with both "sub" and "oid" claims so tests can verify which one ends up as ExternalID
|
||||||
|
claims := map[string]any{
|
||||||
|
"iss": srv.URL,
|
||||||
|
"aud": "test-client-id",
|
||||||
|
"exp": time.Now().Add(time.Hour).Unix(),
|
||||||
|
"sub": cfg.Sub,
|
||||||
|
"oid": cfg.OID,
|
||||||
|
}
|
||||||
|
payload, _ := json.Marshal(claims)
|
||||||
|
header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none"}`))
|
||||||
|
|
||||||
|
// build a JWT-shaped string whose payload encodes claims.
|
||||||
|
// goth's decodeJWT only base64-decodes the payload without verifying the signature, so no real signing infrastructure is needed.
|
||||||
|
idToken := header + "." + base64.RawURLEncoding.EncodeToString(payload) + ".fakesig"
|
||||||
|
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"access_token": "fake-access-token",
|
||||||
|
"token_type": "Bearer",
|
||||||
|
"id_token": idToken,
|
||||||
|
})
|
||||||
|
case "/userinfo":
|
||||||
|
// sub MUST match the id_token sub; goth rejects mismatches.
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"sub": cfg.Sub,
|
||||||
|
"email": cfg.Email,
|
||||||
|
"name": cfg.Name,
|
||||||
|
})
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
return srv
|
||||||
|
}
|
||||||
|
|
||||||
|
// doOIDCSignIn runs a mock OIDC sign-in flow for the given auth source.
|
||||||
|
func doOIDCSignIn(t *testing.T, sourceName string) {
|
||||||
|
t.Helper()
|
||||||
|
session := emptyTestSession(t)
|
||||||
|
|
||||||
|
// Step 1: initiate login
|
||||||
|
resp := session.MakeRequest(t, NewRequest(t, "GET", "/user/oauth2/"+sourceName), http.StatusTemporaryRedirect)
|
||||||
|
|
||||||
|
// Step 2: extract the UUID state that Gitea embedded in the redirect URL.
|
||||||
|
location := resp.Header().Get("Location")
|
||||||
|
u, err := url.Parse(location)
|
||||||
|
require.NoError(t, err)
|
||||||
|
state := u.Query().Get("state")
|
||||||
|
require.NotEmpty(t, state, "redirect to OIDC provider must include state")
|
||||||
|
|
||||||
|
// Step 3: simulate the provider redirecting back.
|
||||||
|
callbackURL := fmt.Sprintf("/user/oauth2/%s/callback?code=test-code&state=%s", sourceName, url.QueryEscape(state))
|
||||||
|
session.MakeRequest(t, NewRequest(t, "GET", callbackURL), http.StatusSeeOther)
|
||||||
|
}
|
||||||
@@ -36,9 +36,17 @@ func TestCompareTag(t *testing.T) {
|
|||||||
// A dropdown for both base and head.
|
// A dropdown for both base and head.
|
||||||
assert.Lenf(t, selection.Nodes, 2, "The template has changed")
|
assert.Lenf(t, selection.Nodes, 2, "The template has changed")
|
||||||
|
|
||||||
|
req = NewRequest(t, "GET", "/user2/repo1/compare/v1.1...HEAD")
|
||||||
|
resp = session.MakeRequest(t, req, http.StatusOK)
|
||||||
|
assert.True(t, test.IsNormalPageCompleted(resp.Body.String()))
|
||||||
|
|
||||||
|
req = NewRequest(t, "GET", "/user2/repo1/compare/v1.1...NotExisting").SetHeader("Accept", "text/html")
|
||||||
|
resp = session.MakeRequest(t, req, http.StatusNotFound)
|
||||||
|
assert.True(t, test.IsNormalPageCompleted(resp.Body.String()))
|
||||||
|
|
||||||
req = NewRequest(t, "GET", "/user2/repo1/compare/invalid").SetHeader("Accept", "text/html")
|
req = NewRequest(t, "GET", "/user2/repo1/compare/invalid").SetHeader("Accept", "text/html")
|
||||||
resp = session.MakeRequest(t, req, http.StatusNotFound)
|
resp = session.MakeRequest(t, req, http.StatusNotFound)
|
||||||
assert.True(t, test.IsNormalPageCompleted(resp.Body.String()), "expect 404 page not 500")
|
assert.True(t, test.IsNormalPageCompleted(resp.Body.String()))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compare with inferred default branch (master)
|
// Compare with inferred default branch (master)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
auth_model "code.gitea.io/gitea/models/auth"
|
||||||
"code.gitea.io/gitea/tests"
|
"code.gitea.io/gitea/tests"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -33,3 +34,41 @@ func TestFeedRepo(t *testing.T) {
|
|||||||
assert.NotEmpty(t, rss.Channel.Items[0].PubDate)
|
assert.NotEmpty(t, rss.Channel.Items[0].PubDate)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestFeedRepoContentTokenScopes ensures repository feed endpoints enforce the
|
||||||
|
// repository token scope, so a PAT without repository scope cannot read private
|
||||||
|
// repository commit/activity data through RSS/Atom feeds.
|
||||||
|
func TestFeedRepoContentTokenScopes(t *testing.T) {
|
||||||
|
defer tests.PrepareTestEnv(t)()
|
||||||
|
|
||||||
|
// user2/repo2 is a private repository owned by user2
|
||||||
|
ownerReadToken := getUserToken(t, "user2", auth_model.AccessTokenScopeReadRepository)
|
||||||
|
miscToken := getUserToken(t, "user2", auth_model.AccessTokenScopeReadMisc)
|
||||||
|
|
||||||
|
urls := []string{
|
||||||
|
"/user2/repo2.rss",
|
||||||
|
"/user2/repo2.atom",
|
||||||
|
"/user2/repo2/rss/branch/master",
|
||||||
|
"/user2/repo2/atom/branch/master",
|
||||||
|
"/user2/repo2/rss/branch/master/README.md",
|
||||||
|
"/user2/repo2/tags.rss",
|
||||||
|
"/user2/repo2/tags.atom",
|
||||||
|
"/user2/repo2/releases.rss",
|
||||||
|
"/user2/repo2/releases.atom",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, url := range urls {
|
||||||
|
t.Run(url, func(t *testing.T) {
|
||||||
|
// feed routes only accept basic auth, so authenticate as the advisory PoC does (user:token)
|
||||||
|
reqDenied := NewRequest(t, "GET", url)
|
||||||
|
reqDenied.SetBasicAuth("user2", miscToken)
|
||||||
|
// a token without repository scope must be denied
|
||||||
|
MakeRequest(t, reqDenied, http.StatusForbidden)
|
||||||
|
|
||||||
|
reqAllowed := NewRequest(t, "GET", url)
|
||||||
|
reqAllowed.SetBasicAuth("user2", ownerReadToken)
|
||||||
|
// a token with repository read scope is allowed
|
||||||
|
MakeRequest(t, reqAllowed, http.StatusOK)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
auth_model "code.gitea.io/gitea/models/auth"
|
auth_model "code.gitea.io/gitea/models/auth"
|
||||||
|
"code.gitea.io/gitea/models/perm"
|
||||||
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/unittest"
|
"code.gitea.io/gitea/models/unittest"
|
||||||
"code.gitea.io/gitea/modules/setting"
|
"code.gitea.io/gitea/modules/setting"
|
||||||
"code.gitea.io/gitea/modules/test"
|
"code.gitea.io/gitea/modules/test"
|
||||||
@@ -26,6 +28,8 @@ func TestGitSmartHTTP(t *testing.T) {
|
|||||||
testGitSmartHTTPTokenScopes(t)
|
testGitSmartHTTPTokenScopes(t)
|
||||||
testRenamedRepoRedirect(t)
|
testRenamedRepoRedirect(t)
|
||||||
testGitArchiveRemote(t, u)
|
testGitArchiveRemote(t, u)
|
||||||
|
t.Run("AnonymousAccess-Repo", func(t *testing.T) { testGitSmartHTTPPrivateRepoAnonymousAccess(t, false) })
|
||||||
|
t.Run("AnonymousAccess-Wiki", func(t *testing.T) { testGitSmartHTTPPrivateRepoAnonymousAccess(t, true) })
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,3 +148,33 @@ func testGitArchiveRemote(t *testing.T, u *url.URL) {
|
|||||||
t.Run("Fetch HEAD archive subpath", doGitRemoteArchive(u.String(), "HEAD", "test"))
|
t.Run("Fetch HEAD archive subpath", doGitRemoteArchive(u.String(), "HEAD", "test"))
|
||||||
t.Run("list compression options", doGitRemoteArchive(u.String(), "--list"))
|
t.Run("list compression options", doGitRemoteArchive(u.String(), "--list"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// testGitSmartHTTPPrivateRepoAnonymousAccess tests that a private repo with
|
||||||
|
// anonymous code access enabled can be cloned without credentials.
|
||||||
|
func testGitSmartHTTPPrivateRepoAnonymousAccess(t *testing.T, isWiki bool) {
|
||||||
|
// repo1 (ID=1) belongs to user2 and is public by default in fixtures
|
||||||
|
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1, OwnerName: "user2", Name: "repo1"})
|
||||||
|
unitType := util.Iif(isWiki, unit.TypeWiki, unit.TypeCode)
|
||||||
|
repoLink := "/" + repo.FullName() + util.Iif(isWiki, ".wiki", "")
|
||||||
|
gitPullPath := repoLink + "/info/refs?service=git-upload-pack"
|
||||||
|
gitPushPath := repoLink + "/info/refs?service=git-receive-pack"
|
||||||
|
|
||||||
|
// make the repo private
|
||||||
|
require.NoError(t, repo_model.UpdateRepositoryColsNoAutoTime(t.Context(), &repo_model.Repository{ID: repo.ID, IsPrivate: true}, "is_private"))
|
||||||
|
|
||||||
|
// without anonymous access: anonymous pull must require auth
|
||||||
|
MakeRequest(t, NewRequest(t, "GET", gitPullPath), http.StatusUnauthorized)
|
||||||
|
|
||||||
|
// enable anonymous read access on the unit
|
||||||
|
require.NoError(t, repo_model.UpdateRepoUnitPublicAccess(t.Context(), &repo_model.RepoUnit{RepoID: repo.ID, Type: unitType, AnonymousAccessMode: perm.AccessModeRead}))
|
||||||
|
|
||||||
|
// with anonymous code access: anonymous pull must succeed without credentials
|
||||||
|
MakeRequest(t, NewRequest(t, "GET", gitPullPath), http.StatusOK)
|
||||||
|
|
||||||
|
// push (receive-pack) must still require auth even with anonymous code access
|
||||||
|
MakeRequest(t, NewRequest(t, "GET", gitPushPath), http.StatusUnauthorized)
|
||||||
|
|
||||||
|
// RequireSignInViewStrict must override anonymous access
|
||||||
|
defer test.MockVariableValue(&setting.Service.RequireSignInViewStrict, true)()
|
||||||
|
MakeRequest(t, NewRequest(t, "GET", gitPullPath), http.StatusUnauthorized)
|
||||||
|
}
|
||||||
|
|||||||
@@ -682,6 +682,59 @@ func TestUpdateIssueDeadline(t *testing.T) {
|
|||||||
assert.True(t, issueAfter.DeadlineUnix.IsZero())
|
assert.True(t, issueAfter.DeadlineUnix.IsZero())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUpdateIssueRefByPoster(t *testing.T) {
|
||||||
|
defer tests.PrepareTestEnv(t)()
|
||||||
|
|
||||||
|
// user4 is a non-admin, non-collaborator on user2/repo1.
|
||||||
|
// They create an issue, making them the poster.
|
||||||
|
posterSession := loginUser(t, "user4")
|
||||||
|
issueURL := testNewIssue(t, posterSession, "user2", "repo1", "Poster ref test", "body")
|
||||||
|
refURL := issueURL + "/ref"
|
||||||
|
|
||||||
|
// The poster (non-collaborator) must be able to update the ref.
|
||||||
|
req := NewRequestWithValues(t, "POST", refURL, map[string]string{"ref": "refs/heads/main"})
|
||||||
|
posterSession.MakeRequest(t, req, http.StatusOK)
|
||||||
|
|
||||||
|
// A different non-collaborator non-poster must be forbidden.
|
||||||
|
otherSession := loginUser(t, "user5")
|
||||||
|
req = NewRequestWithValues(t, "POST", refURL, map[string]string{"ref": "refs/heads/main"})
|
||||||
|
otherSession.MakeRequest(t, req, http.StatusForbidden)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIssueRefSelectorEnabledForPoster(t *testing.T) {
|
||||||
|
defer tests.PrepareTestEnv(t)()
|
||||||
|
|
||||||
|
// user4 creates an issue in user2/repo1 (user4 has no write permission there).
|
||||||
|
posterSession := loginUser(t, "user4")
|
||||||
|
issueURL := testNewIssue(t, posterSession, "user2", "repo1", "Ref selector test", "body")
|
||||||
|
|
||||||
|
resp := posterSession.MakeRequest(t, NewRequest(t, "GET", issueURL), http.StatusOK)
|
||||||
|
htmlDoc := NewHTMLParser(t, resp.Body)
|
||||||
|
|
||||||
|
// The branch selector must not carry the "disabled" CSS class for the poster.
|
||||||
|
sel := htmlDoc.Find(".branch-selector-dropdown")
|
||||||
|
assert.Equal(t, 1, sel.Length())
|
||||||
|
assert.False(t, sel.HasClass("disabled"), "branch selector should be enabled for the issue poster")
|
||||||
|
// The update-ref URL must be present so JS can send the POST request.
|
||||||
|
_, hasURL := sel.Attr("data-url-update-issueref")
|
||||||
|
assert.True(t, hasURL, "data-url-update-issueref must be set for the issue poster")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIssueRefSelectorEnabledForNewIssue(t *testing.T) {
|
||||||
|
defer tests.PrepareTestEnv(t)()
|
||||||
|
|
||||||
|
// user4 (non-collaborator on user2/repo1) must see an enabled ref selector
|
||||||
|
// when creating a new issue.
|
||||||
|
session := loginUser(t, "user4")
|
||||||
|
req := NewRequest(t, "GET", "/user2/repo1/issues/new")
|
||||||
|
resp := session.MakeRequest(t, req, http.StatusOK)
|
||||||
|
htmlDoc := NewHTMLParser(t, resp.Body)
|
||||||
|
|
||||||
|
sel := htmlDoc.Find(".branch-selector-dropdown")
|
||||||
|
assert.Equal(t, 1, sel.Length())
|
||||||
|
assert.False(t, sel.HasClass("disabled"), "branch selector should be enabled on the new issue form")
|
||||||
|
}
|
||||||
|
|
||||||
func TestIssueReferenceURL(t *testing.T) {
|
func TestIssueReferenceURL(t *testing.T) {
|
||||||
defer tests.PrepareTestEnv(t)()
|
defer tests.PrepareTestEnv(t)()
|
||||||
session := loginUser(t, "user2")
|
session := loginUser(t, "user2")
|
||||||
|
|||||||
@@ -173,5 +173,18 @@ func TestRepoMergeUpstream(t *testing.T) {
|
|||||||
}).AddTokenAuth(token)
|
}).AddTokenAuth(token)
|
||||||
MakeRequest(t, req, http.StatusBadRequest)
|
MakeRequest(t, req, http.StatusBadRequest)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("BasePrivateBlocksSync", func(t *testing.T) {
|
||||||
|
// add a new commit to the base repo, then make the base repo private
|
||||||
|
require.NoError(t, createOrReplaceFileInBranch(baseUser, baseRepo, "secret.txt", "master", "private-content"))
|
||||||
|
baseRepo.IsPrivate = true
|
||||||
|
_, err := db.GetEngine(t.Context()).ID(baseRepo.ID).Cols("is_private").Update(baseRepo)
|
||||||
|
require.NoError(t, err)
|
||||||
|
// the fork owner can no longer read the base repo, so syncing must be refused
|
||||||
|
req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/test-repo-fork/merge-upstream", forkUser.Name), &api.MergeUpstreamRequest{
|
||||||
|
Branch: "fork-branch",
|
||||||
|
}).AddTokenAuth(token)
|
||||||
|
MakeRequest(t, req, http.StatusForbidden)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ RUN_MODE = prod
|
|||||||
|
|
||||||
[database]
|
[database]
|
||||||
DB_TYPE = sqlite3
|
DB_TYPE = sqlite3
|
||||||
PATH = gitea.db
|
PATH = gitea-test.db
|
||||||
|
|
||||||
[indexer]
|
[indexer]
|
||||||
REPO_INDEXER_ENABLED = true
|
REPO_INDEXER_ENABLED = true
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import {argv, env, exit} from 'node:process';
|
||||||
|
|
||||||
|
const allowedTypes = [
|
||||||
|
'build',
|
||||||
|
'chore',
|
||||||
|
'ci',
|
||||||
|
'docs',
|
||||||
|
'enhance',
|
||||||
|
'feat',
|
||||||
|
'fix',
|
||||||
|
'perf',
|
||||||
|
'refactor',
|
||||||
|
'revert',
|
||||||
|
'style',
|
||||||
|
'test',
|
||||||
|
] as const;
|
||||||
|
type CommitType = typeof allowedTypes[number];
|
||||||
|
|
||||||
|
const allowedTypesList = allowedTypes.join(', ');
|
||||||
|
const titlePattern = new RegExp(`^(${allowedTypes.join('|')})(\\([\\w/.-]+\\))?(!)?: .+$`);
|
||||||
|
|
||||||
|
function parsePrTitle(title: string): {type: CommitType; breaking: boolean} | null {
|
||||||
|
const match = titlePattern.exec(title);
|
||||||
|
return match ? {type: match[1] as CommitType, breaking: Boolean(match[3])} : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const breakingLabel = 'pr/breaking';
|
||||||
|
|
||||||
|
// Mutually exclusive type labels, fully synced with the title type (added and removed).
|
||||||
|
const typeLabels: Partial<Record<CommitType, string>> = {
|
||||||
|
feat: 'type/feature',
|
||||||
|
enhance: 'type/enhancement',
|
||||||
|
fix: 'type/bug',
|
||||||
|
docs: 'type/docs',
|
||||||
|
test: 'type/testing',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Non-type labels, only added, never auto-removed, so manual labeling is not clobbered.
|
||||||
|
const extraLabels: Partial<Record<CommitType, string>> = {
|
||||||
|
chore: 'skip-changelog',
|
||||||
|
ci: 'skip-changelog',
|
||||||
|
build: 'topic/build',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Labels this tool may remove when the title no longer implies them.
|
||||||
|
const removableLabels = [...Object.values(typeLabels), breakingLabel];
|
||||||
|
|
||||||
|
function labelsForPrTitle(title: string): string[] {
|
||||||
|
const parsed = parsePrTitle(title);
|
||||||
|
if (!parsed) return [];
|
||||||
|
return [typeLabels[parsed.type], extraLabels[parsed.type], parsed.breaking ? breakingLabel : undefined]
|
||||||
|
.filter((label): label is string => label !== undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Command: validate PR_TITLE against the allowed Conventional Commits format.
|
||||||
|
function lintPrTitle(): void {
|
||||||
|
if (!env.PR_TITLE) {
|
||||||
|
console.error('Missing PR_TITLE');
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
if (!parsePrTitle(env.PR_TITLE)) {
|
||||||
|
console.error(`Invalid PR title: ${env.PR_TITLE}`);
|
||||||
|
console.error('Expected format: type(scope): subject (scope optional, append "!" for breaking changes)');
|
||||||
|
console.error(`Allowed types: ${allowedTypesList}`);
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Command: sync the title-derived labels onto the PR via the GitHub API.
|
||||||
|
async function setPrLabels(): Promise<void> {
|
||||||
|
if (!env.PR_TITLE || !env.GITHUB_TOKEN || !env.GITHUB_REPOSITORY || !env.PR_NUMBER) {
|
||||||
|
console.error('set-pr-labels requires PR_TITLE, GITHUB_TOKEN, GITHUB_REPOSITORY and PR_NUMBER');
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const labelsUrl = `https://api.github.com/repos/${env.GITHUB_REPOSITORY}/issues/${env.PR_NUMBER}/labels`;
|
||||||
|
|
||||||
|
async function request(url: string, method = 'GET', body?: unknown): Promise<Response> {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/vnd.github+json',
|
||||||
|
Authorization: `Bearer ${env.GITHUB_TOKEN}`,
|
||||||
|
'X-GitHub-Api-Version': '2022-11-28',
|
||||||
|
...(body ? {'Content-Type': 'application/json'} : {}),
|
||||||
|
},
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`GitHub API ${method} ${url} failed (${response.status}): ${await response.text()}`);
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
const desired = labelsForPrTitle(env.PR_TITLE);
|
||||||
|
const response = await request(`${labelsUrl}?per_page=100`);
|
||||||
|
const current = ((await response.json()) as Array<{name: string}>).map((label) => label.name);
|
||||||
|
|
||||||
|
const toAdd = desired.filter((name) => !current.includes(name));
|
||||||
|
const toRemove = removableLabels.filter((name) => current.includes(name) && !desired.includes(name));
|
||||||
|
|
||||||
|
if (toAdd.length) {
|
||||||
|
await request(labelsUrl, 'POST', {labels: toAdd});
|
||||||
|
console.info(`Added labels: ${toAdd.join(', ')}`);
|
||||||
|
}
|
||||||
|
for (const name of toRemove) {
|
||||||
|
await request(`${labelsUrl}/${encodeURIComponent(name)}`, 'DELETE');
|
||||||
|
console.info(`Removed label: ${name}`);
|
||||||
|
}
|
||||||
|
if (!toAdd.length && !toRemove.length) {
|
||||||
|
console.info('PR labels already in sync');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const commands: Record<string, () => void | Promise<void>> = {
|
||||||
|
'lint-pr-title': lintPrTitle,
|
||||||
|
'set-pr-labels': setPrLabels,
|
||||||
|
};
|
||||||
|
|
||||||
|
const command = argv[2];
|
||||||
|
const handler = commands[command];
|
||||||
|
if (!handler) {
|
||||||
|
console.error(`Usage: ci-tools.ts <${Object.keys(commands).join('|')}>`);
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await handler();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error instanceof Error ? error.message : error);
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
@import "../../node_modules/swagger-ui-dist/swagger-ui.css";
|
@import "swagger-ui-dist/swagger-ui.css";
|
||||||
|
|
||||||
html,
|
html,
|
||||||
html body,
|
html body,
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user