初始提交: Gitea 项目代码

This commit is contained in:
root
2026-05-30 22:47:36 +08:00
commit f288f76350
6116 changed files with 776822 additions and 0 deletions
+281
View File
@@ -0,0 +1,281 @@
// Copyright 2014 The Gogs Authors. All rights reserved.
// Copyright 2019 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package admin
import (
"fmt"
"net/http"
"runtime"
"sort"
"strings"
"time"
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 (
tplDashboard templates.TplName = "admin/dashboard"
tplSystemStatus templates.TplName = "admin/system_status"
tplSelfCheck templates.TplName = "admin/self_check"
tplCron templates.TplName = "admin/cron"
tplQueue templates.TplName = "admin/queue"
tplPerfTrace templates.TplName = "admin/perftrace"
tplStacktrace templates.TplName = "admin/stacktrace"
tplQueueManage templates.TplName = "admin/queue_manage"
tplStats templates.TplName = "admin/stats"
)
var sysStatus struct {
StartTime string
NumGoroutine int
// General statistics.
MemAllocated string // bytes allocated and still in use
MemTotal string // bytes allocated (even if freed)
MemSys string // bytes obtained from system (sum of XxxSys below)
Lookups uint64 // number of pointer lookups
MemMallocs uint64 // number of mallocs
MemFrees uint64 // number of frees
// Main allocation heap statistics.
HeapAlloc string // bytes allocated and still in use
HeapSys string // bytes obtained from system
HeapIdle string // bytes in idle spans
HeapInuse string // bytes in non-idle span
HeapReleased string // bytes released to the OS
HeapObjects uint64 // total number of allocated objects
// Low-level fixed-size structure allocator statistics.
// Inuse is bytes used now.
// Sys is bytes obtained from system.
StackInuse string // bootstrap stacks
StackSys string
MSpanInuse string // mspan structures
MSpanSys string
MCacheInuse string // mcache structures
MCacheSys string
BuckHashSys string // profiling bucket hash table
GCSys string // GC metadata
OtherSys string // other system allocations
// Garbage collector statistics.
NextGC string // next run in HeapAlloc time (bytes)
LastGCTime string // last run time
PauseTotalNs string
PauseNs string // circular buffer of recent GC pause times, most recent at [(NumGC+255)%256]
NumGC uint32
}
func updateSystemStatus() {
sysStatus.StartTime = setting.AppStartTime.Format(time.RFC3339)
m := new(runtime.MemStats)
runtime.ReadMemStats(m)
sysStatus.NumGoroutine = runtime.NumGoroutine()
sysStatus.MemAllocated = base.FileSize(int64(m.Alloc))
sysStatus.MemTotal = base.FileSize(int64(m.TotalAlloc))
sysStatus.MemSys = base.FileSize(int64(m.Sys))
sysStatus.Lookups = m.Lookups
sysStatus.MemMallocs = m.Mallocs
sysStatus.MemFrees = m.Frees
sysStatus.HeapAlloc = base.FileSize(int64(m.HeapAlloc))
sysStatus.HeapSys = base.FileSize(int64(m.HeapSys))
sysStatus.HeapIdle = base.FileSize(int64(m.HeapIdle))
sysStatus.HeapInuse = base.FileSize(int64(m.HeapInuse))
sysStatus.HeapReleased = base.FileSize(int64(m.HeapReleased))
sysStatus.HeapObjects = m.HeapObjects
sysStatus.StackInuse = base.FileSize(int64(m.StackInuse))
sysStatus.StackSys = base.FileSize(int64(m.StackSys))
sysStatus.MSpanInuse = base.FileSize(int64(m.MSpanInuse))
sysStatus.MSpanSys = base.FileSize(int64(m.MSpanSys))
sysStatus.MCacheInuse = base.FileSize(int64(m.MCacheInuse))
sysStatus.MCacheSys = base.FileSize(int64(m.MCacheSys))
sysStatus.BuckHashSys = base.FileSize(int64(m.BuckHashSys))
sysStatus.GCSys = base.FileSize(int64(m.GCSys))
sysStatus.OtherSys = base.FileSize(int64(m.OtherSys))
sysStatus.NextGC = base.FileSize(int64(m.NextGC))
sysStatus.LastGCTime = time.Unix(0, int64(m.LastGC)).Format(time.RFC3339)
sysStatus.PauseTotalNs = fmt.Sprintf("%.1fs", float64(m.PauseTotalNs)/1000/1000/1000)
sysStatus.PauseNs = fmt.Sprintf("%.3fs", float64(m.PauseNs[(m.NumGC+255)%256])/1000/1000/1000)
sysStatus.NumGC = m.NumGC
}
func prepareStartupProblemsAlert(ctx *context.Context) {
if len(setting.StartupProblems) > 0 {
content := setting.StartupProblems[0]
if len(setting.StartupProblems) > 1 {
content += fmt.Sprintf(" (and %d more)", len(setting.StartupProblems)-1)
}
ctx.Flash.Error(content, true)
}
}
// Dashboard show admin panel dashboard
func Dashboard(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("admin.dashboard")
ctx.Data["PageIsAdminDashboard"] = true
ctx.Data["NeedUpdate"] = updatechecker.GetNeedUpdate(ctx)
ctx.Data["RemoteVersion"] = updatechecker.GetRemoteVersion(ctx)
updateSystemStatus()
ctx.Data["SysStatus"] = sysStatus
ctx.Data["SSH"] = setting.SSH
prepareStartupProblemsAlert(ctx)
ctx.HTML(http.StatusOK, tplDashboard)
}
func SystemStatus(ctx *context.Context) {
updateSystemStatus()
ctx.Data["SysStatus"] = sysStatus
ctx.HTML(http.StatusOK, tplSystemStatus)
}
// DashboardPost run an admin operation
func DashboardPost(ctx *context.Context) {
form := web.GetForm(ctx).(*forms.AdminDashboardForm)
ctx.Data["Title"] = ctx.Tr("admin.dashboard")
ctx.Data["PageIsAdminDashboard"] = true
updateSystemStatus()
ctx.Data["SysStatus"] = sysStatus
// Run operation.
if form.Op != "" {
switch form.Op {
case "sync_repo_branches":
go func() {
if err := repo_service.AddAllRepoBranchesToSyncQueue(graceful.GetManager().ShutdownContext()); err != nil {
log.Error("AddAllRepoBranchesToSyncQueue: %v: %v", ctx.Doer.ID, err)
}
}()
ctx.Flash.Success(ctx.Tr("admin.dashboard.sync_branch.started"))
case "sync_repo_tags":
go func() {
if err := release_service.AddAllRepoTagsToSyncQueue(graceful.GetManager().ShutdownContext()); err != nil {
log.Error("AddAllRepoTagsToSyncQueue: %v: %v", ctx.Doer.ID, err)
}
}()
ctx.Flash.Success(ctx.Tr("admin.dashboard.sync_tag.started"))
default:
task := cron.GetTask(form.Op)
if task != nil {
go task.RunWithUser(ctx.Doer, nil)
ctx.Flash.Success(ctx.Tr("admin.dashboard.task.started", ctx.Tr("admin.dashboard."+form.Op)))
} else {
ctx.Flash.Error(ctx.Tr("admin.dashboard.task.unknown", form.Op))
}
}
}
if form.From == "monitor" {
ctx.Redirect(setting.AppSubURL + "/-/admin/monitor/cron")
} else {
ctx.Redirect(setting.AppSubURL + "/-/admin")
}
}
func SelfCheck(ctx *context.Context) {
ctx.Data["PageIsAdminSelfCheck"] = true
ctx.Data["StartupProblems"] = setting.StartupProblems
if len(setting.StartupProblems) == 0 && !setting.IsProd {
if time.Now().Unix()%2 == 0 {
ctx.Data["StartupProblems"] = []string{"This is a test warning message in dev mode"}
}
}
r, err := db.CheckCollationsDefaultEngine()
if err != nil {
ctx.Flash.Error(fmt.Sprintf("CheckCollationsDefaultEngine: %v", err), true)
}
if r != nil {
ctx.Data["DatabaseType"] = setting.Database.Type
ctx.Data["DatabaseCheckResult"] = r
hasProblem := false
if !r.CollationEquals(r.DatabaseCollation, r.ExpectedCollation) {
ctx.Data["DatabaseCheckCollationMismatch"] = true
hasProblem = true
}
if !r.IsCollationCaseSensitive(r.DatabaseCollation) {
ctx.Data["DatabaseCheckCollationCaseInsensitive"] = true
hasProblem = true
}
ctx.Data["DatabaseCheckInconsistentCollationColumns"] = r.InconsistentCollationColumns
hasProblem = hasProblem || len(r.InconsistentCollationColumns) > 0
ctx.Data["DatabaseCheckHasProblems"] = hasProblem
}
elapsed, err := cache.Test()
if err != nil {
ctx.Data["CacheError"] = err
} else if elapsed > cache.SlowCacheThreshold {
ctx.Data["CacheSlow"] = fmt.Sprint(elapsed)
}
ctx.HTML(http.StatusOK, tplSelfCheck)
}
func SelfCheckPost(ctx *context.Context) {
var problems []string
frontendAppURL := ctx.FormString("location_origin") + setting.AppSubURL + "/"
ctxAppURL := httplib.GuessCurrentAppURL(ctx)
if !strings.HasPrefix(ctxAppURL, frontendAppURL) {
problems = append(problems, ctx.Locale.TrString("admin.self_check.location_origin_mismatch", frontendAppURL, ctxAppURL))
}
ctx.JSON(http.StatusOK, map[string]any{"problems": problems})
}
func CronTasks(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("admin.monitor.cron")
ctx.Data["PageIsAdminMonitorCron"] = true
ctx.Data["Entries"] = cron.ListTasks()
ctx.HTML(http.StatusOK, tplCron)
}
func MonitorStats(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("admin.monitor.stats")
ctx.Data["PageIsAdminMonitorStats"] = true
bs, err := json.Marshal(activities_model.GetStatistic(ctx).Counter)
if err != nil {
ctx.ServerError("MonitorStats", err)
return
}
statsCounter := map[string]any{}
err = json.Unmarshal(bs, &statsCounter)
if err != nil {
ctx.ServerError("MonitorStats", err)
return
}
statsKeys := make([]string, 0, len(statsCounter))
for k := range statsCounter {
if statsCounter[k] == nil {
continue
}
statsKeys = append(statsKeys, k)
}
sort.Strings(statsKeys)
ctx.Data["StatsKeys"] = statsKeys
ctx.Data["StatsCounter"] = statsCounter
ctx.HTML(http.StatusOK, tplStats)
}
+45
View File
@@ -0,0 +1,45 @@
// Copyright 2019 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package admin
import (
"net/http"
"testing"
"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 TestSelfCheckPost(t *testing.T) {
defer test.MockVariableValue(&setting.PublicURLDetection)()
defer test.MockVariableValue(&setting.AppURL, "http://config/sub/")()
defer test.MockVariableValue(&setting.AppSubURL, "/sub")()
data := struct {
Problems []string `json:"problems"`
}{}
setting.PublicURLDetection = setting.PublicURLLegacy
ctx, resp := contexttest.MockContext(t, "GET http://host/sub/admin/self_check?location_origin=http://frontend")
SelfCheckPost(ctx)
assert.Equal(t, http.StatusOK, resp.Code)
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &data))
assert.Equal(t, []string{
ctx.Locale.TrString("admin.self_check.location_origin_mismatch", "http://frontend/sub/", "http://config/sub/"),
}, data.Problems)
setting.PublicURLDetection = setting.PublicURLAuto
ctx, resp = contexttest.MockContext(t, "GET http://host/sub/admin/self_check?location_origin=http://frontend")
SelfCheckPost(ctx)
assert.Equal(t, http.StatusOK, resp.Code)
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &data))
assert.Equal(t, []string{
ctx.Locale.TrString("admin.self_check.location_origin_mismatch", "http://frontend/sub/", "http://host/sub/"),
}, data.Problems)
}
+89
View File
@@ -0,0 +1,89 @@
// Copyright 2022 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package admin
import (
"net/http"
"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 (
tplSettingsApplications templates.TplName = "admin/applications/list"
tplSettingsOauth2ApplicationEdit templates.TplName = "admin/applications/oauth2_edit"
)
func newOAuth2CommonHandlers() *user_setting.OAuth2CommonHandlers {
return &user_setting.OAuth2CommonHandlers{
OwnerID: 0,
BasePathList: setting.AppSubURL + "/-/admin/applications",
BasePathEditPrefix: setting.AppSubURL + "/-/admin/applications/oauth2",
TplAppEdit: tplSettingsOauth2ApplicationEdit,
}
}
// Applications render org applications page (for org, at the moment, there are only OAuth2 applications)
func Applications(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("settings.applications")
ctx.Data["PageIsAdminApplications"] = true
apps, err := db.Find[auth.OAuth2Application](ctx, auth.FindOAuth2ApplicationsOptions{
IsGlobal: true,
})
if err != nil {
ctx.ServerError("GetOAuth2ApplicationsByUserID", err)
return
}
ctx.Data["Applications"] = apps
ctx.Data["BuiltinApplications"] = auth.BuiltinApplications()
ctx.HTML(http.StatusOK, tplSettingsApplications)
}
// ApplicationsPost response for adding an oauth2 application
func ApplicationsPost(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("settings.applications")
ctx.Data["PageIsAdminApplications"] = true
oa := newOAuth2CommonHandlers()
oa.AddApp(ctx)
}
// EditApplication displays the given application
func EditApplication(ctx *context.Context) {
ctx.Data["PageIsAdminApplications"] = true
oa := newOAuth2CommonHandlers()
oa.EditShow(ctx)
}
// EditApplicationPost response for editing oauth2 application
func EditApplicationPost(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("settings.applications")
ctx.Data["PageIsAdminApplications"] = true
oa := newOAuth2CommonHandlers()
oa.EditSave(ctx)
}
// ApplicationsRegenerateSecret handles the post request for regenerating the secret
func ApplicationsRegenerateSecret(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("settings_title")
ctx.Data["PageIsAdminApplications"] = true
oa := newOAuth2CommonHandlers()
oa.RegenerateSecret(ctx)
}
// DeleteApplication deletes the given oauth2 application
func DeleteApplication(ctx *context.Context) {
oa := newOAuth2CommonHandlers()
oa.DeleteApp(ctx)
}
// TODO: revokes the grant with the given id
+467
View File
@@ -0,0 +1,467 @@
// Copyright 2014 The Gogs Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package admin
import (
"errors"
"fmt"
"net/http"
"net/url"
"regexp"
"strconv"
"strings"
"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 (
tplAuths templates.TplName = "admin/auth/list"
tplAuthNew templates.TplName = "admin/auth/new"
tplAuthEdit templates.TplName = "admin/auth/edit"
)
var (
separatorAntiPattern = regexp.MustCompile(`[^\w-\.]`)
langCodePattern = regexp.MustCompile(`^[a-z]{2}-[A-Z]{2}$`)
)
// Authentications show authentication config page
func Authentications(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("admin.authentication")
ctx.Data["PageIsAdminAuthentications"] = true
var err error
ctx.Data["Sources"], ctx.Data["Total"], err = db.FindAndCount[auth.Source](ctx, auth.FindSourcesOptions{})
if err != nil {
ctx.ServerError("auth.Sources", err)
return
}
ctx.HTML(http.StatusOK, tplAuths)
}
type dropdownItem struct {
Name string
Type any
}
var (
authSources = func() []dropdownItem {
items := []dropdownItem{
{auth.LDAP.String(), auth.LDAP},
{auth.DLDAP.String(), auth.DLDAP},
{auth.SMTP.String(), auth.SMTP},
{auth.OAuth2.String(), auth.OAuth2},
{auth.SSPI.String(), auth.SSPI},
}
if pam.Supported {
items = append(items, dropdownItem{auth.Names[auth.PAM], auth.PAM})
}
return items
}()
securityProtocols = []dropdownItem{
{ldap.SecurityProtocolNames[ldap.SecurityProtocolUnencrypted], ldap.SecurityProtocolUnencrypted},
{ldap.SecurityProtocolNames[ldap.SecurityProtocolLDAPS], ldap.SecurityProtocolLDAPS},
{ldap.SecurityProtocolNames[ldap.SecurityProtocolStartTLS], ldap.SecurityProtocolStartTLS},
}
)
// NewAuthSource render adding a new auth source page
func NewAuthSource(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("admin.auths.new")
ctx.Data["PageIsAdminAuthentications"] = true
ctx.Data["type"] = auth.LDAP.Int()
ctx.Data["CurrentTypeName"] = auth.Names[auth.LDAP]
ctx.Data["CurrentSecurityProtocol"] = ldap.SecurityProtocolNames[ldap.SecurityProtocolUnencrypted]
ctx.Data["smtp_auth"] = "PLAIN"
ctx.Data["is_active"] = true
ctx.Data["is_sync_enabled"] = true
ctx.Data["AuthSources"] = authSources
ctx.Data["SecurityProtocols"] = securityProtocols
ctx.Data["SMTPAuths"] = smtp.Authenticators
oauth2providers := oauth2.GetSupportedOAuth2Providers(ctx)
ctx.Data["OAuth2Providers"] = oauth2providers
ctx.Data["SSPIAutoCreateUsers"] = true
ctx.Data["SSPIAutoActivateUsers"] = true
ctx.Data["SSPIStripDomainNames"] = true
ctx.Data["SSPISeparatorReplacement"] = "_"
ctx.Data["SSPIDefaultLanguage"] = ""
// only the first as default
if len(oauth2providers) > 0 {
ctx.Data["oauth2_provider"] = oauth2providers[0].Name()
}
ctx.HTML(http.StatusOK, tplAuthNew)
}
func parseLDAPConfig(form forms.AuthenticationForm) *ldap.Source {
var pageSize uint32
if form.UsePagedSearch {
pageSize = uint32(form.SearchPageSize)
}
return &ldap.Source{
Name: form.Name,
Host: form.Host,
Port: form.Port,
SecurityProtocol: ldap.SecurityProtocol(form.SecurityProtocol),
SkipVerify: form.SkipVerify,
BindDN: form.BindDN,
UserDN: form.UserDN,
BindPassword: form.BindPassword,
UserBase: form.UserBase,
AttributeUsername: form.AttributeUsername,
AttributeName: form.AttributeName,
AttributeSurname: form.AttributeSurname,
AttributeMail: form.AttributeMail,
AttributesInBind: form.AttributesInBind,
AttributeSSHPublicKey: form.AttributeSSHPublicKey,
AttributeAvatar: form.AttributeAvatar,
SSHKeysAreVerified: form.SSHKeysAreVerified,
SearchPageSize: pageSize,
Filter: form.Filter,
GroupsEnabled: form.GroupsEnabled,
GroupDN: form.GroupDN,
GroupFilter: form.GroupFilter,
GroupMemberUID: form.GroupMemberUID,
GroupTeamMap: form.GroupTeamMap,
GroupTeamMapRemoval: form.GroupTeamMapRemoval,
UserUID: form.UserUID,
AdminFilter: form.AdminFilter,
RestrictedFilter: form.RestrictedFilter,
AllowDeactivateAll: form.AllowDeactivateAll,
Enabled: true,
}
}
func parseSMTPConfig(form forms.AuthenticationForm) *smtp.Source {
return &smtp.Source{
Auth: form.SMTPAuth,
Host: form.SMTPHost,
Port: form.SMTPPort,
AllowedDomains: form.AllowedDomains,
ForceSMTPS: form.ForceSMTPS,
SkipVerify: form.SkipVerify,
HeloHostname: form.HeloHostname,
DisableHelo: form.DisableHelo,
}
}
func parseOAuth2Config(form forms.AuthenticationForm) *oauth2.Source {
var customURLMapping *oauth2.CustomURLMapping
if form.Oauth2UseCustomURL {
customURLMapping = &oauth2.CustomURLMapping{
TokenURL: form.Oauth2TokenURL,
AuthURL: form.Oauth2AuthURL,
ProfileURL: form.Oauth2ProfileURL,
EmailURL: form.Oauth2EmailURL,
Tenant: form.Oauth2Tenant,
}
} else {
customURLMapping = nil
}
var scopes []string
for s := range strings.SplitSeq(form.Oauth2Scopes, ",") {
s = strings.TrimSpace(s)
if s != "" {
scopes = append(scopes, s)
}
}
return &oauth2.Source{
Provider: form.Oauth2Provider,
ClientID: form.Oauth2Key,
ClientSecret: form.Oauth2Secret,
OpenIDConnectAutoDiscoveryURL: form.OpenIDConnectAutoDiscoveryURL,
CustomURLMapping: customURLMapping,
IconURL: form.Oauth2IconURL,
Scopes: scopes,
RequiredClaimName: form.Oauth2RequiredClaimName,
RequiredClaimValue: form.Oauth2RequiredClaimValue,
GroupClaimName: form.Oauth2GroupClaimName,
RestrictedGroup: form.Oauth2RestrictedGroup,
AdminGroup: form.Oauth2AdminGroup,
GroupTeamMap: form.Oauth2GroupTeamMap,
GroupTeamMapRemoval: form.Oauth2GroupTeamMapRemoval,
SSHPublicKeyClaimName: form.Oauth2SSHPublicKeyClaimName,
FullNameClaimName: form.Oauth2FullNameClaimName,
ExternalIDClaim: form.OpenIDConnectExternalIDClaim,
}
}
func parseSSPIConfig(ctx *context.Context, form forms.AuthenticationForm) (*sspi.Source, error) {
if util.IsEmptyString(form.SSPISeparatorReplacement) {
ctx.Data["Err_SSPISeparatorReplacement"] = true
return nil, errors.New(ctx.Locale.TrString("form.SSPISeparatorReplacement") + ctx.Locale.TrString("form.require_error"))
}
if separatorAntiPattern.MatchString(form.SSPISeparatorReplacement) {
ctx.Data["Err_SSPISeparatorReplacement"] = true
return nil, errors.New(ctx.Locale.TrString("form.SSPISeparatorReplacement") + ctx.Locale.TrString("form.alpha_dash_dot_error"))
}
if form.SSPIDefaultLanguage != "" && !langCodePattern.MatchString(form.SSPIDefaultLanguage) {
ctx.Data["Err_SSPIDefaultLanguage"] = true
return nil, errors.New(ctx.Locale.TrString("form.lang_select_error"))
}
return &sspi.Source{
AutoCreateUsers: form.SSPIAutoCreateUsers,
AutoActivateUsers: form.SSPIAutoActivateUsers,
StripDomainNames: form.SSPIStripDomainNames,
SeparatorReplacement: form.SSPISeparatorReplacement,
DefaultLanguage: form.SSPIDefaultLanguage,
}, nil
}
// NewAuthSourcePost response for adding an auth source
func NewAuthSourcePost(ctx *context.Context) {
form := *web.GetForm(ctx).(*forms.AuthenticationForm)
ctx.Data["Title"] = ctx.Tr("admin.auths.new")
ctx.Data["PageIsAdminAuthentications"] = true
ctx.Data["CurrentTypeName"] = auth.Type(form.Type).String()
ctx.Data["CurrentSecurityProtocol"] = ldap.SecurityProtocolNames[ldap.SecurityProtocol(form.SecurityProtocol)]
ctx.Data["AuthSources"] = authSources
ctx.Data["SecurityProtocols"] = securityProtocols
ctx.Data["SMTPAuths"] = smtp.Authenticators
oauth2providers := oauth2.GetSupportedOAuth2Providers(ctx)
ctx.Data["OAuth2Providers"] = oauth2providers
ctx.Data["SSPIAutoCreateUsers"] = true
ctx.Data["SSPIAutoActivateUsers"] = true
ctx.Data["SSPIStripDomainNames"] = true
ctx.Data["SSPISeparatorReplacement"] = "_"
ctx.Data["SSPIDefaultLanguage"] = ""
hasTLS := false
var config auth.Config
switch auth.Type(form.Type) {
case auth.LDAP, auth.DLDAP:
config = parseLDAPConfig(form)
hasTLS = ldap.SecurityProtocol(form.SecurityProtocol) > ldap.SecurityProtocolUnencrypted
case auth.SMTP:
config = parseSMTPConfig(form)
hasTLS = true
case auth.PAM:
config = &pam_service.Source{
ServiceName: form.PAMServiceName,
EmailDomain: form.PAMEmailDomain,
}
case auth.OAuth2:
config = parseOAuth2Config(form)
oauth2Config := config.(*oauth2.Source)
if oauth2Config.Provider == "openidConnect" {
discoveryURL, err := url.Parse(oauth2Config.OpenIDConnectAutoDiscoveryURL)
if err != nil || (discoveryURL.Scheme != "http" && discoveryURL.Scheme != "https") {
ctx.Data["Err_DiscoveryURL"] = true
ctx.RenderWithErrDeprecated(ctx.Tr("admin.auths.invalid_openIdConnectAutoDiscoveryURL"), tplAuthNew, form)
return
}
}
case auth.SSPI:
var err error
config, err = parseSSPIConfig(ctx, form)
if err != nil {
ctx.RenderWithErrDeprecated(err.Error(), tplAuthNew, form)
return
}
existing, err := db.Find[auth.Source](ctx, auth.FindSourcesOptions{LoginType: auth.SSPI})
if err != nil || len(existing) > 0 {
ctx.Data["Err_Type"] = true
ctx.RenderWithErrDeprecated(ctx.Tr("admin.auths.login_source_of_type_exist"), tplAuthNew, form)
return
}
default:
ctx.HTTPError(http.StatusBadRequest)
return
}
ctx.Data["HasTLS"] = hasTLS
if ctx.HasError() {
ctx.HTML(http.StatusOK, tplAuthNew)
return
}
if err := auth.CreateSource(ctx, &auth.Source{
Type: auth.Type(form.Type),
Name: form.Name,
IsActive: form.IsActive,
IsSyncEnabled: form.IsSyncEnabled,
TwoFactorPolicy: form.TwoFactorPolicy,
Cfg: config,
}); err != nil {
if auth.IsErrSourceAlreadyExist(err) {
ctx.Data["Err_Name"] = true
ctx.RenderWithErrDeprecated(ctx.Tr("admin.auths.login_source_exist", err.(auth.ErrSourceAlreadyExist).Name), tplAuthNew, form)
} else if oauth2.IsErrOpenIDConnectInitialize(err) {
ctx.Data["Err_DiscoveryURL"] = true
unwrapped := err.(oauth2.ErrOpenIDConnectInitialize).Unwrap()
ctx.RenderWithErrDeprecated(ctx.Tr("admin.auths.unable_to_initialize_openid", unwrapped), tplAuthNew, form)
} else {
ctx.ServerError("auth.CreateSource", err)
}
return
}
log.Trace("Authentication created by admin(%s): %s", ctx.Doer.Name, form.Name)
ctx.Flash.Success(ctx.Tr("admin.auths.new_success", form.Name))
ctx.Redirect(setting.AppSubURL + "/-/admin/auths")
}
// EditAuthSource render editing auth source page
func EditAuthSource(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("admin.auths.edit")
ctx.Data["PageIsAdminAuthentications"] = true
ctx.Data["SecurityProtocols"] = securityProtocols
ctx.Data["SMTPAuths"] = smtp.Authenticators
oauth2providers := oauth2.GetSupportedOAuth2Providers(ctx)
ctx.Data["OAuth2Providers"] = oauth2providers
source, err := auth.GetSourceByID(ctx, ctx.PathParamInt64("authid"))
if err != nil {
ctx.ServerError("auth.GetSourceByID", err)
return
}
ctx.Data["Source"] = source
ctx.Data["HasTLS"] = source.HasTLS()
if source.IsOAuth2() {
type Named interface {
Name() string
}
for _, provider := range oauth2providers {
if provider.Name() == source.Cfg.(Named).Name() {
ctx.Data["CurrentOAuth2Provider"] = provider
break
}
}
}
ctx.HTML(http.StatusOK, tplAuthEdit)
}
// EditAuthSourcePost response for editing auth source
func EditAuthSourcePost(ctx *context.Context) {
form := *web.GetForm(ctx).(*forms.AuthenticationForm)
ctx.Data["Title"] = ctx.Tr("admin.auths.edit")
ctx.Data["PageIsAdminAuthentications"] = true
ctx.Data["SMTPAuths"] = smtp.Authenticators
oauth2providers := oauth2.GetSupportedOAuth2Providers(ctx)
ctx.Data["OAuth2Providers"] = oauth2providers
source, err := auth.GetSourceByID(ctx, ctx.PathParamInt64("authid"))
if err != nil {
ctx.ServerError("auth.GetSourceByID", err)
return
}
ctx.Data["Source"] = source
ctx.Data["HasTLS"] = source.HasTLS()
if ctx.HasError() {
ctx.HTML(http.StatusOK, tplAuthEdit)
return
}
var config auth.Config
switch auth.Type(form.Type) {
case auth.LDAP, auth.DLDAP:
config = parseLDAPConfig(form)
case auth.SMTP:
config = parseSMTPConfig(form)
case auth.PAM:
config = &pam_service.Source{
ServiceName: form.PAMServiceName,
EmailDomain: form.PAMEmailDomain,
}
case auth.OAuth2:
config = parseOAuth2Config(form)
oauth2Config := config.(*oauth2.Source)
if oauth2Config.Provider == "openidConnect" {
discoveryURL, err := url.Parse(oauth2Config.OpenIDConnectAutoDiscoveryURL)
if err != nil || (discoveryURL.Scheme != "http" && discoveryURL.Scheme != "https") {
ctx.Data["Err_DiscoveryURL"] = true
ctx.RenderWithErrDeprecated(ctx.Tr("admin.auths.invalid_openIdConnectAutoDiscoveryURL"), tplAuthEdit, form)
return
}
}
case auth.SSPI:
config, err = parseSSPIConfig(ctx, form)
if err != nil {
ctx.RenderWithErrDeprecated(err.Error(), tplAuthEdit, form)
return
}
default:
ctx.HTTPError(http.StatusBadRequest)
return
}
source.Name = form.Name
source.IsActive = form.IsActive
source.IsSyncEnabled = form.IsSyncEnabled
source.Cfg = config
source.TwoFactorPolicy = form.TwoFactorPolicy
if err := auth.UpdateSource(ctx, source); err != nil {
if auth.IsErrSourceAlreadyExist(err) {
ctx.Data["Err_Name"] = true
ctx.RenderWithErrDeprecated(ctx.Tr("admin.auths.login_source_exist", err.(auth.ErrSourceAlreadyExist).Name), tplAuthEdit, form)
} else if oauth2.IsErrOpenIDConnectInitialize(err) {
ctx.Flash.Error(err.Error(), true)
ctx.Data["Err_DiscoveryURL"] = true
ctx.HTML(http.StatusOK, tplAuthEdit)
} else {
ctx.ServerError("UpdateSource", err)
}
return
}
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(source.ID, 10))
}
// DeleteAuthSource response for deleting an auth source
func DeleteAuthSource(ctx *context.Context) {
source, err := auth.GetSourceByID(ctx, ctx.PathParamInt64("authid"))
if err != nil {
ctx.ServerError("auth.GetSourceByID", err)
return
}
if err = auth_service.DeleteSource(ctx, source); err != nil {
if auth.IsErrSourceInUse(err) {
ctx.Flash.Error(ctx.Tr("admin.auths.still_in_used"))
} else {
ctx.Flash.Error(fmt.Sprintf("auth_service.DeleteSource: %v", err))
}
ctx.JSONRedirect(setting.AppSubURL + "/-/admin/auths/" + url.PathEscape(ctx.PathParam("authid")))
return
}
log.Trace("Authentication deleted by admin(%s): %d", ctx.Doer.Name, source.ID)
ctx.Flash.Success(ctx.Tr("admin.auths.deletion_success"))
ctx.JSONRedirect(setting.AppSubURL + "/-/admin/auths")
}
+317
View File
@@ -0,0 +1,317 @@
// Copyright 2026 The Gitea Authors.
// SPDX-License-Identifier: MIT
package admin
import (
"errors"
"net/http"
"net/url"
"strings"
"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 (
tplBadges templates.TplName = "admin/badge/list"
tplBadgeNew templates.TplName = "admin/badge/new"
tplBadgeView templates.TplName = "admin/badge/view"
tplBadgeEdit templates.TplName = "admin/badge/edit"
tplBadgeUsers templates.TplName = "admin/badge/users"
)
// BadgeSearchDefaultAdminSort is the default sort type for admin view
const BadgeSearchDefaultAdminSort = "oldest"
// Badges show all the badges
func Badges(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("admin.badges")
ctx.Data["PageIsAdminBadges"] = true
RenderBadgeSearch(ctx, &user_model.SearchBadgeOptions{
ListOptions: db.ListOptions{
Page: max(ctx.FormInt("page"), 1),
PageSize: setting.UI.Admin.UserPagingNum,
},
}, tplBadges)
}
// NewBadge render adding a new badge
func NewBadge(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("admin.badges.new_badge")
ctx.Data["PageIsAdminBadges"] = true
ctx.HTML(http.StatusOK, tplBadgeNew)
}
// NewBadgePost response for adding a new badge
func NewBadgePost(ctx *context.Context) {
form := web.GetForm(ctx).(*forms.AdminCreateBadgeForm)
if ctx.HasError() {
ctx.JSONError(ctx.GetErrMsg())
return
}
b := &user_model.Badge{
Slug: form.Slug,
Description: form.Description,
ImageURL: form.ImageURL,
}
if err := user_model.CreateBadge(ctx, b); err != nil {
if errors.Is(err, util.ErrAlreadyExist) {
ctx.JSONError(ctx.Tr("admin.badges.slug_been_taken"))
} else {
ctx.ServerError("CreateBadge", err)
}
return
}
log.Trace("Badge created by admin (%s): %s", ctx.Doer.Name, b.Slug)
ctx.Flash.Success(ctx.Tr("admin.badges.new_success", b.Slug))
ctx.JSONRedirect(setting.AppSubURL + "/-/admin/badges/slug/" + url.PathEscape(b.Slug))
}
func prepareBadgeInfo(ctx *context.Context) *user_model.Badge {
b, err := user_model.GetBadge(ctx, ctx.PathParam("badge_slug"))
if err != nil {
if errors.Is(err, util.ErrNotExist) {
ctx.Redirect(setting.AppSubURL + "/-/admin/badges")
} else {
ctx.ServerError("GetBadge", err)
}
return nil
}
ctx.Data["Badge"] = b
return b
}
func ViewBadge(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("admin.badges.details")
ctx.Data["PageIsAdminBadges"] = true
prepareBadgeInfo(ctx)
if ctx.Written() {
return
}
badge := ctx.Data["Badge"].(*user_model.Badge)
opts := &user_model.GetBadgeUsersOptions{
ListOptions: db.ListOptions{
Page: 1,
PageSize: setting.UI.Admin.UserPagingNum,
},
BadgeSlug: badge.Slug,
}
users, count, err := user_model.GetBadgeUsers(ctx, opts)
if err != nil {
ctx.ServerError("GetBadgeUsers", err)
return
}
ctx.Data["Users"] = users
ctx.Data["UsersTotal"] = int(count)
ctx.HTML(http.StatusOK, tplBadgeView)
}
// EditBadge show editing badge page
func EditBadge(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("admin.badges.edit_badge")
ctx.Data["PageIsAdminBadges"] = true
prepareBadgeInfo(ctx)
if ctx.Written() {
return
}
ctx.HTML(http.StatusOK, tplBadgeEdit)
}
// EditBadgePost response for editing badge
func EditBadgePost(ctx *context.Context) {
b := prepareBadgeInfo(ctx)
if ctx.Written() {
return
}
form := web.GetForm(ctx).(*forms.AdminEditBadgeForm)
if ctx.HasError() {
ctx.JSONError(ctx.GetErrMsg())
return
}
b.ImageURL = form.ImageURL
b.Description = form.Description
if err := user_model.UpdateBadge(ctx, b); err != nil {
ctx.ServerError("UpdateBadge", err)
return
}
log.Trace("Badge updated by admin (%s): %s", ctx.Doer.Name, b.Slug)
ctx.Flash.Success(ctx.Tr("admin.badges.update_success"))
ctx.JSONRedirect(setting.AppSubURL + "/-/admin/badges/slug/" + url.PathEscape(ctx.PathParam("badge_slug")))
}
// DeleteBadge response for deleting a badge
func DeleteBadge(ctx *context.Context) {
b, err := user_model.GetBadge(ctx, ctx.PathParam("badge_slug"))
if err != nil {
ctx.ServerError("GetBadge", err)
return
}
if err = user_model.DeleteBadge(ctx, b); err != nil {
ctx.ServerError("DeleteBadge", err)
return
}
log.Trace("Badge deleted by admin (%s): %s", ctx.Doer.Name, b.Slug)
ctx.Flash.Success(ctx.Tr("admin.badges.deletion_success"))
ctx.Redirect(setting.AppSubURL + "/-/admin/badges")
}
func BadgeUsers(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("admin.badges.users_with_badge", ctx.PathParam("badge_slug"))
ctx.Data["PageIsAdminBadges"] = true
page := max(ctx.FormInt("page"), 1)
badge := &user_model.Badge{Slug: ctx.PathParam("badge_slug")}
opts := &user_model.GetBadgeUsersOptions{
ListOptions: db.ListOptions{
Page: page,
PageSize: setting.UI.Admin.UserPagingNum,
},
BadgeSlug: badge.Slug,
}
users, count, err := user_model.GetBadgeUsers(ctx, opts)
if err != nil {
ctx.ServerError("GetBadgeUsers", err)
return
}
ctx.Data["Users"] = users
ctx.Data["Total"] = count
ctx.Data["Page"] = context.NewPagination(count, setting.UI.Admin.UserPagingNum, page, 5)
ctx.HTML(http.StatusOK, tplBadgeUsers)
}
// BadgeUsersPost response for actions for user badges
func BadgeUsersPost(ctx *context.Context) {
name := strings.ToLower(ctx.FormString("user"))
u, err := user_model.GetUserByName(ctx, name)
if err != nil {
if user_model.IsErrUserNotExist(err) {
ctx.Flash.Error(ctx.Tr("form.user_not_exist"))
ctx.Redirect(setting.AppSubURL + ctx.Req.URL.EscapedPath())
} else {
ctx.ServerError("GetUserByName", err)
}
return
}
if err = user_model.AddUserBadge(ctx, u, &user_model.Badge{Slug: ctx.PathParam("badge_slug")}); err != nil {
if errors.Is(err, util.ErrNotExist) {
ctx.Flash.Error(ctx.Tr("admin.badges.not_found"))
ctx.Redirect(setting.AppSubURL + ctx.Req.URL.EscapedPath())
} else if errors.Is(err, util.ErrAlreadyExist) {
ctx.Flash.Error(ctx.Tr("admin.badges.user_already_has"))
ctx.Redirect(setting.AppSubURL + ctx.Req.URL.EscapedPath())
} else {
ctx.ServerError("AddUserBadge", err)
}
return
}
ctx.Flash.Success(ctx.Tr("admin.badges.user_add_success"))
ctx.Redirect(setting.AppSubURL + ctx.Req.URL.EscapedPath())
}
// DeleteBadgeUser delete a badge from a user
func DeleteBadgeUser(ctx *context.Context) {
badgeUsersURL := setting.AppSubURL + "/-/admin/badges/slug/" + url.PathEscape(ctx.PathParam("badge_slug")) + "/users"
user, err := user_model.GetUserByID(ctx, ctx.FormInt64("id"))
if err != nil {
if user_model.IsErrUserNotExist(err) {
ctx.Flash.Error(ctx.Tr("form.user_not_exist"))
ctx.JSONRedirect(badgeUsersURL)
return
} else {
ctx.ServerError("GetUserByID", err)
return
}
}
if err := user_model.RemoveUserBadge(ctx, user, &user_model.Badge{Slug: ctx.PathParam("badge_slug")}); err == nil {
ctx.Flash.Success(ctx.Tr("admin.badges.user_remove_success"))
} else {
ctx.ServerError("RemoveUserBadge", err)
return
}
ctx.JSONRedirect(badgeUsersURL)
}
func RenderBadgeSearch(ctx *context.Context, opts *user_model.SearchBadgeOptions, tplName templates.TplName) {
var (
badges []*user_model.Badge
count int64
err error
orderBy db.SearchOrderBy
)
sortOrder := ctx.FormString("sort")
if sortOrder == "" {
sortOrder = BadgeSearchDefaultAdminSort
}
ctx.Data["SortType"] = sortOrder
switch sortOrder {
case "newest":
orderBy = "`badge`.id DESC"
case "oldest":
orderBy = "`badge`.id ASC"
case "reversealphabetically":
orderBy = "`badge`.slug DESC"
case "alphabetically":
orderBy = "`badge`.slug ASC"
default:
// In case the sort type is invalid, keep admin default sorting.
ctx.Data["SortType"] = "oldest"
orderBy = "`badge`.id ASC"
}
opts.Keyword = ctx.FormTrim("q")
opts.OrderBy = orderBy
if len(opts.Keyword) == 0 || isKeywordValid(opts.Keyword) {
badges, count, err = user_model.SearchBadges(ctx, opts)
if err != nil {
ctx.ServerError("SearchBadges", err)
return
}
}
ctx.Data["Keyword"] = opts.Keyword
ctx.Data["Total"] = count
ctx.Data["Badges"] = badges
pager := context.NewPagination(count, opts.PageSize, opts.Page, 5)
pager.AddParamFromRequest(ctx.Req)
ctx.Data["Page"] = pager
ctx.HTML(http.StatusOK, tplName)
}
+182
View File
@@ -0,0 +1,182 @@
// Copyright 2014 The Gogs Authors. All rights reserved.
// Copyright 2019 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package admin
import (
"errors"
"net/http"
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"
)
const (
tplConfig templates.TplName = "admin/config"
tplConfigSettings templates.TplName = "admin/config_settings/config_settings"
)
// SendTestMail send test mail to confirm mail service is OK
func SendTestMail(ctx *context.Context) {
email := ctx.FormString("email")
// Send a test email to the user's email address and redirect back to Config
if err := mailer.SendTestMail(email); err != nil {
ctx.Flash.Error(ctx.Tr("admin.config.test_mail_failed", email, err))
} else {
ctx.Flash.Info(ctx.Tr("admin.config.test_mail_sent", email))
}
ctx.Redirect(setting.AppSubURL + "/-/admin/config")
}
// TestCache test the cache settings
func TestCache(ctx *context.Context) {
elapsed, err := cache.Test()
if err != nil {
ctx.Flash.Error(ctx.Tr("admin.config.cache_test_failed", err))
} else {
if elapsed > cache.SlowCacheThreshold {
ctx.Flash.Warning(ctx.Tr("admin.config.cache_test_slow", elapsed))
} else {
ctx.Flash.Info(ctx.Tr("admin.config.cache_test_succeeded", elapsed))
}
}
ctx.Redirect(setting.AppSubURL + "/-/admin/config")
}
// Config show admin config page
func Config(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("admin.config_summary")
ctx.Data["PageIsAdminConfig"] = true
ctx.Data["PageIsAdminConfigSummary"] = true
ctx.Data["CustomConf"] = setting.CustomConf
ctx.Data["AppBuiltWith"] = setting.AppBuiltWith
ctx.Data["RunUser"] = setting.RunUser
ctx.Data["RunMode"] = util.ToTitleCase(setting.RunMode)
ctx.Data["GitVersion"] = git.DefaultFeatures().VersionInfo()
ctx.Data["AppDataPath"] = setting.AppDataPath
ctx.Data["RepoRootPath"] = setting.RepoRootPath
ctx.Data["CustomRootPath"] = setting.CustomPath
ctx.Data["LogRootPath"] = setting.Log.RootPath
ctx.Data["ScriptType"] = setting.ScriptType
ctx.Data["ReverseProxyAuthUser"] = setting.ReverseProxyAuthUser
ctx.Data["ReverseProxyAuthEmail"] = setting.ReverseProxyAuthEmail
ctx.Data["SSH"] = setting.SSH
ctx.Data["LFS"] = setting.LFS
ctx.Data["Service"] = setting.Service
ctx.Data["DbCfg"] = setting.Database
ctx.Data["Webhook"] = setting.Webhook
ctx.Data["MailerEnabled"] = false
if setting.MailService != nil {
ctx.Data["MailerEnabled"] = true
ctx.Data["Mailer"] = setting.MailService
}
ctx.Data["CacheAdapter"] = setting.CacheService.Adapter
ctx.Data["CacheInterval"] = setting.CacheService.Interval
ctx.Data["CacheItemTTL"] = setting.CacheService.TTL
sessionCfg := setting.SessionConfig
if sessionCfg.Provider == "VirtualSession" {
var realSession session.Options
if err := json.Unmarshal([]byte(sessionCfg.ProviderConfig), &realSession); err != nil {
log.Error("Unable to unmarshall session config for virtual provider config: %s\nError: %v", sessionCfg.ProviderConfig, err)
}
sessionCfg.Provider = realSession.Provider
sessionCfg.ProviderConfig = realSession.ProviderConfig
sessionCfg.CookieName = realSession.CookieName
sessionCfg.CookiePath = realSession.CookiePath
sessionCfg.Gclifetime = realSession.Gclifetime
sessionCfg.Maxlifetime = realSession.Maxlifetime
sessionCfg.Secure = realSession.Secure
sessionCfg.Domain = realSession.Domain
}
sessionCfg.ProviderConfig = ""
ctx.Data["SessionConfig"] = sessionCfg
ctx.Data["Git"] = setting.Git
ctx.Data["AccessLogTemplate"] = setting.Log.AccessLogTemplate
ctx.Data["LogSQL"] = setting.Database.LogSQL
ctx.Data["Loggers"] = log.GetManager().DumpLoggers()
config.GetDynGetter().InvalidateCache()
prepareStartupProblemsAlert(ctx)
ctx.HTML(http.StatusOK, tplConfig)
}
func ConfigSettings(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("admin.config_settings")
ctx.Data["PageIsAdminConfig"] = true
ctx.Data["PageIsAdminConfigSettings"] = true
ctx.HTML(http.StatusOK, tplConfigSettings)
}
func validateConfigKeyValue(dynKey, input string) error {
opt := config.GetConfigOption(dynKey)
if opt == nil {
return util.NewInvalidArgumentErrorf("unknown config key: %s", dynKey)
}
const limit = 64 * 1024
if len(input) > limit {
return util.NewInvalidArgumentErrorf("value length exceeds limit of %d", limit)
}
if !json.Valid([]byte(input)) {
return util.NewInvalidArgumentErrorf("invalid json value for key: %s", dynKey)
}
return nil
}
func ChangeConfig(ctx *context.Context) {
_ = ctx.Req.ParseForm()
configKeys := ctx.Req.Form["key"]
configValues := ctx.Req.Form["value"]
configSettings := map[string]string{}
loop:
for i, key := range configKeys {
if i >= len(configValues) {
ctx.JSONError(ctx.Tr("admin.config.set_setting_failed", key))
break loop
}
value := configValues[i]
err := validateConfigKeyValue(key, value)
if err != nil {
if errors.Is(err, util.ErrInvalidArgument) {
ctx.JSONError(err.Error())
} else {
ctx.JSONError(ctx.Tr("admin.config.set_setting_failed", key))
}
break loop
}
configSettings[key] = value
}
if ctx.Written() {
return
}
if err := system_model.SetSettings(ctx, configSettings); err != nil {
ctx.ServerError("SetSettings", err)
return
}
config.GetDynGetter().InvalidateCache()
ctx.JSONOK()
}
+76
View File
@@ -0,0 +1,76 @@
// Copyright 2023 The Gitea Authors.
// SPDX-License-Identifier: MIT
package admin
import (
"archive/zip"
"fmt"
"runtime/pprof"
"time"
"gitea.dev/modules/httplib"
"gitea.dev/modules/tailmsg"
"gitea.dev/modules/util"
"gitea.dev/services/context"
)
func MonitorDiagnosis(ctx *context.Context) {
seconds := min(max(ctx.FormInt64("seconds"), 1), 300)
httplib.ServeSetHeaders(ctx.Resp, httplib.ServeHeaderOptions{
ContentType: "application/zip",
Filename: fmt.Sprintf("gitea-diagnosis-%s.zip", time.Now().Format("20060102-150405")),
ContentDisposition: httplib.ContentDispositionAttachment,
})
zipWriter := zip.NewWriter(ctx.Resp)
defer zipWriter.Close()
f, err := zipWriter.CreateHeader(&zip.FileHeader{Name: "goroutine-before.txt", Method: zip.Deflate, Modified: time.Now()})
if err != nil {
ctx.ServerError("Failed to create zip file", err)
return
}
_ = pprof.Lookup("goroutine").WriteTo(f, 1)
f, err = zipWriter.CreateHeader(&zip.FileHeader{Name: "cpu-profile.dat", Method: zip.Deflate, Modified: time.Now()})
if err != nil {
ctx.ServerError("Failed to create zip file", err)
return
}
err = pprof.StartCPUProfile(f)
if err == nil {
time.Sleep(time.Duration(seconds) * time.Second)
pprof.StopCPUProfile()
} else {
_, _ = f.Write([]byte(err.Error()))
}
f, err = zipWriter.CreateHeader(&zip.FileHeader{Name: "goroutine-after.txt", Method: zip.Deflate, Modified: time.Now()})
if err != nil {
ctx.ServerError("Failed to create zip file", err)
return
}
_ = pprof.Lookup("goroutine").WriteTo(f, 1)
f, err = zipWriter.CreateHeader(&zip.FileHeader{Name: "heap.dat", Method: zip.Deflate, Modified: time.Now()})
if err != nil {
ctx.ServerError("Failed to create zip file", err)
return
}
_ = pprof.Lookup("heap").WriteTo(f, 0)
f, err = zipWriter.CreateHeader(&zip.FileHeader{Name: "perftrace.txt", Method: zip.Deflate, Modified: time.Now()})
if err != nil {
ctx.ServerError("Failed to create zip file", err)
return
}
for _, record := range tailmsg.GetManager().GetTraceRecorder().GetRecords() {
_, _ = f.Write(util.UnsafeStringToBytes(record.Time.Format(time.RFC3339)))
_, _ = f.Write([]byte(" "))
_, _ = f.Write(util.UnsafeStringToBytes((record.Content)))
_, _ = f.Write([]byte("\n\n"))
}
}
+182
View File
@@ -0,0 +1,182 @@
// Copyright 2020 The Gitea Authors.
// SPDX-License-Identifier: MIT
package admin
import (
"bytes"
"net/http"
"net/url"
"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 (
tplEmails templates.TplName = "admin/emails/list"
)
// Emails show all emails
func Emails(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("admin.emails")
ctx.Data["PageIsAdminEmails"] = true
opts := &user_model.SearchEmailOptions{
ListOptions: db.ListOptions{
PageSize: setting.UI.Admin.UserPagingNum,
Page: ctx.FormInt("page"),
},
}
if opts.Page <= 1 {
opts.Page = 1
}
type ActiveEmail struct {
user_model.SearchEmailResult
CanChange bool
}
var (
baseEmails []*user_model.SearchEmailResult
emails []ActiveEmail
count int64
err error
orderBy user_model.SearchEmailOrderBy
)
ctx.Data["SortType"] = ctx.FormString("sort")
switch ctx.FormString("sort") {
case "email":
orderBy = user_model.SearchEmailOrderByEmail
case "reverseemail":
orderBy = user_model.SearchEmailOrderByEmailReverse
case "username":
orderBy = user_model.SearchEmailOrderByName
case "reverseusername":
orderBy = user_model.SearchEmailOrderByNameReverse
default:
ctx.Data["SortType"] = "email"
orderBy = user_model.SearchEmailOrderByEmail
}
opts.Keyword = ctx.FormTrim("q")
opts.SortType = orderBy
if len(ctx.FormString("is_activated")) != 0 {
opts.IsActivated = optional.Some(ctx.FormBool("activated"))
}
if len(ctx.FormString("is_primary")) != 0 {
opts.IsPrimary = optional.Some(ctx.FormBool("primary"))
}
if len(opts.Keyword) == 0 || isKeywordValid(opts.Keyword) {
baseEmails, count, err = user_model.SearchEmails(ctx, opts)
if err != nil {
ctx.ServerError("SearchEmails", err)
return
}
emails = make([]ActiveEmail, len(baseEmails))
for i := range baseEmails {
emails[i].SearchEmailResult = *baseEmails[i]
// Don't let the admin deactivate its own primary email address
// We already know the user is admin
emails[i].CanChange = ctx.Doer.ID != emails[i].UID || !emails[i].IsPrimary
}
}
ctx.Data["Keyword"] = opts.Keyword
ctx.Data["Total"] = count
ctx.Data["Emails"] = emails
pager := context.NewPagination(count, opts.PageSize, opts.Page, 5)
pager.AddParamFromRequest(ctx.Req)
ctx.Data["Page"] = pager
ctx.HTML(http.StatusOK, tplEmails)
}
var nullByte = []byte{0x00}
func isKeywordValid(keyword string) bool {
return !bytes.Contains([]byte(keyword), nullByte)
}
// ActivateEmail serves a POST request for activating/deactivating a user's email
func ActivateEmail(ctx *context.Context) {
truefalse := map[string]bool{"1": true, "0": false}
uid := ctx.FormInt64("uid")
email := ctx.FormString("email")
primary, okp := truefalse[ctx.FormString("primary")]
activate, oka := truefalse[ctx.FormString("activate")]
if uid == 0 || len(email) == 0 || !okp || !oka {
ctx.HTTPError(http.StatusBadRequest)
return
}
log.Info("Changing activation for User ID: %d, email: %s, primary: %v to %v", uid, email, primary, activate)
if err := user_model.ActivateUserEmail(ctx, uid, email, activate); err != nil {
log.Error("ActivateUserEmail(%v,%v,%v): %v", uid, email, activate, err)
if user_model.IsErrEmailAlreadyUsed(err) {
ctx.Flash.Error(ctx.Tr("admin.emails.duplicate_active"))
} else {
ctx.Flash.Error(ctx.Tr("admin.emails.not_updated", err))
}
} else {
log.Info("Activation for User ID: %d, email: %s, primary: %v changed to %v", uid, email, primary, activate)
ctx.Flash.Info(ctx.Tr("admin.emails.updated"))
}
redirect, _ := url.Parse(setting.AppSubURL + "/-/admin/emails")
q := url.Values{}
if val := ctx.FormTrim("q"); len(val) > 0 {
q.Set("q", val)
}
if val := ctx.FormTrim("sort"); len(val) > 0 {
q.Set("sort", val)
}
if val := ctx.FormTrim("is_primary"); len(val) > 0 {
q.Set("is_primary", val)
}
if val := ctx.FormTrim("is_activated"); len(val) > 0 {
q.Set("is_activated", val)
}
redirect.RawQuery = q.Encode()
ctx.Redirect(redirect.String())
}
// DeleteEmail serves a POST request for delete a user's email
func DeleteEmail(ctx *context.Context) {
u, err := user_model.GetUserByID(ctx, ctx.FormInt64("uid"))
if err != nil || u == nil {
ctx.ServerError("GetUserByID", err)
return
}
email, err := user_model.GetEmailAddressByID(ctx, u.ID, ctx.FormInt64("id"))
if err != nil || email == nil {
ctx.ServerError("GetEmailAddressByID", err)
return
}
if err := user.DeleteEmailAddresses(ctx, u, []string{email.Email}); err != nil {
if user_model.IsErrPrimaryEmailCannotDelete(err) {
ctx.Flash.Error(ctx.Tr("admin.emails.delete_primary_email_error"))
ctx.JSONRedirect("")
return
}
ctx.ServerError("DeleteEmailAddresses", err)
return
}
log.Trace("Email address deleted: %s %s", u.Name, email.Email)
ctx.Flash.Success(ctx.Tr("admin.emails.deletion_success"))
ctx.JSONRedirect("")
}
+71
View File
@@ -0,0 +1,71 @@
// Copyright 2018 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package admin
import (
"net/http"
"gitea.dev/models/webhook"
"gitea.dev/modules/optional"
"gitea.dev/modules/setting"
"gitea.dev/modules/templates"
"gitea.dev/services/context"
)
const (
// tplAdminHooks template path to render hook settings
tplAdminHooks templates.TplName = "admin/hooks"
)
// DefaultOrSystemWebhooks renders both admin default and system webhook list pages
func DefaultOrSystemWebhooks(ctx *context.Context) {
var err error
ctx.Data["Title"] = ctx.Tr("admin.hooks")
ctx.Data["PageIsAdminSystemHooks"] = true
ctx.Data["PageIsAdminDefaultHooks"] = true
def := make(map[string]any, len(ctx.Data))
sys := make(map[string]any, len(ctx.Data))
for k, v := range ctx.Data {
def[k] = v
sys[k] = v
}
sys["Title"] = ctx.Tr("admin.systemhooks")
sys["Description"] = ctx.Tr("admin.systemhooks.desc", "https://docs.gitea.com/usage/webhooks")
sys["Webhooks"], err = webhook.GetSystemWebhooks(ctx, optional.None[bool]())
sys["BaseLink"] = setting.AppSubURL + "/-/admin/hooks"
sys["BaseLinkNew"] = setting.AppSubURL + "/-/admin/system-hooks"
if err != nil {
ctx.ServerError("GetWebhooksAdmin", err)
return
}
def["Title"] = ctx.Tr("admin.defaulthooks")
def["Description"] = ctx.Tr("admin.defaulthooks.desc", "https://docs.gitea.com/usage/webhooks")
def["Webhooks"], err = webhook.GetDefaultWebhooks(ctx)
def["BaseLink"] = setting.AppSubURL + "/-/admin/hooks"
def["BaseLinkNew"] = setting.AppSubURL + "/-/admin/default-hooks"
if err != nil {
ctx.ServerError("GetWebhooksAdmin", err)
return
}
ctx.Data["DefaultWebhooks"] = def
ctx.Data["SystemWebhooks"] = sys
ctx.HTML(http.StatusOK, tplAdminHooks)
}
// DeleteDefaultOrSystemWebhook handler to delete an admin-defined system or default webhook
func DeleteDefaultOrSystemWebhook(ctx *context.Context) {
if err := webhook.DeleteDefaultSystemWebhook(ctx, ctx.FormInt64("id")); err != nil {
ctx.Flash.Error("DeleteDefaultWebhook: " + err.Error())
} else {
ctx.Flash.Success(ctx.Tr("repo.settings.webhook_deletion_success"))
}
ctx.JSONRedirect(setting.AppSubURL + "/-/admin/hooks")
}
+14
View File
@@ -0,0 +1,14 @@
// Copyright 2018 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package admin
import (
"testing"
"gitea.dev/models/unittest"
)
func TestMain(m *testing.M) {
unittest.MainTest(m)
}
+75
View File
@@ -0,0 +1,75 @@
// Copyright 2014 The Gogs Authors. All rights reserved.
// Copyright 2019 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package admin
import (
"net/http"
"strconv"
"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 (
tplNotices templates.TplName = "admin/notice"
)
// Notices show notices for admin
func Notices(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("admin.notices")
ctx.Data["PageIsAdminNotices"] = true
total := system_model.CountNotices(ctx)
page := max(ctx.FormInt("page"), 1)
notices, err := system_model.Notices(ctx, page, setting.UI.Admin.NoticePagingNum)
if err != nil {
ctx.ServerError("Notices", err)
return
}
ctx.Data["Notices"] = notices
ctx.Data["Total"] = total
ctx.Data["Page"] = context.NewPagination(total, setting.UI.Admin.NoticePagingNum, page, 5)
ctx.HTML(http.StatusOK, tplNotices)
}
// DeleteNotices delete the specific notices
func DeleteNotices(ctx *context.Context) {
strs := ctx.FormStrings("ids[]")
ids := make([]int64, 0, len(strs))
for i := range strs {
id, _ := strconv.ParseInt(strs[i], 10, 64)
if id > 0 {
ids = append(ids, id)
}
}
if err := db.DeleteByIDs[system_model.Notice](ctx, ids...); err != nil {
ctx.Flash.Error("DeleteNoticesByIDs: " + err.Error())
ctx.Status(http.StatusInternalServerError)
} else {
ctx.Flash.Success(ctx.Tr("admin.notices.delete_success"))
ctx.Status(http.StatusOK)
}
}
// EmptyNotices delete all the notices
func EmptyNotices(ctx *context.Context) {
if err := system_model.DeleteNotices(ctx, 0, 0); err != nil {
ctx.ServerError("DeleteNotices", err)
return
}
log.Trace("System notices deleted by admin (%s): [start: %d]", ctx.Doer.Name, 0)
ctx.Flash.Success(ctx.Tr("admin.notices.delete_success"))
ctx.Redirect(setting.AppSubURL + "/-/admin/notices")
}
+39
View File
@@ -0,0 +1,39 @@
// Copyright 2014 The Gogs Authors. All rights reserved.
// Copyright 2020 The Gitea Authors.
// SPDX-License-Identifier: MIT
package admin
import (
"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 (
tplOrgs templates.TplName = "admin/org/list"
)
// Organizations show all the organizations
func Organizations(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("admin.organizations")
ctx.Data["PageIsAdminOrganizations"] = true
if ctx.FormString("sort") == "" {
ctx.SetFormString("sort", UserSearchDefaultAdminSort)
}
explore.RenderUserSearch(ctx, user_model.SearchUserOptions{
Actor: ctx.Doer,
Types: []user_model.UserType{user_model.UserTypeOrganization},
IncludeReserved: true, // administrator needs to list all accounts include reserved
ListOptions: db.ListOptions{
PageSize: setting.UI.Admin.OrgPagingNum,
},
Visible: []structs.VisibleType{structs.VisibleTypePublic, structs.VisibleTypeLimited, structs.VisibleTypePrivate},
}, tplOrgs)
}
+108
View File
@@ -0,0 +1,108 @@
// Copyright 2014 The Gogs Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package admin
import (
"net/http"
"net/url"
"time"
"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 (
tplPackagesList templates.TplName = "admin/packages/list"
)
// Packages shows all packages
func Packages(ctx *context.Context) {
page := max(ctx.FormInt("page"), 1)
query := ctx.FormTrim("q")
packageType := ctx.FormTrim("type")
sort := ctx.FormTrim("sort")
pvs, total, err := packages_model.SearchVersions(ctx, &packages_model.PackageSearchOptions{
Type: packages_model.Type(packageType),
Name: packages_model.SearchValue{Value: query},
Sort: sort,
IsInternal: optional.Some(false),
Paginator: &db.ListOptions{
PageSize: setting.UI.PackagesPagingNum,
Page: page,
},
})
if err != nil {
ctx.ServerError("SearchVersions", err)
return
}
pds, err := packages_model.GetPackageDescriptors(ctx, pvs)
if err != nil {
ctx.ServerError("GetPackageDescriptors", err)
return
}
totalBlobSize, err := packages_model.GetTotalBlobSize(ctx)
if err != nil {
ctx.ServerError("GetTotalBlobSize", err)
return
}
totalUnreferencedBlobSize, err := packages_model.GetTotalUnreferencedBlobSize(ctx)
if err != nil {
ctx.ServerError("CalculateBlobSize", err)
return
}
ctx.Data["Title"] = ctx.Tr("packages.title")
ctx.Data["PageIsAdminPackages"] = true
ctx.Data["Query"] = query
ctx.Data["PackageType"] = packageType
ctx.Data["AvailableTypes"] = packages_model.TypeList
ctx.Data["SortType"] = sort
ctx.Data["PackageDescriptors"] = pds
ctx.Data["TotalCount"] = total
ctx.Data["TotalBlobSize"] = totalBlobSize - totalUnreferencedBlobSize
ctx.Data["TotalUnreferencedBlobSize"] = totalUnreferencedBlobSize
pager := context.NewPagination(total, setting.UI.PackagesPagingNum, page, 5)
pager.AddParamFromRequest(ctx.Req)
ctx.Data["Page"] = pager
ctx.HTML(http.StatusOK, tplPackagesList)
}
// DeletePackageVersion deletes a package version
func DeletePackageVersion(ctx *context.Context) {
pv, err := packages_model.GetVersionByID(ctx, ctx.FormInt64("id"))
if err != nil {
ctx.ServerError("GetRepositoryByID", err)
return
}
if err := packages_service.RemovePackageVersion(ctx, ctx.Doer, pv); err != nil {
ctx.ServerError("RemovePackageVersion", err)
return
}
ctx.Flash.Success(ctx.Tr("packages.settings.delete.version.success"))
ctx.JSONRedirect(setting.AppSubURL + "/-/admin/packages?page=" + url.QueryEscape(ctx.FormString("page")) + "&q=" + url.QueryEscape(ctx.FormString("q")) + "&type=" + url.QueryEscape(ctx.FormString("type")))
}
func CleanupExpiredData(ctx *context.Context) {
if err := packages_cleanup_service.CleanupExpiredData(ctx, time.Duration(0)); err != nil {
ctx.ServerError("CleanupExpiredData", err)
return
}
ctx.Flash.Success(ctx.Tr("admin.packages.cleanup.success"))
ctx.Redirect(setting.AppSubURL + "/-/admin/packages")
}
+18
View File
@@ -0,0 +1,18 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package admin
import (
"net/http"
"gitea.dev/modules/tailmsg"
"gitea.dev/services/context"
)
func PerfTrace(ctx *context.Context) {
monitorTraceCommon(ctx)
ctx.Data["PageIsAdminMonitorPerfTrace"] = true
ctx.Data["PerfTraceRecords"] = tailmsg.GetManager().GetTraceRecorder().GetRecords()
ctx.HTML(http.StatusOK, tplPerfTrace)
}
+89
View File
@@ -0,0 +1,89 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package admin
import (
"net/http"
"strconv"
"gitea.dev/modules/queue"
"gitea.dev/modules/setting"
"gitea.dev/services/context"
)
func Queues(ctx *context.Context) {
if !setting.IsProd {
initTestQueueOnce()
}
ctx.Data["Title"] = ctx.Tr("admin.monitor.queues")
ctx.Data["PageIsAdminMonitorQueue"] = true
ctx.Data["Queues"] = queue.GetManager().ManagedQueues()
ctx.HTML(http.StatusOK, tplQueue)
}
// QueueManage shows details for a specific queue
func QueueManage(ctx *context.Context) {
qid := ctx.PathParamInt64("qid")
mq := queue.GetManager().GetManagedQueue(qid)
if mq == nil {
ctx.Status(http.StatusNotFound)
return
}
ctx.Data["Title"] = ctx.Tr("admin.monitor.queue", mq.GetName())
ctx.Data["PageIsAdminMonitor"] = true
ctx.Data["Queue"] = mq
ctx.HTML(http.StatusOK, tplQueueManage)
}
// QueueSet sets the maximum number of workers and other settings for this queue
func QueueSet(ctx *context.Context) {
qid := ctx.PathParamInt64("qid")
mq := queue.GetManager().GetManagedQueue(qid)
if mq == nil {
ctx.Status(http.StatusNotFound)
return
}
maxNumberStr := ctx.FormString("max-number")
var err error
var maxNumber int
if len(maxNumberStr) > 0 {
maxNumber, err = strconv.Atoi(maxNumberStr)
if err != nil {
ctx.Flash.Error(ctx.Tr("admin.monitor.queue.settings.maxnumberworkers.error"))
ctx.Redirect(setting.AppSubURL + "/-/admin/monitor/queue/" + strconv.FormatInt(qid, 10))
return
}
if maxNumber < -1 {
maxNumber = -1
}
} else {
maxNumber = mq.GetWorkerMaxNumber()
}
mq.SetWorkerMaxNumber(maxNumber)
ctx.Flash.Success(ctx.Tr("admin.monitor.queue.settings.changed"))
ctx.Redirect(setting.AppSubURL + "/-/admin/monitor/queue/" + strconv.FormatInt(qid, 10))
}
func QueueRemoveAllItems(ctx *context.Context) {
// Gitea's queue doesn't have transaction support
// So in rare cases, the queue could be corrupted/out-of-sync
// Site admin could remove all items from the queue to make it work again
qid := ctx.PathParamInt64("qid")
mq := queue.GetManager().GetManagedQueue(qid)
if mq == nil {
ctx.Status(http.StatusNotFound)
return
}
if err := mq.RemoveAllItems(ctx); err != nil {
ctx.ServerError("RemoveAllItems", err)
return
}
ctx.Flash.Success(ctx.Tr("admin.monitor.queue.settings.remove_all_items_done"))
ctx.Redirect(setting.AppSubURL + "/-/admin/monitor/queue/" + strconv.FormatInt(qid, 10))
}
+77
View File
@@ -0,0 +1,77 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package admin
import (
"runtime/pprof"
"sync"
"time"
"gitea.dev/modules/graceful"
"gitea.dev/modules/log"
"gitea.dev/modules/process"
"gitea.dev/modules/queue"
"gitea.dev/modules/setting"
)
var testQueueOnce sync.Once
// initTestQueueOnce initializes the test queue for dev mode
// the test queue will also be shown in the queue list
// developers could see the queue length / worker number / items number on the admin page and try to remove the items
func initTestQueueOnce() {
testQueueOnce.Do(func() {
ctx, _, finished := process.GetManager().AddTypedContext(graceful.GetManager().ShutdownContext(), "TestQueue", process.SystemProcessType, false)
qs := setting.QueueSettings{
Name: "test-queue",
Type: "channel",
Length: 20,
BatchLength: 2,
MaxWorkers: 3,
}
testQueue, err := queue.NewWorkerPoolQueueWithContext(ctx, "test-queue", qs, func(t ...int64) (unhandled []int64) {
for range t {
select {
case <-graceful.GetManager().ShutdownContext().Done():
case <-time.After(5 * time.Second):
}
}
return nil
}, true)
if err != nil {
log.Error("unable to create test queue: %v", err)
return
}
queue.GetManager().AddManagedQueue(testQueue)
testQueue.SetWorkerMaxNumber(5)
go graceful.GetManager().RunWithCancel(testQueue)
go func() {
pprof.SetGoroutineLabels(ctx)
defer finished()
cnt := int64(0)
adding := true
for {
select {
case <-ctx.Done():
case <-time.After(500 * time.Millisecond):
if adding {
if testQueue.GetQueueItemNumber() == qs.Length {
adding = false
}
} else {
if testQueue.GetQueueItemNumber() == 0 {
adding = true
}
}
if adding {
_ = testQueue.Push(cnt)
cnt++
}
}
}
}()
})
}
+161
View File
@@ -0,0 +1,161 @@
// Copyright 2014 The Gogs Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package admin
import (
"net/http"
"net/url"
"strings"
"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 (
tplRepos templates.TplName = "admin/repo/list"
tplUnadoptedRepos templates.TplName = "admin/repo/unadopted"
)
// Repos show all the repositories
func Repos(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("admin.repositories")
ctx.Data["PageIsAdminRepositories"] = true
explore.RenderRepoSearch(ctx, &explore.RepoSearchOptions{
Private: true,
PageSize: setting.UI.Admin.RepoPagingNum,
TplName: tplRepos,
OnlyShowRelevant: false,
})
}
// DeleteRepo delete one repository
func DeleteRepo(ctx *context.Context) {
repo, err := repo_model.GetRepositoryByID(ctx, ctx.FormInt64("id"))
if err != nil {
ctx.ServerError("GetRepositoryByID", err)
return
}
if ctx.Repo != nil && ctx.Repo.GitRepo != nil && ctx.Repo.Repository != nil && ctx.Repo.Repository.ID == repo.ID {
ctx.Repo.GitRepo.Close()
}
if err := repo_service.DeleteRepository(ctx, ctx.Doer, repo, true); err != nil {
ctx.ServerError("DeleteRepository", err)
return
}
log.Trace("Repository deleted: %s", repo.FullName())
ctx.Flash.Success(ctx.Tr("repo.settings.deletion_success"))
ctx.JSONRedirect(setting.AppSubURL + "/-/admin/repos?page=" + url.QueryEscape(ctx.FormString("page")) + "&sort=" + url.QueryEscape(ctx.FormString("sort")))
}
// UnadoptedRepos lists the unadopted repositories
func UnadoptedRepos(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("admin.repositories")
ctx.Data["PageIsAdminRepositories"] = true
opts := db.ListOptions{
PageSize: setting.UI.Admin.UserPagingNum,
Page: ctx.FormInt("page"),
}
if opts.Page <= 0 {
opts.Page = 1
}
ctx.Data["CurrentPage"] = opts.Page
doSearch := ctx.FormBool("search")
ctx.Data["search"] = doSearch
q := ctx.FormString("q")
if !doSearch {
pager := context.NewPagination(0, opts.PageSize, opts.Page, 5)
pager.AddParamFromRequest(ctx.Req)
ctx.Data["Page"] = pager
ctx.HTML(http.StatusOK, tplUnadoptedRepos)
return
}
ctx.Data["Keyword"] = q
repoNames, count, err := repo_service.ListUnadoptedRepositories(ctx, q, &opts)
if err != nil {
ctx.ServerError("ListUnadoptedRepositories", err)
return
}
ctx.Data["Dirs"] = repoNames
pager := context.NewPagination(count, opts.PageSize, opts.Page, 5)
pager.AddParamFromRequest(ctx.Req)
ctx.Data["Page"] = pager
ctx.HTML(http.StatusOK, tplUnadoptedRepos)
}
// AdoptOrDeleteRepository adopts or deletes a repository
func AdoptOrDeleteRepository(ctx *context.Context) {
dir := ctx.FormString("id")
action := ctx.FormString("action")
page := ctx.FormString("page")
q := ctx.FormString("q")
dirSplit := strings.SplitN(dir, "/", 2)
if len(dirSplit) != 2 {
ctx.Redirect(setting.AppSubURL + "/-/admin/repos")
return
}
ctxUser, err := user_model.GetUserByName(ctx, dirSplit[0])
if err != nil {
if user_model.IsErrUserNotExist(err) {
log.Debug("User does not exist: %s", dirSplit[0])
ctx.Redirect(setting.AppSubURL + "/-/admin/repos")
return
}
ctx.ServerError("GetUserByName", err)
return
}
repoName := dirSplit[1]
// check not a repo
has, err := repo_model.IsRepositoryModelExist(ctx, ctxUser, repoName)
if err != nil {
ctx.ServerError("IsRepositoryExist", err)
return
}
exist, err := gitrepo.IsRepositoryExist(ctx, repo_model.StorageRepo(repo_model.RelativePath(ctxUser.Name, repoName)))
if err != nil {
ctx.ServerError("IsDir", err)
return
}
if has || !exist {
// Fallthrough to failure mode
} else if action == "adopt" {
if _, err := repo_service.AdoptRepository(ctx, ctx.Doer, ctxUser, repo_service.CreateRepoOptions{
Name: dirSplit[1],
IsPrivate: true,
}); err != nil {
ctx.ServerError("repository.AdoptRepository", err)
return
}
ctx.Flash.Success(ctx.Tr("repo.adopt_preexisting_success", dir))
} else if action == "delete" {
if err := repo_service.DeleteUnadoptedRepository(ctx, ctx.Doer, ctxUser, dirSplit[1]); err != nil {
ctx.ServerError("repository.AdoptRepository", err)
return
}
ctx.Flash.Success(ctx.Tr("repo.delete_preexisting_success", dir))
}
ctx.Redirect(setting.AppSubURL + "/-/admin/repos/unadopted?search=true&q=" + url.QueryEscape(q) + "&page=" + url.QueryEscape(page))
}
+53
View File
@@ -0,0 +1,53 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package admin
import (
"net/http"
"runtime"
"gitea.dev/modules/process"
"gitea.dev/modules/setting"
"gitea.dev/services/context"
)
func monitorTraceCommon(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("admin.monitor")
ctx.Data["PageIsAdminMonitorTrace"] = true
// Hide the performance trace tab in production, because it shows a lot of SQLs and is not that useful for end users.
// To avoid confusing end users, do not let them know this tab. End users should "download diagnosis report" instead.
ctx.Data["ShowAdminPerformanceTraceTab"] = !setting.IsProd
}
// Stacktrace show admin monitor goroutines page
func Stacktrace(ctx *context.Context) {
monitorTraceCommon(ctx)
ctx.Data["GoroutineCount"] = runtime.NumGoroutine()
show := ctx.FormString("show")
ctx.Data["ShowGoroutineList"] = show
// by default, do not do anything which might cause server errors, to avoid unnecessary 500 pages.
// this page is the entrance of the chance to collect diagnosis report.
if show != "" {
showNoSystem := show == "process"
processStacks, processCount, _, err := process.GetManager().ProcessStacktraces(false, showNoSystem)
if err != nil {
ctx.ServerError("GoroutineStacktrace", err)
return
}
ctx.Data["ProcessStacks"] = processStacks
ctx.Data["ProcessCount"] = processCount
}
ctx.HTML(http.StatusOK, tplStacktrace)
}
// StacktraceCancel cancels a process
func StacktraceCancel(ctx *context.Context) {
pid := ctx.PathParam("pid")
process.GetManager().Cancel(process.IDType(pid))
ctx.JSONRedirect(setting.AppSubURL + "/-/admin/monitor/stacktrace")
}
+553
View File
@@ -0,0 +1,553 @@
// Copyright 2014 The Gogs Authors. All rights reserved.
// Copyright 2020 The Gitea Authors.
// SPDX-License-Identifier: MIT
package admin
import (
"errors"
"net/http"
"net/url"
"strconv"
"strings"
"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 (
tplUsers templates.TplName = "admin/user/list"
tplUserNew templates.TplName = "admin/user/new"
tplUserView templates.TplName = "admin/user/view"
tplUserEdit templates.TplName = "admin/user/edit"
)
// UserSearchDefaultAdminSort is the default sort type for admin view
const UserSearchDefaultAdminSort = "alphabetically"
// Users show all the users
func Users(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("admin.users")
ctx.Data["PageIsAdminUsers"] = true
statusFilterKeys := []string{"is_active", "is_admin", "is_restricted", "is_2fa_enabled", "is_prohibit_login"}
statusFilterMap := map[string]string{}
for _, filterKey := range statusFilterKeys {
paramKey := "status_filter[" + filterKey + "]"
paramVal := ctx.FormString(paramKey)
statusFilterMap[filterKey] = paramVal
}
sortType := ctx.FormString("sort")
if sortType == "" {
sortType = UserSearchDefaultAdminSort
ctx.SetFormString("sort", sortType)
}
ctx.PageData["adminUserListSearchForm"] = map[string]any{
"StatusFilterMap": statusFilterMap,
"SortType": sortType,
}
explore.RenderUserSearch(ctx, user_model.SearchUserOptions{
Actor: ctx.Doer,
Types: []user_model.UserType{user_model.UserTypeIndividual},
ListOptions: db.ListOptions{
PageSize: setting.UI.Admin.UserPagingNum,
},
SearchByEmail: true,
IsActive: optional.ParseBool(statusFilterMap["is_active"]),
IsAdmin: optional.ParseBool(statusFilterMap["is_admin"]),
IsRestricted: optional.ParseBool(statusFilterMap["is_restricted"]),
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
}, tplUsers)
}
// NewUser render adding a new user page
func NewUser(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("admin.users.new_account")
ctx.Data["PageIsAdminUsers"] = true
ctx.Data["DefaultUserVisibilityMode"] = setting.Service.DefaultUserVisibilityMode
ctx.Data["AllowedUserVisibilityModes"] = setting.Service.AllowedUserVisibilityModesSlice.ToVisibleTypeSlice()
ctx.Data["login_type"] = "0-0"
sources, err := db.Find[auth.Source](ctx, auth.FindSourcesOptions{
IsActive: optional.Some(true),
})
if err != nil {
ctx.ServerError("auth.Sources", err)
return
}
ctx.Data["Sources"] = sources
ctx.Data["CanSendEmail"] = setting.MailService != nil
ctx.HTML(http.StatusOK, tplUserNew)
}
// NewUserPost response for adding a new user
func NewUserPost(ctx *context.Context) {
form := web.GetForm(ctx).(*forms.AdminCreateUserForm)
ctx.Data["Title"] = ctx.Tr("admin.users.new_account")
ctx.Data["PageIsAdminUsers"] = true
ctx.Data["DefaultUserVisibilityMode"] = setting.Service.DefaultUserVisibilityMode
ctx.Data["AllowedUserVisibilityModes"] = setting.Service.AllowedUserVisibilityModesSlice.ToVisibleTypeSlice()
sources, err := db.Find[auth.Source](ctx, auth.FindSourcesOptions{
IsActive: optional.Some(true),
})
if err != nil {
ctx.ServerError("auth.Sources", err)
return
}
ctx.Data["Sources"] = sources
ctx.Data["CanSendEmail"] = setting.MailService != nil
if ctx.HasError() {
ctx.HTML(http.StatusOK, tplUserNew)
return
}
u := &user_model.User{
Name: form.UserName,
Email: form.Email,
Passwd: form.Password,
LoginType: auth.Plain,
}
overwriteDefault := &user_model.CreateUserOverwriteOptions{
IsActive: optional.Some(true),
Visibility: &form.Visibility,
}
if len(form.LoginType) > 0 {
fields := strings.Split(form.LoginType, "-")
if len(fields) == 2 {
lType, _ := strconv.ParseInt(fields[0], 10, 0)
u.LoginType = auth.Type(lType)
u.LoginSource, _ = strconv.ParseInt(fields[1], 10, 64)
u.LoginName = form.LoginName
}
}
if u.LoginType == auth.NoType || u.LoginType == auth.Plain {
if len(form.Password) < setting.MinPasswordLength {
ctx.Data["Err_Password"] = true
ctx.RenderWithErrDeprecated(ctx.Tr("auth.password_too_short", setting.MinPasswordLength), tplUserNew, &form)
return
}
if !password.IsComplexEnough(form.Password) {
ctx.Data["Err_Password"] = true
ctx.RenderWithErrDeprecated(password.BuildComplexityError(ctx.Locale), tplUserNew, &form)
return
}
if err := password.IsPwned(ctx, form.Password); err != nil {
ctx.Data["Err_Password"] = true
errMsg := ctx.Tr("auth.password_pwned", "https://haveibeenpwned.com/Passwords")
if password.IsErrIsPwnedRequest(err) {
log.Error(err.Error())
errMsg = ctx.Tr("auth.password_pwned_err")
}
ctx.RenderWithErrDeprecated(errMsg, tplUserNew, &form)
return
}
u.MustChangePassword = form.MustChangePassword
}
if err := user_model.AdminCreateUser(ctx, u, &user_model.Meta{}, overwriteDefault); err != nil {
switch {
case user_model.IsErrUserAlreadyExist(err):
ctx.Data["Err_UserName"] = true
ctx.RenderWithErrDeprecated(ctx.Tr("form.username_been_taken"), tplUserNew, &form)
case user_model.IsErrEmailAlreadyUsed(err):
ctx.Data["Err_Email"] = true
ctx.RenderWithErrDeprecated(ctx.Tr("form.email_been_used"), tplUserNew, &form)
case user_model.IsErrEmailInvalid(err), user_model.IsErrEmailCharIsNotSupported(err):
ctx.Data["Err_Email"] = true
ctx.RenderWithErrDeprecated(ctx.Tr("form.email_invalid"), tplUserNew, &form)
case db.IsErrNameReserved(err):
ctx.Data["Err_UserName"] = true
ctx.RenderWithErrDeprecated(ctx.Tr("user.form.name_reserved", err.(db.ErrNameReserved).Name), tplUserNew, &form)
case db.IsErrNamePatternNotAllowed(err):
ctx.Data["Err_UserName"] = true
ctx.RenderWithErrDeprecated(ctx.Tr("user.form.name_pattern_not_allowed", err.(db.ErrNamePatternNotAllowed).Pattern), tplUserNew, &form)
case db.IsErrNameCharsNotAllowed(err):
ctx.Data["Err_UserName"] = true
ctx.RenderWithErrDeprecated(ctx.Tr("user.form.name_chars_not_allowed", err.(db.ErrNameCharsNotAllowed).Name), tplUserNew, &form)
default:
ctx.ServerError("CreateUser", err)
}
return
}
if !user_model.IsEmailDomainAllowed(u.Email) {
ctx.Flash.Warning(ctx.Tr("form.email_domain_is_not_allowed", u.Email))
}
log.Trace("Account created by admin (%s): %s", ctx.Doer.Name, u.Name)
// Send email notification.
if form.SendNotify {
mailer.SendRegisterNotifyMail(u)
}
ctx.Flash.Success(ctx.Tr("admin.users.new_success", u.Name))
ctx.Redirect(setting.AppSubURL + "/-/admin/users/" + strconv.FormatInt(u.ID, 10))
}
func prepareUserInfo(ctx *context.Context) *user_model.User {
u, err := user_model.GetUserByID(ctx, ctx.PathParamInt64("userid"))
if err != nil {
if user_model.IsErrUserNotExist(err) {
ctx.Redirect(setting.AppSubURL + "/-/admin/users")
} else {
ctx.ServerError("GetUserByID", err)
}
return nil
}
ctx.Data["User"] = u
if u.LoginSource > 0 {
ctx.Data["LoginSource"], err = auth.GetSourceByID(ctx, u.LoginSource)
if err != nil {
ctx.ServerError("auth.GetSourceByID", err)
return nil
}
} else {
ctx.Data["LoginSource"] = &auth.Source{}
}
sources, err := db.Find[auth.Source](ctx, auth.FindSourcesOptions{})
if err != nil {
ctx.ServerError("auth.Sources", err)
return nil
}
ctx.Data["Sources"] = sources
hasTOTP, err := auth.HasTwoFactorByUID(ctx, u.ID)
if err != nil {
ctx.ServerError("auth.HasTwoFactorByUID", err)
return nil
}
hasWebAuthn, err := auth.HasWebAuthnRegistrationsByUID(ctx, u.ID)
if err != nil {
ctx.ServerError("auth.HasWebAuthnRegistrationsByUID", err)
return nil
}
ctx.Data["TwoFactorEnabled"] = hasTOTP || hasWebAuthn
return u
}
func ViewUser(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("admin.users.details")
ctx.Data["PageIsAdminUsers"] = true
ctx.Data["DisableRegularOrgCreation"] = setting.Admin.DisableRegularOrgCreation
ctx.Data["DisableMigrations"] = setting.Repository.DisableMigrations
ctx.Data["AllowedUserVisibilityModes"] = setting.Service.AllowedUserVisibilityModesSlice.ToVisibleTypeSlice()
u := prepareUserInfo(ctx)
if ctx.Written() {
return
}
repos, count, err := repo_model.SearchRepository(ctx, repo_model.SearchRepoOptions{
ListOptions: db.ListOptionsAll,
OwnerID: u.ID,
OrderBy: db.SearchOrderByAlphabetically,
Private: true,
Collaborate: optional.Some(false),
})
if err != nil {
ctx.ServerError("SearchRepository", err)
return
}
ctx.Data["Repos"] = repos
ctx.Data["ReposTotal"] = int(count)
emails, err := user_model.GetEmailAddresses(ctx, u.ID)
if err != nil {
ctx.ServerError("GetEmailAddresses", err)
return
}
ctx.Data["Emails"] = emails
ctx.Data["EmailsTotal"] = len(emails)
orgs, err := db.Find[org_model.Organization](ctx, org_model.FindOrgOptions{
ListOptions: db.ListOptionsAll,
UserID: u.ID,
IncludeVisibility: structs.VisibleTypePrivate,
})
if err != nil {
ctx.ServerError("FindOrgs", err)
return
}
ctx.Data["Users"] = orgs // needed to be able to use explore/user_list template
ctx.Data["OrgsTotal"] = len(orgs)
ctx.HTML(http.StatusOK, tplUserView)
}
func editUserCommon(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("admin.users.edit_account")
ctx.Data["PageIsAdminUsers"] = true
ctx.Data["DisableRegularOrgCreation"] = setting.Admin.DisableRegularOrgCreation
ctx.Data["DisableMigrations"] = setting.Repository.DisableMigrations
ctx.Data["DisableGitHooks"] = setting.DisableGitHooks
ctx.Data["DisableImportLocal"] = !setting.ImportLocalPaths
ctx.Data["AllowedUserVisibilityModes"] = setting.Service.AllowedUserVisibilityModesSlice.ToVisibleTypeSlice()
ctx.Data["DisableGravatar"] = setting.Config().Picture.DisableGravatar.Value(ctx)
}
// EditUser show editing user page
func EditUser(ctx *context.Context) {
editUserCommon(ctx)
prepareUserInfo(ctx)
if ctx.Written() {
return
}
ctx.HTML(http.StatusOK, tplUserEdit)
}
// EditUserPost response for editing user
func EditUserPost(ctx *context.Context) {
editUserCommon(ctx)
u := prepareUserInfo(ctx)
if ctx.Written() {
return
}
form := web.GetForm(ctx).(*forms.AdminEditUserForm)
if ctx.HasError() {
ctx.HTML(http.StatusOK, tplUserEdit)
return
}
if form.UserName != "" {
if err := user_service.RenameUser(ctx, u, form.UserName, ctx.Doer); err != nil {
switch {
case user_model.IsErrUserIsNotLocal(err):
ctx.Data["Err_UserName"] = true
ctx.RenderWithErrDeprecated(ctx.Tr("form.username_change_not_local_user"), tplUserEdit, &form)
case user_model.IsErrUserAlreadyExist(err):
ctx.Data["Err_UserName"] = true
ctx.RenderWithErrDeprecated(ctx.Tr("form.username_been_taken"), tplUserEdit, &form)
case db.IsErrNameReserved(err):
ctx.Data["Err_UserName"] = true
ctx.RenderWithErrDeprecated(ctx.Tr("user.form.name_reserved", form.UserName), tplUserEdit, &form)
case db.IsErrNamePatternNotAllowed(err):
ctx.Data["Err_UserName"] = true
ctx.RenderWithErrDeprecated(ctx.Tr("user.form.name_pattern_not_allowed", form.UserName), tplUserEdit, &form)
case db.IsErrNameCharsNotAllowed(err):
ctx.Data["Err_UserName"] = true
ctx.RenderWithErrDeprecated(ctx.Tr("user.form.name_chars_not_allowed", form.UserName), tplUserEdit, &form)
default:
ctx.ServerError("RenameUser", err)
}
return
}
}
authOpts := &user_service.UpdateAuthOptions{
Password: optional.FromNonDefault(form.Password),
LoginName: optional.Some(form.LoginName),
}
// skip self Prohibit Login
if ctx.Doer.ID == u.ID {
authOpts.ProhibitLogin = optional.Some(false)
} else {
authOpts.ProhibitLogin = optional.Some(form.ProhibitLogin)
}
fields := strings.Split(form.LoginType, "-")
if len(fields) == 2 {
authSource, _ := strconv.ParseInt(fields[1], 10, 64)
authOpts.LoginSource = optional.Some(authSource)
}
if err := user_service.UpdateAuth(ctx, u, authOpts); err != nil {
switch {
case errors.Is(err, password.ErrMinLength):
ctx.Data["Err_Password"] = true
ctx.RenderWithErrDeprecated(ctx.Tr("auth.password_too_short", setting.MinPasswordLength), tplUserEdit, &form)
case errors.Is(err, password.ErrComplexity):
ctx.Data["Err_Password"] = true
ctx.RenderWithErrDeprecated(password.BuildComplexityError(ctx.Locale), tplUserEdit, &form)
case errors.Is(err, password.ErrIsPwned):
ctx.Data["Err_Password"] = true
ctx.RenderWithErrDeprecated(ctx.Tr("auth.password_pwned", "https://haveibeenpwned.com/Passwords"), tplUserEdit, &form)
case password.IsErrIsPwnedRequest(err):
ctx.Data["Err_Password"] = true
ctx.RenderWithErrDeprecated(ctx.Tr("auth.password_pwned_err"), tplUserEdit, &form)
default:
ctx.ServerError("UpdateUser", err)
}
return
}
if form.Email != "" {
if err := user_service.ReplacePrimaryEmailAddress(ctx, u, form.Email); err != nil {
switch {
case user_model.IsErrEmailCharIsNotSupported(err), user_model.IsErrEmailInvalid(err):
ctx.Data["Err_Email"] = true
ctx.RenderWithErrDeprecated(ctx.Tr("form.email_invalid"), tplUserEdit, &form)
case user_model.IsErrEmailAlreadyUsed(err):
ctx.Data["Err_Email"] = true
ctx.RenderWithErrDeprecated(ctx.Tr("form.email_been_used"), tplUserEdit, &form)
default:
ctx.ServerError("AddOrSetPrimaryEmailAddress", err)
}
return
}
if !user_model.IsEmailDomainAllowed(form.Email) {
ctx.Flash.Warning(ctx.Tr("form.email_domain_is_not_allowed", form.Email))
}
}
opts := &user_service.UpdateOptions{
FullName: optional.Some(form.FullName),
Website: optional.Some(form.Website),
Location: optional.Some(form.Location),
IsActive: optional.Some(form.Active),
IsAdmin: user_service.UpdateOptionFieldFromValue(form.Admin),
AllowGitHook: optional.Some(form.AllowGitHook),
AllowImportLocal: optional.Some(form.AllowImportLocal),
MaxRepoCreation: optional.Some(form.MaxRepoCreation),
AllowCreateOrganization: optional.Some(form.AllowCreateOrganization),
IsRestricted: optional.Some(form.Restricted),
Visibility: optional.Some(form.Visibility),
Language: optional.Some(form.Language),
}
if err := user_service.UpdateUser(ctx, u, opts); err != nil {
if user_model.IsErrDeleteLastAdminUser(err) {
ctx.RenderWithErrDeprecated(ctx.Tr("auth.last_admin"), tplUserEdit, &form)
} else {
ctx.ServerError("UpdateUser", err)
}
return
}
log.Trace("Account profile updated by admin (%s): %s", ctx.Doer.Name, u.Name)
if form.Reset2FA {
tf, err := auth.GetTwoFactorByUID(ctx, u.ID)
if err != nil && !auth.IsErrTwoFactorNotEnrolled(err) {
ctx.ServerError("auth.GetTwoFactorByUID", err)
return
} else if tf != nil {
if err := auth.DeleteTwoFactorByID(ctx, tf.ID, u.ID); err != nil {
ctx.ServerError("auth.DeleteTwoFactorByID", err)
return
}
}
wn, err := auth.GetWebAuthnCredentialsByUID(ctx, u.ID)
if err != nil {
ctx.ServerError("auth.GetTwoFactorByUID", err)
return
}
for _, cred := range wn {
if _, err := auth.DeleteCredential(ctx, cred.ID, u.ID); err != nil {
ctx.ServerError("auth.DeleteCredential", err)
return
}
}
}
ctx.Flash.Success(ctx.Tr("admin.users.update_profile_success"))
ctx.Redirect(setting.AppSubURL + "/-/admin/users/" + url.PathEscape(ctx.PathParam("userid")))
}
// DeleteUser response for deleting a user
func DeleteUser(ctx *context.Context) {
u, err := user_model.GetUserByID(ctx, ctx.PathParamInt64("userid"))
if err != nil {
ctx.ServerError("GetUserByID", err)
return
}
// admin should not delete themself
if u.ID == ctx.Doer.ID {
ctx.Flash.Error(ctx.Tr("admin.users.cannot_delete_self"))
ctx.Redirect(setting.AppSubURL + "/-/admin/users/" + url.PathEscape(ctx.PathParam("userid")))
return
}
if err = user_service.DeleteUser(ctx, u, ctx.FormBool("purge")); err != nil {
switch {
case repo_model.IsErrUserOwnRepos(err):
ctx.Flash.Error(ctx.Tr("admin.users.still_own_repo"))
ctx.Redirect(setting.AppSubURL + "/-/admin/users/" + url.PathEscape(ctx.PathParam("userid")))
case org_model.IsErrUserHasOrgs(err):
ctx.Flash.Error(ctx.Tr("admin.users.still_has_org"))
ctx.Redirect(setting.AppSubURL + "/-/admin/users/" + url.PathEscape(ctx.PathParam("userid")))
case packages_model.IsErrUserOwnPackages(err):
ctx.Flash.Error(ctx.Tr("admin.users.still_own_packages"))
ctx.Redirect(setting.AppSubURL + "/-/admin/users/" + url.PathEscape(ctx.PathParam("userid")))
case user_model.IsErrDeleteLastAdminUser(err):
ctx.Flash.Error(ctx.Tr("auth.last_admin"))
ctx.Redirect(setting.AppSubURL + "/-/admin/users/" + url.PathEscape(ctx.PathParam("userid")))
default:
ctx.ServerError("DeleteUser", err)
}
return
}
log.Trace("Account deleted by admin (%s): %s", ctx.Doer.Name, u.Name)
ctx.Flash.Success(ctx.Tr("admin.users.deletion_success"))
ctx.Redirect(setting.AppSubURL + "/-/admin/users")
}
// AvatarPost response for change user's avatar request
func AvatarPost(ctx *context.Context) {
u := prepareUserInfo(ctx)
if ctx.Written() {
return
}
form := web.GetForm(ctx).(*forms.AvatarForm)
if err := user_setting.UpdateAvatarSetting(ctx, form, u); err != nil {
ctx.Flash.Error(err.Error())
} else {
ctx.Flash.Success(ctx.Tr("settings.update_user_avatar_success"))
}
ctx.Redirect(setting.AppSubURL + "/-/admin/users/" + strconv.FormatInt(u.ID, 10))
}
// DeleteAvatar render delete avatar page
func DeleteAvatar(ctx *context.Context) {
u := prepareUserInfo(ctx)
if ctx.Written() {
return
}
if err := user_service.DeleteAvatar(ctx, u); err != nil {
ctx.Flash.Error(err.Error())
}
ctx.JSONRedirect(setting.AppSubURL + "/-/admin/users/" + strconv.FormatInt(u.ID, 10))
}
+199
View File
@@ -0,0 +1,199 @@
// Copyright 2017 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package admin
import (
"testing"
"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"
)
func TestNewUserPost_MustChangePassword(t *testing.T) {
unittest.PrepareTestEnv(t)
ctx, _ := contexttest.MockContext(t, "admin/users/new")
u := unittest.AssertExistsAndLoadBean(t, &user_model.User{
IsAdmin: true,
ID: 2,
})
ctx.Doer = u
username := "gitea"
email := "gitea@gitea.io"
form := forms.AdminCreateUserForm{
LoginType: "local",
LoginName: "local",
UserName: username,
Email: email,
Password: "abc123ABC!=$",
SendNotify: false,
MustChangePassword: true,
}
web.SetForm(ctx, &form)
NewUserPost(ctx)
assert.NotEmpty(t, ctx.Flash.SuccessMsg)
u, err := user_model.GetUserByName(ctx, username)
assert.NoError(t, err)
assert.Equal(t, username, u.Name)
assert.Equal(t, email, u.Email)
assert.True(t, u.MustChangePassword)
}
func TestNewUserPost_MustChangePasswordFalse(t *testing.T) {
unittest.PrepareTestEnv(t)
ctx, _ := contexttest.MockContext(t, "admin/users/new")
u := unittest.AssertExistsAndLoadBean(t, &user_model.User{
IsAdmin: true,
ID: 2,
})
ctx.Doer = u
username := "gitea"
email := "gitea@gitea.io"
form := forms.AdminCreateUserForm{
LoginType: "local",
LoginName: "local",
UserName: username,
Email: email,
Password: "abc123ABC!=$",
SendNotify: false,
MustChangePassword: false,
}
web.SetForm(ctx, &form)
NewUserPost(ctx)
assert.NotEmpty(t, ctx.Flash.SuccessMsg)
u, err := user_model.GetUserByName(ctx, username)
assert.NoError(t, err)
assert.Equal(t, username, u.Name)
assert.Equal(t, email, u.Email)
assert.False(t, u.MustChangePassword)
}
func TestNewUserPost_InvalidEmail(t *testing.T) {
unittest.PrepareTestEnv(t)
ctx, _ := contexttest.MockContext(t, "admin/users/new")
u := unittest.AssertExistsAndLoadBean(t, &user_model.User{
IsAdmin: true,
ID: 2,
})
ctx.Doer = u
username := "gitea"
email := "gitea@gitea.io\r\n"
form := forms.AdminCreateUserForm{
LoginType: "local",
LoginName: "local",
UserName: username,
Email: email,
Password: "abc123ABC!=$",
SendNotify: false,
MustChangePassword: false,
}
web.SetForm(ctx, &form)
NewUserPost(ctx)
assert.NotEmpty(t, ctx.Flash.ErrorMsg)
}
func TestNewUserPost_VisibilityDefaultPublic(t *testing.T) {
unittest.PrepareTestEnv(t)
ctx, _ := contexttest.MockContext(t, "admin/users/new")
u := unittest.AssertExistsAndLoadBean(t, &user_model.User{
IsAdmin: true,
ID: 2,
})
ctx.Doer = u
username := "gitea"
email := "gitea@gitea.io"
form := forms.AdminCreateUserForm{
LoginType: "local",
LoginName: "local",
UserName: username,
Email: email,
Password: "abc123ABC!=$",
SendNotify: false,
MustChangePassword: false,
}
web.SetForm(ctx, &form)
NewUserPost(ctx)
assert.NotEmpty(t, ctx.Flash.SuccessMsg)
u, err := user_model.GetUserByName(ctx, username)
assert.NoError(t, err)
assert.Equal(t, username, u.Name)
assert.Equal(t, email, u.Email)
// As default user visibility
assert.Equal(t, setting.Service.DefaultUserVisibilityMode, u.Visibility)
}
func TestNewUserPost_VisibilityPrivate(t *testing.T) {
unittest.PrepareTestEnv(t)
ctx, _ := contexttest.MockContext(t, "admin/users/new")
u := unittest.AssertExistsAndLoadBean(t, &user_model.User{
IsAdmin: true,
ID: 2,
})
ctx.Doer = u
username := "gitea"
email := "gitea@gitea.io"
form := forms.AdminCreateUserForm{
LoginType: "local",
LoginName: "local",
UserName: username,
Email: email,
Password: "abc123ABC!=$",
SendNotify: false,
MustChangePassword: false,
Visibility: api.VisibleTypePrivate,
}
web.SetForm(ctx, &form)
NewUserPost(ctx)
assert.NotEmpty(t, ctx.Flash.SuccessMsg)
u, err := user_model.GetUserByName(ctx, username)
assert.NoError(t, err)
assert.Equal(t, username, u.Name)
assert.Equal(t, email, u.Email)
// As default user visibility
assert.True(t, u.Visibility.IsPrivate())
}
+164
View File
@@ -0,0 +1,164 @@
// Copyright 2017 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package auth
import (
"errors"
"net/http"
"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 (
tplTwofa templates.TplName = "user/auth/twofa"
tplTwofaScratch templates.TplName = "user/auth/twofa_scratch"
)
// TwoFactor shows the user a two-factor authentication page.
func TwoFactor(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("twofa")
if performAutoLogin(ctx) {
return
}
// Ensure user is in a 2FA session.
if ctx.Session.Get("twofaUid") == nil {
ctx.ServerError("UserSignIn", errors.New("not in 2FA session"))
return
}
ctx.HTML(http.StatusOK, tplTwofa)
}
// TwoFactorPost validates a user's two-factor authentication token.
func TwoFactorPost(ctx *context.Context) {
form := web.GetForm(ctx).(*forms.TwoFactorAuthForm)
ctx.Data["Title"] = ctx.Tr("twofa")
// Ensure user is in a 2FA session.
idSess := ctx.Session.Get("twofaUid")
if idSess == nil {
ctx.ServerError("UserSignIn", errors.New("not in 2FA session"))
return
}
id := idSess.(int64)
twofa, err := auth.GetTwoFactorByUID(ctx, id)
if err != nil {
ctx.ServerError("UserSignIn", err)
return
}
// Validate the passcode with the stored TOTP secret.
ok, err := twofa.ValidateTOTP(form.Passcode)
if err != nil {
ctx.ServerError("UserSignIn", err)
return
}
if ok && twofa.LastUsedPasscode != form.Passcode {
remember := ctx.Session.Get("twofaRemember").(bool)
u, err := user_model.GetUserByID(ctx, id)
if err != nil {
ctx.ServerError("UserSignIn", err)
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)
return
}
_ = ctx.Session.Set(session.KeyUserHasTwoFactorAuth, true)
handleSignIn(ctx, u, remember)
return
}
ctx.RenderWithErrDeprecated(ctx.Tr("auth.twofa_passcode_incorrect"), tplTwofa, forms.TwoFactorAuthForm{})
}
// TwoFactorScratch shows the scratch code form for two-factor authentication.
func TwoFactorScratch(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("twofa_scratch")
if performAutoLogin(ctx) {
return
}
// Ensure user is in a 2FA session.
if ctx.Session.Get("twofaUid") == nil {
ctx.ServerError("UserSignIn", errors.New("not in 2FA session"))
return
}
ctx.HTML(http.StatusOK, tplTwofaScratch)
}
// TwoFactorScratchPost validates and invalidates a user's two-factor scratch token.
func TwoFactorScratchPost(ctx *context.Context) {
form := web.GetForm(ctx).(*forms.TwoFactorScratchAuthForm)
ctx.Data["Title"] = ctx.Tr("twofa_scratch")
// Ensure user is in a 2FA session.
idSess := ctx.Session.Get("twofaUid")
if idSess == nil {
ctx.ServerError("UserSignIn", errors.New("not in 2FA session"))
return
}
id := idSess.(int64)
twofa, err := auth.GetTwoFactorByUID(ctx, id)
if err != nil {
ctx.ServerError("UserSignIn", err)
return
}
// Validate the passcode with the stored TOTP secret.
if twofa.VerifyScratchToken(form.Token) {
// Invalidate the scratch token.
_, err = twofa.GenerateScratchToken()
if err != nil {
ctx.ServerError("UserSignIn", err)
return
}
if err = auth.UpdateTwoFactor(ctx, twofa); err != nil {
ctx.ServerError("UserSignIn", err)
return
}
remember := ctx.Session.Get("twofaRemember").(bool)
u, err := user_model.GetUserByID(ctx, id)
if err != nil {
ctx.ServerError("UserSignIn", err)
return
}
handleSignInFull(ctx, u, remember)
if ctx.Written() {
return
}
ctx.Flash.Info(ctx.Tr("auth.twofa_scratch_used"))
ctx.Redirect(setting.AppSubURL + "/user/settings/security")
return
}
ctx.RenderWithErrDeprecated(ctx.Tr("auth.twofa_scratch_token_incorrect"), tplTwofaScratch, forms.TwoFactorScratchAuthForm{})
}
+954
View File
@@ -0,0 +1,954 @@
// Copyright 2014 The Gogs Authors. All rights reserved.
// Copyright 2018 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package auth
import (
"errors"
"fmt"
"html/template"
"net/http"
"net/url"
"strings"
"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"
)
const (
tplSignIn templates.TplName = "user/auth/signin" // for sign in page
tplSignUp templates.TplName = "user/auth/signup" // for sign up page
TplActivate templates.TplName = "user/auth/activate" // for activate user
TplActivatePrompt templates.TplName = "user/auth/activate_prompt" // for showing a message for user activation
)
type CommonAuthOptions struct {
EnableCaptcha bool
}
func prepareCommonAuthPageData(ctx *context.Context, opt CommonAuthOptions) {
ctx.Data["EnablePasswordSignInForm"] = setting.Service.EnablePasswordSignInForm
ctx.Data["EnablePasskeyAuth"] = setting.Service.EnablePasskeyAuth
// for OpenID Connect
ctx.Data["EnableOpenIDSignUp"] = setting.Service.EnableOpenIDSignUp
ctx.Data["AllowOnlyInternalRegistration"] = setting.Service.AllowOnlyInternalRegistration
if opt.EnableCaptcha {
ctx.Data["EnableCaptcha"] = true
ctx.Data["RecaptchaAPIScriptURL"] = strings.TrimSuffix(setting.Service.RecaptchaURL, "/") + "/api.js"
ctx.Data["CaptchaType"] = setting.Service.CaptchaType
ctx.Data["RecaptchaSitekey"] = setting.Service.RecaptchaSitekey
ctx.Data["HcaptchaSitekey"] = setting.Service.HcaptchaSitekey
ctx.Data["McaptchaSitekey"] = setting.Service.McaptchaSitekey
ctx.Data["McaptchaURL"] = strings.TrimSuffix(setting.Service.McaptchaURL, "/")
ctx.Data["CfTurnstileSitekey"] = setting.Service.CfTurnstileSitekey
if setting.Service.CaptchaType == setting.ImageCaptcha {
ctx.Data["Captcha"] = context.GetImageCaptcha()
}
}
}
// autoSignIn reads cookie and try to auto-login.
func autoSignIn(ctx *context.Context) (bool, error) {
isSucceed := false
defer func() {
if !isSucceed {
ctx.DeleteSiteCookie(setting.CookieRememberName)
}
}()
if err := auth.DeleteExpiredAuthTokens(ctx); err != nil {
log.Error("Failed to delete expired auth tokens: %v", err)
}
t, err := auth_service.CheckAuthToken(ctx, ctx.GetSiteCookie(setting.CookieRememberName))
if err != nil {
switch err {
case auth_service.ErrAuthTokenInvalidFormat, auth_service.ErrAuthTokenExpired:
return false, nil
}
return false, err
}
if t == nil {
return false, nil
}
u, err := user_model.GetUserByID(ctx, t.UserID)
if err != nil {
if !user_model.IsErrUserNotExist(err) {
return false, fmt.Errorf("GetUserByID: %w", err)
}
return false, nil
}
userHasTwoFactorAuth, err := auth.HasTwoFactorOrWebAuthn(ctx, u.ID)
if err != nil {
return false, fmt.Errorf("HasTwoFactorOrWebAuthn: %w", err)
}
isSucceed = true
nt, token, err := auth_service.RegenerateAuthToken(ctx, t)
if err != nil {
return false, err
}
ctx.SetSiteCookie(setting.CookieRememberName, nt.ID+":"+token, setting.LogInRememberDays*timeutil.Day)
if err := updateSession(ctx, nil, map[string]any{
session.KeyUID: u.ID,
session.KeyUname: u.Name,
session.KeyUserHasTwoFactorAuth: userHasTwoFactorAuth,
}); err != nil {
return false, fmt.Errorf("unable to updateSession: %w", err)
}
if err := resetLocale(ctx, u); err != nil {
return false, err
}
return true, nil
}
func resetLocale(ctx *context.Context, u *user_model.User) error {
// Language setting of the user overwrites the one previously set
// If the user does not have a locale set, we save the current one.
if u.Language == "" {
opts := &user_service.UpdateOptions{
Language: optional.Some(ctx.Locale.Language()),
}
if err := user_service.UpdateUser(ctx, u, opts); err != nil {
return err
}
}
middleware.SetLocaleCookie(ctx.Resp, u.Language, 0)
if ctx.Locale.Language() != u.Language {
ctx.Locale = middleware.Locale(ctx.Resp, ctx.Req)
}
return nil
}
func rememberAuthRedirectLink(ctx *context.Context) {
redirectTo := ctx.FormString("redirect_to")
if redirectTo == "" {
if ref, err := url.Parse(ctx.Req.Referer()); err == nil && httplib.IsCurrentGiteaSiteURL(ctx, ctx.Req.Referer()) {
// the request paths starting with "/user/" are either:
// * auth related pages: don't redirect back to them
// * user settings pages: they have "require sign-in" protection already, no "referer redirect" would happen
skipRefererRedirect := strings.HasPrefix(ref.Path, setting.AppSubURL+"/user/")
if !skipRefererRedirect {
redirectTo = ref.RequestURI()
}
}
}
if redirectTo != "" {
middleware.SetRedirectToCookie(ctx.Resp, redirectTo)
}
}
func consumeAuthRedirectLink(ctx *context.Context) string {
redirects := []string{ctx.FormString("redirect_to"), middleware.GetRedirectToCookie(ctx.Req)}
middleware.DeleteRedirectToCookie(ctx.Resp)
if setting.LandingPageURL == setting.LandingPageLogin {
redirects = append(redirects, setting.AppSubURL+"/") // do not cycle-redirect to the login page
} else {
redirects = append(redirects, setting.AppSubURL+string(setting.LandingPageURL))
}
for _, link := range redirects {
if link != "" && httplib.IsCurrentGiteaSiteURL(ctx, link) {
return link
}
}
return setting.AppSubURL + "/"
}
func redirectAfterAuth(ctx *context.Context) {
if setting.Config().Instance.MaintenanceMode.Value(ctx).IsActive() {
// in maintenance mode, redirect to admin dashboard, it is the only accessible page
ctx.Redirect(setting.AppSubURL + "/-/admin")
return
}
ctx.RedirectToCurrentSite(consumeAuthRedirectLink(ctx))
}
func performAutoLogin(ctx *context.Context) bool {
rememberAuthRedirectLink(ctx)
isSucceed, err := autoSignIn(ctx) // try to auto-login
if err != nil {
if errors.Is(err, auth_service.ErrAuthTokenInvalidHash) {
ctx.Flash.Error(ctx.Tr("auth.remember_me.compromised"), true)
return false
}
ctx.ServerError("autoSignIn", err)
return true
}
if isSucceed {
redirectAfterAuth(ctx)
return true
}
return false
}
func performAutoLoginOAuth2(ctx *context.Context, data *preparedSignInData) bool {
// If only 1 OAuth provider is present and other login methods are disabled, redirect to the OAuth provider.
onlySingleOAuth2 := len(data.oauth2Providers) == 1 &&
!setting.Service.EnablePasswordSignInForm &&
!setting.Service.EnableOpenIDSignIn &&
!setting.Service.EnablePasskeyAuth &&
!data.enableSSPI
if !onlySingleOAuth2 {
return false
}
skipToOAuthURL := setting.AppSubURL + "/user/oauth2/" + url.PathEscape(data.oauth2Providers[0].DisplayName())
if redirectTo := ctx.FormString("redirect_to"); redirectTo != "" {
skipToOAuthURL += "?redirect_to=" + url.QueryEscape(redirectTo)
}
ctx.Redirect(skipToOAuthURL)
return true
}
type preparedSignInData struct {
oauth2Providers []oauth2.Provider
enableSSPI bool
}
func prepareSignInPageData(ctx *context.Context) (ret preparedSignInData) {
var err error
ret.enableSSPI = auth.IsSSPIEnabled(ctx)
ret.oauth2Providers, err = oauth2.GetOAuth2Providers(ctx, optional.Some(true))
if err != nil {
log.Error("Failed to get OAuth2 providers: %v", err)
}
ctx.Data["Title"] = ctx.Tr("sign_in")
ctx.Data["OAuth2Providers"] = ret.oauth2Providers
ctx.Data["Title"] = ctx.Tr("sign_in")
ctx.Data["SignInLink"] = setting.AppSubURL + "/user/login"
ctx.Data["PageIsSignIn"] = true
ctx.Data["PageIsLogin"] = true
ctx.Data["EnableSSPI"] = ret.enableSSPI
prepareCommonAuthPageData(ctx, CommonAuthOptions{
EnableCaptcha: setting.Service.EnableCaptcha && setting.Service.RequireCaptchaForLogin,
})
return ret
}
// SignIn render sign in page
func SignIn(ctx *context.Context) {
if performAutoLogin(ctx) {
return
}
if ctx.IsSigned {
redirectAfterAuth(ctx)
return
}
data := prepareSignInPageData(ctx)
if performAutoLoginOAuth2(ctx, &data) {
return
}
ctx.HTML(http.StatusOK, tplSignIn)
}
// SignInPost response for sign in request
func SignInPost(ctx *context.Context) {
if !setting.Service.EnablePasswordSignInForm {
ctx.HTTPError(http.StatusForbidden)
return
}
prepareSignInPageData(ctx)
if ctx.HasError() {
ctx.HTML(http.StatusOK, tplSignIn)
return
}
form := web.GetForm(ctx).(*forms.SignInForm)
if setting.Service.EnableCaptcha && setting.Service.RequireCaptchaForLogin {
context.VerifyCaptcha(ctx, tplSignIn, form)
if ctx.Written() {
return
}
}
u, source, err := auth_service.UserSignIn(ctx, form.UserName, form.Password)
if err != nil {
if errors.Is(err, util.ErrNotExist) || errors.Is(err, util.ErrInvalidArgument) {
ctx.RenderWithErrDeprecated(ctx.Tr("form.username_password_incorrect"), tplSignIn, &form)
log.Warn("Failed authentication attempt for %s from %s: %v", form.UserName, ctx.RemoteAddr(), err)
} else if user_model.IsErrEmailAlreadyUsed(err) {
ctx.RenderWithErrDeprecated(ctx.Tr("form.email_been_used"), tplSignIn, &form)
log.Warn("Failed authentication attempt for %s from %s: %v", form.UserName, ctx.RemoteAddr(), err)
} else if user_model.IsErrUserProhibitLogin(err) {
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)
}
return
}
// Now handle 2FA:
// First of all if the source can skip local two fa we're done
if source.TwoFactorShouldSkip() {
handleSignIn(ctx, u, form.Remember)
return
}
// 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)
if err != nil {
ctx.ServerError("UserSignIn", 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
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)
return
}
// If we have WebAuthn redirect there first
if hasWebAuthnTwofa {
ctx.Redirect(setting.AppSubURL + "/user/webauthn")
return
}
// Fallback to 2FA
ctx.Redirect(setting.AppSubURL + "/user/two_factor")
}
// This handles the final part of the sign-in process of the user.
func handleSignIn(ctx *context.Context, u *user_model.User, remember bool) {
handleSignInFull(ctx, u, remember)
if ctx.Written() {
return
}
redirectAfterAuth(ctx)
}
func handleSignInFull(ctx *context.Context, u *user_model.User, remember bool) {
if remember {
nt, token, err := auth_service.CreateAuthTokenForUserID(ctx, u.ID)
if err != nil {
ctx.ServerError("CreateAuthTokenForUserID", err)
return
}
ctx.SetSiteCookie(setting.CookieRememberName, nt.ID+":"+token, setting.LogInRememberDays*timeutil.Day)
}
userHasTwoFactorAuth, err := auth.HasTwoFactorOrWebAuthn(ctx, u.ID)
if err != nil {
ctx.ServerError("HasTwoFactorOrWebAuthn", err)
return
}
if err := updateSession(ctx, []string{
// Delete the openid, 2fa and link_account data
"openid_verified_uri",
"openid_signin_remember",
"openid_determined_email",
"openid_determined_username",
"twofaUid",
"twofaRemember",
"linkAccount",
"linkAccountData",
}, map[string]any{
session.KeyUID: u.ID,
session.KeyUname: u.Name,
session.KeyUserHasTwoFactorAuth: userHasTwoFactorAuth,
}); err != nil {
ctx.ServerError("RegenerateSession", err)
return
}
// Language setting of the user overwrites the one previously set
// If the user does not have a locale set, we save the current one.
if u.Language == "" {
opts := &user_service.UpdateOptions{
Language: optional.Some(ctx.Locale.Language()),
}
if err := user_service.UpdateUser(ctx, u, opts); err != nil {
ctx.ServerError("UpdateUser Language", fmt.Errorf("Error updating user language [user: %d, locale: %s]", u.ID, ctx.Locale.Language()))
return
}
}
middleware.SetLocaleCookie(ctx.Resp, u.Language, 0)
if ctx.Locale.Language() != u.Language {
ctx.Locale = middleware.Locale(ctx.Resp, ctx.Req)
}
// Register last login
if err := user_service.UpdateUser(ctx, u, &user_service.UpdateOptions{SetLastLogin: true}); err != nil {
ctx.ServerError("UpdateUser", err)
return
}
}
// extractUserNameFromOAuth2 tries to extract a normalized username from the given OAuth2 user.
// It returns ("", nil) if the required field doesn't exist.
func extractUserNameFromOAuth2(gothUser *goth.User) (string, error) {
switch setting.OAuth2Client.Username {
case setting.OAuth2UsernameEmail:
return user_model.NormalizeUserName(gothUser.Email)
case setting.OAuth2UsernamePreferredUsername:
if preferredUsername, ok := gothUser.RawData["preferred_username"].(string); ok {
return user_model.NormalizeUserName(preferredUsername)
}
return "", nil
case setting.OAuth2UsernameNickname:
return user_model.NormalizeUserName(gothUser.NickName)
default: // OAuth2UsernameUserid
return gothUser.UserID, nil
}
}
// HandleSignOut resets the session and sets the cookies
func HandleSignOut(ctx *context.Context) {
_ = ctx.Session.Flush()
_ = ctx.Session.Destroy(ctx.Resp, ctx.Req)
ctx.DeleteSiteCookie(setting.CookieRememberName)
middleware.DeleteRedirectToCookie(ctx.Resp)
}
// SignOut sign out from login status
func SignOut(ctx *context.Context) {
if ctx.Doer != nil {
eventsource.GetManager().SendMessageBlocking(ctx.Doer.ID, &eventsource.Event{
Name: "logout",
Data: ctx.Session.ID(),
})
}
// prepare the sign-out URL before destroying the session
redirectTo := buildSignOutRedirectURL(ctx)
HandleSignOut(ctx)
ctx.Redirect(redirectTo)
}
func buildSignOutRedirectURL(ctx *context.Context) string {
if ctx.Doer != nil && ctx.Doer.LoginType == auth.OAuth2 {
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 + "/"
}
func prepareSignUpPageData(ctx *context.Context) bool {
ctx.Data["Title"] = ctx.Tr("sign_up")
ctx.Data["SignUpLink"] = setting.AppSubURL + "/user/sign_up"
ctx.Data["PageIsSignUp"] = true
ctx.Data["EnableSSPI"] = auth.IsSSPIEnabled(ctx)
hasUsers, err := user_model.HasUsers(ctx)
if err != nil {
ctx.ServerError("HasUsers", err)
return false
}
ctx.Data["IsFirstTimeRegistration"] = !hasUsers.HasAnyUser
oauth2Providers, err := oauth2.GetOAuth2Providers(ctx, optional.Some(true))
if err != nil {
ctx.ServerError("GetOAuth2Providers", err)
return false
}
ctx.Data["OAuth2Providers"] = oauth2Providers
prepareCommonAuthPageData(ctx, CommonAuthOptions{
EnableCaptcha: setting.Service.EnableCaptcha,
})
// Show Disabled Registration message if DisableRegistration or AllowOnlyExternalRegistration options are true
ctx.Data["DisableRegistration"] = setting.Service.DisableRegistration || setting.Service.AllowOnlyExternalRegistration
return true
}
// SignUp render the register page
func SignUp(ctx *context.Context) {
if !prepareSignUpPageData(ctx) {
return
}
rememberAuthRedirectLink(ctx)
ctx.HTML(http.StatusOK, tplSignUp)
}
// SignUpPost response for sign up information submission
func SignUpPost(ctx *context.Context) {
if !prepareSignUpPageData(ctx) {
return
}
form := web.GetForm(ctx).(*forms.RegisterForm)
// Permission denied if DisableRegistration or AllowOnlyExternalRegistration options are true
if setting.Service.DisableRegistration || setting.Service.AllowOnlyExternalRegistration {
ctx.HTTPError(http.StatusForbidden)
return
}
if ctx.HasError() {
ctx.HTML(http.StatusOK, tplSignUp)
return
}
context.VerifyCaptcha(ctx, tplSignUp, form)
if ctx.Written() {
return
}
if !form.IsEmailDomainAllowed() {
ctx.RenderWithErrDeprecated(ctx.Tr("auth.email_domain_blacklisted"), tplSignUp, &form)
return
}
if form.Password != form.Retype {
ctx.Data["Err_Password"] = true
ctx.RenderWithErrDeprecated(ctx.Tr("form.password_not_match"), tplSignUp, &form)
return
}
if len(form.Password) < setting.MinPasswordLength {
ctx.Data["Err_Password"] = true
ctx.RenderWithErrDeprecated(ctx.Tr("auth.password_too_short", setting.MinPasswordLength), tplSignUp, &form)
return
}
if !password.IsComplexEnough(form.Password) {
ctx.Data["Err_Password"] = true
ctx.RenderWithErrDeprecated(password.BuildComplexityError(ctx.Locale), tplSignUp, &form)
return
}
if err := password.IsPwned(ctx, form.Password); err != nil {
errMsg := ctx.Tr("auth.password_pwned", "https://haveibeenpwned.com/Passwords")
if password.IsErrIsPwnedRequest(err) {
log.Error(err.Error())
errMsg = ctx.Tr("auth.password_pwned_err")
}
ctx.Data["Err_Password"] = true
ctx.RenderWithErrDeprecated(errMsg, tplSignUp, &form)
return
}
u := &user_model.User{
Name: form.UserName,
Email: form.Email,
Passwd: form.Password,
}
if !createAndHandleCreatedUser(ctx, tplSignUp, form, u, nil, nil) {
// error already handled
return
}
ctx.Flash.Success(ctx.Tr("auth.sign_up_successful"))
handleSignIn(ctx, u, false)
}
// createAndHandleCreatedUser calls createUserInContext and
// then handleUserCreated.
func createAndHandleCreatedUser(ctx *context.Context, tpl templates.TplName, form any, u *user_model.User, overwrites *user_model.CreateUserOverwriteOptions, possibleLinkAccountData *LinkAccountData) bool {
if !createUserInContext(ctx, tpl, form, u, overwrites, possibleLinkAccountData) {
return false
}
return handleUserCreated(ctx, u, possibleLinkAccountData)
}
// createUserInContext creates a user and handles errors within a given context.
// Optionally, a template can be specified.
func createUserInContext(ctx *context.Context, tpl templates.TplName, form any, u *user_model.User, overwrites *user_model.CreateUserOverwriteOptions, possibleLinkAccountData *LinkAccountData) (ok bool) {
meta := &user_model.Meta{
InitialIP: ctx.RemoteAddr(),
InitialUserAgent: ctx.Req.UserAgent(),
}
if err := user_model.CreateUser(ctx, u, meta, overwrites); err != nil {
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)
return false
}
}
// TODO: probably we should respect 'remember' user's choice...
oauth2LinkAccount(ctx, user, possibleLinkAccountData, true)
return false // user is already created here, all redirects are handled
case setting.OAuth2AccountLinkingLogin:
showLinkingLogin(ctx, possibleLinkAccountData.AuthSourceID, possibleLinkAccountData.GothUser)
return false // user will be created only after linking login
}
}
// handle error without a template
if len(tpl) == 0 {
ctx.ServerError("CreateUser", err)
return false
}
// handle error with template
switch {
case user_model.IsErrUserAlreadyExist(err):
ctx.Data["Err_UserName"] = true
ctx.RenderWithErrDeprecated(ctx.Tr("form.username_been_taken"), tpl, form)
case user_model.IsErrEmailAlreadyUsed(err):
ctx.Data["Err_Email"] = true
ctx.RenderWithErrDeprecated(ctx.Tr("form.email_been_used"), tpl, form)
case user_model.IsErrEmailCharIsNotSupported(err):
ctx.Data["Err_Email"] = true
ctx.RenderWithErrDeprecated(ctx.Tr("form.email_invalid"), tpl, form)
case user_model.IsErrEmailInvalid(err):
ctx.Data["Err_Email"] = true
ctx.RenderWithErrDeprecated(ctx.Tr("form.email_invalid"), tpl, form)
case db.IsErrNameReserved(err):
ctx.Data["Err_UserName"] = true
ctx.RenderWithErrDeprecated(ctx.Tr("user.form.name_reserved", err.(db.ErrNameReserved).Name), tpl, form)
case db.IsErrNamePatternNotAllowed(err):
ctx.Data["Err_UserName"] = true
ctx.RenderWithErrDeprecated(ctx.Tr("user.form.name_pattern_not_allowed", err.(db.ErrNamePatternNotAllowed).Pattern), tpl, form)
case db.IsErrNameCharsNotAllowed(err):
ctx.Data["Err_UserName"] = true
ctx.RenderWithErrDeprecated(ctx.Tr("user.form.name_chars_not_allowed", err.(db.ErrNameCharsNotAllowed).Name), tpl, form)
default:
ctx.ServerError("CreateUser", err)
}
return false
}
log.Trace("Account created: %s", u.Name)
return true
}
// handleUserCreated does additional steps after a new user is created.
// It auto-sets admin for the only user, updates the optional external user and
// sends a confirmation email if required.
func handleUserCreated(ctx *context.Context, u *user_model.User, possibleLinkAccountData *LinkAccountData) (ok bool) {
// Auto-set admin for the only user.
hasUsers, err := user_model.HasUsers(ctx)
if err != nil {
ctx.ServerError("HasUsers", err)
return false
}
if hasUsers.HasOnlyOneUser {
// the only user is the one just created, will set it as admin
opts := &user_service.UpdateOptions{
IsActive: optional.Some(true),
IsAdmin: user_service.UpdateOptionFieldFromValue(true),
SetLastLogin: true,
}
if err := user_service.UpdateUser(ctx, u, opts); err != nil {
ctx.ServerError("UpdateUser", err)
return false
}
}
// update external user information
if possibleLinkAccountData != nil {
if err := externalaccount.EnsureLinkExternalToUser(ctx, possibleLinkAccountData.AuthSourceID, u, possibleLinkAccountData.GothUser); err != nil {
log.Error("EnsureLinkExternalToUser failed: %v", err)
}
}
// for active user or the first (admin) user, we don't need to send confirmation email
if u.IsActive || u.ID == 1 {
return true
}
if setting.Service.RegisterManualConfirm {
renderActivationPromptMessage(ctx, ctx.Locale.Tr("auth.manual_activation_only"))
return false
}
sendActivateEmail(ctx, u)
return false
}
func renderActivationPromptMessage(ctx *context.Context, msg template.HTML) {
ctx.Data["ActivationPromptMessage"] = msg
ctx.HTML(http.StatusOK, TplActivatePrompt)
}
func sendActivateEmail(ctx *context.Context, u *user_model.User) {
if ctx.Cache.IsExist("MailResendLimit_" + u.LowerName) {
renderActivationPromptMessage(ctx, ctx.Locale.Tr("auth.resent_limit_prompt"))
return
}
if err := ctx.Cache.Put("MailResendLimit_"+u.LowerName, u.LowerName, 180); err != nil {
log.Error("Set cache(MailResendLimit) fail: %v", err)
renderActivationPromptMessage(ctx, ctx.Locale.Tr("auth.resent_limit_prompt"))
return
}
mailer.SendActivateAccountMail(ctx.Locale, u)
activeCodeLives := timeutil.MinutesToFriendly(setting.Service.ActiveCodeLives, ctx.Locale)
msgHTML := ctx.Locale.Tr("auth.confirmation_mail_sent_prompt_ex", u.Email, activeCodeLives)
renderActivationPromptMessage(ctx, msgHTML)
}
func renderActivationVerifyPassword(ctx *context.Context, code string) {
ctx.Data["ActivationCode"] = code
ctx.Data["NeedVerifyLocalPassword"] = true
ctx.HTML(http.StatusOK, TplActivate)
}
func renderActivationChangeEmail(ctx *context.Context) {
ctx.HTML(http.StatusOK, TplActivate)
}
// Activate render activate user page
func Activate(ctx *context.Context) {
code := ctx.FormString("code")
if code == "" {
if ctx.Doer == nil {
ctx.Redirect(setting.AppSubURL + "/user/login")
return
} else if ctx.Doer.IsActive {
ctx.Redirect(setting.AppSubURL + "/")
return
}
if setting.MailService == nil || !setting.Service.RegisterEmailConfirm {
renderActivationPromptMessage(ctx, ctx.Tr("auth.disable_register_mail"))
return
}
// Resend confirmation email. FIXME: ideally this should be in a POST request
sendActivateEmail(ctx, ctx.Doer)
return
}
// TODO: ctx.Doer/ctx.Data["SignedUser"] could be nil or not the same user as the one being activated
user := user_model.VerifyUserTimeLimitCode(ctx, &user_model.TimeLimitCodeOptions{Purpose: user_model.TimeLimitCodeActivateAccount}, code)
if user == nil { // if code is wrong
renderActivationPromptMessage(ctx, ctx.Locale.Tr("auth.invalid_code"))
return
}
// if account is local account, verify password
if user.LoginSource == 0 {
renderActivationVerifyPassword(ctx, code)
return
}
handleAccountActivation(ctx, user)
}
// ActivatePost handles account activation with password check
func ActivatePost(ctx *context.Context) {
code := ctx.FormString("code")
if ctx.Doer != nil && ctx.Doer.IsActive {
ctx.Redirect(setting.AppSubURL + "/user/activate") // it will redirect again to the correct page
return
}
if code == "" {
newEmail := strings.TrimSpace(ctx.FormString("change_email"))
if ctx.Doer != nil && newEmail != "" && !strings.EqualFold(ctx.Doer.Email, newEmail) {
if user_model.ValidateEmail(newEmail) != nil {
ctx.Flash.Error(ctx.Locale.Tr("form.email_invalid"), true)
renderActivationChangeEmail(ctx)
return
}
err := user_model.ChangeInactivePrimaryEmail(ctx, ctx.Doer.ID, ctx.Doer.Email, newEmail)
if err != nil {
ctx.Flash.Error(ctx.Locale.Tr("admin.emails.not_updated", newEmail), true)
renderActivationChangeEmail(ctx)
return
}
ctx.Doer.Email = newEmail
}
// FIXME: at the moment, GET request handles the "send confirmation email" action. But the old code does this redirect and then send a confirmation email.
ctx.Redirect(setting.AppSubURL + "/user/activate")
return
}
// TODO: ctx.Doer/ctx.Data["SignedUser"] could be nil or not the same user as the one being activated
user := user_model.VerifyUserTimeLimitCode(ctx, &user_model.TimeLimitCodeOptions{Purpose: user_model.TimeLimitCodeActivateAccount}, code)
if user == nil { // if code is wrong
renderActivationPromptMessage(ctx, ctx.Locale.Tr("auth.invalid_code"))
return
}
// if account is local account, verify password
if user.LoginSource == 0 {
password := ctx.FormString("password")
if password == "" {
renderActivationVerifyPassword(ctx, code)
return
}
if !user.ValidatePassword(password) {
ctx.Flash.Error(ctx.Locale.Tr("auth.invalid_password"), true)
renderActivationVerifyPassword(ctx, code)
return
}
}
handleAccountActivation(ctx, user)
}
func handleAccountActivation(ctx *context.Context, user *user_model.User) {
user.IsActive = true
var err error
if user.Rands, err = user_model.GetUserSalt(); err != nil {
ctx.ServerError("UpdateUser", err)
return
}
if err := user_model.UpdateUserCols(ctx, user, "is_active", "rands"); err != nil {
if user_model.IsErrUserNotExist(err) {
ctx.NotFound(err)
} else {
ctx.ServerError("UpdateUser", err)
}
return
}
if err := user_model.ActivateUserEmail(ctx, user.ID, user.Email, true); err != nil {
log.Error("Unable to activate email for user: %-v with email: %s: %v", user, user.Email, err)
ctx.ServerError("ActivateUserEmail", err)
return
}
log.Trace("User activated: %s", user.Name)
if err := updateSession(ctx, nil, map[string]any{
"uid": user.ID,
"uname": user.Name,
}); err != nil {
log.Error("Unable to regenerate session for user: %-v with email: %s: %v", user, user.Email, err)
ctx.ServerError("ActivateUserEmail", err)
return
}
if err := resetLocale(ctx, user); err != nil {
ctx.ServerError("resetLocale", err)
return
}
if err := user_service.UpdateUser(ctx, user, &user_service.UpdateOptions{SetLastLogin: true}); err != nil {
ctx.ServerError("UpdateUser", err)
return
}
ctx.Flash.Success(ctx.Tr("auth.account_activated"))
redirectAfterAuth(ctx)
}
// ActivateEmail render the activate email page
func ActivateEmail(ctx *context.Context) {
code := ctx.FormString("code")
emailStr := ctx.FormString("email")
// Verify code.
if email := user_model.VerifyActiveEmailCode(ctx, code, emailStr); email != nil {
if err := user_model.ActivateEmail(ctx, email); err != nil {
ctx.ServerError("ActivateEmail", err)
return
}
log.Trace("Email activated: %s", email.Email)
ctx.Flash.Success(ctx.Tr("settings.add_email_success"))
if u, err := user_model.GetUserByID(ctx, email.UID); err != nil {
log.Warn("GetUserByID: %d", email.UID)
} else {
// Allow user to validate more emails
_ = ctx.Cache.Delete("MailResendLimit_" + u.LowerName)
}
}
// FIXME: e-mail verification does not require the user to be logged in,
// so this could be redirecting to the login page.
// Should users be logged in automatically here? (consider 2FA requirements, etc.)
ctx.Redirect(setting.AppSubURL + "/user/settings/account")
}
func updateSession(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)
}
sess := ctx.Session
sessID := sess.ID()
for _, k := range deletes {
if err := sess.Delete(k); err != nil {
return fmt.Errorf("delete %v in session[%s]: %w", k, sessID, err)
}
}
for k, v := range updates {
if err := sess.Set(k, v); err != nil {
return fmt.Errorf("set %v in session[%s]: %w", k, sessID, err)
}
}
if err := sess.Release(); err != nil {
return fmt.Errorf("store session[%s]: %w", sessID, err)
}
return nil
}
+169
View File
@@ -0,0 +1,169 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package auth
import (
"html/template"
"net/http"
"net/http/httptest"
"net/url"
"testing"
auth_model "gitea.dev/models/auth"
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"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func addOAuth2Source(t *testing.T, authName string, cfg oauth2.Source) {
cfg.Provider = util.IfZero(cfg.Provider, "gitea")
err := auth_model.CreateSource(t.Context(), &auth_model.Source{
Type: auth_model.OAuth2,
Name: authName,
IsActive: true,
Cfg: &cfg,
})
require.NoError(t, err)
}
func TestWebAuthUserLogin(t *testing.T) {
ctx, resp := contexttest.MockContext(t, "/user/login")
SignIn(ctx)
assert.Equal(t, http.StatusOK, resp.Code)
ctx, resp = contexttest.MockContext(t, "/user/login")
ctx.IsSigned = true
SignIn(ctx)
assert.Equal(t, http.StatusSeeOther, resp.Code)
assert.Equal(t, "/", test.RedirectURL(resp))
ctx, resp = contexttest.MockContext(t, "/user/login?redirect_to=/other")
ctx.IsSigned = true
SignIn(ctx)
assert.Equal(t, "/other", test.RedirectURL(resp))
ctx, resp = contexttest.MockContext(t, "/user/login")
ctx.Req.AddCookie(&http.Cookie{Name: "redirect_to", Value: "/other-cookie"})
ctx.IsSigned = true
SignIn(ctx)
assert.Equal(t, "/other-cookie", test.RedirectURL(resp))
ctx, resp = contexttest.MockContext(t, "/user/login?redirect_to="+url.QueryEscape("https://example.com"))
ctx.IsSigned = true
SignIn(ctx)
assert.Equal(t, "/", test.RedirectURL(resp))
}
func TestWebAuthOAuth2(t *testing.T) {
defer test.MockVariableValue(&setting.OAuth2Client.EnableAutoRegistration, true)()
_ = oauth2.Init(t.Context())
addOAuth2Source(t, "dummy+auth's source", oauth2.Source{})
t.Run("OAuth2MissingField", func(t *testing.T) {
defer test.MockVariableValue(&gothic.CompleteUserAuth, func(res http.ResponseWriter, req *http.Request) (goth.User, error) {
return goth.User{Provider: "dummy+auth's source", UserID: "dummy-user"}, nil
})()
mockOpt := contexttest.MockContextOption{SessionStore: session.NewMockMemStore("dummy-sid")}
ctx, resp := contexttest.MockContext(t, "/user/oauth2/..../callback?code=dummy-code", mockOpt)
ctx.SetPathParamRaw("provider", "dummy+auth%27s%20source")
SignInOAuthCallback(ctx)
assert.Equal(t, http.StatusSeeOther, resp.Code)
assert.Equal(t, "/user/link_account", test.RedirectURL(resp))
// then the user will be redirected to the link account page, and see a message about the missing fields
ctx, _ = contexttest.MockContext(t, "/user/link_account", mockOpt)
LinkAccount(ctx)
assert.Equal(t, template.HTML("auth.oauth_callback_unable_auto_reg:dummy+auth&#39;s source,email"), ctx.Data["AutoRegistrationFailedPrompt"])
})
t.Run("OAuth2CallbackError", func(t *testing.T) {
mockOpt := contexttest.MockContextOption{SessionStore: session.NewMockMemStore("dummy-sid")}
ctx, resp := contexttest.MockContext(t, "/user/oauth2/...../callback", mockOpt)
ctx.SetPathParamRaw("provider", "dummy+auth%27s%20source")
SignInOAuthCallback(ctx)
assert.Equal(t, http.StatusSeeOther, resp.Code)
assert.Equal(t, "/user/login", test.RedirectURL(resp))
assert.Contains(t, ctx.Flash.ErrorMsg, "auth.oauth.signin.error.general")
})
t.Run("RedirectSingleProvider", func(t *testing.T) {
enablePassword := &setting.Service.EnablePasswordSignInForm
enableOpenID := &setting.Service.EnableOpenIDSignIn
enablePasskey := &setting.Service.EnablePasskeyAuth
defer test.MockVariableValue(enablePassword, false)()
defer test.MockVariableValue(enableOpenID, false)()
defer test.MockVariableValue(enablePasskey, false)()
testSignIn := func(t *testing.T, link string, expectedCode int, expectedRedirect string) {
ctx, resp := contexttest.MockContext(t, link)
SignIn(ctx)
assert.Equal(t, expectedCode, resp.Code)
if expectedCode == http.StatusSeeOther {
assert.Equal(t, expectedRedirect, test.RedirectURL(resp))
}
}
testSignIn(t, "/user/login", http.StatusSeeOther, "/user/oauth2/dummy+auth%27s%20source")
testSignIn(t, "/user/login?redirect_to=/", http.StatusSeeOther, "/user/oauth2/dummy+auth%27s%20source?redirect_to=%2F")
*enablePassword, *enableOpenID, *enablePasskey = true, false, false
testSignIn(t, "/user/login", http.StatusOK, "")
*enablePassword, *enableOpenID, *enablePasskey = false, true, false
testSignIn(t, "/user/login", http.StatusOK, "")
*enablePassword, *enableOpenID, *enablePasskey = false, false, true
testSignIn(t, "/user/login", http.StatusOK, "")
*enablePassword, *enableOpenID, *enablePasskey = false, false, false
addOAuth2Source(t, "dummy-auth-source-2", oauth2.Source{})
testSignIn(t, "/user/login", http.StatusOK, "")
})
t.Run("OIDCLogout", func(t *testing.T) {
var mockServer *httptest.Server
mockServer = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/.well-known/openid-configuration":
_, _ = w.Write([]byte(`{
"issuer": "` + mockServer.URL + `",
"authorization_endpoint": "` + mockServer.URL + `/authorize",
"token_endpoint": "` + mockServer.URL + `/token",
"userinfo_endpoint": "` + mockServer.URL + `/userinfo",
"end_session_endpoint": "https://example.com/oidc-logout?oidc-key=oidc-val"
}`))
default:
http.NotFound(w, r)
}
}))
defer mockServer.Close()
addOAuth2Source(t, "oidc-auth-source", oauth2.Source{
Provider: "openidConnect",
ClientID: "mock-client-id",
OpenIDConnectAutoDiscoveryURL: mockServer.URL + "/.well-known/openid-configuration",
})
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())
})
}
+281
View File
@@ -0,0 +1,281 @@
// Copyright 2017 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package auth
import (
"errors"
"net/http"
"strings"
"gitea.dev/models/auth"
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"
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"
func prepareLinkAccountPageData(ctx *context.Context) {
// TODO Make insecure passwords optional for local accounts also, once email-based Second-Factor Auth is available
ctx.Data["DisablePassword"] = !setting.Service.RequireExternalRegistrationPassword || setting.Service.AllowOnlyExternalRegistration
ctx.Data["Title"] = ctx.Tr("link_account")
ctx.Data["LinkAccountMode"] = true
// use this to set the right link into the signIn and signUp templates in the link_account template
ctx.Data["SignInLink"] = setting.AppSubURL + "/user/link_account_signin"
ctx.Data["SignUpLink"] = setting.AppSubURL + "/user/link_account_signup"
ctx.Data["ShowRegistrationButton"] = false
ctx.Data["DisableRegistration"] = setting.Service.DisableRegistration
prepareCommonAuthPageData(ctx, CommonAuthOptions{
EnableCaptcha: setting.Service.EnableCaptcha && setting.Service.RequireExternalRegistrationCaptcha,
})
}
// LinkAccount shows the page where the user can decide to login or create a new account
func LinkAccount(ctx *context.Context) {
prepareLinkAccountPageData(ctx)
linkAccountData := oauth2GetLinkAccountData(ctx)
// If you'd like to quickly debug the "link account" page layout, just uncomment the blow line
// Don't worry, when the below line exists, the lint won't pass: ineffectual assignment to gothUser (ineffassign)
// linkAccountData = &LinkAccountData{authSource, gothUser} // intentionally use invalid data to avoid pass the registration check
if linkAccountData == nil {
// no account in session, so just redirect to the login page, then the user could restart the process
ctx.Redirect(setting.AppSubURL + "/user/login")
return
}
if missingFields, ok := linkAccountData.GothUser.RawData["__giteaAutoRegMissingFields"].([]string); ok {
ctx.Data["AutoRegistrationFailedPrompt"] = ctx.Tr("auth.oauth_callback_unable_auto_reg", linkAccountData.GothUser.Provider, strings.Join(missingFields, ","))
}
uname, err := extractUserNameFromOAuth2(&linkAccountData.GothUser)
if err != nil {
ctx.ServerError("UserSignIn", err)
return
}
email := linkAccountData.GothUser.Email
ctx.Data["user_name"] = uname
ctx.Data["email"] = email
if email != "" {
u, err := user_model.GetUserByEmail(ctx, email)
if err != nil && !user_model.IsErrUserNotExist(err) {
ctx.ServerError("UserSignIn", err)
return
}
if u != nil {
ctx.Data["user_exists"] = true
}
} else if uname != "" {
u, err := user_model.GetUserByName(ctx, uname)
if err != nil && !user_model.IsErrUserNotExist(err) {
ctx.ServerError("UserSignIn", err)
return
}
if u != nil {
ctx.Data["user_exists"] = true
}
}
ctx.HTML(http.StatusOK, tplLinkAccount)
}
func handleSignInError(ctx *context.Context, userName string, ptrForm any, tmpl templates.TplName, invoker string, err error) {
if errors.Is(err, util.ErrNotExist) {
ctx.RenderWithErrDeprecated(ctx.Tr("form.username_password_incorrect"), tmpl, ptrForm)
} else if errors.Is(err, util.ErrInvalidArgument) {
ctx.Data["user_exists"] = true
ctx.RenderWithErrDeprecated(ctx.Tr("form.username_password_incorrect"), tmpl, ptrForm)
} else if user_model.IsErrUserProhibitLogin(err) {
ctx.Data["user_exists"] = true
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)
}
}
// LinkAccountPostSignIn handle the coupling of external account with another account using signIn
func LinkAccountPostSignIn(ctx *context.Context) {
signInForm := web.GetForm(ctx).(*forms.SignInForm)
ctx.Data["LinkAccountModeSignIn"] = true
prepareLinkAccountPageData(ctx)
linkAccountData := oauth2GetLinkAccountData(ctx)
if linkAccountData == nil {
ctx.ServerError("UserSignIn", errors.New("not in LinkAccount session"))
return
}
if ctx.HasError() {
ctx.HTML(http.StatusOK, tplLinkAccount)
return
}
u, _, err := auth_service.UserSignIn(ctx, signInForm.UserName, signInForm.Password)
if err != nil {
handleSignInError(ctx, signInForm.UserName, &signInForm, tplLinkAccount, "UserLinkAccount", err)
return
}
oauth2LinkAccount(ctx, u, linkAccountData, signInForm.Remember)
}
func oauth2LinkAccount(ctx *context.Context, u *user_model.User, linkAccountData *LinkAccountData, remember bool) {
oauth2SignInSync(ctx, linkAccountData.AuthSourceID, u, linkAccountData.GothUser)
if ctx.Written() {
return
}
// 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)
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
}
handleSignIn(ctx, u, remember)
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)
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")
}
// LinkAccountPostRegister handle the creation of a new account for an external account using signUp
func LinkAccountPostRegister(ctx *context.Context) {
form := web.GetForm(ctx).(*forms.RegisterForm)
ctx.Data["LinkAccountModeRegister"] = true
prepareLinkAccountPageData(ctx)
linkAccountData := oauth2GetLinkAccountData(ctx)
if linkAccountData == nil {
ctx.ServerError("UserSignUp", errors.New("not in LinkAccount session"))
return
}
if ctx.HasError() {
ctx.HTML(http.StatusOK, tplLinkAccount)
return
}
if setting.Service.DisableRegistration || setting.Service.AllowOnlyInternalRegistration {
ctx.HTTPError(http.StatusForbidden)
return
}
if setting.Service.EnableCaptcha && setting.Service.RequireExternalRegistrationCaptcha {
context.VerifyCaptcha(ctx, tplLinkAccount, form)
if ctx.Written() {
return
}
}
if !form.IsEmailDomainAllowed() {
ctx.RenderWithErrDeprecated(ctx.Tr("auth.email_domain_blacklisted"), tplLinkAccount, &form)
return
}
if setting.Service.AllowOnlyExternalRegistration || !setting.Service.RequireExternalRegistrationPassword {
// In user_model.User an empty password is classed as not set, so we set form.Password to empty.
// Eventually the database should be changed to indicate "Second Factor"-enabled accounts
// (accounts that do not introduce the security vulnerabilities of a password).
// If a user decides to circumvent second-factor security, and purposefully create a password,
// they can still do so using the "Recover Account" option.
form.Password = ""
} else {
if (len(strings.TrimSpace(form.Password)) > 0 || len(strings.TrimSpace(form.Retype)) > 0) && form.Password != form.Retype {
ctx.Data["Err_Password"] = true
ctx.RenderWithErrDeprecated(ctx.Tr("form.password_not_match"), tplLinkAccount, &form)
return
}
if len(strings.TrimSpace(form.Password)) > 0 && len(form.Password) < setting.MinPasswordLength {
ctx.Data["Err_Password"] = true
ctx.RenderWithErrDeprecated(ctx.Tr("auth.password_too_short", setting.MinPasswordLength), tplLinkAccount, &form)
return
}
}
u := &user_model.User{
Name: form.UserName,
Email: form.Email,
Passwd: form.Password,
LoginType: auth.OAuth2,
LoginSource: linkAccountData.AuthSourceID,
LoginName: linkAccountData.GothUser.UserID,
}
if !createAndHandleCreatedUser(ctx, tplLinkAccount, form, u, nil, linkAccountData) {
// error already handled
return
}
oauth2SignInSync(ctx, linkAccountData.AuthSourceID, u, linkAccountData.GothUser)
if ctx.Written() {
return
}
authSource, err := auth.GetSourceByID(ctx, linkAccountData.AuthSourceID)
if err != nil {
ctx.ServerError("GetSourceByID", err)
return
}
source := authSource.Cfg.(*oauth2.Source)
if err := syncGroupsToTeams(ctx, source, &linkAccountData.GothUser, u); err != nil {
ctx.ServerError("SyncGroupsToTeams", err)
return
}
handleSignIn(ctx, u, false)
}
func linkAccountFromContext(ctx *context.Context, user *user_model.User) error {
linkAccountData := oauth2GetLinkAccountData(ctx)
if linkAccountData == nil {
return errors.New("not in LinkAccount session")
}
return externalaccount.LinkAccountToUser(ctx, linkAccountData.AuthSourceID, user, linkAccountData.GothUser)
}
+14
View File
@@ -0,0 +1,14 @@
// Copyright 2018 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package auth
import (
"testing"
"gitea.dev/models/unittest"
)
func TestMain(m *testing.M) {
unittest.MainTest(m)
}
+575
View File
@@ -0,0 +1,575 @@
// Copyright 2019 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package auth
import (
"encoding/gob"
"errors"
"fmt"
"html"
"io"
"net/http"
"net/url"
"sort"
"strings"
"time"
"gitea.dev/models/auth"
user_model "gitea.dev/models/user"
auth_module "gitea.dev/modules/auth"
"gitea.dev/modules/container"
"gitea.dev/modules/httplib"
"gitea.dev/modules/log"
"gitea.dev/modules/optional"
"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"
go_oauth2 "golang.org/x/oauth2"
)
// SignInOAuth handles the OAuth2 login buttons
func SignInOAuth(ctx *context.Context) {
authName := ctx.PathParam("provider")
authSource, err := auth.GetActiveOAuth2SourceByAuthName(ctx, authName)
if err != nil {
ctx.ServerError("SignIn", err)
return
}
rememberAuthRedirectLink(ctx)
// try to do a direct callback flow, so we don't authenticate the user again but use the valid accesstoken to get the user
user, gothUser, err := oAuth2UserLoginCallback(ctx, authSource, ctx.Req, ctx.Resp)
if err == nil && user != nil {
// we got the user without going through the whole OAuth2 authentication flow again
handleOAuth2SignIn(ctx, authSource, user, gothUser)
return
}
if err = authSource.Cfg.(*oauth2.Source).Callout(ctx.Req, ctx.Resp); err != nil {
if strings.Contains(err.Error(), "no provider for ") {
if err = oauth2.ResetOAuth2(ctx); err != nil {
ctx.ServerError("SignIn", err)
return
}
if err = authSource.Cfg.(*oauth2.Source).Callout(ctx.Req, ctx.Resp); err != nil {
ctx.ServerError("SignIn", err)
}
return
}
ctx.ServerError("SignIn", err)
}
// redirect is done in oauth2.Auth
}
// SignInOAuthCallback handles the callback from the given provider
func SignInOAuthCallback(ctx *context.Context) {
if ctx.Req.FormValue("error") != "" {
var errorKeyValues []string
for k, vv := range ctx.Req.Form {
for _, v := range vv {
errorKeyValues = append(errorKeyValues, fmt.Sprintf("%s = %s", html.EscapeString(k), html.EscapeString(v)))
}
}
sort.Strings(errorKeyValues)
ctx.Flash.Error(strings.Join(errorKeyValues, "\n"), true)
}
// first look if the provider is still active
authName := ctx.PathParam("provider")
authSource, err := auth.GetActiveOAuth2SourceByAuthName(ctx, authName)
if err != nil {
ctx.ServerError("SignIn", err)
return
}
if authSource == nil {
ctx.ServerError("SignIn", errors.New("no valid provider found, check configured callback url in provider"))
return
}
u, gothUser, err := oAuth2UserLoginCallback(ctx, authSource, ctx.Req, ctx.Resp)
if err != nil {
if user_model.IsErrUserProhibitLogin(err) {
uplerr := err.(user_model.ErrUserProhibitLogin)
log.Info("Failed authentication attempt for %s from %s: %v", uplerr.Name, ctx.RemoteAddr(), err)
ctx.Data["Title"] = ctx.Tr("auth.prohibit_login")
ctx.HTML(http.StatusOK, "user/auth/prohibit_login")
return
}
if callbackErr, ok := err.(errCallback); ok {
log.Info("Failed OAuth callback: (%v) %v", callbackErr.Code, callbackErr.Description)
switch callbackErr.Code {
case "access_denied":
ctx.Flash.Error(ctx.Tr("auth.oauth.signin.error.access_denied"))
case "temporarily_unavailable":
ctx.Flash.Error(ctx.Tr("auth.oauth.signin.error.temporarily_unavailable"))
default:
ctx.Flash.Error(ctx.Tr("auth.oauth.signin.error.general", callbackErr.Description))
}
ctx.Redirect(setting.AppSubURL + "/user/login")
return
}
if err, ok := err.(*go_oauth2.RetrieveError); ok {
ctx.Flash.Error("OAuth2 RetrieveError: " + err.Error())
ctx.Redirect(setting.AppSubURL + "/user/login")
return
}
ctx.ServerError("UserSignIn", err)
return
}
if u == nil {
if ctx.Doer != nil {
// attach user to the current signed-in user
err = externalaccount.LinkAccountToUser(ctx, authSource.ID, ctx.Doer, gothUser)
if err != nil {
ctx.ServerError("UserLinkAccount", err)
return
}
ctx.Redirect(setting.AppSubURL + "/user/settings/security")
return
} else if !setting.Service.AllowOnlyInternalRegistration && setting.OAuth2Client.EnableAutoRegistration {
// create new user with details from oauth2 provider
var missingFields []string
if gothUser.UserID == "" {
missingFields = append(missingFields, "sub")
}
if gothUser.Email == "" {
missingFields = append(missingFields, "email")
}
uname, err := extractUserNameFromOAuth2(&gothUser)
if err != nil {
ctx.ServerError("UserSignIn", err)
return
}
if uname == "" {
switch setting.OAuth2Client.Username {
case setting.OAuth2UsernameNickname:
missingFields = append(missingFields, "nickname")
case setting.OAuth2UsernamePreferredUsername:
missingFields = append(missingFields, "preferred_username")
} // else: "UserID" and "Email" have been handled above separately
}
if len(missingFields) > 0 {
log.Error(`OAuth2 auto registration (ENABLE_AUTO_REGISTRATION) is enabled but OAuth2 provider %q doesn't return required fields: %s. `+
`Suggest to: disable auto registration, or make OPENID_CONNECT_SCOPES (for OpenIDConnect) / Authentication Source Scopes (for Admin panel) to request all required fields, and the fields shouldn't be empty.`,
authSource.Name, strings.Join(missingFields, ","))
// The RawData is the only way to pass the missing fields to the another page at the moment, other ways all have various problems:
// by session or cookie: difficult to clean or reset; by URL: could be injected with uncontrollable content; by ctx.Flash: the link_account page is a mess ...
// Since the RawData is for the provider's data, so we need to use our own prefix here to avoid conflict.
if gothUser.RawData == nil {
gothUser.RawData = make(map[string]any)
}
gothUser.RawData["__giteaAutoRegMissingFields"] = missingFields
showLinkingLogin(ctx, authSource.ID, gothUser)
return
}
u = &user_model.User{
Name: uname,
Email: gothUser.Email,
LoginType: auth.OAuth2,
LoginSource: authSource.ID,
LoginName: gothUser.UserID,
}
overwriteDefault := &user_model.CreateUserOverwriteOptions{
IsActive: optional.Some(!setting.OAuth2Client.RegisterEmailConfirm && !setting.Service.RegisterManualConfirm),
}
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
}
if !createAndHandleCreatedUser(ctx, "", nil, u, overwriteDefault, linkAccountData) {
// error already handled
return
}
if err := syncGroupsToTeams(ctx, source, &gothUser, u); err != nil {
ctx.ServerError("SyncGroupsToTeams", err)
return
}
} else {
// no existing user is found, request attach or new account
showLinkingLogin(ctx, authSource.ID, gothUser)
return
}
}
handleOAuth2SignIn(ctx, authSource, u, gothUser)
}
func claimValueToStringSet(claimValue any) container.Set[string] {
var groups []string
switch rawGroup := claimValue.(type) {
case []string:
groups = rawGroup
case []any:
for _, group := range rawGroup {
groups = append(groups, fmt.Sprintf("%s", group))
}
default:
str := fmt.Sprintf("%s", rawGroup)
groups = strings.Split(str, ",")
}
return container.SetOf(groups...)
}
func syncGroupsToTeams(ctx *context.Context, source *oauth2.Source, gothUser *goth.User, u *user_model.User) error {
if source.GroupTeamMap != "" || source.GroupTeamMapRemoval {
groupTeamMapping, err := auth_module.UnmarshalGroupTeamMapping(source.GroupTeamMap)
if err != nil {
return err
}
groups := getClaimedGroups(source, gothUser)
if err := source_service.SyncGroupsToTeams(ctx, u, groups, groupTeamMapping, source.GroupTeamMapRemoval); err != nil {
return err
}
}
return nil
}
func getClaimedGroups(source *oauth2.Source, gothUser *goth.User) container.Set[string] {
groupClaims, has := gothUser.RawData[source.GroupClaimName]
if !has {
return nil
}
return claimValueToStringSet(groupClaims)
}
func getUserAdminAndRestrictedFromGroupClaims(source *oauth2.Source, gothUser *goth.User) (isAdmin optional.Option[user_service.UpdateOptionField[bool]], isRestricted optional.Option[bool]) {
groups := getClaimedGroups(source, gothUser)
if source.AdminGroup != "" {
isAdmin = user_service.UpdateOptionFieldFromSync(groups.Contains(source.AdminGroup))
}
if source.RestrictedGroup != "" {
isRestricted = optional.Some(groups.Contains(source.RestrictedGroup))
}
return isAdmin, isRestricted
}
type LinkAccountData struct {
AuthSourceID int64
GothUser goth.User
}
func init() {
gob.Register(LinkAccountData{}) // TODO: CHI-SESSION-GOB-REGISTER
}
func oauth2GetLinkAccountData(ctx *context.Context) *LinkAccountData {
v, ok := ctx.Session.Get("linkAccountData").(LinkAccountData)
if !ok {
return nil
}
return &v
}
func Oauth2SetLinkAccountData(ctx *context.Context, linkAccountData LinkAccountData) error {
return updateSession(ctx, nil, map[string]any{
"linkAccountData": linkAccountData,
})
}
func showLinkingLogin(ctx *context.Context, authSourceID int64, gothUser goth.User) {
if err := Oauth2SetLinkAccountData(ctx, LinkAccountData{authSourceID, gothUser}); err != nil {
ctx.ServerError("Oauth2SetLinkAccountData", err)
return
}
ctx.Redirect(setting.AppSubURL + "/user/link_account")
}
var oauth2AvatarHTTPClient = &http.Client{Timeout: 30 * time.Second}
func oauth2UpdateAvatarIfNeed(ctx *context.Context, avatarURL string, u *user_model.User) {
if !setting.OAuth2Client.UpdateAvatar || len(avatarURL) == 0 {
return
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, avatarURL, nil)
if err != nil {
log.Warn("invalid avatar URL %q: %v", avatarURL, err)
return
}
// Some hosts (e.g. Wikimedia) reject Go's default User-Agent.
req.Header.Set("User-Agent", "Gitea "+setting.AppVer)
resp, err := oauth2AvatarHTTPClient.Do(req)
if err != nil {
log.Warn("fetch %q failed: %v", avatarURL, err)
return
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
log.Warn("fetch %q returned status %d", avatarURL, resp.StatusCode)
return
}
data, err := io.ReadAll(io.LimitReader(resp.Body, setting.Avatar.MaxFileSize+1))
if err != nil {
log.Warn("read body from %q failed: %v", avatarURL, err)
return
}
if int64(len(data)) > setting.Avatar.MaxFileSize {
log.Warn("avatar from %q exceeds max size %d", avatarURL, setting.Avatar.MaxFileSize)
return
}
if err := user_service.UploadAvatar(ctx, u, data); err != nil {
log.Warn("UploadAvatar for user %q failed: %v", u.Name, err)
}
}
func handleOAuth2SignIn(ctx *context.Context, authSource *auth.Source, u *user_model.User, gothUser goth.User) {
oauth2SignInSync(ctx, authSource.ID, u, gothUser)
if ctx.Written() {
return
}
needs2FA := false
if !authSource.TwoFactorShouldSkip() {
_, err := auth.GetTwoFactorByUID(ctx, u.ID)
if err != nil && !auth.IsErrTwoFactorNotEnrolled(err) {
ctx.ServerError("UserSignIn", err)
return
}
needs2FA = err == nil
}
oauth2Source := authSource.Cfg.(*oauth2.Source)
groupTeamMapping, err := auth_module.UnmarshalGroupTeamMapping(oauth2Source.GroupTeamMap)
if err != nil {
ctx.ServerError("UnmarshalGroupTeamMapping", err)
return
}
groups := getClaimedGroups(oauth2Source, &gothUser)
opts := &user_service.UpdateOptions{}
// Reactivate user if they are deactivated
if !u.IsActive {
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)
return
}
}
if err := externalaccount.EnsureLinkExternalToUser(ctx, authSource.ID, u, gothUser); err != nil {
ctx.ServerError("EnsureLinkExternalToUser", err)
return
}
// If this user is enrolled in 2FA and this source doesn't override it,
// we can't sign the user in just yet. Instead, redirect them to the 2FA authentication page.
if !needs2FA {
// Register last login
opts.SetLastLogin = true
if err := user_service.UpdateUser(ctx, u, opts); err != nil {
ctx.ServerError("UpdateUser", err)
return
}
userHasTwoFactorAuth, err := auth.HasTwoFactorOrWebAuthn(ctx, u.ID)
if err != nil {
ctx.ServerError("UpdateUser", err)
return
}
if err := updateSession(ctx, nil, map[string]any{
session.KeyUID: u.ID,
session.KeyUname: u.Name,
session.KeyUserHasTwoFactorAuth: userHasTwoFactorAuth,
}); err != nil {
ctx.ServerError("updateSession", err)
return
}
if err := resetLocale(ctx, u); err != nil {
ctx.ServerError("resetLocale", err)
return
}
redirectAfterAuth(ctx)
return
}
if opts.IsActive.Has() || opts.IsAdmin.Has() || opts.IsRestricted.Has() {
if err := user_service.UpdateUser(ctx, u, opts); err != nil {
ctx.ServerError("UpdateUser", err)
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": 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")
}
// OAuth2UserLoginCallback attempts to handle the callback from the OAuth2 provider and if successful
// login the user
func oAuth2UserLoginCallback(ctx *context.Context, authSource *auth.Source, request *http.Request, response http.ResponseWriter) (*user_model.User, goth.User, error) {
oauth2Source := authSource.Cfg.(*oauth2.Source)
// Make sure that the response is not an error response.
errorName := request.FormValue("error")
if len(errorName) > 0 {
errorDescription := request.FormValue("error_description")
// Delete the goth session
err := gothic.Logout(response, request)
if err != nil {
return nil, goth.User{}, err
}
return nil, goth.User{}, errCallback{
Code: errorName,
Description: errorDescription,
}
}
// Proceed to authenticate through goth.
gothUser, err := oauth2Source.Callback(request, response)
if err != nil {
if err.Error() == "securecookie: the value is too long" || strings.Contains(err.Error(), "Data too long") {
err = fmt.Errorf("OAuth2 Provider %s returned too long a token. Current max: %d. Either increase the [OAuth2] MAX_TOKEN_LENGTH or reduce the information returned from the OAuth2 provider", authSource.Name, setting.OAuth2.MaxTokenLength)
log.Error("oauth2Source.Callback failed: %v", err)
} else {
err = errCallback{Code: "internal", Description: err.Error()}
}
return nil, goth.User{}, err
}
if oauth2Source.RequiredClaimName != "" {
claimInterface, has := gothUser.RawData[oauth2Source.RequiredClaimName]
if !has {
return nil, goth.User{}, user_model.ErrUserProhibitLogin{Name: gothUser.UserID}
}
if oauth2Source.RequiredClaimValue != "" {
groups := claimValueToStringSet(claimInterface)
if !groups.Contains(oauth2Source.RequiredClaimValue) {
return nil, goth.User{}, user_model.ErrUserProhibitLogin{Name: gothUser.UserID}
}
}
}
user := &user_model.User{
LoginName: gothUser.UserID,
LoginType: auth.OAuth2,
LoginSource: authSource.ID,
}
hasUser, err := user_model.GetIndividualUser(ctx, user)
if err != nil {
return nil, goth.User{}, err
}
if hasUser {
return user, gothUser, nil
}
// search in external linked users
externalLoginUser := &user_model.ExternalLoginUser{
ExternalID: gothUser.UserID,
LoginSourceID: authSource.ID,
}
hasUser, err = user_model.GetExternalLogin(request.Context(), externalLoginUser)
if err != nil {
return nil, goth.User{}, err
}
if hasUser {
user, err = user_model.GetUserByID(request.Context(), externalLoginUser.UserID)
return user, gothUser, err
}
// no user found to login
return nil, gothUser, nil
}
// buildOIDCEndSessionURL constructs an OIDC RP-Initiated Logout URL for the
// given user. Returns "" if the user's auth source is not OIDC or doesn't
// advertise an end_session_endpoint.
func buildOIDCEndSessionURL(ctx *context.Context, doer *user_model.User) string {
authSource, err := auth.GetSourceByID(ctx, doer.LoginSource)
if err != nil {
log.Error("Failed to get auth source for OIDC logout (source=%d): %v", doer.LoginSource, err)
return ""
}
oauth2Cfg, ok := authSource.Cfg.(*oauth2.Source)
if !ok {
return ""
}
endSessionEndpoint := oauth2.GetOIDCEndSessionEndpoint(authSource.Name)
if endSessionEndpoint == "" {
return ""
}
endSessionURL, err := url.Parse(endSessionEndpoint)
if err != nil {
log.Error("Failed to parse end_session_endpoint %q: %v", endSessionEndpoint, err)
return ""
}
// RP-Initiated Logout 1.0: use client_id to identify the client to the IdP.
// https://openid.net/specs/openid-connect-rpinitiated-1_0.html#RPLogout
params := endSessionURL.Query()
params.Set("client_id", oauth2Cfg.ClientID)
// 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()
}
+708
View File
@@ -0,0 +1,708 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package auth
import (
"errors"
"fmt"
"html"
"html/template"
"net/http"
"net/url"
"strconv"
"strings"
"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"
)
const (
tplGrantAccess templates.TplName = "user/auth/grant"
tplGrantError templates.TplName = "user/auth/grant_error"
)
// TODO move error and responses to SDK or models
// AuthorizeErrorCode represents an error code specified in RFC 6749
// https://datatracker.ietf.org/doc/html/rfc6749#section-4.2.2.1
type AuthorizeErrorCode string
const (
// ErrorCodeInvalidRequest represents the according error in RFC 6749
ErrorCodeInvalidRequest AuthorizeErrorCode = "invalid_request"
// ErrorCodeUnauthorizedClient represents the according error in RFC 6749
ErrorCodeUnauthorizedClient AuthorizeErrorCode = "unauthorized_client"
// ErrorCodeAccessDenied represents the according error in RFC 6749
ErrorCodeAccessDenied AuthorizeErrorCode = "access_denied"
// ErrorCodeUnsupportedResponseType represents the according error in RFC 6749
ErrorCodeUnsupportedResponseType AuthorizeErrorCode = "unsupported_response_type"
// ErrorCodeInvalidScope represents the according error in RFC 6749
ErrorCodeInvalidScope AuthorizeErrorCode = "invalid_scope"
// ErrorCodeServerError represents the according error in RFC 6749
ErrorCodeServerError AuthorizeErrorCode = "server_error"
// ErrorCodeTemporaryUnavailable represents the according error in RFC 6749
ErrorCodeTemporaryUnavailable AuthorizeErrorCode = "temporarily_unavailable"
)
// AuthorizeError represents an error type specified in RFC 6749
// https://datatracker.ietf.org/doc/html/rfc6749#section-4.2.2.1
type AuthorizeError struct {
ErrorCode AuthorizeErrorCode `json:"error" form:"error"`
ErrorDescription string
State string
}
// Error returns the error message
func (err AuthorizeError) Error() string {
return fmt.Sprintf("%s: %s", err.ErrorCode, err.ErrorDescription)
}
// errCallback represents a oauth2 callback error
type errCallback struct {
Code string
Description string
}
func (err errCallback) Error() string {
return err.Description
}
type userInfoResponse struct {
Sub string `json:"sub"`
Name string `json:"name"`
PreferredUsername string `json:"preferred_username"`
Email string `json:"email"`
Picture string `json:"picture"`
Groups []string `json:"groups"`
}
// InfoOAuth manages request for userinfo endpoint
func InfoOAuth(ctx *context.Context) {
if ctx.Doer == nil || ctx.Data["AuthedMethod"] != (&auth_service.OAuth2{}).Name() {
ctx.Resp.Header().Set("WWW-Authenticate", `Bearer realm="Gitea OAuth2"`)
ctx.PlainText(http.StatusUnauthorized, "no valid authorization")
return
}
response := &userInfoResponse{
Sub: strconv.FormatInt(ctx.Doer.ID, 10),
Name: ctx.Doer.DisplayName(),
PreferredUsername: ctx.Doer.Name,
Email: ctx.Doer.Email,
Picture: ctx.Doer.AvatarLink(ctx),
}
var accessTokenScope auth.AccessTokenScope
if auHead := ctx.Req.Header.Get("Authorization"); auHead != "" {
if parsed, ok := httpauth.ParseAuthorizationHeader(auHead); ok && parsed.BearerToken != nil {
accessTokenScope, _ = auth_service.GetOAuthAccessTokenScopeAndUserID(ctx, parsed.BearerToken.Token)
}
}
// since version 1.22 does not verify if groups should be public-only,
// onlyPublicGroups will be set only if 'public-only' is included in a valid scope
onlyPublicGroups, _ := accessTokenScope.PublicOnly()
groups, err := oauth2_provider.GetOAuthGroupsForUser(ctx, ctx.Doer, onlyPublicGroups)
if err != nil {
ctx.ServerError("Oauth groups for user", err)
return
}
response.Groups = groups
ctx.JSON(http.StatusOK, response)
}
// IntrospectOAuth introspects an oauth token
func IntrospectOAuth(ctx *context.Context) {
clientIDValid := false
authHeader := ctx.Req.Header.Get("Authorization")
if parsed, ok := httpauth.ParseAuthorizationHeader(authHeader); ok && parsed.BasicAuth != nil {
clientID, clientSecret := parsed.BasicAuth.Username, parsed.BasicAuth.Password
app, err := auth.GetOAuth2ApplicationByClientID(ctx, clientID)
if err != nil && !auth.IsErrOauthClientIDInvalid(err) {
// this is likely a database error; log it and respond without details
log.Error("Error retrieving client_id: %v", err)
ctx.HTTPError(http.StatusInternalServerError)
return
}
clientIDValid = err == nil && app.ValidateClientSecret([]byte(clientSecret))
}
if !clientIDValid {
ctx.Resp.Header().Set("WWW-Authenticate", `Basic realm="Gitea OAuth2"`)
ctx.PlainText(http.StatusUnauthorized, "no valid authorization")
return
}
var response struct {
Active bool `json:"active"`
Scope string `json:"scope,omitempty"`
Username string `json:"username,omitempty"`
jwt.RegisteredClaims
}
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
}
}
}
ctx.JSON(http.StatusOK, response)
}
// AuthorizeOAuth manages authorize requests
func AuthorizeOAuth(ctx *context.Context) {
form := web.GetForm(ctx).(*forms.AuthorizationForm)
errs := binding.Errors{}
errs = form.Validate(ctx.Req, errs)
if len(errs) > 0 {
var errstring strings.Builder
for _, e := range errs {
errstring.WriteString(e.Error() + "\n")
}
ctx.ServerError("AuthorizeOAuth: Validate: ", fmt.Errorf("errors occurred during validation: %s", errstring.String()))
return
}
app, err := auth.GetOAuth2ApplicationByClientID(ctx, form.ClientID)
if err != nil {
if auth.IsErrOauthClientIDInvalid(err) {
handleAuthorizeError(ctx, AuthorizeError{
ErrorCode: ErrorCodeUnauthorizedClient,
ErrorDescription: "Client ID not registered",
State: form.State,
}, "")
return
}
ctx.ServerError("GetOAuth2ApplicationByClientID", err)
return
}
var user *user_model.User
if app.UID != 0 {
user, err = user_model.GetUserByID(ctx, app.UID)
if err != nil {
ctx.ServerError("GetUserByID", err)
return
}
}
if !app.ContainsRedirectURI(form.RedirectURI) {
handleAuthorizeError(ctx, AuthorizeError{
ErrorCode: ErrorCodeInvalidRequest,
ErrorDescription: "Unregistered Redirect URI",
State: form.State,
}, "")
return
}
if form.ResponseType != "code" {
handleAuthorizeError(ctx, AuthorizeError{
ErrorCode: ErrorCodeUnsupportedResponseType,
ErrorDescription: "Only code response type is supported.",
State: form.State,
}, form.RedirectURI)
return
}
// pkce support
switch form.CodeChallengeMethod {
case "S256", "plain":
if err := ctx.Session.Set("CodeChallengeMethod", form.CodeChallengeMethod); err != nil {
handleAuthorizeError(ctx, AuthorizeError{
ErrorCode: ErrorCodeServerError,
ErrorDescription: "cannot set code challenge method",
State: form.State,
}, form.RedirectURI)
return
}
if err := ctx.Session.Set("CodeChallenge", form.CodeChallenge); err != nil {
handleAuthorizeError(ctx, AuthorizeError{
ErrorCode: ErrorCodeServerError,
ErrorDescription: "cannot set code challenge",
State: form.State,
}, form.RedirectURI)
return
}
// Here we're just going to try to release the session early
if err := ctx.Session.Release(); err != nil {
// we'll tolerate errors here as they *should* get saved elsewhere
log.Error("Unable to save changes to the session: %v", err)
}
case "":
// "Authorization servers SHOULD reject authorization requests from native apps that don't use PKCE by returning an error message"
// https://datatracker.ietf.org/doc/html/rfc8252#section-8.1
if !app.ConfidentialClient {
// "the authorization endpoint MUST return the authorization error response with the "error" value set to "invalid_request""
// https://datatracker.ietf.org/doc/html/rfc7636#section-4.4.1
handleAuthorizeError(ctx, AuthorizeError{
ErrorCode: ErrorCodeInvalidRequest,
ErrorDescription: "PKCE is required for public clients",
State: form.State,
}, form.RedirectURI)
return
}
default:
// "If the server supporting PKCE does not support the requested transformation, the authorization endpoint MUST return the authorization error response with "error" value set to "invalid_request"."
// https://www.rfc-editor.org/rfc/rfc7636#section-4.4.1
handleAuthorizeError(ctx, AuthorizeError{
ErrorCode: ErrorCodeInvalidRequest,
ErrorDescription: "unsupported code challenge method",
State: form.State,
}, form.RedirectURI)
return
}
grant, err := app.GetGrantByUserID(ctx, ctx.Doer.ID)
if err != nil {
handleServerError(ctx, form.State, form.RedirectURI)
return
}
// Redirect if user already granted access and the application is confidential or trusted otherwise
// I.e. always require authorization for untrusted public clients as recommended by RFC 6749 Section 10.2
if (app.ConfidentialClient || app.SkipSecondaryAuthorization) && grant != nil {
code, err := grant.GenerateNewAuthorizationCode(ctx, form.RedirectURI, form.CodeChallenge, form.CodeChallengeMethod)
if err != nil {
handleServerError(ctx, form.State, form.RedirectURI)
return
}
redirect, err := code.GenerateRedirectURI(form.State)
if err != nil {
handleServerError(ctx, form.State, form.RedirectURI)
return
}
// Update nonce to reflect the new session
if len(form.Nonce) > 0 {
err := grant.SetNonce(ctx, form.Nonce)
if err != nil {
log.Error("Unable to update nonce: %v", err)
}
}
ctx.Redirect(redirect.String())
return
}
// check if additional scopes
ctx.Data["AdditionalScopes"] = oauth2_provider.GrantAdditionalScopes(form.Scope) != auth.AccessTokenScopeAll
// show authorize page to grant access
ctx.Data["Application"] = app
ctx.Data["RedirectURI"] = form.RedirectURI
ctx.Data["State"] = form.State
ctx.Data["Scope"] = form.Scope
ctx.Data["Nonce"] = form.Nonce
if user != nil {
ctx.Data["ApplicationCreatorLinkHTML"] = template.HTML(fmt.Sprintf(`<a href="%s">@%s</a>`, html.EscapeString(user.HomeLink()), html.EscapeString(user.Name)))
} else {
ctx.Data["ApplicationCreatorLinkHTML"] = template.HTML(fmt.Sprintf(`<a href="%s">%s</a>`, html.EscapeString(setting.AppSubURL+"/"), html.EscapeString(setting.AppName)))
}
ctx.Data["ApplicationRedirectDomainHTML"] = template.HTML("<strong>" + html.EscapeString(form.RedirectURI) + "</strong>")
// TODO document SESSION <=> FORM
err = ctx.Session.Set("client_id", app.ClientID)
if err != nil {
handleServerError(ctx, form.State, form.RedirectURI)
log.Error(err.Error())
return
}
err = ctx.Session.Set("redirect_uri", form.RedirectURI)
if err != nil {
handleServerError(ctx, form.State, form.RedirectURI)
log.Error(err.Error())
return
}
err = ctx.Session.Set("state", form.State)
if err != nil {
handleServerError(ctx, form.State, form.RedirectURI)
log.Error(err.Error())
return
}
// Here we're just going to try to release the session early
if err := ctx.Session.Release(); err != nil {
// we'll tolerate errors here as they *should* get saved elsewhere
log.Error("Unable to save changes to the session: %v", err)
}
ctx.HTML(http.StatusOK, tplGrantAccess)
}
// 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 ctx.Session.Get("client_id") != form.ClientID || ctx.Session.Get("state") != form.State ||
ctx.Session.Get("redirect_uri") != form.RedirectURI {
ctx.HTTPError(http.StatusBadRequest)
return
}
if !form.Granted {
handleAuthorizeError(ctx, AuthorizeError{
State: form.State,
ErrorDescription: "the request is denied",
ErrorCode: ErrorCodeAccessDenied,
}, form.RedirectURI)
return
}
app, err := auth.GetOAuth2ApplicationByClientID(ctx, form.ClientID)
if err != nil {
ctx.ServerError("GetOAuth2ApplicationByClientID", err)
return
}
grant, err := app.GetGrantByUserID(ctx, ctx.Doer.ID)
if err != nil {
handleServerError(ctx, form.State, form.RedirectURI)
return
}
if grant == nil {
grant, err = app.CreateGrant(ctx, ctx.Doer.ID, form.Scope)
if err != nil {
handleAuthorizeError(ctx, AuthorizeError{
State: form.State,
ErrorDescription: "cannot create grant for user",
ErrorCode: ErrorCodeServerError,
}, form.RedirectURI)
return
}
} else if grant.Scope != form.Scope {
handleAuthorizeError(ctx, AuthorizeError{
State: form.State,
ErrorDescription: "a grant exists with different scope",
ErrorCode: ErrorCodeServerError,
}, form.RedirectURI)
return
}
if len(form.Nonce) > 0 {
err := grant.SetNonce(ctx, form.Nonce)
if err != nil {
log.Error("Unable to update nonce: %v", err)
}
}
var codeChallenge, codeChallengeMethod string
codeChallenge, _ = ctx.Session.Get("CodeChallenge").(string)
codeChallengeMethod, _ = ctx.Session.Get("CodeChallengeMethod").(string)
code, err := grant.GenerateNewAuthorizationCode(ctx, form.RedirectURI, codeChallenge, codeChallengeMethod)
if err != nil {
handleServerError(ctx, form.State, form.RedirectURI)
return
}
redirect, err := code.GenerateRedirectURI(form.State)
if err != nil {
handleServerError(ctx, form.State, form.RedirectURI)
return
}
ctx.Redirect(redirect.String(), http.StatusSeeOther)
}
// OIDCWellKnown generates JSON so OIDC clients know Gitea's capabilities
func OIDCWellKnown(ctx *context.Context) {
if !setting.OAuth2.Enabled {
http.NotFound(ctx.Resp, ctx.Req)
return
}
jwtRegisteredClaims := oauth2_provider.NewJwtRegisteredClaimsFromUser("well-known", 0, nil)
ctx.Data["OidcIssuer"] = jwtRegisteredClaims.Issuer // use the consistent issuer from the JWT registered claims
ctx.Data["OidcBaseUrl"] = strings.TrimSuffix(setting.AppURL, "/")
ctx.Data["SigningKeyMethodAlg"] = oauth2_provider.DefaultSigningKey.SigningMethod().Alg()
ctx.JSONTemplate("user/auth/oidc_wellknown")
}
// OIDCKeys generates the JSON Web Key Set
func OIDCKeys(ctx *context.Context) {
jwk, err := oauth2_provider.DefaultSigningKey.ToJWK()
if err != nil {
log.Error("Error converting signing key to JWK: %v", err)
ctx.HTTPError(http.StatusInternalServerError)
return
}
jwk["use"] = "sig"
jwks := map[string][]map[string]string{
"keys": {
jwk,
},
}
ctx.Resp.Header().Set("Content-Type", "application/json")
enc := json.NewEncoder(ctx.Resp)
if err := enc.Encode(jwks); err != nil {
log.Error("Failed to encode representation as json. Error: %v", err)
}
}
// AccessTokenOAuth manages all access token requests by the client
func AccessTokenOAuth(ctx *context.Context) {
form := *web.GetForm(ctx).(*forms.AccessTokenForm)
// if there is no ClientID or ClientSecret in the request body, fill these fields by the Authorization header and ensure the provided field matches the Authorization header
if form.ClientID == "" || form.ClientSecret == "" {
if authHeader := ctx.Req.Header.Get("Authorization"); authHeader != "" {
parsed, ok := httpauth.ParseAuthorizationHeader(authHeader)
if !ok || parsed.BasicAuth == nil {
handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{
ErrorCode: oauth2_provider.AccessTokenErrorCodeInvalidRequest,
ErrorDescription: "cannot parse basic auth header",
})
return
}
clientID, clientSecret := parsed.BasicAuth.Username, parsed.BasicAuth.Password
// validate that any fields present in the form match the Basic auth header
if form.ClientID != "" && form.ClientID != clientID {
handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{
ErrorCode: oauth2_provider.AccessTokenErrorCodeInvalidRequest,
ErrorDescription: "client_id in request body inconsistent with Authorization header",
})
return
}
form.ClientID = clientID
if form.ClientSecret != "" && form.ClientSecret != clientSecret {
handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{
ErrorCode: oauth2_provider.AccessTokenErrorCodeInvalidRequest,
ErrorDescription: "client_secret in request body inconsistent with Authorization header",
})
return
}
form.ClientSecret = clientSecret
}
}
serverKey := oauth2_provider.DefaultSigningKey
clientKey := serverKey
if serverKey.IsSymmetric() {
var err error
clientKey, err = oauth2_provider.CreateJWTSigningKey(serverKey.SigningMethod().Alg(), []byte(form.ClientSecret))
if err != nil {
handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{
ErrorCode: oauth2_provider.AccessTokenErrorCodeInvalidRequest,
ErrorDescription: "Error creating signing key",
})
return
}
}
switch form.GrantType {
case "refresh_token":
handleRefreshToken(ctx, form, serverKey, clientKey)
case "authorization_code":
handleAuthorizationCode(ctx, form, serverKey, clientKey)
default:
handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{
ErrorCode: oauth2_provider.AccessTokenErrorCodeUnsupportedGrantType,
ErrorDescription: "Only refresh_token or authorization_code grant type is supported",
})
}
}
func handleRefreshToken(ctx *context.Context, form forms.AccessTokenForm, serverKey, clientKey oauth2_provider.JWTSigningKey) {
app, err := auth.GetOAuth2ApplicationByClientID(ctx, form.ClientID)
if err != nil {
handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{
ErrorCode: oauth2_provider.AccessTokenErrorCodeInvalidClient,
ErrorDescription: fmt.Sprintf("cannot load client with client id: %q", form.ClientID),
})
return
}
// "The authorization server MUST ... require client authentication for confidential clients"
// https://datatracker.ietf.org/doc/html/rfc6749#section-6
if app.ConfidentialClient && !app.ValidateClientSecret([]byte(form.ClientSecret)) {
errorDescription := "invalid client secret"
if form.ClientSecret == "" {
errorDescription = "invalid empty client secret"
}
// "invalid_client ... Client authentication failed"
// https://datatracker.ietf.org/doc/html/rfc6749#section-5.2
handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{
ErrorCode: oauth2_provider.AccessTokenErrorCodeInvalidClient,
ErrorDescription: errorDescription,
})
return
}
token, err := oauth2_provider.ParseToken(form.RefreshToken, serverKey)
if err != nil {
handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{
ErrorCode: oauth2_provider.AccessTokenErrorCodeUnauthorizedClient,
ErrorDescription: "unable to parse refresh token",
})
return
}
// get grant before increasing counter
grant, err := auth.GetOAuth2GrantByID(ctx, token.GrantID)
if err != nil || grant == nil {
handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{
ErrorCode: oauth2_provider.AccessTokenErrorCodeInvalidGrant,
ErrorDescription: "grant does not exist",
})
return
}
if grant.ApplicationID != app.ID {
handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{
ErrorCode: oauth2_provider.AccessTokenErrorCodeInvalidGrant,
ErrorDescription: "refresh token belongs to a different client",
})
return
}
// check if token got already used
if setting.OAuth2.InvalidateRefreshTokens && (grant.Counter != token.Counter || token.Counter == 0) {
handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{
ErrorCode: oauth2_provider.AccessTokenErrorCodeUnauthorizedClient,
ErrorDescription: "token was already used",
})
log.Warn("A client tried to use a refresh token for grant_id = %d was used twice!", grant.ID)
return
}
accessToken, tokenErr := oauth2_provider.NewAccessTokenResponse(ctx, grant, serverKey, clientKey)
if tokenErr != nil {
handleAccessTokenError(ctx, *tokenErr)
return
}
ctx.JSON(http.StatusOK, accessToken)
}
func handleAuthorizationCode(ctx *context.Context, form forms.AccessTokenForm, serverKey, clientKey oauth2_provider.JWTSigningKey) {
app, err := auth.GetOAuth2ApplicationByClientID(ctx, form.ClientID)
if err != nil {
handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{
ErrorCode: oauth2_provider.AccessTokenErrorCodeInvalidClient,
ErrorDescription: fmt.Sprintf("cannot load client with client id: '%s'", form.ClientID),
})
return
}
if app.ConfidentialClient && !app.ValidateClientSecret([]byte(form.ClientSecret)) {
errorDescription := "invalid client secret"
if form.ClientSecret == "" {
errorDescription = "invalid empty client secret"
}
handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{
ErrorCode: oauth2_provider.AccessTokenErrorCodeUnauthorizedClient,
ErrorDescription: errorDescription,
})
return
}
if form.RedirectURI != "" && !app.ContainsRedirectURI(form.RedirectURI) {
handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{
ErrorCode: oauth2_provider.AccessTokenErrorCodeUnauthorizedClient,
ErrorDescription: "unexpected redirect URI",
})
return
}
authorizationCode, err := auth.GetOAuth2AuthorizationByCode(ctx, form.Code)
if err != nil || authorizationCode == nil {
handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{
ErrorCode: oauth2_provider.AccessTokenErrorCodeUnauthorizedClient,
ErrorDescription: "client is not authorized",
})
return
}
if authorizationCode.IsExpired() {
_ = authorizationCode.Invalidate(ctx)
handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{
ErrorCode: oauth2_provider.AccessTokenErrorCodeInvalidGrant,
ErrorDescription: "authorization code expired",
})
return
}
// check if code verifier authorizes the client, PKCE support
if !authorizationCode.ValidateCodeChallenge(form.CodeVerifier) {
handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{
ErrorCode: oauth2_provider.AccessTokenErrorCodeUnauthorizedClient,
ErrorDescription: "failed PKCE code challenge",
})
return
}
if authorizationCode.RedirectURI != "" && form.RedirectURI != authorizationCode.RedirectURI {
handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{
ErrorCode: oauth2_provider.AccessTokenErrorCodeInvalidGrant,
ErrorDescription: "redirect_uri differs from the original authorization request",
})
return
}
// check if granted for this application
if authorizationCode.Grant.ApplicationID != app.ID {
handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{
ErrorCode: oauth2_provider.AccessTokenErrorCodeInvalidGrant,
ErrorDescription: "invalid grant",
})
return
}
// remove token from database to deny duplicate usage
if err := authorizationCode.Invalidate(ctx); err != nil {
errDescription := "cannot process your request"
errCode := oauth2_provider.AccessTokenErrorCodeInvalidRequest
if errors.Is(err, auth.ErrOAuth2AuthorizationCodeInvalidated) {
errDescription = "authorization code already used"
errCode = oauth2_provider.AccessTokenErrorCodeInvalidGrant
}
handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{
ErrorCode: errCode,
ErrorDescription: errDescription,
})
return
}
resp, tokenErr := oauth2_provider.NewAccessTokenResponse(ctx, authorizationCode.Grant, serverKey, clientKey)
if tokenErr != nil {
handleAccessTokenError(ctx, *tokenErr)
return
}
// send successful response
ctx.JSON(http.StatusOK, resp)
}
func handleAccessTokenError(ctx *context.Context, acErr oauth2_provider.AccessTokenError) {
ctx.JSON(http.StatusBadRequest, acErr)
}
func handleServerError(ctx *context.Context, state, redirectURI string) {
handleAuthorizeError(ctx, AuthorizeError{
ErrorCode: ErrorCodeServerError,
ErrorDescription: "A server error occurred",
State: state,
}, redirectURI)
}
func handleAuthorizeError(ctx *context.Context, authErr AuthorizeError, redirectURI string) {
if redirectURI == "" {
log.Warn("Authorization failed: %v", authErr.ErrorDescription)
ctx.Data["Error"] = authErr
ctx.HTML(http.StatusBadRequest, tplGrantError)
return
}
redirect, err := url.Parse(redirectURI)
if err != nil {
ctx.ServerError("url.Parse", err)
return
}
q := redirect.Query()
q.Set("error", string(authErr.ErrorCode))
q.Set("error_description", authErr.ErrorDescription)
q.Set("state", authErr.State)
redirect.RawQuery = q.Encode()
ctx.Redirect(redirect.String(), http.StatusSeeOther)
}
+93
View File
@@ -0,0 +1,93 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package auth
import (
"fmt"
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"
"github.com/markbates/goth"
)
func oauth2SignInSync(ctx *context.Context, authSourceID int64, u *user_model.User, gothUser goth.User) {
oauth2UpdateAvatarIfNeed(ctx, gothUser.AvatarURL, u)
authSource, err := auth.GetSourceByID(ctx, authSourceID)
if err != nil {
ctx.ServerError("GetSourceByID", err)
return
}
oauth2Source, _ := authSource.Cfg.(*oauth2.Source)
if !authSource.IsOAuth2() || oauth2Source == nil {
ctx.ServerError("oauth2SignInSync", fmt.Errorf("source %s is not an OAuth2 source", gothUser.Provider))
return
}
// sync full name
fullNameKey := util.IfZero(oauth2Source.FullNameClaimName, "name")
fullName, _ := gothUser.RawData[fullNameKey].(string)
fullName = util.IfZero(fullName, gothUser.Name)
// need to update if the user has no full name set
shouldUpdateFullName := u.FullName == ""
// force to update if the attribute is set
shouldUpdateFullName = shouldUpdateFullName || oauth2Source.FullNameClaimName != ""
// only update if the full name is different
shouldUpdateFullName = shouldUpdateFullName && u.FullName != fullName
if shouldUpdateFullName {
u.FullName = fullName
if err := user_model.UpdateUserCols(ctx, u, "full_name"); err != nil {
log.Error("Unable to sync OAuth2 user full name %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)
}
}
func oauth2SyncGetSSHKeys(source *oauth2.Source, gothUser *goth.User) ([]string, error) {
value, exists := gothUser.RawData[source.SSHPublicKeyClaimName]
if !exists {
return []string{}, nil
}
rawSlice, ok := value.([]any)
if !ok {
return nil, fmt.Errorf("invalid SSH public key value type: %T", value)
}
sshKeys := make([]string, 0, len(rawSlice))
for _, v := range rawSlice {
str, ok := v.(string)
if !ok {
return nil, fmt.Errorf("invalid SSH public key value item type: %T", v)
}
sshKeys = append(sshKeys, str)
}
return sshKeys, nil
}
func oauth2UpdateSSHPubIfNeed(ctx *context.Context, authSource *auth.Source, gothUser *goth.User, user *user_model.User) error {
oauth2Source, _ := authSource.Cfg.(*oauth2.Source)
if oauth2Source == nil || oauth2Source.SSHPublicKeyClaimName == "" {
return nil
}
sshKeys, err := oauth2SyncGetSSHKeys(oauth2Source, gothUser)
if err != nil {
return err
}
if !asymkey_model.SynchronizePublicKeys(ctx, user, authSource, sshKeys, false) {
return nil
}
return asymkey_service.RewriteAllPublicKeys(ctx)
}
+75
View File
@@ -0,0 +1,75 @@
// Copyright 2021 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package auth
import (
"testing"
"gitea.dev/models/auth"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
"gitea.dev/services/oauth2_provider"
"github.com/golang-jwt/jwt/v5"
"github.com/stretchr/testify/assert"
)
func createAndParseToken(t *testing.T, grant *auth.OAuth2Grant) *oauth2_provider.OIDCToken {
signingKey, err := oauth2_provider.CreateJWTSigningKey("HS256", make([]byte, 32))
assert.NoError(t, err)
assert.NotNil(t, signingKey)
response, terr := oauth2_provider.NewAccessTokenResponse(t.Context(), grant, signingKey, signingKey)
assert.Nil(t, terr)
assert.NotNil(t, response)
parsedToken, err := jwt.ParseWithClaims(response.IDToken, &oauth2_provider.OIDCToken{}, func(token *jwt.Token) (any, error) {
assert.NotNil(t, token.Method)
assert.Equal(t, signingKey.SigningMethod().Alg(), token.Method.Alg())
return signingKey.VerifyKey(), nil
})
assert.NoError(t, err)
assert.True(t, parsedToken.Valid)
oidcToken, ok := parsedToken.Claims.(*oauth2_provider.OIDCToken)
assert.True(t, ok)
assert.NotNil(t, oidcToken)
return oidcToken
}
func TestNewAccessTokenResponse_OIDCToken(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
grants, err := auth.GetOAuth2GrantsByUserID(t.Context(), 3)
assert.NoError(t, err)
assert.Len(t, grants, 1)
// Scopes: openid
oidcToken := createAndParseToken(t, grants[0])
assert.Empty(t, oidcToken.Name)
assert.Empty(t, oidcToken.PreferredUsername)
assert.Empty(t, oidcToken.Profile)
assert.Empty(t, oidcToken.Picture)
assert.Empty(t, oidcToken.Website)
assert.Empty(t, oidcToken.UpdatedAt)
assert.Empty(t, oidcToken.Email)
assert.False(t, oidcToken.EmailVerified)
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5})
grants, err = auth.GetOAuth2GrantsByUserID(t.Context(), user.ID)
assert.NoError(t, err)
assert.Len(t, grants, 1)
// Scopes: openid profile email
oidcToken = createAndParseToken(t, grants[0])
assert.Equal(t, user.DisplayName(), oidcToken.Name)
assert.Equal(t, user.Name, oidcToken.PreferredUsername)
assert.Equal(t, user.HTMLURL(t.Context()), oidcToken.Profile)
assert.Equal(t, user.AvatarLink(t.Context()), oidcToken.Picture)
assert.Equal(t, user.Website, oidcToken.Website)
assert.Equal(t, user.UpdatedUnix, oidcToken.UpdatedAt)
assert.Equal(t, user.Email, oidcToken.Email)
assert.Equal(t, user.IsActive, oidcToken.EmailVerified)
}
+379
View File
@@ -0,0 +1,379 @@
// Copyright 2017 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package auth
import (
"errors"
"net/http"
"net/url"
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 (
tplSignInOpenID templates.TplName = "user/auth/signin_openid"
tplConnectOID templates.TplName = "user/auth/signup_openid_connect"
tplSignUpOID templates.TplName = "user/auth/signup_openid_register"
)
// SignInOpenID render sign in page
func SignInOpenID(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("sign_in")
if ctx.FormString("openid.return_to") != "" {
signInOpenIDVerify(ctx)
return
}
if performAutoLogin(ctx) {
return
}
ctx.Data["PageIsSignIn"] = true
ctx.Data["PageIsLoginOpenID"] = true
ctx.HTML(http.StatusOK, tplSignInOpenID)
}
// Check if the given OpenID URI is allowed by blacklist/whitelist
func allowedOpenIDURI(uri string) (err error) {
// In case a Whitelist is present, URI must be in it
// in order to be accepted
if len(setting.Service.OpenIDWhitelist) != 0 {
for _, pat := range setting.Service.OpenIDWhitelist {
if pat.MatchString(uri) {
return nil // pass
}
}
// must match one of this or be refused
return errors.New("URI not allowed by whitelist")
}
// A blacklist match expliclty forbids
for _, pat := range setting.Service.OpenIDBlacklist {
if pat.MatchString(uri) {
return errors.New("URI forbidden by blacklist")
}
}
return nil
}
// SignInOpenIDPost response for openid sign in request
func SignInOpenIDPost(ctx *context.Context) {
form := web.GetForm(ctx).(*forms.SignInOpenIDForm)
ctx.Data["Title"] = ctx.Tr("sign_in")
ctx.Data["PageIsSignIn"] = true
ctx.Data["PageIsLoginOpenID"] = true
if ctx.HasError() {
ctx.HTML(http.StatusOK, tplSignInOpenID)
return
}
id, err := openid.Normalize(form.Openid)
if err != nil {
ctx.RenderWithErrDeprecated(err.Error(), tplSignInOpenID, &form)
return
}
form.Openid = id
log.Trace("OpenID uri: " + id)
err = allowedOpenIDURI(id)
if err != nil {
ctx.RenderWithErrDeprecated(err.Error(), tplSignInOpenID, &form)
return
}
redirectTo := setting.AppURL + "user/login/openid"
url, err := openid.RedirectURL(id, redirectTo, setting.AppURL)
if err != nil {
log.Error("Error in OpenID redirect URL: %s, %v", redirectTo, err.Error())
ctx.RenderWithErrDeprecated("Unable to find OpenID provider in "+redirectTo, tplSignInOpenID, &form)
return
}
// Request optional nickname and email info
// NOTE: change to `openid.sreg.required` to require it
url += "&openid.ns.sreg=http%3A%2F%2Fopenid.net%2Fextensions%2Fsreg%2F1.1"
url += "&openid.sreg.optional=nickname%2Cemail"
log.Trace("Form-passed openid-remember: %t", form.Remember)
if err := ctx.Session.Set("openid_signin_remember", form.Remember); err != nil {
log.Error("SignInOpenIDPost: Could not set openid_signin_remember in session: %v", err)
}
if err := ctx.Session.Release(); err != nil {
log.Error("SignInOpenIDPost: Unable to save changes to the session: %v", err)
}
ctx.Redirect(url)
}
// signInOpenIDVerify handles response from OpenID provider
func signInOpenIDVerify(ctx *context.Context) {
log.Trace("Incoming call to: %s", ctx.Req.URL.String())
fullURL := setting.AppURL + ctx.Req.URL.String()[1:]
log.Trace("Full URL: %s", fullURL)
id, err := openid.Verify(fullURL)
if err != nil {
ctx.RenderWithErrDeprecated(err.Error(), tplSignInOpenID, &forms.SignInOpenIDForm{
Openid: id,
})
return
}
log.Trace("Verified ID: %s", id)
/* Now we should seek for the user and log him in, or prompt
* to register if not found */
u, err := user_model.GetUserByOpenID(ctx, id)
if err != nil {
if !user_model.IsErrUserNotExist(err) {
ctx.RenderWithErrDeprecated(err.Error(), tplSignInOpenID, &forms.SignInOpenIDForm{
Openid: id,
})
return
}
log.Error("signInOpenIDVerify: %v", err)
}
if u != nil {
log.Trace("User exists, logging in")
remember, _ := ctx.Session.Get("openid_signin_remember").(bool)
log.Trace("Session stored openid-remember: %t", remember)
handleSignIn(ctx, u, remember)
return
}
log.Trace("User with openid: %s does not exist, should connect or register", id)
parsedURL, err := url.Parse(fullURL)
if err != nil {
ctx.RenderWithErrDeprecated(err.Error(), tplSignInOpenID, &forms.SignInOpenIDForm{
Openid: id,
})
return
}
values, err := url.ParseQuery(parsedURL.RawQuery)
if err != nil {
ctx.RenderWithErrDeprecated(err.Error(), tplSignInOpenID, &forms.SignInOpenIDForm{
Openid: id,
})
return
}
email := values.Get("openid.sreg.email")
nickname := values.Get("openid.sreg.nickname")
log.Trace("User has email=%s and nickname=%s", email, nickname)
if email != "" {
u, err = user_model.GetUserByEmail(ctx, email)
if err != nil {
if !user_model.IsErrUserNotExist(err) {
ctx.RenderWithErrDeprecated(err.Error(), tplSignInOpenID, &forms.SignInOpenIDForm{
Openid: id,
})
return
}
log.Error("signInOpenIDVerify: %v", err)
}
if u != nil {
log.Trace("Local user %s has OpenID provided email %s", u.LowerName, email)
}
}
if u == nil && nickname != "" {
u, _ = user_model.GetUserByName(ctx, nickname)
if err != nil {
if !user_model.IsErrUserNotExist(err) {
ctx.RenderWithErrDeprecated(err.Error(), tplSignInOpenID, &forms.SignInOpenIDForm{
Openid: id,
})
return
}
}
if u != nil {
log.Trace("Local user %s has OpenID provided nickname %s", u.LowerName, nickname)
}
}
if u != nil {
nickname = u.LowerName
}
if err := updateSession(ctx, nil, map[string]any{
"openid_verified_uri": id,
"openid_determined_email": email,
"openid_determined_username": nickname,
}); err != nil {
ctx.ServerError("updateSession", err)
return
}
if u != nil || !setting.Service.EnableOpenIDSignUp || setting.Service.AllowOnlyInternalRegistration {
ctx.Redirect(setting.AppSubURL + "/user/openid/connect")
} else {
ctx.Redirect(setting.AppSubURL + "/user/openid/register")
}
}
func prepareConnectOpenIDPageData(ctx *context.Context) (oid string) {
oid, _ = ctx.Session.Get("openid_verified_uri").(string)
if oid == "" {
ctx.Redirect(setting.AppSubURL + "/user/login/openid")
return ""
}
ctx.Data["Title"] = "OpenID connect"
ctx.Data["PageIsSignIn"] = true
ctx.Data["PageIsOpenIDConnect"] = true
ctx.Data["OpenID"] = oid
prepareCommonAuthPageData(ctx, CommonAuthOptions{EnableCaptcha: false})
return oid
}
// ConnectOpenID shows a form to connect an OpenID URI to an existing account
func ConnectOpenID(ctx *context.Context) {
oid := prepareConnectOpenIDPageData(ctx)
if oid == "" {
return
}
userName, _ := ctx.Session.Get("openid_determined_username").(string)
if userName != "" {
ctx.Data["user_name"] = userName
}
ctx.HTML(http.StatusOK, tplConnectOID)
}
// ConnectOpenIDPost handles submission of a form to connect an OpenID URI to an existing account
func ConnectOpenIDPost(ctx *context.Context) {
form := web.GetForm(ctx).(*forms.ConnectOpenIDForm)
oid := prepareConnectOpenIDPageData(ctx)
if oid == "" {
return
}
u, _, err := auth.UserSignIn(ctx, form.UserName, form.Password)
if err != nil {
handleSignInError(ctx, form.UserName, &form, tplConnectOID, "ConnectOpenIDPost", err)
return
}
// add OpenID for the user
userOID := &user_model.UserOpenID{UID: u.ID, URI: oid}
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
}
ctx.ServerError("AddUserOpenID", err)
return
}
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)
}
func prepareRegisterOpenIDPageData(ctx *context.Context) (oid string) {
oid, _ = ctx.Session.Get("openid_verified_uri").(string)
if oid == "" {
ctx.Redirect(setting.AppSubURL + "/user/login/openid")
return ""
}
ctx.Data["Title"] = "OpenID signup"
ctx.Data["PageIsSignIn"] = true
ctx.Data["PageIsOpenIDRegister"] = true
ctx.Data["OpenID"] = oid
prepareCommonAuthPageData(ctx, CommonAuthOptions{
EnableCaptcha: setting.Service.EnableCaptcha,
})
return oid
}
// RegisterOpenID shows a form to create a new user authenticated via an OpenID URI
func RegisterOpenID(ctx *context.Context) {
oid := prepareRegisterOpenIDPageData(ctx)
if oid == "" {
return
}
userName, _ := ctx.Session.Get("openid_determined_username").(string)
if userName != "" {
ctx.Data["user_name"] = userName
}
email, _ := ctx.Session.Get("openid_determined_email").(string)
if email != "" {
ctx.Data["email"] = email
}
ctx.HTML(http.StatusOK, tplSignUpOID)
}
// RegisterOpenIDPost handles submission of a form to create a new user authenticated via an OpenID URI
func RegisterOpenIDPost(ctx *context.Context) {
oid := prepareRegisterOpenIDPageData(ctx)
if oid == "" {
return
}
form := web.GetForm(ctx).(*forms.SignUpOpenIDForm)
if setting.Service.AllowOnlyInternalRegistration {
ctx.HTTPError(http.StatusForbidden)
return
}
if setting.Service.EnableCaptcha {
if err := ctx.Req.ParseForm(); err != nil {
ctx.ServerError("", err)
return
}
context.VerifyCaptcha(ctx, tplSignUpOID, form)
}
length := max(setting.MinPasswordLength, 256)
password := util.CryptoRandomString(int64(length))
u := &user_model.User{
Name: form.UserName,
Email: form.Email,
Passwd: password,
}
if !createUserInContext(ctx, tplSignUpOID, form, u, nil, nil) {
// error already handled
return
}
// add OpenID for the user
userOID := &user_model.UserOpenID{UID: u.ID, URI: oid}
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
}
ctx.ServerError("AddUserOpenID", err)
return
}
if !handleUserCreated(ctx, u, nil) {
// error already handled
return
}
remember, _ := ctx.Session.Get("openid_signin_remember").(bool)
log.Trace("Session stored openid-remember: %t", remember)
handleSignIn(ctx, u, remember)
}
+311
View File
@@ -0,0 +1,311 @@
// Copyright 2019 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package auth
import (
"errors"
"net/http"
"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 (
// tplMustChangePassword template for updating a user's password
tplMustChangePassword templates.TplName = "user/auth/change_passwd"
tplForgotPassword templates.TplName = "user/auth/forgot_passwd"
tplResetPassword templates.TplName = "user/auth/reset_passwd"
)
// ForgotPasswd render the forget password page
func ForgotPasswd(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("auth.forgot_password_title")
if setting.MailService == nil {
log.Warn("no mail service configured")
ctx.Data["IsResetDisable"] = true
ctx.HTML(http.StatusOK, tplForgotPassword)
return
}
ctx.Data["Email"] = ctx.FormString("email")
ctx.Data["IsResetRequest"] = true
ctx.HTML(http.StatusOK, tplForgotPassword)
}
// ForgotPasswdPost response for forget password request
func ForgotPasswdPost(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("auth.forgot_password_title")
if setting.MailService == nil {
ctx.NotFound(nil)
return
}
ctx.Data["IsResetRequest"] = true
email := ctx.FormString("email")
ctx.Data["Email"] = email
u, err := user_model.GetUserByEmail(ctx, email)
if err != nil {
if user_model.IsErrUserNotExist(err) {
ctx.Data["ResetPwdCodeLives"] = timeutil.MinutesToFriendly(setting.Service.ResetPwdCodeLives, ctx.Locale)
ctx.Data["IsResetSent"] = true
ctx.HTML(http.StatusOK, tplForgotPassword)
return
}
ctx.ServerError("user.ResetPasswd(check existence)", err)
return
}
if !u.IsLocal() && !u.IsOAuth2() {
ctx.Data["Err_Email"] = true
ctx.RenderWithErrDeprecated(ctx.Tr("auth.non_local_account"), tplForgotPassword, nil)
return
}
if ctx.Cache.IsExist("MailResendLimit_" + u.LowerName) {
ctx.Data["ResendLimited"] = true
ctx.HTML(http.StatusOK, tplForgotPassword)
return
}
mailer.SendResetPasswordMail(u)
if err = ctx.Cache.Put("MailResendLimit_"+u.LowerName, u.LowerName, 180); err != nil {
log.Error("Set cache(MailResendLimit) fail: %v", err)
}
ctx.Data["ResetPwdCodeLives"] = timeutil.MinutesToFriendly(setting.Service.ResetPwdCodeLives, ctx.Locale)
ctx.Data["IsResetSent"] = true
ctx.HTML(http.StatusOK, tplForgotPassword)
}
func commonResetPassword(ctx *context.Context) (*user_model.User, *auth.TwoFactor) {
code := ctx.FormString("code")
ctx.Data["Title"] = ctx.Tr("auth.reset_password")
ctx.Data["Code"] = code
if nil != ctx.Doer {
ctx.Data["user_signed_in"] = true
}
if len(code) == 0 {
ctx.Flash.Error(ctx.Tr("auth.invalid_code_forgot_password", setting.AppSubURL+"/user/forgot_password"), true)
return nil, nil
}
// Fail early, don't frustrate the user
u := user_model.VerifyUserTimeLimitCode(ctx, &user_model.TimeLimitCodeOptions{Purpose: user_model.TimeLimitCodeResetPassword}, code)
if u == nil {
ctx.Flash.Error(ctx.Tr("auth.invalid_code_forgot_password", setting.AppSubURL+"/user/forgot_password"), true)
return nil, nil
}
twofa, err := auth.GetTwoFactorByUID(ctx, u.ID)
if err != nil {
if !auth.IsErrTwoFactorNotEnrolled(err) {
ctx.HTTPError(http.StatusInternalServerError, "CommonResetPassword", err.Error())
return nil, nil
}
} else {
ctx.Data["has_two_factor"] = true
ctx.Data["scratch_code"] = ctx.FormBool("scratch_code")
}
// Show the user that they are affecting the account that they intended to
ctx.Data["user_email"] = u.Email
if nil != ctx.Doer && u.ID != ctx.Doer.ID {
ctx.Flash.Error(ctx.Tr("auth.reset_password_wrong_user", ctx.Doer.Email, u.Email), true)
return nil, nil
}
return u, twofa
}
// ResetPasswd render the account recovery page
func ResetPasswd(ctx *context.Context) {
ctx.Data["IsResetForm"] = true
commonResetPassword(ctx)
if ctx.Written() {
return
}
ctx.HTML(http.StatusOK, tplResetPassword)
}
// ResetPasswdPost response from account recovery request
func ResetPasswdPost(ctx *context.Context) {
u, twofa := commonResetPassword(ctx)
if ctx.Written() {
return
}
if u == nil {
// Flash error has been set
ctx.HTML(http.StatusOK, tplResetPassword)
return
}
// Handle two-factor
regenerateScratchToken := false
if twofa != nil {
if ctx.FormBool("scratch_code") {
if !twofa.VerifyScratchToken(ctx.FormString("token")) {
ctx.Data["IsResetForm"] = true
ctx.Data["Err_Token"] = true
ctx.RenderWithErrDeprecated(ctx.Tr("auth.twofa_scratch_token_incorrect"), tplResetPassword, nil)
return
}
regenerateScratchToken = true
} else {
passcode := ctx.FormString("passcode")
ok, err := twofa.ValidateTOTP(passcode)
if err != nil {
ctx.HTTPError(http.StatusInternalServerError, "ValidateTOTP", err.Error())
return
}
if !ok || twofa.LastUsedPasscode == passcode {
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
}
}
}
opts := &user_service.UpdateAuthOptions{
Password: optional.Some(ctx.FormString("password")),
MustChangePassword: optional.Some(false),
}
if err := user_service.UpdateAuth(ctx, u, opts); err != nil {
ctx.Data["IsResetForm"] = true
ctx.Data["Err_Password"] = true
switch {
case errors.Is(err, password.ErrMinLength):
ctx.RenderWithErrDeprecated(ctx.Tr("auth.password_too_short", setting.MinPasswordLength), tplResetPassword, nil)
case errors.Is(err, password.ErrComplexity):
ctx.RenderWithErrDeprecated(password.BuildComplexityError(ctx.Locale), tplResetPassword, nil)
case errors.Is(err, password.ErrIsPwned):
ctx.RenderWithErrDeprecated(ctx.Tr("auth.password_pwned", "https://haveibeenpwned.com/Passwords"), tplResetPassword, nil)
case password.IsErrIsPwnedRequest(err):
ctx.RenderWithErrDeprecated(ctx.Tr("auth.password_pwned_err"), tplResetPassword, nil)
default:
ctx.ServerError("UpdateAuth", err)
}
return
}
log.Trace("User password reset: %s", u.Name)
ctx.Data["IsResetFailed"] = true
remember := len(ctx.FormString("remember")) != 0
if regenerateScratchToken {
// Invalidate the scratch token.
_, err := twofa.GenerateScratchToken()
if err != nil {
ctx.ServerError("UserSignIn", err)
return
}
if err = auth.UpdateTwoFactor(ctx, twofa); err != nil {
ctx.ServerError("UserSignIn", err)
return
}
handleSignInFull(ctx, u, remember)
if ctx.Written() {
return
}
ctx.Flash.Info(ctx.Tr("auth.twofa_scratch_used"))
ctx.Redirect(setting.AppSubURL + "/user/settings/security")
return
}
handleSignIn(ctx, u, remember)
}
// MustChangePassword renders the page to change a user's password
func MustChangePassword(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("auth.must_change_password")
ctx.Data["ChangePasscodeLink"] = setting.AppSubURL + "/user/settings/change_password"
ctx.Data["MustChangePassword"] = true
ctx.HTML(http.StatusOK, tplMustChangePassword)
}
// MustChangePasswordPost response for updating a user's password after their
// account was created by an admin
func MustChangePasswordPost(ctx *context.Context) {
form := web.GetForm(ctx).(*forms.MustChangePasswordForm)
ctx.Data["Title"] = ctx.Tr("auth.must_change_password")
ctx.Data["ChangePasscodeLink"] = setting.AppSubURL + "/user/settings/change_password"
if ctx.HasError() {
ctx.HTML(http.StatusOK, tplMustChangePassword)
return
}
// Make sure only requests for users who are eligible to change their password via
// this method passes through
if !ctx.Doer.MustChangePassword {
ctx.ServerError("MustUpdatePassword", errors.New("cannot update password. Please visit the settings page"))
return
}
if form.Password != form.Retype {
ctx.Data["Err_Password"] = true
ctx.RenderWithErrDeprecated(ctx.Tr("form.password_not_match"), tplMustChangePassword, &form)
return
}
opts := &user_service.UpdateAuthOptions{
Password: optional.Some(form.Password),
MustChangePassword: optional.Some(false),
}
if err := user_service.UpdateAuth(ctx, ctx.Doer, opts); err != nil {
switch {
case errors.Is(err, password.ErrMinLength):
ctx.Data["Err_Password"] = true
ctx.RenderWithErrDeprecated(ctx.Tr("auth.password_too_short", setting.MinPasswordLength), tplMustChangePassword, &form)
case errors.Is(err, password.ErrComplexity):
ctx.Data["Err_Password"] = true
ctx.RenderWithErrDeprecated(password.BuildComplexityError(ctx.Locale), tplMustChangePassword, &form)
case errors.Is(err, password.ErrIsPwned):
ctx.Data["Err_Password"] = true
ctx.RenderWithErrDeprecated(ctx.Tr("auth.password_pwned", "https://haveibeenpwned.com/Passwords"), tplMustChangePassword, &form)
case password.IsErrIsPwnedRequest(err):
ctx.Data["Err_Password"] = true
ctx.RenderWithErrDeprecated(ctx.Tr("auth.password_pwned_err"), tplMustChangePassword, &form)
default:
ctx.ServerError("UpdateAuth", err)
}
return
}
ctx.Flash.Success(ctx.Tr("settings.change_password_success"))
log.Trace("User updated password: %s", ctx.Doer.Name)
redirectAfterAuth(ctx)
}
+276
View File
@@ -0,0 +1,276 @@
// Copyright 2018 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package auth
import (
"encoding/binary"
"errors"
"net/http"
"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"
)
var tplWebAuthn templates.TplName = "user/auth/webauthn"
// WebAuthn shows the WebAuthn login page
func WebAuthn(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("twofa")
if performAutoLogin(ctx) {
return
}
// Ensure user is in a 2FA session.
if ctx.Session.Get("twofaUid") == nil {
ctx.ServerError("UserSignIn", errors.New("not in WebAuthn session"))
return
}
hasTwoFactor, err := auth.HasTwoFactorByUID(ctx, ctx.Session.Get("twofaUid").(int64))
if err != nil {
ctx.ServerError("HasTwoFactorByUID", err)
return
}
ctx.Data["HasTwoFactor"] = hasTwoFactor
ctx.HTML(http.StatusOK, tplWebAuthn)
}
// WebAuthnPasskeyAssertion submits a WebAuthn challenge for the passkey login to the browser
func WebAuthnPasskeyAssertion(ctx *context.Context) {
if !setting.Service.EnablePasskeyAuth {
ctx.HTTPError(http.StatusForbidden)
return
}
assertion, sessionData, err := wa.WebAuthn.BeginDiscoverableLogin()
if err != nil {
ctx.ServerError("webauthn.BeginDiscoverableLogin", err)
return
}
if err := ctx.Session.Set("webauthnPasskeyAssertion", sessionData); err != nil {
ctx.ServerError("Session.Set", err)
return
}
ctx.JSON(http.StatusOK, assertion)
}
// WebAuthnPasskeyLogin handles the WebAuthn login process using a Passkey
func WebAuthnPasskeyLogin(ctx *context.Context) {
if !setting.Service.EnablePasskeyAuth {
ctx.HTTPError(http.StatusForbidden)
return
}
sessionData, okData := ctx.Session.Get("webauthnPasskeyAssertion").(*webauthn.SessionData)
if !okData || sessionData == nil {
ctx.ServerError("ctx.Session.Get", errors.New("not in WebAuthn session"))
return
}
defer func() {
_ = ctx.Session.Delete("webauthnPasskeyAssertion")
}()
// Validate the parsed response.
// ParseCredentialRequestResponse+ValidateDiscoverableLogin equals to FinishDiscoverableLogin, but we need to ParseCredentialRequestResponse first to get flags
var user *user_model.User
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)
ctx.Status(http.StatusForbidden)
return
}
cred, err := wa.WebAuthn.ValidateDiscoverableLogin(func(rawID, userHandle []byte) (webauthn.User, error) {
userID, n := binary.Varint(userHandle)
if n <= 0 {
return nil, errors.New("invalid rawID")
}
var err error
user, err = user_model.GetUserByID(ctx, userID)
if err != nil {
return nil, err
}
return wa.NewWebAuthnUser(ctx, user, parsedResponse.Response.AuthenticatorData.Flags), nil
}, *sessionData, parsedResponse)
if err != nil {
// Failed authentication attempt.
log.Info("Failed authentication attempt for passkey from %s: %v", ctx.RemoteAddr(), err)
ctx.Status(http.StatusForbidden)
return
}
if !cred.Flags.UserPresent {
ctx.Status(http.StatusBadRequest)
return
}
if user == nil {
ctx.Status(http.StatusBadRequest)
return
}
// Ensure that the credential wasn't cloned by checking if CloneWarning is set.
// (This is set if the sign counter is less than the one we have stored.)
if cred.Authenticator.CloneWarning {
log.Info("Failed authentication attempt for %s from %s: cloned credential", user.Name, ctx.RemoteAddr())
ctx.Status(http.StatusForbidden)
return
}
// Success! Get the credential and update the sign count with the new value we received.
dbCred, err := auth.GetWebAuthnCredentialByCredID(ctx, user.ID, cred.ID)
if err != nil {
ctx.ServerError("GetWebAuthnCredentialByCredID", err)
return
}
dbCred.SignCount = cred.Authenticator.SignCount
if err := dbCred.UpdateSignCount(ctx); err != nil {
ctx.ServerError("UpdateSignCount", err)
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
}
}
remember := false // TODO: implement remember me
handleSignInFull(ctx, user, remember)
ctx.JSONRedirect(consumeAuthRedirectLink(ctx))
}
// WebAuthnLoginAssertion submits a WebAuthn challenge to the browser
func WebAuthnLoginAssertion(ctx *context.Context) {
// Ensure user is in a WebAuthn session.
idSess, ok := ctx.Session.Get("twofaUid").(int64)
if !ok || idSess == 0 {
ctx.ServerError("UserSignIn", errors.New("not in WebAuthn session"))
return
}
user, err := user_model.GetUserByID(ctx, idSess)
if err != nil {
ctx.ServerError("UserSignIn", err)
return
}
exists, err := auth.ExistsWebAuthnCredentialsForUID(ctx, user.ID)
if err != nil {
ctx.ServerError("UserSignIn", err)
return
}
if !exists {
ctx.ServerError("UserSignIn", errors.New("no device registered"))
return
}
webAuthnUser := wa.NewWebAuthnUser(ctx, user)
assertion, sessionData, err := wa.WebAuthn.BeginLogin(webAuthnUser)
if err != nil {
ctx.ServerError("webauthn.BeginLogin", err)
return
}
if err := ctx.Session.Set("webauthnAssertion", sessionData); err != nil {
ctx.ServerError("Session.Set", err)
return
}
ctx.JSON(http.StatusOK, assertion)
}
// WebAuthnLoginAssertionPost validates the signature and logs the user in
func WebAuthnLoginAssertionPost(ctx *context.Context) {
idSess, ok := ctx.Session.Get("twofaUid").(int64)
sessionData, okData := ctx.Session.Get("webauthnAssertion").(*webauthn.SessionData)
if !ok || !okData || sessionData == nil || idSess == 0 {
ctx.ServerError("UserSignIn", errors.New("not in WebAuthn session"))
return
}
defer func() {
_ = ctx.Session.Delete("webauthnAssertion")
}()
// Load the user from the db
user, err := user_model.GetUserByID(ctx, idSess)
if err != nil {
ctx.ServerError("UserSignIn", err)
return
}
log.Trace("Finishing webauthn authentication with user: %s", user.Name)
// Now we do the equivalent of webauthn.FinishLogin using a combination of our session data
// (from webauthnAssertion) and verify the provided request.0
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)
ctx.Status(http.StatusForbidden)
return
}
// Validate the parsed response.
webAuthnUser := wa.NewWebAuthnUser(ctx, user, parsedResponse.Response.AuthenticatorData.Flags)
cred, err := wa.WebAuthn.ValidateLogin(webAuthnUser, *sessionData, parsedResponse)
if err != nil {
// Failed authentication attempt.
log.Info("Failed authentication attempt for %s from %s: %v", user.Name, ctx.RemoteAddr(), err)
ctx.Status(http.StatusForbidden)
return
}
// Ensure that the credential wasn't cloned by checking if CloneWarning is set.
// (This is set if the sign counter is less than the one we have stored.)
if cred.Authenticator.CloneWarning {
log.Info("Failed authentication attempt for %s from %s: cloned credential", user.Name, ctx.RemoteAddr())
ctx.Status(http.StatusForbidden)
return
}
// Success! Get the credential and update the sign count with the new value we received.
dbCred, err := auth.GetWebAuthnCredentialByCredID(ctx, user.ID, cred.ID)
if err != nil {
ctx.ServerError("GetWebAuthnCredentialByCredID", err)
return
}
dbCred.SignCount = cred.Authenticator.SignCount
if err := dbCred.UpdateSignCount(ctx); err != nil {
ctx.ServerError("UpdateSignCount", err)
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
}
}
remember := ctx.Session.Get("twofaRemember").(bool)
handleSignInFull(ctx, user, remember)
_ = ctx.Session.Delete("twofaUid")
ctx.JSONRedirect(consumeAuthRedirectLink(ctx))
}
+94
View File
@@ -0,0 +1,94 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package web
import (
"errors"
"fmt"
"image/png"
"net/http"
"os"
"path"
"strings"
"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 {
prefix = strings.Trim(prefix, "/")
funcInfo := routing.GetFuncInfo(avatarStorageHandler, prefix)
exeModTime := assetfs.GetExecutableModTime()
fallbackEtag := fmt.Sprintf(`"avatar-%s"`, exeModTime.Format("20060102150405"))
handleError := func(w http.ResponseWriter, req *http.Request, avatarPath string, err error) bool {
if err == nil {
return false
}
if errors.Is(err, os.ErrNotExist) || errors.Is(err, util.ErrNotExist) {
// if avatar doesn't exist, generate a random one and serve it with proper cache control headers
w.Header().Set("Content-Type", "image/png")
if !httpcache.HandleGenericETagPublicCache(req, w, fallbackEtag, &exeModTime) {
if req.Method == http.MethodGet {
img := avatar.RandomImageWithSize(96, []byte(avatarPath))
_ = png.Encode(w, img)
} // else: for HEAD request, just return the headers without body
}
} else {
// for internal errors, log the error and return 500
log.Error("Error when serving avatar %s: %s", req.URL.Path, err)
http.Error(w, "unable to serve avatar image", http.StatusInternalServerError)
}
return true
}
return func(w http.ResponseWriter, req *http.Request) {
defer routing.RecordFuncInfo(req.Context(), funcInfo)()
avatarPath, ok := strings.CutPrefix(req.URL.Path, "/"+prefix+"/")
if !ok {
http.Error(w, "invalid avatar path", http.StatusBadRequest)
return
}
avatarPath = util.PathJoinRelX(avatarPath)
if avatarPath == "" || avatarPath == "." {
http.Error(w, "not found", http.StatusNotFound)
return
}
if storageSetting.ServeDirect() {
// Old logic: no check for existence by Stat, so old code's "errors.Is(err, os.ErrNotExist)" didn't work.
// So in theory, it doesn't work with the non-existing avatar fallback, it just gets the URL and redirects to it.
// Checking "stat" requires one more request to the storage, which is inefficient.
// Workaround: disable "SERVE_DIRECT". Leave the problem to the future.
u, err := objStore.ServeDirectURL(avatarPath, path.Base(avatarPath), req.Method, nil)
if handleError(w, req, avatarPath, err) {
return
}
http.Redirect(w, req, u.String(), http.StatusTemporaryRedirect)
return
}
fr, err := objStore.Open(avatarPath)
if handleError(w, req, avatarPath, err) {
return
}
defer fr.Close()
fi, err := fr.Stat()
if handleError(w, req, avatarPath, err) {
return
}
httpcache.SetCacheControlInHeader(w.Header(), httpcache.CacheControlForPublicStatic())
http.ServeContent(w, req, path.Base(avatarPath), fi.ModTime(), fr)
}
}
+253
View File
@@ -0,0 +1,253 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package devtest
import (
"fmt"
"html/template"
"net/http"
"path"
"strconv"
"strings"
"time"
"unicode"
"gitea.dev/models/asymkey"
"gitea.dev/models/db"
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
func List(ctx *context.Context) {
templateNames, err := templates.AssetFS().ListFiles("devtest", true)
if err != nil {
ctx.ServerError("AssetFS().ListFiles", err)
return
}
var subNames []string
for _, tmplName := range templateNames {
subName := strings.TrimSuffix(tmplName, ".tmpl")
if !strings.HasPrefix(subName, "devtest-") {
subNames = append(subNames, subName)
}
}
ctx.Data["SubNames"] = subNames
ctx.HTML(http.StatusOK, "devtest/devtest-list")
}
func FetchActionTest(ctx *context.Context) {
_ = ctx.Req.ParseForm()
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)
ctx.JSONRedirect("")
}
func prepareMockDataGiteaUI(_ *context.Context) {}
func prepareMockDataBadgeCommitSign(ctx *context.Context) {
var commits []*asymkey.SignCommit
mockUsers, _ := db.Find[user_model.User](ctx, user_model.SearchUserOptions{ListOptions: db.ListOptions{PageSize: 1}})
mockUser := mockUsers[0]
commits = append(commits, &asymkey.SignCommit{
Verification: &asymkey.CommitVerification{},
UserCommit: &user_model.UserCommit{
Commit: &git.Commit{ID: git.Sha1ObjectFormat.EmptyObjectID()},
},
})
commits = append(commits, &asymkey.SignCommit{
Verification: &asymkey.CommitVerification{
Verified: true,
Reason: "name / key-id",
SigningUser: mockUser,
SigningKey: &asymkey.GPGKey{KeyID: "12345678"},
TrustStatus: "trusted",
},
UserCommit: &user_model.UserCommit{
User: mockUser,
Commit: &git.Commit{ID: git.Sha1ObjectFormat.EmptyObjectID()},
},
})
commits = append(commits, &asymkey.SignCommit{
Verification: &asymkey.CommitVerification{
Verified: true,
Reason: "name / key-id",
SigningUser: mockUser,
SigningSSHKey: &asymkey.PublicKey{Fingerprint: "aa:bb:cc:dd:ee"},
TrustStatus: "untrusted",
},
UserCommit: &user_model.UserCommit{
User: mockUser,
Commit: &git.Commit{ID: git.Sha1ObjectFormat.EmptyObjectID()},
},
})
commits = append(commits, &asymkey.SignCommit{
Verification: &asymkey.CommitVerification{
Verified: true,
Reason: "name / key-id",
SigningUser: mockUser,
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()},
},
})
commits = append(commits, &asymkey.SignCommit{
Verification: &asymkey.CommitVerification{
Warning: true,
Reason: "gpg.error",
SigningEmail: "test@example.com",
},
UserCommit: &user_model.UserCommit{
User: mockUser,
Commit: &git.Commit{ID: git.Sha1ObjectFormat.EmptyObjectID()},
},
})
ctx.Data["MockCommits"] = commits
}
func prepareMockDataBadgeActionsSvg(ctx *context.Context) {
fontFamilyNames := strings.Split(badge.DefaultFontFamily, ",")
selectedFontFamilyName := ctx.FormString("font", fontFamilyNames[0])
selectedStyle := ctx.FormString("style", badge.DefaultStyle)
var badges []badge.Badge
badges = append(badges, badge.GenerateBadge("啊啊啊啊啊啊啊啊啊啊啊啊", "🌞🌞🌞🌞🌞", "green"))
for r := range rune(256) {
if unicode.IsPrint(r) {
s := strings.Repeat(string(r), 15)
badges = append(badges, badge.GenerateBadge(s, util.TruncateRunes(s, 7), "green"))
}
}
var badgeSVGs []template.HTML
for i, b := range badges {
b.IDPrefix = "devtest-" + strconv.FormatInt(int64(i), 10) + "-"
b.FontFamily = selectedFontFamilyName
var h template.HTML
var err error
switch selectedStyle {
case badge.StyleFlat:
h, err = ctx.RenderToHTML("shared/actions/runner_badge_flat", map[string]any{"Badge": b})
case badge.StyleFlatSquare:
h, err = ctx.RenderToHTML("shared/actions/runner_badge_flat-square", map[string]any{"Badge": b})
default:
err = fmt.Errorf("unknown badge style: %s", selectedStyle)
}
if err != nil {
ctx.ServerError("RenderToHTML", err)
return
}
badgeSVGs = append(badgeSVGs, h)
}
ctx.Data["BadgeSVGs"] = badgeSVGs
ctx.Data["BadgeFontFamilyNames"] = fontFamilyNames
ctx.Data["SelectedFontFamilyName"] = selectedFontFamilyName
ctx.Data["BadgeStyles"] = badge.GlobalVars().AllStyles
ctx.Data["SelectedStyle"] = selectedStyle
}
func prepareMockDataRelativeTime(ctx *context.Context) {
now := time.Now()
ctx.Data["TimeNow"] = now
ctx.Data["TimePast5s"] = now.Add(-5 * time.Second)
ctx.Data["TimeFuture5s"] = now.Add(5 * time.Second)
ctx.Data["TimePast2m"] = now.Add(-2 * time.Minute)
ctx.Data["TimeFuture2m"] = now.Add(2 * time.Minute)
ctx.Data["TimePast3m"] = now.Add(-3 * time.Minute)
ctx.Data["TimePast1h"] = now.Add(-1 * time.Hour)
ctx.Data["TimePast3h"] = now.Add(-3 * time.Hour)
ctx.Data["TimePast1d"] = now.Add(-24 * time.Hour)
ctx.Data["TimePast2d"] = now.Add(-2 * 24 * time.Hour)
ctx.Data["TimePast3d"] = now.Add(-3 * 24 * time.Hour)
ctx.Data["TimePast26h"] = now.Add(-26 * time.Hour)
ctx.Data["TimePast40d"] = now.Add(-40 * 24 * time.Hour)
ctx.Data["TimePast60d"] = now.Add(-60 * 24 * time.Hour)
ctx.Data["TimePast1y"] = now.Add(-366 * 24 * time.Hour)
ctx.Data["TimeFuture1h"] = now.Add(1 * time.Hour)
ctx.Data["TimeFuture3h"] = now.Add(3 * time.Hour)
ctx.Data["TimeFuture3d"] = now.Add(3 * 24 * time.Hour)
ctx.Data["TimeFuture1y"] = now.Add(366 * 24 * time.Hour)
}
func prepareMockData(ctx *context.Context) {
switch ctx.Req.URL.Path {
case "/devtest/gitea-ui":
prepareMockDataGiteaUI(ctx)
case "/devtest/badge-commit-sign":
prepareMockDataBadgeCommitSign(ctx)
case "/devtest/badge-actions-svg":
prepareMockDataBadgeActionsSvg(ctx)
case "/devtest/relative-time":
prepareMockDataRelativeTime(ctx)
case "/devtest/toast-and-message":
prepareMockDataToastAndMessage(ctx)
case "/devtest/unicode-escape":
prepareMockDataUnicodeEscape(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"
content += "if O𝐾 { } // ambiguous char\n"
content += "if O𝐾 && accessLevel != \"user\u202E \u2066// ambiguous char + invisible char\u2069 \u2066\" { }\n"
content += "str := `\xef` // broken char\n"
content += "str := `\x00 \x19 \x7f` // control char\n"
lineNums := []int{1, 2, 3, 4, 5, 6, 7, 8, 9}
highlightLines := code.HighlightSearchResultCode("demo.go", "", lineNums, content)
escapeStatus := &charset.EscapeStatus{}
lineEscapeStatus := make([]*charset.EscapeStatus, len(highlightLines))
for i, hl := range highlightLines {
lineEscapeStatus[i], hl.FormattedContent = charset.EscapeControlHTML(hl.FormattedContent, ctx.Locale)
escapeStatus = escapeStatus.Or(lineEscapeStatus[i])
}
ctx.Data["HighlightLines"] = highlightLines
ctx.Data["EscapeStatus"] = escapeStatus
ctx.Data["LineEscapeStatus"] = lineEscapeStatus
}
func TmplCommon(ctx *context.Context) {
prepareMockData(ctx)
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,
)
time.Sleep(2 * time.Second)
}
ctx.HTML(http.StatusOK, templates.TplName("devtest"+path.Clean("/"+ctx.PathParam("sub"))))
}
+60
View File
@@ -0,0 +1,60 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package devtest
import (
"net/http"
"strings"
"gitea.dev/modules/templates"
"gitea.dev/modules/util"
"gitea.dev/services/context"
"gitea.dev/services/mailer"
"go.yaml.in/yaml/v4"
)
func MailPreviewRender(ctx *context.Context) {
tmplName := ctx.PathParam("*")
mockDataContent, err := templates.AssetFS().ReadFile("mail/" + tmplName + ".devtest.yml")
mockData := map[string]any{}
if err == nil {
err = yaml.Unmarshal(mockDataContent, &mockData)
if err != nil {
http.Error(ctx.Resp, "Failed to parse mock data: "+err.Error(), http.StatusInternalServerError)
return
}
}
mockData["locale"] = ctx.Locale
err = mailer.LoadedTemplates().BodyTemplates.ExecuteTemplate(ctx.Resp, tmplName, mockData)
if err != nil {
_, _ = ctx.Resp.Write([]byte(err.Error()))
}
}
func prepareMailPreviewRender(ctx *context.Context, tmplName string) {
tmplSubject := mailer.LoadedTemplates().SubjectTemplates.Lookup(tmplName)
// FIXME: MAIL-TEMPLATE-SUBJECT: only "issue" related messages support using subject from templates
subject := "(default subject)"
if tmplSubject != nil {
var buf strings.Builder
err := tmplSubject.Execute(&buf, nil)
if err != nil {
subject = "ERROR: " + err.Error()
} else {
subject = util.IfZero(buf.String(), subject)
}
}
ctx.Data["RenderMailSubject"] = subject
ctx.Data["RenderMailTemplateName"] = tmplName
}
func MailPreview(ctx *context.Context) {
ctx.Data["MailTemplateNames"] = mailer.LoadedTemplates().TemplateNames
tmplName := ctx.FormString("tmpl")
if tmplName != "" {
prepareMailPreviewRender(ctx, tmplName)
}
ctx.HTML(http.StatusOK, "devtest/mail-preview")
}
+410
View File
@@ -0,0 +1,410 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package devtest
import (
"fmt"
mathRand "math/rand/v2"
"net/http"
"slices"
"strconv"
"strings"
"time"
actions_model "gitea.dev/models/actions"
user_model "gitea.dev/models/user"
"gitea.dev/modules/setting"
"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 {
mockCountFirst int
mockCountGeneral int
groupRepeat int
}
func generateMockStepsLog(logCur actions.LogCursor, opts generateMockStepsLogOptions) (stepsLog []*actions.ViewStepLog) {
var mockedLogs []string
mockedLogs = append(mockedLogs, "::group::test group for: step={step}, cursor={cursor}")
mockedLogs = append(mockedLogs, slices.Repeat([]string{"in group msg for: step={step}, cursor={cursor}"}, opts.groupRepeat)...)
mockedLogs = append(mockedLogs, "::endgroup::")
mockedLogs = append(mockedLogs,
"message for: step={step}, cursor={cursor}",
"message for: step={step}, cursor={cursor}",
"##[group]test group for: step={step}, cursor={cursor}",
"in group msg for: step={step}, cursor={cursor}",
"##[endgroup]",
"::error::mock error for: step={step}, cursor={cursor}",
"::warning::mock warning for: step={step}, cursor={cursor}",
"::notice::mock notice for: step={step}, cursor={cursor}",
"::debug::mock debug for: step={step}, cursor={cursor}",
)
// usually the cursor is the "file offset", but here we abuse it as "line number" to make the mock easier, intentionally
cur := logCur.Cursor
// for the first batch, return as many as possible to test the auto-expand and auto-scroll
mockCount := util.Iif(logCur.Cursor == 0, opts.mockCountFirst, opts.mockCountGeneral)
for range mockCount {
logStr := mockedLogs[int(cur)%len(mockedLogs)]
cur++
logStr = strings.ReplaceAll(logStr, "{step}", strconv.Itoa(logCur.Step))
logStr = strings.ReplaceAll(logStr, "{cursor}", strconv.FormatInt(cur, 10))
stepsLog = append(stepsLog, &actions.ViewStepLog{
Step: logCur.Step,
Cursor: cur,
Started: time.Now().Unix() - 1,
Lines: []*actions.ViewStepLogLine{
{Index: cur, Message: logStr, Timestamp: float64(time.Now().UnixNano()) / float64(time.Second)},
},
})
}
return stepsLog
}
func MockActionsView(ctx *context.Context) {
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.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.CanDeleteArtifact = true
resp.State.Run.WorkflowID = "workflow-id"
resp.State.Run.WorkflowLink = "./workflow-link"
resp.State.Run.TriggerEvent = "push"
resp.State.Run.Commit = actions.ViewCommit{
ShortSha: "ccccdddd",
Link: "./commit-link",
Pusher: actions.ViewUser{
DisplayName: "pusher user",
Link: "./pusher-link",
},
Branch: actions.ViewBranch{
Name: "commit-branch",
Link: "./branch-link",
IsDeleted: false,
},
}
now := time.Now()
currentAttemptNum := int64(1)
if attemptID > 0 {
currentAttemptNum = attemptID
}
user2 := &user_model.User{Name: "user2"}
user3 := &user_model.User{Name: "user3"}
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(),
})
}
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
resp.Artifacts = append(resp.Artifacts, &actions.ArtifactsViewItem{
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",
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",
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",
ExpiresUnix: 0,
})
jobLink := func(jobID int64) string {
return fmt.Sprintf("%s/jobs/%d", resp.State.Run.Link, jobID)
}
resp.State.Run.Jobs = append(resp.State.Run.Jobs, &actions.ViewJob{
ID: runID * 10,
Link: jobLink(runID * 10),
JobID: "job-100",
Name: "job 100 (testsubname)",
Status: actions_model.StatusRunning.String(),
CanRerun: true,
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(),
CanRerun: false,
Duration: "2h",
Needs: []string{"job-100"},
})
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",
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(),
CanRerun: false,
Duration: "2m",
Needs: []string{"job-100"},
})
// 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: jobID,
Link: jobLink(jobID),
JobID: "job-dup-test-" + strconv.Itoa(i),
Name: "job dup test " + strconv.Itoa(i),
Status: actions_model.StatusSuccess.String(),
CanRerun: false,
Duration: "2m",
Needs: []string{"job-103", "job-101", "job-100"},
})
}
}
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)
// 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)
)
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,
},
{
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)
}
func fillViewRunResponseCurrentJob(ctx *context.Context, resp *actions.ViewResponse) {
jobID := ctx.PathParamInt64("job")
if jobID == 0 {
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{
Summary: "step 0 (mock slow)",
Duration: time.Hour.String(),
Status: actions_model.StatusRunning.String(),
})
mockLogOptions = append(mockLogOptions, generateMockStepsLogOptions{mockCountFirst: 30, mockCountGeneral: 1, groupRepeat: 3})
resp.State.CurrentJob.Steps = append(resp.State.CurrentJob.Steps, &actions.ViewJobStep{
Summary: "step 1 (mock fast)",
Duration: time.Hour.String(),
Status: actions_model.StatusRunning.String(),
})
mockLogOptions = append(mockLogOptions, generateMockStepsLogOptions{mockCountFirst: 30, mockCountGeneral: 3, groupRepeat: 20})
resp.State.CurrentJob.Steps = append(resp.State.CurrentJob.Steps, &actions.ViewJobStep{
Summary: "step 2 (mock error)",
Duration: time.Hour.String(),
Status: actions_model.StatusRunning.String(),
})
mockLogOptions = append(mockLogOptions, generateMockStepsLogOptions{mockCountFirst: 30, mockCountGeneral: 3, groupRepeat: 3})
if len(req.LogCursors) == 0 {
return
}
resp.Logs.StepsLog = []*actions.ViewStepLog{}
doSlowResponse := false
doErrorResponse := false
for _, logCur := range req.LogCursors {
if !logCur.Expanded {
continue
}
doSlowResponse = doSlowResponse || logCur.Step == 0
doErrorResponse = doErrorResponse || logCur.Step == 2
resp.Logs.StepsLog = append(resp.Logs.StepsLog, generateMockStepsLog(logCur, mockLogOptions[logCur.Step])...)
}
if doErrorResponse {
if mathRand.Float64() > 0.5 {
ctx.HTTPError(http.StatusInternalServerError, "devtest mock error response")
return
}
}
if doSlowResponse {
time.Sleep(time.Duration(3000) * time.Millisecond)
} else {
time.Sleep(time.Duration(100) * time.Millisecond) // actually, frontend reload every 1 second, any smaller delay is fine
}
}
+122
View File
@@ -0,0 +1,122 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package events
import (
"net/http"
"time"
"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
func Events(ctx *context.Context) {
// FIXME: Need to check if resp is actually a http.Flusher! - how though?
// Set the headers related to event streaming.
ctx.Resp.Header().Set("Content-Type", "text/event-stream")
ctx.Resp.Header().Set("Cache-Control", "no-cache")
ctx.Resp.Header().Set("Connection", "keep-alive")
ctx.Resp.Header().Set("X-Accel-Buffering", "no")
ctx.Resp.WriteHeader(http.StatusOK)
if !ctx.IsSigned {
// Return unauthorized status event
event := &eventsource.Event{
Name: "close",
Data: "unauthorized",
}
_, _ = event.WriteTo(ctx)
ctx.Resp.Flush()
return
}
// Listen to connection close and un-register messageChan
notify := ctx.Done()
ctx.Resp.Flush()
shutdownCtx := graceful.GetManager().ShutdownContext()
uid := ctx.Doer.ID
messageChan := eventsource.GetManager().Register(uid)
unregister := func() {
eventsource.GetManager().Unregister(uid, messageChan)
// ensure the messageChan is closed
for {
_, ok := <-messageChan
if !ok {
break
}
}
}
if _, err := ctx.Resp.Write([]byte("\n")); err != nil {
log.Error("Unable to write to EventStream: %v", err)
unregister()
return
}
timer := time.NewTicker(30 * time.Second)
loop:
for {
select {
case <-timer.C:
event := &eventsource.Event{
Name: "ping",
}
_, err := event.WriteTo(ctx.Resp)
if err != nil {
log.Error("Unable to write to EventStream for user %s: %v", ctx.Doer.Name, err)
go unregister()
break loop
}
ctx.Resp.Flush()
case <-notify:
go unregister()
break loop
case <-shutdownCtx.Done():
go unregister()
break loop
case event, ok := <-messageChan:
if !ok {
break loop
}
// Handle logout
if event.Name == "logout" {
if ctx.Session.ID() == event.Data {
_, _ = (&eventsource.Event{
Name: "logout",
Data: "here",
}).WriteTo(ctx.Resp)
ctx.Resp.Flush()
go unregister()
auth.HandleSignOut(ctx)
break loop
}
// Replace the event - we don't want to expose the session ID to the user
event = &eventsource.Event{
Name: "logout",
Data: "elsewhere",
}
}
_, err := event.WriteTo(ctx.Resp)
if err != nil {
log.Error("Unable to write to EventStream for user %s: %v", ctx.Doer.Name, err)
go unregister()
break loop
}
ctx.Resp.Flush()
}
}
timer.Stop()
}
+131
View File
@@ -0,0 +1,131 @@
// Copyright 2021 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package explore
import (
"net/http"
"slices"
"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 (
// tplExploreCode explore code page template
tplExploreCode templates.TplName = "explore/code"
)
// Code render explore code page
func Code(ctx *context.Context) {
if !setting.Indexer.RepoIndexerEnabled || setting.Service.Explore.DisableCodePage {
ctx.Redirect(setting.AppSubURL + "/explore")
return
}
ctx.Data["UsersPageIsDisabled"] = setting.Service.Explore.DisableUsersPage
ctx.Data["OrganizationsPageIsDisabled"] = setting.Service.Explore.DisableOrganizationsPage
ctx.Data["IsRepoIndexerEnabled"] = setting.Indexer.RepoIndexerEnabled
ctx.Data["Title"] = ctx.Tr("explore_title")
ctx.Data["PageIsExplore"] = true
ctx.Data["PageIsExploreCode"] = true
ctx.Data["PageIsViewCode"] = true
prepareSearch := common.PrepareCodeSearch(ctx)
if prepareSearch.Keyword == "" {
ctx.HTML(http.StatusOK, tplExploreCode)
return
}
page := ctx.FormInt("page")
if page <= 0 {
page = 1
}
var (
repoIDs []int64
err error
isAdmin bool
)
if ctx.Doer != nil {
isAdmin = ctx.Doer.IsAdmin
}
// guest user or non-admin user
if ctx.Doer == nil || !isAdmin {
repoIDs, err = repo_model.FindUserCodeAccessibleRepoIDs(ctx, ctx.Doer)
if err != nil {
ctx.ServerError("FindUserCodeAccessibleRepoIDs", err)
return
}
}
var (
total int64
searchResults []*code_indexer.Result
searchResultLanguages []*code_indexer.SearchResultLanguages
)
if (len(repoIDs) > 0) || isAdmin {
total, searchResults, searchResultLanguages, err = code_indexer.PerformSearch(ctx, &code_indexer.SearchOptions{
RepoIDs: repoIDs,
Keyword: prepareSearch.Keyword,
SearchMode: prepareSearch.SearchMode,
Language: prepareSearch.Language,
Paginator: &db.ListOptions{
Page: page,
PageSize: setting.UI.RepoSearchPagingNum,
},
})
if err != nil {
if code_indexer.IsAvailable(ctx) {
ctx.ServerError("SearchResults", err)
return
}
ctx.Data["CodeIndexerUnavailable"] = true
} else {
ctx.Data["CodeIndexerUnavailable"] = !code_indexer.IsAvailable(ctx)
}
loadRepoIDs := make([]int64, 0, len(searchResults))
for _, result := range searchResults {
if !slices.Contains(loadRepoIDs, result.RepoID) {
loadRepoIDs = append(loadRepoIDs, result.RepoID)
}
}
repoMaps, err := repo_model.GetRepositoriesMapByIDs(ctx, loadRepoIDs)
if err != nil {
ctx.ServerError("GetRepositoriesMapByIDs", err)
return
}
ctx.Data["RepoMaps"] = repoMaps
if len(loadRepoIDs) != len(repoMaps) {
// Remove deleted repos from search results
cleanedSearchResults := make([]*code_indexer.Result, 0, len(repoMaps))
for _, sr := range searchResults {
if _, found := repoMaps[sr.RepoID]; found {
cleanedSearchResults = append(cleanedSearchResults, sr)
}
}
searchResults = cleanedSearchResults
}
}
ctx.Data["SearchResults"] = searchResults
ctx.Data["SearchResultLanguages"] = searchResultLanguages
pager := context.NewPagination(total, setting.UI.RepoSearchPagingNum, page, 5)
pager.AddParamFromRequest(ctx.Req)
ctx.Data["Page"] = pager
ctx.HTML(http.StatusOK, tplExploreCode)
}
+55
View File
@@ -0,0 +1,55 @@
// Copyright 2021 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package explore
import (
"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
func Organizations(ctx *context.Context) {
if setting.Service.Explore.DisableOrganizationsPage {
ctx.Redirect(setting.AppSubURL + "/explore")
return
}
ctx.Data["UsersPageIsDisabled"] = setting.Service.Explore.DisableUsersPage
ctx.Data["CodePageIsDisabled"] = setting.Service.Explore.DisableCodePage
ctx.Data["Title"] = ctx.Tr("explore_title")
ctx.Data["PageIsExplore"] = true
ctx.Data["PageIsExploreOrganizations"] = true
ctx.Data["IsRepoIndexerEnabled"] = setting.Indexer.RepoIndexerEnabled
visibleTypes := []structs.VisibleType{structs.VisibleTypePublic}
if ctx.Doer != nil {
visibleTypes = append(visibleTypes, structs.VisibleTypeLimited, structs.VisibleTypePrivate)
}
supportedSortOrders := container.SetOf(
"newest",
"oldest",
"alphabetically",
"reversealphabetically",
)
sortOrder := ctx.FormString("sort")
if sortOrder == "" {
sortOrder = util.Iif(supportedSortOrders.Contains(setting.UI.ExploreDefaultSort), setting.UI.ExploreDefaultSort, "newest")
ctx.SetFormString("sort", sortOrder)
}
RenderUserSearch(ctx, user_model.SearchUserOptions{
Actor: ctx.Doer,
Types: []user_model.UserType{user_model.UserTypeOrganization},
ListOptions: db.ListOptions{PageSize: setting.UI.ExplorePagingNum},
Visible: visibleTypes,
SupportedSortOrders: supportedSortOrders,
}, tplExploreUsers)
}
+177
View File
@@ -0,0 +1,177 @@
// Copyright 2021 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package explore
import (
"net/http"
"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 (
// tplExploreRepos explore repositories page template
tplExploreRepos templates.TplName = "explore/repos"
relevantReposOnlyParam string = "only_show_relevant"
)
// RepoSearchOptions when calling search repositories
type RepoSearchOptions struct {
OwnerID int64
Private bool
Restricted bool
PageSize int
OnlyShowRelevant bool
TplName templates.TplName
}
// RenderRepoSearch render repositories search page
// This function is also used to render the Admin Repository Management page.
func RenderRepoSearch(ctx *context.Context, opts *RepoSearchOptions) {
// Sitemap index for sitemap paths
page := ctx.PathParamInt("idx")
isSitemap := ctx.PathParam("idx") != ""
if page <= 1 {
page = ctx.FormInt("page")
}
if page <= 0 {
page = 1
}
if isSitemap {
opts.PageSize = setting.UI.SitemapPagingNum
}
var (
repos []*repo_model.Repository
count int64
err error
orderBy db.SearchOrderBy
)
sortOrder := ctx.FormString("sort")
if sortOrder == "" {
sortOrder = setting.UI.ExploreDefaultSort
}
if order, ok := repo_model.OrderByFlatMap[sortOrder]; ok {
orderBy = order
} else {
sortOrder = "recentupdate"
orderBy = db.SearchOrderByRecentUpdated
}
ctx.Data["SortType"] = sortOrder
keyword := ctx.FormTrim("q")
ctx.Data["OnlyShowRelevant"] = opts.OnlyShowRelevant
topicOnly := ctx.FormBool("topic")
ctx.Data["TopicOnly"] = topicOnly
language := ctx.FormTrim("language")
ctx.Data["Language"] = language
archived := ctx.FormOptionalBool("archived")
ctx.Data["IsArchived"] = archived
fork := ctx.FormOptionalBool("fork")
ctx.Data["IsFork"] = fork
mirror := ctx.FormOptionalBool("mirror")
ctx.Data["IsMirror"] = mirror
template := ctx.FormOptionalBool("template")
ctx.Data["IsTemplate"] = template
private := ctx.FormOptionalBool("private")
ctx.Data["IsPrivate"] = private
repos, count, err = repo_model.SearchRepository(ctx, repo_model.SearchRepoOptions{
ListOptions: db.ListOptions{
Page: page,
PageSize: opts.PageSize,
},
Actor: ctx.Doer,
OrderBy: orderBy,
Private: opts.Private,
Keyword: keyword,
OwnerID: opts.OwnerID,
AllPublic: true,
AllLimited: true,
TopicOnly: topicOnly,
Language: language,
IncludeDescription: setting.UI.SearchRepoDescription,
OnlyShowRelevant: opts.OnlyShowRelevant,
Archived: archived,
Fork: fork,
Mirror: mirror,
Template: template,
IsPrivate: private,
})
if err != nil {
ctx.ServerError("SearchRepository", err)
return
}
if isSitemap {
m := sitemap.NewSitemap()
for _, item := range repos {
m.Add(sitemap.URL{URL: item.HTMLURL(), LastMod: item.UpdatedUnix.AsTimePtr()})
}
ctx.Resp.Header().Set("Content-Type", "text/xml")
if _, err := m.WriteTo(ctx.Resp); err != nil {
log.Error("Failed writing sitemap: %v", err)
}
return
}
ctx.Data["Keyword"] = keyword
ctx.Data["Total"] = count
ctx.Data["Repos"] = repos
ctx.Data["IsRepoIndexerEnabled"] = setting.Indexer.RepoIndexerEnabled
pager := context.NewPagination(count, opts.PageSize, page, 5)
pager.AddParamFromRequest(ctx.Req)
ctx.Data["Page"] = pager
ctx.HTML(http.StatusOK, opts.TplName)
}
// Repos render explore repositories page
func Repos(ctx *context.Context) {
ctx.Data["UsersPageIsDisabled"] = setting.Service.Explore.DisableUsersPage
ctx.Data["OrganizationsPageIsDisabled"] = setting.Service.Explore.DisableOrganizationsPage
ctx.Data["CodePageIsDisabled"] = setting.Service.Explore.DisableCodePage
ctx.Data["Title"] = ctx.Tr("explore_title")
ctx.Data["PageIsExplore"] = true
ctx.Data["ShowRepoOwnerOnList"] = true
ctx.Data["PageIsExploreRepositories"] = true
ctx.Data["IsRepoIndexerEnabled"] = setting.Indexer.RepoIndexerEnabled
var ownerID int64
if ctx.Doer != nil && !ctx.Doer.IsAdmin {
ownerID = ctx.Doer.ID
}
onlyShowRelevant := setting.UI.OnlyShowRelevantRepos
_ = ctx.Req.ParseForm() // parse the form first, to prepare the ctx.Req.Form field
if len(ctx.Req.Form[relevantReposOnlyParam]) != 0 {
onlyShowRelevant = ctx.FormBool(relevantReposOnlyParam)
}
RenderRepoSearch(ctx, &RepoSearchOptions{
PageSize: setting.UI.ExplorePagingNum,
OwnerID: ownerID,
Private: ctx.Doer != nil,
TplName: tplExploreRepos,
OnlyShowRelevant: onlyShowRelevant,
})
}
+41
View File
@@ -0,0 +1,41 @@
// Copyright 2022 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package explore
import (
"net/http"
"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
func TopicSearch(ctx *context.Context) {
opts := &repo_model.FindTopicOptions{
Keyword: ctx.FormString("q"),
ListOptions: db.ListOptions{
Page: ctx.FormInt("page"),
PageSize: convert.ToCorrectPageSize(ctx.FormInt("limit")),
},
}
topics, total, err := db.FindAndCount[repo_model.Topic](ctx, opts)
if err != nil {
ctx.HTTPError(http.StatusInternalServerError)
return
}
topicResponses := make([]*api.TopicResponse, len(topics))
for i, topic := range topics {
topicResponses[i] = convert.ToTopicResponse(topic)
}
ctx.SetTotalCountHeader(total)
ctx.JSON(http.StatusOK, map[string]any{
"topics": topicResponses,
})
}
+163
View File
@@ -0,0 +1,163 @@
// Copyright 2021 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package explore
import (
"bytes"
"net/http"
"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 (
// tplExploreUsers explore users page template
tplExploreUsers templates.TplName = "explore/users"
)
var nullByte = []byte{0x00}
func isKeywordValid(keyword string) bool {
return !bytes.Contains([]byte(keyword), nullByte)
}
// RenderUserSearch render user search page
func RenderUserSearch(ctx *context.Context, opts user_model.SearchUserOptions, tplName templates.TplName) {
// Sitemap index for sitemap paths
opts.Page = ctx.PathParamInt("idx")
isSitemap := ctx.PathParam("idx") != ""
if opts.Page <= 1 {
opts.Page = ctx.FormInt("page")
}
if opts.Page <= 1 {
opts.Page = 1
}
if isSitemap {
opts.PageSize = setting.UI.SitemapPagingNum
}
var (
users []*user_model.User
count int64
err error
orderBy db.SearchOrderBy
)
// 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
}
ctx.Data["SortType"] = sortOrder
switch sortOrder {
case "newest":
orderBy = "`user`.id DESC"
case "oldest":
orderBy = "`user`.id ASC"
case "leastupdate":
orderBy = "`user`.updated_unix ASC"
case "reversealphabetically":
orderBy = "`user`.name DESC"
case "lastlogin":
orderBy = "`user`.last_login_unix ASC"
case "reverselastlogin":
orderBy = "`user`.last_login_unix DESC"
case "alphabetically":
orderBy = "`user`.name ASC"
case "recentupdate":
fallthrough
default:
// in case the sortType is not valid, we set it to recentupdate
sortOrder = "recentupdate"
ctx.Data["SortType"] = "recentupdate"
orderBy = "`user`.updated_unix DESC"
}
if opts.SupportedSortOrders != nil && !opts.SupportedSortOrders.Contains(sortOrder) {
ctx.NotFound(nil)
return
}
opts.Keyword = ctx.FormTrim("q")
opts.OrderBy = orderBy
if len(opts.Keyword) == 0 || isKeywordValid(opts.Keyword) {
users, count, err = user_model.SearchUsers(ctx, opts)
if err != nil {
ctx.ServerError("SearchUsers", err)
return
}
}
if isSitemap {
m := sitemap.NewSitemap()
for _, item := range users {
m.Add(sitemap.URL{URL: item.HTMLURL(ctx), LastMod: item.UpdatedUnix.AsTimePtr()})
}
ctx.Resp.Header().Set("Content-Type", "text/xml")
if _, err := m.WriteTo(ctx.Resp); err != nil {
log.Error("Failed writing sitemap: %v", err)
}
return
}
ctx.Data["Keyword"] = opts.Keyword
ctx.Data["Total"] = count
ctx.Data["Users"] = users
ctx.Data["UsersTwoFaStatus"] = user_model.UserList(users).GetTwoFaStatus(ctx)
ctx.Data["ShowUserEmail"] = setting.UI.ShowUserEmail
ctx.Data["IsRepoIndexerEnabled"] = setting.Indexer.RepoIndexerEnabled
pager := context.NewPagination(count, opts.PageSize, opts.Page, 5)
pager.AddParamFromRequest(ctx.Req)
ctx.Data["Page"] = pager
ctx.HTML(http.StatusOK, tplName)
}
// Users render explore users page
func Users(ctx *context.Context) {
if setting.Service.Explore.DisableUsersPage {
ctx.Redirect(setting.AppSubURL + "/explore")
return
}
ctx.Data["OrganizationsPageIsDisabled"] = setting.Service.Explore.DisableOrganizationsPage
ctx.Data["CodePageIsDisabled"] = setting.Service.Explore.DisableCodePage
ctx.Data["Title"] = ctx.Tr("explore_title")
ctx.Data["PageIsExplore"] = true
ctx.Data["PageIsExploreUsers"] = true
ctx.Data["IsRepoIndexerEnabled"] = setting.Indexer.RepoIndexerEnabled
supportedSortOrders := container.SetOf(
"newest",
"oldest",
"alphabetically",
"reversealphabetically",
)
sortOrder := ctx.FormString("sort")
if sortOrder == "" {
sortOrder = util.Iif(supportedSortOrders.Contains(setting.UI.ExploreDefaultSort), setting.UI.ExploreDefaultSort, "newest")
ctx.SetFormString("sort", sortOrder)
}
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},
SupportedSortOrders: supportedSortOrders,
}, tplExploreUsers)
}
+54
View File
@@ -0,0 +1,54 @@
// Copyright 2022 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package feed
import (
"time"
"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) {
var commits []*git.Commit
var err error
if ctx.Repo.Commit != nil {
commits, err = ctx.Repo.Commit.CommitsByRange(0, 10, "", "", "")
if err != nil {
ctx.ServerError("ShowBranchFeed", err)
return
}
}
title := "Latest commits for branch " + ctx.Repo.BranchName
link := &feeds.Link{Href: repo.HTMLURL() + "/" + ctx.Repo.RefTypeNameSubURL()}
feed := &feeds.Feed{
Title: title,
Link: link,
Description: repo.Description,
Created: time.Now(),
}
for _, commit := range commits {
feed.Items = append(feed.Items, &feeds.Item{
Id: commit.ID.String(),
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.MessageUTF8(), // TODO: description can be shorten content
Content: commit.MessageUTF8(),
Created: commit.Committer.When,
})
}
writeFeed(ctx, feed, formatType)
}
+315
View File
@@ -0,0 +1,315 @@
// Copyright 2021 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package feed
import (
"fmt"
"html"
"html/template"
"net/http"
"net/url"
"strconv"
"strings"
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"
)
func toBranchLink(ctx *context.Context, act *activities_model.Action) string {
return act.GetRepoAbsoluteLink(ctx) + "/src/branch/" + util.PathEscapeSegments(act.GetBranch())
}
func toTagLink(ctx *context.Context, act *activities_model.Action) string {
return act.GetRepoAbsoluteLink(ctx) + "/src/tag/" + util.PathEscapeSegments(act.GetTag())
}
func toIssueLink(ctx *context.Context, act *activities_model.Action) string {
return act.GetRepoAbsoluteLink(ctx) + "/issues/" + url.PathEscape(act.GetIssueInfos()[0])
}
func toPullLink(ctx *context.Context, act *activities_model.Action) string {
return act.GetRepoAbsoluteLink(ctx) + "/pulls/" + url.PathEscape(act.GetIssueInfos()[0])
}
func toSrcLink(ctx *context.Context, act *activities_model.Action) string {
return act.GetRepoAbsoluteLink(ctx) + "/src/" + util.PathEscapeSegments(act.GetBranch())
}
func toReleaseLink(ctx *context.Context, act *activities_model.Action) string {
return act.GetRepoAbsoluteLink(ctx) + "/releases/tag/" + util.PathEscapeSegments(act.GetBranch())
}
// renderCommentMarkdown renders the comment markdown to html
func renderCommentMarkdown(ctx *context.Context, act *activities_model.Action, content string) template.HTML {
_ = act.LoadRepo(ctx)
if act.Repo == nil {
return ""
}
rctx := renderhelper.NewRenderContextRepoComment(ctx, act.Repo).WithUseAbsoluteLink(true)
rendered, err := markdown.RenderString(rctx, content)
if err != nil {
return ""
}
return rendered
}
// feedActionsToFeedItems convert gitea's Action feed to feeds Item
func feedActionsToFeedItems(ctx *context.Context, actions activities_model.ActionList) (items []*feeds.Item, err error) {
renderUtils := templates.NewRenderUtils(ctx)
for _, act := range actions {
act.LoadActUser(ctx)
// TODO: the code seems quite strange (maybe not right)
// sometimes it uses text content but sometimes it uses HTML content
// it should clearly defines which kind of content it should use for the feed items: plan text or rich HTML
var title, desc string
var content template.HTML
link := &feeds.Link{Href: act.GetCommentHTMLURL(ctx)}
// title
title = act.ActUser.GetDisplayName() + " "
var titleExtra template.HTML
switch act.OpType {
case activities_model.ActionCreateRepo:
titleExtra = ctx.Locale.Tr("action.create_repo", act.GetRepoAbsoluteLink(ctx), act.ShortRepoPath(ctx))
link.Href = act.GetRepoAbsoluteLink(ctx)
case activities_model.ActionRenameRepo:
titleExtra = ctx.Locale.Tr("action.rename_repo", act.GetContent(), act.GetRepoAbsoluteLink(ctx), act.ShortRepoPath(ctx))
link.Href = act.GetRepoAbsoluteLink(ctx)
case activities_model.ActionCommitRepo:
link.Href = toBranchLink(ctx, act)
if len(act.Content) != 0 {
titleExtra = ctx.Locale.Tr("action.commit_repo", act.GetRepoAbsoluteLink(ctx), link.Href, act.GetBranch(), act.ShortRepoPath(ctx))
} else {
titleExtra = ctx.Locale.Tr("action.create_branch", act.GetRepoAbsoluteLink(ctx), link.Href, act.GetBranch(), act.ShortRepoPath(ctx))
}
case activities_model.ActionCreateIssue:
link.Href = toIssueLink(ctx, act)
titleExtra = ctx.Locale.Tr("action.create_issue", link.Href, act.GetIssueInfos()[0], act.ShortRepoPath(ctx))
case activities_model.ActionCreatePullRequest:
link.Href = toPullLink(ctx, act)
titleExtra = ctx.Locale.Tr("action.create_pull_request", link.Href, act.GetIssueInfos()[0], act.ShortRepoPath(ctx))
case activities_model.ActionTransferRepo:
link.Href = act.GetRepoAbsoluteLink(ctx)
titleExtra = ctx.Locale.Tr("action.transfer_repo", act.GetContent(), act.GetRepoAbsoluteLink(ctx), act.ShortRepoPath(ctx))
case activities_model.ActionPushTag:
link.Href = toTagLink(ctx, act)
titleExtra = ctx.Locale.Tr("action.push_tag", act.GetRepoAbsoluteLink(ctx), link.Href, act.GetTag(), act.ShortRepoPath(ctx))
case activities_model.ActionCommentIssue:
issueLink := toIssueLink(ctx, act)
if link.Href == "#" {
link.Href = issueLink
}
titleExtra = ctx.Locale.Tr("action.comment_issue", issueLink, act.GetIssueInfos()[0], act.ShortRepoPath(ctx))
case activities_model.ActionMergePullRequest:
pullLink := toPullLink(ctx, act)
if link.Href == "#" {
link.Href = pullLink
}
titleExtra = ctx.Locale.Tr("action.merge_pull_request", pullLink, act.GetIssueInfos()[0], act.ShortRepoPath(ctx))
case activities_model.ActionAutoMergePullRequest:
pullLink := toPullLink(ctx, act)
if link.Href == "#" {
link.Href = pullLink
}
titleExtra = ctx.Locale.Tr("action.auto_merge_pull_request", pullLink, act.GetIssueInfos()[0], act.ShortRepoPath(ctx))
case activities_model.ActionCloseIssue:
issueLink := toIssueLink(ctx, act)
if link.Href == "#" {
link.Href = issueLink
}
titleExtra = ctx.Locale.Tr("action.close_issue", issueLink, act.GetIssueInfos()[0], act.ShortRepoPath(ctx))
case activities_model.ActionReopenIssue:
issueLink := toIssueLink(ctx, act)
if link.Href == "#" {
link.Href = issueLink
}
titleExtra = ctx.Locale.Tr("action.reopen_issue", issueLink, act.GetIssueInfos()[0], act.ShortRepoPath(ctx))
case activities_model.ActionClosePullRequest:
pullLink := toPullLink(ctx, act)
if link.Href == "#" {
link.Href = pullLink
}
titleExtra = ctx.Locale.Tr("action.close_pull_request", pullLink, act.GetIssueInfos()[0], act.ShortRepoPath(ctx))
case activities_model.ActionReopenPullRequest:
pullLink := toPullLink(ctx, act)
if link.Href == "#" {
link.Href = pullLink
}
titleExtra = ctx.Locale.Tr("action.reopen_pull_request", pullLink, act.GetIssueInfos()[0], act.ShortRepoPath(ctx))
case activities_model.ActionDeleteTag:
link.Href = act.GetRepoAbsoluteLink(ctx)
titleExtra = ctx.Locale.Tr("action.delete_tag", act.GetRepoAbsoluteLink(ctx), act.GetTag(), act.ShortRepoPath(ctx))
case activities_model.ActionDeleteBranch:
link.Href = act.GetRepoAbsoluteLink(ctx)
titleExtra = ctx.Locale.Tr("action.delete_branch", act.GetRepoAbsoluteLink(ctx), html.EscapeString(act.GetBranch()), act.ShortRepoPath(ctx))
case activities_model.ActionMirrorSyncPush:
srcLink := toSrcLink(ctx, act)
if link.Href == "#" {
link.Href = srcLink
}
titleExtra = ctx.Locale.Tr("action.mirror_sync_push", act.GetRepoAbsoluteLink(ctx), srcLink, act.RefName, act.ShortRepoPath(ctx))
case activities_model.ActionMirrorSyncCreate:
srcLink := toSrcLink(ctx, act)
if link.Href == "#" {
link.Href = srcLink
}
titleExtra = ctx.Locale.Tr("action.mirror_sync_create", act.GetRepoAbsoluteLink(ctx), srcLink, act.RefName, act.ShortRepoPath(ctx))
case activities_model.ActionMirrorSyncDelete:
link.Href = act.GetRepoAbsoluteLink(ctx)
titleExtra = ctx.Locale.Tr("action.mirror_sync_delete", act.GetRepoAbsoluteLink(ctx), act.RefName, act.ShortRepoPath(ctx))
case activities_model.ActionApprovePullRequest:
pullLink := toPullLink(ctx, act)
titleExtra = ctx.Locale.Tr("action.approve_pull_request", pullLink, act.GetIssueInfos()[0], act.ShortRepoPath(ctx))
case activities_model.ActionRejectPullRequest:
pullLink := toPullLink(ctx, act)
titleExtra = ctx.Locale.Tr("action.reject_pull_request", pullLink, act.GetIssueInfos()[0], act.ShortRepoPath(ctx))
case activities_model.ActionCommentPull:
pullLink := toPullLink(ctx, act)
titleExtra = ctx.Locale.Tr("action.comment_pull", pullLink, act.GetIssueInfos()[0], act.ShortRepoPath(ctx))
case activities_model.ActionPublishRelease:
releaseLink := toReleaseLink(ctx, act)
if link.Href == "#" {
link.Href = releaseLink
}
titleExtra = ctx.Locale.Tr("action.publish_release", act.GetRepoAbsoluteLink(ctx), releaseLink, act.ShortRepoPath(ctx), act.Content)
case activities_model.ActionPullReviewDismissed:
pullLink := toPullLink(ctx, act)
titleExtra = ctx.Locale.Tr("action.review_dismissed", pullLink, act.GetIssueInfos()[0], act.ShortRepoPath(ctx), act.GetIssueInfos()[1])
case activities_model.ActionStarRepo:
link.Href = act.GetRepoAbsoluteLink(ctx)
titleExtra = ctx.Locale.Tr("action.starred_repo", act.GetRepoAbsoluteLink(ctx), act.GetRepoPath(ctx))
case activities_model.ActionWatchRepo:
link.Href = act.GetRepoAbsoluteLink(ctx)
titleExtra = ctx.Locale.Tr("action.watched_repo", act.GetRepoAbsoluteLink(ctx), act.GetRepoPath(ctx))
default:
return nil, fmt.Errorf("unknown action type: %v", act.OpType)
}
// description & content
{
switch act.OpType {
case activities_model.ActionCommitRepo, activities_model.ActionMirrorSyncPush:
push := templates.ActionContent2Commits(act)
_ = act.LoadRepo(ctx)
for _, commit := range push.Commits {
if len(desc) != 0 {
desc += "\n\n"
}
desc += fmt.Sprintf("<a href=\"%s\">%s</a>\n%s",
html.EscapeString(fmt.Sprintf("%s/commit/%s", act.GetRepoAbsoluteLink(ctx), commit.Sha1)),
commit.Sha1,
renderUtils.RenderCommitMessage(commit.Message, act.Repo),
)
}
if push.Len > 1 {
link = &feeds.Link{Href: fmt.Sprintf("%s/%s", setting.AppSubURL, push.CompareURL)}
} else if push.Len == 1 {
link = &feeds.Link{Href: fmt.Sprintf("%s/commit/%s", act.GetRepoAbsoluteLink(ctx), push.Commits[0].Sha1)}
}
case activities_model.ActionCreateIssue, activities_model.ActionCreatePullRequest:
desc = strings.Join(act.GetIssueInfos(), "#")
content = renderCommentMarkdown(ctx, act, act.GetIssueContent(ctx))
case activities_model.ActionCommentIssue, activities_model.ActionApprovePullRequest, activities_model.ActionRejectPullRequest, activities_model.ActionCommentPull:
desc = act.GetIssueTitle(ctx)
comment := act.GetIssueInfos()[1]
if len(comment) != 0 {
desc += "\n\n" + string(renderCommentMarkdown(ctx, act, comment))
}
case activities_model.ActionMergePullRequest, activities_model.ActionAutoMergePullRequest:
desc = act.GetIssueInfos()[1]
case activities_model.ActionCloseIssue, activities_model.ActionReopenIssue, activities_model.ActionClosePullRequest, activities_model.ActionReopenPullRequest:
desc = act.GetIssueTitle(ctx)
case activities_model.ActionPullReviewDismissed:
desc = ctx.Locale.TrString("action.review_dismissed_reason") + "\n\n" + act.GetIssueInfos()[2]
}
}
if len(content) == 0 {
content = markup.Sanitize(desc)
}
items = append(items, &feeds.Item{
Title: template.HTMLEscapeString(title) + string(titleExtra),
Link: link,
Description: desc,
IsPermaLink: "false",
Author: &feeds.Author{
Name: act.ActUser.GetDisplayName(),
Email: act.ActUser.GetEmail(),
},
Id: fmt.Sprintf("%v: %v", strconv.FormatInt(act.ID, 10), link.Href),
Created: act.CreatedUnix.AsTime(),
Content: string(content),
})
}
return items, err
}
// GetFeedType return if it is a feed request and altered name and feed type.
func GetFeedType(name string, req *http.Request) (showFeed bool, feedType string) {
if strings.HasSuffix(name, ".rss") ||
strings.Contains(req.Header.Get("Accept"), "application/rss+xml") {
return true, "rss"
}
if strings.HasSuffix(name, ".atom") ||
strings.Contains(req.Header.Get("Accept"), "application/atom+xml") {
return true, "atom"
}
return false, ""
}
// feedActionsToFeedItems convert gitea's Repo's Releases to feeds Item
func releasesToFeedItems(ctx *context.Context, releases []*repo_model.Release) (items []*feeds.Item, err error) {
for _, rel := range releases {
err := rel.LoadAttributes(ctx)
if err != nil {
return nil, err
}
var title string
var content template.HTML
if rel.IsTag {
title = rel.TagName
} else {
title = rel.Title
}
link := &feeds.Link{Href: rel.HTMLURL()}
rctx := renderhelper.NewRenderContextRepoComment(ctx, rel.Repo).WithUseAbsoluteLink(true)
content, err = markdown.RenderString(rctx,
rel.Note)
if err != nil {
return nil, err
}
items = append(items, &feeds.Item{
Title: title,
Link: link,
Created: rel.CreatedUnix.AsTime(),
Author: &feeds.Author{
Name: rel.Publisher.GetDisplayName(),
Email: rel.Publisher.GetEmail(),
},
Id: fmt.Sprintf("%v: %v", strconv.FormatInt(rel.ID, 10), link.Href),
Content: string(content),
})
}
return items, err
}
+61
View File
@@ -0,0 +1,61 @@
// Copyright 2022 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package feed
import (
"time"
"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) {
fileName := ctx.Repo.TreePath
if len(fileName) == 0 {
return
}
commits, err := ctx.Repo.GitRepo.CommitsByFileAndRange(
git.CommitsByFileAndRangeOptions{
Revision: ctx.Repo.RefFullName.ShortName(), // FIXME: legacy code used ShortName
File: fileName,
Page: 1,
})
if err != nil {
ctx.ServerError("ShowBranchFeed", err)
return
}
title := "Latest commits for file " + ctx.Repo.TreePath
link := &feeds.Link{Href: repo.HTMLURL() + "/" + ctx.Repo.RefTypeNameSubURL() + "/" + util.PathEscapeSegments(ctx.Repo.TreePath)}
feed := &feeds.Feed{
Title: title,
Link: link,
Description: repo.Description,
Created: time.Now(),
}
for _, commit := range commits {
feed.Items = append(feed.Items, &feeds.Item{
Id: commit.ID.String(),
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.MessageUTF8(), // TODO: description can be shorten content
Content: commit.MessageUTF8(),
Created: commit.Committer.When,
})
}
writeFeed(ctx, feed, formatType)
}
+94
View File
@@ -0,0 +1,94 @@
// Copyright 2021 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package feed
import (
"time"
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"
)
// ShowUserFeedRSS show user activity as RSS feed
func ShowUserFeedRSS(ctx *context.Context) {
showUserFeed(ctx, "rss")
}
// ShowUserFeedAtom show user activity as Atom feed
func ShowUserFeedAtom(ctx *context.Context) {
showUserFeed(ctx, "atom")
}
// showUserFeed show user activity as RSS / Atom feed
func showUserFeed(ctx *context.Context, formatType string) {
includePrivate := ctx.IsSigned && (ctx.Doer.IsAdmin || ctx.Doer.ID == ctx.ContextUser.ID)
isOrganisation := ctx.ContextUser.IsOrganization()
if ctx.IsSigned && isOrganisation && !includePrivate {
// When feed is requested by a member of the organization,
// include the private repo's the member has access to.
isOrgMember, err := organization.IsOrganizationMember(ctx, ctx.ContextUser.ID, ctx.Doer.ID)
if err != nil {
ctx.ServerError("IsOrganizationMember", err)
return
}
includePrivate = isOrgMember
}
actions, _, err := feed_service.GetFeeds(ctx, activities_model.GetFeedsOptions{
RequestedUser: ctx.ContextUser,
Actor: ctx.Doer,
IncludePrivate: includePrivate,
OnlyPerformedBy: !isOrganisation,
IncludeDeleted: false,
Date: ctx.FormString("date"),
})
if err != nil {
ctx.ServerError("GetFeeds", err)
return
}
rctx := renderhelper.NewRenderContextSimpleDocument(ctx, ctx.ContextUser.HTMLURL(ctx))
ctxUserDescription, err := markdown.RenderString(rctx,
ctx.ContextUser.Description)
if err != nil {
ctx.ServerError("RenderString", err)
return
}
feed := &feeds.Feed{
Title: ctx.Locale.TrString("home.feed_of", ctx.ContextUser.DisplayName()),
Link: &feeds.Link{Href: ctx.ContextUser.HTMLURL(ctx)},
Description: string(ctxUserDescription),
Created: time.Now(),
}
feed.Items, err = feedActionsToFeedItems(ctx, actions)
if err != nil {
ctx.ServerError("convert feed", err)
return
}
writeFeed(ctx, feed, formatType)
}
// writeFeed write a feeds.Feed as atom or rss to ctx.Resp
func writeFeed(ctx *context.Context, feed *feeds.Feed, formatType string) {
if formatType == "atom" {
ctx.Resp.Header().Set("Content-Type", "application/atom+xml;charset=utf-8")
if err := feed.WriteAtom(ctx.Resp); err != nil {
ctx.ServerError("Render Atom failed", err)
}
} else {
ctx.Resp.Header().Set("Content-Type", "application/rss+xml;charset=utf-8")
if err := feed.WriteRss(ctx.Resp); err != nil {
ctx.ServerError("Render RSS failed", err)
}
}
}
+36
View File
@@ -0,0 +1,36 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package feed_test
import (
"testing"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
"gitea.dev/routers/web/feed"
"gitea.dev/services/contexttest"
"github.com/stretchr/testify/assert"
)
func TestMain(m *testing.M) {
unittest.MainTest(m)
}
func TestCheckGetOrgFeedsAsOrgMember(t *testing.T) {
unittest.PrepareTestEnv(t)
t.Run("OrgMember", func(t *testing.T) {
ctx, resp := contexttest.MockContext(t, "org3.atom")
ctx.ContextUser = unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3})
contexttest.LoadUser(t, ctx, 2)
feed.ShowUserFeedAtom(ctx)
assert.Contains(t, resp.Body.String(), "<entry>") // Should contain 1 private entry
})
t.Run("NonOrgMember", func(t *testing.T) {
ctx, resp := contexttest.MockContext(t, "org3.atom")
ctx.ContextUser = unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3})
contexttest.LoadUser(t, ctx, 5)
feed.ShowUserFeedAtom(ctx)
assert.NotContains(t, resp.Body.String(), "<entry>") // Should not contain any entries
})
}
+52
View File
@@ -0,0 +1,52 @@
// Copyright 2022 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package feed
import (
"time"
"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) {
releases, err := db.Find[repo_model.Release](ctx, repo_model.FindReleasesOptions{
IncludeTags: !isReleasesOnly,
RepoID: ctx.Repo.Repository.ID,
})
if err != nil {
ctx.ServerError("GetReleasesByRepoID", err)
return
}
var title string
var link *feeds.Link
if isReleasesOnly {
title = ctx.Locale.TrString("repo.release.releases_for", repo.FullName())
link = &feeds.Link{Href: repo.HTMLURL() + "/release"}
} else {
title = ctx.Locale.TrString("repo.release.tags_for", repo.FullName())
link = &feeds.Link{Href: repo.HTMLURL() + "/tags"}
}
feed := &feeds.Feed{
Title: title,
Link: link,
Description: repo.Description,
Created: time.Now(),
}
feed.Items, err = releasesToFeedItems(ctx, releases)
if err != nil {
ctx.ServerError("releasesToFeedItems", err)
return
}
writeFeed(ctx, feed, formatType)
}
+25
View File
@@ -0,0 +1,25 @@
// Copyright 2022 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package feed
import (
"gitea.dev/services/context"
)
// RenderBranchFeed render format for branch or file
func RenderBranchFeed(ctx *context.Context, feedType string) {
if ctx.Repo.TreePath == "" {
ShowBranchFeed(ctx, ctx.Repo.Repository, feedType)
} else {
ShowFileFeed(ctx, ctx.Repo.Repository, feedType)
}
}
func RenderBranchFeedRSS(ctx *context.Context) {
RenderBranchFeed(ctx, "rss")
}
func RenderBranchFeedAtom(ctx *context.Context) {
RenderBranchFeed(ctx, "atom")
}
+44
View File
@@ -0,0 +1,44 @@
// Copyright 2022 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package feed
import (
"time"
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) {
actions, _, err := feed_service.GetFeeds(ctx, activities_model.GetFeedsOptions{
RequestedRepo: repo,
Actor: ctx.Doer,
IncludePrivate: true,
Date: ctx.FormString("date"),
})
if err != nil {
ctx.ServerError("GetFeeds", err)
return
}
feed := &feeds.Feed{
Title: ctx.Locale.TrString("home.feed_of", repo.FullName()),
Link: &feeds.Link{Href: repo.HTMLURL()},
Description: repo.Description,
Created: time.Now(),
}
feed.Items, err = feedActionsToFeedItems(ctx, actions)
if err != nil {
ctx.ServerError("convert feed", err)
return
}
writeFeed(ctx, feed, formatType)
}
+26
View File
@@ -0,0 +1,26 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package web
import (
"gitea.dev/modules/web"
"gitea.dev/routers/web/repo"
)
func addOwnerRepoGitHTTPRouters(m *web.Router, middlewares ...any) {
m.Group("/{username}/{reponame}", func() {
m.Methods("POST,OPTIONS", "/git-upload-pack", repo.ServiceUploadPack)
m.Methods("POST,OPTIONS", "/git-receive-pack", repo.ServiceReceivePack)
m.Methods("POST,OPTIONS", "/git-upload-archive", repo.ServiceUploadArchive)
m.Methods("GET,OPTIONS", "/info/refs", repo.GetInfoRefs)
m.Methods("GET,OPTIONS", "/HEAD", repo.GetTextFile("HEAD"))
m.Methods("GET,OPTIONS", "/objects/info/alternates", repo.GetTextFile("objects/info/alternates"))
m.Methods("GET,OPTIONS", "/objects/info/http-alternates", repo.GetTextFile("objects/info/http-alternates"))
m.Methods("GET,OPTIONS", "/objects/info/packs", repo.GetInfoPacks)
m.Methods("GET,OPTIONS", "/objects/info/{file:[^/]*}", repo.GetTextFile(""))
m.Methods("GET,OPTIONS", "/objects/{head:[0-9a-f]{2}}/{hash:[0-9a-f]{38,62}}", repo.GetLooseObject)
m.Methods("GET,OPTIONS", "/objects/pack/pack-{file:[0-9a-f]{40,64}}.pack", repo.GetPackFile)
m.Methods("GET,OPTIONS", "/objects/pack/pack-{file:[0-9a-f]{40,64}}.idx", repo.GetIdxFile)
}, middlewares...)
}
+93
View File
@@ -0,0 +1,93 @@
// Copyright 2021 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package web
import (
"fmt"
"html"
"net/http"
"net/url"
"path"
"strings"
repo_model "gitea.dev/models/repo"
"gitea.dev/modules/setting"
"gitea.dev/modules/util"
"gitea.dev/services/context"
)
func goGet(ctx *context.Context) {
if ctx.Req.Method != http.MethodGet || len(ctx.Req.URL.RawQuery) < 8 || ctx.FormString("go-get") != "1" {
return
}
parts := strings.SplitN(ctx.Req.URL.EscapedPath(), "/", 4)
if len(parts) < 3 {
return
}
ownerName := parts[1]
repoName := parts[2]
// Quick responses appropriate go-get meta with status 200
// regardless of if user have access to the repository,
// or the repository does not exist at all.
// This is particular a workaround for "go get" command which does not respect
// .netrc file.
trimmedRepoName := strings.TrimSuffix(repoName, ".git")
if ownerName == "" || trimmedRepoName == "" {
_, _ = ctx.Write([]byte(`<!doctype html>
<html>
<body>
invalid import path
</body>
</html>
`))
ctx.Status(http.StatusBadRequest)
return
}
branchName := setting.Repository.DefaultBranch
repo, err := repo_model.GetRepositoryByOwnerAndName(ctx, ownerName, repoName)
if err == nil && len(repo.DefaultBranch) > 0 {
branchName = repo.DefaultBranch
}
prefix := setting.AppURL + path.Join(url.PathEscape(ownerName), url.PathEscape(repoName), "src", "branch", util.PathEscapeSegments(branchName))
appURL, _ := url.Parse(setting.AppURL)
insecure := ""
if appURL.Scheme == string(setting.HTTP) {
insecure = "--insecure "
}
goGetImport := context.ComposeGoGetImport(ctx, ownerName, trimmedRepoName)
var cloneURL string
if setting.Repository.GoGetCloneURLProtocol == "ssh" {
cloneURL = repo_model.ComposeSSHCloneURL(ctx.Doer, ownerName, repoName)
} else {
cloneURL = repo_model.ComposeHTTPSCloneURL(ctx, ownerName, repoName)
}
goImportContent := fmt.Sprintf("%s git %s", goGetImport, cloneURL /*CloneLink*/)
goSourceContent := fmt.Sprintf("%s _ %s %s", goGetImport, prefix+"{/dir}" /*GoDocDirectory*/, prefix+"{/dir}/{file}#L{line}" /*GoDocFile*/)
goGetCli := fmt.Sprintf("go get %s%s", insecure, goGetImport)
res := fmt.Sprintf(`<!doctype html>
<html>
<head>
<meta name="go-import" content="%s">
<meta name="go-source" content="%s">
</head>
<body>
%s
</body>
</html>`, html.EscapeString(goImportContent), html.EscapeString(goSourceContent), html.EscapeString(goGetCli))
ctx.RespHeader().Set("Content-Type", "text/html")
_, _ = ctx.Write([]byte(res))
}
+142
View File
@@ -0,0 +1,142 @@
// Copyright 2022 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package healthcheck
import (
"context"
"net/http"
"os"
"time"
"gitea.dev/models/db"
"gitea.dev/modules/cache"
"gitea.dev/modules/json"
"gitea.dev/modules/log"
"gitea.dev/modules/setting"
)
type status string
const (
// pass healthy (acceptable aliases: "ok" to support Node's Terminus and "up" for Java's SpringBoot)
// fail unhealthy (acceptable aliases: "error" to support Node's Terminus and "down" for Java's SpringBoot), and
// warn healthy, with some concerns.
//
// ref https://datatracker.ietf.org/doc/html/draft-inadarei-api-health-check#section-3.1
// status: (required) indicates whether the service status is acceptable
// or not. API publishers SHOULD use following values for the field:
// The value of the status field is case-insensitive and is tightly
// related with the HTTP response code returned by the health endpoint.
// For "pass" status, HTTP response code in the 2xx-3xx range MUST be
// used. For "fail" status, HTTP response code in the 4xx-5xx range
// MUST be used. In case of the "warn" status, endpoints MUST return
// HTTP status in the 2xx-3xx range, and additional information SHOULD
// be provided, utilizing optional fields of the response.
pass status = "pass"
fail status = "fail"
warn status = "warn"
)
func (s status) ToHTTPStatus() int {
if s == pass || s == warn {
return http.StatusOK
}
return http.StatusFailedDependency
}
type checks map[string][]componentStatus
// response is the data returned by the health endpoint, which will be marshaled to JSON format
type response struct {
Status status `json:"status"`
Description string `json:"description"` // a human-friendly description of the service
Checks checks `json:"checks,omitempty"` // The Checks Object, should be omitted on installation route
}
// componentStatus presents one status of a single check object
// an object that provides detailed health statuses of additional downstream systems and endpoints
// which can affect the overall health of the main API.
type componentStatus struct {
Status status `json:"status"`
Time string `json:"time"` // the date-time, in ISO8601 format
Output string `json:"output,omitempty"` // this field SHOULD be omitted for "pass" state.
}
// Check is the health check API handler
//
// HINT: HEALTH-CHECK-ENDPOINT: there is no clear definition about what "health" means.
// In most cases, end users don't need to check such endpoint, because even if database is down,
// Gitea will reover after database is up again. Sysop should monitor database and cache status directly.
//
// And keep in mind: this health check should NEVER be used as a "restart" trigger, for example: Docker's "HEALTHCHECK".
// * If Gitea is upgrading and migrating database, there will be a long time before this endpoint starts to return "pass" status.
// In this case, if the checker restarts Gitea just because it doesn't get "pass" status in short time,
// the instance will just be restarted again and again before the migration finishes and the situation just goes worse.
func Check(w http.ResponseWriter, r *http.Request) {
rsp := response{
Status: pass,
Description: setting.AppName,
Checks: make(checks),
}
statuses := make([]status, 0)
if setting.InstallLock {
statuses = append(statuses, checkDatabase(r.Context(), rsp.Checks))
statuses = append(statuses, checkCache(rsp.Checks))
}
for _, s := range statuses {
if s != pass {
rsp.Status = fail
break
}
}
data, _ := json.MarshalIndent(rsp, "", " ")
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(rsp.Status.ToHTTPStatus())
_, _ = w.Write(data)
}
// database checks gitea database status
func checkDatabase(ctx context.Context, checks checks) status {
st := componentStatus{}
if err := db.GetEngine(ctx).Ping(); err != nil {
st.Status = fail
st.Time = getCheckTime()
log.Error("database ping failed with error: %v", err)
} else {
st.Status = pass
st.Time = getCheckTime()
}
if setting.Database.Type.IsSQLite3() && st.Status == pass {
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)
}
}
checks["database:ping"] = []componentStatus{st}
return st.Status
}
// cache checks gitea cache status
func checkCache(checks checks) status {
st := componentStatus{}
if err := cache.GetCache().Ping(); err != nil {
st.Status = fail
st.Time = getCheckTime()
log.Error("cache ping failed with error: %v", err)
} else {
st.Status = pass
st.Time = getCheckTime()
}
checks["cache:ping"] = []componentStatus{st}
return st.Status
}
func getCheckTime() string {
return time.Now().UTC().Format(time.RFC3339)
}
+111
View File
@@ -0,0 +1,111 @@
// Copyright 2014 The Gogs Authors. All rights reserved.
// Copyright 2019 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package web
import (
"net/http"
"strconv"
"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 (
// tplHome home page template
tplHome templates.TplName = "home"
)
// Home render home page
func Home(ctx *context.Context) {
if ctx.IsSigned {
if !ctx.Doer.IsActive && setting.Service.RegisterEmailConfirm {
ctx.Data["Title"] = ctx.Tr("auth.active_your_account")
ctx.HTML(http.StatusOK, auth.TplActivate)
} else if !ctx.Doer.IsActive || ctx.Doer.ProhibitLogin {
log.Info("Failed authentication attempt for %s from %s", ctx.Doer.Name, ctx.RemoteAddr())
ctx.Data["Title"] = ctx.Tr("auth.prohibit_login")
ctx.HTML(http.StatusOK, "user/auth/prohibit_login")
} else if ctx.Doer.MustChangePassword {
ctx.Data["Title"] = ctx.Tr("auth.must_change_password")
ctx.Data["ChangePasscodeLink"] = setting.AppSubURL + "/user/change_password"
middleware.SetRedirectToCookie(ctx.Resp, setting.AppSubURL+ctx.Req.URL.RequestURI())
ctx.Redirect(setting.AppSubURL + "/user/settings/change_password")
} else {
user.Dashboard(ctx)
}
return
// Check non-logged users landing page.
} else if setting.LandingPageURL != setting.LandingPageHome {
ctx.Redirect(setting.AppSubURL + string(setting.LandingPageURL))
return
}
// Check auto-login.
if ctx.GetSiteCookie(setting.CookieRememberName) != "" {
ctx.Redirect(setting.AppSubURL + "/user/login")
return
}
ctx.Data["PageIsHome"] = true
ctx.Data["IsRepoIndexerEnabled"] = setting.Indexer.RepoIndexerEnabled
ctx.HTML(http.StatusOK, tplHome)
}
// HomeSitemap renders the main sitemap
func HomeSitemap(ctx *context.Context) {
m := sitemap.NewSitemapIndex()
if !setting.Service.Explore.DisableUsersPage {
_, cnt, err := user_model.SearchUsers(ctx, user_model.SearchUserOptions{
Types: []user_model.UserType{user_model.UserTypeIndividual},
ListOptions: db.ListOptions{PageSize: 1},
IsActive: optional.Some(true),
Visible: []structs.VisibleType{structs.VisibleTypePublic},
})
if err != nil {
ctx.ServerError("SearchUsers", err)
return
}
count := int(cnt)
idx := 1
for i := 0; i < count; i += setting.UI.SitemapPagingNum {
m.Add(sitemap.URL{URL: setting.AppURL + "explore/users/sitemap-" + strconv.Itoa(idx) + ".xml"})
idx++
}
}
_, cnt, err := repo_model.SearchRepository(ctx, repo_model.SearchRepoOptions{
ListOptions: db.ListOptions{
PageSize: 1,
},
Actor: ctx.Doer,
AllPublic: true,
})
if err != nil {
ctx.ServerError("SearchRepository", err)
return
}
count := int(cnt)
idx := 1
for i := 0; i < count; i += setting.UI.SitemapPagingNum {
m.Add(sitemap.URL{URL: setting.AppURL + "explore/repos/sitemap-" + strconv.Itoa(idx) + ".xml"})
idx++
}
ctx.Resp.Header().Set("Content-Type", "text/xml")
if _, err := m.WriteTo(ctx.Resp); err != nil {
log.Error("Failed writing sitemap: %v", err)
}
}
+33
View File
@@ -0,0 +1,33 @@
// Copyright 2018 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package web
import (
"crypto/subtle"
"net/http"
"gitea.dev/modules/setting"
"github.com/prometheus/client_golang/prometheus/promhttp"
)
// Metrics validate auth token and render prometheus metrics
func Metrics(resp http.ResponseWriter, req *http.Request) {
if setting.Metrics.Token == "" {
promhttp.Handler().ServeHTTP(resp, req)
return
}
header := req.Header.Get("Authorization")
if header == "" {
http.Error(resp, "", http.StatusUnauthorized)
return
}
got := []byte(header)
want := []byte("Bearer " + setting.Metrics.Token)
if subtle.ConstantTimeCompare(got, want) != 1 {
http.Error(resp, "", http.StatusUnauthorized)
return
}
promhttp.Handler().ServeHTTP(resp, req)
}
+20
View File
@@ -0,0 +1,20 @@
// Copyright 2014 The Gogs Authors. All rights reserved.
// Copyright 2022 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package misc
import (
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
func Markup(ctx *context.Context) {
form := web.GetForm(ctx).(*api.MarkupOption)
mode := util.Iif(form.Wiki, "wiki", form.Mode) //nolint:staticcheck // form.Wiki is deprecated
common.RenderMarkup(ctx.Base, ctx.Repo, mode, form.Text, form.Context, form.FilePath)
}
+90
View File
@@ -0,0 +1,90 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package misc
import (
"net/http"
"path"
"strconv"
"strings"
"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)
return
}
rw.Header().Set("content-type", "text/json;charset=UTF-8")
_, err := rw.Write([]byte(`{"type":"agit","version":1}`))
if err != nil {
log.Error("fail to write result: err: %v", err)
rw.WriteHeader(http.StatusInternalServerError)
return
}
rw.WriteHeader(http.StatusOK)
}
func DummyOK(w http.ResponseWriter, req *http.Request) {
w.WriteHeader(http.StatusOK)
}
func RobotsTxt(w http.ResponseWriter, req *http.Request) {
robotsTxt := util.FilePathJoinAbs(setting.CustomPath, "public/robots.txt")
if ok, _ := util.IsExist(robotsTxt); !ok {
robotsTxt = util.FilePathJoinAbs(setting.CustomPath, "robots.txt") // the legacy "robots.txt"
}
httpcache.SetCacheControlInHeader(w.Header(), httpcache.CacheControlForPublicStatic())
http.ServeFile(w, req, robotsTxt)
}
func StaticRedirect(target string) func(w http.ResponseWriter, req *http.Request) {
return func(w http.ResponseWriter, req *http.Request) {
http.Redirect(w, req, path.Join(setting.StaticURLPrefix, target), http.StatusMovedPermanently)
}
}
func LocationRedirect(target string) func(w http.ResponseWriter, req *http.Request) {
return func(w http.ResponseWriter, req *http.Request) {
http.Redirect(w, req, target, http.StatusSeeOther)
}
}
func WebBannerDismiss(ctx *context.Context) {
_, rev, _ := setting.Config().Instance.WebBanner.ValueRevision(ctx)
middleware.SetSiteCookie(ctx.Resp, middleware.CookieWebBannerDismissed, strconv.Itoa(rev), 48*3600)
ctx.JSONOK()
}
+14
View File
@@ -0,0 +1,14 @@
// Copyright 2017 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package misc
import (
"net/http"
"gitea.dev/services/context"
)
func Swagger(ctx *context.Context) {
ctx.HTML(http.StatusOK, "swagger/openapi-viewer")
}
+42
View File
@@ -0,0 +1,42 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package misc
import (
"net/http"
"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) {
curWebTheme := ctx.TemplateContext.CurrentWebTheme()
renderUtils := templates.NewRenderUtils(ctx)
allThemes := webtheme.GetAvailableThemes()
var results []map[string]any
for _, theme := range allThemes {
results = append(results, map[string]any{
"name": renderUtils.RenderThemeItem(theme, 14),
"value": theme.InternalName,
"class": "item js-aria-clickable" + util.Iif(theme.InternalName == curWebTheme.InternalName, " selected", ""),
})
}
ctx.JSON(http.StatusOK, map[string]any{"results": results})
}
func WebThemeApply(ctx *context.Context) {
themeName := ctx.FormString("theme")
if ctx.Doer != nil {
opts := &user_service.UpdateOptions{Theme: optional.Some(themeName)}
_ = user_service.UpdateUser(ctx, ctx.Doer, opts)
} else {
middleware.SetSiteCookie(ctx.Resp, middleware.CookieTheme, themeName, 0)
}
}
+31
View File
@@ -0,0 +1,31 @@
// Copyright 2021 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package web
import (
"net/http"
"gitea.dev/modules/setting"
"gitea.dev/services/context"
)
type nodeInfoLinks struct {
Links []nodeInfoLink `json:"links"`
}
type nodeInfoLink struct {
Href string `json:"href"`
Rel string `json:"rel"`
}
// NodeInfoLinks returns links to the node info endpoint
func NodeInfoLinks(ctx *context.Context) {
nodeinfolinks := &nodeInfoLinks{
Links: []nodeInfoLink{{
setting.AppURL + "api/v1/nodeinfo",
"http://nodeinfo.diaspora.software/ns/schema/2.1",
}},
}
ctx.JSON(http.StatusOK, nodeinfolinks)
}
+38
View File
@@ -0,0 +1,38 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package org
import (
"net/http"
"gitea.dev/modules/templates"
shared_user "gitea.dev/routers/web/shared/user"
"gitea.dev/services/context"
)
const (
tplSettingsBlockedUsers templates.TplName = "org/settings/blocked_users"
)
func BlockedUsers(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("user.block.list")
ctx.Data["PageIsOrgSettings"] = true
ctx.Data["PageIsSettingsBlockedUsers"] = true
if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil {
ctx.ServerError("RenderUserOrgHeader", err)
return
}
shared_user.BlockedUsers(ctx, ctx.ContextUser)
if ctx.Written() {
return
}
ctx.HTML(http.StatusOK, tplSettingsBlockedUsers)
}
func BlockedUsersPost(ctx *context.Context) {
shared_user.BlockedUsersPost(ctx, ctx.ContextUser, ctx.ContextUser.OrganisationLink()+"/settings/blocked_users")
}
+194
View File
@@ -0,0 +1,194 @@
// Copyright 2019 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package org
import (
"net/http"
"path"
"strings"
"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"
// Home show organization home page
func Home(ctx *context.Context) {
uname := ctx.PathParam("username")
if strings.HasSuffix(uname, ".keys") || strings.HasSuffix(uname, ".gpg") {
ctx.NotFound(nil)
return
}
ctx.SetPathParam("org", uname)
context.OrgAssignment(context.OrgAssignmentOptions{})(ctx)
if ctx.Written() {
return
}
home(ctx, false)
}
func Repositories(ctx *context.Context) {
home(ctx, true)
}
func home(ctx *context.Context, viewRepositories bool) {
org := ctx.Org.Organization
ctx.Data["PageIsUserProfile"] = true
ctx.Data["Title"] = org.DisplayName()
var orderBy db.SearchOrderBy
sortOrder := ctx.FormString("sort")
if _, ok := repo_model.OrderByFlatMap[sortOrder]; !ok {
sortOrder = setting.UI.ExploreDefaultSort // TODO: add new default sort order for org home?
}
ctx.Data["SortType"] = sortOrder
orderBy = repo_model.OrderByFlatMap[sortOrder]
keyword := ctx.FormTrim("q")
ctx.Data["Keyword"] = keyword
language := ctx.FormTrim("language")
ctx.Data["Language"] = language
page := ctx.FormInt("page")
if page <= 0 {
page = 1
}
archived := ctx.FormOptionalBool("archived")
ctx.Data["IsArchived"] = archived
fork := ctx.FormOptionalBool("fork")
ctx.Data["IsFork"] = fork
mirror := ctx.FormOptionalBool("mirror")
ctx.Data["IsMirror"] = mirror
template := ctx.FormOptionalBool("template")
ctx.Data["IsTemplate"] = template
private := ctx.FormOptionalBool("private")
ctx.Data["IsPrivate"] = private
opts := &organization.FindOrgMembersOpts{
Doer: ctx.Doer,
OrgID: org.ID,
IsDoerMember: ctx.Org.IsMember,
ListOptions: db.ListOptions{Page: 1, PageSize: 25},
}
members, _, err := organization.FindOrgMembers(ctx, opts)
if err != nil {
ctx.ServerError("FindOrgMembers", err)
return
}
const orgOverviewTeamsLimit = 5
ctx.Data["OrgOverviewMembers"] = members
ctx.Data["OrgOverviewTeams"] = ctx.Org.Teams[:min(len(ctx.Org.Teams), orgOverviewTeamsLimit)]
ctx.Data["DisableNewPullMirrors"] = setting.Mirror.DisableNewPull
ctx.Data["ShowMemberAndTeamTab"] = ctx.Org.IsMember || len(members) > 0
prepareResult, err := shared_user.RenderUserOrgHeader(ctx)
if err != nil {
ctx.ServerError("RenderUserOrgHeader", err)
return
}
// if no profile readme, it still means "view repositories"
isViewOverview := !viewRepositories && prepareOrgProfileReadme(ctx, prepareResult)
ctx.Data["PageIsViewRepositories"] = !isViewOverview
ctx.Data["PageIsViewOverview"] = isViewOverview
ctx.Data["ShowOrgProfileReadmeSelector"] = isViewOverview && prepareResult.ProfilePublicReadmeBlob != nil && prepareResult.ProfilePrivateReadmeBlob != nil
repos, count, err := repo_model.SearchRepository(ctx, repo_model.SearchRepoOptions{
ListOptions: db.ListOptions{
PageSize: setting.UI.User.RepoPagingNum,
Page: page,
},
Keyword: keyword,
OwnerID: org.ID,
OrderBy: orderBy,
Private: ctx.IsSigned,
Actor: ctx.Doer,
Language: language,
IncludeDescription: setting.UI.SearchRepoDescription,
Archived: archived,
Fork: fork,
Mirror: mirror,
Template: template,
IsPrivate: private,
})
if err != nil {
ctx.ServerError("SearchRepository", err)
return
}
ctx.Data["Repos"] = repos
ctx.Data["Total"] = count
pager := context.NewPagination(count, setting.UI.User.RepoPagingNum, page, 5)
pager.AddParamFromRequest(ctx.Req)
ctx.Data["Page"] = pager
ctx.HTML(http.StatusOK, tplOrgHome)
}
func prepareOrgProfileReadme(ctx *context.Context, prepareResult *shared_user.PrepareOwnerHeaderResult) bool {
viewAs := ctx.FormString("view_as", util.Iif(ctx.Org.IsMember, "member", "public"))
viewAsMember := viewAs == "member"
var profileRepo *repo_model.Repository
var readmeBlob *git.Blob
if viewAsMember {
if prepareResult.ProfilePrivateReadmeBlob != nil {
profileRepo, readmeBlob = prepareResult.ProfilePrivateRepo, prepareResult.ProfilePrivateReadmeBlob
} else {
profileRepo, readmeBlob = prepareResult.ProfilePublicRepo, prepareResult.ProfilePublicReadmeBlob
viewAsMember = false
}
} else {
if prepareResult.ProfilePublicReadmeBlob != nil {
profileRepo, readmeBlob = prepareResult.ProfilePublicRepo, prepareResult.ProfilePublicReadmeBlob
} else {
profileRepo, readmeBlob = prepareResult.ProfilePrivateRepo, prepareResult.ProfilePrivateReadmeBlob
viewAsMember = true
}
}
if readmeBlob == nil {
return false
}
readmeBytes, err := readmeBlob.GetBlobContent(setting.UI.MaxDisplayFileSize)
if err != nil {
log.Error("failed to GetBlobContent for profile %q (view as %q) readme: %v", profileRepo.FullName(), viewAs, err)
return false
}
rctx := renderhelper.NewRenderContextRepoFile(ctx, profileRepo, renderhelper.RepoFileOptions{
CurrentRefSubURL: path.Join("branch", util.PathEscapeSegments(profileRepo.DefaultBranch)),
})
ctx.Data["ProfileReadmeContent"], err = markdown.RenderString(rctx, readmeBytes)
if err != nil {
log.Error("failed to GetBlobContent for profile %q (view as %q) readme: %v", profileRepo.FullName(), viewAs, err)
return false
}
ctx.Data["IsViewingOrgAsMember"] = viewAsMember
return true
}
+14
View File
@@ -0,0 +1,14 @@
// Copyright 2018 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package org_test
import (
"testing"
"gitea.dev/models/unittest"
)
func TestMain(m *testing.M) {
unittest.MainTest(m)
}
+131
View File
@@ -0,0 +1,131 @@
// Copyright 2014 The Gogs Authors. All rights reserved.
// Copyright 2020 The Gitea Authors.
// SPDX-License-Identifier: MIT
package org
import (
"errors"
"net/http"
"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 (
// tplMembers template for organization members page
tplMembers templates.TplName = "org/member/members"
)
// Members render organization users page
func Members(ctx *context.Context) {
org := ctx.Org.Organization
ctx.Data["Title"] = org.FullName
ctx.Data["PageIsOrgMembers"] = true
page := max(ctx.FormInt("page"), 1)
opts := &organization.FindOrgMembersOpts{
Doer: ctx.Doer,
OrgID: org.ID,
}
if ctx.Doer != nil {
isMember, err := ctx.Org.Organization.IsOrgMember(ctx, ctx.Doer.ID)
if err != nil {
ctx.HTTPError(http.StatusInternalServerError, "IsOrgMember")
return
}
opts.IsDoerMember = isMember
}
ctx.Data["PublicOnly"] = opts.PublicOnly()
total, err := organization.CountOrgMembers(ctx, opts)
if err != nil {
ctx.HTTPError(http.StatusInternalServerError, "CountOrgMembers")
return
}
if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil {
ctx.ServerError("RenderUserOrgHeader", err)
return
}
pager := context.NewPagination(total, setting.UI.MembersPagingNum, page, 5)
opts.ListOptions.Page = page
opts.ListOptions.PageSize = setting.UI.MembersPagingNum
members, membersIsPublic, err := organization.FindOrgMembers(ctx, opts)
if err != nil {
ctx.ServerError("GetMembers", err)
return
}
ctx.Data["Page"] = pager
ctx.Data["Members"] = members
ctx.Data["MembersIsPublicMember"] = membersIsPublic
ctx.Data["MembersIsUserOrgOwner"] = organization.IsUserOrgOwner(ctx, members, org.ID)
ctx.Data["MembersTwoFaStatus"] = members.GetTwoFaStatus(ctx)
ctx.HTML(http.StatusOK, tplMembers)
}
// MembersAction response for operation to a member of organization
func MembersAction(ctx *context.Context) {
member, err := user_model.GetUserByID(ctx, ctx.FormInt64("uid"))
if errors.Is(err, util.ErrNotExist) {
ctx.HTTPError(http.StatusNotFound)
return
} else if err != nil {
ctx.ServerError("GetUserByID", err)
return
}
org := ctx.Org.Organization
switch ctx.PathParam("action") {
case "private":
if ctx.Doer.ID != member.ID && !ctx.Org.IsOwner {
ctx.HTTPError(http.StatusNotFound)
return
}
err = organization.ChangeOrgUserStatus(ctx, org.ID, member.ID, false)
case "public":
if ctx.Doer.ID != member.ID && !ctx.Org.IsOwner {
ctx.HTTPError(http.StatusNotFound)
return
}
err = organization.ChangeOrgUserStatus(ctx, org.ID, member.ID, true)
case "remove":
if !ctx.Org.IsOwner {
ctx.HTTPError(http.StatusNotFound)
return
}
err = org_service.RemoveOrgUser(ctx, org, member)
case "leave":
err = org_service.RemoveOrgUser(ctx, org, ctx.Doer)
if err == nil {
ctx.Flash.Success(ctx.Tr("form.organization_leave_success", org.DisplayName()))
ctx.JSONRedirect(setting.AppSubURL + "/")
return
}
}
if err == nil {
ctx.JSONOK()
return
}
if organization.IsErrLastOrgOwner(err) {
ctx.JSONError(ctx.Tr("form.last_org_owner"))
return
}
log.Error("Action(%s): %v", ctx.PathParam("action"), err)
ctx.JSONError(err.Error()) // FIXME: legacy logic, errors are handled together, it's not right, need to distinguish between different errors
}
+42
View File
@@ -0,0 +1,42 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package org
import (
"net/http"
"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.
func GetMentionsInOwner(ctx *context.Context) {
// for individual users, we don't have a concept of "mentionable" users or teams, so just return an empty list
if !ctx.ContextUser.IsOrganization() {
ctx.JSON(http.StatusOK, []shared_mention.Mention{})
return
}
// for org, return members and teams
c := shared_mention.NewCollector()
org := organization.OrgFromUser(ctx.ContextUser)
// Get org members
members, _, err := org.GetMembers(ctx, ctx.Doer)
if err != nil {
ctx.ServerError("GetMembers", err)
return
}
c.AddUsers(ctx, members)
// Get mentionable teams
if err := c.AddMentionableTeams(ctx, ctx.Doer, ctx.ContextUser); err != nil {
ctx.ServerError("AddMentionableTeams", err)
return
}
ctx.JSON(http.StatusOK, util.SliceNilAsEmpty(c.Result))
}
+83
View File
@@ -0,0 +1,83 @@
// Copyright 2014 The Gogs Authors. All rights reserved.
// Copyright 2018 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package org
import (
"errors"
"net/http"
"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 (
// tplCreateOrg template path for create organization
tplCreateOrg templates.TplName = "org/create"
)
// Create render the page for create organization
func Create(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("new_org")
if !ctx.Doer.CanCreateOrganization() {
ctx.ServerError("Not allowed", errors.New(ctx.Locale.TrString("org.form.create_org_not_allowed")))
return
}
ctx.Data["visibility"] = setting.Service.DefaultOrgVisibilityMode
ctx.Data["repo_admin_change_team_access"] = true
ctx.HTML(http.StatusOK, tplCreateOrg)
}
// CreatePost response for create organization
func CreatePost(ctx *context.Context) {
form := *web.GetForm(ctx).(*forms.CreateOrgForm)
ctx.Data["Title"] = ctx.Tr("new_org")
if !ctx.Doer.CanCreateOrganization() {
ctx.ServerError("Not allowed", errors.New(ctx.Locale.TrString("org.form.create_org_not_allowed")))
return
}
if ctx.HasError() {
ctx.HTML(http.StatusOK, tplCreateOrg)
return
}
org := &organization.Organization{
Name: form.OrgName,
IsActive: true,
Type: user_model.UserTypeOrganization,
Visibility: form.Visibility,
RepoAdminChangeTeamAccess: form.RepoAdminChangeTeamAccess,
}
if err := organization.CreateOrganization(ctx, org, ctx.Doer); err != nil {
ctx.Data["Err_OrgName"] = true
switch {
case user_model.IsErrUserAlreadyExist(err):
ctx.RenderWithErrDeprecated(ctx.Tr("form.org_name_been_taken"), tplCreateOrg, &form)
case db.IsErrNameReserved(err):
ctx.RenderWithErrDeprecated(ctx.Tr("org.form.name_reserved", err.(db.ErrNameReserved).Name), tplCreateOrg, &form)
case db.IsErrNamePatternNotAllowed(err):
ctx.RenderWithErrDeprecated(ctx.Tr("org.form.name_pattern_not_allowed", err.(db.ErrNamePatternNotAllowed).Pattern), tplCreateOrg, &form)
case organization.IsErrUserNotAllowedCreateOrg(err):
ctx.RenderWithErrDeprecated(ctx.Tr("org.form.create_org_not_allowed"), tplCreateOrg, &form)
default:
ctx.ServerError("CreateOrganization", err)
}
return
}
log.Trace("Organization created: %s", org.Name)
ctx.Redirect(org.AsUser().DashboardLink())
}
+116
View File
@@ -0,0 +1,116 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package org
import (
"errors"
"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
func RetrieveLabels(ctx *context.Context) {
labels, err := issues_model.GetLabelsByOrgID(ctx, ctx.Org.Organization.ID, ctx.FormString("sort"), db.ListOptions{})
if err != nil {
ctx.ServerError("RetrieveLabels.GetLabels", err)
return
}
for _, l := range labels {
l.CalOpenIssues()
}
ctx.Data["Labels"] = labels
ctx.Data["NumLabels"] = len(labels)
ctx.Data["SortType"] = ctx.FormString("sort")
}
// NewLabel create new label for organization
func NewLabel(ctx *context.Context) {
form := shared_label.GetLabelEditForm(ctx)
if ctx.Written() {
return
}
l := &issues_model.Label{
OrgID: ctx.Org.Organization.ID,
Name: form.Title,
Exclusive: form.Exclusive,
Description: form.Description,
Color: form.Color,
ExclusiveOrder: form.ExclusiveOrder,
}
if err := issues_model.NewLabel(ctx, l); err != nil {
ctx.ServerError("NewLabel", err)
return
}
ctx.JSONRedirect(ctx.Org.OrgLink + "/settings/labels")
}
// UpdateLabel update a label's name and color
func UpdateLabel(ctx *context.Context) {
form := shared_label.GetLabelEditForm(ctx)
if ctx.Written() {
return
}
l, err := issues_model.GetLabelInOrgByID(ctx, ctx.Org.Organization.ID, form.ID)
if errors.Is(err, util.ErrNotExist) {
ctx.JSONErrorNotFound()
return
} else if err != nil {
ctx.ServerError("GetLabelInOrgByID", err)
return
}
l.Name = form.Title
l.Exclusive = form.Exclusive
l.ExclusiveOrder = form.ExclusiveOrder
l.Description = form.Description
l.Color = form.Color
l.SetArchived(form.IsArchived)
if err := issues_model.UpdateLabel(ctx, l); err != nil {
ctx.ServerError("UpdateLabel", err)
return
}
ctx.JSONRedirect(ctx.Org.OrgLink + "/settings/labels")
}
// DeleteLabel delete a label
func DeleteLabel(ctx *context.Context) {
if err := issues_model.DeleteLabel(ctx, ctx.Org.Organization.ID, ctx.FormInt64("id")); err != nil {
ctx.Flash.Error("DeleteLabel: " + err.Error())
} else {
ctx.Flash.Success(ctx.Tr("repo.issues.label_deletion_success"))
}
ctx.JSONRedirect(ctx.Org.OrgLink + "/settings/labels")
}
// InitializeLabels init labels for an organization
func InitializeLabels(ctx *context.Context) {
form := web.GetForm(ctx).(*forms.InitializeLabelsForm)
if ctx.HasError() {
ctx.Redirect(ctx.Org.OrgLink + "/labels")
return
}
if err := repo_module.InitializeLabels(ctx, ctx.Org.Organization.ID, form.TemplateName, true); err != nil {
if label.IsErrTemplateLoad(err) {
originalErr := err.(label.ErrTemplateLoad).OriginalError
ctx.Flash.Error(ctx.Tr("repo.issues.label_templates.fail_to_load_file", form.TemplateName, originalErr))
ctx.Redirect(ctx.Org.OrgLink + "/settings/labels")
return
}
ctx.ServerError("InitializeLabels", err)
return
}
ctx.Redirect(ctx.Org.OrgLink + "/settings/labels")
}
+662
View File
@@ -0,0 +1,662 @@
// Copyright 2022 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package org
import (
"errors"
"fmt"
"net/http"
"strings"
"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"
)
const (
tplProjects templates.TplName = "org/projects/list"
tplProjectsNew templates.TplName = "org/projects/new"
tplProjectsView templates.TplName = "org/projects/view"
)
// Projects renders the home page of projects
func Projects(ctx *context.Context) {
if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil {
ctx.ServerError("RenderUserOrgHeader", err)
return
}
ctx.Data["Title"] = ctx.Tr("repo.projects")
sortType := ctx.FormTrim("sort")
isShowClosed := strings.ToLower(ctx.FormTrim("state")) == "closed"
keyword := ctx.FormTrim("q")
page := max(ctx.FormInt("page"), 1)
var projectType project_model.Type
if ctx.ContextUser.IsOrganization() {
projectType = project_model.TypeOrganization
} else {
projectType = project_model.TypeIndividual
}
projects, total, err := db.FindAndCount[project_model.Project](ctx, project_model.SearchOptions{
ListOptions: db.ListOptions{
Page: page,
PageSize: setting.UI.IssuePagingNum,
},
OwnerID: ctx.ContextUser.ID,
IsClosed: optional.Some(isShowClosed),
OrderBy: project_model.GetSearchOrderByBySortType(sortType),
Type: projectType,
Title: keyword,
})
if err != nil {
ctx.ServerError("FindProjects", err)
return
}
if err := project_service.LoadIssueNumbersForProjects(ctx, projects, ctx.Doer); err != nil {
ctx.ServerError("LoadIssueNumbersForProjects", err)
return
}
opTotal, err := db.Count[project_model.Project](ctx, project_model.SearchOptions{
OwnerID: ctx.ContextUser.ID,
IsClosed: optional.Some(!isShowClosed),
Type: projectType,
})
if err != nil {
ctx.ServerError("CountProjects", err)
return
}
if isShowClosed {
ctx.Data["OpenCount"] = opTotal
ctx.Data["ClosedCount"] = total
} else {
ctx.Data["OpenCount"] = total
ctx.Data["ClosedCount"] = opTotal
}
ctx.Data["Projects"] = projects
if isShowClosed {
ctx.Data["State"] = "closed"
} else {
ctx.Data["State"] = "open"
}
renderUtils := templates.NewRenderUtils(ctx)
for _, project := range projects {
project.RenderedContent = renderUtils.MarkdownToHtml(project.Description)
}
pager := context.NewPagination(total, setting.UI.IssuePagingNum, page, 5)
pager.AddParamFromRequest(ctx.Req)
ctx.Data["Page"] = pager
ctx.Data["CanWriteProjects"] = canWriteProjects(ctx)
ctx.Data["IsShowClosed"] = isShowClosed
ctx.Data["PageIsViewProjects"] = true
ctx.Data["SortType"] = sortType
ctx.HTML(http.StatusOK, tplProjects)
}
func canWriteProjects(ctx *context.Context) bool {
if ctx.ContextUser.IsOrganization() {
return ctx.Org.CanWriteUnit(ctx, unit.TypeProjects)
}
return ctx.Doer != nil && ctx.ContextUser.ID == ctx.Doer.ID
}
// RenderNewProject render creating a project page
func RenderNewProject(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("repo.projects.new")
ctx.Data["TemplateConfigs"] = project_model.GetTemplateConfigs()
ctx.Data["CardTypes"] = project_model.GetCardConfig()
ctx.Data["CanWriteProjects"] = canWriteProjects(ctx)
ctx.Data["PageIsViewProjects"] = true
ctx.Data["HomeLink"] = ctx.ContextUser.HomeLink()
ctx.Data["CancelLink"] = ctx.ContextUser.HomeLink() + "/-/projects"
if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil {
ctx.ServerError("RenderUserOrgHeader", err)
return
}
ctx.HTML(http.StatusOK, tplProjectsNew)
}
// NewProjectPost creates a new project
func NewProjectPost(ctx *context.Context) {
form := web.GetForm(ctx).(*forms.CreateProjectForm)
ctx.Data["Title"] = ctx.Tr("repo.projects.new")
if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil {
ctx.ServerError("RenderUserOrgHeader", err)
return
}
if ctx.HasError() {
RenderNewProject(ctx)
return
}
newProject := project_model.Project{
OwnerID: ctx.ContextUser.ID,
Title: form.Title,
Description: form.Content,
CreatorID: ctx.Doer.ID,
TemplateType: form.TemplateType,
CardType: form.CardType,
}
if ctx.ContextUser.IsOrganization() {
newProject.Type = project_model.TypeOrganization
} else {
newProject.Type = project_model.TypeIndividual
}
if err := project_model.NewProject(ctx, &newProject); err != nil {
ctx.ServerError("NewProject", err)
return
}
ctx.Flash.Success(ctx.Tr("repo.projects.create_success", form.Title))
ctx.Redirect(ctx.ContextUser.HomeLink() + "/-/projects")
}
// ChangeProjectStatus updates the status of a project between "open" and "close"
func ChangeProjectStatus(ctx *context.Context) {
var toClose bool
switch ctx.PathParam("action") {
case "open":
toClose = false
case "close":
toClose = true
default:
ctx.JSONRedirect(ctx.ContextUser.HomeLink() + "/-/projects")
return
}
id := ctx.PathParamInt64("id")
project, err := project_model.GetProjectByIDAndOwner(ctx, id, ctx.ContextUser.ID)
if err != nil {
ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err)
return
}
if err := project_model.ChangeProjectStatusByRepoIDAndID(ctx, 0, project.ID, toClose); err != nil {
ctx.NotFoundOrServerError("ChangeProjectStatusByRepoIDAndID", project_model.IsErrProjectNotExist, err)
return
}
ctx.JSONRedirect(project_model.ProjectLinkForOrg(ctx.ContextUser, project.ID))
}
// DeleteProject delete a project
func DeleteProject(ctx *context.Context) {
p, err := project_model.GetProjectByIDAndOwner(ctx, ctx.PathParamInt64("id"), ctx.ContextUser.ID)
if err != nil {
ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err)
return
}
if err := project_model.DeleteProjectByID(ctx, p.ID); err != nil {
ctx.Flash.Error("DeleteProjectByID: " + err.Error())
} else {
ctx.Flash.Success(ctx.Tr("repo.projects.deletion_success"))
}
ctx.JSONRedirect(ctx.ContextUser.HomeLink() + "/-/projects")
}
// RenderEditProject allows a project to be edited
func RenderEditProject(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("repo.projects.edit")
ctx.Data["PageIsEditProjects"] = true
ctx.Data["PageIsViewProjects"] = true
ctx.Data["CanWriteProjects"] = canWriteProjects(ctx)
ctx.Data["CardTypes"] = project_model.GetCardConfig()
if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil {
ctx.ServerError("RenderUserOrgHeader", err)
return
}
p, err := project_model.GetProjectByIDAndOwner(ctx, ctx.PathParamInt64("id"), ctx.ContextUser.ID)
if err != nil {
ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err)
return
}
ctx.Data["projectID"] = p.ID
ctx.Data["title"] = p.Title
ctx.Data["content"] = p.Description
ctx.Data["redirect"] = ctx.FormString("redirect")
ctx.Data["HomeLink"] = ctx.ContextUser.HomeLink()
ctx.Data["card_type"] = p.CardType
ctx.Data["CancelLink"] = project_model.ProjectLinkForOrg(ctx.ContextUser, p.ID)
ctx.HTML(http.StatusOK, tplProjectsNew)
}
// EditProjectPost response for editing a project
func EditProjectPost(ctx *context.Context) {
form := web.GetForm(ctx).(*forms.CreateProjectForm)
projectID := ctx.PathParamInt64("id")
ctx.Data["Title"] = ctx.Tr("repo.projects.edit")
ctx.Data["PageIsEditProjects"] = true
ctx.Data["PageIsViewProjects"] = true
ctx.Data["CanWriteProjects"] = canWriteProjects(ctx)
ctx.Data["CardTypes"] = project_model.GetCardConfig()
ctx.Data["CancelLink"] = project_model.ProjectLinkForOrg(ctx.ContextUser, projectID)
if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil {
ctx.ServerError("RenderUserOrgHeader", err)
return
}
if ctx.HasError() {
ctx.HTML(http.StatusOK, tplProjectsNew)
return
}
p, err := project_model.GetProjectByIDAndOwner(ctx, projectID, ctx.ContextUser.ID)
if err != nil {
ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err)
return
}
p.Title = form.Title
p.Description = form.Content
p.CardType = form.CardType
if err = project_model.UpdateProject(ctx, p); err != nil {
ctx.ServerError("UpdateProjects", err)
return
}
ctx.Flash.Success(ctx.Tr("repo.projects.edit_success", p.Title))
if ctx.FormString("redirect") == "project" {
ctx.Redirect(p.Link(ctx))
} else {
ctx.Redirect(ctx.ContextUser.HomeLink() + "/-/projects")
}
}
// ViewProject renders the project with board view for a project
func ViewProject(ctx *context.Context) {
project, err := project_model.GetProjectByIDAndOwner(ctx, ctx.PathParamInt64("id"), ctx.ContextUser.ID)
if err != nil {
ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err)
return
}
if err := project.LoadOwner(ctx); err != nil {
ctx.ServerError("LoadOwner", err)
return
}
columns, err := project.GetColumns(ctx)
if err != nil {
ctx.ServerError("GetProjectColumns", err)
return
}
preparedLabelFilter := issue.PrepareFilterIssueLabels(ctx, project.RepoID, project.Owner)
if ctx.Written() {
return
}
assigneeID := ctx.FormString("assignee")
milestoneID := ctx.FormInt64("milestone")
// Prepare milestone IDs for filtering
var milestoneIDs []int64
if milestoneID > 0 {
milestoneIDs = []int64{milestoneID}
} else if milestoneID == db.NoConditionID {
milestoneIDs = []int64{db.NoConditionID}
}
opts := issues_model.IssuesOptions{
LabelIDs: preparedLabelFilter.SelectedLabelIDs,
AssigneeID: assigneeID,
MilestoneIDs: milestoneIDs,
Owner: project.Owner,
}
if ctx.Doer != nil {
opts.Doer = ctx.Doer
} else {
opts.AllPublic = true
}
issuesMap, err := project_service.LoadIssuesFromProject(ctx, project, &opts)
if err != nil {
ctx.ServerError("LoadIssuesOfColumns", err)
return
}
for _, column := range columns {
column.NumIssues = int64(len(issuesMap[column.ID]))
}
if project.CardType != project_model.CardTypeTextOnly {
issuesAttachmentMap := make(map[int64][]*repo_model.Attachment)
for _, issuesList := range issuesMap {
for _, issue := range issuesList {
if issueAttachment, err := repo_model.GetAttachmentsByIssueIDImagesLatest(ctx, issue.ID); err == nil {
issuesAttachmentMap[issue.ID] = issueAttachment
}
}
}
ctx.Data["issuesAttachmentMap"] = issuesAttachmentMap
}
linkedPrsMap := make(map[int64][]*issues_model.Issue)
for _, issuesList := range issuesMap {
for _, issue := range issuesList {
var referencedIDs []int64
for _, comment := range issue.Comments {
if comment.RefIssueID != 0 && comment.RefIsPull {
referencedIDs = append(referencedIDs, comment.RefIssueID)
}
}
if len(referencedIDs) > 0 {
if linkedPrs, err := issues_model.Issues(ctx, &issues_model.IssuesOptions{
IssueIDs: referencedIDs,
IsPull: optional.Some(true),
}); err == nil {
linkedPrsMap[issue.ID] = linkedPrs
}
}
}
}
// TODO: Add option to filter also by repository specific labels
labels, err := issues_model.GetLabelsByOrgID(ctx, project.OwnerID, "", db.ListOptions{})
if err != nil {
ctx.ServerError("GetLabelsByOrgID", err)
return
}
// Get the exclusive scope for every label ID
labelExclusiveScopes := make([]string, 0, len(preparedLabelFilter.SelectedLabelIDs))
for _, labelID := range preparedLabelFilter.SelectedLabelIDs {
foundExclusiveScope := false
for _, label := range labels {
if label.ID == labelID || label.ID == -labelID {
labelExclusiveScopes = append(labelExclusiveScopes, label.ExclusiveScope())
foundExclusiveScope = true
break
}
}
if !foundExclusiveScope {
labelExclusiveScopes = append(labelExclusiveScopes, "")
}
}
for _, l := range labels {
l.LoadSelectedLabelsAfterClick(preparedLabelFilter.SelectedLabelIDs, labelExclusiveScopes)
}
ctx.Data["Labels"] = labels
ctx.Data["NumLabels"] = len(labels)
// Get milestones for filtering
// For organization projects, we need to get milestones from all repos the user has access to
var milestones issues_model.MilestoneList
if project.RepoID > 0 {
// Repo-specific project
milestones, err = db.Find[issues_model.Milestone](ctx, issues_model.FindMilestoneOptions{
RepoID: project.RepoID,
})
if err != nil {
ctx.ServerError("GetRepoMilestones", err)
return
}
} else {
// Organization-wide project - get milestones from all organization repos
// but only from repositories the current user can access.
// Use RepoCond with a subquery to avoid materializing all repo IDs in memory
// which can hit SQL parameter limits for orgs with many repos.
accessCond := repo_model.AccessibleRepositoryCondition(ctx.Doer, unit.TypeIssues)
repoCond := builder.And(
builder.Eq{"owner_id": project.OwnerID},
accessCond,
)
milestones, err = db.Find[issues_model.Milestone](ctx, issues_model.FindMilestoneOptions{
RepoCond: repoCond,
})
if err != nil {
ctx.ServerError("GetOrgMilestones", err)
return
}
}
openMilestones, closedMilestones := milestones.SplitByOpenClosed()
ctx.Data["OpenMilestones"] = openMilestones
ctx.Data["ClosedMilestones"] = closedMilestones
ctx.Data["MilestoneID"] = milestoneID
// Get assignees.
assigneeUsers, err := project_service.LoadIssuesAssigneesForProject(ctx, issuesMap)
if err != nil {
ctx.ServerError("LoadIssuesAssigneesForProject", err)
return
}
ctx.Data["Assignees"] = shared_user.MakeSelfOnTop(ctx.Doer, assigneeUsers)
ctx.Data["AssigneeID"] = assigneeID
project.RenderedContent = templates.NewRenderUtils(ctx).MarkdownToHtml(project.Description)
ctx.Data["LinkedPRs"] = linkedPrsMap
ctx.Data["PageIsViewProjects"] = true
ctx.Data["CanWriteProjects"] = canWriteProjects(ctx)
ctx.Data["Project"] = project
ctx.Data["IssuesMap"] = issuesMap
ctx.Data["Columns"] = columns
ctx.Data["Title"] = fmt.Sprintf("%s - %s", project.Title, ctx.ContextUser.DisplayName())
if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil {
ctx.ServerError("RenderUserOrgHeader", err)
return
}
ctx.HTML(http.StatusOK, tplProjectsView)
}
// DeleteProjectColumn allows for the deletion of a project column
func DeleteProjectColumn(ctx *context.Context) {
if ctx.Doer == nil {
ctx.JSON(http.StatusForbidden, map[string]string{
"message": "Only signed in users are allowed to perform this action.",
})
return
}
project, err := project_model.GetProjectByIDAndOwner(ctx, ctx.PathParamInt64("id"), ctx.ContextUser.ID)
if err != nil {
ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err)
return
}
_, err = project_model.GetColumnByIDAndProjectID(ctx, ctx.PathParamInt64("columnID"), project.ID)
if err != nil {
ctx.NotFoundOrServerError("GetColumnByIDAndProjectID", project_model.IsErrProjectColumnNotExist, err)
return
}
if err := project_model.DeleteColumnByID(ctx, ctx.PathParamInt64("columnID")); err != nil {
ctx.ServerError("DeleteProjectColumnByID", err)
return
}
ctx.JSONOK()
}
// AddColumnToProjectPost allows a new column to be added to a project.
func AddColumnToProjectPost(ctx *context.Context) {
form := web.GetForm(ctx).(*forms.EditProjectColumnForm)
project, err := project_model.GetProjectByIDAndOwner(ctx, ctx.PathParamInt64("id"), ctx.ContextUser.ID)
if err != nil {
ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err)
return
}
if err := project_model.NewColumn(ctx, &project_model.Column{
ProjectID: project.ID,
Title: form.Title,
Color: form.Color,
CreatorID: ctx.Doer.ID,
}); err != nil {
ctx.ServerError("NewProjectColumn", err)
return
}
ctx.JSONOK()
}
// CheckProjectColumnChangePermissions check permission
func CheckProjectColumnChangePermissions(ctx *context.Context) (*project_model.Project, *project_model.Column) {
if ctx.Doer == nil {
ctx.JSON(http.StatusForbidden, map[string]string{
"message": "Only signed in users are allowed to perform this action.",
})
return nil, nil
}
project, err := project_model.GetProjectByIDAndOwner(ctx, ctx.PathParamInt64("id"), ctx.ContextUser.ID)
if err != nil {
ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err)
return nil, nil
}
column, err := project_model.GetColumnByIDAndProjectID(ctx, ctx.PathParamInt64("columnID"), project.ID)
if err != nil {
ctx.NotFoundOrServerError("GetColumnByIDAndProjectID", project_model.IsErrProjectColumnNotExist, err)
return nil, nil
}
return project, column
}
// EditProjectColumn allows a project column's to be updated
func EditProjectColumn(ctx *context.Context) {
form := web.GetForm(ctx).(*forms.EditProjectColumnForm)
_, column := CheckProjectColumnChangePermissions(ctx)
if ctx.Written() {
return
}
if form.Title != "" {
column.Title = form.Title
}
column.Color = form.Color
if form.Sorting != 0 {
column.Sorting = form.Sorting
}
if err := project_model.UpdateColumn(ctx, column); err != nil {
ctx.ServerError("UpdateProjectColumn", err)
return
}
ctx.JSONOK()
}
// SetDefaultProjectColumn set default column for uncategorized issues/pulls
func SetDefaultProjectColumn(ctx *context.Context) {
project, column := CheckProjectColumnChangePermissions(ctx)
if ctx.Written() {
return
}
if err := project_model.SetDefaultColumn(ctx, project.ID, column.ID); err != nil {
ctx.ServerError("SetDefaultColumn", err)
return
}
ctx.JSONOK()
}
// MoveIssues moves or keeps issues in a column and sorts them inside that column
func MoveIssues(ctx *context.Context) {
if ctx.Doer == nil {
ctx.JSON(http.StatusForbidden, map[string]string{
"message": "Only signed in users are allowed to perform this action.",
})
return
}
project, err := project_model.GetProjectByIDAndOwner(ctx, ctx.PathParamInt64("id"), ctx.ContextUser.ID)
if err != nil {
ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err)
return
}
column, err := project_model.GetColumnByIDAndProjectID(ctx, ctx.PathParamInt64("columnID"), project.ID)
if err != nil {
ctx.NotFoundOrServerError("GetColumnByIDAndProjectID", project_model.IsErrProjectColumnNotExist, err)
return
}
type movedIssuesForm struct {
Issues []struct {
IssueID int64 `json:"issueID"`
Sorting int64 `json:"sorting"`
} `json:"issues"`
}
form := &movedIssuesForm{}
if err = json.NewDecoder(ctx.Req.Body).Decode(&form); err != nil {
ctx.ServerError("DecodeMovedIssuesForm", err)
return
}
issueIDs := make([]int64, 0, len(form.Issues))
sortedIssueIDs := make(map[int64]int64)
for _, issue := range form.Issues {
issueIDs = append(issueIDs, issue.IssueID)
sortedIssueIDs[issue.Sorting] = issue.IssueID
}
movedIssues, err := issues_model.GetIssuesByIDs(ctx, issueIDs)
if err != nil {
ctx.NotFoundOrServerError("GetIssueByID", issues_model.IsErrIssueNotExist, err)
return
}
if len(movedIssues) != len(form.Issues) {
ctx.ServerError("some issues do not exist", errors.New("some issues do not exist"))
return
}
if _, err = movedIssues.LoadRepositories(ctx); err != nil {
ctx.ServerError("LoadRepositories", err)
return
}
for _, issue := range movedIssues {
if issue.RepoID != project.RepoID && issue.Repo.OwnerID != project.OwnerID {
ctx.ServerError("Some issue's repoID is not equal to project's repoID", errors.New("Some issue's repoID is not equal to project's repoID"))
return
}
}
if err = project_service.MoveIssuesOnProjectColumn(ctx, ctx.Doer, column, sortedIssueIDs); err != nil {
ctx.ServerError("MoveIssuesOnProjectColumn", err)
return
}
ctx.JSONOK()
}
+58
View File
@@ -0,0 +1,58 @@
// Copyright 2022 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package org_test
import (
"net/http"
"testing"
"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"
)
func TestCheckProjectColumnChangePermissions(t *testing.T) {
unittest.PrepareTestEnv(t)
ctx, _ := contexttest.MockContext(t, "user2/-/projects/4/4")
contexttest.LoadUser(t, ctx, 2)
ctx.ContextUser = ctx.Doer // user2
ctx.SetPathParam("id", "4")
ctx.SetPathParam("columnID", "4")
project, column := org.CheckProjectColumnChangePermissions(ctx)
assert.NotNil(t, project)
assert.NotNil(t, column)
assert.False(t, ctx.Written())
}
func TestChangeProjectStatusRejectsForeignProjects(t *testing.T) {
unittest.PrepareTestEnv(t)
// project 4 is owned by user2 not user1
ctx, _ := contexttest.MockContext(t, "user1/-/projects/4/close")
contexttest.LoadUser(t, ctx, 1)
ctx.ContextUser = ctx.Doer
ctx.SetPathParam("action", "close")
ctx.SetPathParam("id", "4")
org.ChangeProjectStatus(ctx)
assert.Equal(t, http.StatusNotFound, ctx.Resp.WrittenStatus())
}
func TestAddColumnToProjectPostRejectsForeignProjects(t *testing.T) {
unittest.PrepareTestEnv(t)
ctx, _ := contexttest.MockContext(t, "user1/-/projects/4/columns/new")
contexttest.LoadUser(t, ctx, 1)
ctx.ContextUser = ctx.Doer
ctx.SetPathParam("id", "4")
web.SetForm(ctx, &forms.EditProjectColumnForm{Title: "foreign"})
org.AddColumnToProjectPost(ctx)
assert.Equal(t, http.StatusNotFound, ctx.Resp.WrittenStatus())
}
+259
View File
@@ -0,0 +1,259 @@
// Copyright 2014 The Gogs Authors. All rights reserved.
// Copyright 2019 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package org
import (
"errors"
"net/http"
"net/url"
"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 (
// tplSettingsOptions template path for render settings
tplSettingsOptions templates.TplName = "org/settings/options"
// tplSettingsHooks template path for render hook settings
tplSettingsHooks templates.TplName = "org/settings/hooks"
// tplSettingsLabels template path for render labels settings
tplSettingsLabels templates.TplName = "org/settings/labels"
)
// Settings render the main settings page
func Settings(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("org.settings")
ctx.Data["PageIsOrgSettings"] = true
ctx.Data["PageIsSettingsOptions"] = true
ctx.Data["CurrentVisibility"] = ctx.Org.Organization.Visibility
ctx.Data["RepoAdminChangeTeamAccess"] = ctx.Org.Organization.RepoAdminChangeTeamAccess
ctx.Data["ContextUser"] = ctx.ContextUser
if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil {
ctx.ServerError("RenderUserOrgHeader", err)
return
}
ctx.HTML(http.StatusOK, tplSettingsOptions)
}
// SettingsPost response for settings change submitted
func SettingsPost(ctx *context.Context) {
form := web.GetForm(ctx).(*forms.UpdateOrgSettingForm)
ctx.Data["Title"] = ctx.Tr("org.settings")
ctx.Data["PageIsOrgSettings"] = true
ctx.Data["PageIsSettingsOptions"] = true
ctx.Data["CurrentVisibility"] = ctx.Org.Organization.Visibility
if ctx.HasError() {
ctx.HTML(http.StatusOK, tplSettingsOptions)
return
}
org := ctx.Org.Organization
if err := org_service.UpdateOrgEmailAddress(ctx, org, form.Email); err != nil {
if errors.Is(err, util.ErrInvalidArgument) {
ctx.Data["Err_Email"] = true
ctx.RenderWithErrDeprecated(ctx.Tr("form.email_invalid"), tplSettingsOptions, &form)
return
}
ctx.ServerError("UpdateOrgEmailAddress", err)
return
}
opts := &user_service.UpdateOptions{
FullName: optional.FromPtr(form.FullName),
Description: optional.FromPtr(form.Description),
Website: optional.FromPtr(form.Website),
Location: optional.FromPtr(form.Location),
RepoAdminChangeTeamAccess: optional.FromPtr(form.RepoAdminChangeTeamAccess),
}
if ctx.Doer.IsAdmin {
opts.MaxRepoCreation = optional.FromPtr(form.MaxRepoCreation)
}
if err := user_service.UpdateUser(ctx, org.AsUser(), opts); err != nil {
ctx.ServerError("UpdateUser", err)
return
}
log.Trace("Organization setting updated: %s", org.Name)
ctx.Flash.Success(ctx.Tr("org.settings.update_setting_success"))
ctx.Redirect(ctx.Org.OrgLink + "/settings")
}
// SettingsAvatar response for change avatar on settings page
func SettingsAvatar(ctx *context.Context) {
form := web.GetForm(ctx).(*forms.AvatarForm)
form.Source = forms.AvatarLocal
if err := user_setting.UpdateAvatarSetting(ctx, form, ctx.Org.Organization.AsUser()); err != nil {
ctx.Flash.Error(err.Error())
} else {
ctx.Flash.Success(ctx.Tr("org.settings.update_avatar_success"))
}
ctx.Redirect(ctx.Org.OrgLink + "/settings")
}
// SettingsDeleteAvatar response for delete avatar on settings page
func SettingsDeleteAvatar(ctx *context.Context) {
if err := user_service.DeleteAvatar(ctx, ctx.Org.Organization.AsUser()); err != nil {
ctx.Flash.Error(err.Error())
}
ctx.JSONRedirect(ctx.Org.OrgLink + "/settings")
}
// SettingsDeleteOrgPost response for deleting an organization
func SettingsDeleteOrgPost(ctx *context.Context) {
if ctx.Org.Organization.Name != ctx.FormString("org_name") {
ctx.JSONError(ctx.Tr("form.enterred_invalid_org_name"))
return
}
if err := org_service.DeleteOrganization(ctx, ctx.Org.Organization, false /* no purge */); err != nil {
if repo_model.IsErrUserOwnRepos(err) {
ctx.JSONError(ctx.Tr("form.org_still_own_repo"))
} else if packages_model.IsErrUserOwnPackages(err) {
ctx.JSONError(ctx.Tr("form.org_still_own_packages"))
} else {
log.Error("DeleteOrganization: %v", err)
ctx.JSONError(util.Iif(ctx.Doer.IsAdmin, err.Error(), string(ctx.Tr("org.settings.delete_failed"))))
}
return
}
ctx.Flash.Success(ctx.Tr("org.settings.delete_successful", ctx.Org.Organization.Name))
ctx.JSONRedirect(setting.AppSubURL + "/")
}
// Webhooks render webhook list page
func Webhooks(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("org.settings")
ctx.Data["PageIsOrgSettings"] = true
ctx.Data["PageIsSettingsHooks"] = true
ctx.Data["BaseLink"] = ctx.Org.OrgLink + "/settings/hooks"
ctx.Data["BaseLinkNew"] = ctx.Org.OrgLink + "/settings/hooks"
ctx.Data["Description"] = ctx.Tr("org.settings.hooks_desc")
ws, err := db.Find[webhook.Webhook](ctx, webhook.ListWebhookOptions{OwnerID: ctx.Org.Organization.ID})
if err != nil {
ctx.ServerError("ListWebhooksByOpts", err)
return
}
if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil {
ctx.ServerError("RenderUserOrgHeader", err)
return
}
ctx.Data["Webhooks"] = ws
ctx.HTML(http.StatusOK, tplSettingsHooks)
}
// DeleteWebhook response for delete webhook
func DeleteWebhook(ctx *context.Context) {
if err := webhook.DeleteWebhookByOwnerID(ctx, ctx.Org.Organization.ID, ctx.FormInt64("id")); err != nil {
ctx.Flash.Error("DeleteWebhookByOwnerID: " + err.Error())
} else {
ctx.Flash.Success(ctx.Tr("repo.settings.webhook_deletion_success"))
}
ctx.JSONRedirect(ctx.Org.OrgLink + "/settings/hooks")
}
// Labels render organization labels page
func Labels(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("repo.labels")
ctx.Data["PageIsOrgSettings"] = true
ctx.Data["PageIsOrgSettingsLabels"] = true
ctx.Data["LabelTemplateFiles"] = repo_module.LabelTemplateFiles
if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil {
ctx.ServerError("RenderUserOrgHeader", err)
return
}
ctx.HTML(http.StatusOK, tplSettingsLabels)
}
// SettingsRenamePost response for renaming organization
func SettingsRenamePost(ctx *context.Context) {
form := web.GetForm(ctx).(*forms.RenameOrgForm)
if ctx.HasError() {
ctx.JSONError(ctx.GetErrMsg())
return
}
oldOrgName, newOrgName := ctx.Org.Organization.Name, form.NewOrgName
if form.OrgName != oldOrgName {
ctx.JSONError(ctx.Tr("form.enterred_invalid_org_name"))
return
}
if newOrgName == oldOrgName {
ctx.JSONError(ctx.Tr("org.settings.rename_no_change"))
return
}
if err := user_service.RenameUser(ctx, ctx.Org.Organization.AsUser(), newOrgName, ctx.Doer); err != nil {
if user_model.IsErrUserAlreadyExist(err) {
ctx.JSONError(ctx.Tr("org.form.name_been_taken", newOrgName))
} else if db.IsErrNameReserved(err) {
ctx.JSONError(ctx.Tr("org.form.name_reserved", newOrgName))
} else if db.IsErrNamePatternNotAllowed(err) {
ctx.JSONError(ctx.Tr("org.form.name_pattern_not_allowed", newOrgName))
} else {
log.Error("RenameOrganization: %v", err)
ctx.JSONError(util.Iif(ctx.Doer.IsAdmin, err.Error(), string(ctx.Tr("org.settings.rename_failed"))))
}
return
}
ctx.Flash.Success(ctx.Tr("org.settings.rename_success", oldOrgName, newOrgName))
ctx.JSONRedirect(setting.AppSubURL + "/org/" + url.PathEscape(newOrgName) + "/settings")
}
// SettingsChangeVisibilityPost response for change organization visibility
func SettingsChangeVisibilityPost(ctx *context.Context) {
visibility, ok := structs.VisibilityModes[ctx.FormString("visibility")]
if !ok {
ctx.Flash.Error(ctx.Tr("invalid_data", visibility))
ctx.JSONRedirect(setting.AppSubURL + "/org/" + url.PathEscape(ctx.Org.Organization.Name) + "/settings")
return
}
if ctx.Org.Organization.Visibility == visibility {
ctx.Flash.Info(ctx.Tr("nothing_has_been_changed"))
ctx.JSONRedirect(setting.AppSubURL + "/org/" + url.PathEscape(ctx.Org.Organization.Name) + "/settings")
return
}
if err := org_service.ChangeOrganizationVisibility(ctx, ctx.Org.Organization, visibility); err != nil {
log.Error("ChangeOrganizationVisibility: %v", err)
ctx.JSONError(ctx.Tr("error.occurred"))
return
}
ctx.Flash.Success(ctx.Tr("org.settings.change_visibility_success", ctx.Org.Organization.Name))
ctx.JSONRedirect(setting.AppSubURL + "/org/" + url.PathEscape(ctx.Org.Organization.Name) + "/settings")
}
+101
View File
@@ -0,0 +1,101 @@
// Copyright 2022 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package org
import (
"fmt"
"net/http"
"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 (
tplSettingsApplications templates.TplName = "org/settings/applications"
tplSettingsOAuthApplicationEdit templates.TplName = "org/settings/applications_oauth2_edit"
)
func newOAuth2CommonHandlers(org *context.Organization) *user_setting.OAuth2CommonHandlers {
return &user_setting.OAuth2CommonHandlers{
OwnerID: org.Organization.ID,
BasePathList: fmt.Sprintf("%s/org/%s/settings/applications", setting.AppSubURL, org.Organization.Name),
BasePathEditPrefix: fmt.Sprintf("%s/org/%s/settings/applications/oauth2", setting.AppSubURL, org.Organization.Name),
TplAppEdit: tplSettingsOAuthApplicationEdit,
}
}
// Applications render org applications page (for org, at the moment, there are only OAuth2 applications)
func Applications(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("settings.applications")
ctx.Data["PageIsOrgSettings"] = true
ctx.Data["PageIsSettingsApplications"] = true
apps, err := db.Find[auth.OAuth2Application](ctx, auth.FindOAuth2ApplicationsOptions{
OwnerID: ctx.Org.Organization.ID,
})
if err != nil {
ctx.ServerError("GetOAuth2ApplicationsByUserID", err)
return
}
ctx.Data["Applications"] = apps
if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil {
ctx.ServerError("RenderUserOrgHeader", err)
return
}
ctx.HTML(http.StatusOK, tplSettingsApplications)
}
// OAuthApplicationsPost response for adding an oauth2 application
func OAuthApplicationsPost(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("settings.applications")
ctx.Data["PageIsOrgSettings"] = true
ctx.Data["PageIsSettingsApplications"] = true
oa := newOAuth2CommonHandlers(ctx.Org)
oa.AddApp(ctx)
}
// OAuth2ApplicationShow displays the given application
func OAuth2ApplicationShow(ctx *context.Context) {
ctx.Data["PageIsOrgSettings"] = true
ctx.Data["PageIsSettingsApplications"] = true
oa := newOAuth2CommonHandlers(ctx.Org)
oa.EditShow(ctx)
}
// OAuth2ApplicationEdit response for editing oauth2 application
func OAuth2ApplicationEdit(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("settings.applications")
ctx.Data["PageIsOrgSettings"] = true
ctx.Data["PageIsSettingsApplications"] = true
oa := newOAuth2CommonHandlers(ctx.Org)
oa.EditSave(ctx)
}
// OAuthApplicationsRegenerateSecret handles the post request for regenerating the secret
func OAuthApplicationsRegenerateSecret(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("settings_title")
ctx.Data["PageIsOrgSettings"] = true
ctx.Data["PageIsSettingsApplications"] = true
oa := newOAuth2CommonHandlers(ctx.Org)
oa.RegenerateSecret(ctx)
}
// DeleteOAuth2Application deletes the given oauth2 application
func DeleteOAuth2Application(ctx *context.Context) {
oa := newOAuth2CommonHandlers(ctx.Org)
oa.DeleteApp(ctx)
}
// TODO: revokes the grant with the given id
+127
View File
@@ -0,0 +1,127 @@
// Copyright 2022 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package org
import (
"fmt"
"net/http"
"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 (
tplSettingsPackages templates.TplName = "org/settings/packages"
tplSettingsPackagesRuleEdit templates.TplName = "org/settings/packages_cleanup_rules_edit"
tplSettingsPackagesRulePreview templates.TplName = "org/settings/packages_cleanup_rules_preview"
)
func Packages(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("packages.title")
ctx.Data["PageIsOrgSettings"] = true
ctx.Data["PageIsSettingsPackages"] = true
if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil {
ctx.ServerError("RenderUserOrgHeader", err)
return
}
shared.SetPackagesContext(ctx, ctx.ContextUser)
ctx.HTML(http.StatusOK, tplSettingsPackages)
}
func PackagesRuleAdd(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("packages.title")
ctx.Data["PageIsOrgSettings"] = true
ctx.Data["PageIsSettingsPackages"] = true
if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil {
ctx.ServerError("RenderUserOrgHeader", err)
return
}
shared.SetRuleAddContext(ctx)
ctx.HTML(http.StatusOK, tplSettingsPackagesRuleEdit)
}
func PackagesRuleEdit(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("packages.title")
ctx.Data["PageIsOrgSettings"] = true
ctx.Data["PageIsSettingsPackages"] = true
if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil {
ctx.ServerError("RenderUserOrgHeader", err)
return
}
shared.SetRuleEditContext(ctx, ctx.ContextUser)
ctx.HTML(http.StatusOK, tplSettingsPackagesRuleEdit)
}
func PackagesRuleAddPost(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("packages.title")
ctx.Data["PageIsOrgSettings"] = true
ctx.Data["PageIsSettingsPackages"] = true
shared.PerformRuleAddPost(
ctx,
ctx.ContextUser,
fmt.Sprintf("%s/org/%s/settings/packages", setting.AppSubURL, ctx.ContextUser.Name),
tplSettingsPackagesRuleEdit,
)
}
func PackagesRuleEditPost(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("packages.title")
ctx.Data["PageIsOrgSettings"] = true
ctx.Data["PageIsSettingsPackages"] = true
shared.PerformRuleEditPost(
ctx,
ctx.ContextUser,
fmt.Sprintf("%s/org/%s/settings/packages", setting.AppSubURL, ctx.ContextUser.Name),
tplSettingsPackagesRuleEdit,
)
}
func PackagesRulePreview(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("packages.title")
ctx.Data["PageIsOrgSettings"] = true
ctx.Data["PageIsSettingsPackages"] = true
if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil {
ctx.ServerError("RenderUserOrgHeader", err)
return
}
shared.SetRulePreviewContext(ctx, ctx.ContextUser)
ctx.HTML(http.StatusOK, tplSettingsPackagesRulePreview)
}
func InitializeCargoIndex(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("packages.title")
ctx.Data["PageIsOrgSettings"] = true
ctx.Data["PageIsSettingsPackages"] = true
shared.InitializeCargoIndex(ctx, ctx.ContextUser)
ctx.Redirect(fmt.Sprintf("%s/org/%s/settings/packages", setting.AppSubURL, ctx.ContextUser.Name))
}
func RebuildCargoIndex(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("packages.title")
ctx.Data["PageIsOrgSettings"] = true
ctx.Data["PageIsSettingsPackages"] = true
shared.RebuildCargoIndex(ctx, ctx.ContextUser)
ctx.Redirect(fmt.Sprintf("%s/org/%s/settings/packages", setting.AppSubURL, ctx.ContextUser.Name))
}
+678
View File
@@ -0,0 +1,678 @@
// Copyright 2014 The Gogs Authors. All rights reserved.
// Copyright 2019 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package org
import (
"errors"
"fmt"
"net/http"
"net/url"
"path"
"strconv"
"strings"
"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/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 (
// tplTeams template path for teams list page
tplTeams templates.TplName = "org/team/teams"
// tplTeamNew template path for create new team page
tplTeamNew templates.TplName = "org/team/new"
// tplTeamMembers template path for showing team members page
tplTeamMembers templates.TplName = "org/team/members"
// tplTeamRepositories template path for showing team repositories page
tplTeamRepositories templates.TplName = "org/team/repositories"
// tplTeamInvite template path for team invites page
tplTeamInvite templates.TplName = "org/team/invite"
)
// Teams render teams list page
func Teams(ctx *context.Context) {
if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil {
ctx.ServerError("RenderUserOrgHeader", err)
return
}
org := ctx.Org.Organization
ctx.Data["Title"] = org.FullName
ctx.Data["PageIsOrgTeams"] = true
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,
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["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)
}
// TeamsAction response for join, leave, remove, add operations to team
func TeamsAction(ctx *context.Context) {
page := ctx.FormString("page")
var err error
switch ctx.PathParam("action") {
case "join":
if !ctx.Org.IsOwner {
ctx.HTTPError(http.StatusNotFound)
return
}
err = org_service.AddTeamMember(ctx, ctx.Org.Team, ctx.Doer)
case "leave":
err = org_service.RemoveTeamMember(ctx, ctx.Org.Team, ctx.Doer)
if err != nil {
if org_model.IsErrLastOrgOwner(err) {
ctx.Flash.Error(ctx.Tr("form.last_org_owner"))
} else {
log.Error("Action(%s): %v", ctx.PathParam("action"), err)
ctx.JSON(http.StatusOK, map[string]any{
"ok": false,
"err": err.Error(),
})
return
}
}
checkIsOrgMemberAndRedirect(ctx, ctx.Org.OrgLink+"/teams/")
return
case "remove":
if !ctx.Org.IsOwner {
ctx.HTTPError(http.StatusNotFound)
return
}
user, _ := user_model.GetUserByID(ctx, ctx.FormInt64("uid"))
if user == nil {
ctx.Redirect(ctx.Org.OrgLink + "/teams")
return
}
err = org_service.RemoveTeamMember(ctx, ctx.Org.Team, user)
if err != nil {
if org_model.IsErrLastOrgOwner(err) {
ctx.Flash.Error(ctx.Tr("form.last_org_owner"))
} else {
log.Error("Action(%s): %v", ctx.PathParam("action"), err)
ctx.JSON(http.StatusOK, map[string]any{
"ok": false,
"err": err.Error(),
})
return
}
}
checkIsOrgMemberAndRedirect(ctx, ctx.Org.OrgLink+"/teams/"+url.PathEscape(ctx.Org.Team.LowerName))
return
case "add":
if !ctx.Org.IsOwner {
ctx.HTTPError(http.StatusNotFound)
return
}
uname := strings.ToLower(ctx.FormString("uname"))
var u *user_model.User
u, err = user_model.GetUserByName(ctx, uname)
if err != nil {
if user_model.IsErrUserNotExist(err) {
if setting.MailService != nil && user_model.ValidateEmail(uname) == nil {
if err := org_service.CreateTeamInvite(ctx, ctx.Doer, ctx.Org.Team, uname); err != nil {
if org_model.IsErrTeamInviteAlreadyExist(err) {
ctx.Flash.Error(ctx.Tr("form.duplicate_invite_to_team"))
} else if org_model.IsErrUserEmailAlreadyAdded(err) {
ctx.Flash.Error(ctx.Tr("org.teams.add_duplicate_users"))
} else {
ctx.ServerError("CreateTeamInvite", err)
return
}
}
} else {
ctx.Flash.Error(ctx.Tr("form.user_not_exist"))
}
ctx.Redirect(ctx.Org.OrgLink + "/teams/" + url.PathEscape(ctx.Org.Team.LowerName))
} else {
ctx.ServerError("GetUserByName", err)
}
return
}
if u.IsOrganization() {
ctx.Flash.Error(ctx.Tr("form.cannot_add_org_to_team"))
ctx.Redirect(ctx.Org.OrgLink + "/teams/" + url.PathEscape(ctx.Org.Team.LowerName))
return
}
if ctx.Org.Team.IsMember(ctx, u.ID) {
ctx.Flash.Error(ctx.Tr("org.teams.add_duplicate_users"))
} else {
err = org_service.AddTeamMember(ctx, ctx.Org.Team, u)
}
page = "team"
case "remove_invite":
if !ctx.Org.IsOwner {
ctx.HTTPError(http.StatusNotFound)
return
}
iid := ctx.FormInt64("iid")
if iid == 0 {
ctx.Redirect(ctx.Org.OrgLink + "/teams/" + url.PathEscape(ctx.Org.Team.LowerName))
return
}
if err := org_model.RemoveInviteByID(ctx, iid, ctx.Org.Team.ID); err != nil {
log.Error("Action(%s): %v", ctx.PathParam("action"), err)
ctx.ServerError("RemoveInviteByID", err)
return
}
page = "team"
}
if err != nil {
if org_model.IsErrLastOrgOwner(err) {
ctx.Flash.Error(ctx.Tr("form.last_org_owner"))
} else if errors.Is(err, user_model.ErrBlockedUser) {
ctx.Flash.Error(ctx.Tr("org.teams.members.blocked_user"))
} else {
log.Error("Action(%s): %v", ctx.PathParam("action"), err)
ctx.JSON(http.StatusOK, map[string]any{
"ok": false,
"err": err.Error(),
})
return
}
}
switch page {
case "team":
ctx.Redirect(ctx.Org.OrgLink + "/teams/" + url.PathEscape(ctx.Org.Team.LowerName))
case "home":
ctx.Redirect(ctx.Org.Organization.AsUser().HomeLink())
default:
ctx.Redirect(ctx.Org.OrgLink + "/teams")
}
}
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 && !ctx.Doer.IsAdmin {
if ctx.Org.Organization.Visibility.IsPrivate() {
defaultRedirect = setting.AppSubURL + "/"
} else {
defaultRedirect = ctx.Org.Organization.HomeLink()
}
}
ctx.JSONRedirect(defaultRedirect)
}
// TeamsRepoAction operate team's repository
func TeamsRepoAction(ctx *context.Context) {
if !ctx.Org.IsOwner {
ctx.HTTPError(http.StatusNotFound)
return
}
var err error
action := ctx.PathParam("action")
switch action {
case "add":
repoName := path.Base(ctx.FormString("repo_name"))
var repo *repo_model.Repository
repo, err = repo_model.GetRepositoryByName(ctx, ctx.Org.Organization.ID, repoName)
if err != nil {
if repo_model.IsErrRepoNotExist(err) {
ctx.Flash.Error(ctx.Tr("org.teams.add_nonexistent_repo"))
ctx.Redirect(ctx.Org.OrgLink + "/teams/" + url.PathEscape(ctx.Org.Team.LowerName) + "/repositories")
return
}
ctx.ServerError("GetRepositoryByName", err)
return
}
err = repo_service.TeamAddRepository(ctx, ctx.Org.Team, repo)
case "remove":
err = repo_service.RemoveRepositoryFromTeam(ctx, ctx.Org.Team, ctx.FormInt64("repoid"))
case "addall":
err = repo_service.AddAllRepositoriesToTeam(ctx, ctx.Org.Team)
case "removeall":
err = repo_service.RemoveAllRepositoriesFromTeam(ctx, ctx.Org.Team)
}
if err != nil {
log.Error("Action(%s): '%s' %v", ctx.PathParam("action"), ctx.Org.Team.Name, err)
ctx.ServerError("TeamsRepoAction", err)
return
}
if action == "addall" || action == "removeall" {
ctx.JSONRedirect(ctx.Org.OrgLink + "/teams/" + url.PathEscape(ctx.Org.Team.LowerName) + "/repositories")
return
}
ctx.Redirect(ctx.Org.OrgLink + "/teams/" + url.PathEscape(ctx.Org.Team.LowerName) + "/repositories")
}
// NewTeam render create new team page
func NewTeam(ctx *context.Context) {
if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil {
ctx.ServerError("RenderUserOrgHeader", err)
return
}
ctx.Data["Title"] = ctx.Org.Organization.FullName
ctx.Data["PageIsOrgTeams"] = true
ctx.Data["PageIsOrgTeamsNew"] = true
ctx.Data["Team"] = &org_model.Team{}
ctx.Data["Units"] = unit_model.Units
ctx.HTML(http.StatusOK, tplTeamNew)
}
// FIXME: TEAM-UNIT-PERMISSION: this design is not right, when a new unit is added in the future,
// The existing teams won't inherit the correct admin permission for the new unit.
// The full history is like this:
// 1. There was only "team", no "team unit", so "team.authorize" was used to determine the team permission.
// 2. Later, "team unit" was introduced, then the usage of "team.authorize" became inconsistent, and causes various bugs.
// - Sometimes, "team.authorize" is used to determine the team permission, e.g. admin, owner
// - Sometimes, "team unit" is used not really used and "team unit" is used.
// - Some functions like `GetTeamsWithAccessToAnyRepoUnit` use both.
//
// 3. After introducing "team unit" and more unclear changes, it becomes difficult to maintain team permissions.
// - Org owner need to click the permission for each unit, but can't just set a common "write" permission for all units.
//
// Ideally, "team.authorize=write" should mean the team has write access to all units including newly (future) added ones.
func getUnitPerms(forms url.Values, teamPermission perm.AccessMode) map[unit_model.Type]perm.AccessMode {
unitPerms := make(map[unit_model.Type]perm.AccessMode)
for _, ut := range unit_model.AllRepoUnitTypes {
// Default access mode is none
unitPerms[ut] = perm.AccessModeNone
v, ok := forms[fmt.Sprintf("unit_%d", ut)]
if ok {
vv, _ := strconv.Atoi(v[0])
if teamPermission >= perm.AccessModeAdmin {
unitPerms[ut] = teamPermission
// Don't allow `TypeExternal{Tracker,Wiki}` to influence this as they can only be set to READ perms.
if ut == unit_model.TypeExternalTracker || ut == unit_model.TypeExternalWiki {
unitPerms[ut] = perm.AccessModeRead
}
} else {
unitPerms[ut] = perm.AccessMode(vv)
if unitPerms[ut] >= perm.AccessModeAdmin {
unitPerms[ut] = perm.AccessModeWrite
}
}
}
}
return unitPerms
}
// NewTeamPost response for create new team
func NewTeamPost(ctx *context.Context) {
form := web.GetForm(ctx).(*forms.CreateTeamForm)
includesAllRepositories := form.RepoAccess == "all"
teamPermission := perm.ParseAccessMode(form.Permission, perm.AccessModeNone, perm.AccessModeAdmin)
unitPerms := getUnitPerms(ctx.Req.Form, teamPermission)
t := &org_model.Team{
OrgID: ctx.Org.Organization.ID,
Name: form.TeamName,
Description: form.Description,
AccessMode: teamPermission,
IncludesAllRepositories: includesAllRepositories,
CanCreateOrgRepo: form.CanCreateOrgRepo,
}
units := make([]*org_model.TeamUnit, 0, len(unitPerms))
for tp, perm := range unitPerms {
units = append(units, &org_model.TeamUnit{
OrgID: ctx.Org.Organization.ID,
Type: tp,
AccessMode: perm,
})
}
t.Units = units
ctx.Data["Title"] = ctx.Org.Organization.FullName
ctx.Data["PageIsOrgTeams"] = true
ctx.Data["PageIsOrgTeamsNew"] = true
ctx.Data["Units"] = unit_model.Units
ctx.Data["Team"] = t
if ctx.HasError() {
ctx.HTML(http.StatusOK, tplTeamNew)
return
}
if t.AccessMode < perm.AccessModeAdmin && len(unitPerms) == 0 {
ctx.RenderWithErrDeprecated(ctx.Tr("form.team_no_units_error"), tplTeamNew, &form)
return
}
if err := org_service.NewTeam(ctx, t); err != nil {
ctx.Data["Err_TeamName"] = true
switch {
case org_model.IsErrTeamAlreadyExist(err):
ctx.RenderWithErrDeprecated(ctx.Tr("form.team_name_been_taken"), tplTeamNew, &form)
default:
ctx.ServerError("NewTeam", err)
}
return
}
log.Trace("Team created: %s/%s", ctx.Org.Organization.Name, t.Name)
ctx.Redirect(ctx.Org.OrgLink + "/teams/" + url.PathEscape(t.LowerName))
}
// TeamMembers render team members page
func TeamMembers(ctx *context.Context) {
if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil {
ctx.ServerError("RenderUserOrgHeader", err)
return
}
ctx.Data["Title"] = ctx.Org.Team.Name
ctx.Data["PageIsOrgTeams"] = true
ctx.Data["PageIsOrgTeamMembers"] = true
if err := ctx.Org.Team.LoadMembers(ctx); err != nil {
ctx.ServerError("GetMembers", err)
return
}
ctx.Data["Units"] = unit_model.Units
invites, err := org_model.GetInvitesByTeamID(ctx, ctx.Org.Team.ID)
if err != nil {
ctx.ServerError("GetInvitesByTeamID", err)
return
}
ctx.Data["Invites"] = invites
ctx.Data["IsEmailInviteEnabled"] = setting.MailService != nil
ctx.HTML(http.StatusOK, tplTeamMembers)
}
// TeamRepositories show the repositories of team
func TeamRepositories(ctx *context.Context) {
if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil {
ctx.ServerError("RenderUserOrgHeader", err)
return
}
ctx.Data["Title"] = ctx.Org.Team.Name
ctx.Data["PageIsOrgTeams"] = true
ctx.Data["PageIsOrgTeamRepos"] = true
repos, err := repo_model.GetTeamRepositories(ctx, &repo_model.SearchTeamRepoOptions{
TeamID: ctx.Org.Team.ID,
})
if err != nil {
ctx.ServerError("GetTeamRepositories", err)
return
}
ctx.Data["Units"] = unit_model.Units
ctx.Data["TeamRepos"] = repos
ctx.HTML(http.StatusOK, tplTeamRepositories)
}
// SearchTeam api for searching teams
func SearchTeam(ctx *context.Context) {
listOptions := db.ListOptions{
Page: ctx.FormInt("page"),
PageSize: convert.ToCorrectPageSize(ctx.FormInt("limit")),
}
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,
}
teams, maxResults, err := org_model.SearchTeam(ctx, opts)
if err != nil {
log.Error("SearchTeam failed: %v", err)
ctx.JSON(http.StatusInternalServerError, map[string]any{
"ok": false,
"error": "SearchTeam internal failure",
})
return
}
apiTeams, err := convert.ToTeams(ctx, teams, false)
if err != nil {
log.Error("convert ToTeams failed: %v", err)
ctx.JSON(http.StatusInternalServerError, map[string]any{
"ok": false,
"error": "SearchTeam failed to get units",
})
return
}
ctx.SetTotalCountHeader(maxResults)
ctx.JSON(http.StatusOK, map[string]any{
"ok": true,
"data": apiTeams,
})
}
// EditTeam render team edit page
func EditTeam(ctx *context.Context) {
if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil {
ctx.ServerError("RenderUserOrgHeader", err)
return
}
ctx.Data["Title"] = ctx.Org.Organization.FullName
ctx.Data["PageIsOrgTeams"] = true
if err := ctx.Org.Team.LoadUnits(ctx); err != nil {
ctx.ServerError("LoadUnits", err)
return
}
ctx.Data["Team"] = ctx.Org.Team
ctx.Data["Units"] = unit_model.Units
ctx.HTML(http.StatusOK, tplTeamNew)
}
// EditTeamPost response for modify team information
func EditTeamPost(ctx *context.Context) {
form := web.GetForm(ctx).(*forms.CreateTeamForm)
t := ctx.Org.Team
teamPermission := perm.ParseAccessMode(form.Permission, perm.AccessModeNone, perm.AccessModeAdmin)
unitPerms := getUnitPerms(ctx.Req.Form, teamPermission)
isAuthChanged := false
isIncludeAllChanged := false
includesAllRepositories := form.RepoAccess == "all"
ctx.Data["Title"] = ctx.Org.Organization.FullName
ctx.Data["PageIsOrgTeams"] = true
ctx.Data["Team"] = t
ctx.Data["Units"] = unit_model.Units
if !t.IsOwnerTeam() {
t.Name = form.TeamName
if t.AccessMode != teamPermission {
isAuthChanged = true
t.AccessMode = teamPermission
}
if t.IncludesAllRepositories != includesAllRepositories {
isIncludeAllChanged = true
t.IncludesAllRepositories = includesAllRepositories
}
t.CanCreateOrgRepo = form.CanCreateOrgRepo
} else {
t.CanCreateOrgRepo = true
}
t.Description = form.Description
units := make([]*org_model.TeamUnit, 0, len(unitPerms))
for tp, perm := range unitPerms {
units = append(units, &org_model.TeamUnit{
OrgID: t.OrgID,
TeamID: t.ID,
Type: tp,
AccessMode: perm,
})
}
t.Units = units
if ctx.HasError() {
ctx.HTML(http.StatusOK, tplTeamNew)
return
}
if t.AccessMode < perm.AccessModeAdmin && len(unitPerms) == 0 {
ctx.RenderWithErrDeprecated(ctx.Tr("form.team_no_units_error"), tplTeamNew, &form)
return
}
if err := org_service.UpdateTeam(ctx, t, isAuthChanged, isIncludeAllChanged); err != nil {
ctx.Data["Err_TeamName"] = true
switch {
case org_model.IsErrTeamAlreadyExist(err):
ctx.RenderWithErrDeprecated(ctx.Tr("form.team_name_been_taken"), tplTeamNew, &form)
default:
ctx.ServerError("UpdateTeam", err)
}
return
}
ctx.Redirect(ctx.Org.OrgLink + "/teams/" + url.PathEscape(t.LowerName))
}
// DeleteTeam response for the delete team request
func DeleteTeam(ctx *context.Context) {
if err := org_service.DeleteTeam(ctx, ctx.Org.Team); err != nil {
ctx.Flash.Error("DeleteTeam: " + err.Error())
} else {
ctx.Flash.Success(ctx.Tr("org.teams.delete_team_success"))
}
ctx.JSONRedirect(ctx.Org.OrgLink + "/teams")
}
// 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)
} else {
ctx.ServerError("getTeamInviteFromContext", err)
}
return
}
ctx.Data["Title"] = ctx.Tr("org.teams.invite_team_member", team.Name)
ctx.Data["Invite"] = invite
ctx.Data["Organization"] = org
ctx.Data["Team"] = team
ctx.Data["Inviter"] = inviter
ctx.HTML(http.StatusOK, tplTeamInvite)
}
// TeamInvitePost handles the team invitation
func TeamInvitePost(ctx *context.Context) {
invite, org, team, _, err := getTeamInviteFromContext(ctx)
if err != nil {
if org_model.IsErrTeamInviteNotFound(err) {
ctx.NotFound(err)
} else {
ctx.ServerError("getTeamInviteFromContext", err)
}
return
}
if err := org_service.AddTeamMember(ctx, team, ctx.Doer); err != nil {
ctx.ServerError("AddTeamMember", err)
return
}
if err := org_model.RemoveInviteByID(ctx, invite.ID, team.ID); err != nil {
log.Error("RemoveInviteByID: %v", err)
}
ctx.Redirect(org.OrganisationLink() + "/teams/" + url.PathEscape(team.LowerName))
}
func getTeamInviteFromContext(ctx *context.Context) (*org_model.TeamInvite, *org_model.Organization, *org_model.Team, *user_model.User, error) {
invite, err := org_model.GetInviteByToken(ctx, ctx.PathParam("token"))
if err != nil {
return nil, nil, nil, nil, err
}
inviter, err := user_model.GetUserByID(ctx, invite.InviterID)
if err != nil {
return nil, nil, nil, nil, err
}
team, err := org_model.GetTeamByID(ctx, invite.TeamID)
if err != nil {
return nil, nil, nil, nil, err
}
org, err := user_model.GetUserByID(ctx, team.OrgID)
if err != nil {
return nil, nil, nil, nil, err
}
return invite, org_model.OrgFromUser(org), team, inviter, nil
}
+82
View File
@@ -0,0 +1,82 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package org
import (
"net/http"
"time"
"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"
// parseOrgTimes contains functionality that is required in all these functions,
// like parsing the date from the request, setting default dates, etc.
func parseOrgTimes(ctx *context.Context) (unixFrom, unixTo int64) {
rangeFrom := ctx.FormString("from")
rangeTo := ctx.FormString("to")
if rangeFrom == "" {
rangeFrom = time.Now().Format("2006-01") + "-01" // defaults to start of current month
}
if rangeTo == "" {
rangeTo = time.Now().Format("2006-01-02") // defaults to today
}
ctx.Data["RangeFrom"] = rangeFrom
ctx.Data["RangeTo"] = rangeTo
timeFrom, err := time.Parse("2006-01-02", rangeFrom)
if err != nil {
ctx.ServerError("time.Parse", err)
}
timeTo, err := time.Parse("2006-01-02", rangeTo)
if err != nil {
ctx.ServerError("time.Parse", err)
}
unixFrom = timeFrom.Unix()
unixTo = timeTo.Add(1440*time.Minute - 1*time.Second).Unix() // humans expect that we include the ending day too
return unixFrom, unixTo
}
func Worktime(ctx *context.Context) {
ctx.Data["PageIsOrgTimes"] = true
unixFrom, unixTo := parseOrgTimes(ctx)
if ctx.Written() {
return
}
worktimeBy := ctx.FormString("by")
ctx.Data["WorktimeBy"] = worktimeBy
var worktimeSumResult any
var err error
switch worktimeBy {
case "milestones":
worktimeSumResult, err = organization.GetWorktimeByMilestones(ctx, ctx.Org.Organization, unixFrom, unixTo)
ctx.Data["WorktimeByMilestones"] = true
case "members":
worktimeSumResult, err = organization.GetWorktimeByMembers(ctx, ctx.Org.Organization, unixFrom, unixTo)
ctx.Data["WorktimeByMembers"] = true
default: /* by repos */
worktimeSumResult, err = organization.GetWorktimeByRepos(ctx, ctx.Org.Organization, unixFrom, unixTo)
ctx.Data["WorktimeByRepos"] = true
}
if err != nil {
ctx.ServerError("GetWorktime", err)
return
}
if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil {
ctx.ServerError("RenderUserOrgHeader", err)
return
}
ctx.Data["WorktimeSumResult"] = worktimeSumResult
ctx.HTML(http.StatusOK, tplByRepos)
}
+24
View File
@@ -0,0 +1,24 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package web
import (
"net/http"
"gitea.dev/modules/setting"
"gitea.dev/services/context"
)
type passkeyEndpointsType struct {
Enroll string `json:"enroll"`
Manage string `json:"manage"`
}
func passkeyEndpoints(ctx *context.Context) {
url := setting.AppURL + "user/settings/security"
ctx.JSON(http.StatusOK, passkeyEndpointsType{
Enroll: url,
Manage: url,
})
}
+535
View File
@@ -0,0 +1,535 @@
// Copyright 2022 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package actions
import (
"bytes"
stdCtx "context"
"errors"
"fmt"
"net/http"
"net/url"
"slices"
"strings"
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/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"
"gitea.dev/services/context"
"gitea.dev/services/convert"
act_model "gitea.com/gitea/runner/act/model"
"go.yaml.in/yaml/v4"
)
const (
tplListActions templates.TplName = "repo/actions/list"
tplDispatchInputsActions templates.TplName = "repo/actions/workflow_dispatch_inputs"
tplViewActions templates.TplName = "repo/actions/view"
)
type WorkflowInfo struct {
Entry git.TreeEntry
ErrMsg string
Workflow *act_model.Workflow
}
// 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 {
ctx.NotFound(nil)
return
}
if unit.TypeActions.UnitGlobalDisabled() {
ctx.NotFound(nil)
return
}
if ctx.Repo.Repository != nil {
if !ctx.Repo.Permission.CanRead(unit.TypeActions) {
ctx.NotFound(nil)
return
}
}
}
func List(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("actions.actions")
ctx.Data["PageIsActions"] = true
commit, err := ctx.Repo.GitRepo.GetBranchCommit(ctx.Repo.Repository.DefaultBranch)
if errors.Is(err, util.ErrNotExist) {
ctx.Data["NotFoundPrompt"] = ctx.Tr("repo.branch.default_branch_not_exist", ctx.Repo.Repository.DefaultBranch)
ctx.NotFound(nil)
return
} else if err != nil {
ctx.ServerError("GetBranchCommit", err)
return
}
workflows, curWorkflowID := prepareWorkflowTemplate(ctx, commit)
if ctx.Written() {
return
}
otherWorkflows := prepareOtherWorkflows(ctx, workflows, curWorkflowID)
if ctx.Written() {
return
}
prepareWorkflowDispatchTemplate(ctx, workflows, curWorkflowID)
if ctx.Written() {
return
}
prepareWorkflowList(ctx, workflows, otherWorkflows)
if ctx.Written() {
return
}
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, 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 {
ids, err := actions_model.GetRunWorkflowIDs(ctx, ctx.Repo.Repository.ID)
if err != nil {
ctx.ServerError("GetRunWorkflowIDs", err)
return nil
}
other = container.FilterSlice(ids, func(id string) (string, bool) {
return id, id != "" && !listed.Contains(id)
})
}
ctx.Data["OtherWorkflows"] = other
ctx.Data["CurWorkflowIsListed"] = curWorkflowID == "" || listed.Contains(curWorkflowID)
return other
}
func WorkflowDispatchInputs(ctx *context.Context) {
ref := ctx.FormString("ref")
if ref == "" {
ctx.NotFound(nil)
return
}
// get target commit of run from specified ref
refName := git.RefName(ref)
var commit *git.Commit
var err error
if refName.IsTag() {
commit, err = ctx.Repo.GitRepo.GetTagCommit(refName.TagName())
} else if refName.IsBranch() {
commit, err = ctx.Repo.GitRepo.GetBranchCommit(refName.BranchName())
} else {
ctx.ServerError("UnsupportedRefType", nil)
return
}
if err != nil {
ctx.ServerError("GetTagCommit/GetBranchCommit", err)
return
}
workflows, curWorkflowID := prepareWorkflowTemplate(ctx, commit)
if ctx.Written() {
return
}
prepareWorkflowDispatchTemplate(ctx, workflows, curWorkflowID)
if ctx.Written() {
return
}
ctx.HTML(http.StatusOK, tplDispatchInputsActions)
}
func prepareWorkflowTemplate(ctx *context.Context, commit *git.Commit) (workflows []WorkflowInfo, curWorkflowID string) {
curWorkflowID = ctx.FormString("workflow")
_, entries, err := actions.ListWorkflows(commit)
if err != nil {
ctx.ServerError("ListWorkflows", err)
return nil, ""
}
workflows = make([]WorkflowInfo, 0, len(entries))
for _, entry := range entries {
workflow := WorkflowInfo{Entry: *entry}
content, err := actions.GetContentFromEntry(entry)
if err != nil {
ctx.ServerError("GetContentFromEntry", err)
return nil, ""
}
wf, err := act_model.ReadWorkflow(bytes.NewReader(content))
if err != nil {
workflow.ErrMsg = ctx.Locale.TrString("actions.runs.invalid_workflow_helper", err.Error())
workflows = append(workflows, workflow)
continue
}
if err := actions.ValidateWorkflowContent(content); err != nil {
workflow.ErrMsg = ctx.Locale.TrString("actions.runs.invalid_workflow_helper", err.Error())
workflows = append(workflows, workflow)
continue
}
workflow.Workflow = wf
// The workflow must contain at least one job without "needs". Otherwise, a deadlock will occur and no jobs will be able to run.
hasJobWithoutNeeds := false
// Check whether you have matching runner and a job without "needs"
emptyJobsNumber := 0
for _, j := range wf.Jobs {
if j == nil {
emptyJobsNumber++
continue
}
if !hasJobWithoutNeeds && len(j.Needs()) == 0 {
hasJobWithoutNeeds = true
}
}
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.Permission.IsAdmin()
actionsConfig := ctx.Repo.Repository.MustGetUnit(ctx, unit.TypeActions).ActionsConfig()
ctx.Data["ActionsConfig"] = actionsConfig
ctx.Data["CurWorkflow"] = curWorkflowID
ctx.Data["CurWorkflowDisabled"] = actionsConfig.IsWorkflowDisabled(curWorkflowID)
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.Permission.CanWrite(unit.TypeActions) || 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
}
curWorkflow = workflowInfo.Workflow
break
}
}
if curWorkflow == nil {
return
}
ctx.Data["CurWorkflowExists"] = true
curWfDispatchCfg := workflowDispatchConfig(curWorkflow)
if curWfDispatchCfg == nil {
return
}
ctx.Data["WorkflowDispatchConfig"] = curWfDispatchCfg
branchOpts := git_model.FindBranchOptions{
RepoID: ctx.Repo.Repository.ID,
IsDeletedBranch: optional.Some(false),
ListOptions: db.ListOptions{
ListAll: true,
},
}
branches, err := git_model.FindBranchNames(ctx, branchOpts)
if err != nil {
ctx.ServerError("FindBranchNames", err)
return
}
// always put default branch on the top
branches = util.SliceRemoveAll(branches, ctx.Repo.Repository.DefaultBranch)
branches = append([]string{ctx.Repo.Repository.DefaultBranch}, branches...)
ctx.Data["Branches"] = branches
tags, err := repo_model.GetTagNamesByRepoID(ctx, ctx.Repo.Repository.ID)
if err != nil {
ctx.ServerError("GetTagNamesByRepoID", err)
return
}
ctx.Data["Tags"] = tags
}
func prepareWorkflowList(ctx *context.Context, workflows []WorkflowInfo, otherWorkflows []string) {
actorID := ctx.FormInt64("actor")
status := ctx.FormInt("status")
workflowID := ctx.FormString("workflow")
branch := ctx.FormString("branch")
page := ctx.FormInt("page")
if page <= 0 {
page = 1
}
// if status or actor query param is not given to frontend href, (href="/<repoLink>/actions")
// they will be 0 by default, which indicates get all status or actors
ctx.Data["CurActor"] = actorID
ctx.Data["CurStatus"] = status
ctx.Data["CurBranch"] = branch
if actorID > 0 || status > int(actions_model.StatusUnknown) || branch != "" {
ctx.Data["IsFiltered"] = true
}
opts := actions_model.FindRunOptions{
ListOptions: db.ListOptions{
Page: page,
PageSize: convert.ToCorrectPageSize(ctx.FormInt("limit")),
},
RepoID: ctx.Repo.Repository.ID,
WorkflowID: workflowID,
TriggerUserID: actorID,
}
// 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 {
ctx.ServerError("FindAndCount", err)
return
}
for _, run := range runs {
run.Repo = ctx.Repo.Repository
}
if err := actions_model.RunList(runs).LoadTriggerUser(ctx); err != nil {
ctx.ServerError("LoadTriggerUser", err)
return
}
if err := loadIsRefDeleted(ctx, ctx.Repo.Repository.ID, runs); err != nil {
log.Error("LoadIsRefDeleted", err)
}
// Check for each run if there is at least one online runner that can run its jobs
runErrors := make(map[int64]string)
runners, err := db.Find[actions_model.ActionRunner](ctx, actions_model.FindRunnerOptions{
RepoID: ctx.Repo.Repository.ID,
IsOnline: optional.Some(true),
WithAvailable: true,
})
if err != nil {
ctx.ServerError("FindRunners", err)
return
}
for _, run := range runs {
if !run.Status.In(actions_model.StatusWaiting, actions_model.StatusRunning) {
continue
}
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() {
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
break
}
}
if !hasOnlineRunner {
runErrors[run.ID] = ctx.Locale.TrString("actions.runs.no_matching_online_runner_helper", strings.Join(job.RunsOn, ","))
break
}
}
}
ctx.Data["RunErrors"] = runErrors
ctx.Data["Runs"] = runs
workflowNames := make(map[string]string, len(workflows))
for _, wf := range workflows {
workflowNames[wf.Entry.Name()] = wf.DisplayName()
}
ctx.Data["WorkflowNames"] = workflowNames
actors, err := actions_model.GetActors(ctx, ctx.Repo.Repository.ID)
if err != nil {
ctx.ServerError("GetActors", err)
return
}
ctx.Data["Actors"] = shared_user.MakeSelfOnTop(ctx.Doer, actors)
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(otherWorkflows) > 0 || len(runs) > 0
ctx.Data["CanWriteRepoUnitActions"] = ctx.Repo.Permission.CanWrite(unit.TypeActions)
}
// loadIsRefDeleted loads the IsRefDeleted field for each run in the list.
// TODO: move this function to models/actions/run_list.go but now it will result in a circular import.
func loadIsRefDeleted(ctx stdCtx.Context, repoID int64, runs actions_model.RunList) error {
branches := make(container.Set[string], len(runs))
for _, run := range runs {
refName := git.RefName(run.Ref)
if refName.IsBranch() {
branches.Add(refName.ShortName())
}
}
if len(branches) == 0 {
return nil
}
branchInfos, err := git_model.GetBranches(ctx, repoID, branches.Values(), false)
if err != nil {
return err
}
branchSet := git_model.BranchesToNamesSet(branchInfos)
for _, run := range runs {
refName := git.RefName(run.Ref)
if refName.IsBranch() && !branchSet.Contains(refName.ShortName()) {
run.IsRefDeleted = true
}
}
return nil
}
type WorkflowDispatchInput struct {
Name string `yaml:"name"`
Description string `yaml:"description"`
Required bool `yaml:"required"`
Default string `yaml:"default"`
Type string `yaml:"type"`
Options []string `yaml:"options"`
}
type WorkflowDispatch struct {
Inputs []WorkflowDispatchInput
}
func workflowDispatchConfig(w *act_model.Workflow) *WorkflowDispatch {
switch w.RawOn.Kind {
case yaml.ScalarNode:
var val string
if !decodeNode(w.RawOn, &val) {
return nil
}
if val == "workflow_dispatch" {
return &WorkflowDispatch{}
}
case yaml.SequenceNode:
var val []string
if !decodeNode(w.RawOn, &val) {
return nil
}
if slices.Contains(val, "workflow_dispatch") {
return &WorkflowDispatch{}
}
case yaml.MappingNode:
var val map[string]yaml.Node
if !decodeNode(w.RawOn, &val) {
return nil
}
workflowDispatchNode, found := val["workflow_dispatch"]
if !found {
return nil
}
var workflowDispatch WorkflowDispatch
var workflowDispatchVal map[string]yaml.Node
if !decodeNode(workflowDispatchNode, &workflowDispatchVal) {
return &workflowDispatch
}
inputsNode, found := workflowDispatchVal["inputs"]
if !found || inputsNode.Kind != yaml.MappingNode {
return &workflowDispatch
}
i := 0
for {
if i+1 >= len(inputsNode.Content) {
break
}
var input WorkflowDispatchInput
if decodeNode(*inputsNode.Content[i+1], &input) {
input.Name = inputsNode.Content[i].Value
workflowDispatch.Inputs = append(workflowDispatch.Inputs, input)
}
i += 2
}
return &workflowDispatch
default:
return nil
}
return nil
}
func decodeNode(node yaml.Node, out any) bool {
if err := node.Decode(out); err != nil {
log.Warn("Failed to decode node %v into %T: %v", node, out, err)
return false
}
return true
}
func actionsListRedirectURL(repoLink, workflow, actor, status, branch string) string {
return fmt.Sprintf("%s/actions?workflow=%s&actor=%s&status=%s&branch=%s",
repoLink,
url.QueryEscape(workflow),
url.QueryEscape(actor),
url.QueryEscape(status),
url.QueryEscape(branch),
)
}
+178
View File
@@ -0,0 +1,178 @@
// Copyright 2022 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package actions
import (
"strings"
"testing"
actions_model "gitea.dev/models/actions"
"gitea.dev/models/db"
unittest "gitea.dev/models/unittest"
act_model "gitea.com/gitea/runner/act/model"
"github.com/stretchr/testify/assert"
)
func TestReadWorkflow_WorkflowDispatchConfig(t *testing.T) {
yaml := `
name: local-action-docker-url
`
workflow, err := act_model.ReadWorkflow(strings.NewReader(yaml))
assert.NoError(t, err, "read workflow should succeed")
workflowDispatch := workflowDispatchConfig(workflow)
assert.Nil(t, workflowDispatch)
yaml = `
name: local-action-docker-url
on: push
`
workflow, err = act_model.ReadWorkflow(strings.NewReader(yaml))
assert.NoError(t, err, "read workflow should succeed")
workflowDispatch = workflowDispatchConfig(workflow)
assert.Nil(t, workflowDispatch)
yaml = `
name: local-action-docker-url
on: workflow_dispatch
`
workflow, err = act_model.ReadWorkflow(strings.NewReader(yaml))
assert.NoError(t, err, "read workflow should succeed")
workflowDispatch = workflowDispatchConfig(workflow)
assert.NotNil(t, workflowDispatch)
assert.Nil(t, workflowDispatch.Inputs)
yaml = `
name: local-action-docker-url
on: [push, pull_request]
`
workflow, err = act_model.ReadWorkflow(strings.NewReader(yaml))
assert.NoError(t, err, "read workflow should succeed")
workflowDispatch = workflowDispatchConfig(workflow)
assert.Nil(t, workflowDispatch)
yaml = `
name: local-action-docker-url
on:
push:
pull_request:
`
workflow, err = act_model.ReadWorkflow(strings.NewReader(yaml))
assert.NoError(t, err, "read workflow should succeed")
workflowDispatch = workflowDispatchConfig(workflow)
assert.Nil(t, workflowDispatch)
yaml = `
name: local-action-docker-url
on: [push, workflow_dispatch]
`
workflow, err = act_model.ReadWorkflow(strings.NewReader(yaml))
assert.NoError(t, err, "read workflow should succeed")
workflowDispatch = workflowDispatchConfig(workflow)
assert.NotNil(t, workflowDispatch)
assert.Nil(t, workflowDispatch.Inputs)
yaml = `
name: local-action-docker-url
on:
- push
- workflow_dispatch
`
workflow, err = act_model.ReadWorkflow(strings.NewReader(yaml))
assert.NoError(t, err, "read workflow should succeed")
workflowDispatch = workflowDispatchConfig(workflow)
assert.NotNil(t, workflowDispatch)
assert.Nil(t, workflowDispatch.Inputs)
yaml = `
name: local-action-docker-url
on:
push:
pull_request:
workflow_dispatch:
inputs:
`
workflow, err = act_model.ReadWorkflow(strings.NewReader(yaml))
assert.NoError(t, err, "read workflow should succeed")
workflowDispatch = workflowDispatchConfig(workflow)
assert.NotNil(t, workflowDispatch)
assert.Nil(t, workflowDispatch.Inputs)
yaml = `
name: local-action-docker-url
on:
push:
pull_request:
workflow_dispatch:
inputs:
logLevel:
description: 'Log level'
required: true
default: 'warning'
type: choice
options:
- info
- warning
- debug
boolean_default_true:
description: 'Test scenario tags'
required: true
type: boolean
default: true
boolean_default_false:
description: 'Test scenario tags'
required: true
type: boolean
default: false
`
workflow, err = act_model.ReadWorkflow(strings.NewReader(yaml))
assert.NoError(t, err, "read workflow should succeed")
workflowDispatch = workflowDispatchConfig(workflow)
assert.NotNil(t, workflowDispatch)
assert.Equal(t, WorkflowDispatchInput{
Name: "logLevel",
Default: "warning",
Description: "Log level",
Options: []string{
"info",
"warning",
"debug",
},
Required: true,
Type: "choice",
}, workflowDispatch.Inputs[0])
assert.Equal(t, WorkflowDispatchInput{
Name: "boolean_default_true",
Default: "true",
Description: "Test scenario tags",
Required: true,
Type: "boolean",
}, workflowDispatch.Inputs[1])
assert.Equal(t, WorkflowDispatchInput{
Name: "boolean_default_false",
Default: "false",
Description: "Test scenario tags",
Required: true,
Type: "boolean",
}, workflowDispatch.Inputs[2])
}
func Test_loadIsRefDeleted(t *testing.T) {
unittest.PrepareTestEnv(t)
runs, total, err := db.FindAndCount[actions_model.ActionRun](t.Context(),
actions_model.FindRunOptions{RepoID: 4, Ref: "refs/heads/test"})
assert.NoError(t, err)
assert.Len(t, runs, 1)
assert.EqualValues(t, 1, total)
for _, run := range runs {
assert.False(t, run.IsRefDeleted)
}
assert.NoError(t, loadIsRefDeleted(t.Context(), 4, runs))
for _, run := range runs {
assert.True(t, run.IsRefDeleted)
}
}
+59
View File
@@ -0,0 +1,59 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package actions
import (
"errors"
"net/http"
"path/filepath"
"strings"
actions_model "gitea.dev/models/actions"
"gitea.dev/modules/badge"
"gitea.dev/modules/git"
"gitea.dev/modules/util"
"gitea.dev/services/context"
)
func GetWorkflowBadge(ctx *context.Context) {
workflowFile := ctx.PathParam("workflow_name")
branch := ctx.FormString("branch", ctx.Repo.Repository.DefaultBranch)
event := ctx.FormString("event")
style := ctx.FormString("style")
branchRef := git.RefNameFromBranch(branch)
b, err := getWorkflowBadge(ctx, workflowFile, branchRef.String(), event)
if err != nil {
ctx.ServerError("GetWorkflowBadge", err)
return
}
ctx.Data["Badge"] = b
ctx.RespHeader().Set("Content-Type", "image/svg+xml")
switch style {
case badge.StyleFlatSquare:
ctx.HTML(http.StatusOK, "shared/actions/runner_badge_flat-square")
default: // defaults to badge.StyleFlat
ctx.HTML(http.StatusOK, "shared/actions/runner_badge_flat")
}
}
func getWorkflowBadge(ctx *context.Context, workflowFile, branchName, event string) (badge.Badge, error) {
extension := filepath.Ext(workflowFile)
workflowName := strings.TrimSuffix(workflowFile, extension)
run, err := actions_model.GetWorkflowLatestRun(ctx, ctx.Repo.Repository.ID, workflowFile, branchName, event)
if err != nil {
if errors.Is(err, util.ErrNotExist) {
return badge.GenerateBadge(workflowName, "no status", badge.DefaultColor), nil
}
return badge.Badge{}, err
}
color, ok := badge.GlobalVars().StatusColorMap[run.Status]
if !ok {
return badge.GenerateBadge(workflowName, "unknown status", badge.DefaultColor), nil
}
return badge.GenerateBadge(workflowName, run.Status.String(), color), nil
}
+14
View File
@@ -0,0 +1,14 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package actions
import (
"testing"
"gitea.dev/models/unittest"
)
func TestMain(m *testing.M) {
unittest.MainTest(m)
}
File diff suppressed because it is too large Load Diff
+78
View File
@@ -0,0 +1,78 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package actions
import (
"testing"
actions_model "gitea.dev/models/actions"
"gitea.dev/modules/timeutil"
"gitea.dev/modules/translation"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestConvertToViewModel(t *testing.T) {
task := &actions_model.ActionTask{
Status: actions_model.StatusSuccess,
Steps: []*actions_model.ActionTaskStep{
{Name: "Run step-name", Index: 0, Status: actions_model.StatusSuccess, LogLength: 1, Started: timeutil.TimeStamp(1), Stopped: timeutil.TimeStamp(5)},
},
Stopped: timeutil.TimeStamp(20),
}
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: "4s",
Status: "success",
},
{
Summary: "Complete job",
Duration: "15s",
Status: "success",
},
}
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)
}
+114
View File
@@ -0,0 +1,114 @@
// Copyright 2017 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package repo
import (
"net/http"
"time"
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 (
tplActivity templates.TplName = "repo/activity"
)
// Activity render the page to show repository latest changes
func Activity(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("repo.activity")
ctx.Data["PageIsActivity"] = true
ctx.Data["PageIsPulse"] = true
timeUntil := time.Now()
period, timeFrom := "weekly", timeUntil.Add(-time.Hour*168)
switch ctx.PathParam("period") {
case "daily":
period, timeFrom = "daily", timeUntil.Add(-time.Hour*24)
case "halfweekly":
period, timeFrom = "halfweekly", timeUntil.Add(-time.Hour*72)
case "weekly":
period, timeFrom = "weekly", timeUntil.Add(-time.Hour*168)
case "monthly":
period, timeFrom = "monthly", timeUntil.AddDate(0, -1, 0)
case "quarterly":
period, timeFrom = "quarterly", timeUntil.AddDate(0, -3, 0)
case "semiyearly":
period, timeFrom = "semiyearly", timeUntil.AddDate(0, -6, 0)
case "yearly":
period, timeFrom = "yearly", timeUntil.AddDate(-1, 0, 0)
}
ctx.Data["DateFrom"] = timeFrom
ctx.Data["DateUntil"] = timeUntil
ctx.Data["Period"] = period
ctx.Data["PeriodText"] = ctx.Tr("repo.activity.period." + period)
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)
if !branchExist {
ctx.Data["NotFoundPrompt"] = ctx.Tr("repo.branch.default_branch_not_exist", ctx.Repo.Repository.DefaultBranch)
ctx.NotFound(nil)
return
}
}
var err error
// TODO: refactor these arguments to a struct
ctx.Data["Activity"], err = activities_model.GetActivityStats(ctx, ctx.Repo.Repository, timeFrom,
ctx.Repo.Permission.CanRead(unit.TypeReleases),
ctx.Repo.Permission.CanRead(unit.TypeIssues),
ctx.Repo.Permission.CanRead(unit.TypePullRequests),
canReadCode,
)
if err != nil {
ctx.ServerError("GetActivityStats", err)
return
}
if ctx.PageData["repoActivityTopAuthors"], err = activities_model.GetActivityStatsTopAuthors(ctx, ctx.Repo.Repository, timeFrom, 10); err != nil {
ctx.ServerError("GetActivityStatsTopAuthors", err)
return
}
ctx.HTML(http.StatusOK, tplActivity)
}
// ActivityAuthors renders JSON with top commit authors for given time period over all branches
func ActivityAuthors(ctx *context.Context) {
timeUntil := time.Now()
var timeFrom time.Time
switch ctx.PathParam("period") {
case "daily":
timeFrom = timeUntil.Add(-time.Hour * 24)
case "halfweekly":
timeFrom = timeUntil.Add(-time.Hour * 72)
case "weekly":
timeFrom = timeUntil.Add(-time.Hour * 168)
case "monthly":
timeFrom = timeUntil.AddDate(0, -1, 0)
case "quarterly":
timeFrom = timeUntil.AddDate(0, -3, 0)
case "semiyearly":
timeFrom = timeUntil.AddDate(0, -6, 0)
case "yearly":
timeFrom = timeUntil.AddDate(-1, 0, 0)
default:
timeFrom = timeUntil.Add(-time.Hour * 168)
}
authors, err := activities_model.GetActivityStatsTopAuthors(ctx, ctx.Repo.Repository, timeFrom, 10)
if err != nil {
ctx.ServerError("GetActivityStatsTopAuthors", err)
return
}
ctx.JSON(http.StatusOK, authors)
}
+230
View File
@@ -0,0 +1,230 @@
// Copyright 2017 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package repo
import (
"net/http"
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) {
switch unitType {
case unit.TypeIssues, unit.TypePullRequests:
return auth_model.AccessTokenScopeReadIssue, true
case unit.TypeReleases:
return auth_model.AccessTokenScopeReadRepository, true
default:
return "", false
}
}
// UploadIssueAttachment response for Issue/PR attachments
func UploadIssueAttachment(ctx *context.Context) {
uploadAttachment(ctx, ctx.Repo.Repository.ID, attachment.UploadAttachmentForIssue)
}
// UploadReleaseAttachment response for uploading release attachments
func UploadReleaseAttachment(ctx *context.Context) {
uploadAttachment(ctx, ctx.Repo.Repository.ID, attachment.UploadAttachmentForRelease)
}
// UploadAttachment response for uploading attachments
func uploadAttachment(ctx *context.Context, repoID int64, uploadFunc attachment.UploadAttachmentFunc) {
if !setting.Attachment.Enabled {
ctx.HTTPError(http.StatusNotFound, "attachment is not enabled")
return
}
file, header, err := ctx.Req.FormFile("file")
if err != nil {
ctx.ServerError("FormFile", err)
return
}
defer file.Close()
uploaderFile := attachment.NewLimitedUploaderKnownSize(file, header.Size)
attach, err := uploadFunc(ctx, uploaderFile, &repo_model.Attachment{
Name: header.Filename,
UploaderID: ctx.Doer.ID,
RepoID: repoID,
})
if err != nil {
if upload.IsErrFileTypeForbidden(err) {
ctx.HTTPError(http.StatusBadRequest, err.Error())
return
}
ctx.ServerError("uploadAttachment(uploadFunc)", err)
return
}
log.Trace("New attachment uploaded: %s", attach.UUID)
ctx.JSON(http.StatusOK, map[string]string{
"uuid": attach.UUID,
})
}
// DeleteAttachment response for deleting issue's attachment
func DeleteAttachment(ctx *context.Context) {
file := ctx.FormString("file")
attach, err := repo_model.GetAttachmentByUUID(ctx, file)
if err != nil {
ctx.HTTPError(http.StatusBadRequest, err.Error())
return
}
if !ctx.IsSigned {
ctx.HTTPError(http.StatusForbidden)
return
}
if attach.RepoID != ctx.Repo.Repository.ID {
ctx.HTTPError(http.StatusBadRequest, "attachment does not belong to this repository")
return
}
if ctx.Doer.ID != attach.UploaderID {
if attach.IssueID > 0 {
issue, err := issues_model.GetIssueByID(ctx, attach.IssueID)
if err != nil {
ctx.ServerError("GetIssueByID", err)
return
}
if !ctx.Repo.Permission.CanWriteIssuesOrPulls(issue.IsPull) {
ctx.HTTPError(http.StatusForbidden)
return
}
} else if attach.ReleaseID > 0 {
if !ctx.Repo.Permission.CanWrite(unit.TypeReleases) {
ctx.HTTPError(http.StatusForbidden)
return
}
} else {
if !ctx.Repo.Permission.IsAdmin() && !ctx.Repo.Permission.IsOwner() {
ctx.HTTPError(http.StatusForbidden)
return
}
}
}
err = repo_model.DeleteAttachment(ctx, attach, true)
if err != nil {
ctx.ServerError("DeleteAttachment", err)
return
}
ctx.JSON(http.StatusOK, map[string]string{
"uuid": attach.UUID,
})
}
// ServeAttachment serve attachments with the given UUID
func ServeAttachment(ctx *context.Context, uuid string) {
attach, err := repo_model.GetAttachmentByUUID(ctx, uuid)
if err != nil {
if repo_model.IsErrAttachmentNotExist(err) {
ctx.HTTPError(http.StatusNotFound)
} else {
ctx.ServerError("GetAttachmentByUUID", err)
}
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 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
ctx.HTTPError(http.StatusNotFound)
return
}
} else { // If we have the linked type, we need to check access
var (
perm access_model.Permission
repo = ctx.Repo.Repository
)
if repo == nil {
repo, err = repo_model.GetRepositoryByID(ctx, repoID)
if err != nil {
ctx.ServerError("GetRepositoryByID", err)
return
}
perm, err = access_model.GetDoerRepoPermission(ctx, repo, ctx.Doer)
if err != nil {
ctx.ServerError("GetDoerRepoPermission", err)
return
}
} else {
perm = ctx.Repo.Permission
}
if !perm.CanRead(unitType) {
ctx.HTTPError(http.StatusNotFound)
return
}
if requiredScope, ok := attachmentReadScope(unitType); ok {
context.CheckTokenScopes(ctx, repo, requiredScope)
if ctx.Written() {
return
}
}
}
if err := attach.IncreaseDownloadCount(ctx); err != nil {
ctx.ServerError("IncreaseDownloadCount", err)
return
}
if setting.Attachment.Storage.ServeDirect() {
// If we have a signed url (S3, object storage), redirect to this directly.
u, err := storage.Attachments.ServeDirectURL(attach.RelativePath(), attach.Name, ctx.Req.Method, nil)
if u != nil && err == nil {
ctx.Redirect(u.String())
return
}
}
if httpcache.HandleGenericETagPrivateCache(ctx.Req, ctx.Resp, `"`+attach.UUID+`"`, attach.CreatedUnix.AsTimePtr()) {
return
}
// If we have matched and access to release or issue
fr, err := storage.Attachments.Open(attach.RelativePath())
if err != nil {
ctx.ServerError("Open", err)
return
}
defer fr.Close()
httplib.ServeUserContentByFile(ctx.Req, ctx.Resp, fr, httplib.ServeHeaderOptions{Filename: attach.Name})
}
// GetAttachment serve attachments
func GetAttachment(ctx *context.Context) {
ServeAttachment(ctx, ctx.PathParam("uuid"))
}
+284
View File
@@ -0,0 +1,284 @@
// Copyright 2019 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package repo
import (
"bytes"
"fmt"
"html/template"
"net/http"
"net/url"
"path"
"strconv"
repo_model "gitea.dev/models/repo"
user_model "gitea.dev/models/user"
"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
Code template.HTML
EscapeStatus *charset.EscapeStatus
}
// RefBlame render blame page
func RefBlame(ctx *context.Context) {
ctx.Data["IsBlame"] = true
prepareRepoViewContent(ctx, ctx.Repo.RefTypeNameSubURL())
// Get current entry user currently looking at.
if ctx.Repo.TreePath == "" {
ctx.NotFound(nil)
return
}
entry, err := ctx.Repo.Commit.GetTreeEntryByPath(ctx.Repo.TreePath)
if err != nil {
HandleGitError(ctx, "Repo.Commit.GetTreeEntryByPath", err)
return
}
blob := entry.Blob()
fileSize := blob.Size()
ctx.Data["FileSize"] = fileSize
ctx.Data["FileTreePath"] = ctx.Repo.TreePath
tplName := tplRepoViewContent
if !ctx.FormBool("only_content") {
prepareHomeTreeSideBarSwitch(ctx)
tplName = tplRepoView
}
if fileSize >= setting.UI.MaxDisplayFileSize {
ctx.Data["IsFileTooLarge"] = true
ctx.HTML(http.StatusOK, tplName)
return
}
ctx.Data["NumLines"], err = blob.GetBlobLineCount(nil)
if err != nil {
ctx.NotFound(err)
return
}
bypassBlameIgnore, _ := strconv.ParseBool(ctx.FormString("bypass-blame-ignore"))
result, err := performBlame(ctx, ctx.Repo.Repository, ctx.Repo.Commit, ctx.Repo.TreePath, bypassBlameIgnore)
if err != nil {
ctx.NotFound(err)
return
}
ctx.Data["UsesIgnoreRevs"] = result.UsesIgnoreRevs
ctx.Data["FaultyIgnoreRevsFile"] = result.FaultyIgnoreRevsFile
commitNames := processBlameParts(ctx, result.Parts)
if ctx.Written() {
return
}
renderBlame(ctx, result.Parts, commitNames)
ctx.HTML(http.StatusOK, tplName)
}
type blameResult struct {
Parts []*gitrepo.BlamePart
UsesIgnoreRevs bool
FaultyIgnoreRevsFile bool
}
func performBlame(ctx *context.Context, repo *repo_model.Repository, commit *git.Commit, file string, bypassBlameIgnore bool) (*blameResult, error) {
objectFormat := ctx.Repo.GetObjectFormat()
blameReader, err := gitrepo.CreateBlameReader(ctx, objectFormat, repo, commit, file, bypassBlameIgnore)
if err != nil {
return nil, err
}
r := &blameResult{}
if err := fillBlameResult(blameReader, r); err != nil {
_ = blameReader.Close()
return nil, err
}
err = blameReader.Close()
if err != nil {
if len(r.Parts) == 0 && r.UsesIgnoreRevs {
// try again without ignored revs
blameReader, err = gitrepo.CreateBlameReader(ctx, objectFormat, repo, commit, file, true)
if err != nil {
return nil, err
}
r := &blameResult{
FaultyIgnoreRevsFile: true,
}
if err := fillBlameResult(blameReader, r); err != nil {
_ = blameReader.Close()
return nil, err
}
return r, blameReader.Close()
}
return nil, err
}
return r, nil
}
func fillBlameResult(br *gitrepo.BlameReader, r *blameResult) error {
r.UsesIgnoreRevs = br.UsesIgnoreRevs()
previousHelper := make(map[string]*gitrepo.BlamePart)
r.Parts = make([]*gitrepo.BlamePart, 0, 5)
for {
blamePart, err := br.NextPart()
if err != nil {
return fmt.Errorf("BlameReader.NextPart failed: %w", err)
}
if blamePart == nil {
break
}
if prev, ok := previousHelper[blamePart.Sha]; ok {
if blamePart.PreviousSha == "" {
blamePart.PreviousSha = prev.PreviousSha
blamePart.PreviousPath = prev.PreviousPath
}
} else {
previousHelper[blamePart.Sha] = blamePart
}
r.Parts = append(r.Parts, blamePart)
}
return nil
}
func processBlameParts(ctx *context.Context, blameParts []*gitrepo.BlamePart) map[string]*user_model.UserCommit {
// store commit data by SHA to look up avatar info etc
commitNames := make(map[string]*user_model.UserCommit)
// and as blameParts can reference the same commits multiple
// times, we cache the lookup work locally
commits := make([]*git.Commit, 0, len(blameParts))
commitCache := map[string]*git.Commit{}
commitCache[ctx.Repo.Commit.ID.String()] = ctx.Repo.Commit
for _, part := range blameParts {
sha := part.Sha
if _, ok := commitNames[sha]; ok {
continue
}
// find the blamePart commit, to look up parent & email address for avatars
commit, ok := commitCache[sha]
var err error
if !ok {
commit, err = ctx.Repo.GitRepo.GetCommit(sha)
if err != nil {
if git.IsErrNotExist(err) {
ctx.NotFound(err)
} else {
ctx.ServerError("Repo.GitRepo.GetCommit", err)
}
return nil
}
commitCache[sha] = commit
}
commits = append(commits, commit)
}
// populate commit email addresses to later look up avatars.
validatedCommits, err := user_model.ValidateCommitsWithEmails(ctx, commits)
if err != nil {
ctx.ServerError("ValidateCommitsWithEmails", err)
return nil
}
for _, c := range validatedCommits {
commitNames[c.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)
}
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.MessageUTF8()
br.CommitSince = templates.TimeSince(commit.Author.When)
}
func renderBlame(ctx *context.Context, blameParts []*gitrepo.BlamePart, commitNames map[string]*user_model.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)
}
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 {
rowNumber++
br := &blameRow{RowNumber: rowNumber}
rows = append(rows, br)
if int64(buf.Len()) < setting.UI.MaxDisplayFileSize {
buf.WriteString(line)
buf.WriteByte('\n')
}
if partLineIdx == 0 {
renderBlameFillFirstBlameRow(ctx.Repo.RepoLink, avatarUtils, part, commitNames[part.Sha], br)
}
}
}
escapeStatus := &charset.EscapeStatus{}
bufContent := buf.Bytes()
bufContent = charset.ToUTF8(bufContent, charset.ConvertOpts{})
highlighted, _, lexerDisplayName := highlight.RenderCodeSlowGuess(path.Base(ctx.Repo.TreePath), language, util.UnsafeBytesToString(bufContent))
unsafeLines := highlight.UnsafeSplitHighlightedLines(highlighted)
for i, br := range rows {
var line template.HTML
if i < len(unsafeLines) {
line = template.HTML(util.UnsafeBytesToString(unsafeLines[i]))
}
br.EscapeStatus, br.Code = charset.EscapeControlHTML(line, ctx.Locale)
escapeStatus = escapeStatus.Or(br.EscapeStatus)
}
ctx.Data["EscapeStatus"] = escapeStatus
ctx.Data["BlameRows"] = rows
ctx.Data["LexerName"] = lexerDisplayName
}
+275
View File
@@ -0,0 +1,275 @@
// Copyright 2014 The Gogs Authors. All rights reserved.
// Copyright 2018 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package repo
import (
"errors"
"fmt"
"net/http"
"net/url"
"strings"
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 (
tplBranch templates.TplName = "repo/branch/list"
)
// Branches render repository branch page
func Branches(ctx *context.Context) {
ctx.Data["Title"] = "Branches"
ctx.Data["AllowsPulls"] = ctx.Repo.Repository.AllowsPulls(ctx)
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.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
page := max(ctx.FormInt("page"), 1)
pageSize := setting.Git.BranchesRangeSize
kw := ctx.FormString("q")
defaultBranch, 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}
for _, branch := range branches {
commitIDs = append(commitIDs, branch.DBBranch.CommitID)
}
commitStatuses, err := git_model.GetLatestCommitStatusForRepoCommitIDs(ctx, ctx.Repo.Repository.ID, commitIDs)
if err != nil {
ctx.ServerError("LoadBranches", err)
return
}
if !ctx.Repo.Permission.CanRead(unit.TypeActions) {
for key := range commitStatuses {
git_model.CommitStatusesHideActionsURL(ctx, commitStatuses[key])
}
}
commitStatus := make(map[string]*git_model.CommitStatus)
for commitID, cs := range commitStatuses {
commitStatus[commitID] = git_model.CalcCommitStatus(cs)
}
ctx.Data["Keyword"] = kw
ctx.Data["Branches"] = branches
ctx.Data["CommitStatus"] = commitStatus
ctx.Data["CommitStatuses"] = commitStatuses
ctx.Data["DefaultBranchBranch"] = defaultBranch
pager := context.NewPagination(branchesCount, pageSize, page, 5)
pager.AddParamFromRequest(ctx.Req)
ctx.Data["Page"] = pager
ctx.HTML(http.StatusOK, tplBranch)
}
// DeleteBranchPost responses for delete merged branch
func DeleteBranchPost(ctx *context.Context) {
defer jsonRedirectBranches(ctx)
branchName := ctx.FormString("name")
if err := repo_service.DeleteBranch(ctx, ctx.Doer, ctx.Repo.Repository, ctx.Repo.GitRepo, branchName); err != nil {
switch {
case git.IsErrBranchNotExist(err):
log.Debug("DeleteBranch: Can't delete non existing branch '%s'", branchName)
ctx.Flash.Error(ctx.Tr("repo.branch.deletion_failed", branchName))
case errors.Is(err, repo_service.ErrBranchIsDefault):
log.Debug("DeleteBranch: Can't delete default branch '%s'", branchName)
ctx.Flash.Error(ctx.Tr("repo.branch.default_deletion_failed", branchName))
case errors.Is(err, git_model.ErrBranchIsProtected):
log.Debug("DeleteBranch: Can't delete protected branch '%s'", branchName)
ctx.Flash.Error(ctx.Tr("repo.branch.protected_deletion_failed", branchName))
default:
log.Error("DeleteBranch: %v", err)
ctx.Flash.Error(ctx.Tr("repo.branch.deletion_failed", branchName))
}
return
}
ctx.Flash.Success(ctx.Tr("repo.branch.deletion_success", branchName))
}
// RestoreBranchPost responses for delete merged branch
func RestoreBranchPost(ctx *context.Context) {
defer jsonRedirectBranches(ctx)
branchID := ctx.FormInt64("branch_id")
branchName := ctx.FormString("name")
deletedBranch, err := git_model.GetDeletedBranchByID(ctx, ctx.Repo.Repository.ID, branchID)
if err != nil {
log.Error("GetDeletedBranchByID: %v", err)
ctx.Flash.Error(ctx.Tr("repo.branch.restore_failed", branchName))
return
} else if deletedBranch == nil {
log.Debug("RestoreBranch: Can't restore branch[%d] '%s', as it does not exist", branchID, branchName)
ctx.Flash.Error(ctx.Tr("repo.branch.restore_failed", branchName))
return
}
if err := gitrepo.Push(ctx, ctx.Repo.Repository, ctx.Repo.Repository, git.PushOptions{
Branch: fmt.Sprintf("%s:%s%s", deletedBranch.CommitID, git.BranchPrefix, deletedBranch.Name),
Env: repo_module.PushingEnvironment(ctx.Doer, ctx.Repo.Repository),
}); err != nil {
if strings.Contains(err.Error(), "already exists") {
log.Debug("RestoreBranch: Can't restore branch '%s', since one with same name already exist", deletedBranch.Name)
ctx.Flash.Error(ctx.Tr("repo.branch.already_exists", deletedBranch.Name))
return
}
log.Error("RestoreBranch: CreateBranch: %v", err)
ctx.Flash.Error(ctx.Tr("repo.branch.restore_failed", deletedBranch.Name))
return
}
objectFormat := git.ObjectFormatFromName(ctx.Repo.Repository.ObjectFormatName)
// Don't return error below this
if err := repo_service.PushUpdate(
&repo_module.PushUpdateOptions{
RefFullName: git.RefNameFromBranch(deletedBranch.Name),
OldCommitID: objectFormat.EmptyObjectID().String(),
NewCommitID: deletedBranch.CommitID,
PusherID: ctx.Doer.ID,
PusherName: ctx.Doer.Name,
RepoUserName: ctx.Repo.Owner.Name,
RepoName: ctx.Repo.Repository.Name,
}); err != nil {
log.Error("RestoreBranch: Update: %v", err)
}
ctx.Flash.Success(ctx.Tr("repo.branch.restore_success", deletedBranch.Name))
}
func jsonRedirectBranches(ctx *context.Context) {
ctx.JSONRedirect(ctx.Repo.RepoLink + "/branches?page=" + url.QueryEscape(ctx.FormString("page")))
}
// CreateBranch creates new branch in repository
func CreateBranch(ctx *context.Context) {
form := web.GetForm(ctx).(*forms.NewBranchForm)
if !ctx.Repo.CanCreateBranch() {
ctx.NotFound(nil)
return
}
if ctx.HasError() {
ctx.Flash.Error(ctx.GetErrMsg())
ctx.Redirect(ctx.Repo.RepoLink + "/src/" + ctx.Repo.RefTypeNameSubURL())
return
}
var err error
if form.CreateTag {
target := ctx.Repo.CommitID
if ctx.Repo.RefFullName.IsBranch() {
target = ctx.Repo.BranchName
}
err = release_service.CreateNewTag(ctx, ctx.Doer, ctx.Repo.Repository, target, form.NewBranchName, "")
} else if ctx.Repo.RefFullName.IsBranch() {
err = repo_service.CreateNewBranch(ctx, ctx.Doer, ctx.Repo.Repository, ctx.Repo.BranchName, form.NewBranchName)
} else {
err = repo_service.CreateNewBranchFromCommit(ctx, ctx.Doer, ctx.Repo.Repository, ctx.Repo.CommitID, form.NewBranchName)
}
if err != nil {
if release_service.IsErrProtectedTagName(err) {
ctx.Flash.Error(ctx.Tr("repo.release.tag_name_protected"))
ctx.Redirect(ctx.Repo.RepoLink + "/src/" + ctx.Repo.RefTypeNameSubURL())
return
}
if release_service.IsErrTagAlreadyExists(err) {
e := err.(release_service.ErrTagAlreadyExists)
ctx.Flash.Error(ctx.Tr("repo.branch.tag_collision", e.TagName))
ctx.Redirect(ctx.Repo.RepoLink + "/src/" + ctx.Repo.RefTypeNameSubURL())
return
}
if git_model.IsErrBranchAlreadyExists(err) || git.IsErrPushOutOfDate(err) {
ctx.Flash.Error(ctx.Tr("repo.branch.branch_already_exists", form.NewBranchName))
ctx.Redirect(ctx.Repo.RepoLink + "/src/" + ctx.Repo.RefTypeNameSubURL())
return
}
if git_model.IsErrBranchNameConflict(err) {
e := err.(git_model.ErrBranchNameConflict)
ctx.Flash.Error(ctx.Tr("repo.branch.branch_name_conflict", form.NewBranchName, e.BranchName))
ctx.Redirect(ctx.Repo.RepoLink + "/src/" + ctx.Repo.RefTypeNameSubURL())
return
}
if git.IsErrPushRejected(err) {
e := err.(*git.ErrPushRejected)
if len(e.Message) == 0 {
ctx.Flash.Error(ctx.Tr("repo.editor.push_rejected_no_message"))
} else {
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.EscapeFlashErrorString(e.Message),
})
if err != nil {
ctx.ServerError("UpdatePullRequest.HTMLString", err)
return
}
ctx.Flash.Error(flashError)
}
ctx.Redirect(ctx.Repo.RepoLink + "/src/" + ctx.Repo.RefTypeNameSubURL())
return
}
ctx.ServerError("CreateNewBranch", err)
return
}
if form.CreateTag {
ctx.Flash.Success(ctx.Tr("repo.tag.create_success", form.NewBranchName))
ctx.Redirect(ctx.Repo.RepoLink + "/src/tag/" + util.PathEscapeSegments(form.NewBranchName))
return
}
ctx.Flash.Success(ctx.Tr("repo.branch.create_success", form.NewBranchName))
ctx.Redirect(ctx.Repo.RepoLink + "/src/branch/" + util.PathEscapeSegments(form.NewBranchName) + "/" + util.PathEscapeSegments(form.CurrentPath))
}
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) {
ctx.JSONErrorNotFound()
return
} else if pull_service.IsErrMergeConflicts(err) {
ctx.JSONError(ctx.Tr("repo.pulls.merge_conflict"))
return
}
ctx.ServerError("MergeUpstream", err)
return
}
ctx.JSONRedirect("")
}
+41
View File
@@ -0,0 +1,41 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package repo
import (
"errors"
"net/http"
"gitea.dev/modules/templates"
"gitea.dev/services/context"
contributors_service "gitea.dev/services/repository"
)
const (
tplCodeFrequency templates.TplName = "repo/activity"
)
// CodeFrequency renders the page to show repository code frequency
func CodeFrequency(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("repo.activity.navbar.code_frequency")
ctx.Data["PageIsActivity"] = true
ctx.Data["PageIsCodeFrequency"] = true
ctx.PageData["repoLink"] = ctx.Repo.RepoLink
ctx.HTML(http.StatusOK, tplCodeFrequency)
}
// CodeFrequencyData returns JSON of code frequency data
func CodeFrequencyData(ctx *context.Context) {
if contributorStats, err := contributors_service.GetContributorStats(ctx, ctx.Cache, ctx.Repo.Repository, ctx.Repo.Repository.DefaultBranch); err != nil {
if errors.Is(err, contributors_service.ErrAwaitGeneration) {
ctx.Status(http.StatusAccepted)
return
}
ctx.ServerError("GetContributorStats", err)
} else {
ctx.JSON(http.StatusOK, contributorStats["total"].Weeks)
}
}
+467
View File
@@ -0,0 +1,467 @@
// Copyright 2014 The Gogs Authors. All rights reserved.
// Copyright 2019 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package repo
import (
"errors"
"fmt"
"html/template"
"net/http"
"strings"
asymkey_model "gitea.dev/models/asymkey"
"gitea.dev/models/db"
git_model "gitea.dev/models/git"
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 (
tplCommits templates.TplName = "repo/commits"
tplGraph templates.TplName = "repo/graph"
tplGraphDiv templates.TplName = "repo/graph/div"
tplCommitPage templates.TplName = "repo/commit_page"
)
// RefCommits render commits page
func RefCommits(ctx *context.Context) {
switch {
case len(ctx.Repo.TreePath) == 0:
Commits(ctx)
case ctx.Repo.TreePath == "search":
SearchCommits(ctx)
default:
FileHistory(ctx)
}
}
// Commits render branch's commits
func Commits(ctx *context.Context) {
ctx.Data["PageIsCommits"] = true
if ctx.Repo.Commit == nil {
ctx.NotFound(nil)
return
}
ctx.Data["PageIsViewCode"] = true
commitsCount := ctx.Repo.CommitsCount
page := max(ctx.FormInt("page"), 1)
pageSize := ctx.FormInt("limit")
if pageSize <= 0 {
pageSize = setting.Git.CommitsRangeSize
}
// Both `git log branchName` and `git log commitId` work.
commits, err := ctx.Repo.Commit.CommitsByRange(page, pageSize, "", "", "")
if err != nil {
ctx.ServerError("CommitsByRange", err)
return
}
ctx.Data["Commits"], err = processGitCommits(ctx, commits)
if err != nil {
ctx.ServerError("processGitCommits", err)
return
}
commitIDs := make([]string, 0, len(commits))
for _, c := range commits {
commitIDs = append(commitIDs, c.ID.String())
}
commitsTagsMap, err := repo_model.FindTagsByCommitIDs(ctx, ctx.Repo.Repository.ID, commitIDs...)
if err != nil {
log.Error("FindTagsByCommitIDs: %v", err)
ctx.Flash.Error(ctx.Tr("internal_error_skipped", "FindTagsByCommitIDs"))
} else {
ctx.Data["CommitsTagsMap"] = commitsTagsMap
}
ctx.Data["CommitCount"] = commitsCount
pager := context.NewPagination(commitsCount, pageSize, page, 5)
pager.AddParamFromRequest(ctx.Req)
ctx.Data["Page"] = pager
ctx.HTML(http.StatusOK, tplCommits)
}
// Graph render commit graph - show commits from all branches.
func Graph(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("repo.commit_graph")
ctx.Data["PageIsCommits"] = true
ctx.Data["PageIsViewCode"] = true
mode := strings.ToLower(ctx.FormTrim("mode"))
if mode != "monochrome" {
mode = "color"
}
ctx.Data["Mode"] = mode
hidePRRefs := ctx.FormBool("hide-pr-refs")
ctx.Data["HidePRRefs"] = hidePRRefs
branches := ctx.FormStrings("branch")
realBranches := make([]string, len(branches))
copy(realBranches, branches)
for i, branch := range realBranches {
if strings.HasPrefix(branch, "--") {
realBranches[i] = git.BranchPrefix + branch
}
}
ctx.Data["SelectedBranches"] = realBranches
files := ctx.FormStrings("file")
graphCommitsCount, err := ctx.Repo.GetCommitGraphsCount(ctx, hidePRRefs, realBranches, files)
if err != nil {
log.Warn("GetCommitGraphsCount error for generate graph exclude prs: %t branches: %s in %-v, Will Ignore branches and try again. Underlying Error: %v", hidePRRefs, branches, ctx.Repo.Repository, err)
realBranches = []string{}
graphCommitsCount, err = ctx.Repo.GetCommitGraphsCount(ctx, hidePRRefs, realBranches, files)
if err != nil {
ctx.ServerError("GetCommitGraphsCount", err)
return
}
}
page := ctx.FormInt("page")
graph, err := gitgraph.GetCommitGraph(ctx.Repo.GitRepo, page, 0, hidePRRefs, realBranches, files)
if err != nil {
ctx.ServerError("GetCommitGraph", err)
return
}
if err := graph.LoadAndProcessCommits(ctx, ctx.Repo.Repository, ctx.Repo.GitRepo); err != nil {
ctx.ServerError("LoadAndProcessCommits", err)
return
}
ctx.Data["Graph"] = graph
gitRefs, err := ctx.Repo.GitRepo.GetRefs()
if err != nil {
ctx.ServerError("GitRepo.GetRefs", err)
return
}
ctx.Data["AllRefs"] = gitRefs
divOnly := ctx.FormBool("div-only")
queryParams := ctx.Req.URL.Query()
queryParams.Del("div-only")
paginator := context.NewPagination(graphCommitsCount, setting.UI.GraphMaxCommitNum, page, 5)
paginator.AddParamFromQuery(queryParams)
ctx.Data["Page"] = paginator
if divOnly {
ctx.HTML(http.StatusOK, tplGraphDiv)
return
}
ctx.HTML(http.StatusOK, tplGraph)
}
// SearchCommits render commits filtered by keyword
func SearchCommits(ctx *context.Context) {
ctx.Data["PageIsCommits"] = true
ctx.Data["PageIsViewCode"] = true
query := ctx.FormTrim("q")
if len(query) == 0 {
ctx.Redirect(ctx.Repo.RepoLink + "/commits/" + ctx.Repo.RefTypeNameSubURL())
return
}
all := ctx.FormBool("all")
opts := git.NewSearchCommitsOptions(query, all)
commits, err := ctx.Repo.Commit.SearchCommits(opts)
if err != nil {
ctx.ServerError("SearchCommits", err)
return
}
ctx.Data["CommitCount"] = len(commits)
ctx.Data["Commits"], err = processGitCommits(ctx, commits)
if err != nil {
ctx.ServerError("processGitCommits", err)
return
}
ctx.Data["Keyword"] = query
if all {
ctx.Data["All"] = true
}
ctx.HTML(http.StatusOK, tplCommits)
}
// FileHistory show a file's reversions
func FileHistory(ctx *context.Context) {
if ctx.Repo.TreePath == "" {
Commits(ctx)
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
}
page := max(ctx.FormInt("page"), 1)
commits, err := ctx.Repo.GitRepo.CommitsByFileAndRange(
git.CommitsByFileAndRangeOptions{
Revision: ctx.Repo.RefFullName.ShortName(), // FIXME: legacy code used ShortName
File: ctx.Repo.TreePath,
Page: page,
})
if err != nil {
ctx.ServerError("CommitsByFileAndRange", err)
return
}
ctx.Data["Commits"], err = processGitCommits(ctx, commits)
if err != nil {
ctx.ServerError("processGitCommits", err)
return
}
ctx.Data["FileTreePath"] = ctx.Repo.TreePath
ctx.Data["CommitCount"] = commitsCount
pager := context.NewPagination(commitsCount, setting.Git.CommitsRangeSize, page, 5)
pager.AddParamFromRequest(ctx.Req)
ctx.Data["Page"] = pager
ctx.HTML(http.StatusOK, tplCommits)
}
func LoadBranchesAndTags(ctx *context.Context) {
response, err := repo_service.LoadBranchesAndTags(ctx, ctx.Repo, ctx.PathParam("sha"))
if err == nil {
ctx.JSON(http.StatusOK, response)
return
}
ctx.NotFoundOrServerError(fmt.Sprintf("could not load branches and tags the commit %s belongs to", ctx.PathParam("sha")), git.IsErrNotExist, err)
}
// Diff show different from current commit to previous commit
func Diff(ctx *context.Context) {
ctx.Data["PageIsDiff"] = true
userName := ctx.Repo.Owner.Name
repoName := ctx.Repo.Repository.Name
commitID := ctx.PathParam("sha")
diffBlobExcerptData := &gitdiff.DiffBlobExcerptData{
BaseLink: ctx.Repo.RepoLink + "/blob_excerpt",
DiffStyle: GetDiffViewStyle(ctx),
AfterCommitID: commitID,
}
gitRepo := ctx.Repo.GitRepo
var gitRepoStore gitrepo.Repository = ctx.Repo.Repository
if ctx.Data["PageIsWiki"] != nil {
var err error
gitRepoStore = ctx.Repo.Repository.WikiStorageRepo()
gitRepo, err = gitrepo.RepositoryFromRequestContextOrOpen(ctx, gitRepoStore)
if err != nil {
ctx.ServerError("Repo.GitRepo.GetCommit", err)
return
}
diffBlobExcerptData.BaseLink = ctx.Repo.RepoLink + "/wiki/blob_excerpt"
}
commit, err := gitRepo.GetCommit(commitID)
if err != nil {
if git.IsErrNotExist(err) {
ctx.NotFound(err)
} else {
ctx.ServerError("Repo.GitRepo.GetCommit", err)
}
return
}
if len(commitID) != commit.ID.Type().FullLength() {
commitID = commit.ID.String()
}
fileOnly := ctx.FormBool("file-only")
maxLines, maxFiles := setting.Git.MaxGitDiffLines, setting.Git.MaxGitDiffFiles
files := ctx.FormStrings("files")
if fileOnly && (len(files) == 2 || len(files) == 1) {
maxLines, maxFiles = -1, -1
}
diff, err := gitdiff.GetDiffForRender(ctx, ctx.Repo.RepoLink, gitRepo, &gitdiff.DiffOptions{
AfterCommitID: commitID,
SkipTo: ctx.FormString("skip-to"),
MaxLines: maxLines,
MaxLineCharacters: setting.Git.MaxGitDiffLineCharacters,
MaxFiles: maxFiles,
WhitespaceBehavior: gitdiff.GetWhitespaceFlag(GetWhitespaceBehavior(ctx)),
}, files...)
if err != nil {
ctx.NotFound(err)
return
}
diffShortStat, err := gitdiff.GetDiffShortStat(ctx, gitRepoStore, gitRepo, "", commitID)
if err != nil {
ctx.ServerError("GetDiffShortStat", err)
return
}
ctx.Data["DiffShortStat"] = diffShortStat
parents := make([]string, commit.ParentCount())
for i := 0; i < commit.ParentCount(); i++ {
sha, err := commit.ParentID(i)
if err != nil {
ctx.NotFound(err)
return
}
parents[i] = sha.String()
}
ctx.Data["CommitID"] = commitID
ctx.Data["AfterCommitID"] = commitID
var parentCommit *git.Commit
var parentCommitID string
if commit.ParentCount() > 0 {
parentCommit, err = gitRepo.GetCommit(parents[0])
if err != nil {
ctx.NotFound(err)
return
}
parentCommitID = parentCommit.ID.String()
}
setCompareContext(ctx, parentCommit, commit, userName, repoName)
ctx.Data["Title"] = commit.MessageTitle() + " · " + base.ShortSha(commitID)
ctx.Data["Commit"] = commit
ctx.Data["Diff"] = diff
ctx.Data["DiffBlobExcerptData"] = diffBlobExcerptData
if !fileOnly {
diffTree, err := gitdiff.GetDiffTree(ctx, gitRepo, false, parentCommitID, commitID)
if err != nil {
ctx.ServerError("GetDiffTree", err)
return
}
renderedIconPool := fileicon.NewRenderedIconPool()
ctx.PageData["DiffFileTree"] = transformDiffTreeForWeb(renderedIconPool, diffTree, nil)
ctx.PageData["FolderIcon"] = fileicon.RenderEntryIconHTML(renderedIconPool, fileicon.EntryInfoFolder())
ctx.PageData["FolderOpenIcon"] = fileicon.RenderEntryIconHTML(renderedIconPool, fileicon.EntryInfoFolderOpen())
ctx.Data["FileIconPoolHTML"] = renderedIconPool.RenderToHTML()
}
statuses, err := git_model.GetLatestCommitStatus(ctx, ctx.Repo.Repository.ID, commitID, db.ListOptionsAll)
if err != nil {
log.Error("GetLatestCommitStatus: %v", err)
}
if !ctx.Repo.Permission.CanRead(unit_model.TypeActions) {
git_model.CommitStatusesHideActionsURL(ctx, statuses)
}
ctx.Data["CommitStatus"] = git_model.CalcCommitStatus(statuses)
ctx.Data["CommitStatuses"] = statuses
verification := asymkey_service.ParseCommitWithSignature(ctx, commit)
ctx.Data["Verification"] = verification
ctx.Data["Author"] = user_model.ValidateCommitWithEmail(ctx, commit)
ctx.Data["Parents"] = parents
ctx.Data["DiffNotAvailable"] = diffShortStat.NumFiles == 0
if err := asymkey_model.CalculateTrustStatus(verification, ctx.Repo.Repository.GetTrustModel(), func(user *user_model.User) (bool, error) {
return repo_model.IsOwnerMemberCollaborator(ctx, ctx.Repo.Repository, user.ID)
}, nil); err != nil {
ctx.ServerError("CalculateTrustStatus", err)
return
}
note := &git.Note{}
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{CurrentRefSubURL: "commit/" + util.PathEscapeSegments(commitID)})
htmlMessage := template.HTML(template.HTMLEscapeString(string(charset.ToUTF8WithFallback(note.Message, charset.ConvertOpts{}))))
ctx.Data["NoteRendered"], err = markup.PostProcessCommitMessage(rctx, htmlMessage)
if err != nil {
ctx.ServerError("PostProcessCommitMessage", err)
return
}
} else if !git.IsErrNotExist(err) {
log.Error("GetNote: %v", err)
}
pr, _ := issues_model.GetPullRequestByMergedCommit(ctx, ctx.Repo.Repository.ID, commitID)
if pr != nil {
ctx.Data["MergedPRIssueNumber"] = pr.Index
}
ctx.HTML(http.StatusOK, tplCommitPage)
}
// RawDiff dumps diff results of repository in given commit ID to io.Writer
func RawDiff(ctx *context.Context) {
var gitRepo *git.Repository
if ctx.Data["PageIsWiki"] != nil {
wikiRepo, err := gitrepo.OpenRepository(ctx, ctx.Repo.Repository.WikiStorageRepo())
if err != nil {
ctx.ServerError("OpenRepository", err)
return
}
defer wikiRepo.Close()
gitRepo = wikiRepo
} else {
gitRepo = ctx.Repo.GitRepo
if gitRepo == nil {
ctx.ServerError("GitRepo not open", fmt.Errorf("no open git repo for '%s'", ctx.Repo.Repository.FullName()))
return
}
}
if err := git.GetRawDiff(
gitRepo,
ctx.PathParam("sha"),
git.RawDiffType(ctx.PathParam("ext")),
ctx.Resp,
); err != nil {
if git.IsErrNotExist(err) {
ctx.NotFound(errors.New("commit " + ctx.PathParam("sha") + " does not exist."))
return
}
ctx.ServerError("GetRawDiff", err)
return
}
}
func processGitCommits(ctx *context.Context, gitCommits []*git.Commit) ([]*git_model.SignCommitWithStatuses, error) {
commits, err := git_service.ConvertFromGitCommit(ctx, gitCommits, ctx.Repo.Repository)
if err != nil {
return nil, err
}
if !ctx.Repo.Permission.CanRead(unit_model.TypeActions) {
for _, commit := range commits {
if commit.Status == nil {
continue
}
commit.Status.HideActionsURL(ctx)
git_model.CommitStatusesHideActionsURL(ctx, commit.Statuses)
}
}
return commits, nil
}
+73
View File
@@ -0,0 +1,73 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package repo
import (
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 {
RecentlyPushedNewBranches []*git_model.RecentlyPushedNewBranch
}
func prepareRecentlyPushedNewBranches(ctx *context.Context) {
if ctx.Doer == nil {
return
}
if err := ctx.Repo.Repository.GetBaseRepo(ctx); err != nil {
log.Error("GetBaseRepo: %v", err)
return
}
opts := git_model.FindRecentlyPushedNewBranchesOptions{
Repo: ctx.Repo.Repository,
BaseRepo: ctx.Repo.Repository,
}
if ctx.Repo.Repository.IsFork {
opts.BaseRepo = ctx.Repo.Repository.BaseRepo
}
baseRepoPerm, err := access_model.GetDoerRepoPermission(ctx, opts.BaseRepo, ctx.Doer)
if err != nil {
log.Error("GetDoerRepoPermission: %v", err)
return
}
if !opts.Repo.CanContentChange() || !opts.BaseRepo.CanContentChange() {
return
}
if !opts.BaseRepo.UnitEnabled(ctx, unit_model.TypePullRequests) || !baseRepoPerm.CanRead(unit_model.TypePullRequests) {
return
}
var finalBranches []*git_model.RecentlyPushedNewBranch
branches, err := git_model.FindRecentlyPushedNewBranches(ctx, ctx.Doer, opts)
if err != nil {
log.Error("FindRecentlyPushedNewBranches failed: %v", err)
return
}
for _, branch := range branches {
divergingInfo, err := repo_service.GetBranchDivergingInfo(ctx,
branch.BranchRepo, branch.BranchName, // "base" repo for diverging info
opts.BaseRepo, opts.BaseRepo.DefaultBranch, // "head" repo for diverging info
)
if err != nil {
log.Error("GetBranchDivergingInfo failed: %v", err)
continue
}
branchRepoHasNewCommits := divergingInfo.BaseHasNewCommits
baseRepoCommitsBehind := divergingInfo.HeadCommitsBehind
if branchRepoHasNewCommits || baseRepoCommitsBehind > 0 {
finalBranches = append(finalBranches, branch)
}
}
if len(finalBranches) > 0 {
ctx.Data["RecentBranchesPromptData"] = RecentBranchesPromptDataStruct{finalBranches}
}
}
+795
View File
@@ -0,0 +1,795 @@
// Copyright 2019 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package repo
import (
gocontext "context"
"encoding/csv"
"errors"
"io"
"net/http"
"net/url"
"path/filepath"
"sort"
"strings"
"unicode"
"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 (
tplCompare templates.TplName = "repo/diff/compare"
tplBlobExcerpt templates.TplName = "repo/diff/blob_excerpt"
tplDiffBox templates.TplName = "repo/diff/box"
)
// setCompareContext sets context data.
func setCompareContext(ctx *context.Context, before, head *git.Commit, headOwner, headName string) {
ctx.Data["BeforeCommit"] = before
ctx.Data["HeadCommit"] = head
ctx.Data["GetBlobByPathForCommit"] = func(commit *git.Commit, path string) *git.Blob {
if commit == nil {
return nil
}
blob, err := commit.GetBlobByPath(path)
if err != nil {
return nil
}
return blob
}
ctx.Data["GetSniffedTypeForBlob"] = func(blob *git.Blob) typesniffer.SniffedType {
st := typesniffer.SniffedType{}
if blob == nil {
return st
}
st, err := blob.GuessContentType()
if err != nil {
log.Error("GuessContentType failed: %v", err)
return st
}
return st
}
setPathsCompareContext(ctx, before, head, headOwner, headName)
setImageCompareContext(ctx)
setCsvCompareContext(ctx)
}
// SourceCommitURL creates a relative URL for a commit in the given repository
func SourceCommitURL(owner, name string, commit *git.Commit) string {
return setting.AppSubURL + "/" + url.PathEscape(owner) + "/" + url.PathEscape(name) + "/src/commit/" + url.PathEscape(commit.ID.String())
}
// RawCommitURL creates a relative URL for the raw commit in the given repository
func RawCommitURL(owner, name string, commit *git.Commit) string {
return setting.AppSubURL + "/" + url.PathEscape(owner) + "/" + url.PathEscape(name) + "/raw/commit/" + url.PathEscape(commit.ID.String())
}
// setPathsCompareContext sets context data for source and raw paths
func setPathsCompareContext(ctx *context.Context, base, head *git.Commit, headOwner, headName string) {
ctx.Data["SourcePath"] = SourceCommitURL(headOwner, headName, head)
ctx.Data["RawPath"] = RawCommitURL(headOwner, headName, head)
if base != nil {
ctx.Data["BeforeSourcePath"] = SourceCommitURL(headOwner, headName, base)
ctx.Data["BeforeRawPath"] = RawCommitURL(headOwner, headName, base)
}
}
// setImageCompareContext sets context data that is required by image compare template
func setImageCompareContext(ctx *context.Context) {
ctx.Data["IsSniffedTypeAnImage"] = func(st typesniffer.SniffedType) bool {
return st.IsImage() && (setting.UI.SVG.Enabled || !st.IsSvgImage())
}
}
// setCsvCompareContext sets context data that is required by the CSV compare template
func setCsvCompareContext(ctx *context.Context) {
ctx.Data["IsCsvFile"] = func(diffFile *gitdiff.DiffFile) bool {
extension := strings.ToLower(filepath.Ext(diffFile.Name))
return extension == ".csv" || extension == ".tsv"
}
type CsvDiffResult struct {
Sections []*gitdiff.TableDiffSection
Error string
}
ctx.Data["CreateCsvDiff"] = func(diffFile *gitdiff.DiffFile, baseBlob, headBlob *git.Blob) CsvDiffResult {
if diffFile == nil {
return CsvDiffResult{nil, ""}
}
errTooLarge := errors.New(ctx.Locale.TrString("repo.error.csv.too_large"))
csvReaderFromCommit := func(ctx *markup.RenderContext, blob *git.Blob) (*csv.Reader, io.Closer, error) {
if blob == nil {
// It's ok for blob to be nil (file added or deleted)
return nil, nil, nil
}
if setting.UI.CSV.MaxFileSize != 0 && setting.UI.CSV.MaxFileSize < blob.Size() {
return nil, nil, errTooLarge
}
reader, err := blob.DataAsync()
if err != nil {
return nil, nil, err
}
var closer io.Closer = reader
csvReader, err := csv_module.CreateReaderAndDetermineDelimiter(ctx, charset.ToUTF8WithFallbackReader(reader, charset.ConvertOpts{}))
return csvReader, closer, err
}
baseReader, baseBlobCloser, err := csvReaderFromCommit(markup.NewRenderContext(ctx).WithRelativePath(diffFile.OldName), baseBlob)
if baseBlobCloser != nil {
defer baseBlobCloser.Close()
}
if err != nil {
if err == errTooLarge {
return CsvDiffResult{nil, err.Error()}
}
log.Error("error whilst creating csv.Reader from file %s in base commit %s in %s: %v", diffFile.Name, baseBlob.ID.String(), ctx.Repo.Repository.Name, err)
return CsvDiffResult{nil, "unable to load file"}
}
headReader, headBlobCloser, err := csvReaderFromCommit(markup.NewRenderContext(ctx).WithRelativePath(diffFile.Name), headBlob)
if headBlobCloser != nil {
defer headBlobCloser.Close()
}
if err != nil {
if err == errTooLarge {
return CsvDiffResult{nil, err.Error()}
}
log.Error("error whilst creating csv.Reader from file %s in head commit %s in %s: %v", diffFile.Name, headBlob.ID.String(), ctx.Repo.Repository.Name, err)
return CsvDiffResult{nil, "unable to load file"}
}
sections, err := gitdiff.CreateCsvDiff(diffFile, baseReader, headReader)
if err != nil {
errMessage, err := csv_module.FormatError(err, ctx.Locale)
if err != nil {
log.Error("CreateCsvDiff FormatError failed: %v", err)
return CsvDiffResult{nil, "unknown csv diff error"}
}
return CsvDiffResult{nil, errMessage}
}
return CsvDiffResult{sections, ""}
}
}
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) 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 != "" {
return util.NewInvalidArgumentErrorf("unsupported comparison syntax: ref with suffix")
}
// 2 get repository and owner for head
headOwner, headRepo, err := common.GetHeadOwnerAndRepo(ctx, baseRepo, compareReq)
if err != nil {
return err
}
// 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 {
return err
}
if !permHead.CanRead(unit.TypeCode) {
return util.NewNotExistErrorf("") // permission: no error message for end users
}
ctx.Data["CanWriteToHeadRepo"] = permHead.CanWrite(unit.TypeCode)
}
// 4 get base and head refs
baseRefName := util.IfZero(compareReq.BaseOriRef, baseRepo.GetPullRequestTargetBranch(ctx))
headRefName := util.IfZero(compareReq.HeadOriRef, headRepo.DefaultBranch)
baseRef := ctx.Repo.GitRepo.UnstableGuessRefByShortName(baseRefName)
if baseRef == "" {
return util.NewNotExistErrorf("no base ref: %s", baseRefName)
}
headGitRepo, err := gitrepo.RepositoryFromRequestContextOrOpen(ctx, headRepo)
if err != nil {
return err
}
headRef := headGitRepo.UnstableGuessRefByShortName(headRefName)
if headRef == "" {
return util.NewNotExistErrorf("no head ref: %s", headRefName)
}
ctx.Data["BaseName"] = baseRepo.OwnerName
ctx.Data["BaseBranch"] = baseRef.ShortName() // for legacy templates
ctx.Data["HeadUser"] = headOwner
ctx.Data["HeadBranch"] = headRef.ShortName() // for legacy templates
ctx.Data["IsPull"] = true
context.InitRepoPullRequestCtx(ctx, baseRepo, headRepo)
// The current base and head repositories and branches may not
// actually be the intended branches that the user wants to
// create a pull-request from - but also determining the head
// repo is difficult.
// We will want therefore to offer a few repositories to set as
// our base and head
// 1. First if the baseRepo is a fork get the "RootRepo" it was
// forked from
var rootRepo *repo_model.Repository
if baseRepo.IsFork {
err = baseRepo.GetBaseRepo(ctx)
if err != nil && !repo_model.IsErrRepoNotExist(err) {
return err
} else if err == nil {
rootRepo = baseRepo.BaseRepo
}
}
// 2. Now if the current user is not the owner of the baseRepo,
// check if they have a fork of the base repo and offer that as
// "OwnForkRepo"
var ownForkRepo *repo_model.Repository
if ctx.Doer != nil && baseRepo.OwnerID != ctx.Doer.ID {
repo := repo_model.GetForkedRepo(ctx, ctx.Doer.ID, baseRepo.ID)
if repo != nil {
ownForkRepo = repo
ctx.Data["OwnForkRepo"] = ownForkRepo
}
}
ctx.Data["HeadRepo"] = headRepo
ctx.Data["BaseCompareRepo"] = ctx.Repo.Repository
// If we have a rootRepo, and it's different from:
// 1. the computed base
// 2. the computed head
// then get the branches of it
if rootRepo != nil &&
rootRepo.ID != headRepo.ID &&
rootRepo.ID != baseRepo.ID {
canRead := access_model.CheckRepoUnitUser(ctx, rootRepo, ctx.Doer, unit.TypeCode)
if canRead {
ctx.Data["RootRepo"] = rootRepo
if !fileOnly {
branches, tags, err := getBranchesAndTagsForRepo(ctx, rootRepo)
if err != nil {
return err
}
ctx.Data["RootRepoBranches"] = branches
ctx.Data["RootRepoTags"] = tags
}
}
}
// 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)
// then get the branches from it.
if ownForkRepo != nil &&
ownForkRepo.ID != headRepo.ID &&
ownForkRepo.ID != baseRepo.ID &&
(rootRepo == nil || ownForkRepo.ID != rootRepo.ID) {
canRead := access_model.CheckRepoUnitUser(ctx, ownForkRepo, ctx.Doer, unit.TypeCode)
if canRead {
ctx.Data["OwnForkRepo"] = ownForkRepo
if !fileOnly {
branches, tags, err := getBranchesAndTagsForRepo(ctx, ownForkRepo)
if err != nil {
return err
}
ctx.Data["OwnForkRepoBranches"] = branches
ctx.Data["OwnForkRepoTags"] = tags
}
}
}
compareInfo, err := git_service.GetCompareInfo(ctx, baseRepo, headRepo, headGitRepo, baseRef, headRef, compareReq.DirectComparison(), fileOnly)
if err != nil {
return err
}
// 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.
func autoTitleFromBranchName(name string) string {
var buf strings.Builder
var prevIsSpace bool
runes := []rune(name)
for i, r := range runes {
isSpace := unicode.IsSpace(r)
if r == '-' || r == '_' || isSpace {
if !prevIsSpace {
buf.WriteRune(' ')
}
prevIsSpace = true
continue
}
if !prevIsSpace && unicode.IsUpper(r) {
needSpace := i > 0 && unicode.IsLower(runes[i-1]) || i < len(runes)-1 && unicode.IsLower(runes[i+1])
if needSpace {
buf.WriteRune(' ')
}
}
buf.WriteRune(unicode.ToLower(r))
prevIsSpace = isSpace
}
out := strings.TrimSpace(buf.String())
if out == "" {
return out
}
outRunes := []rune(out)
outRunes[0] = unicode.ToUpper(outRunes[0])
return string(outRunes)
}
func prepareNewPullRequestTitleContent(ci *git_service.CompareInfo, commits []*git_model.SignCommitWithStatuses, defaultTitleSource string) (title, content string) {
useFirstCommitAsTitle := len(commits) == 1 || (defaultTitleSource == setting.RepoPRTitleSourceFirstCommit && len(commits) > 0)
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 = c.UserCommit.MessageTitle()
} else {
title = autoTitleFromBranchName(ci.HeadRef.ShortName())
}
if len(commits) == 1 {
c := commits[0]
content = c.MessageBody()
}
var titleTrailer string
// TODO: 255 doesn't seem to be a good limit for title, just keep the old behavior
title, titleTrailer = util.EllipsisDisplayStringX(title, 255)
if titleTrailer != "" {
if content != "" {
content = titleTrailer + "\n\n" + content
} else {
content = titleTrailer + "\n"
}
}
return title, content
}
// 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
newPrFormTitle := ctx.FormTrim("title")
newPrFormBody := ctx.FormTrim("body")
ctx.Data["ExpandNewPrForm"] = ctx.FormBool("expand") || ctx.FormBool("quick_pull") || newPrFormTitle != "" || newPrFormBody != ""
ctx.Data["TitleQuery"] = newPrFormTitle
ctx.Data["BodyQuery"] = newPrFormBody
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
cpi.nothingToCompare = true
return
}
beforeCommitID := ci.CompareBase
maxLines, maxFiles := setting.Git.MaxGitDiffLines, setting.Git.MaxGitDiffFiles
files := ctx.FormStrings("files")
if len(files) == 2 || len(files) == 1 {
maxLines, maxFiles = -1, -1
}
fileOnly := ctx.FormBool("file-only")
diff, err := gitdiff.GetDiffForRender(ctx, ci.HeadRepo.Link(), ci.HeadGitRepo,
&gitdiff.DiffOptions{
BeforeCommitID: beforeCommitID,
AfterCommitID: headCommitID,
SkipTo: ctx.FormString("skip-to"),
MaxLines: maxLines,
MaxLineCharacters: setting.Git.MaxGitDiffLineCharacters,
MaxFiles: maxFiles,
WhitespaceBehavior: whitespaceBehavior,
DirectComparison: ci.DirectComparison(),
}, ctx.FormStrings("files")...)
if err != nil {
ctx.ServerError("GetDiff", err)
return
}
diffShortStat, err := gitdiff.GetDiffShortStat(ctx, ci.HeadRepo, ci.HeadGitRepo, beforeCommitID, headCommitID)
if err != nil {
ctx.ServerError("GetDiffShortStat", err)
return
}
ctx.Data["DiffShortStat"] = diffShortStat
ctx.Data["Diff"] = diff
ctx.Data["DiffBlobExcerptData"] = &gitdiff.DiffBlobExcerptData{
BaseLink: ci.HeadRepo.Link() + "/blob_excerpt",
DiffStyle: GetDiffViewStyle(ctx),
AfterCommitID: headCommitID,
}
ctx.Data["DiffNotAvailable"] = diffShortStat.NumFiles == 0
if !fileOnly {
diffTree, err := gitdiff.GetDiffTree(ctx, ci.HeadGitRepo, false, beforeCommitID, headCommitID)
if err != nil {
ctx.ServerError("GetDiffTree", err)
return
}
renderedIconPool := fileicon.NewRenderedIconPool()
ctx.PageData["DiffFileTree"] = transformDiffTreeForWeb(renderedIconPool, diffTree, nil)
ctx.PageData["FolderIcon"] = fileicon.RenderEntryIconHTML(renderedIconPool, fileicon.EntryInfoFolder())
ctx.PageData["FolderOpenIcon"] = fileicon.RenderEntryIconHTML(renderedIconPool, fileicon.EntryInfoFolderOpen())
ctx.Data["FileIconPoolHTML"] = renderedIconPool.RenderToHTML()
}
headCommit, err := ci.HeadGitRepo.GetCommit(headCommitID)
if err != nil {
ctx.ServerError("GetCommit", err)
return
}
baseGitRepo := ctx.Repo.GitRepo
beforeCommit, err := baseGitRepo.GetCommit(beforeCommitID)
if err != nil {
ctx.ServerError("GetCommit", err)
return
}
commits, err := processGitCommits(ctx, ci.Commits)
if err != nil {
ctx.ServerError("processGitCommits", err)
return
}
ctx.Data["Commits"] = commits
ctx.Data["CommitCount"] = len(commits)
ctx.Data["title"], ctx.Data["content"] = prepareNewPullRequestTitleContent(ci, commits, setting.Repository.PullRequest.DefaultTitleSource)
setCompareContext(ctx, beforeCommit, headCommit, ci.HeadRepo.OwnerName, repo.Name)
}
func getBranchesAndTagsForRepo(ctx gocontext.Context, repo *repo_model.Repository) (branches, tags []string, err error) {
branches, err = git_model.FindBranchNames(ctx, git_model.FindBranchOptions{
RepoID: repo.ID,
ListOptions: db.ListOptionsAll,
IsDeletedBranch: optional.Some(false),
})
if err != nil {
return nil, nil, err
}
tags, err = repo_model.GetTagNamesByRepoID(ctx, repo.ID)
if err != nil {
return nil, nil, err
}
return branches, tags, nil
}
// CompareDiff show different from one commit to another commit
func CompareDiff(ctx *context.Context) {
comparePageInfo := newComparePageInfo()
err := comparePageInfo.parseCompareInfo(ctx)
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
// 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)
if err != nil {
ctx.ServerError("GetTagNamesByRepoID", err)
return
}
ctx.Data["Tags"] = baseTags
fileOnly := ctx.FormBool("file-only")
if fileOnly {
ctx.HTML(http.StatusOK, tplDiffBox)
return
}
headBranches, headTags, err := getBranchesAndTagsForRepo(ctx, ci.HeadRepo)
if err != nil {
ctx.ServerError("GetBranchesAndTagsForRepo", err)
return
}
ctx.Data["HeadBranches"] = headBranches
ctx.Data["HeadTags"] = headTags
// For compare repo branches
PrepareBranchList(ctx)
if ctx.Written() {
return
}
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)
}
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) {
ctx.ServerError("GetUnmergedPullRequest", err)
return
}
} else {
ctx.Data["HasPullRequest"] = true
if err := pr.LoadIssue(ctx); err != nil {
ctx.ServerError("LoadIssue", err)
return
}
ctx.Data["PullRequest"] = pr
return
}
if !cpi.nothingToCompare {
// Setup information for new form.
pageMetaData := retrieveRepoIssueMetaData(ctx, ctx.Repo.Repository, nil, true)
if ctx.Written() {
return
}
_, templateErrs := setTemplateIfExists(ctx, pullRequestTemplateKey, pullRequestTemplateCandidates, pageMetaData)
if len(templateErrs) > 0 {
ctx.Flash.Warning(renderErrorOfTemplates(ctx, templateErrs), true)
}
}
}
beforeCommitID := cpi.compareInfo.CompareBase
afterCommitID := cpi.compareInfo.HeadCommitID
ctx.Data["Title"] = "Comparing " + base.ShortSha(beforeCommitID) + ci.CompareSeparator + base.ShortSha(afterCommitID)
ctx.Data["IsDiffCompare"] = true
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 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
// one empty line between them.
ctx.Data[pullRequestTemplateKey] = content + "\n\n" + templateContent
}
// When using form fields, also add content to field with id "body".
if fields, ok := ctx.Data["Fields"].([]*api.IssueFormField); ok {
for _, field := range fields {
if field.ID == "body" {
if fieldValue, ok := field.Attributes["value"].(string); ok && fieldValue != "" {
field.Attributes["value"] = content + "\n\n" + fieldValue
} else {
field.Attributes["value"] = content
}
}
}
}
}
ctx.Data["IsProjectsEnabled"] = ctx.Repo.Permission.CanWrite(unit.TypeProjects)
ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled
upload.AddUploadContext(ctx, "comment")
ctx.Data["HasIssuesOrPullsWritePermission"] = ctx.Repo.Permission.CanWrite(unit.TypePullRequests)
prConfig := ctx.Repo.Repository.MustGetUnit(ctx, unit.TypePullRequests).PullRequestsConfig()
ctx.Data["AllowMaintainerEdit"] = prConfig.DefaultAllowMaintainerEdit
}
// attachCommentsToLines attaches comments to their corresponding diff lines
func attachCommentsToLines(section *gitdiff.DiffSection, lineComments map[int64][]*issues_model.Comment) {
for _, line := range section.Lines {
if comments, ok := lineComments[int64(line.LeftIdx*-1)]; ok {
line.Comments = append(line.Comments, comments...)
}
if comments, ok := lineComments[int64(line.RightIdx)]; ok {
line.Comments = append(line.Comments, comments...)
}
sort.SliceStable(line.Comments, func(i, j int) bool {
return line.Comments[i].CreatedUnix < line.Comments[j].CreatedUnix
})
}
}
// attachHiddenCommentIDs calculates and attaches hidden comment IDs to expand buttons
func attachHiddenCommentIDs(section *gitdiff.DiffSection, lineComments map[int64][]*issues_model.Comment) {
for _, line := range section.Lines {
gitdiff.FillHiddenCommentIDsForDiffLine(line, lineComments)
}
}
// ExcerptBlob render blob excerpt contents
func ExcerptBlob(ctx *context.Context) {
commitID := ctx.PathParam("sha")
opts := gitdiff.BlobExcerptOptions{
LastLeft: ctx.FormInt("last_left"),
LastRight: ctx.FormInt("last_right"),
LeftIndex: ctx.FormInt("left"),
RightIndex: ctx.FormInt("right"),
LeftHunkSize: ctx.FormInt("left_hunk_size"),
RightHunkSize: ctx.FormInt("right_hunk_size"),
Direction: ctx.FormString("direction"),
Language: ctx.FormString("filelang"),
}
filePath := ctx.FormString("path")
gitRepo := ctx.Repo.GitRepo
diffBlobExcerptData := &gitdiff.DiffBlobExcerptData{
BaseLink: ctx.Repo.RepoLink + "/blob_excerpt",
DiffStyle: GetDiffViewStyle(ctx),
AfterCommitID: commitID,
}
if ctx.Data["PageIsWiki"] == true {
var err error
gitRepo, err = gitrepo.RepositoryFromRequestContextOrOpen(ctx, ctx.Repo.Repository.WikiStorageRepo())
if err != nil {
ctx.ServerError("OpenRepository", err)
return
}
diffBlobExcerptData.BaseLink = ctx.Repo.RepoLink + "/wiki/blob_excerpt"
}
commit, err := gitRepo.GetCommit(commitID)
if err != nil {
ctx.ServerError("GetCommit", err)
return
}
blob, err := commit.Tree.GetBlobByPath(filePath)
if err != nil {
ctx.ServerError("GetBlobByPath", err)
return
}
reader, err := blob.DataAsync()
if err != nil {
ctx.ServerError("DataAsync", err)
return
}
defer reader.Close()
section, err := gitdiff.BuildBlobExcerptDiffSection(filePath, reader, opts)
if err != nil {
ctx.ServerError("BuildBlobExcerptDiffSection", err)
return
}
diffBlobExcerptData.PullIssueIndex = ctx.FormInt64("pull_issue_index")
if diffBlobExcerptData.PullIssueIndex > 0 {
if !ctx.Repo.Permission.CanRead(unit.TypePullRequests) {
ctx.NotFound(nil)
return
}
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, diffBlobExcerptData.PullIssueIndex)
if err != nil {
log.Error("GetIssueByIndex error: %v", err)
} else if issue.IsPull {
// FIXME: DIFF-CONVERSATION-DATA: the following data assignment is fragile
ctx.Data["Issue"] = issue
ctx.Data["CanBlockUser"] = func(blocker, blockee *user_model.User) bool {
return user_service.CanBlockUser(ctx, ctx.Doer, blocker, blockee)
}
// and "diff/comment_form.tmpl" (reply comment) needs them
ctx.Data["PageIsPullFiles"] = true
ctx.Data["AfterCommitID"] = diffBlobExcerptData.AfterCommitID
allComments, err := issues_model.FetchCodeComments(ctx, issue, ctx.Doer, ctx.FormBool("show_outdated"))
if err != nil {
log.Error("FetchCodeComments error: %v", err)
} else {
if lineComments, ok := allComments[filePath]; ok {
attachCommentsToLines(section, lineComments)
attachHiddenCommentIDs(section, lineComments)
}
}
}
}
ctx.Data["section"] = section
ctx.Data["FileNameHash"] = git.HashFilePathForWebUI(filePath)
ctx.Data["DiffBlobExcerptData"] = diffBlobExcerptData
ctx.HTML(http.StatusOK, tplBlobExcerpt)
}
+126
View File
@@ -0,0 +1,126 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package repo
import (
"strings"
"testing"
asymkey_model "gitea.dev/models/asymkey"
git_model "gitea.dev/models/git"
issues_model "gitea.dev/models/issues"
user_model "gitea.dev/models/user"
"gitea.dev/modules/git"
"gitea.dev/modules/setting"
git_service "gitea.dev/services/git"
"gitea.dev/services/gitdiff"
"github.com/stretchr/testify/assert"
)
func TestAttachCommentsToLines(t *testing.T) {
section := &gitdiff.DiffSection{
Lines: []*gitdiff.DiffLine{
{LeftIdx: 5, RightIdx: 10},
{LeftIdx: 6, RightIdx: 11},
},
}
lineComments := map[int64][]*issues_model.Comment{
-5: {{ID: 100, CreatedUnix: 1000}}, // left side comment
10: {{ID: 200, CreatedUnix: 2000}}, // right side comment
11: {{ID: 300, CreatedUnix: 1500}, {ID: 301, CreatedUnix: 2500}}, // multiple comments
}
attachCommentsToLines(section, lineComments)
// First line should have left and right comments
assert.Len(t, section.Lines[0].Comments, 2)
assert.Equal(t, int64(100), section.Lines[0].Comments[0].ID)
assert.Equal(t, int64(200), section.Lines[0].Comments[1].ID)
// Second line should have two comments, sorted by creation time
assert.Len(t, section.Lines[1].Comments, 2)
assert.Equal(t, int64(300), section.Lines[1].Comments[0].ID)
assert.Equal(t, int64(301), section.Lines[1].Comments[1].ID)
}
func TestNewPullRequestTitleContent(t *testing.T) {
ci := &git_service.CompareInfo{HeadRef: "refs/heads/head-branch"}
mockCommit := func(msg string) *git_model.SignCommitWithStatuses {
return &git_model.SignCommitWithStatuses{
SignCommit: &asymkey_model.SignCommit{
UserCommit: &user_model.UserCommit{
Commit: &git.Commit{
CommitMessage: git.CommitMessage{MessageRaw: msg},
},
},
},
}
}
// no commit
title, content := prepareNewPullRequestTitleContent(ci, nil, setting.RepoPRTitleSourceAuto)
assert.Equal(t, "Head branch", title)
assert.Empty(t, content)
title, content = prepareNewPullRequestTitleContent(ci, nil, setting.RepoPRTitleSourceFirstCommit)
assert.Equal(t, "Head branch", title)
assert.Empty(t, content)
// single commit
title, content = prepareNewPullRequestTitleContent(ci, []*git_model.SignCommitWithStatuses{mockCommit("single-commit-title\nbody")}, setting.RepoPRTitleSourceAuto)
assert.Equal(t, "single-commit-title", title)
assert.Equal(t, "body", content)
title, content = prepareNewPullRequestTitleContent(ci, []*git_model.SignCommitWithStatuses{mockCommit("single-commit-title\nbody")}, setting.RepoPRTitleSourceFirstCommit)
assert.Equal(t, "single-commit-title", title)
assert.Equal(t, "body", content)
// multiple commits
commits := []*git_model.SignCommitWithStatuses{
// ordered from newest to oldest
mockCommit("title2\nbody2"),
mockCommit("title1\nbody1"),
}
title, content = prepareNewPullRequestTitleContent(ci, commits, setting.RepoPRTitleSourceAuto)
assert.Equal(t, "Head branch", title)
assert.Empty(t, content)
title, content = prepareNewPullRequestTitleContent(ci, commits, setting.RepoPRTitleSourceFirstCommit)
assert.Equal(t, "title1", title)
assert.Empty(t, content)
// title string handling
title, content = prepareNewPullRequestTitleContent(ci, []*git_model.SignCommitWithStatuses{mockCommit("title-" + strings.Repeat("a", 255))}, setting.RepoPRTitleSourceFirstCommit)
assert.Equal(t, "title-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa…", title)
assert.Equal(t, "…aaaaaaaaa\n", 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) {
cases := []struct {
branch string
want string
}{
{"fix/the-bug", "Fix/the bug"},
{"Already-Capitalized", "Already capitalized"},
{"ALL-CAPS-BRANCH", "All caps branch"},
{"FixHTMLBug", "Fix html bug"},
{"MixedCase-Name", "Mixed case name"},
{"fooBar-baz", "Foo bar baz"},
{"foo/BAR", "Foo/bar"},
{"_leading-underscore", "Leading underscore"},
{"CamelCase", "Camel case"},
{"foo--double-dash", "Foo double dash"},
{"123-fix", "123 fix"},
}
for _, c := range cases {
assert.Equal(t, c.want, autoTitleFromBranchName(c.branch), "branch: %q", c.branch)
}
}
+38
View File
@@ -0,0 +1,38 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package repo
import (
"errors"
"net/http"
"gitea.dev/modules/templates"
"gitea.dev/services/context"
contributors_service "gitea.dev/services/repository"
)
const (
tplContributors templates.TplName = "repo/activity"
)
// Contributors render the page to show repository contributors graph
func Contributors(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("repo.activity.navbar.contributors")
ctx.Data["PageIsActivity"] = true
ctx.Data["PageIsContributors"] = true
ctx.HTML(http.StatusOK, tplContributors)
}
// ContributorsData renders JSON of contributors along with their weekly commit statistics
func ContributorsData(ctx *context.Context) {
if contributorStats, err := contributors_service.GetContributorStats(ctx, ctx.Cache, ctx.Repo.Repository, ctx.Repo.Repository.DefaultBranch); err != nil {
if errors.Is(err, contributors_service.ErrAwaitGeneration) {
ctx.Status(http.StatusAccepted)
return
}
ctx.ServerError("GetContributorStats", err)
} else {
ctx.JSON(http.StatusOK, contributorStats)
}
}
+165
View File
@@ -0,0 +1,165 @@
// Copyright 2014 The Gogs Authors. All rights reserved.
// Copyright 2018 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package repo
import (
"time"
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 {
context.CheckRepoScopedToken(ctx, ctx.Repo.Repository, auth_model.Read)
return !ctx.Written()
}
// ServeBlobOrLFS download a git.Blob redirecting to LFS if necessary
func ServeBlobOrLFS(ctx *context.Context, blob *git.Blob, lastModified *time.Time) error {
if httpcache.HandleGenericETagPrivateCache(ctx.Req, ctx.Resp, `"`+blob.ID.String()+`"`, lastModified) {
return nil
}
lfsPointerBuf, err := blob.GetBlobBytes(lfs.MetaFileMaxSize)
if err != nil {
return err
}
pointer, _ := lfs.ReadPointerFromBuffer(lfsPointerBuf)
if pointer.IsValid() {
meta, _ := git_model.GetLFSMetaObjectByOid(ctx, ctx.Repo.Repository.ID, pointer.Oid)
if meta == nil {
return common.ServeBlob(ctx.Base, ctx.Repo.Repository, ctx.Repo.TreePath, blob, lastModified)
}
if httpcache.HandleGenericETagPrivateCache(ctx.Req, ctx.Resp, `"`+pointer.Oid+`"`, meta.UpdatedUnix.AsTimePtr()) {
return nil
}
if setting.LFS.Storage.ServeDirect() {
// If we have a signed url (S3, object storage, blob storage), redirect to this directly.
u, err := storage.LFS.ServeDirectURL(pointer.RelativePath(), blob.Name(), ctx.Req.Method, nil)
if u != nil && err == nil {
ctx.Redirect(u.String())
return nil
}
}
lfsDataFile, err := lfs.ReadMetaObject(meta.Pointer)
if err != nil {
return err
}
defer lfsDataFile.Close()
httplib.ServeUserContentByFile(ctx.Req, ctx.Resp, lfsDataFile, httplib.ServeHeaderOptions{Filename: ctx.Repo.TreePath})
return nil
}
return common.ServeBlob(ctx.Base, ctx.Repo.Repository, ctx.Repo.TreePath, blob, lastModified)
}
func getBlobForEntry(ctx *context.Context) (*git.Blob, *time.Time) {
entry, err := ctx.Repo.Commit.GetTreeEntryByPath(ctx.Repo.TreePath)
if err != nil {
if git.IsErrNotExist(err) {
ctx.NotFound(err)
} else {
ctx.ServerError("GetTreeEntryByPath", err)
}
return nil, nil
}
if entry.IsDir() || entry.IsSubModule() {
ctx.NotFound(nil)
return nil, nil
}
latestCommit, err := ctx.Repo.GitRepo.GetTreePathLatestCommit(ctx.Repo.Commit.ID.String(), ctx.Repo.TreePath)
if err != nil {
ctx.ServerError("GetTreePathLatestCommit", err)
return nil, nil
}
lastModified := &latestCommit.Committer.When
return entry.Blob(), lastModified
}
// SingleDownload download a file by repos path
func SingleDownload(ctx *context.Context) {
if !checkDownloadTokenScope(ctx) {
return
}
blob, lastModified := getBlobForEntry(ctx)
if blob == nil {
return
}
if err := common.ServeBlob(ctx.Base, ctx.Repo.Repository, ctx.Repo.TreePath, blob, lastModified); err != nil {
ctx.ServerError("ServeBlob", err)
}
}
// SingleDownloadOrLFS download a file by repos path redirecting to LFS if necessary
func SingleDownloadOrLFS(ctx *context.Context) {
if !checkDownloadTokenScope(ctx) {
return
}
blob, lastModified := getBlobForEntry(ctx)
if blob == nil {
return
}
if err := ServeBlobOrLFS(ctx, blob, lastModified); err != nil {
ctx.ServerError("ServeBlobOrLFS", err)
}
}
// DownloadByID download a file by sha1 ID
func DownloadByID(ctx *context.Context) {
if !checkDownloadTokenScope(ctx) {
return
}
blob, err := ctx.Repo.GitRepo.GetBlob(ctx.PathParam("sha"))
if err != nil {
if git.IsErrNotExist(err) {
ctx.NotFound(nil)
} else {
ctx.ServerError("GetBlob", err)
}
return
}
if err = common.ServeBlob(ctx.Base, ctx.Repo.Repository, ctx.Repo.TreePath, blob, nil); err != nil {
ctx.ServerError("ServeBlob", err)
}
}
// DownloadByIDOrLFS download a file by sha1 ID taking account of LFS
func DownloadByIDOrLFS(ctx *context.Context) {
if !checkDownloadTokenScope(ctx) {
return
}
blob, err := ctx.Repo.GitRepo.GetBlob(ctx.PathParam("sha"))
if err != nil {
if git.IsErrNotExist(err) {
ctx.NotFound(nil)
} else {
ctx.ServerError("GetBlob", err)
}
return
}
if err = ServeBlobOrLFS(ctx, blob, nil); err != nil {
ctx.ServerError("ServeBlob", err)
}
}
+484
View File
@@ -0,0 +1,484 @@
// Copyright 2016 The Gogs Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package repo
import (
"bytes"
"fmt"
"io"
"net/http"
"path"
"strings"
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 (
tplEditFile templates.TplName = "repo/editor/edit"
tplEditDiffPreview templates.TplName = "repo/editor/diff_preview"
tplDeleteFile templates.TplName = "repo/editor/delete"
tplUploadFile templates.TplName = "repo/editor/upload"
tplPatchFile templates.TplName = "repo/editor/patch"
tplCherryPick templates.TplName = "repo/editor/cherry_pick"
editorCommitChoiceDirect string = "direct"
editorCommitChoiceNewBranch string = "commit-to-new-branch"
)
func prepareEditorPage(ctx *context.Context, editorAction string) *context.CommitFormOptions {
prepareHomeTreeSideBarSwitch(ctx)
return prepareEditorPageFormOptions(ctx, editorAction)
}
func prepareEditorPageFormOptions(ctx *context.Context, editorAction string) *context.CommitFormOptions {
cleanedTreePath := files_service.CleanGitTreePath(ctx.Repo.TreePath)
if cleanedTreePath != ctx.Repo.TreePath {
redirectTo := fmt.Sprintf("%s/%s/%s/%s", ctx.Repo.RepoLink, editorAction, util.PathEscapeSegments(ctx.Repo.BranchName), util.PathEscapeSegments(cleanedTreePath))
if ctx.Req.URL.RawQuery != "" {
redirectTo += "?" + ctx.Req.URL.RawQuery
}
ctx.Redirect(redirectTo)
return nil
}
commitFormOptions, err := context.PrepareCommitFormOptions(ctx, ctx.Doer, ctx.Repo.Repository, ctx.Repo.Permission, ctx.Repo.RefFullName)
if err != nil {
ctx.ServerError("PrepareCommitFormOptions", err)
return nil
}
if commitFormOptions.NeedFork {
ForkToEdit(ctx)
return nil
}
if commitFormOptions.WillSubmitToFork && !commitFormOptions.TargetRepo.CanEnableEditor() {
ctx.Data["NotFoundPrompt"] = ctx.Locale.Tr("repo.editor.fork_not_editable")
ctx.NotFound(nil)
}
ctx.Data["BranchLink"] = ctx.Repo.RepoLink + "/src/" + ctx.Repo.RefTypeNameSubURL()
ctx.Data["TreePath"] = ctx.Repo.TreePath
ctx.Data["CommitFormOptions"] = commitFormOptions
// for online editor
ctx.Data["IsEditingFileOnly"] = ctx.FormString("return_uri") != ""
ctx.Data["ReturnURI"] = ctx.FormString("return_uri")
// form fields
ctx.Data["commit_summary"] = ""
ctx.Data["commit_message"] = ""
ctx.Data["commit_choice"] = util.Iif(commitFormOptions.CanCommitToBranch, editorCommitChoiceDirect, editorCommitChoiceNewBranch)
ctx.Data["new_branch_name"] = getUniquePatchBranchName(ctx, ctx.Doer.LowerName, commitFormOptions.TargetRepo)
ctx.Data["last_commit"] = ctx.Repo.CommitID
return commitFormOptions
}
func prepareTreePathFieldsAndPaths(ctx *context.Context, treePath string) {
// show the tree path fields in the "breadcrumb" and help users to edit the target tree path
ctx.Data["TreeNames"], ctx.Data["TreePaths"] = getParentTreeFields(strings.TrimPrefix(treePath, "/"))
}
type preparedEditorCommitForm[T any] struct {
form T
commonForm *forms.CommitCommonForm
CommitFormOptions *context.CommitFormOptions
OldBranchName string
NewBranchName string
GitCommitter *files_service.IdentityOptions
}
func (f *preparedEditorCommitForm[T]) GetCommitMessage(defaultCommitMessage string) string {
commitMessage := util.IfZero(strings.TrimSpace(f.commonForm.CommitSummary), defaultCommitMessage)
if body := strings.TrimSpace(f.commonForm.CommitMessage); body != "" {
commitMessage += "\n\n" + body
}
return commitMessage
}
func prepareEditorCommitSubmittedForm[T forms.CommitCommonFormInterface](ctx *context.Context) *preparedEditorCommitForm[T] {
form := web.GetForm(ctx).(T)
if ctx.HasError() {
ctx.JSONError(ctx.GetErrMsg())
return nil
}
commonForm := form.GetCommitCommonForm()
commonForm.TreePath = files_service.CleanGitTreePath(commonForm.TreePath)
commitFormOptions, err := context.PrepareCommitFormOptions(ctx, ctx.Doer, ctx.Repo.Repository, ctx.Repo.Permission, ctx.Repo.RefFullName)
if err != nil {
ctx.ServerError("PrepareCommitFormOptions", err)
return nil
}
if commitFormOptions.NeedFork {
// It shouldn't happen, because we should have done the checks in the "GET" request. But just in case.
ctx.JSONError(ctx.Locale.TrString("error.not_found"))
return nil
}
// check commit behavior
fromBaseBranch := ctx.FormString("from_base_branch")
commitToNewBranch := commonForm.CommitChoice == editorCommitChoiceNewBranch || fromBaseBranch != ""
targetBranchName := util.Iif(commitToNewBranch, commonForm.NewBranchName, ctx.Repo.BranchName)
if targetBranchName == ctx.Repo.BranchName && !commitFormOptions.CanCommitToBranch {
ctx.JSONError(ctx.Tr("repo.editor.cannot_commit_to_protected_branch", targetBranchName))
return nil
}
if !issues.CanMaintainerWriteToBranch(ctx, ctx.Repo.Permission, targetBranchName, ctx.Doer) {
ctx.NotFound(nil)
return nil
}
// Committer user info
gitCommitter, valid := WebGitOperationGetCommitChosenEmailIdentity(ctx, commonForm.CommitEmail)
if !valid {
ctx.JSONError(ctx.Tr("repo.editor.invalid_commit_email"))
return nil
}
if commitToNewBranch {
// if target branch exists, we should stop
targetBranchExists, err := git_model.IsBranchExist(ctx, commitFormOptions.TargetRepo.ID, targetBranchName)
if err != nil {
ctx.ServerError("IsBranchExist", err)
return nil
} else if targetBranchExists {
if fromBaseBranch != "" {
ctx.JSONError(ctx.Tr("repo.editor.fork_branch_exists", targetBranchName))
} else {
ctx.JSONError(ctx.Tr("repo.editor.branch_already_exists", targetBranchName))
}
return nil
}
}
oldBranchName := ctx.Repo.BranchName
if fromBaseBranch != "" {
err = editorPushBranchToForkedRepository(ctx, ctx.Doer, ctx.Repo.Repository.BaseRepo, fromBaseBranch, commitFormOptions.TargetRepo, targetBranchName)
if err != nil {
log.Error("Unable to editorPushBranchToForkedRepository: %v", err)
ctx.JSONError(ctx.Tr("repo.editor.fork_failed_to_push_branch", targetBranchName))
return nil
}
// we have pushed the base branch as the new branch, now we need to commit the changes directly to the new branch
oldBranchName = targetBranchName
}
return &preparedEditorCommitForm[T]{
form: form,
commonForm: commonForm,
CommitFormOptions: commitFormOptions,
OldBranchName: oldBranchName,
NewBranchName: targetBranchName,
GitCommitter: gitCommitter,
}
}
// redirectForCommitChoice redirects after committing the edit to a branch
func redirectForCommitChoice[T any](ctx *context.Context, parsed *preparedEditorCommitForm[T], treePath string) {
// when editing a file in a PR, it should return to the origin location
if returnURI := ctx.FormString("return_uri"); returnURI != "" && httplib.IsCurrentGiteaSiteURL(ctx, returnURI) {
ctx.JSONRedirect(returnURI)
return
}
if parsed.commonForm.CommitChoice == editorCommitChoiceNewBranch {
// Redirect to a pull request when possible
redirectToPullRequest := false
repo, baseBranch, headBranch := ctx.Repo.Repository, parsed.OldBranchName, parsed.NewBranchName
if ctx.Repo.Repository.IsFork && parsed.CommitFormOptions.CanCreateBasePullRequest {
redirectToPullRequest = true
baseBranch = repo.BaseRepo.DefaultBranch
headBranch = repo.Owner.Name + "/" + repo.Name + ":" + headBranch
repo = repo.BaseRepo
} else if repo.UnitEnabled(ctx, unit.TypePullRequests) {
redirectToPullRequest = true
}
if redirectToPullRequest {
ctx.JSONRedirect(repo.Link() + "/compare/" + util.PathEscapeSegments(baseBranch) + "..." + util.PathEscapeSegments(headBranch))
return
}
}
// redirect to the newly updated file
redirectTo := ctx.Repo.RepoLink + "/src/branch/" + util.PathEscapeSegments(parsed.NewBranchName) + "/" + util.PathEscapeSegments(treePath)
redirectTo = strings.TrimSuffix(redirectTo, "/")
ctx.JSONRedirect(redirectTo)
}
func editFileOpenExisting(ctx *context.Context) (prefetch []byte, dataRc io.ReadCloser, fInfo *fileInfo) {
entry, err := ctx.Repo.Commit.GetTreeEntryByPath(ctx.Repo.TreePath)
if err != nil {
HandleGitError(ctx, "GetTreeEntryByPath", err)
return nil, nil, nil
}
// No way to edit a directory online.
if entry.IsDir() {
ctx.NotFound(nil)
return nil, nil, nil
}
blob := entry.Blob()
buf, dataRc, fInfo, err := getFileReader(ctx, ctx.Repo.Repository.ID, blob)
if err != nil {
if git.IsErrNotExist(err) {
ctx.NotFound(err)
} else {
ctx.ServerError("getFileReader", err)
}
return nil, nil, nil
}
if fInfo.isLFSFile() {
lfsLock, err := git_model.GetTreePathLock(ctx, ctx.Repo.Repository.ID, ctx.Repo.TreePath)
if err != nil {
_ = dataRc.Close()
ctx.ServerError("GetTreePathLock", err)
return nil, nil, nil
} else if lfsLock != nil && lfsLock.OwnerID != ctx.Doer.ID {
_ = dataRc.Close()
ctx.NotFound(nil)
return nil, nil, nil
}
}
return buf, dataRc, fInfo
}
func EditFile(ctx *context.Context) {
editorAction := ctx.PathParam("editor_action")
isNewFile := editorAction == "_new"
ctx.Data["IsNewFile"] = isNewFile
// Check if the filename (and additional path) is specified in the querystring
// (filename is a misnomer, but kept for compatibility with GitHub)
urlQuery := ctx.Req.URL.Query()
queryFilename := urlQuery.Get("filename")
if queryFilename != "" {
newTreePath := path.Join(ctx.Repo.TreePath, queryFilename)
redirectTo := fmt.Sprintf("%s/%s/%s/%s", ctx.Repo.RepoLink, editorAction, util.PathEscapeSegments(ctx.Repo.BranchName), util.PathEscapeSegments(newTreePath))
urlQuery.Del("filename")
if newQueryParams := urlQuery.Encode(); newQueryParams != "" {
redirectTo += "?" + newQueryParams
}
ctx.Redirect(redirectTo)
return
}
// on the "New File" page, we should add an empty path field to make end users could input a new name
prepareTreePathFieldsAndPaths(ctx, util.Iif(isNewFile, ctx.Repo.TreePath+"/", ctx.Repo.TreePath))
prepareEditorPage(ctx, editorAction)
if ctx.Written() {
return
}
if !isNewFile {
prefetch, dataRc, fInfo := editFileOpenExisting(ctx)
if ctx.Written() {
return
}
defer dataRc.Close()
ctx.Data["FileSize"] = fInfo.blobOrLfsSize
// Only some file types are editable online as text.
if fInfo.isLFSFile() {
ctx.Data["NotEditableReason"] = ctx.Tr("repo.editor.cannot_edit_lfs_files")
} else if !fInfo.st.IsRepresentableAsText() {
ctx.Data["NotEditableReason"] = ctx.Tr("repo.editor.cannot_edit_non_text_files")
} else if fInfo.blobOrLfsSize >= setting.UI.MaxDisplayFileSize {
ctx.Data["NotEditableReason"] = ctx.Tr("repo.editor.cannot_edit_too_large_file")
}
if ctx.Data["NotEditableReason"] == nil {
buf, err := io.ReadAll(io.MultiReader(bytes.NewReader(prefetch), dataRc))
if err != nil {
ctx.ServerError("ReadAll", err)
return
}
ctx.Data["FileContent"] = string(charset.ToUTF8(buf, charset.ConvertOpts{KeepBOM: true, ErrorReturnOrigin: true}))
}
}
editorConfig := getCodeEditorConfigByEditorconfig(ctx, ctx.Repo.TreePath)
editorConfig.Autofocus = !isNewFile
if isNewFile {
editorConfig.Filename = ""
}
ctx.Data["CodeEditorConfig"] = editorConfig
ctx.HTML(http.StatusOK, tplEditFile)
}
func EditFilePost(ctx *context.Context) {
editorAction := ctx.PathParam("editor_action")
isNewFile := editorAction == "_new"
parsed := prepareEditorCommitSubmittedForm[*forms.EditRepoFileForm](ctx)
if ctx.Written() {
return
}
defaultCommitMessage := util.Iif(isNewFile, ctx.Locale.TrString("repo.editor.add", parsed.form.TreePath), ctx.Locale.TrString("repo.editor.update", parsed.form.TreePath))
var operation string
if isNewFile {
operation = "create"
} else if parsed.form.Content.Has() {
// The form content only has data if the file is representable as text, is not too large and not in lfs.
operation = "update"
} else if ctx.Repo.TreePath != parsed.form.TreePath {
// If it doesn't have data, the only possible operation is a "rename"
operation = "rename"
} else {
// It should never happen, just in case
ctx.JSONError(ctx.Tr("error.occurred"))
return
}
_, err := files_service.ChangeRepoFiles(ctx, ctx.Repo.Repository, ctx.Doer, &files_service.ChangeRepoFilesOptions{
LastCommitID: parsed.form.LastCommit,
OldBranch: parsed.OldBranchName,
NewBranch: parsed.NewBranchName,
Message: parsed.GetCommitMessage(defaultCommitMessage),
Files: []*files_service.ChangeRepoFile{
{
Operation: operation,
FromTreePath: ctx.Repo.TreePath,
TreePath: parsed.form.TreePath,
ContentReader: strings.NewReader(strings.ReplaceAll(parsed.form.Content.Value(), "\r", "")),
},
},
Signoff: parsed.form.Signoff,
Author: parsed.GitCommitter,
Committer: parsed.GitCommitter,
})
if err != nil {
editorHandleFileOperationError(ctx, parsed.NewBranchName, err)
return
}
redirectForCommitChoice(ctx, parsed, parsed.form.TreePath)
}
// DeleteFile render delete file page
func DeleteFile(ctx *context.Context) {
prepareEditorPage(ctx, "_delete")
if ctx.Written() {
return
}
ctx.Data["PageIsDelete"] = true
prepareTreePathFieldsAndPaths(ctx, ctx.Repo.TreePath)
ctx.HTML(http.StatusOK, tplDeleteFile)
}
// DeleteFilePost response for deleting file or directory
func DeleteFilePost(ctx *context.Context) {
parsed := prepareEditorCommitSubmittedForm[*forms.DeleteRepoFileForm](ctx)
if ctx.Written() {
return
}
treePath := ctx.Repo.TreePath
if treePath == "" {
ctx.JSONError("cannot delete root directory") // it should not happen unless someone is trying to be malicious
return
}
// Check if the path is a directory
entry, err := ctx.Repo.Commit.GetTreeEntryByPath(treePath)
if err != nil {
ctx.NotFoundOrServerError("GetTreeEntryByPath", git.IsErrNotExist, err)
return
}
var commitMessage string
if entry.IsDir() {
commitMessage = parsed.GetCommitMessage(ctx.Locale.TrString("repo.editor.delete_directory", treePath))
} else {
commitMessage = parsed.GetCommitMessage(ctx.Locale.TrString("repo.editor.delete", treePath))
}
_, err = files_service.ChangeRepoFiles(ctx, ctx.Repo.Repository, ctx.Doer, &files_service.ChangeRepoFilesOptions{
LastCommitID: parsed.form.LastCommit,
OldBranch: parsed.OldBranchName,
NewBranch: parsed.NewBranchName,
Files: []*files_service.ChangeRepoFile{
{
Operation: "delete",
TreePath: treePath,
DeleteRecursively: true,
},
},
Message: commitMessage,
Signoff: parsed.form.Signoff,
Author: parsed.GitCommitter,
Committer: parsed.GitCommitter,
})
if err != nil {
editorHandleFileOperationError(ctx, parsed.NewBranchName, err)
return
}
if entry.IsDir() {
ctx.Flash.Success(ctx.Tr("repo.editor.directory_delete_success", treePath))
} else {
ctx.Flash.Success(ctx.Tr("repo.editor.file_delete_success", treePath))
}
redirectTreePath := getClosestParentWithFiles(ctx.Repo.GitRepo, parsed.NewBranchName, treePath)
redirectForCommitChoice(ctx, parsed, redirectTreePath)
}
func UploadFile(ctx *context.Context) {
ctx.Data["PageIsUpload"] = true
prepareTreePathFieldsAndPaths(ctx, ctx.Repo.TreePath)
opts := prepareEditorPage(ctx, "_upload")
if ctx.Written() {
return
}
upload.AddUploadContextForRepo(ctx, opts.TargetRepo)
ctx.HTML(http.StatusOK, tplUploadFile)
}
func UploadFilePost(ctx *context.Context) {
parsed := prepareEditorCommitSubmittedForm[*forms.UploadRepoFileForm](ctx)
if ctx.Written() {
return
}
defaultCommitMessage := ctx.Locale.TrString("repo.editor.upload_files_to_dir", util.IfZero(parsed.form.TreePath, "/"))
err := files_service.UploadRepoFiles(ctx, ctx.Repo.Repository, ctx.Doer, &files_service.UploadRepoFileOptions{
LastCommitID: parsed.form.LastCommit,
OldBranch: parsed.OldBranchName,
NewBranch: parsed.NewBranchName,
TreePath: parsed.form.TreePath,
Message: parsed.GetCommitMessage(defaultCommitMessage),
Files: parsed.form.Files,
Signoff: parsed.form.Signoff,
Author: parsed.GitCommitter,
Committer: parsed.GitCommitter,
})
if err != nil {
editorHandleFileOperationError(ctx, parsed.NewBranchName, err)
return
}
redirectForCommitChoice(ctx, parsed, parsed.form.TreePath)
}
+52
View File
@@ -0,0 +1,52 @@
// Copyright 2021 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package repo
import (
"net/http"
"strings"
"gitea.dev/modules/util"
"gitea.dev/services/context"
"gitea.dev/services/forms"
"gitea.dev/services/repository/files"
)
func NewDiffPatch(ctx *context.Context) {
prepareEditorPage(ctx, "_diffpatch")
if ctx.Written() {
return
}
ctx.Data["PageIsPatch"] = true
ctx.Data["CodeEditorConfig"] = CodeEditorConfig{Filename: "diff.patch"}
ctx.HTML(http.StatusOK, tplPatchFile)
}
// NewDiffPatchPost response for sending patch page
func NewDiffPatchPost(ctx *context.Context) {
parsed := prepareEditorCommitSubmittedForm[*forms.EditRepoFileForm](ctx)
if ctx.Written() {
return
}
defaultCommitMessage := ctx.Locale.TrString("repo.editor.patch")
_, err := files.ApplyDiffPatch(ctx, ctx.Repo.Repository, ctx.Doer, &files.ApplyDiffPatchOptions{
LastCommitID: parsed.form.LastCommit,
OldBranch: parsed.OldBranchName,
NewBranch: parsed.NewBranchName,
Message: parsed.GetCommitMessage(defaultCommitMessage),
Content: strings.ReplaceAll(parsed.form.Content.Value(), "\r\n", "\n"),
Author: parsed.GitCommitter,
Committer: parsed.GitCommitter,
})
if err != nil {
err = util.ErrorWrapTranslatable(err, "repo.editor.fail_to_apply_patch")
}
if err != nil {
editorHandleFileOperationError(ctx, parsed.NewBranchName, err)
return
}
redirectForCommitChoice(ctx, parsed, parsed.form.TreePath)
}
+84
View File
@@ -0,0 +1,84 @@
// Copyright 2021 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package repo
import (
"bytes"
"net/http"
"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) {
prepareEditorPage(ctx, "_cherrypick")
if ctx.Written() {
return
}
fromCommitID := ctx.PathParam("sha")
ctx.Data["FromCommitID"] = fromCommitID
cherryPickCommit, err := ctx.Repo.GitRepo.GetCommit(fromCommitID)
if err != nil {
HandleGitError(ctx, "GetCommit", err)
return
}
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.MessageUTF8()
} else {
ctx.Data["CherryPickType"] = "cherry-pick"
ctx.Data["commit_summary"], ctx.Data["commit_message"] = cherryPickCommit.MessageTitle(), cherryPickCommit.MessageBody()
}
ctx.HTML(http.StatusOK, tplCherryPick)
}
func CherryPickPost(ctx *context.Context) {
fromCommitID := ctx.PathParam("sha")
parsed := prepareEditorCommitSubmittedForm[*forms.CherryPickForm](ctx)
if ctx.Written() {
return
}
defaultCommitMessage := util.Iif(parsed.form.Revert, ctx.Locale.TrString("repo.commit.revert-header", fromCommitID), ctx.Locale.TrString("repo.commit.cherry-pick-header", fromCommitID))
opts := &files.ApplyDiffPatchOptions{
LastCommitID: parsed.form.LastCommit,
OldBranch: parsed.OldBranchName,
NewBranch: parsed.NewBranchName,
Message: parsed.GetCommitMessage(defaultCommitMessage),
Author: parsed.GitCommitter,
Committer: parsed.GitCommitter,
}
// First try the simple plain read-tree -m approach
opts.Content = fromCommitID
if _, err := files.CherryPick(ctx, ctx.Repo.Repository, ctx.Doer, parsed.form.Revert, opts); err != nil {
// Drop through to the "apply" method
buf := &bytes.Buffer{}
if parsed.form.Revert {
err = gitrepo.GetReverseRawDiff(ctx, ctx.Repo.Repository, fromCommitID, buf)
} else {
err = git.GetRawDiff(ctx.Repo.GitRepo, fromCommitID, git.RawDiffPatch, buf)
}
if err == nil {
opts.Content = buf.String()
_, err = files.ApplyDiffPatch(ctx, ctx.Repo.Repository, ctx.Doer, opts)
if err != nil {
err = util.ErrorWrapTranslatable(err, "repo.editor.fail_to_apply_patch")
}
}
if err != nil {
editorHandleFileOperationError(ctx, parsed.NewBranchName, err)
return
}
}
redirectForCommitChoice(ctx, parsed, parsed.form.TreePath)
}
+82
View File
@@ -0,0 +1,82 @@
// Copyright 2025 Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package repo
import (
"errors"
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) {
if errors.As(v, &e) {
return e, true
}
return e, false
}
func editorHandleFileOperationErrorRender(ctx *context_service.Context, message, summary, details string) {
flashError, err := ctx.RenderToHTML(tplAlertDetails, map[string]any{
"Message": message,
"Summary": summary,
"Details": utils.EscapeFlashErrorString(details),
})
if err == nil {
ctx.JSONError(flashError)
} else {
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
}
}
func editorHandleFileOperationError(ctx *context_service.Context, targetBranchName string, err error) {
if errAs := util.ErrorAsTranslatable(err); errAs != nil {
ctx.JSONError(errAs.Translate(ctx.Locale))
} else if errAs, ok := errorAs[git.ErrNotExist](err); ok {
ctx.JSONError(ctx.Tr("repo.editor.file_modifying_no_longer_exists", errAs.RelPath))
} else if errAs, ok := errorAs[git_model.ErrLFSFileLocked](err); ok {
ctx.JSONError(ctx.Tr("repo.editor.upload_file_is_locked", errAs.Path, errAs.UserName))
} else if errAs, ok := errorAs[files_service.ErrFilenameInvalid](err); ok {
ctx.JSONError(ctx.Tr("repo.editor.filename_is_invalid", errAs.Path))
} else if errAs, ok := errorAs[files_service.ErrFilePathInvalid](err); ok {
switch errAs.Type {
case git.EntryModeSymlink:
ctx.JSONError(ctx.Tr("repo.editor.file_is_a_symlink", errAs.Path))
case git.EntryModeTree:
ctx.JSONError(ctx.Tr("repo.editor.filename_is_a_directory", errAs.Path))
case git.EntryModeBlob:
ctx.JSONError(ctx.Tr("repo.editor.directory_is_a_file", errAs.Path))
default:
ctx.JSONError(ctx.Tr("repo.editor.filename_is_invalid", errAs.Path))
}
} else if errAs, ok := errorAs[files_service.ErrRepoFileAlreadyExists](err); ok {
ctx.JSONError(ctx.Tr("repo.editor.file_already_exists", errAs.Path))
} else if errAs, ok := errorAs[git.ErrBranchNotExist](err); ok {
ctx.JSONError(ctx.Tr("repo.editor.branch_does_not_exist", errAs.Name))
} else if errAs, ok := errorAs[git_model.ErrBranchAlreadyExists](err); ok {
ctx.JSONError(ctx.Tr("repo.editor.branch_already_exists", errAs.BranchName))
} else if files_service.IsErrCommitIDDoesNotMatch(err) {
ctx.JSONError(ctx.Tr("repo.editor.commit_id_not_matching"))
} else if files_service.IsErrCommitIDDoesNotMatch(err) || git.IsErrPushOutOfDate(err) {
ctx.JSONError(ctx.Tr("repo.editor.file_changed_while_editing", ctx.Repo.RepoLink+"/compare/"+util.PathEscapeSegments(ctx.Repo.CommitID)+"..."+util.PathEscapeSegments(targetBranchName)))
} else if errAs, ok := errorAs[*git.ErrPushRejected](err); ok {
if errAs.Message == "" {
ctx.JSONError(ctx.Tr("repo.editor.push_rejected_no_message"))
} else {
editorHandleFileOperationErrorRender(ctx, ctx.Locale.TrString("repo.editor.push_rejected"), ctx.Locale.TrString("repo.editor.push_rejected_summary"), errAs.Message)
}
} else if errors.Is(err, util.ErrNotExist) {
ctx.JSONError(ctx.Tr("error.not_found"))
} else {
setting.PanicInDevOrTesting("unclear err %T: %v", err, err)
editorHandleFileOperationErrorRender(ctx, ctx.Locale.TrString("repo.editor.failed_to_commit"), ctx.Locale.TrString("repo.editor.failed_to_commit_summary"), err.Error())
}
}
+31
View File
@@ -0,0 +1,31 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package repo
import (
"net/http"
"gitea.dev/modules/templates"
"gitea.dev/services/context"
repo_service "gitea.dev/services/repository"
)
const tplEditorFork templates.TplName = "repo/editor/fork"
func ForkToEdit(ctx *context.Context) {
ctx.HTML(http.StatusOK, tplEditorFork)
}
func ForkToEditPost(ctx *context.Context) {
ForkRepoTo(ctx, ctx.Doer, repo_service.ForkRepoOptions{
BaseRepo: ctx.Repo.Repository,
Name: getUniqueRepositoryName(ctx, ctx.Doer.ID, ctx.Repo.Repository.Name),
Description: ctx.Repo.Repository.Description,
SingleBranch: ctx.Repo.Repository.DefaultBranch, // maybe we only need the default branch in the fork?
})
if ctx.Written() {
return
}
ctx.JSONRedirect("") // reload the page, the new fork should be editable now
}
+47
View File
@@ -0,0 +1,47 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package repo
import (
"net/http"
"gitea.dev/modules/setting"
"gitea.dev/services/context"
files_service "gitea.dev/services/repository/files"
)
func DiffPreviewPost(ctx *context.Context) {
newContent := ctx.FormString("content")
treePath := files_service.CleanGitTreePath(ctx.Repo.TreePath)
if treePath == "" {
ctx.HTTPError(http.StatusBadRequest, "file name to diff is invalid")
return
}
entry, err := ctx.Repo.Commit.GetTreeEntryByPath(treePath)
if err != nil {
ctx.ServerError("GetTreeEntryByPath", err)
return
} else if entry.IsDir() {
ctx.HTTPError(http.StatusUnprocessableEntity)
return
}
oldContent, err := entry.Blob().GetBlobContent(setting.UI.MaxDisplayFileSize)
if err != nil {
ctx.ServerError("GetBlobContent", err)
return
}
diff, err := files_service.GetDiffPreview(ctx, ctx.Repo.Repository, ctx.Repo.BranchName, treePath, oldContent, newContent)
if err != nil {
ctx.ServerError("GetDiffPreview", err)
return
}
if len(diff.Files) != 0 {
ctx.Data["File"] = diff.Files[0]
}
ctx.HTML(http.StatusOK, tplEditDiffPreview)
}
+31
View File
@@ -0,0 +1,31 @@
// Copyright 2018 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package repo
import (
"testing"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest"
"gitea.dev/modules/gitrepo"
"github.com/stretchr/testify/assert"
)
func TestEditorUtils(t *testing.T) {
unittest.PrepareTestEnv(t)
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
t.Run("getUniquePatchBranchName", func(t *testing.T) {
branchName := getUniquePatchBranchName(t.Context(), "user2", repo)
assert.Equal(t, "user2-patch-1", branchName)
})
t.Run("getClosestParentWithFiles", func(t *testing.T) {
gitRepo, _ := gitrepo.OpenRepository(t.Context(), repo)
defer gitRepo.Close()
treePath := getClosestParentWithFiles(gitRepo, "sub-home-md-img-check", "docs/foo/bar")
assert.Equal(t, "docs", treePath)
treePath = getClosestParentWithFiles(gitRepo, "sub-home-md-img-check", "any/other")
assert.Empty(t, treePath)
})
}
+63
View File
@@ -0,0 +1,63 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package repo
import (
"net/http"
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
func UploadFileToServer(ctx *context.Context) {
file, header, err := ctx.Req.FormFile("file")
if err != nil {
ctx.ServerError("FormFile", err)
return
}
defer file.Close()
buf := make([]byte, 1024)
n, _ := util.ReadAtMost(file, buf)
if n > 0 {
buf = buf[:n]
}
err = upload.Verify(buf, header.Filename, setting.Repository.Upload.AllowedTypes)
if err != nil {
ctx.HTTPError(http.StatusBadRequest, err.Error())
return
}
name := files_service.CleanGitTreePath(header.Filename)
if len(name) == 0 {
ctx.HTTPError(http.StatusBadRequest, "Upload file name is invalid")
return
}
// FIXME: need to check the file size according to setting.Repository.Upload.FileMaxSize
uploaded, err := repo_model.NewUpload(ctx, name, buf, file)
if err != nil {
ctx.ServerError("NewUpload", err)
return
}
ctx.JSON(http.StatusOK, map[string]string{"uuid": uploaded.UUID})
}
// RemoveUploadFileFromServer remove file from server file dir
func RemoveUploadFileFromServer(ctx *context.Context) {
fileUUID := ctx.FormString("file")
if err := repo_model.DeleteUploadByUUID(ctx, fileUUID); err != nil {
ctx.ServerError("DeleteUploadByUUID", err)
return
}
ctx.Status(http.StatusNoContent)
}

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