From dd136858f1ea40ad3c94191d647487fa4f31926c Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Fri, 18 Oct 2024 20:33:49 +0200 Subject: Adding upstream version 9.0.0. Signed-off-by: Daniel Baumann --- routers/web/admin/admin.go | 254 +++++++++++++++++ routers/web/admin/admin_test.go | 117 ++++++++ routers/web/admin/applications.go | 90 ++++++ routers/web/admin/auths.go | 465 +++++++++++++++++++++++++++++++ routers/web/admin/config.go | 255 +++++++++++++++++ routers/web/admin/diagnosis.go | 68 +++++ routers/web/admin/emails.go | 182 +++++++++++++ routers/web/admin/hooks.go | 73 +++++ routers/web/admin/main_test.go | 14 + routers/web/admin/notice.go | 78 ++++++ routers/web/admin/orgs.go | 39 +++ routers/web/admin/packages.go | 113 ++++++++ routers/web/admin/queue.go | 89 ++++++ routers/web/admin/queue_tester.go | 77 ++++++ routers/web/admin/repos.go | 163 +++++++++++ routers/web/admin/runners.go | 13 + routers/web/admin/stacktrace.go | 46 ++++ routers/web/admin/users.go | 557 ++++++++++++++++++++++++++++++++++++++ routers/web/admin/users_test.go | 200 ++++++++++++++ 19 files changed, 2893 insertions(+) create mode 100644 routers/web/admin/admin.go create mode 100644 routers/web/admin/admin_test.go create mode 100644 routers/web/admin/applications.go create mode 100644 routers/web/admin/auths.go create mode 100644 routers/web/admin/config.go create mode 100644 routers/web/admin/diagnosis.go create mode 100644 routers/web/admin/emails.go create mode 100644 routers/web/admin/hooks.go create mode 100644 routers/web/admin/main_test.go create mode 100644 routers/web/admin/notice.go create mode 100644 routers/web/admin/orgs.go create mode 100644 routers/web/admin/packages.go create mode 100644 routers/web/admin/queue.go create mode 100644 routers/web/admin/queue_tester.go create mode 100644 routers/web/admin/repos.go create mode 100644 routers/web/admin/runners.go create mode 100644 routers/web/admin/stacktrace.go create mode 100644 routers/web/admin/users.go create mode 100644 routers/web/admin/users_test.go (limited to 'routers/web/admin') diff --git a/routers/web/admin/admin.go b/routers/web/admin/admin.go new file mode 100644 index 0000000..067203b --- /dev/null +++ b/routers/web/admin/admin.go @@ -0,0 +1,254 @@ +// 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" + "reflect" + "runtime" + "time" + + activities_model "code.gitea.io/gitea/models/activities" + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/cache" + "code.gitea.io/gitea/modules/graceful" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/updatechecker" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/cron" + "code.gitea.io/gitea/services/forms" + release_service "code.gitea.io/gitea/services/release" + repo_service "code.gitea.io/gitea/services/repository" +) + +const ( + tplDashboard base.TplName = "admin/dashboard" + tplSystemStatus base.TplName = "admin/system_status" + tplSelfCheck base.TplName = "admin/self_check" + tplCron base.TplName = "admin/cron" + tplQueue base.TplName = "admin/queue" + tplStacktrace base.TplName = "admin/stacktrace" + tplQueueManage base.TplName = "admin/queue_manage" + tplStats base.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 prepareDeprecatedWarningsAlert(ctx *context.Context) { + if len(setting.DeprecatedWarnings) > 0 { + content := setting.DeprecatedWarnings[0] + if len(setting.DeprecatedWarnings) > 1 { + content += fmt.Sprintf(" (and %d more)", len(setting.DeprecatedWarnings)-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 + prepareDeprecatedWarningsAlert(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 + 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 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 + modelStats := activities_model.GetStatistic(ctx).Counter + stats := map[string]any{} + + // To avoid manually converting the values of the stats struct to an map, + // and to avoid using JSON to do this for us (JSON encoder converts numbers to + // scientific notation). Use reflect to convert the struct to an map. + rv := reflect.ValueOf(modelStats) + for i := 0; i < rv.NumField(); i++ { + field := rv.Field(i) + // Preserve old behavior, do not show arrays that are empty. + if field.Kind() == reflect.Slice && field.Len() == 0 { + continue + } + stats[rv.Type().Field(i).Name] = field.Interface() + } + + ctx.Data["Stats"] = stats + ctx.HTML(http.StatusOK, tplStats) +} diff --git a/routers/web/admin/admin_test.go b/routers/web/admin/admin_test.go new file mode 100644 index 0000000..3518869 --- /dev/null +++ b/routers/web/admin/admin_test.go @@ -0,0 +1,117 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package admin + +import ( + "testing" + + activities_model "code.gitea.io/gitea/models/activities" + "code.gitea.io/gitea/models/unittest" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/test" + "code.gitea.io/gitea/services/contexttest" + + "github.com/stretchr/testify/assert" +) + +func TestShadowPassword(t *testing.T) { + kases := []struct { + Provider string + CfgItem string + Result string + }{ + { + Provider: "redis", + CfgItem: "network=tcp,addr=:6379,password=gitea,db=0,pool_size=100,idle_timeout=180", + Result: "network=tcp,addr=:6379,password=******,db=0,pool_size=100,idle_timeout=180", + }, + { + Provider: "mysql", + CfgItem: "root:@tcp(localhost:3306)/gitea?charset=utf8", + Result: "root:******@tcp(localhost:3306)/gitea?charset=utf8", + }, + { + Provider: "mysql", + CfgItem: "/gitea?charset=utf8", + Result: "/gitea?charset=utf8", + }, + { + Provider: "mysql", + CfgItem: "user:mypassword@/dbname", + Result: "user:******@/dbname", + }, + { + Provider: "postgres", + CfgItem: "user=pqgotest dbname=pqgotest sslmode=verify-full", + Result: "user=pqgotest dbname=pqgotest sslmode=verify-full", + }, + { + Provider: "postgres", + CfgItem: "user=pqgotest password= dbname=pqgotest sslmode=verify-full", + Result: "user=pqgotest password=****** dbname=pqgotest sslmode=verify-full", + }, + { + Provider: "postgres", + CfgItem: "postgres://user:pass@hostname/dbname", + Result: "postgres://user:******@hostname/dbname", + }, + { + Provider: "couchbase", + CfgItem: "http://dev-couchbase.example.com:8091/", + Result: "http://dev-couchbase.example.com:8091/", + }, + { + Provider: "couchbase", + CfgItem: "http://user:the_password@dev-couchbase.example.com:8091/", + Result: "http://user:******@dev-couchbase.example.com:8091/", + }, + } + + for _, k := range kases { + assert.EqualValues(t, k.Result, shadowPassword(k.Provider, k.CfgItem)) + } +} + +func TestMonitorStats(t *testing.T) { + unittest.PrepareTestEnv(t) + + t.Run("Normal", func(t *testing.T) { + defer test.MockVariableValue(&setting.Metrics.EnabledIssueByLabel, false)() + defer test.MockVariableValue(&setting.Metrics.EnabledIssueByRepository, false)() + + ctx, _ := contexttest.MockContext(t, "admin/stats") + MonitorStats(ctx) + + // Test some of the stats manually. + mappedStats := ctx.Data["Stats"].(map[string]any) + stats := activities_model.GetStatistic(ctx).Counter + + assert.EqualValues(t, stats.Comment, mappedStats["Comment"]) + assert.EqualValues(t, stats.Issue, mappedStats["Issue"]) + assert.EqualValues(t, stats.User, mappedStats["User"]) + assert.EqualValues(t, stats.Milestone, mappedStats["Milestone"]) + + // Ensure that these aren't set. + assert.Empty(t, stats.IssueByLabel) + assert.Empty(t, stats.IssueByRepository) + assert.Nil(t, mappedStats["IssueByLabel"]) + assert.Nil(t, mappedStats["IssueByRepository"]) + }) + + t.Run("IssueByX", func(t *testing.T) { + defer test.MockVariableValue(&setting.Metrics.EnabledIssueByLabel, true)() + defer test.MockVariableValue(&setting.Metrics.EnabledIssueByRepository, true)() + + ctx, _ := contexttest.MockContext(t, "admin/stats") + MonitorStats(ctx) + + mappedStats := ctx.Data["Stats"].(map[string]any) + stats := activities_model.GetStatistic(ctx).Counter + + assert.NotEmpty(t, stats.IssueByLabel) + assert.NotEmpty(t, stats.IssueByRepository) + assert.EqualValues(t, stats.IssueByLabel, mappedStats["IssueByLabel"]) + assert.EqualValues(t, stats.IssueByRepository, mappedStats["IssueByRepository"]) + }) +} diff --git a/routers/web/admin/applications.go b/routers/web/admin/applications.go new file mode 100644 index 0000000..8583398 --- /dev/null +++ b/routers/web/admin/applications.go @@ -0,0 +1,90 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package admin + +import ( + "fmt" + "net/http" + + "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/setting" + user_setting "code.gitea.io/gitea/routers/web/user/setting" + "code.gitea.io/gitea/services/context" +) + +var ( + tplSettingsApplications base.TplName = "admin/applications/list" + tplSettingsOauth2ApplicationEdit base.TplName = "admin/applications/oauth2_edit" +) + +func newOAuth2CommonHandlers() *user_setting.OAuth2CommonHandlers { + return &user_setting.OAuth2CommonHandlers{ + OwnerID: 0, + BasePathList: fmt.Sprintf("%s/admin/applications", setting.AppSubURL), + BasePathEditPrefix: fmt.Sprintf("%s/admin/applications/oauth2", setting.AppSubURL), + 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") + 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 diff --git a/routers/web/admin/auths.go b/routers/web/admin/auths.go new file mode 100644 index 0000000..799b7e8 --- /dev/null +++ b/routers/web/admin/auths.go @@ -0,0 +1,465 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package admin + +import ( + "errors" + "fmt" + "net/http" + "net/url" + "regexp" + "strconv" + "strings" + + "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/modules/auth/pam" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/modules/web" + auth_service "code.gitea.io/gitea/services/auth" + "code.gitea.io/gitea/services/auth/source/ldap" + "code.gitea.io/gitea/services/auth/source/oauth2" + pam_service "code.gitea.io/gitea/services/auth/source/pam" + "code.gitea.io/gitea/services/auth/source/smtp" + "code.gitea.io/gitea/services/auth/source/sspi" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/forms" + + "xorm.io/xorm/convert" +) + +const ( + tplAuths base.TplName = "admin/auth/list" + tplAuthNew base.TplName = "admin/auth/new" + tplAuthEdit base.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.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 + 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, + DefaultDomainName: form.DefaultDomainName, + AttributeUsername: form.AttributeUsername, + AttributeName: form.AttributeName, + AttributeSurname: form.AttributeSurname, + AttributeMail: form.AttributeMail, + AttributesInBind: form.AttributesInBind, + AttributeSSHPublicKey: form.AttributeSSHPublicKey, + AttributeAvatar: form.AttributeAvatar, + 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, + SkipLocalTwoFA: form.SkipLocalTwoFA, + } +} + +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, + SkipLocalTwoFA: form.SkipLocalTwoFA, + } +} + +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.Split(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, + SkipLocalTwoFA: form.SkipLocalTwoFA, + GroupClaimName: form.Oauth2GroupClaimName, + RestrictedGroup: form.Oauth2RestrictedGroup, + AdminGroup: form.Oauth2AdminGroup, + GroupTeamMap: form.Oauth2GroupTeamMap, + GroupTeamMapRemoval: form.Oauth2GroupTeamMapRemoval, + } +} + +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.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 convert.Conversion + 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, + SkipLocalTwoFA: form.SkipLocalTwoFA, + } + 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.RenderWithErr(ctx.Tr("admin.auths.invalid_openIdConnectAutoDiscoveryURL"), tplAuthNew, form) + return + } + } + case auth.SSPI: + var err error + config, err = parseSSPIConfig(ctx, form) + if err != nil { + ctx.RenderWithErr(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.RenderWithErr(ctx.Tr("admin.auths.login_source_of_type_exist"), tplAuthNew, form) + return + } + default: + ctx.Error(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, + Cfg: config, + }); err != nil { + if auth.IsErrSourceAlreadyExist(err) { + ctx.Data["Err_Name"] = true + ctx.RenderWithErr(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.RenderWithErr(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.Data["OAuth2Providers"] = oauth2providers + + source, err := auth.GetSourceByID(ctx, ctx.ParamsInt64(":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.Data["OAuth2Providers"] = oauth2providers + + source, err := auth.GetSourceByID(ctx, ctx.ParamsInt64(":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 convert.Conversion + 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.RenderWithErr(ctx.Tr("admin.auths.invalid_openIdConnectAutoDiscoveryURL"), tplAuthEdit, form) + return + } + } + case auth.SSPI: + config, err = parseSSPIConfig(ctx, form) + if err != nil { + ctx.RenderWithErr(err.Error(), tplAuthEdit, form) + return + } + default: + ctx.Error(http.StatusBadRequest) + return + } + + source.Name = form.Name + source.IsActive = form.IsActive + source.IsSyncEnabled = form.IsSyncEnabled + source.Cfg = config + if err := auth.UpdateSource(ctx, source); err != nil { + if auth.IsErrSourceAlreadyExist(err) { + ctx.Data["Err_Name"] = true + ctx.RenderWithErr(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(form.ID, 10)) +} + +// DeleteAuthSource response for deleting an auth source +func DeleteAuthSource(ctx *context.Context) { + source, err := auth.GetSourceByID(ctx, ctx.ParamsInt64(":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.Params(":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") +} diff --git a/routers/web/admin/config.go b/routers/web/admin/config.go new file mode 100644 index 0000000..06d0ea6 --- /dev/null +++ b/routers/web/admin/config.go @@ -0,0 +1,255 @@ +// 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" + "net/url" + "strconv" + "strings" + + system_model "code.gitea.io/gitea/models/system" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/cache" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/setting/config" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/mailer" + + "code.forgejo.org/go-chi/session" +) + +const ( + tplConfig base.TplName = "admin/config" + tplConfigSettings base.TplName = "admin/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") +} + +func shadowPasswordKV(cfgItem, splitter string) string { + fields := strings.Split(cfgItem, splitter) + for i := 0; i < len(fields); i++ { + if strings.HasPrefix(fields[i], "password=") { + fields[i] = "password=******" + break + } + } + return strings.Join(fields, splitter) +} + +func shadowURL(provider, cfgItem string) string { + u, err := url.Parse(cfgItem) + if err != nil { + log.Error("Shadowing Password for %v failed: %v", provider, err) + return cfgItem + } + if u.User != nil { + atIdx := strings.Index(cfgItem, "@") + if atIdx > 0 { + colonIdx := strings.LastIndex(cfgItem[:atIdx], ":") + if colonIdx > 0 { + return cfgItem[:colonIdx+1] + "******" + cfgItem[atIdx:] + } + } + } + return cfgItem +} + +func shadowPassword(provider, cfgItem string) string { + switch provider { + case "redis": + return shadowPasswordKV(cfgItem, ",") + case "mysql": + // root:@tcp(localhost:3306)/macaron?charset=utf8 + atIdx := strings.Index(cfgItem, "@") + if atIdx > 0 { + colonIdx := strings.Index(cfgItem[:atIdx], ":") + if colonIdx > 0 { + return cfgItem[:colonIdx+1] + "******" + cfgItem[atIdx:] + } + } + return cfgItem + case "postgres": + // user=jiahuachen dbname=macaron port=5432 sslmode=disable + if !strings.HasPrefix(cfgItem, "postgres://") { + return shadowPasswordKV(cfgItem, " ") + } + fallthrough + case "couchbase": + return shadowURL(provider, cfgItem) + // postgres://pqgotest:password@localhost/pqgotest?sslmode=verify-full + // Notice: use shadowURL + } + return cfgItem +} + +// Config show admin config page +func Config(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("admin.config_summary") + ctx.Data["PageIsAdminConfig"] = true + ctx.Data["PageIsAdminConfigSummary"] = true + + ctx.Data["CustomConf"] = setting.CustomConf + ctx.Data["AppUrl"] = setting.AppURL + ctx.Data["AppBuiltWith"] = setting.AppBuiltWith + ctx.Data["Domain"] = setting.Domain + ctx.Data["OfflineMode"] = setting.OfflineMode + ctx.Data["RunUser"] = setting.RunUser + ctx.Data["RunMode"] = util.ToTitleCase(setting.RunMode) + ctx.Data["GitVersion"] = git.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["CacheConn"] = shadowPassword(setting.CacheService.Adapter, setting.CacheService.Conn) + 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 = shadowPassword(sessionCfg.Provider, 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() + prepareDeprecatedWarningsAlert(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.Data["DefaultOpenWithEditorAppsString"] = setting.DefaultOpenWithEditorApps().ToTextareaString() + ctx.HTML(http.StatusOK, tplConfigSettings) +} + +func ChangeConfig(ctx *context.Context) { + key := strings.TrimSpace(ctx.FormString("key")) + value := ctx.FormString("value") + cfg := setting.Config() + + marshalBool := func(v string) (string, error) { //nolint:unparam + if b, _ := strconv.ParseBool(v); b { + return "true", nil + } + return "false", nil + } + marshalOpenWithApps := func(value string) (string, error) { + lines := strings.Split(value, "\n") + var openWithEditorApps setting.OpenWithEditorAppsType + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + displayName, openURL, ok := strings.Cut(line, "=") + displayName, openURL = strings.TrimSpace(displayName), strings.TrimSpace(openURL) + if !ok || displayName == "" || openURL == "" { + continue + } + openWithEditorApps = append(openWithEditorApps, setting.OpenWithEditorApp{ + DisplayName: strings.TrimSpace(displayName), + OpenURL: strings.TrimSpace(openURL), + }) + } + b, err := json.Marshal(openWithEditorApps) + if err != nil { + return "", err + } + return string(b), nil + } + marshallers := map[string]func(string) (string, error){ + cfg.Picture.DisableGravatar.DynKey(): marshalBool, + cfg.Picture.EnableFederatedAvatar.DynKey(): marshalBool, + cfg.Repository.OpenWithEditorApps.DynKey(): marshalOpenWithApps, + } + marshaller, hasMarshaller := marshallers[key] + if !hasMarshaller { + ctx.JSONError(ctx.Tr("admin.config.set_setting_failed", key)) + return + } + marshaledValue, err := marshaller(value) + if err != nil { + ctx.JSONError(ctx.Tr("admin.config.set_setting_failed", key)) + return + } + if err = system_model.SetSettings(ctx, map[string]string{key: marshaledValue}); err != nil { + ctx.JSONError(ctx.Tr("admin.config.set_setting_failed", key)) + return + } + + config.GetDynGetter().InvalidateCache() + ctx.JSONOK() +} diff --git a/routers/web/admin/diagnosis.go b/routers/web/admin/diagnosis.go new file mode 100644 index 0000000..020554a --- /dev/null +++ b/routers/web/admin/diagnosis.go @@ -0,0 +1,68 @@ +// Copyright 2023 The Gitea Authors. +// SPDX-License-Identifier: MIT + +package admin + +import ( + "archive/zip" + "fmt" + "runtime/pprof" + "time" + + "code.gitea.io/gitea/modules/httplib" + "code.gitea.io/gitea/services/context" +) + +func MonitorDiagnosis(ctx *context.Context) { + seconds := ctx.FormInt64("seconds") + if seconds <= 5 { + seconds = 5 + } + if seconds > 300 { + seconds = 300 + } + + httplib.ServeSetHeaders(ctx.Resp, &httplib.ServeHeaderOptions{ + ContentType: "application/zip", + Disposition: "attachment", + Filename: fmt.Sprintf("gitea-diagnosis-%s.zip", time.Now().Format("20060102-150405")), + }) + + 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) +} diff --git a/routers/web/admin/emails.go b/routers/web/admin/emails.go new file mode 100644 index 0000000..f0d8555 --- /dev/null +++ b/routers/web/admin/emails.go @@ -0,0 +1,182 @@ +// Copyright 2020 The Gitea Authors. +// SPDX-License-Identifier: MIT + +package admin + +import ( + "bytes" + "net/http" + "net/url" + + "code.gitea.io/gitea/models/db" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/optional" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/user" +) + +const ( + tplEmails base.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(int(count), opts.PageSize, opts.Page, 5) + pager.SetDefaultParams(ctx) + 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.Error(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("") +} diff --git a/routers/web/admin/hooks.go b/routers/web/admin/hooks.go new file mode 100644 index 0000000..cdca0a5 --- /dev/null +++ b/routers/web/admin/hooks.go @@ -0,0 +1,73 @@ +// Copyright 2018 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package admin + +import ( + "net/http" + + "code.gitea.io/gitea/models/webhook" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/services/context" + webhook_service "code.gitea.io/gitea/services/webhook" +) + +const ( + // tplAdminHooks template path to render hook settings + tplAdminHooks base.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://forgejo.org/docs/latest/user/webhooks/") + sys["Webhooks"], err = webhook.GetSystemWebhooks(ctx, false) + sys["BaseLink"] = setting.AppSubURL + "/admin/hooks" + sys["BaseLinkNew"] = setting.AppSubURL + "/admin/system-hooks" + sys["WebhookList"] = webhook_service.List() + if err != nil { + ctx.ServerError("GetWebhooksAdmin", err) + return + } + + def["Title"] = ctx.Tr("admin.defaulthooks") + def["Description"] = ctx.Tr("admin.defaulthooks.desc", "https://forgejo.org/docs/latest/user/webhooks/") + def["Webhooks"], err = webhook.GetDefaultWebhooks(ctx) + def["BaseLink"] = setting.AppSubURL + "/admin/hooks" + def["BaseLinkNew"] = setting.AppSubURL + "/admin/default-hooks" + def["WebhookList"] = webhook_service.List() + 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") +} diff --git a/routers/web/admin/main_test.go b/routers/web/admin/main_test.go new file mode 100644 index 0000000..e1294dd --- /dev/null +++ b/routers/web/admin/main_test.go @@ -0,0 +1,14 @@ +// Copyright 2018 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package admin + +import ( + "testing" + + "code.gitea.io/gitea/models/unittest" +) + +func TestMain(m *testing.M) { + unittest.MainTest(m) +} diff --git a/routers/web/admin/notice.go b/routers/web/admin/notice.go new file mode 100644 index 0000000..36303cb --- /dev/null +++ b/routers/web/admin/notice.go @@ -0,0 +1,78 @@ +// 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" + + "code.gitea.io/gitea/models/db" + system_model "code.gitea.io/gitea/models/system" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/services/context" +) + +const ( + tplNotices base.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 := ctx.FormInt("page") + if page <= 1 { + 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(int(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") +} diff --git a/routers/web/admin/orgs.go b/routers/web/admin/orgs.go new file mode 100644 index 0000000..cea28f8 --- /dev/null +++ b/routers/web/admin/orgs.go @@ -0,0 +1,39 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Copyright 2020 The Gitea Authors. +// SPDX-License-Identifier: MIT + +package admin + +import ( + "code.gitea.io/gitea/models/db" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/routers/web/explore" + "code.gitea.io/gitea/services/context" +) + +const ( + tplOrgs base.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, + Type: 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) +} diff --git a/routers/web/admin/packages.go b/routers/web/admin/packages.go new file mode 100644 index 0000000..39f064a --- /dev/null +++ b/routers/web/admin/packages.go @@ -0,0 +1,113 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package admin + +import ( + "net/http" + "net/url" + "time" + + "code.gitea.io/gitea/models/db" + packages_model "code.gitea.io/gitea/models/packages" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/optional" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/services/context" + packages_service "code.gitea.io/gitea/services/packages" + packages_cleanup_service "code.gitea.io/gitea/services/packages/cleanup" +) + +const ( + tplPackagesList base.TplName = "admin/packages/list" +) + +// Packages shows all packages +func Packages(ctx *context.Context) { + page := ctx.FormInt("page") + if page <= 1 { + 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(int(total), setting.UI.PackagesPagingNum, page, 5) + pager.AddParamString("q", query) + pager.AddParamString("type", packageType) + pager.AddParamString("sort", sort) + 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.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") +} diff --git a/routers/web/admin/queue.go b/routers/web/admin/queue.go new file mode 100644 index 0000000..246ab37 --- /dev/null +++ b/routers/web/admin/queue.go @@ -0,0 +1,89 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package admin + +import ( + "net/http" + "strconv" + + "code.gitea.io/gitea/modules/queue" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/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.ParamsInt64("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.ParamsInt64("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) { + // Queue in Forgejo 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.ParamsInt64("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)) +} diff --git a/routers/web/admin/queue_tester.go b/routers/web/admin/queue_tester.go new file mode 100644 index 0000000..8f713b3 --- /dev/null +++ b/routers/web/admin/queue_tester.go @@ -0,0 +1,77 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package admin + +import ( + "runtime/pprof" + "sync" + "time" + + "code.gitea.io/gitea/modules/graceful" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/process" + "code.gitea.io/gitea/modules/queue" + "code.gitea.io/gitea/modules/setting" +) + +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++ + } + } + } + }() + }) +} diff --git a/routers/web/admin/repos.go b/routers/web/admin/repos.go new file mode 100644 index 0000000..d0339fd --- /dev/null +++ b/routers/web/admin/repos.go @@ -0,0 +1,163 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package admin + +import ( + "net/http" + "net/url" + "strings" + + "code.gitea.io/gitea/models/db" + repo_model "code.gitea.io/gitea/models/repo" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/routers/web/explore" + "code.gitea.io/gitea/services/context" + repo_service "code.gitea.io/gitea/services/repository" +) + +const ( + tplRepos base.TplName = "admin/repo/list" + tplUnadoptedRepos base.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.SetDefaultParams(ctx) + pager.AddParam(ctx, "search", "search") + 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.SetDefaultParams(ctx) + pager.AddParam(ctx, "search", "search") + 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 + } + isDir, err := util.IsDir(repo_model.RepoPath(ctxUser.Name, repoName)) + if err != nil { + ctx.ServerError("IsDir", err) + return + } + if has || !isDir { + // 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)) +} diff --git a/routers/web/admin/runners.go b/routers/web/admin/runners.go new file mode 100644 index 0000000..d73290a --- /dev/null +++ b/routers/web/admin/runners.go @@ -0,0 +1,13 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package admin + +import ( + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/services/context" +) + +func RedirectToDefaultSetting(ctx *context.Context) { + ctx.Redirect(setting.AppSubURL + "/admin/actions/runners") +} diff --git a/routers/web/admin/stacktrace.go b/routers/web/admin/stacktrace.go new file mode 100644 index 0000000..d6def94 --- /dev/null +++ b/routers/web/admin/stacktrace.go @@ -0,0 +1,46 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package admin + +import ( + "net/http" + "runtime" + + "code.gitea.io/gitea/modules/process" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/services/context" +) + +// Stacktrace show admin monitor goroutines page +func Stacktrace(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("admin.monitor") + ctx.Data["PageIsAdminMonitorStacktrace"] = true + + 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.Params("pid") + process.GetManager().Cancel(process.IDType(pid)) + ctx.JSONRedirect(setting.AppSubURL + "/admin/monitor/stacktrace") +} diff --git a/routers/web/admin/users.go b/routers/web/admin/users.go new file mode 100644 index 0000000..25fef5f --- /dev/null +++ b/routers/web/admin/users.go @@ -0,0 +1,557 @@ +// 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" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" + org_model "code.gitea.io/gitea/models/organization" + repo_model "code.gitea.io/gitea/models/repo" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/auth/password" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/optional" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/routers/web/explore" + user_setting "code.gitea.io/gitea/routers/web/user/setting" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/forms" + "code.gitea.io/gitea/services/mailer" + user_service "code.gitea.io/gitea/services/user" +) + +const ( + tplUsers base.TplName = "admin/user/list" + tplUserNew base.TplName = "admin/user/new" + tplUserView base.TplName = "admin/user/view" + tplUserEdit base.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 + + extraParamStrings := map[string]string{} + 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 + if paramVal != "" { + extraParamStrings[paramKey] = 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, + Type: user_model.UserTypeIndividual, + ListOptions: db.ListOptions{ + PageSize: setting.UI.Admin.UserPagingNum, + }, + SearchByEmail: true, + IsActive: util.OptionalBoolParse(statusFilterMap["is_active"]), + IsAdmin: util.OptionalBoolParse(statusFilterMap["is_admin"]), + IsRestricted: util.OptionalBoolParse(statusFilterMap["is_restricted"]), + IsTwoFactorEnabled: util.OptionalBoolParse(statusFilterMap["is_2fa_enabled"]), + IsProhibitLogin: util.OptionalBoolParse(statusFilterMap["is_prohibit_login"]), + IncludeReserved: true, // administrator needs to list all accounts include reserved, bot, remote ones + ExtraParamStrings: extraParamStrings, + }, 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.RenderWithErr(ctx.Tr("auth.password_too_short", setting.MinPasswordLength), tplUserNew, &form) + return + } + if !password.IsComplexEnough(form.Password) { + ctx.Data["Err_Password"] = true + ctx.RenderWithErr(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.RenderWithErr(errMsg, tplUserNew, &form) + return + } + u.MustChangePassword = form.MustChangePassword + } + + if err := user_model.AdminCreateUser(ctx, u, overwriteDefault); err != nil { + switch { + case user_model.IsErrUserAlreadyExist(err): + ctx.Data["Err_UserName"] = true + ctx.RenderWithErr(ctx.Tr("form.username_been_taken"), tplUserNew, &form) + case user_model.IsErrEmailAlreadyUsed(err): + ctx.Data["Err_Email"] = true + ctx.RenderWithErr(ctx.Tr("form.email_been_used"), tplUserNew, &form) + case user_model.IsErrEmailInvalid(err), user_model.IsErrEmailCharIsNotSupported(err): + ctx.Data["Err_Email"] = true + ctx.RenderWithErr(ctx.Tr("form.email_invalid"), tplUserNew, &form) + case db.IsErrNameReserved(err): + ctx.Data["Err_UserName"] = true + ctx.RenderWithErr(ctx.Tr("user.form.name_reserved", err.(db.ErrNameReserved).Name), tplUserNew, &form) + case db.IsErrNamePatternNotAllowed(err): + ctx.Data["Err_UserName"] = true + ctx.RenderWithErr(ctx.Tr("user.form.name_pattern_not_allowed", err.(db.ErrNamePatternNotAllowed).Pattern), tplUserNew, &form) + case db.IsErrNameCharsNotAllowed(err): + ctx.Data["Err_UserName"] = true + ctx.RenderWithErr(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.ParamsInt64(":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, + IncludePrivate: true, + }) + 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["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); err != nil { + switch { + case user_model.IsErrUserIsNotLocal(err): + ctx.Data["Err_UserName"] = true + ctx.RenderWithErr(ctx.Tr("form.username_change_not_local_user"), tplUserEdit, &form) + case user_model.IsErrUserAlreadyExist(err): + ctx.Data["Err_UserName"] = true + ctx.RenderWithErr(ctx.Tr("form.username_been_taken"), tplUserEdit, &form) + case db.IsErrNameReserved(err): + ctx.Data["Err_UserName"] = true + ctx.RenderWithErr(ctx.Tr("user.form.name_reserved", form.UserName), tplUserEdit, &form) + case db.IsErrNamePatternNotAllowed(err): + ctx.Data["Err_UserName"] = true + ctx.RenderWithErr(ctx.Tr("user.form.name_pattern_not_allowed", form.UserName), tplUserEdit, &form) + case db.IsErrNameCharsNotAllowed(err): + ctx.Data["Err_UserName"] = true + ctx.RenderWithErr(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.RenderWithErr(ctx.Tr("auth.password_too_short", setting.MinPasswordLength), tplUserEdit, &form) + case errors.Is(err, password.ErrComplexity): + ctx.Data["Err_Password"] = true + ctx.RenderWithErr(password.BuildComplexityError(ctx.Locale), tplUserEdit, &form) + case errors.Is(err, password.ErrIsPwned): + ctx.Data["Err_Password"] = true + ctx.RenderWithErr(ctx.Tr("auth.password_pwned", "https://haveibeenpwned.com/Passwords"), tplUserEdit, &form) + case password.IsErrIsPwnedRequest(err): + ctx.Data["Err_Password"] = true + ctx.RenderWithErr(ctx.Tr("auth.password_pwned_err"), tplUserEdit, &form) + default: + ctx.ServerError("UpdateUser", err) + } + return + } + + if form.Email != "" { + if err := user_service.AdminAddOrSetPrimaryEmailAddress(ctx, u, form.Email); err != nil { + switch { + case user_model.IsErrEmailCharIsNotSupported(err), user_model.IsErrEmailInvalid(err): + ctx.Data["Err_Email"] = true + ctx.RenderWithErr(ctx.Tr("form.email_invalid"), tplUserEdit, &form) + case user_model.IsErrEmailAlreadyUsed(err): + ctx.Data["Err_Email"] = true + ctx.RenderWithErr(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), + Pronouns: optional.Some(form.Pronouns), + IsActive: optional.Some(form.Active), + IsAdmin: optional.Some(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 models.IsErrDeleteLastAdminUser(err) { + ctx.RenderWithErr(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.Params(":userid"))) +} + +// DeleteUser response for deleting a user +func DeleteUser(ctx *context.Context) { + u, err := user_model.GetUserByID(ctx, ctx.ParamsInt64(":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.Params(":userid"))) + return + } + + if err = user_service.DeleteUser(ctx, u, ctx.FormBool("purge")); err != nil { + switch { + case models.IsErrUserOwnRepos(err): + ctx.Flash.Error(ctx.Tr("admin.users.still_own_repo")) + ctx.Redirect(setting.AppSubURL + "/admin/users/" + url.PathEscape(ctx.Params(":userid"))) + case models.IsErrUserHasOrgs(err): + ctx.Flash.Error(ctx.Tr("admin.users.still_has_org")) + ctx.Redirect(setting.AppSubURL + "/admin/users/" + url.PathEscape(ctx.Params(":userid"))) + case models.IsErrUserOwnPackages(err): + ctx.Flash.Error(ctx.Tr("admin.users.still_own_packages")) + ctx.Redirect(setting.AppSubURL + "/admin/users/" + url.PathEscape(ctx.Params(":userid"))) + case models.IsErrDeleteLastAdminUser(err): + ctx.Flash.Error(ctx.Tr("auth.last_admin")) + ctx.Redirect(setting.AppSubURL + "/admin/users/" + url.PathEscape(ctx.Params(":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)) +} diff --git a/routers/web/admin/users_test.go b/routers/web/admin/users_test.go new file mode 100644 index 0000000..ae3b130 --- /dev/null +++ b/routers/web/admin/users_test.go @@ -0,0 +1,200 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package admin + +import ( + "testing" + + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/setting" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/contexttest" + "code.gitea.io/gitea/services/forms" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +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) + + require.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) + + require.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) + + require.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) + + require.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()) +} -- cgit v1.2.3