summaryrefslogtreecommitdiffstats
path: root/routers/web/admin
diff options
context:
space:
mode:
authorDaniel Baumann <daniel@debian.org>2024-10-18 20:33:49 +0200
committerDaniel Baumann <daniel@debian.org>2024-12-12 23:57:56 +0100
commite68b9d00a6e05b3a941f63ffb696f91e554ac5ec (patch)
tree97775d6c13b0f416af55314eb6a89ef792474615 /routers/web/admin
parentInitial commit. (diff)
downloadforgejo-e68b9d00a6e05b3a941f63ffb696f91e554ac5ec.tar.xz
forgejo-e68b9d00a6e05b3a941f63ffb696f91e554ac5ec.zip
Adding upstream version 9.0.3.
Signed-off-by: Daniel Baumann <daniel@debian.org>
Diffstat (limited to 'routers/web/admin')
-rw-r--r--routers/web/admin/admin.go254
-rw-r--r--routers/web/admin/admin_test.go117
-rw-r--r--routers/web/admin/applications.go90
-rw-r--r--routers/web/admin/auths.go465
-rw-r--r--routers/web/admin/config.go255
-rw-r--r--routers/web/admin/diagnosis.go68
-rw-r--r--routers/web/admin/emails.go182
-rw-r--r--routers/web/admin/hooks.go73
-rw-r--r--routers/web/admin/main_test.go14
-rw-r--r--routers/web/admin/notice.go78
-rw-r--r--routers/web/admin/orgs.go39
-rw-r--r--routers/web/admin/packages.go113
-rw-r--r--routers/web/admin/queue.go89
-rw-r--r--routers/web/admin/queue_tester.go77
-rw-r--r--routers/web/admin/repos.go163
-rw-r--r--routers/web/admin/runners.go13
-rw-r--r--routers/web/admin/stacktrace.go46
-rw-r--r--routers/web/admin/users.go557
-rw-r--r--routers/web/admin/users_test.go200
19 files changed, 2893 insertions, 0 deletions
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())
+}