feat: update gitea vendor
runner nix smoke / nix label and flake smoke (push) Failing after 1m28s

This commit is contained in:
2026-09-26 21:18:24 +00:00
parent c439c1b948
commit d9b2a4e787
3538 changed files with 116131 additions and 44340 deletions
+17 -17
View File
@@ -12,23 +12,23 @@ import (
"strings"
"time"
activities_model "code.gitea.io/gitea/models/activities"
"code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/modules/base"
"code.gitea.io/gitea/modules/cache"
"code.gitea.io/gitea/modules/graceful"
"code.gitea.io/gitea/modules/httplib"
"code.gitea.io/gitea/modules/json"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/templates"
"code.gitea.io/gitea/modules/updatechecker"
"code.gitea.io/gitea/modules/web"
"code.gitea.io/gitea/services/context"
"code.gitea.io/gitea/services/cron"
"code.gitea.io/gitea/services/forms"
release_service "code.gitea.io/gitea/services/release"
repo_service "code.gitea.io/gitea/services/repository"
activities_model "gitea.dev/models/activities"
"gitea.dev/models/db"
"gitea.dev/modules/base"
"gitea.dev/modules/cache"
"gitea.dev/modules/graceful"
"gitea.dev/modules/httplib"
"gitea.dev/modules/json"
"gitea.dev/modules/log"
"gitea.dev/modules/setting"
"gitea.dev/modules/templates"
"gitea.dev/modules/updatechecker"
"gitea.dev/modules/web"
"gitea.dev/services/context"
"gitea.dev/services/cron"
"gitea.dev/services/forms"
release_service "gitea.dev/services/release"
repo_service "gitea.dev/services/repository"
)
const (
@@ -7,73 +7,15 @@ import (
"net/http"
"testing"
"code.gitea.io/gitea/modules/json"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/test"
"code.gitea.io/gitea/services/contexttest"
"gitea.dev/modules/json"
"gitea.dev/modules/setting"
"gitea.dev/modules/test"
"gitea.dev/services/contexttest"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestShadowPassword(t *testing.T) {
kases := []struct {
Provider string
CfgItem string
Result string
}{
{
Provider: "redis",
CfgItem: "network=tcp,addr=:6379,password=gitea,db=0,pool_size=100,idle_timeout=180",
Result: "network=tcp,addr=:6379,password=******,db=0,pool_size=100,idle_timeout=180",
},
{
Provider: "mysql",
CfgItem: "root:@tcp(localhost:3306)/gitea?charset=utf8",
Result: "root:******@tcp(localhost:3306)/gitea?charset=utf8",
},
{
Provider: "mysql",
CfgItem: "/gitea?charset=utf8",
Result: "/gitea?charset=utf8",
},
{
Provider: "mysql",
CfgItem: "user:mypassword@/dbname",
Result: "user:******@/dbname",
},
{
Provider: "postgres",
CfgItem: "user=pqgotest dbname=pqgotest sslmode=verify-full",
Result: "user=pqgotest dbname=pqgotest sslmode=verify-full",
},
{
Provider: "postgres",
CfgItem: "user=pqgotest password= dbname=pqgotest sslmode=verify-full",
Result: "user=pqgotest password=****** dbname=pqgotest sslmode=verify-full",
},
{
Provider: "postgres",
CfgItem: "postgres://user:pass@hostname/dbname",
Result: "postgres://user:******@hostname/dbname",
},
{
Provider: "couchbase",
CfgItem: "http://dev-couchbase.example.com:8091/",
Result: "http://dev-couchbase.example.com:8091/",
},
{
Provider: "couchbase",
CfgItem: "http://user:the_password@dev-couchbase.example.com:8091/",
Result: "http://user:******@dev-couchbase.example.com:8091/",
},
}
for _, k := range kases {
assert.Equal(t, k.Result, shadowPassword(k.Provider, k.CfgItem))
}
}
func TestSelfCheckPost(t *testing.T) {
defer test.MockVariableValue(&setting.PublicURLDetection)()
defer test.MockVariableValue(&setting.AppURL, "http://config/sub/")()
@@ -6,12 +6,12 @@ package admin
import (
"net/http"
"code.gitea.io/gitea/models/auth"
"code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/templates"
user_setting "code.gitea.io/gitea/routers/web/user/setting"
"code.gitea.io/gitea/services/context"
"gitea.dev/models/auth"
"gitea.dev/models/db"
"gitea.dev/modules/setting"
"gitea.dev/modules/templates"
user_setting "gitea.dev/routers/web/user/setting"
"gitea.dev/services/context"
)
var (
+18 -17
View File
@@ -12,22 +12,22 @@ import (
"strconv"
"strings"
"code.gitea.io/gitea/models/auth"
"code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/modules/auth/pam"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/templates"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/modules/web"
auth_service "code.gitea.io/gitea/services/auth"
"code.gitea.io/gitea/services/auth/source/ldap"
"code.gitea.io/gitea/services/auth/source/oauth2"
pam_service "code.gitea.io/gitea/services/auth/source/pam"
"code.gitea.io/gitea/services/auth/source/smtp"
"code.gitea.io/gitea/services/auth/source/sspi"
"code.gitea.io/gitea/services/context"
"code.gitea.io/gitea/services/forms"
"gitea.dev/models/auth"
"gitea.dev/models/db"
"gitea.dev/modules/auth/pam"
"gitea.dev/modules/log"
"gitea.dev/modules/setting"
"gitea.dev/modules/templates"
"gitea.dev/modules/util"
"gitea.dev/modules/web"
auth_service "gitea.dev/services/auth"
"gitea.dev/services/auth/source/ldap"
"gitea.dev/services/auth/source/oauth2"
pam_service "gitea.dev/services/auth/source/pam"
"gitea.dev/services/auth/source/smtp"
"gitea.dev/services/auth/source/sspi"
"gitea.dev/services/context"
"gitea.dev/services/forms"
)
const (
@@ -205,6 +205,7 @@ func parseOAuth2Config(form forms.AuthenticationForm) *oauth2.Source {
SSHPublicKeyClaimName: form.Oauth2SSHPublicKeyClaimName,
FullNameClaimName: form.Oauth2FullNameClaimName,
ExternalIDClaim: form.OpenIDConnectExternalIDClaim,
}
}
@@ -439,7 +440,7 @@ func EditAuthSourcePost(ctx *context.Context) {
log.Trace("Authentication changed by admin(%s): %d", ctx.Doer.Name, source.ID)
ctx.Flash.Success(ctx.Tr("admin.auths.update_success"))
ctx.Redirect(setting.AppSubURL + "/-/admin/auths/" + strconv.FormatInt(form.ID, 10))
ctx.Redirect(setting.AppSubURL + "/-/admin/auths/" + strconv.FormatInt(source.ID, 10))
}
// DeleteAuthSource response for deleting an auth source
@@ -9,15 +9,15 @@ import (
"net/url"
"strings"
"code.gitea.io/gitea/models/db"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/templates"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/modules/web"
"code.gitea.io/gitea/services/context"
"code.gitea.io/gitea/services/forms"
"gitea.dev/models/db"
user_model "gitea.dev/models/user"
"gitea.dev/modules/log"
"gitea.dev/modules/setting"
"gitea.dev/modules/templates"
"gitea.dev/modules/util"
"gitea.dev/modules/web"
"gitea.dev/services/context"
"gitea.dev/services/forms"
)
const (
@@ -7,20 +7,18 @@ package admin
import (
"errors"
"net/http"
"net/url"
"strings"
system_model "code.gitea.io/gitea/models/system"
"code.gitea.io/gitea/modules/cache"
"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/json"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/setting/config"
"code.gitea.io/gitea/modules/templates"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/services/context"
"code.gitea.io/gitea/services/mailer"
system_model "gitea.dev/models/system"
"gitea.dev/modules/cache"
"gitea.dev/modules/git"
"gitea.dev/modules/json"
"gitea.dev/modules/log"
"gitea.dev/modules/setting"
"gitea.dev/modules/setting/config"
"gitea.dev/modules/templates"
"gitea.dev/modules/util"
"gitea.dev/services/context"
"gitea.dev/services/mailer"
"gitea.com/go-chi/session"
)
@@ -59,63 +57,6 @@ func TestCache(ctx *context.Context) {
ctx.Redirect(setting.AppSubURL + "/-/admin/config")
}
func shadowPasswordKV(cfgItem, splitter string) string {
fields := strings.Split(cfgItem, splitter)
for i := range fields {
if strings.HasPrefix(fields[i], "password=") {
fields[i] = "password=******"
break
}
}
return strings.Join(fields, splitter)
}
func shadowURL(provider, cfgItem string) string {
u, err := url.Parse(cfgItem)
if err != nil {
log.Error("Shadowing Password for %v failed: %v", provider, err)
return cfgItem
}
if u.User != nil {
atIdx := strings.Index(cfgItem, "@")
if atIdx > 0 {
colonIdx := strings.LastIndex(cfgItem[:atIdx], ":")
if colonIdx > 0 {
return cfgItem[:colonIdx+1] + "******" + cfgItem[atIdx:]
}
}
}
return cfgItem
}
func shadowPassword(provider, cfgItem string) string {
switch provider {
case "redis":
return shadowPasswordKV(cfgItem, ",")
case "mysql":
// root:@tcp(localhost:3306)/macaron?charset=utf8
atIdx := strings.Index(cfgItem, "@")
if atIdx > 0 {
colonIdx := strings.Index(cfgItem[:atIdx], ":")
if colonIdx > 0 {
return cfgItem[:colonIdx+1] + "******" + cfgItem[atIdx:]
}
}
return cfgItem
case "postgres":
// user=jiahuachen dbname=macaron port=5432 sslmode=disable
if !strings.HasPrefix(cfgItem, "postgres://") {
return shadowPasswordKV(cfgItem, " ")
}
fallthrough
case "couchbase":
return shadowURL(provider, cfgItem)
// postgres://pqgotest:password@localhost/pqgotest?sslmode=verify-full
// Notice: use shadowURL
}
return cfgItem
}
// Config show admin config page
func Config(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("admin.config_summary")
@@ -150,8 +91,6 @@ func Config(ctx *context.Context) {
ctx.Data["CacheAdapter"] = setting.CacheService.Adapter
ctx.Data["CacheInterval"] = setting.CacheService.Interval
ctx.Data["CacheConn"] = shadowPassword(setting.CacheService.Adapter, setting.CacheService.Conn)
ctx.Data["CacheItemTTL"] = setting.CacheService.TTL
sessionCfg := setting.SessionConfig
@@ -169,7 +108,7 @@ func Config(ctx *context.Context) {
sessionCfg.Secure = realSession.Secure
sessionCfg.Domain = realSession.Domain
}
sessionCfg.ProviderConfig = shadowPassword(sessionCfg.Provider, sessionCfg.ProviderConfig)
sessionCfg.ProviderConfig = ""
ctx.Data["SessionConfig"] = sessionCfg
ctx.Data["Git"] = setting.Git
@@ -9,10 +9,10 @@ import (
"runtime/pprof"
"time"
"code.gitea.io/gitea/modules/httplib"
"code.gitea.io/gitea/modules/tailmsg"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/services/context"
"gitea.dev/modules/httplib"
"gitea.dev/modules/tailmsg"
"gitea.dev/modules/util"
"gitea.dev/services/context"
)
func MonitorDiagnosis(ctx *context.Context) {
@@ -8,14 +8,14 @@ import (
"net/http"
"net/url"
"code.gitea.io/gitea/models/db"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/optional"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/templates"
"code.gitea.io/gitea/services/context"
"code.gitea.io/gitea/services/user"
"gitea.dev/models/db"
user_model "gitea.dev/models/user"
"gitea.dev/modules/log"
"gitea.dev/modules/optional"
"gitea.dev/modules/setting"
"gitea.dev/modules/templates"
"gitea.dev/services/context"
"gitea.dev/services/user"
)
const (
@@ -6,11 +6,11 @@ package admin
import (
"net/http"
"code.gitea.io/gitea/models/webhook"
"code.gitea.io/gitea/modules/optional"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/templates"
"code.gitea.io/gitea/services/context"
"gitea.dev/models/webhook"
"gitea.dev/modules/optional"
"gitea.dev/modules/setting"
"gitea.dev/modules/templates"
"gitea.dev/services/context"
)
const (
@@ -6,7 +6,7 @@ package admin
import (
"testing"
"code.gitea.io/gitea/models/unittest"
"gitea.dev/models/unittest"
)
func TestMain(m *testing.M) {
@@ -8,12 +8,12 @@ import (
"net/http"
"strconv"
"code.gitea.io/gitea/models/db"
system_model "code.gitea.io/gitea/models/system"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/templates"
"code.gitea.io/gitea/services/context"
"gitea.dev/models/db"
system_model "gitea.dev/models/system"
"gitea.dev/modules/log"
"gitea.dev/modules/setting"
"gitea.dev/modules/templates"
"gitea.dev/services/context"
)
const (
+9 -11
View File
@@ -5,13 +5,13 @@
package admin
import (
"code.gitea.io/gitea/models/db"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/templates"
"code.gitea.io/gitea/routers/web/explore"
"code.gitea.io/gitea/services/context"
"gitea.dev/models/db"
user_model "gitea.dev/models/user"
"gitea.dev/modules/setting"
"gitea.dev/modules/structs"
"gitea.dev/modules/templates"
"gitea.dev/routers/web/explore"
"gitea.dev/services/context"
)
const (
@@ -23,10 +23,7 @@ func Organizations(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("admin.organizations")
ctx.Data["PageIsAdminOrganizations"] = true
if ctx.FormString("sort") == "" {
ctx.SetFormString("sort", UserSearchDefaultAdminSort)
}
sortOrder := ctx.FormString("sort", UserSearchDefaultAdminSort)
explore.RenderUserSearch(ctx, user_model.SearchUserOptions{
Actor: ctx.Doer,
Types: []user_model.UserType{user_model.UserTypeOrganization},
@@ -35,5 +32,6 @@ func Organizations(ctx *context.Context) {
PageSize: setting.UI.Admin.OrgPagingNum,
},
Visible: []structs.VisibleType{structs.VisibleTypePublic, structs.VisibleTypeLimited, structs.VisibleTypePrivate},
OrderBy: db.SearchOrderBy(sortOrder),
}, tplOrgs)
}
@@ -8,14 +8,14 @@ import (
"net/url"
"time"
"code.gitea.io/gitea/models/db"
packages_model "code.gitea.io/gitea/models/packages"
"code.gitea.io/gitea/modules/optional"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/templates"
"code.gitea.io/gitea/services/context"
packages_service "code.gitea.io/gitea/services/packages"
packages_cleanup_service "code.gitea.io/gitea/services/packages/cleanup"
"gitea.dev/models/db"
packages_model "gitea.dev/models/packages"
"gitea.dev/modules/optional"
"gitea.dev/modules/setting"
"gitea.dev/modules/templates"
"gitea.dev/services/context"
packages_service "gitea.dev/services/packages"
packages_cleanup_service "gitea.dev/services/packages/cleanup"
)
const (
@@ -6,8 +6,8 @@ package admin
import (
"net/http"
"code.gitea.io/gitea/modules/tailmsg"
"code.gitea.io/gitea/services/context"
"gitea.dev/modules/tailmsg"
"gitea.dev/services/context"
)
func PerfTrace(ctx *context.Context) {
@@ -7,9 +7,9 @@ import (
"net/http"
"strconv"
"code.gitea.io/gitea/modules/queue"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/services/context"
"gitea.dev/modules/queue"
"gitea.dev/modules/setting"
"gitea.dev/services/context"
)
func Queues(ctx *context.Context) {
@@ -8,11 +8,11 @@ import (
"sync"
"time"
"code.gitea.io/gitea/modules/graceful"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/process"
"code.gitea.io/gitea/modules/queue"
"code.gitea.io/gitea/modules/setting"
"gitea.dev/modules/graceful"
"gitea.dev/modules/log"
"gitea.dev/modules/process"
"gitea.dev/modules/queue"
"gitea.dev/modules/setting"
)
var testQueueOnce sync.Once
+10 -10
View File
@@ -8,16 +8,16 @@ import (
"net/url"
"strings"
"code.gitea.io/gitea/models/db"
repo_model "code.gitea.io/gitea/models/repo"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/gitrepo"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/templates"
"code.gitea.io/gitea/routers/web/explore"
"code.gitea.io/gitea/services/context"
repo_service "code.gitea.io/gitea/services/repository"
"gitea.dev/models/db"
repo_model "gitea.dev/models/repo"
user_model "gitea.dev/models/user"
"gitea.dev/modules/gitrepo"
"gitea.dev/modules/log"
"gitea.dev/modules/setting"
"gitea.dev/modules/templates"
"gitea.dev/routers/web/explore"
"gitea.dev/services/context"
repo_service "gitea.dev/services/repository"
)
const (
@@ -7,9 +7,9 @@ import (
"net/http"
"runtime"
"code.gitea.io/gitea/modules/process"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/services/context"
"gitea.dev/modules/process"
"gitea.dev/modules/setting"
"gitea.dev/services/context"
)
func monitorTraceCommon(ctx *context.Context) {
+21 -24
View File
@@ -11,25 +11,25 @@ import (
"strconv"
"strings"
"code.gitea.io/gitea/models/auth"
"code.gitea.io/gitea/models/db"
org_model "code.gitea.io/gitea/models/organization"
packages_model "code.gitea.io/gitea/models/packages"
repo_model "code.gitea.io/gitea/models/repo"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/auth/password"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/optional"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/templates"
"code.gitea.io/gitea/modules/web"
"code.gitea.io/gitea/routers/web/explore"
user_setting "code.gitea.io/gitea/routers/web/user/setting"
"code.gitea.io/gitea/services/context"
"code.gitea.io/gitea/services/forms"
"code.gitea.io/gitea/services/mailer"
user_service "code.gitea.io/gitea/services/user"
"gitea.dev/models/auth"
"gitea.dev/models/db"
org_model "gitea.dev/models/organization"
packages_model "gitea.dev/models/packages"
repo_model "gitea.dev/models/repo"
user_model "gitea.dev/models/user"
"gitea.dev/modules/auth/password"
"gitea.dev/modules/log"
"gitea.dev/modules/optional"
"gitea.dev/modules/setting"
"gitea.dev/modules/structs"
"gitea.dev/modules/templates"
"gitea.dev/modules/web"
"gitea.dev/routers/web/explore"
user_setting "gitea.dev/routers/web/user/setting"
"gitea.dev/services/context"
"gitea.dev/services/forms"
"gitea.dev/services/mailer"
user_service "gitea.dev/services/user"
)
const (
@@ -55,11 +55,7 @@ func Users(ctx *context.Context) {
statusFilterMap[filterKey] = paramVal
}
sortType := ctx.FormString("sort")
if sortType == "" {
sortType = UserSearchDefaultAdminSort
ctx.SetFormString("sort", sortType)
}
sortType := ctx.FormString("sort", UserSearchDefaultAdminSort)
ctx.PageData["adminUserListSearchForm"] = map[string]any{
"StatusFilterMap": statusFilterMap,
"SortType": sortType,
@@ -78,6 +74,7 @@ func Users(ctx *context.Context) {
IsTwoFactorEnabled: optional.ParseBool(statusFilterMap["is_2fa_enabled"]),
IsProhibitLogin: optional.ParseBool(statusFilterMap["is_prohibit_login"]),
IncludeReserved: true, // administrator needs to list all accounts include reserved, bot, remote ones
OrderBy: db.SearchOrderBy(sortType),
}, tplUsers)
}
@@ -6,13 +6,13 @@ package admin
import (
"testing"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/setting"
api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/web"
"code.gitea.io/gitea/services/contexttest"
"code.gitea.io/gitea/services/forms"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
"gitea.dev/modules/setting"
api "gitea.dev/modules/structs"
"gitea.dev/modules/web"
"gitea.dev/services/contexttest"
"gitea.dev/services/forms"
"github.com/stretchr/testify/assert"
)
+18 -22
View File
@@ -7,14 +7,14 @@ import (
"errors"
"net/http"
"code.gitea.io/gitea/models/auth"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/session"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/templates"
"code.gitea.io/gitea/modules/web"
"code.gitea.io/gitea/services/context"
"code.gitea.io/gitea/services/forms"
"gitea.dev/models/auth"
user_model "gitea.dev/models/user"
"gitea.dev/modules/session"
"gitea.dev/modules/setting"
"gitea.dev/modules/templates"
"gitea.dev/modules/web"
"gitea.dev/services/context"
"gitea.dev/services/forms"
)
var (
@@ -58,14 +58,14 @@ func TwoFactorPost(ctx *context.Context) {
return
}
// Validate the passcode with the stored TOTP secret.
ok, err := twofa.ValidateTOTP(form.Passcode)
// Validate the passcode and atomically consume it to prevent reuse/replay.
ok, err := twofa.ValidateAndConsumeTOTP(ctx, form.Passcode)
if err != nil {
ctx.ServerError("UserSignIn", err)
return
}
if ok && twofa.LastUsedPasscode != form.Passcode {
if ok {
remember := ctx.Session.Get("twofaRemember").(bool)
u, err := user_model.GetUserByID(ctx, id)
if err != nil {
@@ -73,17 +73,8 @@ func TwoFactorPost(ctx *context.Context) {
return
}
if ctx.Session.Get("linkAccount") != nil {
err = linkAccountFromContext(ctx, u)
if err != nil {
ctx.ServerError("UserSignIn", err)
return
}
}
twofa.LastUsedPasscode = form.Passcode
if err = auth.UpdateTwoFactor(ctx, twofa); err != nil {
ctx.ServerError("UserSignIn", err)
if err = completePendingLinks(ctx, u); err != nil {
ctx.ServerError("completePendingLinks", err)
return
}
@@ -151,6 +142,11 @@ func TwoFactorScratchPost(ctx *context.Context) {
return
}
if err = completePendingLinks(ctx, u); err != nil {
ctx.ServerError("completePendingLinks", err)
return
}
handleSignInFull(ctx, u, remember)
if ctx.Written() {
return
+66 -73
View File
@@ -8,32 +8,33 @@ import (
"errors"
"fmt"
"html/template"
"maps"
"net/http"
"net/url"
"strings"
"code.gitea.io/gitea/models/auth"
"code.gitea.io/gitea/models/db"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/auth/password"
"code.gitea.io/gitea/modules/eventsource"
"code.gitea.io/gitea/modules/httplib"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/optional"
"code.gitea.io/gitea/modules/session"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/templates"
"code.gitea.io/gitea/modules/timeutil"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/modules/web"
"code.gitea.io/gitea/modules/web/middleware"
auth_service "code.gitea.io/gitea/services/auth"
"code.gitea.io/gitea/services/auth/source/oauth2"
"code.gitea.io/gitea/services/context"
"code.gitea.io/gitea/services/externalaccount"
"code.gitea.io/gitea/services/forms"
"code.gitea.io/gitea/services/mailer"
user_service "code.gitea.io/gitea/services/user"
"gitea.dev/models/auth"
"gitea.dev/models/db"
user_model "gitea.dev/models/user"
"gitea.dev/modules/auth/password"
"gitea.dev/modules/eventsource"
"gitea.dev/modules/httplib"
"gitea.dev/modules/log"
"gitea.dev/modules/optional"
"gitea.dev/modules/session"
"gitea.dev/modules/setting"
"gitea.dev/modules/templates"
"gitea.dev/modules/timeutil"
"gitea.dev/modules/util"
"gitea.dev/modules/web"
"gitea.dev/modules/web/middleware"
auth_service "gitea.dev/services/auth"
"gitea.dev/services/auth/source/oauth2"
"gitea.dev/services/context"
"gitea.dev/services/externalaccount"
"gitea.dev/services/forms"
"gitea.dev/services/mailer"
user_service "gitea.dev/services/user"
"github.com/markbates/goth"
)
@@ -118,7 +119,7 @@ func autoSignIn(ctx *context.Context) (bool, error) {
ctx.SetSiteCookie(setting.CookieRememberName, nt.ID+":"+token, setting.LogInRememberDays*timeutil.Day)
if err := updateSession(ctx, nil, map[string]any{
if err := regenerateSession(ctx, nil, map[string]any{
session.KeyUID: u.ID,
session.KeyUname: u.Name,
session.KeyUserHasTwoFactorAuth: userHasTwoFactorAuth,
@@ -314,15 +315,6 @@ func SignInPost(ctx *context.Context) {
log.Warn("Failed authentication attempt for %s from %s: %v", form.UserName, ctx.RemoteAddr(), err)
ctx.Data["Title"] = ctx.Tr("auth.prohibit_login")
ctx.HTML(http.StatusOK, "user/auth/prohibit_login")
} else if user_model.IsErrUserInactive(err) {
if setting.Service.RegisterEmailConfirm {
ctx.Data["Title"] = ctx.Tr("auth.active_your_account")
ctx.HTML(http.StatusOK, TplActivate)
} else {
log.Warn("Failed authentication attempt for %s from %s: %v", form.UserName, ctx.RemoteAddr(), err)
ctx.Data["Title"] = ctx.Tr("auth.prohibit_login")
ctx.HTML(http.StatusOK, "user/auth/prohibit_login")
}
} else {
ctx.ServerError("UserSignIn", err)
}
@@ -338,46 +330,35 @@ func SignInPost(ctx *context.Context) {
// If this user is enrolled in 2FA TOTP, we can't sign the user in just yet.
// Instead, redirect them to the 2FA authentication page.
hasTOTPtwofa, err := auth.HasTwoFactorByUID(ctx, u.ID)
hasTwoFactor, err := auth.HasTwoFactorOrWebAuthn(ctx, u.ID)
if err != nil {
ctx.ServerError("UserSignIn", err)
ctx.ServerError("HasTwoFactorOrWebAuthn", err)
return
}
// Check if the user has webauthn registration
hasWebAuthnTwofa, err := auth.HasWebAuthnRegistrationsByUID(ctx, u.ID)
if err != nil {
ctx.ServerError("UserSignIn", err)
return
}
if !hasTOTPtwofa && !hasWebAuthnTwofa {
// No two-factor auth configured we can sign in the user
if !hasTwoFactor {
handleSignIn(ctx, u, form.Remember)
return
}
updates := map[string]any{
// User will need to use 2FA TOTP or WebAuthn, save data
"twofaUid": u.ID,
"twofaRemember": form.Remember,
}
if hasTOTPtwofa {
// User will need to use WebAuthn, save data
updates["totpEnrolled"] = u.ID
}
if err := updateSession(ctx, nil, updates); err != nil {
ctx.ServerError("UserSignIn: Unable to update session", err)
handleTwoFactorRequired(ctx, u, form.Remember, nil)
}
func handleTwoFactorRequired(ctx *context.Context, u *user_model.User, remember bool, extra map[string]any) {
updates := map[string]any{"twofaUid": u.ID, "twofaRemember": remember}
maps.Copy(updates, extra)
if err := regenerateSession(ctx, nil, updates); err != nil {
ctx.ServerError("RegenerateSession", err)
return
}
// If we have WebAuthn redirect there first
if hasWebAuthnTwofa {
hasWebAuthn, err := auth.HasWebAuthnRegistrationsByUID(ctx, u.ID)
if err != nil {
ctx.ServerError("HasWebAuthnRegistrationsByUID", err)
return
}
if hasWebAuthn {
ctx.Redirect(setting.AppSubURL + "/user/webauthn")
return
}
// Fallback to 2FA
ctx.Redirect(setting.AppSubURL + "/user/two_factor")
}
@@ -407,7 +388,7 @@ func handleSignInFull(ctx *context.Context, u *user_model.User, remember bool) {
return
}
if err := updateSession(ctx, []string{
if err := regenerateSession(ctx, []string{
// Delete the openid, 2fa and link_account data
"openid_verified_uri",
"openid_signin_remember",
@@ -417,6 +398,7 @@ func handleSignInFull(ctx *context.Context, u *user_model.User, remember bool) {
"twofaRemember",
"linkAccount",
"linkAccountData",
"openidPendingURI",
}, map[string]any{
session.KeyUID: u.ID,
session.KeyUname: u.Name,
@@ -493,20 +475,26 @@ func SignOut(ctx *context.Context) {
}
func buildSignOutRedirectURL(ctx *context.Context) string {
if ctx.Doer != nil && ctx.Doer.LoginType == auth.OAuth2 {
if ctx.Doer != nil && shouldRedirectToOIDCEndSession(ctx) {
if s := buildOIDCEndSessionURL(ctx, ctx.Doer); s != "" {
return s
}
}
// The assumption is: if reverse proxy auth is enabled, then the users should only sign-in via reverse proxy auth.
// TODO: in the future, if we need to distinguish different sign-in methods, we need to save the sign-in method in session and check here
if setting.Service.EnableReverseProxyAuth && setting.ReverseProxyLogoutRedirect != "" {
return setting.ReverseProxyLogoutRedirect
}
return setting.AppSubURL + "/"
}
// shouldRedirectToOIDCEndSession reports whether this session should end at the
// OIDC provider. Prefer the session sign-in method so an OAuth2-linked account
// that signed in with a password does not hit end_session_endpoint.
func shouldRedirectToOIDCEndSession(ctx *context.Context) bool {
return ctx.Session.Get(session.KeySignInMethod) == session.SignInMethodOAuth2
}
func prepareSignUpPageData(ctx *context.Context) bool {
ctx.Data["Title"] = ctx.Tr("sign_up")
ctx.Data["SignUpLink"] = setting.AppSubURL + "/user/sign_up"
@@ -636,14 +624,19 @@ func createUserInContext(ctx *context.Context, tpl templates.TplName, form any,
if possibleLinkAccountData != nil && (user_model.IsErrUserAlreadyExist(err) || user_model.IsErrEmailAlreadyUsed(err)) {
switch setting.OAuth2Client.AccountLinking {
case setting.OAuth2AccountLinkingAuto:
var user *user_model.User
user = &user_model.User{Name: u.Name}
hasUser, err := user_model.GetIndividualUser(ctx, user)
if !hasUser || err != nil {
user = &user_model.User{Email: u.Email}
hasUser, err = user_model.GetIndividualUser(ctx, user)
if !hasUser || err != nil {
ctx.ServerError("UserLinkAccount", err)
user, err := user_model.GetIndividualUserByName(ctx, u.Name)
if err != nil {
if !user_model.IsErrUserNotExist(err) {
ctx.ServerError("GetIndividualUserByName", err)
return false
}
user, err = user_model.GetIndividualUserByPrimaryEmail(ctx, u.Email)
if err != nil {
if !user_model.IsErrUserNotExist(err) {
ctx.ServerError("GetIndividualUserByPrimaryEmail", err)
} else {
ctx.NotFound(err)
}
return false
}
}
@@ -888,7 +881,7 @@ func handleAccountActivation(ctx *context.Context, user *user_model.User) {
log.Trace("User activated: %s", user.Name)
if err := updateSession(ctx, nil, map[string]any{
if err := regenerateSession(ctx, nil, map[string]any{
"uid": user.ID,
"uname": user.Name,
}); err != nil {
@@ -940,7 +933,7 @@ func ActivateEmail(ctx *context.Context) {
ctx.Redirect(setting.AppSubURL + "/user/settings/account")
}
func updateSession(ctx *context.Context, deletes []string, updates map[string]any) error {
func regenerateSession(ctx *context.Context, deletes []string, updates map[string]any) error {
if _, err := session.RegenerateSession(ctx.Resp, ctx.Req); err != nil {
return fmt.Errorf("regenerate session: %w", err)
}
@@ -10,14 +10,15 @@ import (
"net/url"
"testing"
auth_model "code.gitea.io/gitea/models/auth"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/session"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/test"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/services/auth/source/oauth2"
"code.gitea.io/gitea/services/contexttest"
auth_model "gitea.dev/models/auth"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
"gitea.dev/modules/session"
"gitea.dev/modules/setting"
"gitea.dev/modules/test"
"gitea.dev/modules/util"
"gitea.dev/services/auth/source/oauth2"
"gitea.dev/services/contexttest"
"github.com/markbates/goth"
"github.com/markbates/goth/gothic"
@@ -154,16 +155,47 @@ func TestWebAuthOAuth2(t *testing.T) {
authSource, err := auth_model.GetActiveOAuth2SourceByAuthName(t.Context(), "oidc-auth-source")
require.NoError(t, err)
mockOpt := contexttest.MockContextOption{SessionStore: session.NewMockMemStore("dummy-sid")}
ctx, resp := contexttest.MockContext(t, "/user/logout", mockOpt)
ctx.Doer = &user_model.User{ID: 1, LoginType: auth_model.OAuth2, LoginSource: authSource.ID}
SignOut(ctx)
assert.Equal(t, http.StatusSeeOther, resp.Code)
u, err := url.Parse(test.RedirectURL(resp))
require.NoError(t, err)
expectedValues := url.Values{"oidc-key": []string{"oidc-val"}, "post_logout_redirect_uri": []string{setting.AppURL}, "client_id": []string{"mock-client-id"}}
assert.Equal(t, expectedValues, u.Query())
u.RawQuery = ""
assert.Equal(t, "https://example.com/oidc-logout", u.String())
oauthUser := &user_model.User{ID: 1, LoginType: auth_model.OAuth2, LoginSource: authSource.ID}
t.Run("OAuth2SignInRedirectsToOIDC", func(t *testing.T) {
mockOpt := contexttest.MockContextOption{SessionStore: session.NewMockMemStore("dummy-sid-oauth")}
ctx, resp := contexttest.MockContext(t, "/user/logout", mockOpt)
ctx.Doer = oauthUser
require.NoError(t, ctx.Session.Set(session.KeySignInMethod, session.SignInMethodOAuth2))
SignOut(ctx)
assert.Equal(t, http.StatusSeeOther, resp.Code)
u, err := url.Parse(test.RedirectURL(resp))
require.NoError(t, err)
expectedValues := url.Values{"oidc-key": []string{"oidc-val"}, "post_logout_redirect_uri": []string{setting.AppURL}, "client_id": []string{"mock-client-id"}}
assert.Equal(t, expectedValues, u.Query())
u.RawQuery = ""
assert.Equal(t, "https://example.com/oidc-logout", u.String())
})
t.Run("PasswordSignInSkipsOIDC", func(t *testing.T) {
// OAuth2-linked account signed in via password form must not hit end_session_endpoint.
mockOpt := contexttest.MockContextOption{SessionStore: session.NewMockMemStore("dummy-sid-password")}
ctx, resp := contexttest.MockContext(t, "/user/logout", mockOpt)
ctx.Doer = oauthUser
SignOut(ctx)
assert.Equal(t, http.StatusSeeOther, resp.Code)
assert.Equal(t, "/", test.RedirectURL(resp))
})
})
}
func TestOpenIDRequireTwoFactor(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
mockOpt := contexttest.MockContextOption{SessionStore: session.NewMockMemStore("dummy-sid-openid")}
user32 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 32}) // has a webauthn credential
ctx, resp := contexttest.MockContext(t, "/user/openid/connect", mockOpt)
openIDRequireTwoFactor(ctx, user32, false, "https://example.com/id")
assert.Equal(t, "/user/webauthn", test.RedirectURL(resp))
unittest.AssertNotExistsBean(t, &user_model.UserOpenID{UID: user32.ID}) // not attached before the key answered
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
ctx, _ = contexttest.MockContext(t, "/user/openid/connect", mockOpt)
openIDRequireTwoFactor(ctx, user2, false, "https://example.com/id")
assert.False(t, ctx.Written())
}
@@ -8,18 +8,19 @@ import (
"net/http"
"strings"
"code.gitea.io/gitea/models/auth"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/templates"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/modules/web"
auth_service "code.gitea.io/gitea/services/auth"
"code.gitea.io/gitea/services/auth/source/oauth2"
"code.gitea.io/gitea/services/context"
"code.gitea.io/gitea/services/externalaccount"
"code.gitea.io/gitea/services/forms"
"gitea.dev/models/auth"
user_model "gitea.dev/models/user"
"gitea.dev/modules/log"
"gitea.dev/modules/session"
"gitea.dev/modules/setting"
"gitea.dev/modules/templates"
"gitea.dev/modules/util"
"gitea.dev/modules/web"
auth_service "gitea.dev/services/auth"
"gitea.dev/services/auth/source/oauth2"
"gitea.dev/services/context"
"gitea.dev/services/externalaccount"
"gitea.dev/services/forms"
)
var tplLinkAccount templates.TplName = "user/auth/link_account"
@@ -105,16 +106,6 @@ func handleSignInError(ctx *context.Context, userName string, ptrForm any, tmpl
log.Info("Failed authentication attempt for %s from %s: %v", userName, ctx.RemoteAddr(), err)
ctx.Data["Title"] = ctx.Tr("auth.prohibit_login")
ctx.HTML(http.StatusOK, "user/auth/prohibit_login")
} else if user_model.IsErrUserInactive(err) {
ctx.Data["user_exists"] = true
if setting.Service.RegisterEmailConfirm {
ctx.Data["Title"] = ctx.Tr("auth.active_your_account")
ctx.HTML(http.StatusOK, TplActivate)
} else {
log.Info("Failed authentication attempt for %s from %s: %v", userName, ctx.RemoteAddr(), err)
ctx.Data["Title"] = ctx.Tr("auth.prohibit_login")
ctx.HTML(http.StatusOK, "user/auth/prohibit_login")
}
} else {
ctx.ServerError(invoker, err)
}
@@ -157,15 +148,13 @@ func oauth2LinkAccount(ctx *context.Context, u *user_model.User, linkAccountData
// If this user is enrolled in 2FA, we can't sign the user in just yet.
// Instead, redirect them to the 2FA authentication page.
// We deliberately ignore the skip local 2fa setting here because we are linking to a previous user here
_, err := auth.GetTwoFactorByUID(ctx, u.ID)
hasTwoFactor, err := auth.HasTwoFactorOrWebAuthn(ctx, u.ID)
if err != nil {
if !auth.IsErrTwoFactorNotEnrolled(err) {
ctx.ServerError("UserLinkAccount", err)
return
}
err = externalaccount.LinkAccountToUser(ctx, linkAccountData.AuthSourceID, u, linkAccountData.GothUser)
if err != nil {
ctx.ServerError("UserLinkAccount", err)
return
}
if !hasTwoFactor {
if err := externalaccount.LinkAccountToUser(ctx, linkAccountData.AuthSourceID, u, linkAccountData.GothUser); err != nil {
ctx.ServerError("UserLinkAccount", err)
return
}
@@ -174,24 +163,15 @@ func oauth2LinkAccount(ctx *context.Context, u *user_model.User, linkAccountData
return
}
if err := updateSession(ctx, nil, map[string]any{
// User needs to use 2FA, save data and redirect to 2FA page.
"twofaUid": u.ID,
"twofaRemember": remember,
"linkAccount": true,
}); err != nil {
ctx.ServerError("RegenerateSession", err)
if err := Oauth2SetLinkAccountData(ctx, *linkAccountData); err != nil {
ctx.ServerError("Oauth2SetLinkAccountData", err)
return
}
// If WebAuthn is enrolled -> Redirect to WebAuthn instead
regs, err := auth.GetWebAuthnCredentialsByUID(ctx, u.ID)
if err == nil && len(regs) > 0 {
ctx.Redirect(setting.AppSubURL + "/user/webauthn")
return
}
ctx.Redirect(setting.AppSubURL + "/user/two_factor")
handleTwoFactorRequired(ctx, u, remember, map[string]any{
"linkAccount": true,
session.KeySignInMethod: session.SignInMethodOAuth2,
})
}
// LinkAccountPostRegister handle the creation of a new account for an external account using signUp
@@ -282,6 +262,15 @@ func LinkAccountPostRegister(ctx *context.Context) {
handleSignIn(ctx, u, false)
}
func completePendingLinks(ctx *context.Context, user *user_model.User) error {
if ctx.Session.Get("linkAccount") != nil {
if err := linkAccountFromContext(ctx, user); err != nil {
return err
}
}
return openIDConnectFromContext(ctx, user)
}
func linkAccountFromContext(ctx *context.Context, user *user_model.User) error {
linkAccountData := oauth2GetLinkAccountData(ctx)
if linkAccountData == nil {
@@ -6,7 +6,7 @@ package auth
import (
"testing"
"code.gitea.io/gitea/models/unittest"
"gitea.dev/models/unittest"
)
func TestMain(m *testing.M) {
+91 -65
View File
@@ -15,20 +15,22 @@ import (
"strings"
"time"
"code.gitea.io/gitea/models/auth"
user_model "code.gitea.io/gitea/models/user"
auth_module "code.gitea.io/gitea/modules/auth"
"code.gitea.io/gitea/modules/container"
"code.gitea.io/gitea/modules/httplib"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/optional"
"code.gitea.io/gitea/modules/session"
"code.gitea.io/gitea/modules/setting"
source_service "code.gitea.io/gitea/services/auth/source"
"code.gitea.io/gitea/services/auth/source/oauth2"
"code.gitea.io/gitea/services/context"
"code.gitea.io/gitea/services/externalaccount"
user_service "code.gitea.io/gitea/services/user"
"gitea.dev/models/auth"
user_model "gitea.dev/models/user"
auth_module "gitea.dev/modules/auth"
"gitea.dev/modules/container"
"gitea.dev/modules/hostmatcher"
"gitea.dev/modules/httplib"
"gitea.dev/modules/log"
"gitea.dev/modules/optional"
"gitea.dev/modules/proxy"
"gitea.dev/modules/session"
"gitea.dev/modules/setting"
source_service "gitea.dev/services/auth/source"
"gitea.dev/services/auth/source/oauth2"
"gitea.dev/services/context"
"gitea.dev/services/externalaccount"
user_service "gitea.dev/services/user"
"github.com/markbates/goth"
"github.com/markbates/goth/gothic"
@@ -80,7 +82,7 @@ func SignInOAuthCallback(ctx *context.Context) {
}
}
sort.Strings(errorKeyValues)
ctx.Flash.Error(strings.Join(errorKeyValues, "<br>"), true)
ctx.Flash.Error(strings.Join(errorKeyValues, "\n"), true)
}
// first look if the provider is still active
@@ -188,10 +190,6 @@ func SignInOAuthCallback(ctx *context.Context) {
source := authSource.Cfg.(*oauth2.Source)
isAdmin, isRestricted := getUserAdminAndRestrictedFromGroupClaims(source, &gothUser)
u.IsAdmin = isAdmin.ValueOrDefault(user_service.UpdateOptionField[bool]{FieldValue: false}).FieldValue
u.IsRestricted = isRestricted.ValueOrDefault(setting.Service.DefaultUserIsRestricted)
linkAccountData := &LinkAccountData{authSource.ID, gothUser}
if setting.OAuth2Client.AccountLinking == setting.OAuth2AccountLinkingDisabled {
linkAccountData = nil
@@ -289,9 +287,7 @@ func oauth2GetLinkAccountData(ctx *context.Context) *LinkAccountData {
}
func Oauth2SetLinkAccountData(ctx *context.Context, linkAccountData LinkAccountData) error {
return updateSession(ctx, nil, map[string]any{
"linkAccountData": linkAccountData,
})
return ctx.Session.Set("linkAccountData", linkAccountData)
}
func showLinkingLogin(ctx *context.Context, authSourceID int64, gothUser goth.User) {
@@ -302,7 +298,23 @@ func showLinkingLogin(ctx *context.Context, authSourceID int64, gothUser goth.Us
ctx.Redirect(setting.AppSubURL + "/user/link_account")
}
var oauth2AvatarHTTPClient = &http.Client{Timeout: 30 * time.Second}
// oauth2AvatarAllowList parses the host allow-list applied to avatar fetches from the global
// [security] ALLOWED_HOST_LIST, defaulting an empty setting to the built-in "external" set. An empty
// host-match list would otherwise disable the allow-list check entirely and permit any host, including
// loopback/private addresses (SSRF).
func oauth2AvatarAllowList() *hostmatcher.HostMatchList {
return hostmatcher.ParseHostMatchList("security.ALLOWED_HOST_LIST", setting.Security.AllowedHostList)
}
// oauth2AvatarHTTPClient builds the SSRF-protected client for avatar fetches. It is constructed per call
// so a changed allowlist takes effect (avatar fetches are infrequent, so this is not a hot path).
func oauth2AvatarHTTPClient() *http.Client {
allowList := oauth2AvatarAllowList()
return &http.Client{
Timeout: 30 * time.Second,
Transport: hostmatcher.NewHTTPTransport("oauth2-avatar", allowList, nil, proxy.Proxy(), setting.Proxy.ProxyURLFixed, nil),
}
}
func oauth2UpdateAvatarIfNeed(ctx *context.Context, avatarURL string, u *user_model.User) {
if !setting.OAuth2Client.UpdateAvatar || len(avatarURL) == 0 {
@@ -316,7 +328,7 @@ func oauth2UpdateAvatarIfNeed(ctx *context.Context, avatarURL string, u *user_mo
// Some hosts (e.g. Wikimedia) reject Go's default User-Agent.
req.Header.Set("User-Agent", "Gitea "+setting.AppVer)
resp, err := oauth2AvatarHTTPClient.Do(req)
resp, err := oauth2AvatarHTTPClient().Do(req)
if err != nil {
log.Warn("fetch %q failed: %v", avatarURL, err)
return
@@ -349,12 +361,11 @@ func handleOAuth2SignIn(ctx *context.Context, authSource *auth.Source, u *user_m
needs2FA := false
if !authSource.TwoFactorShouldSkip() {
_, err := auth.GetTwoFactorByUID(ctx, u.ID)
if err != nil && !auth.IsErrTwoFactorNotEnrolled(err) {
var err error
if needs2FA, err = auth.HasTwoFactorOrWebAuthn(ctx, u.ID); err != nil {
ctx.ServerError("UserSignIn", err)
return
}
needs2FA = err == nil
}
oauth2Source := authSource.Cfg.(*oauth2.Source)
@@ -368,14 +379,26 @@ func handleOAuth2SignIn(ctx *context.Context, authSource *auth.Source, u *user_m
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 {
opts.IsActive = optional.Some(true)
extLogin, hasExt, err := user_model.GetExternalLogin(ctx, authSource.ID, gothUser.UserID)
if err != nil {
ctx.ServerError("GetExternalLogin", err)
return
}
// the cron clears all three token fields when it disables a user, so require the
// full signature; a RefreshToken alone is empty for many normal logins (e.g. GitHub
// or OIDC without offline_access), which would otherwise reactivate admin-disabled users
isDisabledByAutoSync := hasExt && extLogin.AccessToken == "" && extLogin.RefreshToken == "" && extLogin.ExpiresAt.IsZero()
if isDisabledByAutoSync {
opts.IsActive = optional.Some(true)
}
}
// Update GroupClaims
opts.IsAdmin, opts.IsRestricted = getUserAdminAndRestrictedFromGroupClaims(oauth2Source, &gothUser)
if oauth2Source.GroupTeamMap != "" || oauth2Source.GroupTeamMapRemoval {
if err := source_service.SyncGroupsToTeams(ctx, u, groups, groupTeamMapping, oauth2Source.GroupTeamMapRemoval); err != nil {
ctx.ServerError("SyncGroupsToTeams", err)
@@ -404,10 +427,11 @@ func handleOAuth2SignIn(ctx *context.Context, authSource *auth.Source, u *user_m
return
}
if err := updateSession(ctx, nil, map[string]any{
if err := regenerateSession(ctx, nil, map[string]any{
session.KeyUID: u.ID,
session.KeyUname: u.Name,
session.KeyUserHasTwoFactorAuth: userHasTwoFactorAuth,
session.KeySignInMethod: session.SignInMethodOAuth2,
}); err != nil {
ctx.ServerError("updateSession", err)
return
@@ -429,23 +453,7 @@ func handleOAuth2SignIn(ctx *context.Context, authSource *auth.Source, u *user_m
}
}
if err := updateSession(ctx, nil, map[string]any{
// User needs to use 2FA, save data and redirect to 2FA page.
"twofaUid": u.ID,
"twofaRemember": false,
}); err != nil {
ctx.ServerError("updateSession", err)
return
}
// If WebAuthn is enrolled -> Redirect to WebAuthn instead
regs, err := auth.GetWebAuthnCredentialsByUID(ctx, u.ID)
if err == nil && len(regs) > 0 {
ctx.Redirect(setting.AppSubURL + "/user/webauthn")
return
}
ctx.Redirect(setting.AppSubURL + "/user/two_factor")
handleTwoFactorRequired(ctx, u, false, map[string]any{session.KeySignInMethod: session.SignInMethodOAuth2})
}
// OAuth2UserLoginCallback attempts to handle the callback from the OAuth2 provider and if successful
@@ -498,13 +506,7 @@ func oAuth2UserLoginCallback(ctx *context.Context, authSource *auth.Source, requ
}
}
user := &user_model.User{
LoginName: gothUser.UserID,
LoginType: auth.OAuth2,
LoginSource: authSource.ID,
}
hasUser, err := user_model.GetIndividualUser(ctx, user)
user, hasUser, err := user_model.GetIndividualUserByLoginSource(ctx, auth.OAuth2, authSource.ID, gothUser.UserID)
if err != nil {
return nil, goth.User{}, err
}
@@ -514,17 +516,33 @@ func oAuth2UserLoginCallback(ctx *context.Context, authSource *auth.Source, requ
}
// search in external linked users
externalLoginUser := &user_model.ExternalLoginUser{
ExternalID: gothUser.UserID,
LoginSourceID: authSource.ID,
}
hasUser, err = user_model.GetExternalLogin(request.Context(), externalLoginUser)
externalLoginUser, hasUser, err := user_model.GetExternalLogin(ctx, authSource.ID, gothUser.UserID)
if err != nil {
return nil, goth.User{}, err
}
if hasUser {
user, err = user_model.GetUserByID(request.Context(), externalLoginUser.UserID)
return user, gothUser, err
user, err = user_model.GetUserByID(ctx, externalLoginUser.UserID)
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
@@ -561,7 +579,15 @@ func buildOIDCEndSessionURL(ctx *context.Context, doer *user_model.User) string
// https://openid.net/specs/openid-connect-rpinitiated-1_0.html#RPLogout
params := endSessionURL.Query()
params.Set("client_id", oauth2Cfg.ClientID)
params.Set("post_logout_redirect_uri", httplib.GuessCurrentAppURL(ctx))
// AWS Cognito uses "logout_uri" instead of the standard "post_logout_redirect_uri"
redirectURI := httplib.GuessCurrentAppURL(ctx)
if oauth2Cfg.Provider == oauth2.ProviderNameAwsCognito {
params.Set("logout_uri", redirectURI)
} else {
params.Set("post_logout_redirect_uri", redirectURI)
}
endSessionURL.RawQuery = params.Encode()
return endSessionURL.String()
}
@@ -13,18 +13,18 @@ import (
"strconv"
"strings"
"code.gitea.io/gitea/models/auth"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/auth/httpauth"
"code.gitea.io/gitea/modules/json"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/templates"
"code.gitea.io/gitea/modules/web"
auth_service "code.gitea.io/gitea/services/auth"
"code.gitea.io/gitea/services/context"
"code.gitea.io/gitea/services/forms"
"code.gitea.io/gitea/services/oauth2_provider"
"gitea.dev/models/auth"
user_model "gitea.dev/models/user"
"gitea.dev/modules/auth/httpauth"
"gitea.dev/modules/json"
"gitea.dev/modules/log"
"gitea.dev/modules/setting"
"gitea.dev/modules/templates"
"gitea.dev/modules/web"
auth_service "gitea.dev/services/auth"
"gitea.dev/services/context"
"gitea.dev/services/forms"
"gitea.dev/services/oauth2_provider"
"gitea.com/go-chi/binding"
jwt "github.com/golang-jwt/jwt/v5"
@@ -98,6 +98,19 @@ func InfoOAuth(ctx *context.Context) {
return
}
// enforce the same user scope the REST API requires before returning identity
// claims; OIDC access tokens map to the "all" scope, so standard OIDC clients
// are unaffected and only explicitly-restricted tokens are rejected
tokenScope, _ := ctx.Data["ApiTokenScope"].(auth.AccessTokenScope)
if allowed, err := tokenScope.HasScope(auth.AccessTokenScopeReadUser); err != nil {
ctx.ServerError("HasScope", err)
return
} else if !allowed {
ctx.Resp.Header().Set("WWW-Authenticate", `Bearer realm="Gitea OAuth2"`)
ctx.PlainText(http.StatusForbidden, "token does not have required scope: read:user")
return
}
response := &userInfoResponse{
Sub: strconv.FormatInt(ctx.Doer.ID, 10),
Name: ctx.Doer.DisplayName(),
@@ -128,7 +141,7 @@ func InfoOAuth(ctx *context.Context) {
// IntrospectOAuth introspects an oauth token
func IntrospectOAuth(ctx *context.Context) {
clientIDValid := false
var introspectingApp *auth.OAuth2Application
authHeader := ctx.Req.Header.Get("Authorization")
if parsed, ok := httpauth.ParseAuthorizationHeader(authHeader); ok && parsed.BasicAuth != nil {
clientID, clientSecret := parsed.BasicAuth.Username, parsed.BasicAuth.Password
@@ -139,9 +152,14 @@ func IntrospectOAuth(ctx *context.Context) {
ctx.HTTPError(http.StatusInternalServerError)
return
}
clientIDValid = err == nil && app.ValidateClientSecret([]byte(clientSecret))
clientIDValid := err == nil && app.ValidateClientSecret([]byte(clientSecret))
if clientIDValid {
introspectingApp = app
}
}
if !clientIDValid {
if introspectingApp == nil {
// RFC 7662 requires the caller to authenticate to the introspection endpoint.
// https://www.rfc-editor.org/rfc/rfc7662.html#section-2.1
ctx.Resp.Header().Set("WWW-Authenticate", `Basic realm="Gitea OAuth2"`)
ctx.PlainText(http.StatusUnauthorized, "no valid authorization")
return
@@ -156,27 +174,57 @@ func IntrospectOAuth(ctx *context.Context) {
form := web.GetForm(ctx).(*forms.IntrospectTokenForm)
token, err := oauth2_provider.ParseToken(form.Token, oauth2_provider.DefaultSigningKey)
if err == nil {
grant, err := auth.GetOAuth2GrantByID(ctx, token.GrantID)
if err == nil && grant != nil {
app, err := auth.GetOAuth2ApplicationByID(ctx, grant.ApplicationID)
if err == nil && app != nil {
response.Active = true
response.Scope = grant.Scope
response.RegisteredClaims = oauth2_provider.NewJwtRegisteredClaimsFromUser(app.ClientID, grant.UserID, nil /*exp*/)
}
if user, err := user_model.GetUserByID(ctx, grant.UserID); err == nil {
response.Username = user.Name
}
}
if err != nil {
// RFC 7662 returns inactive token metadata for invalid/unknown tokens.
// https://www.rfc-editor.org/rfc/rfc7662.html#section-2.2
log.Trace("Ignoring invalid token during introspection: %v", err)
ctx.JSON(http.StatusOK, response)
return
}
grant, err := auth.GetOAuth2GrantByID(ctx, token.GrantID)
if err != nil {
ctx.ServerError("GetOAuth2GrantByID", err)
return
}
if grant == nil || grant.ApplicationID != introspectingApp.ID {
// RFC 7662 allows the server to reply inactive when the caller must not learn more.
// https://www.rfc-editor.org/rfc/rfc7662.html#section-2.2
ctx.JSON(http.StatusOK, response)
return
}
response.Active = true
response.Scope = grant.Scope
response.RegisteredClaims = oauth2_provider.NewJwtRegisteredClaimsFromUser(introspectingApp.ClientID, grant.UserID, nil /*exp*/)
user, err := user_model.GetUserByID(ctx, grant.UserID)
if err != nil {
ctx.ServerError("GetUserByID", err)
return
}
response.Username = user.Name
ctx.JSON(http.StatusOK, response)
}
func oauthDoerAuthorizePreCheck(ctx *context.Context, formState string) bool {
if ctx.DoerNeedTwoFactorAuth() {
handleAuthorizeError(ctx, AuthorizeError{
ErrorCode: ErrorCodeAccessDenied,
ErrorDescription: "two-factor authentication is required",
State: formState,
}, "")
return false
}
return true
}
// AuthorizeOAuth manages authorize requests
func AuthorizeOAuth(ctx *context.Context) {
form := web.GetForm(ctx).(*forms.AuthorizationForm)
if !oauthDoerAuthorizePreCheck(ctx, form.State) {
return
}
errs := binding.Errors{}
errs = form.Validate(ctx.Req, errs)
if len(errs) > 0 {
@@ -352,6 +400,10 @@ func AuthorizeOAuth(ctx *context.Context) {
// GrantApplicationOAuth manages the post request submitted when a user grants access to an application
func GrantApplicationOAuth(ctx *context.Context) {
form := web.GetForm(ctx).(*forms.GrantApplicationForm)
if !oauthDoerAuthorizePreCheck(ctx, form.State) {
return
}
if ctx.Session.Get("client_id") != form.ClientID || ctx.Session.Get("state") != form.State ||
ctx.Session.Get("redirect_uri") != form.RedirectURI {
ctx.HTTPError(http.StatusBadRequest)
@@ -6,14 +6,15 @@ package auth
import (
"fmt"
asymkey_model "code.gitea.io/gitea/models/asymkey"
"code.gitea.io/gitea/models/auth"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/util"
asymkey_service "code.gitea.io/gitea/services/asymkey"
"code.gitea.io/gitea/services/auth/source/oauth2"
"code.gitea.io/gitea/services/context"
asymkey_model "gitea.dev/models/asymkey"
"gitea.dev/models/auth"
user_model "gitea.dev/models/user"
"gitea.dev/modules/log"
"gitea.dev/modules/util"
asymkey_service "gitea.dev/services/asymkey"
"gitea.dev/services/auth/source/oauth2"
"gitea.dev/services/context"
user_service "gitea.dev/services/user"
"github.com/markbates/goth"
)
@@ -50,6 +51,14 @@ func oauth2SignInSync(ctx *context.Context, authSourceID int64, u *user_model.Us
}
}
// sync user flags (admin/restricted)
isAdmin, isRestricted := getUserAdminAndRestrictedFromGroupClaims(oauth2Source, &gothUser)
if isAdmin.Has() || isRestricted.Has() {
if err = user_service.UpdateUser(ctx, u, &user_service.UpdateOptions{IsAdmin: isAdmin, IsRestricted: isRestricted}); err != nil {
log.Error("Unable to sync OAuth2 user admin or restricted status %s: %v", gothUser.Provider, err)
}
}
err = oauth2UpdateSSHPubIfNeed(ctx, authSource, &gothUser, u)
if err != nil {
log.Error("Unable to sync OAuth2 SSH public key %s: %v", gothUser.Provider, err)
@@ -4,15 +4,22 @@
package auth
import (
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"code.gitea.io/gitea/models/auth"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/services/oauth2_provider"
"gitea.dev/models/auth"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
"gitea.dev/modules/hostmatcher"
"gitea.dev/modules/setting"
"gitea.dev/modules/test"
"gitea.dev/services/oauth2_provider"
"github.com/golang-jwt/jwt/v5"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func createAndParseToken(t *testing.T, grant *auth.OAuth2Grant) *oauth2_provider.OIDCToken {
@@ -73,3 +80,42 @@ func TestNewAccessTokenResponse_OIDCToken(t *testing.T) {
assert.Equal(t, user.Email, oidcToken.Email)
assert.Equal(t, user.IsActive, oidcToken.EmailVerified)
}
func TestOAuth2AvatarClientBlocksLoopback(t *testing.T) {
var hit atomic.Bool
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hit.Store(true)
_, _ = w.Write([]byte("img"))
}))
defer srv.Close()
// the httptest server binds a loopback address, which the SSRF-protected dialer must refuse
resp, err := oauth2AvatarHTTPClient().Get(srv.URL)
if resp != nil {
_ = resp.Body.Close()
}
require.Error(t, err)
assert.False(t, hit.Load(), "avatar client must refuse to dial a loopback address")
}
func TestOAuth2AvatarAllowListRestricts(t *testing.T) {
defer test.MockVariableValue(&setting.Security.AllowedHostList, "avatars.example.com")()
allowList := oauth2AvatarAllowList()
assert.True(t, allowList.MatchHostName("avatars.example.com"), "the configured host must be allowed")
assert.False(t, allowList.MatchHostName("8.8.8.8"), "an unrelated external host must be rejected")
// the default `external` allow-list still permits external hosts
setting.Security.AllowedHostList = hostmatcher.MatchBuiltinExternal
assert.True(t, oauth2AvatarAllowList().MatchHostName("8.8.8.8"), "default allow-list permits external hosts")
}
func TestOAuth2AvatarClientBlocksCloudMetadata(t *testing.T) {
// external-only allow-list must reject link-local cloud metadata (169.254.169.254) at dial time
resp, err := oauth2AvatarHTTPClient().Get("http://169.254.169.254/latest/meta-data/")
if resp != nil {
_ = resp.Body.Close()
}
require.Error(t, err)
assert.ErrorContains(t, err, "can only call allowed HTTP servers",
"avatar client must refuse a link-local cloud-metadata address")
}
+55 -22
View File
@@ -8,16 +8,17 @@ import (
"net/http"
"net/url"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/auth/openid"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/templates"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/modules/web"
"code.gitea.io/gitea/services/auth"
"code.gitea.io/gitea/services/context"
"code.gitea.io/gitea/services/forms"
auth_model "gitea.dev/models/auth"
user_model "gitea.dev/models/user"
"gitea.dev/modules/auth/openid"
"gitea.dev/modules/log"
"gitea.dev/modules/setting"
"gitea.dev/modules/templates"
"gitea.dev/modules/util"
"gitea.dev/modules/web"
"gitea.dev/services/auth"
"gitea.dev/services/context"
"gitea.dev/services/forms"
)
const (
@@ -26,6 +27,36 @@ const (
tplSignUpOID templates.TplName = "user/auth/signup_openid_register"
)
// the OpenID is attached only after the second factor passed, so a stolen password cannot leave one behind
func openIDRequireTwoFactor(ctx *context.Context, u *user_model.User, remember bool, pendingURI string) {
hasTwoFactor, err := auth_model.HasTwoFactorOrWebAuthn(ctx, u.ID)
if err != nil {
ctx.ServerError("HasTwoFactorOrWebAuthn", err)
return
}
if !hasTwoFactor {
return
}
handleTwoFactorRequired(ctx, u, remember, map[string]any{"openidPendingURI": pendingURI})
}
func openIDConnectFromContext(ctx *context.Context, u *user_model.User) error {
uri, _ := ctx.Session.Get("openidPendingURI").(string)
if uri == "" {
return nil
}
if err := ctx.Session.Delete("openidPendingURI"); err != nil {
return err
}
if err := user_model.AddUserOpenID(ctx, &user_model.UserOpenID{UID: u.ID, URI: uri}); err != nil {
if !user_model.IsErrOpenIDAlreadyUsed(err) {
return err
}
ctx.Flash.Error(ctx.Tr("form.openid_been_used", uri))
}
return nil
}
// SignInOpenID render sign in page
func SignInOpenID(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("sign_in")
@@ -154,6 +185,10 @@ func signInOpenIDVerify(ctx *context.Context) {
log.Trace("User exists, logging in")
remember, _ := ctx.Session.Get("openid_signin_remember").(bool)
log.Trace("Session stored openid-remember: %t", remember)
openIDRequireTwoFactor(ctx, u, remember, "")
if ctx.Written() {
return
}
handleSignIn(ctx, u, remember)
return
}
@@ -213,7 +248,7 @@ func signInOpenIDVerify(ctx *context.Context) {
if u != nil {
nickname = u.LowerName
}
if err := updateSession(ctx, nil, map[string]any{
if err := regenerateSession(ctx, nil, map[string]any{
"openid_verified_uri": id,
"openid_determined_email": email,
"openid_determined_username": nickname,
@@ -270,9 +305,14 @@ func ConnectOpenIDPost(ctx *context.Context) {
return
}
// add OpenID for the user
remember, _ := ctx.Session.Get("openid_signin_remember").(bool)
openIDRequireTwoFactor(ctx, u, remember, oid)
if ctx.Written() {
return
}
userOID := &user_model.UserOpenID{UID: u.ID, URI: oid}
if err = user_model.AddUserOpenID(ctx, userOID); err != nil {
if err := user_model.AddUserOpenID(ctx, userOID); err != nil {
if user_model.IsErrOpenIDAlreadyUsed(err) {
ctx.RenderWithErrDeprecated(ctx.Tr("form.openid_been_used", oid), tplConnectOID, &form)
return
@@ -282,9 +322,6 @@ func ConnectOpenIDPost(ctx *context.Context) {
}
ctx.Flash.Success(ctx.Tr("settings.add_openid_success"))
remember, _ := ctx.Session.Get("openid_signin_remember").(bool)
log.Trace("Session stored openid-remember: %t", remember)
handleSignIn(ctx, u, remember)
}
@@ -345,11 +382,7 @@ func RegisterOpenIDPost(ctx *context.Context) {
}
length := max(setting.MinPasswordLength, 256)
password, err := util.CryptoRandomString(int64(length))
if err != nil {
ctx.RenderWithErrDeprecated(err.Error(), tplSignUpOID, form)
return
}
password := util.CryptoRandomString(int64(length))
u := &user_model.User{
Name: form.UserName,
@@ -363,7 +396,7 @@ func RegisterOpenIDPost(ctx *context.Context) {
// add OpenID for the user
userOID := &user_model.UserOpenID{UID: u.ID, URI: oid}
if err = user_model.AddUserOpenID(ctx, userOID); err != nil {
if err := user_model.AddUserOpenID(ctx, userOID); err != nil {
if user_model.IsErrOpenIDAlreadyUsed(err) {
ctx.RenderWithErrDeprecated(ctx.Tr("form.openid_been_used", oid), tplSignUpOID, &form)
return
@@ -7,19 +7,19 @@ import (
"errors"
"net/http"
"code.gitea.io/gitea/models/auth"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/auth/password"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/optional"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/templates"
"code.gitea.io/gitea/modules/timeutil"
"code.gitea.io/gitea/modules/web"
"code.gitea.io/gitea/services/context"
"code.gitea.io/gitea/services/forms"
"code.gitea.io/gitea/services/mailer"
user_service "code.gitea.io/gitea/services/user"
"gitea.dev/models/auth"
user_model "gitea.dev/models/user"
"gitea.dev/modules/auth/password"
"gitea.dev/modules/log"
"gitea.dev/modules/optional"
"gitea.dev/modules/setting"
"gitea.dev/modules/templates"
"gitea.dev/modules/timeutil"
"gitea.dev/modules/web"
"gitea.dev/services/context"
"gitea.dev/services/forms"
"gitea.dev/services/mailer"
user_service "gitea.dev/services/user"
)
var (
@@ -177,23 +177,17 @@ func ResetPasswdPost(ctx *context.Context) {
regenerateScratchToken = true
} else {
passcode := ctx.FormString("passcode")
ok, err := twofa.ValidateTOTP(passcode)
ok, err := twofa.ValidateAndConsumeTOTP(ctx, passcode)
if err != nil {
ctx.HTTPError(http.StatusInternalServerError, "ValidateTOTP", err.Error())
ctx.HTTPError(http.StatusInternalServerError, "ValidateAndConsumeTOTP", err.Error())
return
}
if !ok || twofa.LastUsedPasscode == passcode {
if !ok {
ctx.Data["IsResetForm"] = true
ctx.Data["Err_Passcode"] = true
ctx.RenderWithErrDeprecated(ctx.Tr("auth.twofa_passcode_incorrect"), tplResetPassword, nil)
return
}
twofa.LastUsedPasscode = passcode
if err = auth.UpdateTwoFactor(ctx, twofa); err != nil {
ctx.ServerError("ResetPasswdPost: UpdateTwoFactor", err)
return
}
}
}
@@ -244,6 +238,19 @@ func ResetPasswdPost(ctx *context.Context) {
return
}
// the reset form only carries a TOTP field, so a WebAuthn-only user finishes on its own page
if twofa == nil {
hasWebAuthn, err := auth.HasWebAuthnRegistrationsByUID(ctx, u.ID)
if err != nil {
ctx.ServerError("HasWebAuthnRegistrationsByUID", err)
return
}
if hasWebAuthn {
handleTwoFactorRequired(ctx, u, remember, nil)
return
}
}
handleSignIn(ctx, u, remember)
}
@@ -8,13 +8,13 @@ import (
"errors"
"net/http"
"code.gitea.io/gitea/models/auth"
user_model "code.gitea.io/gitea/models/user"
wa "code.gitea.io/gitea/modules/auth/webauthn"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/templates"
"code.gitea.io/gitea/services/context"
"gitea.dev/models/auth"
user_model "gitea.dev/models/user"
wa "gitea.dev/modules/auth/webauthn"
"gitea.dev/modules/log"
"gitea.dev/modules/setting"
"gitea.dev/modules/templates"
"gitea.dev/services/context"
"github.com/go-webauthn/webauthn/protocol"
"github.com/go-webauthn/webauthn/webauthn"
@@ -54,7 +54,8 @@ func WebAuthnPasskeyAssertion(ctx *context.Context) {
return
}
assertion, sessionData, err := wa.WebAuthn.BeginDiscoverableLogin()
// a passkey is the only factor here
assertion, sessionData, err := wa.WebAuthn.BeginDiscoverableLogin(webauthn.WithUserVerification(protocol.VerificationRequired))
if err != nil {
ctx.ServerError("webauthn.BeginDiscoverableLogin", err)
return
@@ -91,7 +92,7 @@ func WebAuthnPasskeyLogin(ctx *context.Context) {
parsedResponse, err := protocol.ParseCredentialRequestResponse(ctx.Req)
if err != nil {
// Failed authentication attempt.
log.Info("Failed authentication attempt for %s from %s: %v", user.Name, ctx.RemoteAddr(), err)
log.Info("Failed authentication attempt from %s: %v", ctx.RemoteAddr(), err)
ctx.Status(http.StatusForbidden)
return
}
@@ -147,12 +148,9 @@ func WebAuthnPasskeyLogin(ctx *context.Context) {
return
}
// Now handle account linking if that's requested
if ctx.Session.Get("linkAccount") != nil {
if err := linkAccountFromContext(ctx, user); err != nil {
ctx.ServerError("LinkAccountFromStore", err)
return
}
if err := completePendingLinks(ctx, user); err != nil {
ctx.ServerError("completePendingLinks", err)
return
}
remember := false // TODO: implement remember me
@@ -186,7 +184,8 @@ func WebAuthnLoginAssertion(ctx *context.Context) {
}
webAuthnUser := wa.NewWebAuthnUser(ctx, user)
assertion, sessionData, err := wa.WebAuthn.BeginLogin(webAuthnUser)
// "discouraged" would hide credProtect protected credentials
assertion, sessionData, err := wa.WebAuthn.BeginLogin(webAuthnUser, webauthn.WithUserVerification(protocol.VerificationPreferred))
if err != nil {
ctx.ServerError("webauthn.BeginLogin", err)
return
@@ -261,12 +260,9 @@ func WebAuthnLoginAssertionPost(ctx *context.Context) {
return
}
// Now handle account linking if that's requested
if ctx.Session.Get("linkAccount") != nil {
if err := linkAccountFromContext(ctx, user); err != nil {
ctx.ServerError("LinkAccountFromStore", err)
return
}
if err := completePendingLinks(ctx, user); err != nil {
ctx.ServerError("completePendingLinks", err)
return
}
remember := ctx.Session.Get("twofaRemember").(bool)
+8 -8
View File
@@ -12,14 +12,14 @@ import (
"path"
"strings"
"code.gitea.io/gitea/modules/assetfs"
"code.gitea.io/gitea/modules/avatar"
"code.gitea.io/gitea/modules/httpcache"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/storage"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/modules/web/routing"
"gitea.dev/modules/assetfs"
"gitea.dev/modules/avatar"
"gitea.dev/modules/httpcache"
"gitea.dev/modules/log"
"gitea.dev/modules/setting"
"gitea.dev/modules/storage"
"gitea.dev/modules/util"
"gitea.dev/modules/web/routing"
)
func avatarStorageHandler(storageSetting *setting.Storage, prefix string, objStore storage.ObjectStorage) http.HandlerFunc {
@@ -13,16 +13,17 @@ import (
"time"
"unicode"
"code.gitea.io/gitea/models/asymkey"
"code.gitea.io/gitea/models/db"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/badge"
"code.gitea.io/gitea/modules/charset"
"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/indexer/code"
"code.gitea.io/gitea/modules/templates"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/services/context"
"gitea.dev/models/asymkey"
"gitea.dev/models/db"
"gitea.dev/models/gituser"
user_model "gitea.dev/models/user"
"gitea.dev/modules/badge"
"gitea.dev/modules/charset"
"gitea.dev/modules/git"
"gitea.dev/modules/indexer/code"
"gitea.dev/modules/templates"
"gitea.dev/modules/util"
"gitea.dev/services/context"
)
// List all devtest templates, they will be used for e2e tests for the UI components
@@ -45,8 +46,8 @@ func List(ctx *context.Context) {
func FetchActionTest(ctx *context.Context) {
_ = ctx.Req.ParseForm()
ctx.Flash.Info("fetch-action: " + ctx.Req.Method + " " + ctx.Req.RequestURI + "<br>" +
"Form: " + ctx.Req.Form.Encode() + "<br>" +
ctx.Flash.Info("fetch action: " + ctx.Req.Method + " " + ctx.Req.RequestURI + "\n" +
"Form: " + ctx.Req.Form.Encode() + "\n" +
"PostForm: " + ctx.Req.PostForm.Encode(),
)
time.Sleep(2 * time.Second)
@@ -61,8 +62,8 @@ func prepareMockDataBadgeCommitSign(ctx *context.Context) {
mockUser := mockUsers[0]
commits = append(commits, &asymkey.SignCommit{
Verification: &asymkey.CommitVerification{},
UserCommit: &user_model.UserCommit{
Commit: &git.Commit{ID: git.Sha1ObjectFormat.EmptyObjectID()},
UserCommit: &gituser.UserCommit{
GitCommit: &git.Commit{ID: git.Sha1ObjectFormat.EmptyObjectID()},
},
})
commits = append(commits, &asymkey.SignCommit{
@@ -73,9 +74,9 @@ func prepareMockDataBadgeCommitSign(ctx *context.Context) {
SigningKey: &asymkey.GPGKey{KeyID: "12345678"},
TrustStatus: "trusted",
},
UserCommit: &user_model.UserCommit{
User: mockUser,
Commit: &git.Commit{ID: git.Sha1ObjectFormat.EmptyObjectID()},
UserCommit: &gituser.UserCommit{
AuthorUser: mockUser,
GitCommit: &git.Commit{ID: git.Sha1ObjectFormat.EmptyObjectID()},
},
})
commits = append(commits, &asymkey.SignCommit{
@@ -86,9 +87,9 @@ func prepareMockDataBadgeCommitSign(ctx *context.Context) {
SigningSSHKey: &asymkey.PublicKey{Fingerprint: "aa:bb:cc:dd:ee"},
TrustStatus: "untrusted",
},
UserCommit: &user_model.UserCommit{
User: mockUser,
Commit: &git.Commit{ID: git.Sha1ObjectFormat.EmptyObjectID()},
UserCommit: &gituser.UserCommit{
AuthorUser: mockUser,
GitCommit: &git.Commit{ID: git.Sha1ObjectFormat.EmptyObjectID()},
},
})
commits = append(commits, &asymkey.SignCommit{
@@ -99,9 +100,9 @@ func prepareMockDataBadgeCommitSign(ctx *context.Context) {
SigningSSHKey: &asymkey.PublicKey{Fingerprint: "aa:bb:cc:dd:ee"},
TrustStatus: "other(unmatch)",
},
UserCommit: &user_model.UserCommit{
User: mockUser,
Commit: &git.Commit{ID: git.Sha1ObjectFormat.EmptyObjectID()},
UserCommit: &gituser.UserCommit{
AuthorUser: mockUser,
GitCommit: &git.Commit{ID: git.Sha1ObjectFormat.EmptyObjectID()},
},
})
commits = append(commits, &asymkey.SignCommit{
@@ -110,9 +111,9 @@ func prepareMockDataBadgeCommitSign(ctx *context.Context) {
Reason: "gpg.error",
SigningEmail: "test@example.com",
},
UserCommit: &user_model.UserCommit{
User: mockUser,
Commit: &git.Commit{ID: git.Sha1ObjectFormat.EmptyObjectID()},
UserCommit: &gituser.UserCommit{
AuthorUser: mockUser,
GitCommit: &git.Commit{ID: git.Sha1ObjectFormat.EmptyObjectID()},
},
})
@@ -159,6 +160,59 @@ func prepareMockDataBadgeActionsSvg(ctx *context.Context) {
ctx.Data["SelectedStyle"] = selectedStyle
}
func prepareMockDataAvatarStack(ctx *context.Context) {
/*
mockUsers, _ := db.Find[user_model.User](ctx, user_model.SearchUserOptions{ListOptions: db.ListOptions{PageSize: 3}})
if len(mockUsers) == 0 {
return
}
u0 := mockUsers[0]
u1, u2 := u0, u0
if len(mockUsers) >= 2 {
u1 = mockUsers[1]
}
if len(mockUsers) >= 3 {
u2 = mockUsers[2]
}
authorSig := func(u *user_model.User) *git.Signature {
return &git.Signature{Name: u.Name, Email: u.Email}
}
coLinked := func(u *user_model.User) *gituser.CommitParticipant {
return &gituser.CommitParticipant{GiteaUser: u, GitIdentity: authorSig(u)}
}
coUnlinked := func(name, email string) *gituser.CommitParticipant {
return &gituser.CommitParticipant{GitIdentity: &git.Signature{Name: name, Email: email}}
}
nUnlinked := func(n int) []*gituser.CommitParticipant {
out := make([]*gituser.CommitParticipant, n)
for i := range out {
out[i] = coUnlinked(fmt.Sprintf("Contributor %d", i+1), fmt.Sprintf("contrib%d@example.com", i+1))
}
return out
}
type scenario struct {
Label string
Data *gituser.AvatarStackData
}
mk := gituser.BuildAvatarStackData()
extSig := &git.Signature{Name: "External Contributor", Email: "external@example.com"}
ctx.Data["AvatarStackScenarios"] = []scenario{
{Label: "linked author, no co-authors", Data: mk(u0, authorSig(u0), nil)},
{Label: "unlinked author, no co-authors", Data: mk(nil, extSig, nil)},
{Label: "1 linked co-author", Data: mk(u0, authorSig(u0), []*gituser.CommitParticipant{coLinked(u1)})},
{Label: "1 unlinked co-author", Data: mk(u0, authorSig(u0), []*gituser.CommitParticipant{coUnlinked("Bob Smith", "bob@example.com")})},
{Label: "2 co-authors (3 people), u1 author", Data: mk(u1, authorSig(u1), []*gituser.CommitParticipant{coLinked(u0), coUnlinked("Bob Smith", "bob@example.com")})},
{Label: "3 co-authors mixed (4 people)", Data: mk(u0, authorSig(u0), []*gituser.CommitParticipant{coLinked(u1), coLinked(u2), coUnlinked("Bob Smith", "bob@example.com")})},
{Label: "9 co-authors (max visible, no overflow), u2 author", Data: mk(u2, authorSig(u2), nUnlinked(9))},
{Label: "10 co-authors (overflow +1)", Data: mk(u0, authorSig(u0), nUnlinked(10))},
{Label: "15 co-authors (overflow +6), unlinked author", Data: mk(nil, extSig, nUnlinked(15))},
{Label: "30 co-authors (overflow +21)", Data: mk(u0, authorSig(u0), nUnlinked(30))},
}
*/
}
func prepareMockDataRelativeTime(ctx *context.Context) {
now := time.Now()
ctx.Data["TimeNow"] = now
@@ -192,11 +246,33 @@ func prepareMockData(ctx *context.Context) {
prepareMockDataBadgeActionsSvg(ctx)
case "/devtest/relative-time":
prepareMockDataRelativeTime(ctx)
case "/devtest/toast-and-message":
prepareMockDataToastAndMessage(ctx)
case "/devtest/unicode-escape":
prepareMockDataUnicodeEscape(ctx)
case "/devtest/avatar-stack":
prepareMockDataAvatarStack(ctx)
}
}
func prepareMockDataToastAndMessage(ctx *context.Context) {
msgWithDetails, _ := ctx.RenderToHTML("base/alert_details", map[string]any{
"Message": "message with details <script>escape xss</script>",
"Summary": "summary with details",
"Details": "details line 1\n details line 2\n details line 3",
})
msgWithSummary, _ := ctx.RenderToHTML("base/alert_details", map[string]any{
"Message": "message with summary <script>escape xss</script>",
"Summary": "summary only",
})
ctx.Flash.ErrorMsg = string(msgWithDetails)
ctx.Flash.WarningMsg = string(msgWithSummary)
ctx.Flash.InfoMsg = "a long message with line break\nthe second line <script>removed xss</script>"
ctx.Flash.SuccessMsg = "single line message <script>removed xss</script>"
ctx.Data["Flash"] = ctx.Flash
}
func prepareMockDataUnicodeEscape(ctx *context.Context) {
content := "// demo code\n"
content += "if accessLevel != \"user\u202E \u2066// Check if admin (invisible char)\u2069 \u2066\" { }\n"
@@ -221,10 +297,9 @@ func prepareMockDataUnicodeEscape(ctx *context.Context) {
func TmplCommon(ctx *context.Context) {
prepareMockData(ctx)
if ctx.Req.Method == http.MethodPost {
_ = ctx.Req.ParseForm()
ctx.Flash.Info("form: "+ctx.Req.Method+" "+ctx.Req.RequestURI+"<br>"+
"Form: "+ctx.Req.Form.Encode()+"<br>"+
if ctx.Req.Method == http.MethodPost && ctx.FormBool("mock_response_delay") {
ctx.Flash.Info("form submit: "+ctx.Req.Method+" "+ctx.Req.RequestURI+"\n"+
"Form: "+ctx.Req.Form.Encode()+"\n"+
"PostForm: "+ctx.Req.PostForm.Encode(),
true,
)
@@ -7,12 +7,12 @@ import (
"net/http"
"strings"
"code.gitea.io/gitea/modules/templates"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/services/context"
"code.gitea.io/gitea/services/mailer"
"gitea.dev/modules/templates"
"gitea.dev/modules/util"
"gitea.dev/services/context"
"gitea.dev/services/mailer"
"gopkg.in/yaml.v3"
"go.yaml.in/yaml/v4"
)
func MailPreviewRender(ctx *context.Context) {
@@ -4,6 +4,7 @@
package devtest
import (
"fmt"
mathRand "math/rand/v2"
"net/http"
"slices"
@@ -11,12 +12,15 @@ import (
"strings"
"time"
actions_model "code.gitea.io/gitea/models/actions"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/modules/web"
"code.gitea.io/gitea/routers/web/repo/actions"
"code.gitea.io/gitea/services/context"
actions_model "gitea.dev/models/actions"
user_model "gitea.dev/models/user"
"gitea.dev/modules/setting"
"gitea.dev/modules/templates"
"gitea.dev/modules/timeutil"
"gitea.dev/modules/util"
"gitea.dev/modules/web"
"gitea.dev/routers/web/repo/actions"
"gitea.dev/services/context"
)
type generateMockStepsLogOptions struct {
@@ -63,73 +67,270 @@ func generateMockStepsLog(logCur actions.LogCursor, opts generateMockStepsLogOpt
}
func MockActionsView(ctx *context.Context) {
ctx.Data["RunID"] = ctx.PathParamInt64("run")
if runID := ctx.PathParamInt64("run"); runID == 0 {
ctx.Redirect("/repo-action-view/runs/10")
return
}
ctx.Data["JobID"] = ctx.PathParamInt64("job")
ctx.Data["ActionsViewURL"] = ctx.Req.URL.Path
ctx.HTML(http.StatusOK, "devtest/repo-action-view")
}
func MockActionsRunsJobs(ctx *context.Context) {
runID := ctx.PathParamInt64("run")
attemptID := ctx.PathParamInt64("attempt")
alignTime := func(v, unit int64) int64 {
return (v + unit) / unit * unit
}
resp := &actions.ViewResponse{}
resp.State.Run.RepoID = 12345
resp.State.Run.Index = runID
resp.State.Run.TitleHTML = `mock run title <a href="/">link</a>`
resp.State.Run.Link = setting.AppSubURL + "/devtest/repo-action-view/runs/" + strconv.FormatInt(runID, 10)
resp.State.Run.Status = actions_model.StatusRunning.String()
resp.State.Run.CanCancel = runID == 10
resp.State.Run.CanApprove = runID == 20
resp.State.Run.CanRerun = runID == 30
resp.State.Run.CanRerunFailed = runID == 30
resp.State.Run.CanDeleteArtifact = true
resp.State.Run.WorkflowID = "workflow-id"
resp.State.Run.WorkflowLink = "./workflow-link"
resp.State.Run.Duration = "1h 23m 45s"
resp.State.Run.TriggeredAt = time.Now().Add(-time.Hour).Unix()
resp.State.Run.WorkflowID = "workflow-id.yml"
resp.State.Run.TriggerEvent = "push"
renderUtils := templates.NewRenderUtils(ctx)
user2, _ := user_model.GetUserByID(ctx, 2)
if user2 == nil {
user2 = &user_model.User{Name: "user2"}
}
user3, _ := user_model.GetUserByID(ctx, 3)
if user3 == nil {
user3 = &user_model.User{Name: "user3"}
}
resp.State.Run.Commit = actions.ViewCommit{
ShortSha: "ccccdddd",
Link: "./commit-link",
Pusher: actions.ViewUser{
DisplayName: "pusher user",
Link: "./pusher-link",
DisplayName: user2.GetDisplayName(),
Link: user2.HomeLink(),
AvatarLink: user2.AvatarLinkWithSize(ctx, 16),
},
Branch: actions.ViewBranch{
Name: "commit-branch",
Name: "user2:commit-branch",
Link: "./branch-link",
IsDeleted: false,
},
}
resp.State.Run.PullRequest = &actions.ViewPullRequest{
Index: "#37658",
Link: "./pull/37658",
}
now := time.Now()
currentAttemptNum := int64(1)
if attemptID > 0 {
currentAttemptNum = attemptID
}
attempts := []*actions_model.ActionRunAttempt{{
Attempt: 1,
Status: actions_model.StatusSuccess,
Created: timeutil.TimeStamp(now.Add(-time.Hour).Unix()),
TriggerUserID: 2,
TriggerUser: user2,
}}
if runID == 10 {
attempts = []*actions_model.ActionRunAttempt{
{
Attempt: 3,
Status: actions_model.StatusSuccess,
Created: timeutil.TimeStamp(alignTime(now.Add(-time.Hour).Unix(), 3600)),
TriggerUserID: 2,
TriggerUser: user2,
},
{
Attempt: 2,
Status: actions_model.StatusFailure,
Created: timeutil.TimeStamp(alignTime(now.Add(-2*time.Hour).Unix(), 3600)),
TriggerUserID: 1,
TriggerUser: user3,
},
{
Attempt: 1,
Status: actions_model.StatusSuccess,
Created: timeutil.TimeStamp(alignTime(now.Add(-3*time.Hour).Unix(), 3600)),
TriggerUserID: 2,
TriggerUser: user2,
},
}
if attemptID == 0 {
currentAttemptNum = 3
}
}
latestAttempt := attempts[0]
resp.State.Run.RunAttempt = currentAttemptNum
resp.State.Run.Done = latestAttempt.Status.IsDone()
resp.State.Run.Status = latestAttempt.Status.String()
resp.State.Run.Duration = "1h 23m 45s"
resp.State.Run.TriggeredAt = latestAttempt.Created.AsTime().Unix()
resp.State.Run.ViewLink = resp.State.Run.Link
for _, attempt := range attempts {
link := resp.State.Run.Link
if attempt.Attempt != latestAttempt.Attempt {
link = fmt.Sprintf("%s/attempts/%d", resp.State.Run.Link, attempt.Attempt)
}
current := attempt.Attempt == currentAttemptNum
if current {
resp.State.Run.Status = attempt.Status.String()
resp.State.Run.Done = attempt.Status.IsDone()
resp.State.Run.TriggeredAt = attempt.Created.AsTime().Unix()
if attempt.Attempt != latestAttempt.Attempt {
resp.State.Run.ViewLink = link
}
}
resp.State.Run.Attempts = append(resp.State.Run.Attempts, &actions.ViewRunAttempt{
Attempt: attempt.Attempt,
Status: attempt.Status.String(),
Done: attempt.Status.IsDone(),
Link: link,
Current: current,
Latest: attempt.Attempt == latestAttempt.Attempt,
TriggeredAt: attempt.Created.AsTime().Unix(),
TriggerUserName: attempt.TriggerUser.GetDisplayName(),
TriggerUserLink: attempt.TriggerUser.HomeLink(),
TriggerUserAvatar: attempt.TriggerUser.AvatarLinkWithSize(ctx, 16),
})
}
isLatestAttempt := currentAttemptNum == latestAttempt.Attempt
resp.State.Run.CanCancel = runID == 10 && isLatestAttempt
resp.State.Run.CanApprove = runID == 20 && isLatestAttempt
resp.State.Run.CanRerun = runID == 30 && isLatestAttempt
resp.State.Run.CanRerunFailed = runID == 30 && isLatestAttempt
// Mock job summaries so the devtest page can preview the Summary panel rendering.
// Only some runs have summaries, so the page also exercises the "no summary" state.
if runID == 10 || runID == 20 {
resp.State.Run.JobSummaries = []*actions.ViewJobSummary{
{
JobID: runID * 10,
JobName: "job 100 (testsubname)",
SummaryHTML: renderUtils.MarkdownToHtml("### Devtest job summary\n\n- Markdown rendering\n- Links: [example](https://example.com)\n\n```sh\necho hello\n```\n"),
},
{
JobID: runID*10 + 2,
JobName: "ULTRA LOOOOOOOOOOOONG job name 102 that exceeds the limit",
SummaryHTML: renderUtils.MarkdownToHtml("### Another summary\n\nThis demonstrates multiple job summaries in one run.\n\n- Item A\n- Item B\n"),
},
}
}
resp.Artifacts = append(resp.Artifacts, &actions.ArtifactsViewItem{
Name: "artifact-a",
Size: 100 * 1024,
Status: "expired",
Name: "artifact-a",
Size: 100 * 1024,
Status: "expired",
ExpiresUnix: alignTime(time.Now().Add(-24*time.Hour).Unix(), 3600),
})
resp.Artifacts = append(resp.Artifacts, &actions.ArtifactsViewItem{
Name: "artifact-b",
Size: 1024 * 1024,
Status: "completed",
Name: "artifact-b",
Size: 1024 * 1024,
Status: "completed",
ExpiresUnix: alignTime(time.Now().Add(24*time.Hour).Unix(), 3600),
})
resp.Artifacts = append(resp.Artifacts, &actions.ArtifactsViewItem{
Name: "artifact-very-loooooooooooooooooooooooooooooooooooooooooooooooooooooooong",
Size: 100 * 1024,
Status: "expired",
Name: "artifact-very-loooooooooooooooooooooooooooooooooooooooooooooooooooooooong",
Size: 100 * 1024,
Status: "expired",
ExpiresUnix: alignTime(time.Now().Add(-24*time.Hour).Unix(), 3600),
})
resp.Artifacts = append(resp.Artifacts, &actions.ArtifactsViewItem{
Name: "artifact-really-loooooooooooooooooooooooooooooooooooooooooooooooooooooooong",
Size: 1024 * 1024,
Status: "completed",
Name: "artifact-really-loooooooooooooooooooooooooooooooooooooooooooooooooooooooong",
Size: 1024 * 1024,
Status: "completed",
ExpiresUnix: 0,
})
jobLink := func(jobID int64) string {
return fmt.Sprintf("%s/jobs/%d", resp.State.Run.Link, jobID)
}
// Keep devtest mock runs minimal: use run 10 as a "complex graph" repro.
// This combines long durations, parallel roots, and a multi-dependency downstream job
// to validate the workflow graph rendering.
if runID == 10 {
resp.State.Run.WorkflowID = "workflow-devtest-complex"
resp.State.Run.Duration = "7h 12m 34s"
type mj struct {
jobID string
name string
status actions_model.Status
duration string
needs []string
}
mockJobs := []mj{
{jobID: "job-100", name: "job-100", status: actions_model.StatusSuccess, duration: "3s", needs: nil},
{jobID: "job-101", name: "job-101", status: actions_model.StatusSuccess, duration: "3s", needs: []string{"job-100"}},
{jobID: "job-102", name: "job-102", status: actions_model.StatusSuccess, duration: "4s", needs: []string{"job-100", "job-101"}},
{jobID: "job-103", name: "job-103", status: actions_model.StatusSuccess, duration: "2s", needs: []string{"job-100"}},
{jobID: "prep-jdk", name: "prep-jdk", status: actions_model.StatusSuccess, duration: "3s", needs: nil},
{jobID: "code-analysis", name: "code-analysis", status: actions_model.StatusSuccess, duration: "3s", needs: nil},
// Matrix expansion: the legs share a single JobID, which is what the frontend groups rows on
{jobID: "matrix-e2e", name: "matrix-e2e (1, chromium)", status: actions_model.StatusSuccess, duration: "2s", needs: []string{"prep-jdk"}},
{jobID: "matrix-e2e", name: "matrix-e2e (1, firefox)", status: actions_model.StatusSuccess, duration: "2s", needs: []string{"prep-jdk"}},
{jobID: "matrix-e2e", name: "matrix-e2e (2, chromium)", status: actions_model.StatusSuccess, duration: "2s", needs: []string{"prep-jdk"}},
{jobID: "matrix-e2e", name: "matrix-e2e (3, chromium)", status: actions_model.StatusSuccess, duration: "4s", needs: []string{"prep-jdk"}},
{jobID: "matrix-e2e", name: "matrix-e2e (3, firefox)", status: actions_model.StatusSuccess, duration: "2s", needs: []string{"prep-jdk"}},
{jobID: "matrix-e2e", name: "matrix-e2e (99, webkit)", status: actions_model.StatusSuccess, duration: "2s", needs: []string{"prep-jdk"}},
// Matrix legs whose `name:` interpolates matrix values, so no " (...)" suffix is derived
{jobID: "e2e-browsers", name: "E2E on chromium", status: actions_model.StatusSuccess, duration: "2s", needs: []string{"prep-jdk"}},
{jobID: "e2e-browsers", name: "E2E on firefox", status: actions_model.StatusSuccess, duration: "3s", needs: []string{"prep-jdk"}},
{jobID: "e2e-browsers", name: "E2E on webkit", status: actions_model.StatusSuccess, duration: "2s", needs: []string{"prep-jdk"}},
{jobID: "unit-test", name: "unit-test", status: actions_model.StatusSuccess, duration: "3s", needs: []string{"prep-jdk"}},
{jobID: "arch-test", name: "arch-test", status: actions_model.StatusSuccess, duration: "3s", needs: []string{"prep-jdk"}},
{jobID: "integration-test", name: "integration-test", status: actions_model.StatusSuccess, duration: "4s", needs: []string{"prep-jdk"}},
{jobID: "build-image", name: "build-image with a very long name that does not fit into the sidebar", status: actions_model.StatusSuccess, duration: "3s", needs: []string{
"unit-test",
"arch-test",
"integration-test",
"code-analysis",
"matrix-e2e",
"e2e-browsers",
}},
// Separate jobs that only look like matrix legs, so they must stay separate nodes
{jobID: "deploy-staging", name: "Deploy (staging)", status: actions_model.StatusSuccess, duration: "5s", needs: []string{"build-image"}},
{jobID: "deploy-prod", name: "Deploy (prod)", status: actions_model.StatusSuccess, duration: "6s", needs: []string{"deploy-staging"}},
}
resp.State.Run.Jobs = nil
for i, j := range mockJobs {
id := runID*1000 + int64(i)
resp.State.Run.Jobs = append(resp.State.Run.Jobs, &actions.ViewJob{
ID: id,
Link: jobLink(id),
JobID: j.jobID,
Name: j.name,
Status: j.status.String(),
CanRerun: j.jobID == "job-100",
Duration: j.duration,
Needs: j.needs,
})
}
fillViewRunResponseCurrentJob(ctx, resp)
ctx.JSON(http.StatusOK, resp)
return
}
resp.State.Run.Jobs = append(resp.State.Run.Jobs, &actions.ViewJob{
ID: runID * 10,
Link: jobLink(runID * 10),
JobID: "job-100",
Name: "job 100",
Name: "job 100 (testsubname)",
Status: actions_model.StatusRunning.String(),
CanRerun: true,
Duration: "1h",
Duration: "1h23m45s",
})
resp.State.Run.Jobs = append(resp.State.Run.Jobs, &actions.ViewJob{
ID: runID*10 + 1,
Link: jobLink(runID*10 + 1),
JobID: "job-101",
Name: "job 101",
Status: actions_model.StatusWaiting.String(),
@@ -139,15 +340,17 @@ func MockActionsRunsJobs(ctx *context.Context) {
})
resp.State.Run.Jobs = append(resp.State.Run.Jobs, &actions.ViewJob{
ID: runID*10 + 2,
Link: jobLink(runID*10 + 2),
JobID: "job-102",
Name: "ULTRA LOOOOOOOOOOOONG job name 102 that exceeds the limit",
Status: actions_model.StatusFailure.String(),
CanRerun: false,
Duration: "3h",
Duration: "3h35m10s",
Needs: []string{"job-100", "job-101"},
})
resp.State.Run.Jobs = append(resp.State.Run.Jobs, &actions.ViewJob{
ID: runID*10 + 3,
Link: jobLink(runID*10 + 3),
JobID: "job-103",
Name: "job 103",
Status: actions_model.StatusCancelled.String(),
@@ -159,8 +362,10 @@ func MockActionsRunsJobs(ctx *context.Context) {
// add more jobs to a run for UI testing
if resp.State.Run.CanCancel {
for i := range 10 {
jobID := runID*1000 + int64(i)
resp.State.Run.Jobs = append(resp.State.Run.Jobs, &actions.ViewJob{
ID: runID*1000 + int64(i),
ID: jobID,
Link: jobLink(jobID),
JobID: "job-dup-test-" + strconv.Itoa(i),
Name: "job dup test " + strconv.Itoa(i),
Status: actions_model.StatusSuccess.String(),
@@ -171,6 +376,139 @@ func MockActionsRunsJobs(ctx *context.Context) {
}
}
if runID == 40 {
// Reusable workflow caller demo: same-repo caller (with a nested same-repo caller inside),
// alongside a flat cross-repo caller.
// Layout:
// prepare (regular, top-level)
// local_caller (caller, same-repo, expanded)
// ├ lib_step (regular)
// └ inner_caller (caller, same-repo nested, expanded)
// └ deep_job (regular)
// cross_caller (caller, cross-repo, expanded)
// └ external_job (regular)
// build (linux|windows|macos) (regular matrix; graph folds into one "build" node)
// build-call (linux|windows|macos) (caller matrix, each calls build.yml; folds into one "build-call" node like "build")
// final (regular, needs local_caller + cross_caller)
const (
prepareID = int64(400)
localCallerID = int64(401)
libStepID = int64(402)
innerCallerID = int64(403)
deepJobID = int64(404)
crossCallerID = int64(405)
externalJobID = int64(406)
finalID = int64(407)
// Regular matrix set – the graph already folds these into a single "build" node.
buildLinuxID = int64(410)
buildWindowsID = int64(411)
buildMacosID = int64(412)
// Caller matrix set – each matrix leg calls the same reusable workflow. #38466: like the
// regular "build" matrix above, these fold into one "build-call" node. Matrix legs share a
// single JobID, so the legs below use JobID "build-call" and differ only by their name suffix.
buildCallLinuxID = int64(420)
buildCallWindowsID = int64(421)
buildCallMacosID = int64(422)
buildCallLinuxJobID = int64(423)
buildCallWinJobID = int64(424)
buildCallMacJobID = int64(425)
)
resp.State.Run.Jobs = []*actions.ViewJob{
{
ID: prepareID, Link: jobLink(prepareID), JobID: "prepare", Name: "prepare",
Status: actions_model.StatusSuccess.String(), Duration: "30s",
},
{
ID: localCallerID, Link: jobLink(localCallerID), JobID: "local_caller", Name: "local caller",
Status: actions_model.StatusRunning.String(), Duration: "5m",
Needs: []string{"prepare"},
IsReusableCaller: true, CallUses: "./.gitea/workflows/lib.yml",
},
{
ID: libStepID, Link: jobLink(libStepID), JobID: "lib_step", Name: "lib step",
Status: actions_model.StatusSuccess.String(), Duration: "1m",
ParentJobID: localCallerID,
},
{
ID: innerCallerID, Link: jobLink(innerCallerID), JobID: "inner_caller", Name: "inner caller (nested)",
Status: actions_model.StatusRunning.String(), Duration: "4m",
ParentJobID: localCallerID,
IsReusableCaller: true, CallUses: "./.gitea/workflows/inner.yml",
},
{
ID: deepJobID, Link: jobLink(deepJobID), JobID: "deep_job", Name: "deep job",
Status: actions_model.StatusRunning.String(), Duration: "2m",
ParentJobID: innerCallerID,
},
{
ID: crossCallerID, Link: jobLink(crossCallerID), JobID: "cross_caller", Name: "cross-repo caller",
Status: actions_model.StatusWaiting.String(), Duration: "0s",
Needs: []string{"prepare"},
IsReusableCaller: true, CallUses: "user2/lib-repo/.gitea/workflows/external.yml@main",
},
{
ID: externalJobID, Link: jobLink(externalJobID), JobID: "external_job", Name: "external job",
Status: actions_model.StatusWaiting.String(), Duration: "0s",
ParentJobID: crossCallerID,
},
// Regular matrix "build" – these fold into one matrix node in the graph. The matrix legs
// share a single JobID ("build"); the " (variant)" name suffix distinguishes the legs.
{
ID: buildLinuxID, Link: jobLink(buildLinuxID), JobID: "build", Name: "build (linux)",
Status: actions_model.StatusSuccess.String(), Duration: "1m", Needs: []string{"prepare"},
},
{
ID: buildWindowsID, Link: jobLink(buildWindowsID), JobID: "build", Name: "build (windows)",
Status: actions_model.StatusSuccess.String(), Duration: "2m", Needs: []string{"prepare"},
},
{
ID: buildMacosID, Link: jobLink(buildMacosID), JobID: "build", Name: "build (macos)",
Status: actions_model.StatusSuccess.String(), Duration: "90s", Needs: []string{"prepare"},
},
// Caller matrix "build-call" – each leg calls the same reusable workflow. #38466: like the
// regular "build" matrix above, these fold into one node. The matrix legs share a single
// JobID ("build-call"); the " (variant)" name suffix distinguishes the legs.
{
ID: buildCallLinuxID, Link: jobLink(buildCallLinuxID), JobID: "build-call", Name: "build-call (linux)",
Status: actions_model.StatusSuccess.String(), Duration: "1m", Needs: []string{"prepare"},
IsReusableCaller: true, CallUses: "./.gitea/workflows/build.yml",
},
{
ID: buildCallLinuxJobID, Link: jobLink(buildCallLinuxJobID), JobID: "bc_linux_build", Name: "build",
Status: actions_model.StatusSuccess.String(), Duration: "1m", ParentJobID: buildCallLinuxID,
},
{
ID: buildCallWindowsID, Link: jobLink(buildCallWindowsID), JobID: "build-call", Name: "build-call (windows)",
Status: actions_model.StatusSuccess.String(), Duration: "2m", Needs: []string{"prepare"},
IsReusableCaller: true, CallUses: "./.gitea/workflows/build.yml",
},
{
ID: buildCallWinJobID, Link: jobLink(buildCallWinJobID), JobID: "bc_windows_build", Name: "build",
Status: actions_model.StatusSuccess.String(), Duration: "2m", ParentJobID: buildCallWindowsID,
},
{
ID: buildCallMacosID, Link: jobLink(buildCallMacosID), JobID: "build-call", Name: "build-call (macos)",
Status: actions_model.StatusSuccess.String(), Duration: "90s", Needs: []string{"prepare"},
IsReusableCaller: true, CallUses: "./.gitea/workflows/build.yml",
},
{
ID: buildCallMacJobID, Link: jobLink(buildCallMacJobID), JobID: "bc_macos_build", Name: "build",
Status: actions_model.StatusSuccess.String(), Duration: "90s", ParentJobID: buildCallMacosID,
},
{
ID: finalID, Link: jobLink(finalID), JobID: "final", Name: "final",
Status: actions_model.StatusBlocked.String(), Duration: "0s",
Needs: []string{"local_caller", "cross_caller"},
},
}
}
fillViewRunResponseCurrentJob(ctx, resp)
ctx.JSON(http.StatusOK, resp)
}
@@ -181,6 +519,14 @@ func fillViewRunResponseCurrentJob(ctx *context.Context, resp *actions.ViewRespo
return
}
for _, job := range resp.State.Run.Jobs {
if job.ID == jobID {
resp.State.CurrentJob.Title = job.Name
resp.State.CurrentJob.Detail = job.Status
break
}
}
req := web.GetForm(ctx).(*actions.ViewRequest)
var mockLogOptions []generateMockStepsLogOptions
resp.State.CurrentJob.Steps = append(resp.State.CurrentJob.Steps, &actions.ViewJobStep{
@@ -7,11 +7,11 @@ import (
"net/http"
"time"
"code.gitea.io/gitea/modules/eventsource"
"code.gitea.io/gitea/modules/graceful"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/routers/web/auth"
"code.gitea.io/gitea/services/context"
"gitea.dev/modules/eventsource"
"gitea.dev/modules/graceful"
"gitea.dev/modules/log"
"gitea.dev/routers/web/auth"
"gitea.dev/services/context"
)
// Events listens for events
@@ -7,13 +7,13 @@ import (
"net/http"
"slices"
"code.gitea.io/gitea/models/db"
repo_model "code.gitea.io/gitea/models/repo"
code_indexer "code.gitea.io/gitea/modules/indexer/code"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/templates"
"code.gitea.io/gitea/routers/common"
"code.gitea.io/gitea/services/context"
"gitea.dev/models/db"
repo_model "gitea.dev/models/repo"
code_indexer "gitea.dev/modules/indexer/code"
"gitea.dev/modules/setting"
"gitea.dev/modules/templates"
"gitea.dev/routers/common"
"gitea.dev/services/context"
)
const (
+10 -13
View File
@@ -4,13 +4,13 @@
package explore
import (
"code.gitea.io/gitea/models/db"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/container"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/services/context"
"gitea.dev/models/db"
user_model "gitea.dev/models/user"
"gitea.dev/modules/container"
"gitea.dev/modules/setting"
"gitea.dev/modules/structs"
"gitea.dev/modules/util"
"gitea.dev/services/context"
)
// Organizations render explore organizations page
@@ -38,17 +38,14 @@ func Organizations(ctx *context.Context) {
"alphabetically",
"reversealphabetically",
)
sortOrder := ctx.FormString("sort")
if sortOrder == "" {
sortOrder = util.Iif(supportedSortOrders.Contains(setting.UI.ExploreDefaultSort), setting.UI.ExploreDefaultSort, "newest")
ctx.SetFormString("sort", sortOrder)
}
sortOrderDefault := util.Iif(supportedSortOrders.Contains(setting.UI.ExploreDefaultSort), setting.UI.ExploreDefaultSort, "newest")
sortOrder := ctx.FormString("sort", sortOrderDefault)
RenderUserSearch(ctx, user_model.SearchUserOptions{
Actor: ctx.Doer,
Types: []user_model.UserType{user_model.UserTypeOrganization},
ListOptions: db.ListOptions{PageSize: setting.UI.ExplorePagingNum},
Visible: visibleTypes,
OrderBy: db.SearchOrderBy(sortOrder),
SupportedSortOrders: supportedSortOrders,
}, tplExploreUsers)
@@ -6,13 +6,13 @@ package explore
import (
"net/http"
"code.gitea.io/gitea/models/db"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/sitemap"
"code.gitea.io/gitea/modules/templates"
"code.gitea.io/gitea/services/context"
"gitea.dev/models/db"
repo_model "gitea.dev/models/repo"
"gitea.dev/modules/log"
"gitea.dev/modules/setting"
"gitea.dev/modules/sitemap"
"gitea.dev/modules/templates"
"gitea.dev/services/context"
)
const (
@@ -6,11 +6,11 @@ package explore
import (
"net/http"
"code.gitea.io/gitea/models/db"
repo_model "code.gitea.io/gitea/models/repo"
api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/services/context"
"code.gitea.io/gitea/services/convert"
"gitea.dev/models/db"
repo_model "gitea.dev/models/repo"
api "gitea.dev/modules/structs"
"gitea.dev/services/context"
"gitea.dev/services/convert"
)
// TopicSearch search for creating topic
@@ -7,17 +7,17 @@ import (
"bytes"
"net/http"
"code.gitea.io/gitea/models/db"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/container"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/optional"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/sitemap"
"code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/templates"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/services/context"
"gitea.dev/models/db"
user_model "gitea.dev/models/user"
"gitea.dev/modules/container"
"gitea.dev/modules/log"
"gitea.dev/modules/optional"
"gitea.dev/modules/setting"
"gitea.dev/modules/sitemap"
"gitea.dev/modules/structs"
"gitea.dev/modules/templates"
"gitea.dev/modules/util"
"gitea.dev/services/context"
)
const (
@@ -55,11 +55,7 @@ func RenderUserSearch(ctx *context.Context, opts user_model.SearchUserOptions, t
)
// we can not set orderBy to `models.SearchOrderByXxx`, because there may be a JOIN in the statement, different tables may have the same name columns
sortOrder := ctx.FormString("sort")
if sortOrder == "" {
sortOrder = setting.UI.ExploreDefaultSort
}
sortOrder := util.IfZero(string(opts.OrderBy), ctx.FormString("sort", setting.UI.ExploreDefaultSort))
ctx.Data["SortType"] = sortOrder
switch sortOrder {
@@ -145,18 +141,15 @@ func Users(ctx *context.Context) {
"alphabetically",
"reversealphabetically",
)
sortOrder := ctx.FormString("sort")
if sortOrder == "" {
sortOrder = util.Iif(supportedSortOrders.Contains(setting.UI.ExploreDefaultSort), setting.UI.ExploreDefaultSort, "newest")
ctx.SetFormString("sort", sortOrder)
}
sortOrderDefault := util.Iif(supportedSortOrders.Contains(setting.UI.ExploreDefaultSort), setting.UI.ExploreDefaultSort, "newest")
sortOrder := ctx.FormString("sort", sortOrderDefault)
RenderUserSearch(ctx, user_model.SearchUserOptions{
Actor: ctx.Doer,
Types: []user_model.UserType{user_model.UserTypeIndividual},
ListOptions: db.ListOptions{PageSize: setting.UI.ExplorePagingNum},
IsActive: optional.Some(true),
Visible: []structs.VisibleType{structs.VisibleTypePublic, structs.VisibleTypeLimited, structs.VisibleTypePrivate},
OrderBy: db.SearchOrderBy(sortOrder),
SupportedSortOrders: supportedSortOrders,
}, tplExploreUsers)
@@ -4,18 +4,20 @@
package feed
import (
"strings"
"time"
"code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/services/context"
"gitea.dev/models/repo"
"gitea.dev/modules/git"
"gitea.dev/services/context"
"github.com/gorilla/feeds"
)
// ShowBranchFeed shows tags and/or releases on the repo as RSS / Atom feed
func ShowBranchFeed(ctx *context.Context, repo *repo.Repository, formatType string) {
if !checkRepoFeedTokenScope(ctx) {
return
}
var commits []*git.Commit
var err error
if ctx.Repo.Commit != nil {
@@ -39,14 +41,14 @@ func ShowBranchFeed(ctx *context.Context, repo *repo.Repository, formatType stri
for _, commit := range commits {
feed.Items = append(feed.Items, &feeds.Item{
Id: commit.ID.String(),
Title: strings.TrimSpace(strings.Split(commit.Message(), "\n")[0]),
Title: commit.MessageTitle(),
Link: &feeds.Link{Href: repo.HTMLURL() + "/commit/" + commit.ID.String()},
Author: &feeds.Author{
Name: commit.Author.Name,
Email: commit.Author.Email,
},
Description: commit.Message(),
Content: commit.Message(),
Description: commit.MessageUTF8(), // TODO: description can be shorten content
Content: commit.MessageUTF8(),
Created: commit.Committer.When,
})
}
@@ -12,14 +12,15 @@ import (
"strconv"
"strings"
activities_model "code.gitea.io/gitea/models/activities"
"code.gitea.io/gitea/models/renderhelper"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/modules/markup/markdown"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/templates"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/services/context"
activities_model "gitea.dev/models/activities"
"gitea.dev/models/renderhelper"
repo_model "gitea.dev/models/repo"
"gitea.dev/modules/markup"
"gitea.dev/modules/markup/markdown"
"gitea.dev/modules/setting"
"gitea.dev/modules/templates"
"gitea.dev/modules/util"
"gitea.dev/services/context"
"github.com/gorilla/feeds"
)
@@ -237,7 +238,7 @@ func feedActionsToFeedItems(ctx *context.Context, actions activities_model.Actio
}
}
if len(content) == 0 {
content = templates.SanitizeHTML(desc)
content = markup.Sanitize(desc)
}
items = append(items, &feeds.Item{
+11 -9
View File
@@ -4,24 +4,26 @@
package feed
import (
"strings"
"time"
"code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/services/context"
"gitea.dev/models/repo"
"gitea.dev/modules/git"
"gitea.dev/modules/util"
"gitea.dev/services/context"
"github.com/gorilla/feeds"
)
// ShowFileFeed shows tags and/or releases on the repo as RSS / Atom feed
func ShowFileFeed(ctx *context.Context, repo *repo.Repository, formatType string) {
if !checkRepoFeedTokenScope(ctx) {
return
}
fileName := ctx.Repo.TreePath
if len(fileName) == 0 {
return
}
commits, err := ctx.Repo.GitRepo.CommitsByFileAndRange(
commits, _, err := ctx.Repo.GitRepo.CommitsByFileAndRange(
git.CommitsByFileAndRangeOptions{
Revision: ctx.Repo.RefFullName.ShortName(), // FIXME: legacy code used ShortName
File: fileName,
@@ -46,14 +48,14 @@ func ShowFileFeed(ctx *context.Context, repo *repo.Repository, formatType string
for _, commit := range commits {
feed.Items = append(feed.Items, &feeds.Item{
Id: commit.ID.String(),
Title: strings.TrimSpace(strings.Split(commit.Message(), "\n")[0]),
Title: commit.MessageTitle(),
Link: &feeds.Link{Href: repo.HTMLURL() + "/commit/" + commit.ID.String()},
Author: &feeds.Author{
Name: commit.Author.Name,
Email: commit.Author.Email,
},
Description: commit.Message(),
Content: commit.Message(),
Description: commit.MessageUTF8(), // TODO: description can be shorten content
Content: commit.MessageUTF8(),
Created: commit.Committer.When,
})
}
@@ -6,12 +6,12 @@ package feed
import (
"time"
activities_model "code.gitea.io/gitea/models/activities"
"code.gitea.io/gitea/models/organization"
"code.gitea.io/gitea/models/renderhelper"
"code.gitea.io/gitea/modules/markup/markdown"
"code.gitea.io/gitea/services/context"
feed_service "code.gitea.io/gitea/services/feed"
activities_model "gitea.dev/models/activities"
"gitea.dev/models/organization"
"gitea.dev/models/renderhelper"
"gitea.dev/modules/markup/markdown"
"gitea.dev/services/context"
feed_service "gitea.dev/services/feed"
"github.com/gorilla/feeds"
)
@@ -41,6 +41,11 @@ func showUserFeed(ctx *context.Context, formatType string) {
includePrivate = isOrgMember
}
// a public-only API token must not surface private activity, even for its own owner
if includePrivate && context.TokenIsPublicOnly(ctx) {
includePrivate = false
}
actions, _, err := feed_service.GetFeeds(ctx, activities_model.GetFeedsOptions{
RequestedUser: ctx.ContextUser,
Actor: ctx.Doer,
@@ -5,10 +5,10 @@ package feed_test
import (
"testing"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/routers/web/feed"
"code.gitea.io/gitea/services/contexttest"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
"gitea.dev/routers/web/feed"
"gitea.dev/services/contexttest"
"github.com/stretchr/testify/assert"
)
@@ -6,15 +6,18 @@ package feed
import (
"time"
"code.gitea.io/gitea/models/db"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/services/context"
"gitea.dev/models/db"
repo_model "gitea.dev/models/repo"
"gitea.dev/services/context"
"github.com/gorilla/feeds"
)
// 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) {
if !checkRepoFeedTokenScope(ctx) {
return
}
releases, err := db.Find[repo_model.Release](ctx, repo_model.FindReleasesOptions{
IncludeTags: !isReleasesOnly,
RepoID: ctx.Repo.Repository.ID,
@@ -4,9 +4,18 @@
package feed
import (
"code.gitea.io/gitea/services/context"
auth_model "gitea.dev/models/auth"
"gitea.dev/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
func RenderBranchFeed(ctx *context.Context, feedType string) {
if ctx.Repo.TreePath == "" {
@@ -6,16 +6,19 @@ package feed
import (
"time"
activities_model "code.gitea.io/gitea/models/activities"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/services/context"
feed_service "code.gitea.io/gitea/services/feed"
activities_model "gitea.dev/models/activities"
repo_model "gitea.dev/models/repo"
"gitea.dev/services/context"
feed_service "gitea.dev/services/feed"
"github.com/gorilla/feeds"
)
// ShowRepoFeed shows user activity on the repo as RSS / Atom feed
func ShowRepoFeed(ctx *context.Context, repo *repo_model.Repository, formatType string) {
if !checkRepoFeedTokenScope(ctx) {
return
}
actions, _, err := feed_service.GetFeeds(ctx, activities_model.GetFeedsOptions{
RequestedRepo: repo,
Actor: ctx.Doer,
+2 -2
View File
@@ -4,8 +4,8 @@
package web
import (
"code.gitea.io/gitea/modules/web"
"code.gitea.io/gitea/routers/web/repo"
"gitea.dev/modules/web"
"gitea.dev/routers/web/repo"
)
func addOwnerRepoGitHTTPRouters(m *web.Router, middlewares ...any) {
+54 -8
View File
@@ -11,10 +11,14 @@ import (
"path"
"strings"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/services/context"
auth_model "gitea.dev/models/auth"
access_model "gitea.dev/models/perm/access"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unit"
user_model "gitea.dev/models/user"
"gitea.dev/modules/setting"
"gitea.dev/modules/util"
"gitea.dev/services/context"
)
func goGet(ctx *context.Context) {
@@ -51,10 +55,8 @@ func goGet(ctx *context.Context) {
return
}
branchName := setting.Repository.DefaultBranch
repo, err := repo_model.GetRepositoryByOwnerAndName(ctx, ownerName, repoName)
if err == nil && len(repo.DefaultBranch) > 0 {
branchName = repo.DefaultBranch
if repo, err := repo_model.GetRepositoryByOwnerAndName(ctx, ownerName, repoName); err == nil {
branchName = goGetDefaultBranch(ctx, repo)
}
prefix := setting.AppURL + path.Join(url.PathEscape(ownerName), url.PathEscape(repoName), "src", "branch", util.PathEscapeSegments(branchName))
@@ -91,3 +93,47 @@ func goGet(ctx *context.Context) {
ctx.RespHeader().Set("Content-Type", "text/html")
_, _ = ctx.Write([]byte(res))
}
// goGetDefaultBranch returns the repository's real default branch only when the caller may genuinely
// reach it, otherwise the neutral instance default, so the meta response does not disclose the branch
// name (or the repo's existence) to callers who cannot see the repository.
func goGetDefaultBranch(ctx *context.Context, repo *repo_model.Repository) string {
def := setting.Repository.DefaultBranch
if len(repo.DefaultBranch) == 0 {
return def
}
if err := repo.LoadOwner(ctx); err != nil || repo.Owner == nil {
return def
}
// a token that was not granted repository read scope must not learn repository details, even when the
// account behind it could read the repo through the web UI
if !goGetTokenCanReadRepo(ctx) {
return def
}
// a public-only token may only reach genuinely public resources (a public repo under a public owner)
if context.TokenIsPublicOnly(ctx) && (repo.IsPrivate || !repo.Owner.Visibility.IsPublic()) {
return def
}
// the caller must be able to read the code and see the owner: a limited/private owner hides its repos
// from anonymous/non-member callers even when the repo itself is public
perm, err := access_model.GetDoerRepoPermission(ctx, repo, ctx.Doer)
if err != nil || !perm.CanRead(unit.TypeCode) || !user_model.IsUserVisibleToViewer(ctx, repo.Owner, ctx.Doer) {
return def
}
return repo.DefaultBranch
}
// goGetTokenCanReadRepo reports whether the request may learn repository details. A non-token request
// always may; a token request may only when its scope grants repository read, so a PAT that was never
// scoped for repositories cannot disclose the branch even if its owner can read the repo.
func goGetTokenCanReadRepo(ctx *context.Context) bool {
if ctx.Data["IsApiToken"] != true {
return true
}
scope, ok := ctx.Data["ApiTokenScope"].(auth_model.AccessTokenScope)
if !ok {
return false
}
has, err := scope.HasScope(auth_model.AccessTokenScopeReadRepository)
return err == nil && has
}
@@ -9,11 +9,11 @@ import (
"os"
"time"
"code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/modules/cache"
"code.gitea.io/gitea/modules/json"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
"gitea.dev/models/db"
"gitea.dev/modules/cache"
"gitea.dev/modules/json"
"gitea.dev/modules/log"
"gitea.dev/modules/setting"
)
type status string
@@ -111,16 +111,10 @@ func checkDatabase(ctx context.Context, checks checks) status {
}
if setting.Database.Type.IsSQLite3() && st.Status == pass {
if !setting.EnableSQLite3 {
if _, err := os.Stat(setting.Database.Path); err != nil {
st.Status = fail
st.Time = getCheckTime()
log.Error("SQLite3 health check failed with error: %v", "this Gitea binary is built without SQLite3 enabled")
} else {
if _, err := os.Stat(setting.Database.Path); err != nil {
st.Status = fail
st.Time = getCheckTime()
log.Error("SQLite3 file exists check failed with error: %v", err)
}
log.Error("SQLite3 file exists check failed with error: %v", err)
}
}
+13 -19
View File
@@ -8,19 +8,19 @@ import (
"net/http"
"strconv"
"code.gitea.io/gitea/models/db"
repo_model "code.gitea.io/gitea/models/repo"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/optional"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/sitemap"
"code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/templates"
"code.gitea.io/gitea/modules/web/middleware"
"code.gitea.io/gitea/routers/web/auth"
"code.gitea.io/gitea/routers/web/user"
"code.gitea.io/gitea/services/context"
"gitea.dev/models/db"
repo_model "gitea.dev/models/repo"
user_model "gitea.dev/models/user"
"gitea.dev/modules/log"
"gitea.dev/modules/optional"
"gitea.dev/modules/setting"
"gitea.dev/modules/sitemap"
"gitea.dev/modules/structs"
"gitea.dev/modules/templates"
"gitea.dev/modules/web/middleware"
"gitea.dev/routers/web/auth"
"gitea.dev/routers/web/user"
"gitea.dev/services/context"
)
const (
@@ -109,9 +109,3 @@ func HomeSitemap(ctx *context.Context) {
log.Error("Failed writing sitemap: %v", err)
}
}
// NotFound render 404 page
func NotFound(ctx *context.Context) {
ctx.Data["Title"] = "Page Not Found"
ctx.NotFound(nil)
}
+1 -1
View File
@@ -7,7 +7,7 @@ import (
"crypto/subtle"
"net/http"
"code.gitea.io/gitea/modules/setting"
"gitea.dev/modules/setting"
"github.com/prometheus/client_golang/prometheus/promhttp"
)
@@ -5,11 +5,11 @@
package misc
import (
api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/modules/web"
"code.gitea.io/gitea/routers/common"
"code.gitea.io/gitea/services/context"
api "gitea.dev/modules/structs"
"gitea.dev/modules/util"
"gitea.dev/modules/web"
"gitea.dev/routers/common"
"gitea.dev/services/context"
)
// Markup render markup document to HTML
+33 -7
View File
@@ -7,16 +7,42 @@ import (
"net/http"
"path"
"strconv"
"strings"
"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/httpcache"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/modules/web/middleware"
"code.gitea.io/gitea/services/context"
"gitea.dev/modules/git"
"gitea.dev/modules/httpcache"
"gitea.dev/modules/httplib"
"gitea.dev/modules/json"
"gitea.dev/modules/log"
"gitea.dev/modules/setting"
"gitea.dev/modules/util"
"gitea.dev/modules/web/middleware"
"gitea.dev/services/context"
)
func SiteManifest(w http.ResponseWriter, req *http.Request) {
w.Header().Set("Content-Type", "application/manifest+json")
if httpcache.HandleGenericETagPublicCache(req, w, "", &setting.AppStartTime) {
return
}
if req.Method == http.MethodHead {
return
}
ctx := req.Context()
absoluteAssetURL := strings.TrimSuffix(httplib.MakeAbsoluteURL(ctx, setting.StaticURLPrefix), "/")
manifest := map[string]any{
"name": setting.AppName,
"short_name": setting.AppName,
"start_url": httplib.GuessCurrentAppURL(ctx),
"icons": []map[string]string{
{"src": absoluteAssetURL + "/assets/img/logo.png", "type": "image/png", "sizes": "512x512"},
{"src": absoluteAssetURL + "/assets/img/logo.svg", "type": "image/svg+xml", "sizes": "512x512"},
},
}
_ = json.NewEncoder(w).Encode(manifest)
}
func SSHInfo(rw http.ResponseWriter, req *http.Request) {
if !git.DefaultFeatures().SupportProcReceive {
rw.WriteHeader(http.StatusNotFound)
@@ -6,7 +6,7 @@ package misc
import (
"net/http"
"code.gitea.io/gitea/services/context"
"gitea.dev/services/context"
)
func Swagger(ctx *context.Context) {
@@ -6,13 +6,13 @@ package misc
import (
"net/http"
"code.gitea.io/gitea/modules/optional"
"code.gitea.io/gitea/modules/templates"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/modules/web/middleware"
"code.gitea.io/gitea/services/context"
user_service "code.gitea.io/gitea/services/user"
"code.gitea.io/gitea/services/webtheme"
"gitea.dev/modules/optional"
"gitea.dev/modules/templates"
"gitea.dev/modules/util"
"gitea.dev/modules/web/middleware"
"gitea.dev/services/context"
user_service "gitea.dev/services/user"
"gitea.dev/services/webtheme"
)
func WebThemeList(ctx *context.Context) {
+2 -2
View File
@@ -6,8 +6,8 @@ package web
import (
"net/http"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/services/context"
"gitea.dev/modules/setting"
"gitea.dev/services/context"
)
type nodeInfoLinks struct {
+4 -14
View File
@@ -6,9 +6,9 @@ package org
import (
"net/http"
"code.gitea.io/gitea/modules/templates"
shared_user "code.gitea.io/gitea/routers/web/shared/user"
"code.gitea.io/gitea/services/context"
"gitea.dev/modules/templates"
shared_user "gitea.dev/routers/web/shared/user"
"gitea.dev/services/context"
)
const (
@@ -34,15 +34,5 @@ func BlockedUsers(ctx *context.Context) {
}
func BlockedUsersPost(ctx *context.Context) {
if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil {
ctx.ServerError("RenderUserOrgHeader", err)
return
}
shared_user.BlockedUsersPost(ctx, ctx.ContextUser)
if ctx.Written() {
return
}
ctx.Redirect(ctx.ContextUser.OrganisationLink() + "/settings/blocked_users")
shared_user.BlockedUsersPost(ctx, ctx.ContextUser, ctx.ContextUser.OrganisationLink()+"/settings/blocked_users")
}
+36 -16
View File
@@ -5,21 +5,20 @@ package org
import (
"net/http"
"path"
"strings"
"code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/models/organization"
"code.gitea.io/gitea/models/renderhelper"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/markup/markdown"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/templates"
"code.gitea.io/gitea/modules/util"
shared_user "code.gitea.io/gitea/routers/web/shared/user"
"code.gitea.io/gitea/services/context"
"gitea.dev/models/db"
"gitea.dev/models/organization"
"gitea.dev/models/renderhelper"
repo_model "gitea.dev/models/repo"
"gitea.dev/modules/git"
"gitea.dev/modules/log"
"gitea.dev/modules/markup/markdown"
"gitea.dev/modules/setting"
"gitea.dev/modules/templates"
"gitea.dev/modules/util"
shared_user "gitea.dev/routers/web/shared/user"
"gitea.dev/services/context"
)
const tplOrgHome templates.TplName = "org/home"
@@ -98,8 +97,29 @@ func home(ctx *context.Context, viewRepositories bool) {
ctx.ServerError("FindOrgMembers", err)
return
}
ctx.Data["Members"] = members
ctx.Data["Teams"] = ctx.Org.Teams
const orgOverviewTeamsLimit = 5
ctx.Data["OrgOverviewMembers"] = members
// The overview widget shows only teams the viewer belongs to. ctx.Org.Teams
// may include visible-but-not-joined teams (via IncludeVisibilities for
// signed-in non-members), so re-query the viewer's own membership; owners
// keep the full list they are entitled to manage.
overviewTeams := ctx.Org.Teams
if !ctx.Org.IsOwner {
overviewTeams = nil
if ctx.Org.IsMember {
overviewTeams, _, err = organization.SearchTeam(ctx, &organization.SearchTeamOptions{
OrgID: org.ID,
UserID: ctx.Doer.ID,
ListOptions: db.ListOptions{Page: 1, PageSize: orgOverviewTeamsLimit},
})
if err != nil {
ctx.ServerError("SearchTeam", err)
return
}
}
}
ctx.Data["OrgOverviewTeams"] = overviewTeams[:min(len(overviewTeams), orgOverviewTeamsLimit)]
ctx.Data["DisableNewPullMirrors"] = setting.Mirror.DisableNewPull
ctx.Data["ShowMemberAndTeamTab"] = ctx.Org.IsMember || len(members) > 0
@@ -180,7 +200,7 @@ func prepareOrgProfileReadme(ctx *context.Context, prepareResult *shared_user.Pr
}
rctx := renderhelper.NewRenderContextRepoFile(ctx, profileRepo, renderhelper.RepoFileOptions{
CurrentRefPath: path.Join("branch", util.PathEscapeSegments(profileRepo.DefaultBranch)),
CurrentRefSubURL: git.RefNameFromBranch(profileRepo.DefaultBranch).RefWebLinkPath(),
})
ctx.Data["ProfileReadmeContent"], err = markdown.RenderString(rctx, readmeBytes)
if err != nil {
@@ -6,7 +6,7 @@ package org_test
import (
"testing"
"code.gitea.io/gitea/models/unittest"
"gitea.dev/models/unittest"
)
func TestMain(m *testing.M) {
+21 -16
View File
@@ -8,15 +8,15 @@ import (
"errors"
"net/http"
"code.gitea.io/gitea/models/organization"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/templates"
"code.gitea.io/gitea/modules/util"
shared_user "code.gitea.io/gitea/routers/web/shared/user"
"code.gitea.io/gitea/services/context"
org_service "code.gitea.io/gitea/services/org"
"gitea.dev/models/organization"
user_model "gitea.dev/models/user"
"gitea.dev/modules/log"
"gitea.dev/modules/setting"
"gitea.dev/modules/templates"
"gitea.dev/modules/util"
shared_user "gitea.dev/routers/web/shared/user"
"gitea.dev/services/context"
org_service "gitea.dev/services/org"
)
const (
@@ -31,16 +31,19 @@ func Members(ctx *context.Context) {
ctx.Data["PageIsOrgMembers"] = true
page := max(ctx.FormInt("page"), 1)
keyword := ctx.FormTrim("q")
ctx.Data["Keyword"] = keyword
opts := &organization.FindOrgMembersOpts{
Doer: ctx.Doer,
OrgID: org.ID,
Doer: ctx.Doer,
OrgID: org.ID,
Keyword: keyword,
}
if ctx.Doer != nil {
isMember, err := ctx.Org.Organization.IsOrgMember(ctx, ctx.Doer.ID)
if err != nil {
ctx.HTTPError(http.StatusInternalServerError, "IsOrgMember")
ctx.ServerError("IsOrgMember", err)
return
}
opts.IsDoerMember = isMember
@@ -49,7 +52,7 @@ func Members(ctx *context.Context) {
total, err := organization.CountOrgMembers(ctx, opts)
if err != nil {
ctx.HTTPError(http.StatusInternalServerError, "CountOrgMembers")
ctx.ServerError("CountOrgMembers", err)
return
}
@@ -58,9 +61,11 @@ func Members(ctx *context.Context) {
return
}
pager := context.NewPagination(total, setting.UI.MembersPagingNum, page, 5)
opts.ListOptions.Page = page
opts.ListOptions.PageSize = setting.UI.MembersPagingNum
pageSize := setting.UI.MembersPagingNum
pager := context.NewPagination(total, pageSize, page, 5)
pager.AddParamFromRequest(ctx.Req)
opts.ListOptions.Page = pager.Paginater.Current()
opts.ListOptions.PageSize = pageSize
members, membersIsPublic, err := organization.FindOrgMembers(ctx, opts)
if err != nil {
ctx.ServerError("GetMembers", err)
@@ -6,10 +6,10 @@ package org
import (
"net/http"
"code.gitea.io/gitea/models/organization"
"code.gitea.io/gitea/modules/util"
shared_mention "code.gitea.io/gitea/routers/web/shared/mention"
"code.gitea.io/gitea/services/context"
"gitea.dev/models/organization"
"gitea.dev/modules/util"
shared_mention "gitea.dev/routers/web/shared/mention"
"gitea.dev/services/context"
)
// GetMentionsInOwner returns JSON data for mention autocomplete on owner-level pages.
+9 -9
View File
@@ -8,15 +8,15 @@ import (
"errors"
"net/http"
"code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/models/organization"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/templates"
"code.gitea.io/gitea/modules/web"
"code.gitea.io/gitea/services/context"
"code.gitea.io/gitea/services/forms"
"gitea.dev/models/db"
"gitea.dev/models/organization"
user_model "gitea.dev/models/user"
"gitea.dev/modules/log"
"gitea.dev/modules/setting"
"gitea.dev/modules/templates"
"gitea.dev/modules/web"
"gitea.dev/services/context"
"gitea.dev/services/forms"
)
const (
@@ -6,15 +6,15 @@ package org
import (
"errors"
"code.gitea.io/gitea/models/db"
issues_model "code.gitea.io/gitea/models/issues"
"code.gitea.io/gitea/modules/label"
repo_module "code.gitea.io/gitea/modules/repository"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/modules/web"
shared_label "code.gitea.io/gitea/routers/web/shared/label"
"code.gitea.io/gitea/services/context"
"code.gitea.io/gitea/services/forms"
"gitea.dev/models/db"
issues_model "gitea.dev/models/issues"
"gitea.dev/modules/label"
repo_module "gitea.dev/modules/repository"
"gitea.dev/modules/util"
"gitea.dev/modules/web"
shared_label "gitea.dev/routers/web/shared/label"
"gitea.dev/services/context"
"gitea.dev/services/forms"
)
// RetrieveLabels find all the labels of an organization
@@ -9,21 +9,21 @@ import (
"net/http"
"strings"
"code.gitea.io/gitea/models/db"
issues_model "code.gitea.io/gitea/models/issues"
project_model "code.gitea.io/gitea/models/project"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unit"
"code.gitea.io/gitea/modules/json"
"code.gitea.io/gitea/modules/optional"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/templates"
"code.gitea.io/gitea/modules/web"
"code.gitea.io/gitea/routers/web/shared/issue"
shared_user "code.gitea.io/gitea/routers/web/shared/user"
"code.gitea.io/gitea/services/context"
"code.gitea.io/gitea/services/forms"
project_service "code.gitea.io/gitea/services/projects"
"gitea.dev/models/db"
issues_model "gitea.dev/models/issues"
project_model "gitea.dev/models/project"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unit"
"gitea.dev/modules/json"
"gitea.dev/modules/optional"
"gitea.dev/modules/setting"
"gitea.dev/modules/templates"
"gitea.dev/modules/web"
"gitea.dev/routers/web/shared/issue"
shared_user "gitea.dev/routers/web/shared/user"
"gitea.dev/services/context"
"gitea.dev/services/forms"
project_service "gitea.dev/services/projects"
"xorm.io/builder"
)
@@ -34,14 +34,6 @@ const (
tplProjectsView templates.TplName = "org/projects/view"
)
// MustEnableProjects check if projects are enabled in settings
func MustEnableProjects(ctx *context.Context) {
if unit.TypeProjects.UnitGlobalDisabled() {
ctx.NotFound(nil)
return
}
}
// Projects renders the home page of projects
func Projects(ctx *context.Context) {
if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil {
@@ -458,7 +450,7 @@ func ViewProject(ctx *context.Context) {
ctx.Data["MilestoneID"] = milestoneID
// Get assignees.
assigneeUsers, err := project_service.LoadIssuesAssigneesForProject(ctx, issuesMap)
assigneeUsers, err := project_service.LoadIssuesAssigneesForProject(ctx, project.ID)
if err != nil {
ctx.ServerError("LoadIssuesAssigneesForProject", err)
return
@@ -7,11 +7,11 @@ import (
"net/http"
"testing"
"code.gitea.io/gitea/models/unittest"
"code.gitea.io/gitea/modules/web"
"code.gitea.io/gitea/routers/web/org"
"code.gitea.io/gitea/services/contexttest"
"code.gitea.io/gitea/services/forms"
"gitea.dev/models/unittest"
"gitea.dev/modules/web"
"gitea.dev/routers/web/org"
"gitea.dev/services/contexttest"
"gitea.dev/services/forms"
"github.com/stretchr/testify/assert"
)
+19 -19
View File
@@ -9,25 +9,25 @@ import (
"net/http"
"net/url"
"code.gitea.io/gitea/models/db"
packages_model "code.gitea.io/gitea/models/packages"
repo_model "code.gitea.io/gitea/models/repo"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/models/webhook"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/optional"
repo_module "code.gitea.io/gitea/modules/repository"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/templates"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/modules/web"
shared_user "code.gitea.io/gitea/routers/web/shared/user"
user_setting "code.gitea.io/gitea/routers/web/user/setting"
"code.gitea.io/gitea/services/context"
"code.gitea.io/gitea/services/forms"
org_service "code.gitea.io/gitea/services/org"
user_service "code.gitea.io/gitea/services/user"
"gitea.dev/models/db"
packages_model "gitea.dev/models/packages"
repo_model "gitea.dev/models/repo"
user_model "gitea.dev/models/user"
"gitea.dev/models/webhook"
"gitea.dev/modules/log"
"gitea.dev/modules/optional"
repo_module "gitea.dev/modules/repository"
"gitea.dev/modules/setting"
"gitea.dev/modules/structs"
"gitea.dev/modules/templates"
"gitea.dev/modules/util"
"gitea.dev/modules/web"
shared_user "gitea.dev/routers/web/shared/user"
user_setting "gitea.dev/routers/web/user/setting"
"gitea.dev/services/context"
"gitea.dev/services/forms"
org_service "gitea.dev/services/org"
user_service "gitea.dev/services/user"
)
const (
@@ -7,13 +7,13 @@ import (
"fmt"
"net/http"
"code.gitea.io/gitea/models/auth"
"code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/templates"
shared_user "code.gitea.io/gitea/routers/web/shared/user"
user_setting "code.gitea.io/gitea/routers/web/user/setting"
"code.gitea.io/gitea/services/context"
"gitea.dev/models/auth"
"gitea.dev/models/db"
"gitea.dev/modules/setting"
"gitea.dev/modules/templates"
shared_user "gitea.dev/routers/web/shared/user"
user_setting "gitea.dev/routers/web/user/setting"
"gitea.dev/services/context"
)
const (
@@ -7,11 +7,11 @@ import (
"fmt"
"net/http"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/templates"
shared "code.gitea.io/gitea/routers/web/shared/packages"
shared_user "code.gitea.io/gitea/routers/web/shared/user"
"code.gitea.io/gitea/services/context"
"gitea.dev/modules/setting"
"gitea.dev/modules/templates"
shared "gitea.dev/routers/web/shared/packages"
shared_user "gitea.dev/routers/web/shared/user"
"gitea.dev/services/context"
)
const (
+80 -20
View File
@@ -13,22 +13,24 @@ import (
"strconv"
"strings"
"code.gitea.io/gitea/models/db"
org_model "code.gitea.io/gitea/models/organization"
"code.gitea.io/gitea/models/perm"
repo_model "code.gitea.io/gitea/models/repo"
unit_model "code.gitea.io/gitea/models/unit"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/templates"
"code.gitea.io/gitea/modules/web"
shared_user "code.gitea.io/gitea/routers/web/shared/user"
"code.gitea.io/gitea/services/context"
"code.gitea.io/gitea/services/convert"
"code.gitea.io/gitea/services/forms"
org_service "code.gitea.io/gitea/services/org"
repo_service "code.gitea.io/gitea/services/repository"
"gitea.dev/models/db"
org_model "gitea.dev/models/organization"
"gitea.dev/models/perm"
repo_model "gitea.dev/models/repo"
unit_model "gitea.dev/models/unit"
user_model "gitea.dev/models/user"
"gitea.dev/modules/log"
"gitea.dev/modules/setting"
"gitea.dev/modules/structs"
"gitea.dev/modules/templates"
"gitea.dev/modules/util"
"gitea.dev/modules/web"
shared_user "gitea.dev/routers/web/shared/user"
"gitea.dev/services/context"
"gitea.dev/services/convert"
"gitea.dev/services/forms"
org_service "gitea.dev/services/org"
repo_service "gitea.dev/services/repository"
)
const (
@@ -54,13 +56,56 @@ func Teams(ctx *context.Context) {
ctx.Data["Title"] = org.FullName
ctx.Data["PageIsOrgTeams"] = true
for _, t := range ctx.Org.Teams {
keyword := ctx.FormTrim("q")
page := max(ctx.FormInt("page"), 1)
pagingNum := setting.UI.MembersPagingNum
searchTeams := func() (teams []*org_model.Team, count int64, err error) {
if keyword == "" {
// fast path, use existing teams in context if no need to filter from database
count = int64(len(ctx.Org.Teams))
start := (page - 1) * pagingNum
if start > len(ctx.Org.Teams) {
return nil, count, nil
}
end := min(start+pagingNum, len(ctx.Org.Teams))
return ctx.Org.Teams[start:end], count, nil
}
shouldSeeAllOrgTeams, err := context.UserShouldSeeAllOrgTeams(ctx)
if err != nil {
return nil, 0, err
}
opts := &org_model.SearchTeamOptions{
OrgID: org.ID,
UserID: util.Iif(shouldSeeAllOrgTeams, 0, ctx.Doer.ID),
Keyword: keyword,
IncludeDesc: true,
IncludeVisibilities: util.Iif(shouldSeeAllOrgTeams, nil,
org_model.VisibleTeamVisibilitiesFor(ctx.Org.IsMember, ctx.IsSigned)),
ListOptions: db.ListOptions{Page: page, PageSize: pagingNum},
}
return org_model.SearchTeam(ctx, opts)
}
teams, count, err := searchTeams()
if err != nil {
ctx.ServerError("SearchTeam", err)
return
}
for _, t := range teams {
if err := t.LoadMembers(ctx); err != nil {
ctx.ServerError("GetMembers", err)
return
}
}
ctx.Data["Teams"] = ctx.Org.Teams
ctx.Data["OrgListTeams"] = teams
ctx.Data["Keyword"] = keyword
pager := context.NewPagination(count, setting.UI.MembersPagingNum, page, 5)
pager.AddParamFromRequest(ctx.Req)
ctx.Data["Page"] = pager
ctx.HTML(http.StatusOK, tplTeams)
}
@@ -213,7 +258,7 @@ func checkIsOrgMemberAndRedirect(ctx *context.Context, defaultRedirect string) {
if isOrgMember, err := org_model.IsOrganizationMember(ctx, ctx.Org.Organization.ID, ctx.Doer.ID); err != nil {
ctx.ServerError("IsOrganizationMember", err)
return
} else if !isOrgMember {
} else if !isOrgMember && !ctx.Doer.IsAdmin {
if ctx.Org.Organization.Visibility.IsPrivate() {
defaultRedirect = setting.AppSubURL + "/"
} else {
@@ -335,6 +380,7 @@ func NewTeamPost(ctx *context.Context) {
AccessMode: teamPermission,
IncludesAllRepositories: includesAllRepositories,
CanCreateOrgRepo: form.CanCreateOrgRepo,
Visibility: org_model.NormalizeTeamVisibility(form.Visibility),
}
units := make([]*org_model.TeamUnit, 0, len(unitPerms))
@@ -435,13 +481,22 @@ func SearchTeam(ctx *context.Context) {
PageSize: convert.ToCorrectPageSize(ctx.FormInt("limit")),
}
shouldSeeAll, err := context.UserShouldSeeAllOrgTeams(ctx)
if err != nil {
ctx.ServerError("UserShouldSeeAllOrgTeams", err)
return
}
opts := &org_model.SearchTeamOptions{
// UserID is not set because the router already requires the doer to be an org admin. Thus, we don't need to restrict to teams that the user belongs in
Keyword: ctx.FormTrim("q"),
OrgID: ctx.Org.Organization.ID,
IncludeDesc: ctx.FormString("include_desc") == "" || ctx.FormBool("include_desc"),
ListOptions: listOptions,
}
if !shouldSeeAll {
opts.UserID = ctx.Doer.ID
opts.IncludeVisibilities = org_model.VisibleTeamVisibilitiesFor(ctx.Org.IsMember, ctx.IsSigned)
}
teams, maxResults, err := org_model.SearchTeam(ctx, opts)
if err != nil {
@@ -514,8 +569,11 @@ func EditTeamPost(ctx *context.Context) {
t.IncludesAllRepositories = includesAllRepositories
}
t.CanCreateOrgRepo = form.CanCreateOrgRepo
t.Visibility = org_model.NormalizeTeamVisibility(form.Visibility)
} else {
t.CanCreateOrgRepo = true
// The owner team must remain listable to all org members.
t.Visibility = structs.VisibleTypeLimited
}
t.Description = form.Description
@@ -567,6 +625,8 @@ func DeleteTeam(ctx *context.Context) {
// TeamInvite renders the team invite page
func TeamInvite(ctx *context.Context) {
invite, org, team, inviter, err := getTeamInviteFromContext(ctx)
// TODO: to quickly debug the UI, can uncomment this (don't worry, it won't pass CI lint)
// invite, org, team, inviter, err = &org_model.TeamInvite{}, &org_model.Organization{}, &org_model.Team{}, ctx.Doer, nil
if err != nil {
if org_model.IsErrTeamInviteNotFound(err) {
ctx.NotFound(err)
@@ -7,10 +7,10 @@ import (
"net/http"
"time"
"code.gitea.io/gitea/models/organization"
"code.gitea.io/gitea/modules/templates"
shared_user "code.gitea.io/gitea/routers/web/shared/user"
"code.gitea.io/gitea/services/context"
"gitea.dev/models/organization"
"gitea.dev/modules/templates"
shared_user "gitea.dev/routers/web/shared/user"
"gitea.dev/services/context"
)
const tplByRepos templates.TplName = "org/worktime"
+2 -2
View File
@@ -6,8 +6,8 @@ package web
import (
"net/http"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/services/context"
"gitea.dev/modules/setting"
"gitea.dev/services/context"
)
type passkeyEndpointsType struct {
@@ -4,31 +4,34 @@
package actions
import (
"bytes"
stdCtx "context"
"errors"
"fmt"
"net/http"
"net/url"
"slices"
"strings"
actions_model "code.gitea.io/gitea/models/actions"
"code.gitea.io/gitea/models/db"
git_model "code.gitea.io/gitea/models/git"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unit"
"code.gitea.io/gitea/modules/actions"
"code.gitea.io/gitea/modules/container"
"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/optional"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/templates"
"code.gitea.io/gitea/modules/util"
shared_user "code.gitea.io/gitea/routers/web/shared/user"
"code.gitea.io/gitea/services/context"
"code.gitea.io/gitea/services/convert"
actions_model "gitea.dev/models/actions"
"gitea.dev/models/db"
git_model "gitea.dev/models/git"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unit"
"gitea.dev/modules/actions"
"gitea.dev/modules/actions/jobparser"
"gitea.dev/modules/container"
"gitea.dev/modules/git"
"gitea.dev/modules/log"
"gitea.dev/modules/optional"
"gitea.dev/modules/setting"
"gitea.dev/modules/templates"
"gitea.dev/modules/util"
shared_user "gitea.dev/routers/web/shared/user"
actions_service "gitea.dev/services/actions"
"gitea.dev/services/context"
"gitea.dev/services/convert"
act_model "github.com/nektos/act/pkg/model"
act_model "gitea.com/gitea/runner/act/model"
"go.yaml.in/yaml/v4"
)
@@ -44,6 +47,20 @@ type WorkflowInfo struct {
Workflow *act_model.Workflow
}
type workflowBadge struct {
DisplayName string
BadgeURL string
WorkflowURL string
}
// DisplayName returns the workflow name from the YAML file if present, otherwise the filename.
func (w WorkflowInfo) DisplayName() string {
if w.Workflow != nil && w.Workflow.Name != "" {
return w.Workflow.Name
}
return w.Entry.Name()
}
// MustEnableActions check if actions are enabled in settings
func MustEnableActions(ctx *context.Context) {
if !setting.Actions.Enabled {
@@ -57,7 +74,7 @@ func MustEnableActions(ctx *context.Context) {
}
if ctx.Repo.Repository != nil {
if !ctx.Repo.CanRead(unit.TypeActions) {
if !ctx.Repo.Permission.CanRead(unit.TypeActions) {
ctx.NotFound(nil)
return
}
@@ -82,12 +99,22 @@ func List(ctx *context.Context) {
if ctx.Written() {
return
}
prepareWorkflowDispatchTemplate(ctx, workflows, curWorkflowID)
curWorkflowRepoID := ctx.FormInt64("scoped_workflow_source_repo_id")
ctx.Data["CurWorkflowRepoID"] = curWorkflowRepoID
scopedNames := prepareScopedWorkflows(ctx, curWorkflowID, curWorkflowRepoID)
if ctx.Written() {
return
}
otherWorkflows := prepareOtherWorkflows(ctx, workflows, scopedNames, curWorkflowID)
if ctx.Written() {
return
}
prepareWorkflowDispatchTemplate(ctx, workflows, curWorkflowID, curWorkflowRepoID)
if ctx.Written() {
return
}
prepareWorkflowList(ctx, workflows)
prepareWorkflowList(ctx, workflows, otherWorkflows, len(scopedNames) > 0)
if ctx.Written() {
return
}
@@ -95,6 +122,33 @@ func List(ctx *context.Context) {
ctx.HTML(http.StatusOK, tplListActions)
}
// prepareOtherWorkflows surfaces historical runs whose workflow file no longer
// exists on the default branch (renamed, removed, or only on other branches).
func prepareOtherWorkflows(ctx *context.Context, workflows []WorkflowInfo, scopedNames container.Set[string], curWorkflowID string) []string {
listed := make(container.Set[string], len(workflows))
for _, w := range workflows {
listed.Add(w.Entry.Name())
}
var other []string
if ctx.Repo.Repository.NumActionRuns > 0 {
// "Other workflows" lists repo-level orphans only: GetRepoRunWorkflowIDs excludes scoped runs.
ids, err := actions_model.GetRepoRunWorkflowIDs(ctx, ctx.Repo.Repository.ID)
if err != nil {
ctx.ServerError("GetRepoRunWorkflowIDs", err)
return nil
}
other = container.FilterSlice(ids, func(id string) (string, bool) {
return id, id != "" && !listed.Contains(id)
})
}
ctx.Data["OtherWorkflows"] = other
// A selected workflow counts as "listed" if it is a repo-level file or an active scoped workflow.
ctx.Data["CurWorkflowIsListed"] = curWorkflowID == "" || listed.Contains(curWorkflowID) || scopedNames.Contains(curWorkflowID)
return other
}
func WorkflowDispatchInputs(ctx *context.Context) {
ref := ctx.FormString("ref")
if ref == "" {
@@ -121,7 +175,7 @@ func WorkflowDispatchInputs(ctx *context.Context) {
if ctx.Written() {
return
}
prepareWorkflowDispatchTemplate(ctx, workflows, curWorkflowID)
prepareWorkflowDispatchTemplate(ctx, workflows, curWorkflowID, ctx.FormInt64("scoped_workflow_source_repo_id"))
if ctx.Written() {
return
}
@@ -145,7 +199,7 @@ func prepareWorkflowTemplate(ctx *context.Context, commit *git.Commit) (workflow
ctx.ServerError("GetContentFromEntry", err)
return nil, ""
}
wf, err := act_model.ReadWorkflow(bytes.NewReader(content))
wf, err := jobparser.ReadWorkflow(content)
if err != nil {
workflow.ErrMsg = ctx.Locale.TrString("actions.runs.invalid_workflow_helper", err.Error())
workflows = append(workflows, workflow)
@@ -169,19 +223,28 @@ func prepareWorkflowTemplate(ctx *context.Context, commit *git.Commit) (workflow
if !hasJobWithoutNeeds && len(j.Needs()) == 0 {
hasJobWithoutNeeds = true
}
if j.Uses != "" {
if _, err := actions_service.ResolveUses(ctx, j.Uses); err != nil {
workflow.ErrMsg = ctx.Locale.TrString("actions.runs.invalid_reusable_workflow_uses", err.Error())
break
}
}
}
if !hasJobWithoutNeeds {
workflow.ErrMsg = ctx.Locale.TrString("actions.runs.no_job_without_needs")
}
if emptyJobsNumber == len(wf.Jobs) {
workflow.ErrMsg = ctx.Locale.TrString("actions.runs.no_job")
if workflow.ErrMsg == "" {
if !hasJobWithoutNeeds {
workflow.ErrMsg = ctx.Locale.TrString("actions.runs.no_job_without_needs")
}
if emptyJobsNumber == len(wf.Jobs) {
workflow.ErrMsg = ctx.Locale.TrString("actions.runs.no_job")
}
}
workflows = append(workflows, workflow)
}
ctx.Data["workflows"] = workflows
ctx.Data["RepoLink"] = ctx.Repo.Repository.Link()
ctx.Data["AllowDisableOrEnableWorkflow"] = ctx.Repo.IsAdmin()
ctx.Data["RepoID"] = ctx.Repo.Repository.ID
ctx.Data["AllowDisableOrEnableWorkflow"] = ctx.Repo.Permission.IsAdmin()
actionsConfig := ctx.Repo.Repository.MustGetUnit(ctx, unit.TypeActions).ActionsConfig()
ctx.Data["ActionsConfig"] = actionsConfig
ctx.Data["CurWorkflow"] = curWorkflowID
@@ -190,21 +253,165 @@ func prepareWorkflowTemplate(ctx *context.Context, commit *git.Commit) (workflow
return workflows, curWorkflowID
}
func prepareWorkflowDispatchTemplate(ctx *context.Context, workflowInfos []WorkflowInfo, curWorkflowID string) {
actionsConfig := ctx.Repo.Repository.MustGetUnit(ctx, unit.TypeActions).ActionsConfig()
if curWorkflowID == "" || !ctx.Repo.CanWrite(unit.TypeActions) || actionsConfig.IsWorkflowDisabled(curWorkflowID) {
// ScopedWorkflowInfo describes a scoped workflow effective for the current repo, listed under its source group.
type ScopedWorkflowInfo struct {
SourceRepoID int64
EntryName string
DisplayName string
Required bool
Disabled bool
}
// ScopedWorkflowSourceGroup groups the scoped workflows contributed by one source repo for the All-Workflows sidebar.
type ScopedWorkflowSourceGroup struct {
SourceRepoID int64
SourceRepoName string // owner/name of the source repo; shown for instance-level sources and used as the tooltip
SourceRepoShortName string // name only; shown for owner-level sources, where the owner is always the current owner
FromInstance bool // registered at instance level (owner_id == 0) rather than by the owner
IsActive bool // the currently-selected workflow belongs to this source; render the group expanded
Workflows []ScopedWorkflowInfo
}
// prepareScopedWorkflows lists the scoped workflows effective for the repo's owner (and instance) for the All-Workflows sidebar.
func prepareScopedWorkflows(ctx *context.Context, curWorkflowID string, curWorkflowRepoID int64) container.Set[string] {
scopedNames := make(container.Set[string])
repo := ctx.Repo.Repository
sources, err := actions_model.GetEffectiveScopedWorkflowSources(ctx, repo.OwnerID)
if err != nil {
ctx.ServerError("GetEffectiveScopedWorkflowSources", err)
return scopedNames
}
if len(sources) == 0 {
return scopedNames
}
actionsConfig := repo.MustGetUnit(ctx, unit.TypeActions).ActionsConfig()
groups := make([]ScopedWorkflowSourceGroup, 0, len(sources))
seen := make(map[int64]bool, len(sources))
for _, source := range sources {
if seen[source.SourceRepoID] {
continue
}
seen[source.SourceRepoID] = true
sourceRepo, err := repo_model.GetRepositoryByID(ctx, source.SourceRepoID)
if err != nil {
log.Error("scoped workflows list: load source repo %d: %v", source.SourceRepoID, err)
continue
}
if sourceRepo.IsEmpty {
continue
}
_, entries, err := actions_service.LoadParsedScopedWorkflows(ctx, sourceRepo)
if err != nil {
log.Error("scoped workflows list: parse %s: %v", sourceRepo.FullName(), err)
continue
}
if len(entries) == 0 {
continue
}
group := ScopedWorkflowSourceGroup{
SourceRepoID: sourceRepo.ID,
SourceRepoName: sourceRepo.FullName(),
SourceRepoShortName: sourceRepo.Name,
FromInstance: source.OwnerID == 0,
}
for _, e := range entries {
scopedNames.Add(e.EntryName)
required := actions_model.IsWorkflowRequiredInSources(sources, sourceRepo.ID, e.EntryName)
disabled := actionsConfig.IsScopedWorkflowDisabled(sourceRepo.ID, e.EntryName)
group.Workflows = append(group.Workflows, ScopedWorkflowInfo{
SourceRepoID: sourceRepo.ID,
EntryName: e.EntryName,
DisplayName: e.DisplayName,
Required: required,
Disabled: disabled,
})
if curWorkflowID == e.EntryName && curWorkflowRepoID == sourceRepo.ID {
ctx.Data["CurWorkflowDisabled"] = disabled
ctx.Data["CurWorkflowScopedRepoID"] = sourceRepo.ID
ctx.Data["CurWorkflowRequired"] = required
group.IsActive = true // keep this group expanded so the selected workflow stays visible
}
}
groups = append(groups, group)
}
ctx.Data["ScopedWorkflowGroups"] = groups
return scopedNames
}
// loadScopedWorkflowModel reads and parses a scoped workflow's content from its source repo's default branch.
func loadScopedWorkflowModel(ctx *context.Context, repo *repo_model.Repository, sourceRepoID int64, workflowID string) *act_model.Workflow {
effective, err := actions_model.IsScopedWorkflowSourceEffective(ctx, repo.OwnerID, sourceRepoID)
if err != nil {
log.Error("scoped dispatch: IsScopedWorkflowSourceEffective: %v", err)
return nil
}
if !effective {
return nil
}
sourceRepo, err := repo_model.GetRepositoryByID(ctx, sourceRepoID)
if err != nil || sourceRepo.IsEmpty {
return nil
}
content, err := actions_service.ScopedWorkflowContent(ctx, sourceRepo, workflowID)
if err != nil {
log.Error("scoped dispatch: content of %s in %s: %v", workflowID, sourceRepo.RelativePath(), err)
return nil
}
if content == nil {
return nil // the workflow does not exist on the source's default branch
}
wf, err := jobparser.ReadWorkflow(content)
if err != nil {
return nil
}
return wf
}
func prepareWorkflowDispatchTemplate(ctx *context.Context, workflowInfos []WorkflowInfo, curWorkflowID string, curWorkflowRepoID int64) {
repo := ctx.Repo.Repository
if curWorkflowID == "" || !ctx.Repo.Permission.CanWrite(unit.TypeActions) {
return
}
actionsConfig := repo.MustGetUnit(ctx, unit.TypeActions).ActionsConfig()
isScoped := curWorkflowRepoID > 0
if isScoped {
// a required scoped workflow can never be opted out, so a stale disabled flag must not hide its dispatch form
optedOut, err := actions_model.IsScopedWorkflowOptedOut(ctx, actionsConfig, repo.OwnerID, curWorkflowRepoID, curWorkflowID)
if err != nil {
log.Error("IsScopedWorkflowOptedOut: %v", err)
return
}
if optedOut {
return
}
} else if actionsConfig.IsWorkflowDisabled(curWorkflowID) {
return
}
var curWorkflow *act_model.Workflow
for _, workflowInfo := range workflowInfos {
if workflowInfo.Entry.Name() == curWorkflowID {
if workflowInfo.Workflow == nil {
log.Debug("CurWorkflowID %s is found but its workflowInfo.Workflow is nil", curWorkflowID)
return
if isScoped {
// a scoped workflow's content lives in its source repo, not in workflowInfos (the consumer's own files)
curWorkflow = loadScopedWorkflowModel(ctx, repo, curWorkflowRepoID, curWorkflowID)
} else {
for _, workflowInfo := range workflowInfos {
if workflowInfo.Entry.Name() == curWorkflowID {
if workflowInfo.Workflow == nil {
log.Debug("CurWorkflowID %s is found but its workflowInfo.Workflow is nil", curWorkflowID)
return
}
curWorkflow = workflowInfo.Workflow
break
}
curWorkflow = workflowInfo.Workflow
break
}
}
@@ -245,10 +452,12 @@ func prepareWorkflowDispatchTemplate(ctx *context.Context, workflowInfos []Workf
ctx.Data["Tags"] = tags
}
func prepareWorkflowList(ctx *context.Context, workflows []WorkflowInfo) {
func prepareWorkflowList(ctx *context.Context, workflows []WorkflowInfo, otherWorkflows []string, hasScopedWorkflows bool) {
actorID := ctx.FormInt64("actor")
status := ctx.FormInt("status")
workflowID := ctx.FormString("workflow")
scopedWorkflowSourceRepoID := ctx.FormInt64("scoped_workflow_source_repo_id")
branch := ctx.FormString("branch")
page := ctx.FormInt("page")
if page <= 0 {
page = 1
@@ -258,7 +467,8 @@ func prepareWorkflowList(ctx *context.Context, workflows []WorkflowInfo) {
// they will be 0 by default, which indicates get all status or actors
ctx.Data["CurActor"] = actorID
ctx.Data["CurStatus"] = status
if actorID > 0 || status > int(actions_model.StatusUnknown) {
ctx.Data["CurBranch"] = branch
if actorID > 0 || status > int(actions_model.StatusUnknown) || branch != "" {
ctx.Data["IsFiltered"] = true
}
@@ -267,15 +477,24 @@ func prepareWorkflowList(ctx *context.Context, workflows []WorkflowInfo) {
Page: page,
PageSize: convert.ToCorrectPageSize(ctx.FormInt("limit")),
},
RepoID: ctx.Repo.Repository.ID,
WorkflowID: workflowID,
TriggerUserID: actorID,
RepoID: ctx.Repo.Repository.ID,
WorkflowID: workflowID,
WorkflowRepoID: scopedWorkflowSourceRepoID,
TriggerUserID: actorID,
}
// Constrain scoped vs repo-level only for a listed workflow, whose link carries scoped_workflow_source_repo_id.
if workflowID != "" && !slices.Contains(otherWorkflows, workflowID) {
opts.IsScopedRun = optional.Some(scopedWorkflowSourceRepoID > 0)
}
// if status is not StatusUnknown, it means user has selected a status filter
if actions_model.Status(status) != actions_model.StatusUnknown {
opts.Status = []actions_model.Status{actions_model.Status(status)}
}
if branch != "" {
opts.Ref = string(git.RefNameFromBranch(branch))
}
runs, total, err := db.FindAndCount[actions_model.ActionRun](ctx, opts)
if err != nil {
@@ -308,32 +527,40 @@ func prepareWorkflowList(ctx *context.Context, workflows []WorkflowInfo) {
return
}
for _, run := range runs {
if !run.Status.In(actions_model.StatusWaiting, actions_model.StatusRunning) {
if !run.Status.In(actions_model.StatusWaiting, actions_model.StatusRunning, actions_model.StatusBlocked) {
continue
}
jobs, err := actions_model.GetRunJobsByRunID(ctx, run.ID)
jobs, err := actions_model.GetLatestAttemptJobsByRepoAndRunID(ctx, run.RepoID, run.ID)
if err != nil {
ctx.ServerError("GetRunJobsByRunID", err)
return
}
for _, job := range jobs {
if !job.Status.IsWaiting() {
if !job.Status.In(actions_model.StatusWaiting, actions_model.StatusBlocked) {
continue
}
if err := actions.ValidateWorkflowContent(job.WorkflowPayload); err != nil {
runErrors[run.ID] = ctx.Locale.TrString("actions.runs.invalid_workflow_helper", err.Error())
break
}
hasOnlineRunner := false
for _, runner := range runners {
if !runner.IsDisabled && runner.CanMatchLabels(job.RunsOn) {
hasOnlineRunner = true
if job.CallUses != "" {
if _, err := actions_service.ResolveUses(ctx, job.CallUses); err != nil {
runErrors[run.ID] = ctx.Locale.TrString("actions.runs.invalid_reusable_workflow_uses", err.Error())
break
}
}
if !hasOnlineRunner {
runErrors[run.ID] = ctx.Locale.TrString("actions.runs.no_matching_online_runner_helper", strings.Join(job.RunsOn, ","))
break
if job.Status.IsWaiting() {
hasOnlineRunner := false
for _, runner := range runners {
if !runner.IsDisabled && runner.CanMatchLabels(job.RunsOn) {
hasOnlineRunner = true
break
}
}
if !hasOnlineRunner {
runErrors[run.ID] = ctx.Locale.TrString("actions.runs.no_matching_online_runner_helper", strings.Join(job.RunsOn, ","))
break
}
}
}
}
@@ -341,6 +568,22 @@ func prepareWorkflowList(ctx *context.Context, workflows []WorkflowInfo) {
ctx.Data["Runs"] = runs
workflowNames := make(map[string]string, len(workflows))
workflowDisplayName := workflowID
for _, wf := range workflows {
displayName := wf.DisplayName()
workflowNames[wf.Entry.Name()] = displayName
if wf.Entry.Name() == workflowID {
workflowDisplayName = displayName
}
}
ctx.Data["WorkflowNames"] = workflowNames
// A scoped workflow has no repo-level badge on this repo (the badge endpoint reads is_scoped_run=false runs),
// so don't offer the "create status badge" entry for it.
if scopedWorkflowSourceRepoID == 0 {
prepareWorkflowBadgeTemplate(ctx, workflowID, workflowDisplayName)
}
actors, err := actions_model.GetActors(ctx, ctx.Repo.Repository.ID)
if err != nil {
ctx.ServerError("GetActors", err)
@@ -350,12 +593,36 @@ func prepareWorkflowList(ctx *context.Context, workflows []WorkflowInfo) {
ctx.Data["StatusInfoList"] = actions_model.GetStatusInfoList(ctx, ctx.Locale)
runBranches, err := actions_model.GetRunBranches(ctx, ctx.Repo.Repository.ID)
if err != nil {
ctx.ServerError("GetRunBranches", err)
return
}
ctx.Data["RunBranches"] = runBranches
pager := context.NewPagination(total, opts.PageSize, opts.Page, 5)
pager.AddParamFromRequest(ctx.Req)
ctx.Data["Page"] = pager
ctx.Data["HasWorkflowsOrRuns"] = len(workflows) > 0 || len(runs) > 0
ctx.Data["HasWorkflowsOrRuns"] = len(workflows) > 0 || len(otherWorkflows) > 0 || len(runs) > 0 || hasScopedWorkflows
ctx.Data["CanWriteRepoUnitActions"] = ctx.Repo.CanWrite(unit.TypeActions)
ctx.Data["CanWriteRepoUnitActions"] = ctx.Repo.Permission.CanWrite(unit.TypeActions)
}
func prepareWorkflowBadgeTemplate(ctx *context.Context, workflowID, displayName string) {
if workflowID == "" {
return
}
displayName = util.IfZero(displayName, workflowID)
repoURL := ctx.Repo.Repository.HTMLURL(ctx)
badgeURL := fmt.Sprintf("%s/actions/workflows/%s/badge.svg?branch=%s", repoURL, util.PathEscapeSegments(workflowID), url.QueryEscape(ctx.Repo.Repository.DefaultBranch))
workflowURL := fmt.Sprintf("%s/actions?workflow=%s", repoURL, url.QueryEscape(workflowID))
ctx.Data["WorkflowBadge"] = workflowBadge{
BadgeURL: badgeURL,
WorkflowURL: workflowURL,
DisplayName: displayName,
}
}
// loadIsRefDeleted loads the IsRefDeleted field for each run in the list.
@@ -395,6 +662,10 @@ type WorkflowDispatchInput struct {
Options []string `yaml:"options"`
}
func (i WorkflowDispatchInput) IsDefaultTrue() bool {
return util.ParseYamlBool(i.Default)
}
type WorkflowDispatch struct {
Inputs []WorkflowDispatchInput
}
@@ -466,3 +737,14 @@ func decodeNode(node yaml.Node, out any) bool {
}
return true
}
func actionsListRedirectURL(repoLink, workflow, scopedWorkflowSourceRepoID, actor, status, branch string) string {
return fmt.Sprintf("%s/actions?workflow=%s&scoped_workflow_source_repo_id=%s&actor=%s&status=%s&branch=%s",
repoLink,
url.QueryEscape(workflow),
url.QueryEscape(scopedWorkflowSourceRepoID),
url.QueryEscape(actor),
url.QueryEscape(status),
url.QueryEscape(branch),
)
}
@@ -4,14 +4,20 @@
package actions
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
actions_model "code.gitea.io/gitea/models/actions"
"code.gitea.io/gitea/models/db"
unittest "code.gitea.io/gitea/models/unittest"
actions_model "gitea.dev/models/actions"
"gitea.dev/models/db"
repo_model "gitea.dev/models/repo"
unittest "gitea.dev/models/unittest"
"gitea.dev/modules/setting"
"gitea.dev/modules/test"
web_context "gitea.dev/services/context"
act_model "github.com/nektos/act/pkg/model"
act_model "gitea.com/gitea/runner/act/model"
"github.com/stretchr/testify/assert"
)
@@ -176,3 +182,41 @@ func Test_loadIsRefDeleted(t *testing.T) {
assert.True(t, run.IsRefDeleted)
}
}
func TestPrepareWorkflowBadgeTemplate(t *testing.T) {
defer test.MockVariableValue(&setting.AppURL, "https://gitea.example.com/")()
t.Run("no workflow selected", func(t *testing.T) {
ctx := newWorkflowBadgeTestContext(t)
prepareWorkflowBadgeTemplate(ctx, "", "ignored")
assert.NotContains(t, ctx.Data, "WorkflowBadge")
})
t.Run("selected workflow", func(t *testing.T) {
ctx := newWorkflowBadgeTestContext(t)
prepareWorkflowBadgeTemplate(ctx, "build/test workflow.yml", `CI [prod]\build "fast" <ok>`)
assert.Equal(t, workflowBadge{
BadgeURL: "https://gitea.example.com/user1/repo1/actions/workflows/build/test%20workflow.yml/badge.svg?branch=release%2F1.0+%26+hotfix",
WorkflowURL: "https://gitea.example.com/user1/repo1/actions?workflow=build%2Ftest+workflow.yml",
DisplayName: `CI [prod]\build "fast" <ok>`,
}, ctx.Data["WorkflowBadge"])
})
}
func newWorkflowBadgeTestContext(t *testing.T) *web_context.Context {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "https://gitea.example.com/user1/repo1/actions", nil)
resp := httptest.NewRecorder()
ctx := web_context.NewWebContext(web_context.NewBaseContextForTest(resp, req), nil, nil)
ctx.Repo.Repository = &repo_model.Repository{
OwnerName: "user1",
Name: "repo1",
DefaultBranch: "release/1.0 & hotfix",
}
return ctx
}
@@ -9,14 +9,20 @@ import (
"path/filepath"
"strings"
actions_model "code.gitea.io/gitea/models/actions"
"code.gitea.io/gitea/modules/badge"
"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/services/context"
actions_model "gitea.dev/models/actions"
auth_model "gitea.dev/models/auth"
"gitea.dev/modules/badge"
"gitea.dev/modules/git"
"gitea.dev/modules/util"
"gitea.dev/services/context"
)
func GetWorkflowBadge(ctx *context.Context) {
context.CheckRepoScopedToken(ctx, ctx.Repo.Repository, auth_model.Read)
if ctx.Written() {
return
}
workflowFile := ctx.PathParam("workflow_name")
branch := ctx.FormString("branch", ctx.Repo.Repository.DefaultBranch)
event := ctx.FormString("event")
@@ -6,7 +6,7 @@ package actions
import (
"testing"
"code.gitea.io/gitea/models/unittest"
"gitea.dev/models/unittest"
)
func TestMain(m *testing.M) {
File diff suppressed because it is too large Load Diff
@@ -6,14 +6,76 @@ package actions
import (
"testing"
actions_model "code.gitea.io/gitea/models/actions"
"code.gitea.io/gitea/modules/timeutil"
"code.gitea.io/gitea/modules/translation"
actions_model "gitea.dev/models/actions"
repo_model "gitea.dev/models/repo"
api "gitea.dev/modules/structs"
"gitea.dev/modules/timeutil"
"gitea.dev/modules/translation"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestViewPullRequestFromRun(t *testing.T) {
repo := &repo_model.Repository{ID: 1, OwnerName: "owner", Name: "repo"}
t.Run("pull ref", func(t *testing.T) {
run := &actions_model.ActionRun{Repo: repo, Ref: "refs/pull/123/head"}
assert.Equal(t, &ViewPullRequest{Index: "#123", Link: "/owner/repo/pulls/123"}, viewPullRequestFromRun(t.Context(), run, nil))
})
t.Run("pull request event payload", func(t *testing.T) {
// a non-pull ref forces the payload branch instead of the ref branch
run := &actions_model.ActionRun{Repo: repo, Ref: "refs/heads/feature"}
payload := &api.PullRequestPayload{Index: 42}
assert.Equal(t, &ViewPullRequest{Index: "#42", Link: "/owner/repo/pulls/42"}, viewPullRequestFromRun(t.Context(), run, payload))
})
t.Run("nil repo", func(t *testing.T) {
run := &actions_model.ActionRun{Ref: "refs/pull/1/head"}
assert.Nil(t, viewPullRequestFromRun(t.Context(), run, nil))
})
}
func TestViewSummaryBranchFromRun(t *testing.T) {
repo := &repo_model.Repository{ID: 1, OwnerName: "owner", Name: "repo"}
t.Run("pull request event same repo", func(t *testing.T) {
run := &actions_model.ActionRun{Repo: repo, Ref: "refs/pull/7/head"}
payload := &api.PullRequestPayload{
PullRequest: &api.PullRequest{Head: &api.PRBranchInfo{
Name: "feature",
Ref: "refs/heads/feature",
RepoID: 1,
Repository: &api.Repository{Link: "/owner/repo"},
}},
}
assert.Equal(t, ViewBranch{Name: "feature", Link: "/owner/repo/src/branch/feature"}, viewSummaryBranchFromRun(t.Context(), run, payload))
})
t.Run("pull request event from fork prefixes owner", func(t *testing.T) {
run := &actions_model.ActionRun{Repo: repo, Ref: "refs/pull/7/head"}
payload := &api.PullRequestPayload{
PullRequest: &api.PullRequest{Head: &api.PRBranchInfo{
Name: "feature",
Ref: "refs/heads/feature",
RepoID: 2,
Repository: &api.Repository{
Link: "/forkowner/repo",
Owner: &api.User{UserName: "forkowner"},
},
}},
}
assert.Equal(t, ViewBranch{Name: "forkowner:feature", Link: "/forkowner/repo/src/branch/feature"}, viewSummaryBranchFromRun(t.Context(), run, payload))
})
t.Run("push to tag does not query branch", func(t *testing.T) {
// a tag ref is not a branch, so no GetBranch DB lookup happens
run := &actions_model.ActionRun{Repo: repo, Ref: "refs/tags/v1.0.0"}
assert.Equal(t, ViewBranch{Name: "v1.0.0", Link: "/owner/repo/src/tag/v1.0.0"}, viewSummaryBranchFromRun(t.Context(), run, nil))
})
}
func TestConvertToViewModel(t *testing.T) {
task := &actions_model.ActionTask{
Status: actions_model.StatusSuccess,
@@ -45,3 +107,79 @@ func TestConvertToViewModel(t *testing.T) {
}
assert.Equal(t, expectedViewJobs, viewJobSteps)
}
func TestConvertToViewModelCancellingTaskDoesNotRenderRunningSteps(t *testing.T) {
task := &actions_model.ActionTask{
Status: actions_model.StatusCancelling,
Steps: []*actions_model.ActionTaskStep{
{Name: "Run step-name", Index: 0, Status: actions_model.StatusRunning, LogLength: 1},
},
}
viewJobSteps, _, err := convertToViewModel(t.Context(), translation.MockLocale{}, nil, task)
require.NoError(t, err)
expectedViewJobs := []*ViewJobStep{
{
Summary: "Set up job",
Duration: "0s",
Status: "success",
},
{
Summary: "Run step-name",
Duration: "0s",
Status: "cancelling",
},
{
Summary: "Complete job",
Duration: "0s",
Status: "waiting",
},
}
assert.Equal(t, expectedViewJobs, viewJobSteps)
}
func TestPendingNeeds(t *testing.T) {
current := &actions_model.ActionRunJob{JobID: "deploy", Needs: []string{"build", "test"}}
jobs := []*actions_model.ActionRunJob{
current,
{JobID: "build", Status: actions_model.StatusSuccess},
{JobID: "test", Status: actions_model.StatusRunning},
}
// "test" is not done yet, "build" succeeded, so only "test" blocks.
assert.Equal(t, []string{"test"}, pendingNeeds(current, jobs))
t.Run("all needs done", func(t *testing.T) {
done := []*actions_model.ActionRunJob{
current,
{JobID: "build", Status: actions_model.StatusSuccess},
{JobID: "test", Status: actions_model.StatusSkipped},
}
assert.Empty(t, pendingNeeds(current, done))
})
t.Run("matrix expansion all required", func(t *testing.T) {
matrix := []*actions_model.ActionRunJob{
current,
{JobID: "build", Status: actions_model.StatusSuccess},
{JobID: "build", Status: actions_model.StatusRunning},
{JobID: "test", Status: actions_model.StatusSuccess},
}
assert.Equal(t, []string{"build"}, pendingNeeds(current, matrix))
})
t.Run("unresolved need treated as pending", func(t *testing.T) {
missing := []*actions_model.ActionRunJob{current}
assert.Equal(t, []string{"build", "test"}, pendingNeeds(current, missing))
})
t.Run("parent job scope", func(t *testing.T) {
// a same-named job under a different parent must not satisfy the need
scoped := &actions_model.ActionRunJob{JobID: "deploy", Needs: []string{"build"}, ParentJobID: 5}
jobs := []*actions_model.ActionRunJob{
scoped,
{JobID: "build", Status: actions_model.StatusSuccess, ParentJobID: 0},
}
assert.Equal(t, []string{"build"}, pendingNeeds(scoped, jobs))
})
}
@@ -7,11 +7,11 @@ import (
"net/http"
"time"
activities_model "code.gitea.io/gitea/models/activities"
git_model "code.gitea.io/gitea/models/git"
"code.gitea.io/gitea/models/unit"
"code.gitea.io/gitea/modules/templates"
"code.gitea.io/gitea/services/context"
activities_model "gitea.dev/models/activities"
git_model "gitea.dev/models/git"
"gitea.dev/models/unit"
"gitea.dev/modules/templates"
"gitea.dev/services/context"
)
const (
@@ -48,7 +48,7 @@ func Activity(ctx *context.Context) {
ctx.Data["Period"] = period
ctx.Data["PeriodText"] = ctx.Tr("repo.activity.period." + period)
canReadCode := ctx.Repo.CanRead(unit.TypeCode)
canReadCode := ctx.Repo.Permission.CanRead(unit.TypeCode)
if canReadCode {
// GetActivityStats needs to read the default branch to get some information
branchExist, _ := git_model.IsBranchExist(ctx, ctx.Repo.Repository.ID, ctx.Repo.Repository.DefaultBranch)
@@ -62,9 +62,9 @@ func Activity(ctx *context.Context) {
var err error
// TODO: refactor these arguments to a struct
ctx.Data["Activity"], err = activities_model.GetActivityStats(ctx, ctx.Repo.Repository, timeFrom,
ctx.Repo.CanRead(unit.TypeReleases),
ctx.Repo.CanRead(unit.TypeIssues),
ctx.Repo.CanRead(unit.TypePullRequests),
ctx.Repo.Permission.CanRead(unit.TypeReleases),
ctx.Repo.Permission.CanRead(unit.TypeIssues),
ctx.Repo.Permission.CanRead(unit.TypePullRequests),
canReadCode,
)
if err != nil {
@@ -6,20 +6,20 @@ package repo
import (
"net/http"
auth_model "code.gitea.io/gitea/models/auth"
issues_model "code.gitea.io/gitea/models/issues"
access_model "code.gitea.io/gitea/models/perm/access"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unit"
"code.gitea.io/gitea/modules/httpcache"
"code.gitea.io/gitea/modules/httplib"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/storage"
"code.gitea.io/gitea/services/attachment"
"code.gitea.io/gitea/services/context"
"code.gitea.io/gitea/services/context/upload"
repo_service "code.gitea.io/gitea/services/repository"
auth_model "gitea.dev/models/auth"
issues_model "gitea.dev/models/issues"
access_model "gitea.dev/models/perm/access"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unit"
"gitea.dev/modules/httpcache"
"gitea.dev/modules/httplib"
"gitea.dev/modules/log"
"gitea.dev/modules/setting"
"gitea.dev/modules/storage"
"gitea.dev/services/attachment"
"gitea.dev/services/context"
"gitea.dev/services/context/upload"
repo_service "gitea.dev/services/repository"
)
func attachmentReadScope(unitType unit.Type) (auth_model.AccessTokenScope, bool) {
@@ -143,18 +143,18 @@ func ServeAttachment(ctx *context.Context, uuid string) {
return
}
// prevent visiting attachment from other repository directly
// The check will be ignored before this code merged.
if attach.CreatedUnix > repo_model.LegacyAttachmentMissingRepoIDCutoff && ctx.Repo.Repository != nil && ctx.Repo.Repository.ID != attach.RepoID {
ctx.HTTPError(http.StatusNotFound)
return
}
unitType, repoID, err := repo_service.GetAttachmentLinkedTypeAndRepoID(ctx, attach)
if err != nil {
ctx.ServerError("GetAttachmentLinkedTypeAndRepoID", err)
return
}
if repoID == 0 {
repoID = attach.RepoID
}
if ctx.Repo.Repository != nil && repoID != 0 && ctx.Repo.Repository.ID != repoID {
ctx.HTTPError(http.StatusNotFound)
return
}
if unitType == unit.TypeInvalid { // unlinked attachment can only be accessed by the uploader
if !(ctx.IsSigned && attach.UploaderID == ctx.Doer.ID) { // We block if not the uploader
@@ -186,6 +186,22 @@ func ServeAttachment(ctx *context.Context, uuid string) {
return
}
// Draft release attachments must not be exposed to anyone without write
// access, matching the API-side canAccessReleaseDraft gate. Otherwise the
// UUID-based web endpoints would leak draft attachments to any recipient of
// the (leaked) download URL.
if unitType == unit.TypeReleases && attach.ReleaseID != 0 && !perm.CanWrite(unit.TypeReleases) {
rel, err := repo_model.GetReleaseByID(ctx, attach.ReleaseID)
if err != nil {
ctx.ServerError("GetReleaseByID", err)
return
}
if rel.IsDraft {
ctx.HTTPError(http.StatusNotFound)
return
}
}
if requiredScope, ok := attachmentReadScope(unitType); ok {
context.CheckTokenScopes(ctx, repo, requiredScope)
if ctx.Written() {
+26 -31
View File
@@ -12,30 +12,31 @@ import (
"path"
"strconv"
repo_model "code.gitea.io/gitea/models/repo"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/charset"
"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/git/languagestats"
"code.gitea.io/gitea/modules/gitrepo"
"code.gitea.io/gitea/modules/highlight"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/templates"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/services/context"
"gitea.dev/models/gituser"
repo_model "gitea.dev/models/repo"
"gitea.dev/modules/charset"
"gitea.dev/modules/git"
"gitea.dev/modules/git/languagestats"
"gitea.dev/modules/gitrepo"
"gitea.dev/modules/highlight"
"gitea.dev/modules/log"
"gitea.dev/modules/setting"
"gitea.dev/modules/templates"
"gitea.dev/modules/util"
"gitea.dev/services/context"
)
type blameRow struct {
RowNumber int
Avatar template.HTML
PreviousSha string
PreviousShaURL string
CommitURL string
CommitMessage string
CommitSince template.HTML
AvatarStackData *gituser.AvatarStackData
Code template.HTML
EscapeStatus *charset.EscapeStatus
}
@@ -174,9 +175,9 @@ func fillBlameResult(br *gitrepo.BlameReader, r *blameResult) error {
return nil
}
func processBlameParts(ctx *context.Context, blameParts []*gitrepo.BlamePart) map[string]*user_model.UserCommit {
func processBlameParts(ctx *context.Context, blameParts []*gitrepo.BlamePart) map[string]*gituser.UserCommit {
// store commit data by SHA to look up avatar info etc
commitNames := make(map[string]*user_model.UserCommit)
commitNames := make(map[string]*gituser.UserCommit)
// and as blameParts can reference the same commits multiple
// times, we cache the lookup work locally
commits := make([]*git.Commit, 0, len(blameParts))
@@ -209,33 +210,28 @@ func processBlameParts(ctx *context.Context, blameParts []*gitrepo.BlamePart) ma
}
// populate commit email addresses to later look up avatars.
validatedCommits, err := user_model.ValidateCommitsWithEmails(ctx, commits)
userCommits, err := gituser.GetUserCommitsByGitCommits(ctx, commits, ctx.Repo.RepoLink, ctx.Repo.RefFullName)
if err != nil {
ctx.ServerError("ValidateCommitsWithEmails", err)
ctx.ServerError("GetUserCommitsByGitCommits", err)
return nil
}
for _, c := range validatedCommits {
commitNames[c.ID.String()] = c
for _, c := range userCommits {
commitNames[c.GitCommit.ID.String()] = c
}
return commitNames
}
func renderBlameFillFirstBlameRow(repoLink string, avatarUtils *templates.AvatarUtils, part *gitrepo.BlamePart, commit *user_model.UserCommit, br *blameRow) {
if commit.User != nil {
br.Avatar = avatarUtils.Avatar(commit.User, 18)
} else {
br.Avatar = avatarUtils.AvatarByEmail(commit.Author.Email, commit.Author.Name, 18)
}
func renderBlameFillFirstBlameRow(ctx *context.Context, repoLink string, part *gitrepo.BlamePart, commit *gituser.UserCommit, br *blameRow) {
br.AvatarStackData = gituser.BuildAvatarStackData(ctx, commit.GitCommit.AllAuthorIdentities(), nil)
br.PreviousSha = part.PreviousSha
br.PreviousShaURL = fmt.Sprintf("%s/blame/commit/%s/%s", repoLink, url.PathEscape(part.PreviousSha), util.PathEscapeSegments(part.PreviousPath))
br.CommitURL = fmt.Sprintf("%s/commit/%s", repoLink, url.PathEscape(part.Sha))
br.CommitMessage = commit.Message()
br.CommitSince = templates.TimeSince(commit.Author.When)
br.CommitMessage = commit.GitCommit.MessageUTF8()
br.CommitSince = templates.TimeSince(commit.GitCommit.Author.When)
}
func renderBlame(ctx *context.Context, blameParts []*gitrepo.BlamePart, commitNames map[string]*user_model.UserCommit) {
func renderBlame(ctx *context.Context, blameParts []*gitrepo.BlamePart, commitNames map[string]*gituser.UserCommit) {
language, err := languagestats.GetFileLanguage(ctx, ctx.Repo.GitRepo, ctx.Repo.CommitID, ctx.Repo.TreePath)
if err != nil {
log.Error("Unable to get file language for %-v:%s. Error: %v", ctx.Repo.Repository, ctx.Repo.TreePath, err)
@@ -243,7 +239,6 @@ func renderBlame(ctx *context.Context, blameParts []*gitrepo.BlamePart, commitNa
buf := &bytes.Buffer{}
rows := make([]*blameRow, 0)
avatarUtils := templates.NewAvatarUtils(ctx)
rowNumber := 0 // will be 1-based
for _, part := range blameParts {
for partLineIdx, line := range part.Lines {
@@ -258,7 +253,7 @@ func renderBlame(ctx *context.Context, blameParts []*gitrepo.BlamePart, commitNa
}
if partLineIdx == 0 {
renderBlameFillFirstBlameRow(ctx.Repo.RepoLink, avatarUtils, part, commitNames[part.Sha], br)
renderBlameFillFirstBlameRow(ctx, ctx.Repo.RepoLink, part, commitNames[part.Sha], br)
}
}
}
+30 -27
View File
@@ -11,24 +11,24 @@ import (
"net/url"
"strings"
git_model "code.gitea.io/gitea/models/git"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unit"
"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/gitrepo"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/optional"
repo_module "code.gitea.io/gitea/modules/repository"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/templates"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/modules/web"
"code.gitea.io/gitea/routers/utils"
"code.gitea.io/gitea/services/context"
"code.gitea.io/gitea/services/forms"
pull_service "code.gitea.io/gitea/services/pull"
release_service "code.gitea.io/gitea/services/release"
repo_service "code.gitea.io/gitea/services/repository"
git_model "gitea.dev/models/git"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unit"
"gitea.dev/modules/git"
"gitea.dev/modules/gitrepo"
"gitea.dev/modules/log"
"gitea.dev/modules/optional"
repo_module "gitea.dev/modules/repository"
"gitea.dev/modules/setting"
"gitea.dev/modules/templates"
"gitea.dev/modules/util"
"gitea.dev/modules/web"
"gitea.dev/routers/utils"
"gitea.dev/services/context"
"gitea.dev/services/forms"
pull_service "gitea.dev/services/pull"
release_service "gitea.dev/services/release"
repo_service "gitea.dev/services/repository"
)
const (
@@ -39,10 +39,10 @@ const (
func Branches(ctx *context.Context) {
ctx.Data["Title"] = "Branches"
ctx.Data["AllowsPulls"] = ctx.Repo.Repository.AllowsPulls(ctx)
ctx.Data["IsWriter"] = ctx.Repo.CanWrite(unit.TypeCode)
ctx.Data["IsWriter"] = ctx.Repo.Permission.CanWrite(unit.TypeCode)
ctx.Data["IsMirror"] = ctx.Repo.Repository.IsMirror
// TODO: Can be replaced by ctx.Repo.PullRequestCtx.CanCreateNewPull()
ctx.Data["CanPull"] = ctx.Repo.CanWrite(unit.TypeCode) ||
ctx.Data["CanPull"] = ctx.Repo.Permission.CanWrite(unit.TypeCode) ||
(ctx.IsSigned && repo_model.HasForkedRepo(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID))
ctx.Data["PageIsViewCode"] = true
ctx.Data["PageIsBranches"] = true
@@ -52,13 +52,16 @@ func Branches(ctx *context.Context) {
kw := ctx.FormString("q")
defaultBranch, branches, branchesCount, err := repo_service.LoadBranches(ctx, ctx.Repo.Repository, ctx.Repo.GitRepo, optional.None[bool](), kw, page, pageSize)
defaultBranchOptional, branches, branchesCount, err := repo_service.LoadBranches(ctx, ctx.Repo.Repository, ctx.Repo.GitRepo, optional.None[bool](), kw, page, pageSize)
if err != nil {
ctx.ServerError("LoadBranches", err)
return
}
commitIDs := []string{defaultBranch.DBBranch.CommitID}
commitIDs := make([]string, 0, len(branches)+1)
if defaultBranchOptional != nil {
commitIDs = append(commitIDs, defaultBranchOptional.DBBranch.CommitID)
}
for _, branch := range branches {
commitIDs = append(commitIDs, branch.DBBranch.CommitID)
}
@@ -68,7 +71,7 @@ func Branches(ctx *context.Context) {
ctx.ServerError("LoadBranches", err)
return
}
if !ctx.Repo.CanRead(unit.TypeActions) {
if !ctx.Repo.Permission.CanRead(unit.TypeActions) {
for key := range commitStatuses {
git_model.CommitStatusesHideActionsURL(ctx, commitStatuses[key])
}
@@ -83,7 +86,7 @@ func Branches(ctx *context.Context) {
ctx.Data["Branches"] = branches
ctx.Data["CommitStatus"] = commitStatus
ctx.Data["CommitStatuses"] = commitStatuses
ctx.Data["DefaultBranchBranch"] = defaultBranch
ctx.Data["DefaultBranchBranch"] = defaultBranchOptional
pager := context.NewPagination(branchesCount, pageSize, page, 5)
pager.AddParamFromRequest(ctx.Req)
ctx.Data["Page"] = pager
@@ -152,7 +155,7 @@ func RestoreBranchPost(ctx *context.Context) {
objectFormat := git.ObjectFormatFromName(ctx.Repo.Repository.ObjectFormatName)
// Don't return error below this
if err := repo_service.PushUpdate(
if err := repo_service.PushUpdates(
&repo_module.PushUpdateOptions{
RefFullName: git.RefNameFromBranch(deletedBranch.Name),
OldCommitID: objectFormat.EmptyObjectID().String(),
@@ -231,7 +234,7 @@ func CreateBranch(ctx *context.Context) {
flashError, err := ctx.RenderToHTML(tplAlertDetails, map[string]any{
"Message": ctx.Tr("repo.editor.push_rejected"),
"Summary": ctx.Tr("repo.editor.push_rejected_summary"),
"Details": utils.SanitizeFlashErrorString(e.Message),
"Details": utils.EscapeFlashErrorString(e.Message),
})
if err != nil {
ctx.ServerError("UpdatePullRequest.HTMLString", err)
@@ -261,7 +264,7 @@ func MergeUpstream(ctx *context.Context) {
branchName := ctx.FormString("branch")
_, err := repo_service.MergeUpstream(ctx, ctx.Doer, ctx.Repo.Repository, branchName, false)
if err != nil {
if errors.Is(err, util.ErrNotExist) {
if errors.Is(err, util.ErrNotExist) || errors.Is(err, util.ErrPermissionDenied) {
ctx.JSONErrorNotFound()
return
} else if pull_service.IsErrMergeConflicts(err) {
@@ -7,9 +7,9 @@ import (
"errors"
"net/http"
"code.gitea.io/gitea/modules/templates"
"code.gitea.io/gitea/services/context"
contributors_service "code.gitea.io/gitea/services/repository"
"gitea.dev/modules/templates"
"gitea.dev/services/context"
contributors_service "gitea.dev/services/repository"
)
const (
+68 -66
View File
@@ -9,33 +9,33 @@ import (
"fmt"
"html/template"
"net/http"
"path"
"strings"
asymkey_model "code.gitea.io/gitea/models/asymkey"
"code.gitea.io/gitea/models/db"
git_model "code.gitea.io/gitea/models/git"
issues_model "code.gitea.io/gitea/models/issues"
"code.gitea.io/gitea/models/renderhelper"
repo_model "code.gitea.io/gitea/models/repo"
unit_model "code.gitea.io/gitea/models/unit"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/base"
"code.gitea.io/gitea/modules/charset"
"code.gitea.io/gitea/modules/fileicon"
"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/gitrepo"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/markup"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/templates"
"code.gitea.io/gitea/modules/util"
asymkey_service "code.gitea.io/gitea/services/asymkey"
"code.gitea.io/gitea/services/context"
git_service "code.gitea.io/gitea/services/git"
"code.gitea.io/gitea/services/gitdiff"
repo_service "code.gitea.io/gitea/services/repository"
"code.gitea.io/gitea/services/repository/gitgraph"
asymkey_model "gitea.dev/models/asymkey"
"gitea.dev/models/db"
git_model "gitea.dev/models/git"
"gitea.dev/models/gituser"
issues_model "gitea.dev/models/issues"
"gitea.dev/models/renderhelper"
repo_model "gitea.dev/models/repo"
unit_model "gitea.dev/models/unit"
user_model "gitea.dev/models/user"
"gitea.dev/modules/base"
"gitea.dev/modules/charset"
"gitea.dev/modules/fileicon"
"gitea.dev/modules/git"
"gitea.dev/modules/gitrepo"
"gitea.dev/modules/log"
"gitea.dev/modules/markup"
"gitea.dev/modules/setting"
"gitea.dev/modules/templates"
"gitea.dev/modules/util"
asymkey_service "gitea.dev/services/asymkey"
"gitea.dev/services/context"
git_service "gitea.dev/services/git"
"gitea.dev/services/gitdiff"
repo_service "gitea.dev/services/repository"
"gitea.dev/services/repository/gitgraph"
)
const (
@@ -50,7 +50,7 @@ func RefCommits(ctx *context.Context) {
switch {
case len(ctx.Repo.TreePath) == 0:
Commits(ctx)
case ctx.Repo.TreePath == "search":
case ctx.Repo.TreePath == "search": // FIXME: legacy dirty design, it conflicts with the FileHistory
SearchCommits(ctx)
default:
FileHistory(ctx)
@@ -97,8 +97,6 @@ func Commits(ctx *context.Context) {
} else {
ctx.Data["CommitsTagsMap"] = commitsTagsMap
}
ctx.Data["Username"] = ctx.Repo.Owner.Name
ctx.Data["Reponame"] = ctx.Repo.Repository.Name
ctx.Data["CommitCount"] = commitsCount
pager := context.NewPagination(commitsCount, pageSize, page, 5)
@@ -164,9 +162,6 @@ func Graph(ctx *context.Context) {
ctx.Data["AllRefs"] = gitRefs
ctx.Data["Username"] = ctx.Repo.Owner.Name
ctx.Data["Reponame"] = ctx.Repo.Repository.Name
divOnly := ctx.FormBool("div-only")
queryParams := ctx.Req.URL.Query()
queryParams.Del("div-only")
@@ -210,8 +205,6 @@ func SearchCommits(ctx *context.Context) {
if all {
ctx.Data["All"] = true
}
ctx.Data["Username"] = ctx.Repo.Owner.Name
ctx.Data["Reponame"] = ctx.Repo.Repository.Name
ctx.HTML(http.StatusOK, tplCommits)
}
@@ -222,39 +215,52 @@ func FileHistory(ctx *context.Context) {
return
}
commitsCount, err := gitrepo.FileCommitsCount(ctx, ctx.Repo.Repository, ctx.Repo.RefFullName.ShortName(), ctx.Repo.TreePath)
if err != nil {
ctx.ServerError("FileCommitsCount", err)
return
} else if commitsCount == 0 {
ctx.NotFound(nil)
return
}
followRename := ctx.FormBool("follow-rename")
ctx.Data["ShowFollowRename"] = true
ctx.Data["FollowRenameChecked"] = followRename
page := max(ctx.FormInt("page"), 1)
commits, err := ctx.Repo.GitRepo.CommitsByFileAndRange(
commits, hasMore, err := ctx.Repo.GitRepo.CommitsByFileAndRange(
git.CommitsByFileAndRangeOptions{
Revision: ctx.Repo.RefFullName.ShortName(), // FIXME: legacy code used ShortName
File: ctx.Repo.TreePath,
Page: page,
Revision: ctx.Repo.RefFullName.ShortName(), // FIXME: legacy code used ShortName
File: ctx.Repo.TreePath,
Page: page,
FollowRename: followRename,
})
if err != nil {
ctx.ServerError("CommitsByFileAndRange", err)
return
}
var commitsCount int64
if followRename {
// there is no quick method to know the total count when "follow rename"
commitsCount = -1
} else {
commitsCount, err = gitrepo.FileCommitsCount(ctx, ctx.Repo.Repository, ctx.Repo.RefFullName.ShortName(), ctx.Repo.TreePath)
if err != nil {
ctx.ServerError("FileCommitsCount", err)
return
}
}
if len(commits) == 0 {
ctx.NotFound(nil)
return
}
ctx.Data["FileTreePath"] = ctx.Repo.TreePath
ctx.Data["CommitCount"] = commitsCount
ctx.Data["Commits"], err = processGitCommits(ctx, commits)
if err != nil {
ctx.ServerError("processGitCommits", err)
return
}
ctx.Data["Username"] = ctx.Repo.Owner.Name
ctx.Data["Reponame"] = ctx.Repo.Repository.Name
ctx.Data["FileTreePath"] = ctx.Repo.TreePath
ctx.Data["CommitCount"] = commitsCount
pager := context.NewPagination(commitsCount, setting.Git.CommitsRangeSize, page, 5)
if commitsCount == -1 {
pager.WithUnlimitedPaging(len(commits), hasMore)
}
pager.AddParamFromRequest(ctx.Req)
ctx.Data["Page"] = pager
ctx.HTML(http.StatusOK, tplCommits)
@@ -322,7 +328,7 @@ func Diff(ctx *context.Context) {
MaxLines: maxLines,
MaxLineCharacters: setting.Git.MaxGitDiffLineCharacters,
MaxFiles: maxFiles,
WhitespaceBehavior: gitdiff.GetWhitespaceFlag(ctx.Data["WhitespaceBehavior"].(string)),
WhitespaceBehavior: gitdiff.GetWhitespaceFlag(GetWhitespaceBehavior(ctx)),
}, files...)
if err != nil {
ctx.NotFound(err)
@@ -347,8 +353,6 @@ func Diff(ctx *context.Context) {
ctx.Data["CommitID"] = commitID
ctx.Data["AfterCommitID"] = commitID
ctx.Data["Username"] = userName
ctx.Data["Reponame"] = repoName
var parentCommit *git.Commit
var parentCommitID string
@@ -361,7 +365,7 @@ func Diff(ctx *context.Context) {
parentCommitID = parentCommit.ID.String()
}
setCompareContext(ctx, parentCommit, commit, userName, repoName)
ctx.Data["Title"] = commit.Summary() + " · " + base.ShortSha(commitID)
ctx.Data["Title"] = commit.MessageTitle() + " · " + base.ShortSha(commitID)
ctx.Data["Commit"] = commit
ctx.Data["Diff"] = diff
ctx.Data["DiffBlobExcerptData"] = diffBlobExcerptData
@@ -384,7 +388,7 @@ func Diff(ctx *context.Context) {
if err != nil {
log.Error("GetLatestCommitStatus: %v", err)
}
if !ctx.Repo.CanRead(unit_model.TypeActions) {
if !ctx.Repo.Permission.CanRead(unit_model.TypeActions) {
git_model.CommitStatusesHideActionsURL(ctx, statuses)
}
@@ -393,7 +397,8 @@ func Diff(ctx *context.Context) {
verification := asymkey_service.ParseCommitWithSignature(ctx, commit)
ctx.Data["Verification"] = verification
ctx.Data["Author"] = user_model.ValidateCommitWithEmail(ctx, commit)
ctx.Data["Author"] = user_model.GetUserByGitAuthor(ctx, commit)
ctx.Data["CommitOtherParticipants"] = gituser.BuildAvatarStackData(ctx, commit.CoAuthorIdentities(), nil).Participants
ctx.Data["Parents"] = parents
ctx.Data["DiffNotAvailable"] = diffShortStat.NumFiles == 0
@@ -408,13 +413,10 @@ func Diff(ctx *context.Context) {
err = git.GetNote(ctx, ctx.Repo.GitRepo, commitID, note)
if err == nil {
ctx.Data["NoteCommit"] = note.Commit
ctx.Data["NoteAuthor"] = user_model.ValidateCommitWithEmail(ctx, note.Commit)
rctx := renderhelper.NewRenderContextRepoComment(ctx, ctx.Repo.Repository, renderhelper.RepoCommentOptions{CurrentRefPath: path.Join("commit", util.PathEscapeSegments(commitID))})
ctx.Data["NoteRendered"], err = markup.PostProcessCommitMessage(rctx, template.HTMLEscapeString(string(charset.ToUTF8WithFallback(note.Message, charset.ConvertOpts{}))))
if err != nil {
ctx.ServerError("PostProcessCommitMessage", err)
return
}
ctx.Data["NoteAuthor"] = user_model.GetUserByGitAuthor(ctx, note.Commit)
rctx := renderhelper.NewRenderContextRepoComment(ctx, ctx.Repo.Repository, renderhelper.RepoCommentOptions{CurrentRefSubURL: "commit/" + util.PathEscapeSegments(commitID)})
htmlMessage := template.HTML(template.HTMLEscapeString(string(charset.ToUTF8WithFallback(note.Message, charset.ConvertOpts{}))))
ctx.Data["NoteRendered"] = markup.PostProcessCommitMessage(rctx, htmlMessage)
} else if !git.IsErrNotExist(err) {
log.Error("GetNote: %v", err)
}
@@ -461,11 +463,11 @@ func RawDiff(ctx *context.Context) {
}
func processGitCommits(ctx *context.Context, gitCommits []*git.Commit) ([]*git_model.SignCommitWithStatuses, error) {
commits, err := git_service.ConvertFromGitCommit(ctx, gitCommits, ctx.Repo.Repository)
commits, err := git_service.ConvertFromGitCommit(ctx, gitCommits, ctx.Repo.Repository, ctx.Repo.RefFullName)
if err != nil {
return nil, err
}
if !ctx.Repo.CanRead(unit_model.TypeActions) {
if !ctx.Repo.Permission.CanRead(unit_model.TypeActions) {
for _, commit := range commits {
if commit.Status == nil {
continue
@@ -4,12 +4,12 @@
package repo
import (
git_model "code.gitea.io/gitea/models/git"
access_model "code.gitea.io/gitea/models/perm/access"
unit_model "code.gitea.io/gitea/models/unit"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/services/context"
repo_service "code.gitea.io/gitea/services/repository"
git_model "gitea.dev/models/git"
access_model "gitea.dev/models/perm/access"
unit_model "gitea.dev/models/unit"
"gitea.dev/modules/log"
"gitea.dev/services/context"
repo_service "gitea.dev/services/repository"
)
type RecentBranchesPromptDataStruct struct {
+168 -221
View File
@@ -15,34 +15,34 @@ import (
"strings"
"unicode"
"code.gitea.io/gitea/models/db"
git_model "code.gitea.io/gitea/models/git"
issues_model "code.gitea.io/gitea/models/issues"
access_model "code.gitea.io/gitea/models/perm/access"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unit"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/base"
"code.gitea.io/gitea/modules/charset"
csv_module "code.gitea.io/gitea/modules/csv"
"code.gitea.io/gitea/modules/fileicon"
"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/git/gitcmd"
"code.gitea.io/gitea/modules/gitrepo"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/markup"
"code.gitea.io/gitea/modules/optional"
"code.gitea.io/gitea/modules/setting"
api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/templates"
"code.gitea.io/gitea/modules/typesniffer"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/routers/common"
"code.gitea.io/gitea/services/context"
"code.gitea.io/gitea/services/context/upload"
git_service "code.gitea.io/gitea/services/git"
"code.gitea.io/gitea/services/gitdiff"
user_service "code.gitea.io/gitea/services/user"
"gitea.dev/models/db"
git_model "gitea.dev/models/git"
issues_model "gitea.dev/models/issues"
access_model "gitea.dev/models/perm/access"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unit"
user_model "gitea.dev/models/user"
"gitea.dev/modules/base"
"gitea.dev/modules/charset"
csv_module "gitea.dev/modules/csv"
"gitea.dev/modules/fileicon"
"gitea.dev/modules/git"
"gitea.dev/modules/git/gitcmd"
"gitea.dev/modules/gitrepo"
"gitea.dev/modules/log"
"gitea.dev/modules/markup"
"gitea.dev/modules/optional"
"gitea.dev/modules/setting"
api "gitea.dev/modules/structs"
"gitea.dev/modules/templates"
"gitea.dev/modules/typesniffer"
"gitea.dev/modules/util"
"gitea.dev/routers/common"
"gitea.dev/services/context"
"gitea.dev/services/context/upload"
git_service "gitea.dev/services/git"
"gitea.dev/services/gitdiff"
user_service "gitea.dev/services/user"
)
const (
@@ -190,57 +190,44 @@ func setCsvCompareContext(ctx *context.Context) {
}
}
// ParseCompareInfo parse compare info between two commit for preparing comparing references
func ParseCompareInfo(ctx *context.Context) *git_service.CompareInfo {
type comparePageInfoType struct {
compareInfo *git_service.CompareInfo
nothingToCompare bool
allowCreatePull bool
}
func newComparePageInfo() *comparePageInfoType {
return &comparePageInfoType{}
}
// parseCompareInfo parse compare info between two commit for preparing comparing references
func (cpi *comparePageInfoType) parseCompareInfo(ctx *context.Context, compareParam string) error {
baseRepo := ctx.Repo.Repository
fileOnly := ctx.FormBool("file-only")
// 1 Parse compare router param
compareReq := common.ParseCompareRouterParam(ctx.PathParam("*"))
// remove the check when we support compare with carets
if compareReq.BaseOriRefSuffix != "" {
ctx.HTTPError(http.StatusBadRequest, "Unsupported comparison syntax: ref with suffix")
return nil
}
compareReq := common.ParseCompareRouterParam(compareParam)
// 2 get repository and owner for head
headOwner, headRepo, err := common.GetHeadOwnerAndRepo(ctx, baseRepo, compareReq)
switch {
case errors.Is(err, util.ErrInvalidArgument):
ctx.HTTPError(http.StatusBadRequest, err.Error())
return nil
case errors.Is(err, util.ErrNotExist):
ctx.NotFound(nil)
return nil
case err != nil:
ctx.ServerError("GetHeadOwnerAndRepo", err)
return nil
if err != nil {
return err
}
isSameRepo := baseRepo.ID == headRepo.ID
// 3 permission check
// base repository's code unit read permission check has been done on web.go
permBase := ctx.Repo.Permission
// If we're not merging from the same repo:
isSameRepo := baseRepo.ID == headRepo.ID
if !isSameRepo {
// Assert ctx.Doer has permission to read headRepo's codes
permHead, err := access_model.GetDoerRepoPermission(ctx, headRepo, ctx.Doer)
if err != nil {
ctx.ServerError("GetDoerRepoPermission", err)
return nil
return err
}
if !permHead.CanRead(unit.TypeCode) {
if log.IsTrace() {
log.Trace("Permission Denied: User: %-v cannot read code in Repo: %-v\nUser in headRepo has Permissions: %-+v",
ctx.Doer,
headRepo,
permHead)
}
ctx.NotFound(nil)
return nil
return util.NewNotExistErrorf("") // permission: no error message for end users
}
ctx.Data["CanWriteToHeadRepo"] = permHead.CanWrite(unit.TypeCode)
}
@@ -249,26 +236,18 @@ func ParseCompareInfo(ctx *context.Context) *git_service.CompareInfo {
baseRefName := util.IfZero(compareReq.BaseOriRef, baseRepo.GetPullRequestTargetBranch(ctx))
headRefName := util.IfZero(compareReq.HeadOriRef, headRepo.DefaultBranch)
baseRef := ctx.Repo.GitRepo.UnstableGuessRefByShortName(baseRefName)
if baseRef == "" {
ctx.NotFound(nil)
return nil
baseRef, err := common.ResolveRefWithSuffix(ctx.Repo.GitRepo, baseRefName, compareReq.BaseOriRefSuffix)
if err != nil {
return err
}
var headGitRepo *git.Repository
if isSameRepo {
headGitRepo = ctx.Repo.GitRepo
} else {
headGitRepo, err = gitrepo.OpenRepository(ctx, headRepo)
if err != nil {
ctx.ServerError("OpenRepository", err)
return nil
}
defer headGitRepo.Close()
headGitRepo, err := gitrepo.RepositoryFromRequestContextOrOpen(ctx, headRepo)
if err != nil {
return err
}
headRef := headGitRepo.UnstableGuessRefByShortName(headRefName)
if headRef == "" {
ctx.NotFound(nil)
return nil
headRef, err := common.ResolveRefWithSuffix(headGitRepo, headRefName, compareReq.HeadOriRefSuffix)
if err != nil {
return err
}
ctx.Data["BaseName"] = baseRepo.OwnerName
@@ -292,12 +271,9 @@ func ParseCompareInfo(ctx *context.Context) *git_service.CompareInfo {
var rootRepo *repo_model.Repository
if baseRepo.IsFork {
err = baseRepo.GetBaseRepo(ctx)
if err != nil {
if !repo_model.IsErrRepoNotExist(err) {
ctx.ServerError("Unable to find root repo", err)
return nil
}
} else {
if err != nil && !repo_model.IsErrRepoNotExist(err) {
return err
} else if err == nil {
rootRepo = baseRepo.BaseRepo
}
}
@@ -314,42 +290,10 @@ func ParseCompareInfo(ctx *context.Context) *git_service.CompareInfo {
}
}
has := headRepo != nil
// 3. If the base is a forked from "RootRepo" and the owner of
// the "RootRepo" is the :headUser - set headRepo to that
if !has && rootRepo != nil && rootRepo.OwnerID == headOwner.ID {
headRepo = rootRepo
has = true
}
// 4. If the ctx.Doer has their own fork of the baseRepo and the headUser is the ctx.Doer
// set the headRepo to the ownFork
if !has && ownForkRepo != nil && ownForkRepo.OwnerID == headOwner.ID {
headRepo = ownForkRepo
has = true
}
// 5. If the headOwner has a fork of the baseRepo - use that
if !has {
headRepo = repo_model.GetForkedRepo(ctx, headOwner.ID, baseRepo.ID)
has = headRepo != nil
}
// 6. If the baseRepo is a fork and the headUser has a fork of that use that
if !has && baseRepo.IsFork {
headRepo = repo_model.GetForkedRepo(ctx, headOwner.ID, baseRepo.ForkID)
has = headRepo != nil
}
// 7. Otherwise if we're not the same repo and haven't found a repo give up
if !isSameRepo && !has {
ctx.Data["PageIsComparePull"] = false
}
ctx.Data["HeadRepo"] = headRepo
ctx.Data["BaseCompareRepo"] = ctx.Repo.Repository
// If we have a rootRepo and it's different from:
// If we have a rootRepo, and it's different from:
// 1. the computed base
// 2. the computed head
// then get the branches of it
@@ -362,17 +306,15 @@ func ParseCompareInfo(ctx *context.Context) *git_service.CompareInfo {
if !fileOnly {
branches, tags, err := getBranchesAndTagsForRepo(ctx, rootRepo)
if err != nil {
ctx.ServerError("GetBranchesForRepo", err)
return nil
return err
}
ctx.Data["RootRepoBranches"] = branches
ctx.Data["RootRepoTags"] = tags
}
}
}
// If we have a ownForkRepo and it's different from:
// If we have a ownForkRepo, and it's different from:
// 1. The computed base
// 2. The computed head
// 3. The rootRepo (if we have one)
@@ -387,8 +329,7 @@ func ParseCompareInfo(ctx *context.Context) *git_service.CompareInfo {
if !fileOnly {
branches, tags, err := getBranchesAndTagsForRepo(ctx, ownForkRepo)
if err != nil {
ctx.ServerError("GetBranchesForRepo", err)
return nil
return err
}
ctx.Data["OwnForkRepoBranches"] = branches
ctx.Data["OwnForkRepoTags"] = tags
@@ -396,38 +337,16 @@ func ParseCompareInfo(ctx *context.Context) *git_service.CompareInfo {
}
}
// Treat as pull request if both references are branches
if ctx.Data["PageIsComparePull"] == nil {
ctx.Data["PageIsComparePull"] = baseRef.IsBranch() && headRef.IsBranch() && permBase.CanReadIssuesOrPulls(true)
}
if ctx.Data["PageIsComparePull"] == true && !permBase.CanReadIssuesOrPulls(true) {
if log.IsTrace() {
log.Trace("Permission Denied: User: %-v cannot create/read pull requests in Repo: %-v\nUser in baseRepo has Permissions: %-+v",
ctx.Doer,
baseRepo,
permBase)
}
ctx.NotFound(nil)
return nil
}
compareInfo, err := git_service.GetCompareInfo(ctx, baseRepo, headRepo, headGitRepo, baseRef, headRef, compareReq.DirectComparison(), fileOnly)
if err != nil {
if errors.Is(err, util.ErrNotExist) {
ctx.Data["IsNoMergeBase"] = true
return compareInfo
}
ctx.ServerError("GetCompareInfo", err)
return nil
}
if compareReq.DirectComparison() {
ctx.Data["BeforeCommitID"] = compareInfo.BaseCommitID
} else {
ctx.Data["BeforeCommitID"] = compareInfo.MergeBase
return err
}
return compareInfo
// Treat as pull request if both references are branches
cpi.allowCreatePull = baseRef.IsBranch() && headRef.IsBranch() && permBase.CanReadIssuesOrPulls(true)
cpi.allowCreatePull = cpi.allowCreatePull && compareInfo.CompareBase != ""
cpi.compareInfo = &compareInfo
return nil
}
// autoTitleFromBranchName humanizes a branch name into a PR title.
@@ -467,16 +386,14 @@ func prepareNewPullRequestTitleContent(ci *git_service.CompareInfo, commits []*g
if useFirstCommitAsTitle {
// the "commits" are from "ShowPrettyFormatLogToList", which is ordered from newest to oldest, here take the oldest one
c := commits[len(commits)-1]
title = strings.TrimSpace(c.UserCommit.Summary())
title = c.UserCommit.GitCommit.MessageTitle()
} else {
title = autoTitleFromBranchName(ci.HeadRef.ShortName())
}
if len(commits) == 1 {
c := commits[0]
_, content, _ = strings.Cut(strings.TrimSpace(c.UserCommit.CommitMessage), "\n")
content = strings.TrimSpace(content)
content = string(charset.ToUTF8([]byte(content), charset.ConvertOpts{}))
content = c.GitCommit.MessageBody()
}
var titleTrailer string
@@ -492,16 +409,18 @@ func prepareNewPullRequestTitleContent(ci *git_service.CompareInfo, commits []*g
return title, content
}
// PrepareCompareDiff renders compare diff page
func PrepareCompareDiff(
ctx *context.Context,
ci *git_service.CompareInfo,
whitespaceBehavior gitcmd.TrustedCmdArgs,
) (nothingToCompare bool) {
// prepareCompareDiff renders compare diff page. TODO: need to refactor it and other "compare diff" related functions together
func (cpi *comparePageInfoType) prepareCompareDiff(ctx *context.Context, whitespaceBehavior gitcmd.TrustedCmdArgs) {
ci := cpi.compareInfo
if ci.CompareBase == "" {
cpi.nothingToCompare = true
return
}
repo := ctx.Repo.Repository
headCommitID := ci.HeadCommitID
ctx.Data["CommitRepoLink"] = ci.HeadRepo.Link()
ctx.Data["BeforeCommitID"] = ci.CompareBase
ctx.Data["AfterCommitID"] = headCommitID
// follow GitHub's behavior: autofill the form and expand
@@ -511,26 +430,18 @@ func PrepareCompareDiff(
ctx.Data["TitleQuery"] = newPrFormTitle
ctx.Data["BodyQuery"] = newPrFormBody
if (headCommitID == ci.MergeBase && !ci.DirectComparison()) ||
headCommitID == ci.BaseCommitID {
ctx.Data["IsNothingToCompare"] = true
if unit, err := repo.GetUnit(ctx, unit.TypePullRequests); err == nil {
config := unit.PullRequestsConfig()
if headCommitID == ci.CompareBase {
config := repo.MustGetUnit(ctx, unit.TypePullRequests).PullRequestsConfig()
// if auto-detect manual merge, an empty PR will be closed immediately because it is already on base branch
supportEmptyPr := !config.AutodetectManualMerge
acrossRepoPr := !ci.IsSameRef()
ctx.Data["AllowEmptyPr"] = supportEmptyPr && acrossRepoPr
if !config.AutodetectManualMerge {
ctx.Data["AllowEmptyPr"] = !ci.IsSameRef()
return ci.IsSameRef()
}
ctx.Data["AllowEmptyPr"] = false
}
return true
cpi.nothingToCompare = true
return
}
beforeCommitID := ci.MergeBase
if ci.DirectComparison() {
beforeCommitID = ci.BaseCommitID
}
beforeCommitID := ci.CompareBase
maxLines, maxFiles := setting.Git.MaxGitDiffLines, setting.Git.MaxGitDiffFiles
files := ctx.FormStrings("files")
@@ -553,12 +464,12 @@ func PrepareCompareDiff(
}, ctx.FormStrings("files")...)
if err != nil {
ctx.ServerError("GetDiff", err)
return false
return
}
diffShortStat, err := gitdiff.GetDiffShortStat(ctx, ci.HeadRepo, ci.HeadGitRepo, beforeCommitID, headCommitID)
if err != nil {
ctx.ServerError("GetDiffShortStat", err)
return false
return
}
ctx.Data["DiffShortStat"] = diffShortStat
ctx.Data["Diff"] = diff
@@ -573,7 +484,7 @@ func PrepareCompareDiff(
diffTree, err := gitdiff.GetDiffTree(ctx, ci.HeadGitRepo, false, beforeCommitID, headCommitID)
if err != nil {
ctx.ServerError("GetDiffTree", err)
return false
return
}
renderedIconPool := fileicon.NewRenderedIconPool()
@@ -586,7 +497,7 @@ func PrepareCompareDiff(
headCommit, err := ci.HeadGitRepo.GetCommit(headCommitID)
if err != nil {
ctx.ServerError("GetCommit", err)
return false
return
}
baseGitRepo := ctx.Repo.GitRepo
@@ -594,24 +505,20 @@ func PrepareCompareDiff(
beforeCommit, err := baseGitRepo.GetCommit(beforeCommitID)
if err != nil {
ctx.ServerError("GetCommit", err)
return false
return
}
commits, err := processGitCommits(ctx, ci.Commits)
if err != nil {
ctx.ServerError("processGitCommits", err)
return false
return
}
ctx.Data["Commits"] = commits
ctx.Data["CommitCount"] = len(commits)
ctx.Data["title"], ctx.Data["content"] = prepareNewPullRequestTitleContent(ci, commits, setting.Repository.PullRequest.DefaultTitleSource)
ctx.Data["Username"] = ci.HeadRepo.OwnerName
ctx.Data["Reponame"] = ci.HeadRepo.Name
setCompareContext(ctx, beforeCommit, headCommit, ci.HeadRepo.OwnerName, repo.Name)
return false
}
func getBranchesAndTagsForRepo(ctx gocontext.Context, repo *repo_model.Repository) (branches, tags []string, err error) {
@@ -632,27 +539,24 @@ func getBranchesAndTagsForRepo(ctx gocontext.Context, repo *repo_model.Repositor
// CompareDiff show different from one commit to another commit
func CompareDiff(ctx *context.Context) {
ci := ParseCompareInfo(ctx)
if ctx.Written() {
comparePageInfo := newComparePageInfo()
err := comparePageInfo.parseCompareInfo(ctx, ctx.PathParam("*"))
if errors.Is(err, util.ErrNotExist) || errors.Is(err, util.ErrInvalidArgument) {
ctx.NotFound(nil)
return
} else if err != nil {
ctx.ServerError("ParseCompareInfo", err)
return
}
ci := comparePageInfo.compareInfo
ctx.Data["PageIsViewCode"] = true
ctx.Data["PullRequestWorkInProgressPrefixes"] = setting.Repository.PullRequest.WorkInProgressPrefixes
ctx.Data["CompareInfo"] = ci
var nothingToCompare bool
noMergeBase := ctx.Data["IsNoMergeBase"] == true
if noMergeBase {
ctx.Flash.Error(ctx.Tr("repo.pulls.no_common_history"), true)
ctx.Data["PageIsComparePull"] = false
ctx.Data["CommitCount"] = 0
nothingToCompare = true
} else {
nothingToCompare = PrepareCompareDiff(ctx, ci, gitdiff.GetWhitespaceFlag(ctx.Data["WhitespaceBehavior"].(string)))
if ctx.Written() {
return
}
// TODO: need to refactor "prepare compare" related functions together
comparePageInfo.prepareCompareDiff(ctx, gitdiff.GetWhitespaceFlag(GetWhitespaceBehavior(ctx)))
if ctx.Written() {
return
}
baseTags, err := repo_model.GetTagNamesByRepoID(ctx, ctx.Repo.Repository.ID)
@@ -682,12 +586,62 @@ func CompareDiff(ctx *context.Context) {
return
}
if noMergeBase {
ctx.HTML(http.StatusOK, tplCompare)
if ci.CompareBase != "" {
comparePageInfo.prepareCreatePullRequestPage(ctx)
if ctx.Written() {
return
}
} else {
ctx.Flash.Error(ctx.Tr("repo.pulls.no_common_history"), true)
ctx.Data["CommitCount"] = 0
}
ctx.Data["PageIsComparePull"] = comparePageInfo.allowCreatePull
ctx.Data["IsNothingToCompare"] = comparePageInfo.nothingToCompare
ctx.HTML(http.StatusOK, tplCompare)
}
// DownloadCompareDiff render a comparison's raw unified diff
func DownloadCompareDiff(ctx *context.Context) {
downloadCompareDiffOrPatch(ctx, false)
}
// DownloadComparePatch render a comparison as a git format-patch
func DownloadComparePatch(ctx *context.Context) {
downloadCompareDiffOrPatch(ctx, true)
}
func downloadCompareDiffOrPatch(ctx *context.Context, patch bool) {
// The route captures `basehead` separately so the `.diff`/`.patch` suffix is
// stripped from the catch-all `*` param parseCompareInfo would otherwise read.
cpi := newComparePageInfo()
if err := cpi.parseCompareInfo(ctx, ctx.PathParam("basehead")); err != nil {
if errors.Is(err, util.ErrNotExist) || errors.Is(err, util.ErrInvalidArgument) {
ctx.NotFound(nil)
} else {
ctx.ServerError("ParseCompareInfo", err)
}
return
}
ci := cpi.compareInfo
if ctx.Data["PageIsComparePull"] == true {
ctx.Resp.Header().Set("Content-Type", "text/plain; charset=utf-8")
compareArg := ci.BaseCommitID + ci.CompareSeparator + ci.HeadCommitID
var err error
if patch {
err = ci.HeadGitRepo.GetPatch(compareArg, ctx.Resp)
} else {
err = ci.HeadGitRepo.GetDiff(compareArg, ctx.Resp)
}
if err != nil {
ctx.ServerError("DownloadCompareDiffOrPatch", err)
return
}
}
func (cpi *comparePageInfoType) prepareCreatePullRequestPage(ctx *context.Context) {
ci := cpi.compareInfo
if cpi.allowCreatePull {
pr, err := issues_model.GetUnmergedPullRequest(ctx, ci.HeadRepo.ID, ctx.Repo.Repository.ID, ci.HeadRef.ShortName(), ci.BaseRef.ShortName(), issues_model.PullRequestFlowGithub)
if err != nil {
if !issues_model.IsErrPullRequestNotExist(err) {
@@ -701,11 +655,10 @@ func CompareDiff(ctx *context.Context) {
return
}
ctx.Data["PullRequest"] = pr
ctx.HTML(http.StatusOK, tplCompareDiff)
return
}
if !nothingToCompare {
if !cpi.nothingToCompare {
// Setup information for new form.
pageMetaData := retrieveRepoIssueMetaData(ctx, ctx.Repo.Repository, nil, true)
if ctx.Written() {
@@ -717,8 +670,8 @@ func CompareDiff(ctx *context.Context) {
}
}
}
beforeCommitID := ctx.Data["BeforeCommitID"].(string)
afterCommitID := ctx.Data["AfterCommitID"].(string)
beforeCommitID := cpi.compareInfo.CompareBase
afterCommitID := cpi.compareInfo.HeadCommitID
ctx.Data["Title"] = "Comparing " + base.ShortSha(beforeCommitID) + ci.CompareSeparator + base.ShortSha(afterCommitID)
@@ -727,7 +680,7 @@ func CompareDiff(ctx *context.Context) {
if content, ok := ctx.Data["content"].(string); ok && content != "" {
// If a template content is set, prepend the "content". In this case that's only
// applicable if you have one commit to compare and that commit has a message.
// In that case the commit message will be prepend to the template body.
// In that case the commit message will be prepended to the template body.
if templateContent, ok := ctx.Data[pullRequestTemplateKey].(string); ok && templateContent != "" {
// Re-use the same key as that's prioritized over the "content" key.
// Add two new lines between the content to ensure there's always at least
@@ -749,20 +702,14 @@ func CompareDiff(ctx *context.Context) {
}
}
ctx.Data["IsProjectsEnabled"] = ctx.Repo.CanWrite(unit.TypeProjects)
ctx.Data["IsProjectsEnabled"] = ctx.Repo.Permission.CanWrite(unit.TypeProjects)
ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled
upload.AddUploadContext(ctx, "comment")
ctx.Data["HasIssuesOrPullsWritePermission"] = ctx.Repo.CanWrite(unit.TypePullRequests)
ctx.Data["HasIssuesOrPullsWritePermission"] = ctx.Repo.Permission.CanWrite(unit.TypePullRequests)
if unit, err := ctx.Repo.Repository.GetUnit(ctx, unit.TypePullRequests); err == nil {
config := unit.PullRequestsConfig()
ctx.Data["AllowMaintainerEdit"] = config.DefaultAllowMaintainerEdit
} else {
ctx.Data["AllowMaintainerEdit"] = false
}
ctx.HTML(http.StatusOK, tplCompare)
prConfig := ctx.Repo.Repository.MustGetUnit(ctx, unit.TypePullRequests).PullRequestsConfig()
ctx.Data["AllowMaintainerEdit"] = prConfig.DefaultAllowMaintainerEdit
}
// attachCommentsToLines attaches comments to their corresponding diff lines
@@ -844,7 +791,7 @@ func ExcerptBlob(ctx *context.Context) {
diffBlobExcerptData.PullIssueIndex = ctx.FormInt64("pull_issue_index")
if diffBlobExcerptData.PullIssueIndex > 0 {
if !ctx.Repo.CanRead(unit.TypePullRequests) {
if !ctx.Repo.Permission.CanRead(unit.TypePullRequests) {
ctx.NotFound(nil)
return
}
@@ -6,16 +6,15 @@ package repo
import (
"strings"
"testing"
"unicode/utf8"
asymkey_model "code.gitea.io/gitea/models/asymkey"
git_model "code.gitea.io/gitea/models/git"
issues_model "code.gitea.io/gitea/models/issues"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/setting"
git_service "code.gitea.io/gitea/services/git"
"code.gitea.io/gitea/services/gitdiff"
asymkey_model "gitea.dev/models/asymkey"
git_model "gitea.dev/models/git"
"gitea.dev/models/gituser"
issues_model "gitea.dev/models/issues"
"gitea.dev/modules/git"
"gitea.dev/modules/setting"
git_service "gitea.dev/services/git"
"gitea.dev/services/gitdiff"
"github.com/stretchr/testify/assert"
)
@@ -53,9 +52,9 @@ func TestNewPullRequestTitleContent(t *testing.T) {
mockCommit := func(msg string) *git_model.SignCommitWithStatuses {
return &git_model.SignCommitWithStatuses{
SignCommit: &asymkey_model.SignCommit{
UserCommit: &user_model.UserCommit{
Commit: &git.Commit{
CommitMessage: msg,
UserCommit: &gituser.UserCommit{
GitCommit: &git.Commit{
CommitMessage: git.CommitMessage{MessageRaw: msg},
},
},
},
@@ -99,9 +98,9 @@ func TestNewPullRequestTitleContent(t *testing.T) {
assert.Equal(t, "title-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa…", title)
assert.Equal(t, "…aaaaaaaaa\n", content)
title, content = prepareNewPullRequestTitleContent(ci, []*git_model.SignCommitWithStatuses{mockCommit("a\xf0\xf0\xf0\nb\xf0\xf0\xf0")}, setting.RepoPRTitleSourceFirstCommit)
assert.Equal(t, "a?", title) // FIXME: GIT-COMMIT-MESSAGE-ENCODING: "title" doesn't use the same charset converting logic as "content"
assert.Equal(t, "b"+string(utf8.RuneError)+string(utf8.RuneError), content)
title, content = prepareNewPullRequestTitleContent(ci, []*git_model.SignCommitWithStatuses{mockCommit("title \xf0\nbody \xf0")}, setting.RepoPRTitleSourceFirstCommit)
assert.Equal(t, "title ð", title)
assert.Equal(t, "body ð", content)
}
func TestAutoTitleFromBranchName(t *testing.T) {
@@ -7,9 +7,9 @@ import (
"errors"
"net/http"
"code.gitea.io/gitea/modules/templates"
"code.gitea.io/gitea/services/context"
contributors_service "code.gitea.io/gitea/services/repository"
"gitea.dev/modules/templates"
"gitea.dev/services/context"
contributors_service "gitea.dev/services/repository"
)
const (
@@ -7,16 +7,16 @@ package repo
import (
"time"
auth_model "code.gitea.io/gitea/models/auth"
git_model "code.gitea.io/gitea/models/git"
"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/httpcache"
"code.gitea.io/gitea/modules/httplib"
"code.gitea.io/gitea/modules/lfs"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/storage"
"code.gitea.io/gitea/routers/common"
"code.gitea.io/gitea/services/context"
auth_model "gitea.dev/models/auth"
git_model "gitea.dev/models/git"
"gitea.dev/modules/git"
"gitea.dev/modules/httpcache"
"gitea.dev/modules/httplib"
"gitea.dev/modules/lfs"
"gitea.dev/modules/setting"
"gitea.dev/modules/storage"
"gitea.dev/routers/common"
"gitea.dev/services/context"
)
func checkDownloadTokenScope(ctx *context.Context) bool {
+15 -15
View File
@@ -11,21 +11,21 @@ import (
"path"
"strings"
git_model "code.gitea.io/gitea/models/git"
"code.gitea.io/gitea/models/issues"
"code.gitea.io/gitea/models/unit"
"code.gitea.io/gitea/modules/charset"
"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/httplib"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/templates"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/modules/web"
"code.gitea.io/gitea/services/context"
"code.gitea.io/gitea/services/context/upload"
"code.gitea.io/gitea/services/forms"
files_service "code.gitea.io/gitea/services/repository/files"
git_model "gitea.dev/models/git"
"gitea.dev/models/issues"
"gitea.dev/models/unit"
"gitea.dev/modules/charset"
"gitea.dev/modules/git"
"gitea.dev/modules/httplib"
"gitea.dev/modules/log"
"gitea.dev/modules/setting"
"gitea.dev/modules/templates"
"gitea.dev/modules/util"
"gitea.dev/modules/web"
"gitea.dev/services/context"
"gitea.dev/services/context/upload"
"gitea.dev/services/forms"
files_service "gitea.dev/services/repository/files"
)
const (
@@ -7,10 +7,10 @@ import (
"net/http"
"strings"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/services/context"
"code.gitea.io/gitea/services/forms"
"code.gitea.io/gitea/services/repository/files"
"gitea.dev/modules/util"
"gitea.dev/services/context"
"gitea.dev/services/forms"
"gitea.dev/services/repository/files"
)
func NewDiffPatch(ctx *context.Context) {
@@ -6,14 +6,13 @@ package repo
import (
"bytes"
"net/http"
"strings"
"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/gitrepo"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/services/context"
"code.gitea.io/gitea/services/forms"
"code.gitea.io/gitea/services/repository/files"
"gitea.dev/modules/git"
"gitea.dev/modules/gitrepo"
"gitea.dev/modules/util"
"gitea.dev/services/context"
"gitea.dev/services/forms"
"gitea.dev/services/repository/files"
)
func CherryPick(ctx *context.Context) {
@@ -33,10 +32,10 @@ func CherryPick(ctx *context.Context) {
if ctx.FormString("cherry-pick-type") == "revert" {
ctx.Data["CherryPickType"] = "revert"
ctx.Data["commit_summary"] = "revert " + ctx.PathParam("sha")
ctx.Data["commit_message"] = "revert " + cherryPickCommit.Message()
ctx.Data["commit_message"] = "revert " + cherryPickCommit.MessageUTF8()
} else {
ctx.Data["CherryPickType"] = "cherry-pick"
ctx.Data["commit_summary"], ctx.Data["commit_message"], _ = strings.Cut(cherryPickCommit.Message(), "\n")
ctx.Data["commit_summary"], ctx.Data["commit_message"] = cherryPickCommit.MessageTitle(), cherryPickCommit.MessageBody()
}
ctx.HTML(http.StatusOK, tplCherryPick)
@@ -6,14 +6,14 @@ package repo
import (
"errors"
git_model "code.gitea.io/gitea/models/git"
"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/routers/utils"
context_service "code.gitea.io/gitea/services/context"
files_service "code.gitea.io/gitea/services/repository/files"
git_model "gitea.dev/models/git"
"gitea.dev/modules/git"
"gitea.dev/modules/log"
"gitea.dev/modules/setting"
"gitea.dev/modules/util"
"gitea.dev/routers/utils"
context_service "gitea.dev/services/context"
files_service "gitea.dev/services/repository/files"
)
func errorAs[T error](v error) (e T, ok bool) {
@@ -27,13 +27,13 @@ func editorHandleFileOperationErrorRender(ctx *context_service.Context, message,
flashError, err := ctx.RenderToHTML(tplAlertDetails, map[string]any{
"Message": message,
"Summary": summary,
"Details": utils.SanitizeFlashErrorString(details),
"Details": utils.EscapeFlashErrorString(details),
})
if err == nil {
ctx.JSONError(flashError)
} else {
log.Error("RenderToHTML: %v", err)
ctx.JSONError(message + "\n" + summary + "\n" + utils.SanitizeFlashErrorString(details))
log.Error("RenderToHTML(%q, %q, %q), error: %v", message, summary, details, err)
ctx.JSONError("Unable to render error details, see server logs") // it should never happen
}
}
@@ -6,9 +6,9 @@ package repo
import (
"net/http"
"code.gitea.io/gitea/modules/templates"
"code.gitea.io/gitea/services/context"
repo_service "code.gitea.io/gitea/services/repository"
"gitea.dev/modules/templates"
"gitea.dev/services/context"
repo_service "gitea.dev/services/repository"
)
const tplEditorFork templates.TplName = "repo/editor/fork"
@@ -6,9 +6,9 @@ package repo
import (
"net/http"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/services/context"
files_service "code.gitea.io/gitea/services/repository/files"
"gitea.dev/modules/setting"
"gitea.dev/services/context"
files_service "gitea.dev/services/repository/files"
)
func DiffPreviewPost(ctx *context.Context) {
@@ -6,9 +6,9 @@ package repo
import (
"testing"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest"
"code.gitea.io/gitea/modules/gitrepo"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest"
"gitea.dev/modules/gitrepo"
"github.com/stretchr/testify/assert"
)
@@ -6,12 +6,12 @@ package repo
import (
"net/http"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/services/context"
"code.gitea.io/gitea/services/context/upload"
files_service "code.gitea.io/gitea/services/repository/files"
repo_model "gitea.dev/models/repo"
"gitea.dev/modules/setting"
"gitea.dev/modules/util"
"gitea.dev/services/context"
"gitea.dev/services/context/upload"
files_service "gitea.dev/services/repository/files"
)
// UploadFileToServer upload file to server file dir not git

Some files were not shown because too many files have changed in this diff Show More